mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Re-split library and preview when the editor is hidden; remember the editor's width
This commit is contained in:
+8
-1
@@ -3,6 +3,7 @@ import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { LivePreview } from './components/LivePreview';
|
||||
import { ModalShell } from './components/ModalShell';
|
||||
import { Onboarding } from './components/Onboarding';
|
||||
import { PaneSplitHandle } from './components/PaneSplitHandle';
|
||||
import { PaneToggleStrip } from './components/PaneToggleStrip';
|
||||
import { ResizeHandle } from './components/ResizeHandle';
|
||||
import { SnippetLibrary } from './components/SnippetLibrary';
|
||||
@@ -143,10 +144,16 @@ export function App() {
|
||||
<SnippetLibrary />
|
||||
</section>
|
||||
)}
|
||||
{/* A resize handle only sits between two visible panes that flank the editor. */}
|
||||
{/* A resize handle sits between any two adjacent visible panes. With the
|
||||
editor present it flanks the editor (it absorbs the drag); with the
|
||||
editor hidden, the library and preview become adjacent and share a
|
||||
single split handle between them (spec §01A). */}
|
||||
{libraryVisible && editorVisible && (
|
||||
<ResizeHandle side="library" label="Resize snippet library" />
|
||||
)}
|
||||
{libraryVisible && previewVisible && !editorVisible && (
|
||||
<PaneSplitHandle label="Resize library and preview" />
|
||||
)}
|
||||
{editorVisible && (
|
||||
<section id="pane-editor" className={styles.paneEditor} aria-label="Spec editor">
|
||||
<SpecEditor />
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Vertical drag handle between the library and preview when the editor is hidden
|
||||
* (spec §01A). With no editor between them to absorb the change, the two side
|
||||
* panes share the span: dragging re-splits it — one grows, the other shrinks
|
||||
* (zero-sum) — each kept above its minimum by the pure `splitLibraryWidth`.
|
||||
*
|
||||
* The split is read from the live DOM: the two flanking panes (this handle's
|
||||
* previous/next siblings) render proportionally (`flex: width 1 0`), so writing
|
||||
* their measured widths back makes the boundary track the pointer 1:1, at any
|
||||
* window size. Re-showing the editor later keeps whatever ratio was left here
|
||||
* (PanesStore → `shownSideWidths`).
|
||||
*
|
||||
* Accessibility mirrors the editor-flanking ResizeHandle (WAI-ARIA APG → Window
|
||||
* Splitter): a focusable `separator` reporting the library's 0–100 position via
|
||||
* `aria-valuenow`, driven by ←/→ to nudge and Home/End to jump to min/max. It
|
||||
* reuses ResizeHandle's stylesheet so the two handles look identical.
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { splitLibraryWidth, splitValue, usePanesStore } from '../stores/PanesStore';
|
||||
import styles from './ResizeHandle.module.css';
|
||||
|
||||
/** Keyboard nudge step (px) per arrow press — matches ResizeHandle. */
|
||||
const KEY_STEP = 16;
|
||||
|
||||
interface PaneSplitHandleProps {
|
||||
/** Accessible label, e.g. "Resize library and preview". */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function PaneSplitHandle({ label }: PaneSplitHandleProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The reported position recomputes from the stored split as it changes. It reads
|
||||
// the *stored* ratio (container-independent, per the file header), so unlike
|
||||
// ResizeHandle aria-valuenow can drift from the rendered position after a window
|
||||
// enlargement (worst near the extremes) — an accepted simplicity trade-off, the
|
||||
// contract in arch/10 (splitter). Drag/keyboard read live clientWidth below, so
|
||||
// resizing itself stays accurate; only the announced value drifts.
|
||||
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
||||
const previewWidth = usePanesStore((s) => s.previewWidth);
|
||||
const valueNow = splitValue(libraryWidth, previewWidth);
|
||||
|
||||
/** The panes this handle sits between: library before it, preview after it. */
|
||||
const flanks = () => ({
|
||||
lib: ref.current?.previousElementSibling as HTMLElement | null,
|
||||
prev: ref.current?.nextElementSibling as HTMLElement | null,
|
||||
});
|
||||
|
||||
/** Apply a desired (rendered) library width, clamped against the live span. */
|
||||
const applySplit = (desiredLibrary: number) => {
|
||||
const { lib, prev } = flanks();
|
||||
if (!lib || !prev) return;
|
||||
const avail = lib.clientWidth + prev.clientWidth;
|
||||
const library = splitLibraryWidth(desiredLibrary, avail);
|
||||
usePanesStore.getState().setSplit(library, avail - library);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return; // primary button only
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startLib = flanks().lib?.clientWidth ?? 0;
|
||||
|
||||
const onMove = (ev: PointerEvent) => applySplit(startLib + (ev.clientX - startX));
|
||||
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);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
// Keyboard per WAI-ARIA APG → Window Splitter: arrows nudge the library side;
|
||||
// Home/End jump to the library's smallest/largest allowed share of the span.
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const lib = flanks().lib?.clientWidth ?? 0;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
applySplit(lib - KEY_STEP);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
applySplit(lib + KEY_STEP);
|
||||
break;
|
||||
case 'Home': // library at its minimum
|
||||
applySplit(0);
|
||||
break;
|
||||
case 'End': // library at its maximum (preview at its minimum)
|
||||
applySplit(Number.MAX_SAFE_INTEGER);
|
||||
break;
|
||||
default:
|
||||
return; // not ours — let it bubble
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.handle}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={label}
|
||||
// The split governs both panes it sits between.
|
||||
aria-controls="pane-library pane-preview"
|
||||
aria-valuenow={valueNow ?? undefined}
|
||||
aria-valuemin={valueNow === null ? undefined : 0}
|
||||
aria-valuemax={valueNow === null ? undefined : 100}
|
||||
aria-valuetext={valueNow === null ? undefined : `${valueNow}%`}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<span className={styles.grip} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,16 @@ export function PaneToggleStrip() {
|
||||
preview: previewVisible,
|
||||
};
|
||||
|
||||
// The strip's own element — its parent is the panes row, so we can measure the
|
||||
// width available to the panes (row minus this strip) when toggling the editor,
|
||||
// which needs it to remember/restore the editor's width (PanesStore.togglePane).
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
const panesInner = (): number => {
|
||||
const strip = stripRef.current;
|
||||
const row = strip?.parentElement;
|
||||
return row ? row.clientWidth - strip.offsetWidth : 0;
|
||||
};
|
||||
|
||||
// Roving tabindex over the toggles + the trailing Datasets action (the divider
|
||||
// is not a control). One tab stop; the focused index is the only `tabIndex=0`.
|
||||
const count = PANES.length + 1;
|
||||
@@ -88,6 +98,7 @@ export function PaneToggleStrip() {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={stripRef}
|
||||
role="toolbar"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Workspace panes"
|
||||
@@ -117,7 +128,7 @@ export function PaneToggleStrip() {
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
togglePane(item.pane);
|
||||
togglePane(item.pane, panesInner());
|
||||
setFocusIndex(i);
|
||||
}}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* 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.
|
||||
* budget (HANDLE_WIDTH in PanesStore); keep them in sync if this changes.
|
||||
*/
|
||||
.handle {
|
||||
flex: 0 0 6px;
|
||||
|
||||
@@ -28,6 +28,8 @@ const KEY = 'astrolabe:ux-prefs';
|
||||
export interface PanelLayout {
|
||||
libraryWidth?: number;
|
||||
previewWidth?: number;
|
||||
/** The editor's remembered width, so it re-shows at it when revealed (§01A). */
|
||||
editorWidth?: number;
|
||||
}
|
||||
|
||||
/** Per-pane visibility. Optional — a missing field falls back to "shown". */
|
||||
@@ -91,6 +93,7 @@ export function loadPanelLayout(): PanelLayout {
|
||||
return {
|
||||
libraryWidth: posNumber(stored.libraryWidth),
|
||||
previewWidth: posNumber(stored.previewWidth),
|
||||
editorWidth: posNumber(stored.editorWidth),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,11 +32,15 @@ export function initPanes(): void {
|
||||
export function wirePanes(): () => void {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
return usePanesStore.subscribe((state, prev) => {
|
||||
if (state.libraryWidth !== prev.libraryWidth || state.previewWidth !== prev.previewWidth) {
|
||||
if (
|
||||
state.libraryWidth !== prev.libraryWidth ||
|
||||
state.previewWidth !== prev.previewWidth ||
|
||||
state.editorWidth !== prev.editorWidth
|
||||
) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const { libraryWidth, previewWidth } = usePanesStore.getState();
|
||||
savePanelLayout({ libraryWidth, previewWidth });
|
||||
const { libraryWidth, previewWidth, editorWidth } = usePanesStore.getState();
|
||||
savePanelLayout({ libraryWidth, previewWidth, editorWidth });
|
||||
}, PANES_PERSIST_DEBOUNCE_MS);
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import {
|
||||
capturedEditorWidth,
|
||||
clampSideWidth,
|
||||
HANDLE_WIDTH,
|
||||
HANDLES_TOTAL,
|
||||
maxSideWidth,
|
||||
PANE_DEFAULT,
|
||||
PANE_MIN,
|
||||
shownSideWidths,
|
||||
sideWidthValue,
|
||||
splitLibraryWidth,
|
||||
splitValue,
|
||||
usePanesStore,
|
||||
} from './PanesStore';
|
||||
|
||||
@@ -139,6 +144,136 @@ describe('pane visibility (§01A)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitLibraryWidth (editor-hidden library↔preview split, §01A)', () => {
|
||||
// The two side panes share this span when the editor is hidden.
|
||||
const AVAIL = 1000;
|
||||
|
||||
test('passes a comfortable width through unchanged', () => {
|
||||
expect(splitLibraryWidth(400, AVAIL)).toBe(400);
|
||||
});
|
||||
|
||||
test('never goes below the library minimum', () => {
|
||||
expect(splitLibraryWidth(50, AVAIL)).toBe(PANE_MIN.library);
|
||||
});
|
||||
|
||||
test('caps so the preview keeps at least its minimum', () => {
|
||||
expect(splitLibraryWidth(AVAIL, AVAIL)).toBe(AVAIL - PANE_MIN.preview);
|
||||
});
|
||||
|
||||
test('a span too small for both minimums still keeps the library minimum', () => {
|
||||
// 300px can't fit library(180)+preview(240); the library holds its own min.
|
||||
expect(splitLibraryWidth(250, 300)).toBe(PANE_MIN.library);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitValue (aria-valuenow for the editor-hidden split)', () => {
|
||||
test('reports 0 at the library minimum and 100 at its maximum', () => {
|
||||
const span = 1000;
|
||||
expect(splitValue(PANE_MIN.library, span - PANE_MIN.library)).toBe(0);
|
||||
expect(splitValue(span - PANE_MIN.preview, PANE_MIN.preview)).toBe(100);
|
||||
});
|
||||
|
||||
test('reports the midpoint as ~50', () => {
|
||||
// Library halfway between its min and its max (preview at min) over a 1000 span.
|
||||
const max = 1000 - PANE_MIN.preview;
|
||||
const mid = (PANE_MIN.library + max) / 2;
|
||||
expect(splitValue(mid, 1000 - mid)).toBe(50);
|
||||
});
|
||||
|
||||
test('returns null when the span has no range (too narrow for both minimums)', () => {
|
||||
expect(splitValue(190, 190)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('capturedEditorWidth (remembered on hide, §01A)', () => {
|
||||
const PANES_INNER = 1200;
|
||||
|
||||
test('is the span left after both side panes and their two handles', () => {
|
||||
expect(capturedEditorWidth(PANES_INNER, 280, 360, true, true)).toBe(
|
||||
PANES_INNER - 280 - 360 - HANDLES_TOTAL,
|
||||
);
|
||||
});
|
||||
|
||||
test('accounts for only the visible side panes (one handle when one is hidden)', () => {
|
||||
expect(capturedEditorWidth(PANES_INNER, 280, 360, true, false)).toBe(
|
||||
PANES_INNER - 280 - HANDLE_WIDTH,
|
||||
);
|
||||
});
|
||||
|
||||
test('floors at the editor minimum on a cramped row', () => {
|
||||
expect(capturedEditorWidth(700, 300, 300, true, true)).toBe(PANE_MIN.editor);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shownSideWidths (editor re-shown, §01A)', () => {
|
||||
const PANES_INNER = 1200;
|
||||
// The width captured when hiding the editor from a 280/360 layout.
|
||||
const E = capturedEditorWidth(PANES_INNER, 280, 360, true, true); // 548
|
||||
|
||||
test('an untouched split returns to pixel-perfect on re-show', () => {
|
||||
expect(shownSideWidths(PANES_INNER, E, 280, 360, true, true)).toEqual({
|
||||
libraryWidth: 280,
|
||||
previewWidth: 360,
|
||||
});
|
||||
});
|
||||
|
||||
test('pins the editor width and keeps the ratio set while hidden', () => {
|
||||
// The user re-split to 400:240 while the editor was hidden.
|
||||
const { libraryWidth, previewWidth } = shownSideWidths(PANES_INNER, E, 400, 240, true, true);
|
||||
// Editor reclaims its slot, so the side panes' combined width is unchanged…
|
||||
expect(libraryWidth + previewWidth).toBe(PANES_INNER - E - HANDLES_TOTAL);
|
||||
// …and the 400:240 ratio is preserved (5:3).
|
||||
expect(libraryWidth / previewWidth).toBeCloseTo(400 / 240, 5);
|
||||
});
|
||||
|
||||
test('no remembered editor width leaves the side widths untouched (legacy/never hidden)', () => {
|
||||
expect(shownSideWidths(PANES_INNER, 0, 280, 360, true, true)).toEqual({
|
||||
libraryWidth: 280,
|
||||
previewWidth: 360,
|
||||
});
|
||||
});
|
||||
|
||||
test('a lone visible side pane takes the whole leftover span', () => {
|
||||
const { libraryWidth, previewWidth } = shownSideWidths(PANES_INNER, E, 280, 360, true, false);
|
||||
expect(libraryWidth).toBe(PANES_INNER - E - HANDLE_WIDTH);
|
||||
expect(previewWidth).toBe(360); // the hidden preview keeps its remembered width
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor toggle remembers and restores its width (§01A)', () => {
|
||||
const PANES_INNER = 1200;
|
||||
|
||||
test('hiding captures the editor width; showing restores the whole layout', () => {
|
||||
store().hydrate({ libraryWidth: 280, previewWidth: 360 });
|
||||
store().togglePane('editor', PANES_INNER);
|
||||
expect(store().editorVisible).toBe(false);
|
||||
expect(store().editorWidth).toBe(PANES_INNER - 280 - 360 - HANDLES_TOTAL);
|
||||
// The side widths are untouched while hidden (they fill via proportional flex).
|
||||
expect(store().libraryWidth).toBe(280);
|
||||
expect(store().previewWidth).toBe(360);
|
||||
|
||||
store().togglePane('editor', PANES_INNER);
|
||||
expect(store().editorVisible).toBe(true);
|
||||
expect(store().libraryWidth).toBe(280);
|
||||
expect(store().previewWidth).toBe(360);
|
||||
});
|
||||
|
||||
test('a split made while hidden carries its ratio back, editor width pinned', () => {
|
||||
store().hydrate({ libraryWidth: 280, previewWidth: 360 });
|
||||
store().togglePane('editor', PANES_INNER);
|
||||
const captured = store().editorWidth;
|
||||
// Drag the library↔preview handle while hidden (setSplit writes both widths).
|
||||
store().setSplit(400, 240);
|
||||
|
||||
store().togglePane('editor', PANES_INNER);
|
||||
expect(store().editorWidth).toBe(captured); // editor reclaims its slot
|
||||
expect(store().libraryWidth + store().previewWidth).toBe(
|
||||
PANES_INNER - captured - HANDLES_TOTAL,
|
||||
);
|
||||
expect(store().libraryWidth / store().previewWidth).toBeCloseTo(400 / 240, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOnboardingSplit (spec §02 → First-Run & Empty Workspace)', () => {
|
||||
test('lays out 25·25·50 on a roomy container', () => {
|
||||
const W = 1600;
|
||||
|
||||
@@ -31,8 +31,11 @@ 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;
|
||||
/** Width (px) of one resize handle. */
|
||||
export const HANDLE_WIDTH = 6;
|
||||
|
||||
/** Combined width (px) the two handles flanking the editor occupy. */
|
||||
export const HANDLES_TOTAL = HANDLE_WIDTH * 2;
|
||||
|
||||
/**
|
||||
* The widest a side pane can be while the editor still meets its minimum.
|
||||
@@ -82,6 +85,88 @@ export function sideWidthValue(
|
||||
return Math.round(Math.min(100, Math.max(0, pct)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp the library's width when it and the preview share a span with no editor
|
||||
* between them — the editor-hidden split (spec §01A). `availForBoth` is the
|
||||
* combined width the two panes fill; each keeps at least its own minimum. Pure —
|
||||
* the single place the library↔preview split constraint lives, used by both the
|
||||
* split drag handle and the editor-reveal re-layout.
|
||||
*/
|
||||
export function splitLibraryWidth(desiredLibrary: number, availForBoth: number): number {
|
||||
const min = PANE_MIN.library;
|
||||
// Never let the library crowd the preview below its minimum; if the span is too
|
||||
// small for both, the library still keeps its own minimum.
|
||||
const max = Math.max(min, availForBoth - PANE_MIN.preview);
|
||||
return Math.max(min, Math.min(desiredLibrary, max));
|
||||
}
|
||||
|
||||
/**
|
||||
* The library's 0–100 position in the editor-hidden split, for the separator's
|
||||
* `aria-valuenow` (WAI-ARIA APG → Window Splitter): 0 = library at its minimum,
|
||||
* 100 = library at its maximum (preview at its minimum). Reads the ratio straight
|
||||
* from the two stored widths, so it's independent of the container. Null when the
|
||||
* span has no range (too narrow for both minimums), so the caller omits the attr.
|
||||
*/
|
||||
export function splitValue(libraryWidth: number, previewWidth: number): number | null {
|
||||
const span = libraryWidth + previewWidth;
|
||||
const min = PANE_MIN.library;
|
||||
const max = span - PANE_MIN.preview;
|
||||
if (max <= min) return null;
|
||||
const pct = ((libraryWidth - min) / (max - min)) * 100;
|
||||
return Math.round(Math.min(100, Math.max(0, pct)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The width to remember for the editor at the moment it is hidden, so it re-shows
|
||||
* at the same width (spec §01A; the editor gains a remembered width of its own).
|
||||
* It's the span the editor currently occupies: the panes row minus the toggle
|
||||
* strip (`panesInner`), minus the visible side panes and the handle beside each.
|
||||
* Floored at the editor minimum.
|
||||
*/
|
||||
export function capturedEditorWidth(
|
||||
panesInner: number,
|
||||
libraryWidth: number,
|
||||
previewWidth: number,
|
||||
libraryVisible: boolean,
|
||||
previewVisible: boolean,
|
||||
): number {
|
||||
const sides = (libraryVisible ? libraryWidth : 0) + (previewVisible ? previewWidth : 0);
|
||||
const handles = (Number(libraryVisible) + Number(previewVisible)) * HANDLE_WIDTH;
|
||||
return Math.max(PANE_MIN.editor, panesInner - sides - handles);
|
||||
}
|
||||
|
||||
/**
|
||||
* The side-pane widths to apply when the editor is shown again: pin the editor to
|
||||
* its remembered width and keep the library:preview *ratio*, rescaled to whatever
|
||||
* space is left (spec §01A — the editor reclaims its slot; the side panes keep the
|
||||
* proportion set while it was hidden). When the window hasn't changed since hiding,
|
||||
* the remembered editor width makes `avail` equal the side panes' former combined
|
||||
* width, so an untouched split returns pixel-perfect. A zero/absent remembered
|
||||
* width (never hidden, or legacy state) means leave the widths be and let the
|
||||
* editor fill the remainder as the flex filler.
|
||||
*/
|
||||
export function shownSideWidths(
|
||||
panesInner: number,
|
||||
editorWidth: number,
|
||||
libraryWidth: number,
|
||||
previewWidth: number,
|
||||
libraryVisible: boolean,
|
||||
previewVisible: boolean,
|
||||
): { libraryWidth: number; previewWidth: number } {
|
||||
const handles = (Number(libraryVisible) + Number(previewVisible)) * HANDLE_WIDTH;
|
||||
const avail = panesInner - editorWidth - handles;
|
||||
if (editorWidth <= 0 || avail <= 0) return { libraryWidth, previewWidth };
|
||||
if (libraryVisible && previewVisible) {
|
||||
const ratio = libraryWidth / (libraryWidth + previewWidth || 1);
|
||||
const library = splitLibraryWidth(Math.round(ratio * avail), avail);
|
||||
return { libraryWidth: library, previewWidth: avail - library };
|
||||
}
|
||||
// Only one side pane is visible: it takes the whole leftover span.
|
||||
if (libraryVisible) return { libraryWidth: Math.max(PANE_MIN.library, avail), previewWidth };
|
||||
if (previewVisible) return { libraryWidth, previewWidth: Math.max(PANE_MIN.preview, avail) };
|
||||
return { libraryWidth, previewWidth };
|
||||
}
|
||||
|
||||
/** Per-pane visibility (spec §01A). Widths are kept *independently* of visibility,
|
||||
* so a hidden pane keeps its remembered width and re-shows at it (not a default). */
|
||||
export interface PaneVisibility {
|
||||
@@ -93,12 +178,24 @@ export interface PaneVisibility {
|
||||
export interface PanesState {
|
||||
libraryWidth: number;
|
||||
previewWidth: number;
|
||||
/**
|
||||
* The editor's remembered width, captured when it is hidden so it re-shows at
|
||||
* the same size (spec §01A). Zero until the editor has been hidden at least once
|
||||
* — while the editor is shown it is the flex filler and this is unused.
|
||||
*/
|
||||
editorWidth: number;
|
||||
/** Whether each pane is currently shown. All visible by default (§01A). */
|
||||
libraryVisible: boolean;
|
||||
editorVisible: boolean;
|
||||
previewVisible: boolean;
|
||||
/** Set a side pane's width (already clamped by the caller). */
|
||||
setWidth: (side: PaneSide, width: number) => void;
|
||||
/**
|
||||
* Set both side-pane widths at once — the editor-hidden split, where dragging
|
||||
* the library↔preview handle re-proportions the two together (already clamped by
|
||||
* the caller via `splitLibraryWidth`).
|
||||
*/
|
||||
setSplit: (libraryWidth: number, previewWidth: number) => void;
|
||||
/**
|
||||
* Lay the workspace out at the onboarding default split — library 25% · editor
|
||||
* 25% · preview 50% of `containerWidth` — and show all three panes. Applied when
|
||||
@@ -108,11 +205,17 @@ export interface PanesState {
|
||||
* to keep every pane at least its minimum.
|
||||
*/
|
||||
applyOnboardingSplit: (containerWidth: number) => void;
|
||||
/** Show/hide one pane. Hiding all panes is permitted (§01A) — the strip stays. */
|
||||
togglePane: (pane: PaneName) => void;
|
||||
/**
|
||||
* Show/hide one pane. Hiding all panes is permitted (§01A) — the strip stays.
|
||||
* Toggling the **editor** is layout-aware: hiding remembers its current width and
|
||||
* showing pins it back, keeping the library:preview ratio — so `panesInner` (the
|
||||
* panes-row width minus the toggle strip) must be passed for the editor. It is
|
||||
* ignored for the side panes, which only flip visibility.
|
||||
*/
|
||||
togglePane: (pane: PaneName, panesInner?: number) => void;
|
||||
/** Restore persisted widths + visibility on startup; missing values keep defaults. */
|
||||
hydrate: (
|
||||
layout: { libraryWidth?: number; previewWidth?: number },
|
||||
layout: { libraryWidth?: number; previewWidth?: number; editorWidth?: number },
|
||||
visibility?: PaneVisibility,
|
||||
) => void;
|
||||
}
|
||||
@@ -126,6 +229,7 @@ const VISIBLE_KEY = {
|
||||
export const usePanesStore = create<PanesState>((set) => ({
|
||||
libraryWidth: PANE_DEFAULT.library,
|
||||
previewWidth: PANE_DEFAULT.preview,
|
||||
editorWidth: 0,
|
||||
libraryVisible: true,
|
||||
editorVisible: true,
|
||||
previewVisible: true,
|
||||
@@ -133,6 +237,8 @@ export const usePanesStore = create<PanesState>((set) => ({
|
||||
setWidth: (side, width) =>
|
||||
set(side === 'library' ? { libraryWidth: width } : { previewWidth: width }),
|
||||
|
||||
setSplit: (libraryWidth, previewWidth) => set({ libraryWidth, previewWidth }),
|
||||
|
||||
applyOnboardingSplit: (containerWidth) =>
|
||||
set(() => {
|
||||
// Clamp preview (the larger share) first, then library against it, so the
|
||||
@@ -158,12 +264,45 @@ export const usePanesStore = create<PanesState>((set) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
togglePane: (pane) => set((s) => ({ [VISIBLE_KEY[pane]]: !s[VISIBLE_KEY[pane]] })),
|
||||
togglePane: (pane, panesInner = 0) =>
|
||||
set((s) => {
|
||||
// Side panes only flip visibility; the editor (the flex filler) also carries
|
||||
// its width across the hide/show so it reclaims its slot (spec §01A).
|
||||
if (pane !== 'editor') return { [VISIBLE_KEY[pane]]: !s[VISIBLE_KEY[pane]] };
|
||||
if (s.editorVisible) {
|
||||
// Hiding: remember the editor's current width; the side panes keep theirs
|
||||
// and expand proportionally to fill the freed space (their flex render).
|
||||
return {
|
||||
editorVisible: false,
|
||||
editorWidth: capturedEditorWidth(
|
||||
panesInner,
|
||||
s.libraryWidth,
|
||||
s.previewWidth,
|
||||
s.libraryVisible,
|
||||
s.previewVisible,
|
||||
),
|
||||
};
|
||||
}
|
||||
// Showing: pin the editor to its remembered width; re-split the rest between
|
||||
// the side panes at the ratio they were left at while it was hidden.
|
||||
return {
|
||||
editorVisible: true,
|
||||
...shownSideWidths(
|
||||
panesInner,
|
||||
s.editorWidth,
|
||||
s.libraryWidth,
|
||||
s.previewWidth,
|
||||
s.libraryVisible,
|
||||
s.previewVisible,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
hydrate: (layout, visibility) =>
|
||||
set({
|
||||
libraryWidth: layout.libraryWidth ?? PANE_DEFAULT.library,
|
||||
previewWidth: layout.previewWidth ?? PANE_DEFAULT.preview,
|
||||
editorWidth: layout.editorWidth ?? 0,
|
||||
libraryVisible: visibility?.library ?? true,
|
||||
editorVisible: visibility?.editor ?? true,
|
||||
previewVisible: visibility?.preview ?? true,
|
||||
|
||||
Reference in New Issue
Block a user