mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
8c0a6b9239
A persistent left-rail toolbar (APG toolbar + roving tabindex) toggles the library / editor / preview panes; a hidden pane frees its space and the rest redistribute proportionally to their remembered widths. Visibility persists to astrolabe:ux-prefs (separate key from widths, so a hidden pane keeps its width). Adds the positional pane-* glyph sub-family to Icon and a Datasets shortcut button in the strip.
138 lines
5.1 KiB
TypeScript
138 lines
5.1 KiB
TypeScript
/**
|
||
* 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';
|
||
|
||
/** Every pane that can be shown/hidden via the toggle strip (spec §01A). */
|
||
export type PaneName = 'library' | 'editor' | '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;
|
||
|
||
/**
|
||
* The widest a side pane can be while the editor still meets its minimum.
|
||
* `containerWidth` is the full panes-row width; `otherWidth` is the opposite
|
||
* side pane's current width. Never reports below the pane's own minimum.
|
||
*/
|
||
export function maxSideWidth(side: PaneSide, containerWidth: number, otherWidth: number): number {
|
||
return Math.max(PANE_MIN[side], containerWidth - otherWidth - HANDLES_TOTAL - PANE_MIN.editor);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
export function clampSideWidth(
|
||
side: PaneSide,
|
||
desired: number,
|
||
containerWidth: number,
|
||
otherWidth: number,
|
||
): number {
|
||
return Math.max(
|
||
PANE_MIN[side],
|
||
Math.min(desired, maxSideWidth(side, containerWidth, otherWidth)),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Normalize a side pane's width to the 0–100 position a window splitter reports
|
||
* via `aria-valuenow` (WAI-ARIA APG → Window Splitter): 0 = pane at its minimum
|
||
* size, 100 = pane at its maximum. Returns null when the container width isn't
|
||
* known yet (pre-layout) or the range has collapsed, so the caller omits the
|
||
* attribute rather than emitting NaN. The 0–100 scale is APG's "typical" choice
|
||
* and announces as a percentage — more meaningful than a moving pixel count.
|
||
*/
|
||
export function sideWidthValue(
|
||
side: PaneSide,
|
||
width: number,
|
||
containerWidth: number,
|
||
otherWidth: number,
|
||
): number | null {
|
||
if (containerWidth <= 0) return null;
|
||
const min = PANE_MIN[side];
|
||
const max = maxSideWidth(side, containerWidth, otherWidth);
|
||
if (max <= min) return null;
|
||
const pct = ((width - min) / (max - min)) * 100;
|
||
return Math.round(Math.min(100, Math.max(0, pct)));
|
||
}
|
||
|
||
/** 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 {
|
||
library?: boolean;
|
||
editor?: boolean;
|
||
preview?: boolean;
|
||
}
|
||
|
||
export interface PanesState {
|
||
libraryWidth: number;
|
||
previewWidth: 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;
|
||
/** Show/hide one pane. Hiding all panes is permitted (§01A) — the strip stays. */
|
||
togglePane: (pane: PaneName) => void;
|
||
/** Restore persisted widths + visibility on startup; missing values keep defaults. */
|
||
hydrate: (
|
||
layout: { libraryWidth?: number; previewWidth?: number },
|
||
visibility?: PaneVisibility,
|
||
) => void;
|
||
}
|
||
|
||
const VISIBLE_KEY = {
|
||
library: 'libraryVisible',
|
||
editor: 'editorVisible',
|
||
preview: 'previewVisible',
|
||
} as const;
|
||
|
||
export const usePanesStore = create<PanesState>((set) => ({
|
||
libraryWidth: PANE_DEFAULT.library,
|
||
previewWidth: PANE_DEFAULT.preview,
|
||
libraryVisible: true,
|
||
editorVisible: true,
|
||
previewVisible: true,
|
||
|
||
setWidth: (side, width) =>
|
||
set(side === 'library' ? { libraryWidth: width } : { previewWidth: width }),
|
||
|
||
togglePane: (pane) => set((s) => ({ [VISIBLE_KEY[pane]]: !s[VISIBLE_KEY[pane]] })),
|
||
|
||
hydrate: (layout, visibility) =>
|
||
set({
|
||
libraryWidth: layout.libraryWidth ?? PANE_DEFAULT.library,
|
||
previewWidth: layout.previewWidth ?? PANE_DEFAULT.preview,
|
||
libraryVisible: visibility?.library ?? true,
|
||
editorVisible: visibility?.editor ?? true,
|
||
previewVisible: visibility?.preview ?? true,
|
||
}),
|
||
}));
|