Add resizable panes with drag handles, min widths, and persistence

This commit is contained in:
2026-06-05 10:46:07 +03:00
parent 411bfbc6c2
commit c50f141d57
11 changed files with 482 additions and 25 deletions
+55
View File
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { clampSideWidth, HANDLES_TOTAL, PANE_DEFAULT, PANE_MIN, usePanesStore } from './PanesStore';
const store = () => usePanesStore.getState();
describe('clampSideWidth', () => {
// A roomy container where nothing is constrained.
const W = 1400;
test('passes a comfortable width through unchanged', () => {
expect(clampSideWidth('library', 300, W, PANE_DEFAULT.preview)).toBe(300);
expect(clampSideWidth('preview', 400, W, PANE_DEFAULT.library)).toBe(400);
});
test('never goes below the pane minimum', () => {
expect(clampSideWidth('library', 50, W, PANE_DEFAULT.preview)).toBe(PANE_MIN.library);
expect(clampSideWidth('preview', 10, W, PANE_DEFAULT.library)).toBe(PANE_MIN.preview);
});
test('caps the width so the editor keeps at least its minimum', () => {
const other = PANE_DEFAULT.preview;
const max = W - other - HANDLES_TOTAL - PANE_MIN.editor;
// Asking for far more than the editor can spare clamps to that maximum.
expect(clampSideWidth('library', W, W, other)).toBe(max);
// The editor sits exactly at its minimum at that point.
expect(W - max - other - HANDLES_TOTAL).toBe(PANE_MIN.editor);
});
test('a container too narrow for all minimums still never drops below the min', () => {
// 500px total can't fit library(180)+preview(240)+editor(320)+handles.
expect(clampSideWidth('library', 300, 500, PANE_MIN.preview)).toBe(PANE_MIN.library);
});
});
describe('usePanesStore', () => {
beforeEach(() =>
store().hydrate({ libraryWidth: PANE_DEFAULT.library, previewWidth: PANE_DEFAULT.preview }),
);
test('setWidth updates the targeted side only', () => {
store().setWidth('library', 320);
expect(store().libraryWidth).toBe(320);
expect(store().previewWidth).toBe(PANE_DEFAULT.preview);
store().setWidth('preview', 420);
expect(store().previewWidth).toBe(420);
expect(store().libraryWidth).toBe(320);
});
test('hydrate fills missing values from defaults', () => {
store().hydrate({ libraryWidth: 250 });
expect(store().libraryWidth).toBe(250);
expect(store().previewWidth).toBe(PANE_DEFAULT.preview);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Pane layout state — the resizable three-pane shell (spec §01A, persisted per
* §09D "Panel layout").
*
* Model: the two **side** panes (library, preview) carry explicit remembered
* widths; the **center** editor flexes to fill the remainder. This is what makes
* a drag "leave the rest of the layout unaffected" (§01A): dragging the left
* handle trades width between library and editor, the right handle between
* preview and editor — the opposite side pane never moves.
*
* Per-pane visibility / the toggle strip (the other half of §01A) is not here
* yet; this slice is resize + persistence, pulled forward from M6 because it is
* coupled to the Live Preview's container sizing.
*
* Pure clamp helpers live alongside the store so the resize math is unit-tested
* without a DOM. Persistence is a startup subscriber (orchestration/panes), not
* done here — the store stays browser-free.
*/
import { create } from 'zustand';
/** Which side pane a drag handle controls. */
export type PaneSide = 'library' | 'preview';
/** Minimum widths (px) enforced while resizing, so no pane collapses (§01A). */
export const PANE_MIN = { library: 180, preview: 240, editor: 320 } as const;
/** Initial side-pane widths (px) on first run, before any persisted layout. */
export const PANE_DEFAULT = { library: 280, preview: 360 } as const;
/** Combined width (px) the resize handles occupy between the panes. */
export const HANDLES_TOTAL = 12;
/**
* Clamp a desired side-pane width so it stays at least its own minimum and
* leaves the editor at least its minimum. Pure — the single place the resize
* constraint lives. `containerWidth` is the full panes-row width; `otherWidth`
* is the opposite side pane's current width.
*/
export function clampSideWidth(
side: PaneSide,
desired: number,
containerWidth: number,
otherWidth: number,
): number {
const min = PANE_MIN[side];
// The widest this pane can be while the editor still meets its minimum.
const max = containerWidth - otherWidth - HANDLES_TOTAL - PANE_MIN.editor;
// If the container is too narrow for everyone, the min wins (never below it).
return Math.max(min, Math.min(desired, Math.max(min, max)));
}
export interface PanesState {
libraryWidth: number;
previewWidth: number;
/** Set a side pane's width (already clamped by the caller). */
setWidth: (side: PaneSide, width: number) => void;
/** Restore persisted widths on startup; missing values keep their defaults. */
hydrate: (layout: { libraryWidth?: number; previewWidth?: number }) => void;
}
export const usePanesStore = create<PanesState>((set) => ({
libraryWidth: PANE_DEFAULT.library,
previewWidth: PANE_DEFAULT.preview,
setWidth: (side, width) =>
set(side === 'library' ? { libraryWidth: width } : { previewWidth: width }),
hydrate: (layout) =>
set({
libraryWidth: layout.libraryWidth ?? PANE_DEFAULT.library,
previewWidth: layout.previewWidth ?? PANE_DEFAULT.preview,
}),
}));