Files
astrolabe/src/app/stores/PanesStore.ts
T
oleh 8c0a6b9239 Add pane show/hide toggle strip with persisted visibility (M6, §01A)
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.
2026-06-07 17:15:58 +03:00

138 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 0100 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 0100 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,
}),
}));