mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add resizable panes with drag handles, min widths, and persistence
This commit is contained in:
@@ -18,7 +18,7 @@ src/
|
||||
│ ├── snippet-store.ts # IndexedDB: snippets (metadata + drafts)
|
||||
│ ├── dataset-store.ts # IndexedDB: datasets (heavy payloads)
|
||||
│ ├── settings-store.ts # localStorage: UserSettings
|
||||
│ └── prefs-store.ts # localStorage: app/UI prefs (sort, layout)
|
||||
│ └── ux-prefs.ts # localStorage: app/UI prefs (sort, panel layout)
|
||||
```
|
||||
|
||||
### Why this boundary exists
|
||||
@@ -317,17 +317,24 @@ export function saveSettings(s: UserSettings): void {
|
||||
}
|
||||
```
|
||||
|
||||
App/UI prefs follow the identical pattern under their own keys, e.g.:
|
||||
App/UI prefs follow the identical guard+fallback pattern, but live in **one
|
||||
record under their own key**, `astrolabe:ux-prefs` (the snippet sort and the
|
||||
panel layout together — the plan's "ux-prefs for sort + panel layout", §09D):
|
||||
|
||||
```ts
|
||||
// src/app/infrastructure/prefs-store.ts
|
||||
const SORT_KEY = 'astrolabe:snippet-sort';
|
||||
const LAYOUT_KEY = 'astrolabe:panel-layout';
|
||||
// src/app/infrastructure/ux-prefs.ts
|
||||
const KEY = 'astrolabe:ux-prefs'; // { panelLayout: { libraryWidth, previewWidth }, sort: { … } }
|
||||
|
||||
const SORT_DEFAULTS = { sortBy: 'modified' as const, sortOrder: 'desc' as const };
|
||||
// loadSort()/saveSort() and loadLayout()/saveLayout() mirror §5's guard+fallback shape.
|
||||
// loadPanelLayout()/savePanelLayout() (and sort later) mirror §5's guard+fallback shape,
|
||||
// merging the changed section so a frequent write (a drag) never clobbers the others.
|
||||
```
|
||||
|
||||
> One nuance vs. the settings record: the panel-layout widths are **validated**
|
||||
> (positive finite numbers) and surfaced as `undefined` when absent/invalid; the
|
||||
> **defaults live in the `PanesStore`** (`PANE_DEFAULT`), applied at `hydrate`,
|
||||
> rather than merged in the adapter. Persistence of the live drag is **debounced**
|
||||
> in `orchestration/panes.ts` (a drag emits an update per pointer move).
|
||||
|
||||
> **Do:** keep a single complete `DEFAULTS` object as the source of truth and merge over it.
|
||||
> **Don't:** read individual keys with bespoke `?? fallback` at each call site; one stale default and the shapes drift.
|
||||
|
||||
|
||||
+8
-12
@@ -40,28 +40,24 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Side panes (library, preview) carry an explicit width (set inline from the
|
||||
* PanesStore) and don't grow or shrink — the drag handles change that width.
|
||||
* The editor between them flexes to fill the remainder, so a drag leaves the
|
||||
* opposite side pane untouched (spec §01A). Panes are separated by the
|
||||
* ResizeHandle, so no inter-pane borders here.
|
||||
*/
|
||||
.pane {
|
||||
flex: 1 1 0;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
border-right: var(--border-width) solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Library is a fixed-ish sidebar; editor + preview share the rest. */
|
||||
.panes > .pane:first-child {
|
||||
flex: 0 0 280px;
|
||||
}
|
||||
|
||||
/* Editor pane: Monaco manages its own scroll/layout, so no padding. */
|
||||
.paneEditor {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-right: var(--border-width) solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.pane:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
+16
-4
@@ -1,19 +1,25 @@
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { LivePreview } from './components/LivePreview';
|
||||
import { ResizeHandle } from './components/ResizeHandle';
|
||||
import { SnippetLibrary } from './components/SnippetLibrary';
|
||||
import { SpecEditor } from './components/SpecEditor';
|
||||
import { ThemeToggle } from './components/ThemeToggle';
|
||||
import { usePanesStore } from './stores/PanesStore';
|
||||
import styles from './App.module.css';
|
||||
|
||||
/**
|
||||
* Application shell — the three-pane workspace from spec §01A
|
||||
* (library · editor · preview) under a fixed header.
|
||||
*
|
||||
* M1 fills the panes with the MVP authoring loop. Pane resizing/toggling,
|
||||
* modals, routing, and shortcuts arrive in later milestones (see
|
||||
* The center editor flexes; the library and preview carry remembered widths and
|
||||
* are resized via the drag handles between them (spec §01A). Pane show/hide
|
||||
* toggling, modals, routing, and shortcuts arrive in later milestones (see
|
||||
* docs/IMPLEMENTATION-PLAN.md).
|
||||
*/
|
||||
export function App() {
|
||||
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
||||
const previewWidth = usePanesStore((s) => s.previewWidth);
|
||||
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<header className={styles.header}>
|
||||
@@ -24,13 +30,19 @@ export function App() {
|
||||
</header>
|
||||
|
||||
<main className={styles.panes}>
|
||||
<section className={styles.pane} aria-label="Snippet library">
|
||||
<section
|
||||
className={styles.pane}
|
||||
style={{ width: libraryWidth }}
|
||||
aria-label="Snippet library"
|
||||
>
|
||||
<SnippetLibrary />
|
||||
</section>
|
||||
<ResizeHandle side="library" label="Resize snippet library" />
|
||||
<section className={styles.paneEditor} aria-label="Spec editor">
|
||||
<SpecEditor />
|
||||
</section>
|
||||
<section className={styles.pane} aria-label="Live preview">
|
||||
<ResizeHandle side="preview" label="Resize live preview" />
|
||||
<section className={styles.pane} style={{ width: previewWidth }} aria-label="Live preview">
|
||||
<LivePreview />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 6px-wide hit target with a thin visible grip. Width is part of the layout
|
||||
* budget (HANDLES_TOTAL in PanesStore); keep them in sync if this changes.
|
||||
*/
|
||||
.handle {
|
||||
flex: 0 0 6px;
|
||||
position: relative;
|
||||
cursor: col-resize;
|
||||
background: var(--border);
|
||||
touch-action: none; /* let pointer drags own the gesture, not scroll */
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.handle:hover,
|
||||
.handle:focus-visible {
|
||||
background: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* A short centered grip line, so the handle reads as a draggable divider. */
|
||||
.grip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.handle:hover .grip,
|
||||
.handle:focus-visible .grip {
|
||||
background: var(--accent-contrast);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Vertical drag handle between two panes (spec §01A).
|
||||
*
|
||||
* Sits between a side pane and the editor; dragging resizes the side pane while
|
||||
* the editor absorbs the change, so the opposite side pane is unaffected. Width
|
||||
* is clamped (pure `clampSideWidth`) so neither the dragged pane nor the editor
|
||||
* falls below its minimum. Keyboard accessible: focus and use ←/→ to nudge.
|
||||
*
|
||||
* The handle reads the panes-row width from its own parent at interaction time,
|
||||
* so it needs no layout props — it just controls the `side` it is told to.
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { clampSideWidth, usePanesStore, type PaneSide } from '../stores/PanesStore';
|
||||
import styles from './ResizeHandle.module.css';
|
||||
|
||||
/** Keyboard nudge step (px) per arrow press. */
|
||||
const KEY_STEP = 16;
|
||||
|
||||
interface ResizeHandleProps {
|
||||
/** Which side pane this handle resizes. */
|
||||
side: PaneSide;
|
||||
/** Accessible label, e.g. "Resize snippet library". */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function ResizeHandle({ side, label }: ResizeHandleProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
/** Full panes-row width — the handle's parent (`.panes`). */
|
||||
const containerWidth = (): number => ref.current?.parentElement?.clientWidth ?? 0;
|
||||
|
||||
/** Apply a desired width for this side, clamped against the current layout. */
|
||||
const applyWidth = (desired: number) => {
|
||||
const { libraryWidth, previewWidth, setWidth } = usePanesStore.getState();
|
||||
const other = side === 'library' ? previewWidth : libraryWidth;
|
||||
setWidth(side, clampSideWidth(side, desired, containerWidth(), other));
|
||||
};
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return; // primary button only
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth =
|
||||
side === 'library'
|
||||
? usePanesStore.getState().libraryWidth
|
||||
: usePanesStore.getState().previewWidth;
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
// The left handle grows its pane as it moves right; the right handle (left
|
||||
// of the preview) shrinks the preview as it moves right.
|
||||
const desired = side === 'library' ? startWidth + delta : startWidth - delta;
|
||||
applyWidth(desired);
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
// While dragging, force the resize cursor and suppress text selection.
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
e.preventDefault();
|
||||
const dir = e.key === 'ArrowRight' ? 1 : -1;
|
||||
const current =
|
||||
side === 'library'
|
||||
? usePanesStore.getState().libraryWidth
|
||||
: usePanesStore.getState().previewWidth;
|
||||
const delta = side === 'library' ? dir * KEY_STEP : -dir * KEY_STEP;
|
||||
applyWidth(current + delta);
|
||||
};
|
||||
|
||||
return (
|
||||
// TODO: a focusable window-splitter should also expose aria-valuenow/min/max
|
||||
// (the side pane's current/min/max width) so assistive tech can announce the
|
||||
// size as it changes. Wire it when the pane toggle strip lands in M6.
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.handle}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<span className={styles.grip} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadPanelLayout, savePanelLayout } from './ux-prefs';
|
||||
|
||||
const KEY = 'astrolabe:ux-prefs';
|
||||
|
||||
/** In-memory localStorage stub (Node's global one is non-functional). */
|
||||
function makeStorageStub() {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||
setItem: (k: string, v: string) => void map.set(k, String(v)),
|
||||
removeItem: (k: string) => void map.delete(k),
|
||||
clear: () => map.clear(),
|
||||
key: (i: number) => [...map.keys()][i] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ux-prefs · panelLayout', () => {
|
||||
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns empty widths when nothing is stored', () => {
|
||||
expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
|
||||
});
|
||||
|
||||
it('round-trips stored widths', () => {
|
||||
savePanelLayout({ libraryWidth: 300, previewWidth: 420 });
|
||||
expect(loadPanelLayout()).toEqual({ libraryWidth: 300, previewWidth: 420 });
|
||||
});
|
||||
|
||||
it('merges partial writes without dropping the other width', () => {
|
||||
savePanelLayout({ libraryWidth: 300, previewWidth: 420 });
|
||||
savePanelLayout({ libraryWidth: 250 });
|
||||
expect(loadPanelLayout()).toEqual({ libraryWidth: 250, previewWidth: 420 });
|
||||
});
|
||||
|
||||
it('rejects non-positive / non-finite junk', () => {
|
||||
localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify({ panelLayout: { libraryWidth: -5, previewWidth: 'wide' } }),
|
||||
);
|
||||
expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
|
||||
});
|
||||
|
||||
it('tolerates malformed JSON', () => {
|
||||
localStorage.setItem(KEY, '{ not json');
|
||||
expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
|
||||
});
|
||||
|
||||
it('preserves unrelated keys in the record (forward-compatible merge)', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ sort: { sortBy: 'name', sortOrder: 'asc' } }));
|
||||
savePanelLayout({ libraryWidth: 260 });
|
||||
const stored = JSON.parse(localStorage.getItem(KEY)!) as {
|
||||
sort: { sortBy: string };
|
||||
panelLayout: { libraryWidth: number };
|
||||
};
|
||||
expect(stored.sort.sortBy).toBe('name');
|
||||
expect(stored.panelLayout.libraryWidth).toBe(260);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* UX preferences persistence (localStorage) — docs/architecture/02 §5, spec §09D.
|
||||
*
|
||||
* These preferences persist **separately** from UserSettings so they can change
|
||||
* frequently (a drag emits many width updates) without rewriting the settings
|
||||
* record. Its own key, `astrolabe:ux-prefs`, holds the snippet sort preference
|
||||
* (lands with M5/M6) and the panel layout (per-pane widths + visibility).
|
||||
*
|
||||
* This slice persists the panel **widths** only; visibility joins it when the
|
||||
* toggle strip lands. Read-with-fallback + write-through merge, the same
|
||||
* contract as the settings adapter, so adding fields later upgrades cleanly.
|
||||
*
|
||||
* Per the architecture rule this is one of the only modules that may touch
|
||||
* `localStorage`; everything else goes through these typed functions.
|
||||
*/
|
||||
|
||||
const KEY = 'astrolabe:ux-prefs';
|
||||
|
||||
/** Per-pane widths (px). Optional — a missing field falls back to its default. */
|
||||
export interface PanelLayout {
|
||||
libraryWidth?: number;
|
||||
previewWidth?: number;
|
||||
}
|
||||
|
||||
interface StoredPrefs {
|
||||
panelLayout?: PanelLayout;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
function available(): boolean {
|
||||
try {
|
||||
return typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readRaw(): StoredPrefs {
|
||||
if (!available()) return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as StoredPrefs) : {};
|
||||
} catch (err) {
|
||||
console.warn('[ux-prefs] failed to read, using defaults', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeRaw(next: StoredPrefs): void {
|
||||
if (!available()) return;
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch (err) {
|
||||
console.warn('[ux-prefs] failed to write', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** A finite positive number, or undefined — guards against junk in storage. */
|
||||
function posNumber(v: unknown): number | undefined {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
/** The persisted panel layout, with only valid numeric widths surfaced. */
|
||||
export function loadPanelLayout(): PanelLayout {
|
||||
const stored = readRaw().panelLayout ?? {};
|
||||
return {
|
||||
libraryWidth: posNumber(stored.libraryWidth),
|
||||
previewWidth: posNumber(stored.previewWidth),
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist the panel layout, preserving every other key already in the record. */
|
||||
export function savePanelLayout(layout: PanelLayout): void {
|
||||
const current = readRaw();
|
||||
writeRaw({ ...current, panelLayout: { ...current.panelLayout, ...layout } });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Pane-layout orchestration — bridges the (browser-free) PanesStore to the
|
||||
* ux-prefs adapter. Same store↔adapter pattern as theme/preferences.
|
||||
*
|
||||
* `initPanes` hydrates persisted widths into the store before render. `wirePanes`
|
||||
* persists changes, **debounced**, because a drag emits a width update on every
|
||||
* pointer move — writing localStorage on each would thrash. The debounce settles
|
||||
* the write to the final resting widths (the same approach as snippet auto-save).
|
||||
*/
|
||||
|
||||
import { loadPanelLayout, savePanelLayout } from '../infrastructure/ux-prefs';
|
||||
import { usePanesStore } from '../stores/PanesStore';
|
||||
|
||||
/** Delay before a settled resize is persisted. */
|
||||
export const PANES_PERSIST_DEBOUNCE_MS = 300;
|
||||
|
||||
/** Hydrate persisted pane widths into the store. Call before render. */
|
||||
export function initPanes(): void {
|
||||
usePanesStore.getState().hydrate(loadPanelLayout());
|
||||
}
|
||||
|
||||
/** Persist width changes (debounced). Returns a teardown that detaches the subscriber. */
|
||||
export function wirePanes(): () => void {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
return usePanesStore.subscribe((state, prev) => {
|
||||
if (state.libraryWidth === prev.libraryWidth && state.previewWidth === prev.previewWidth) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const { libraryWidth, previewWidth } = usePanesStore.getState();
|
||||
savePanelLayout({ libraryWidth, previewWidth });
|
||||
}, PANES_PERSIST_DEBOUNCE_MS);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
+5
-2
@@ -1,5 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
import { initPanes, wirePanes } from './app/orchestration/panes';
|
||||
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
|
||||
import { initApp } from './app/orchestration/startup';
|
||||
import { initTheme, wireTheme } from './app/orchestration/theme';
|
||||
@@ -11,10 +12,12 @@ import '../styles/base.css';
|
||||
initTheme();
|
||||
wireTheme();
|
||||
|
||||
// Hydrate + persist the preview fit mode the same way (pulled ahead of the M5
|
||||
// Settings modal); hydrating before render keeps the store authoritative.
|
||||
// Hydrate + persist the small UI preferences (preview fit mode, pane widths) the
|
||||
// same way. Pane widths hydrate before render so the layout opens as left.
|
||||
initPreviewFitMode();
|
||||
wirePreviewFitMode();
|
||||
initPanes();
|
||||
wirePanes();
|
||||
|
||||
// Load the library from IndexedDB (seeding a sample on first run) and wire
|
||||
// persistence. Fire-and-forget: the UI renders immediately and fills in when
|
||||
|
||||
Reference in New Issue
Block a user