diff --git a/README.md b/README.md index fca09a1..87f9f94 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Markdown files, and they are yours. watcher), with conflict handling when a note changes underneath you - **Multiple workspaces & windows** (desktop): switch folders with `⌃R`, open a workspace — or a single note — in its own window +- **Resizable side panels**: drag the divider beside the folder rail or the notes list + (double-click resets, arrow keys nudge) — widths remembered per workspace - **Make it yours**: light/dark/system theme, editor font, accent color, text width — app-wide, per workspace, or per note - **Mobile-ready**: a single-pane list↔editor layout kicks in at ≤700px (phone, or a narrow @@ -91,8 +93,6 @@ in-browser storage. The desktop app reads the folder natively, with no re-prompt - Density (line spacing) setting to complement the per-note font/width overrides? - Restore all workspace windows on relaunch (today only the last-active one comes back) -- Resizable left panel - - Preserve cmd+z between notes - Cmd+z for undoing deleting of notes and moves between folders? diff --git a/docs/architecture.md b/docs/architecture.md index fbd81ee..889b418 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,7 +61,9 @@ menu's Open Recent, the `⌃R` switcher, and launch restore. On the desktop each its own native window, plus Apple-Notes-style **per-note windows** (`⌘↵` on a list row) — small, panels tucked away, focus-if-open, with `⌘0` bringing back the workspace's main window. The window ↔ workspace/note assignments live in the Rust shell, so new windows boot straight into the right -folder. +folder. Per-workspace UI layout — sidebar collapsed, rail open, selected folder, and the two +panel widths (`rail-width` / `sidebar-width`, from dragging the dividers) — lives under +workspace-namespaced localStorage keys; note windows neither read nor write those. ## Mobile & iOS diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 3dc9d28..0072649 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -71,8 +71,19 @@ With the folder rail open (`⌘⇧\`) and a folder focused: With the rail closed, a small **folder chip** above the list names the active scope — click it to open the rail, `✕` to go back to All Notes. **New note** (`⌘N`) lands in the selected folder. +## Panels + +The folder rail and the notes list resize by dragging the divider on their right edge +(double-click it to reset). With a divider focused (`Tab`): + +| Keys | Action | +| -------------- | -------------------------------------- | +| `←` / `→` | Nudge the width by 16px | +| `Home` / `End` | Jump to the narrowest / widest allowed | + ## Mouse **Right-click** a note or folder for its actions (pin, rename, move, duplicate, delete, …) — the same menu the row's `⋯` button opens, at the cursor. **⌘-click** a note row opens it in its own -window (desktop). Neither moves your selection. +window (desktop). Neither moves your selection. **Drag** the divider beside the folder rail or +the notes list to resize it; **double-click** the divider resets the width. diff --git a/src/components/NoteList.css b/src/components/NoteList.css index 7519a4f..8cfe5ed 100644 --- a/src/components/NoteList.css +++ b/src/components/NoteList.css @@ -8,7 +8,13 @@ background-color: var(--g-color-base-background); } -/* Filter + New, above the list. */ +/* Filter + New, above the list. The toolbar is its own container so the New button below can + adapt to the LIVE width during a divider drag, which React state (committed on release) + cannot. Scoped here rather than on .note-list: inline-size containment also brings layout + + style containment — the element becomes a stacking context and the containing block for any + non-portaled fixed/absolute descendant (today's row menus are portaled Gravity popups, which + escape it) — and on the list itself it would also sit in the sidebar overlay's intrinsic-width + math, which WebKit already gets wrong (see Workspace.css). */ .note-list__toolbar { display: flex; align-items: center; @@ -16,6 +22,23 @@ padding: 8px 12px; border-bottom: 1px solid var(--g-color-line-generic); flex-shrink: 0; + container-type: inline-size; +} + +/* Dragged tight: the New button folds to its icon so the sort select keeps a readable width + (below a ~250px list the full button squeezes it under ~110px, where "Title (A→Z)" clips; the + toolbar's content box runs 24px of padding narrower, hence 225). Gravity's text wrapper + carries the label and its spacing — hide it wholesale and square the button to match the true + icon-only Folders button beside it. */ +@container (max-width: 225px) { + .note-list__new { + width: 28px; + padding-inline: 0; + } + + .note-list__new .g-button__text { + display: none; + } } .note-list__sort { diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 3594f30..2913a1a 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -869,7 +869,15 @@ export const NoteList = forwardRef(function NoteL {value: 'created', content: 'Created'}, ]} /> - diff --git a/src/components/PanelResizer.css b/src/components/PanelResizer.css new file mode 100644 index 0000000..74eed01 --- /dev/null +++ b/src/components/PanelResizer.css @@ -0,0 +1,60 @@ +/* A 7px hit strip straddling the 1px border-right the panel to its left already draws: 4px over + the panel (its border is the strip's center), 3px over the neighbor — net zero layout width, so + adding/removing a divider never shifts the panes. Sits above both neighbors to win the pointer. */ +.panel-resizer { + flex: 0 0 7px; + margin-inline: -4px -3px; + position: relative; + z-index: 2; + cursor: col-resize; + /* Pointer-capture drags own the gesture — no scroll/zoom fallback on touch. */ + touch-action: none; + /* Keyboard focus is shown by the ::after line (below), not an outline box around a 7px sliver. */ + outline: none; + /* WebKit needs the prefix; the unprefixed form is for everyone else. */ + -webkit-user-select: none; + user-select: none; +} + +/* The grab affordance: a 2px line centered on the underlying 1px border — one shade up from it + (generic-active), a nudge rather than a highlight. Appears on hover (delayed — casual mouse + travel across the app must not flash it), instantly while dragging or focused via keyboard. */ +.panel-resizer::after { + content: ''; + position: absolute; + inset-block: 0; + inset-inline-start: 3px; + width: 2px; + background-color: var(--g-color-line-generic-active); + opacity: 0; + transition: opacity 0.1s ease; +} + +.panel-resizer:hover::after { + opacity: 1; + transition-delay: 0.25s; +} + +.panel-resizer_dragging::after { + opacity: 1; + transition-delay: 0s; +} + +/* Keyboard focus: the same line, but in the dedicated focus color — Tab is the ONLY way to reach + the divider (pointerdown is canceled, so clicks never focus it), and the quiet hover shade + alone is too faint to announce where the keyboard landed. */ +.panel-resizer:focus-visible::after { + opacity: 1; + transition-delay: 0s; + background-color: var(--g-color-line-focus); +} + +/* Live drag, set on by PanelResizer: the pointer strays off the 7px strip between capture + updates, so the whole app keeps the resize cursor and gives up text selection until release. + (Second line of defense — the pointerdown preventDefault is what actually stops WebKit from + starting a selection; this class lands an effect-tick too late to do that by itself.) */ +body.panel-resizing { + cursor: col-resize; + -webkit-user-select: none; + user-select: none; +} diff --git a/src/components/PanelResizer.test.tsx b/src/components/PanelResizer.test.tsx new file mode 100644 index 0000000..403026d --- /dev/null +++ b/src/components/PanelResizer.test.tsx @@ -0,0 +1,183 @@ +import {fireEvent, screen} from '@testing-library/react'; +import {describe, expect, it, vi} from 'vitest'; + +import {renderWithProviders} from '../test/render'; + +import { + PANEL_MAX_WIDTH, + PANEL_MIN_WIDTH, + PanelResizer, + clampPanelWidth, + parsePanelWidth, +} from './PanelResizer'; + +const handlers = () => ({ + onResize: vi.fn(), + onCommit: vi.fn(), + onReset: vi.fn(), +}); + +const renderResizer = (props: Partial[0]> = {}) => { + const h = handlers(); + const {unmount} = renderWithProviders( + , + ); + return {divider: screen.getByRole('separator', {name: 'Resize note list'}), unmount, ...h}; +}; + +describe('clampPanelWidth', () => { + it('clamps into the shared range', () => { + expect(clampPanelWidth(PANEL_MIN_WIDTH - 100)).toBe(PANEL_MIN_WIDTH); + expect(clampPanelWidth(PANEL_MAX_WIDTH + 100)).toBe(PANEL_MAX_WIDTH); + expect(clampPanelWidth(300)).toBe(300); + }); + + it('tightens to a gesture cap, but the cap never wins below the minimum', () => { + expect(clampPanelWidth(400, 320)).toBe(320); + // A tiny window must not wedge the divider into an undraggable dead state. + expect(clampPanelWidth(400, PANEL_MIN_WIDTH - 50)).toBe(PANEL_MIN_WIDTH); + }); +}); + +describe('parsePanelWidth', () => { + it('parses a stored width, clamping and rounding', () => { + expect(parsePanelWidth('300')).toBe(300); + expect(parsePanelWidth('300.6')).toBe(301); + expect(parsePanelWidth(String(PANEL_MAX_WIDTH + 500))).toBe(PANEL_MAX_WIDTH); + expect(parsePanelWidth('1')).toBe(PANEL_MIN_WIDTH); + }); + + it('treats missing or garbage values as "use the default"', () => { + expect(parsePanelWidth(null)).toBeNull(); + expect(parsePanelWidth('')).toBeNull(); + expect(parsePanelWidth('wide')).toBeNull(); + expect(parsePanelWidth('NaN')).toBeNull(); + expect(parsePanelWidth('Infinity')).toBeNull(); + }); +}); + +describe('PanelResizer', () => { + it('is an accessible vertical separator reporting its width', () => { + const {divider} = renderResizer(); + expect(divider).toHaveAttribute('aria-orientation', 'vertical'); + expect(divider).toHaveAttribute('aria-valuemin', String(PANEL_MIN_WIDTH)); + expect(divider).toHaveAttribute('aria-valuemax', String(PANEL_MAX_WIDTH)); + expect(divider).toHaveAttribute('aria-valuenow', '280'); + }); + + it('drags: live onResize per move, one onCommit on release', () => { + const {divider, onResize, onCommit} = renderResizer(); + fireEvent.pointerDown(divider, {button: 0, clientX: 100, pointerId: 1}); + fireEvent.pointerMove(divider, {clientX: 140, pointerId: 1}); + expect(onResize).toHaveBeenLastCalledWith(320); + fireEvent.pointerMove(divider, {clientX: 120, pointerId: 1}); + expect(onResize).toHaveBeenLastCalledWith(300); + expect(onCommit).not.toHaveBeenCalled(); + fireEvent.pointerUp(divider, {pointerId: 1}); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith(300); + }); + + it('clamps a drag to the range and to the gesture cap from getMaxWidth', () => { + const {divider, onResize} = renderResizer({getMaxWidth: () => 310}); + fireEvent.pointerDown(divider, {button: 0, clientX: 0, pointerId: 1}); + fireEvent.pointerMove(divider, {clientX: 500, pointerId: 1}); + expect(onResize).toHaveBeenLastCalledWith(310); + fireEvent.pointerMove(divider, {clientX: -500, pointerId: 1}); + expect(onResize).toHaveBeenLastCalledWith(PANEL_MIN_WIDTH); + }); + + it('ignores moves with no drag in progress and non-primary buttons', () => { + const {divider, onResize, onCommit} = renderResizer(); + fireEvent.pointerMove(divider, {clientX: 400, pointerId: 1}); + fireEvent.pointerDown(divider, {button: 2, clientX: 100, pointerId: 1}); + fireEvent.pointerMove(divider, {clientX: 400, pointerId: 1}); + fireEvent.pointerUp(divider, {pointerId: 1}); + expect(onResize).not.toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('resets on double-click', () => { + const {divider, onReset} = renderResizer(); + fireEvent.doubleClick(divider); + expect(onReset).toHaveBeenCalledTimes(1); + }); + + it('cancels the native pointerdown (WebKit selection start, focus steal)', () => { + const {divider} = renderResizer(); + // fireEvent returns false when a handler called preventDefault. + expect(fireEvent.pointerDown(divider, {button: 0, clientX: 100, pointerId: 1})).toBe(false); + // Non-primary buttons pass through untouched. + expect(fireEvent.pointerDown(divider, {button: 2, clientX: 100, pointerId: 2})).toBe(true); + }); + + it('resizes from the keyboard: arrows step, Home/End jump, all clamped', () => { + const {divider, onCommit} = renderResizer({getMaxWidth: () => 400}); + fireEvent.keyDown(divider, {key: 'ArrowRight'}); + expect(onCommit).toHaveBeenLastCalledWith(296); + fireEvent.keyDown(divider, {key: 'ArrowLeft'}); + expect(onCommit).toHaveBeenLastCalledWith(264); + fireEvent.keyDown(divider, {key: 'Home'}); + expect(onCommit).toHaveBeenLastCalledWith(PANEL_MIN_WIDTH); + fireEvent.keyDown(divider, {key: 'End'}); + expect(onCommit).toHaveBeenLastCalledWith(400); + }); + + it('does not re-commit a keyboard step already at the edge', () => { + const {divider, onCommit} = renderResizer({width: PANEL_MIN_WIDTH}); + fireEvent.keyDown(divider, {key: 'ArrowLeft'}); + fireEvent.keyDown(divider, {key: 'Home'}); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('marks while dragging so the app keeps the resize cursor', () => { + const {divider} = renderResizer(); + fireEvent.pointerDown(divider, {button: 0, clientX: 100, pointerId: 1}); + expect(document.body).toHaveClass('panel-resizing'); + fireEvent.pointerUp(divider, {pointerId: 1}); + expect(document.body).not.toHaveClass('panel-resizing'); + }); + + it('does not commit a stray click (zero movement)', () => { + const {divider, onCommit} = renderResizer(); + fireEvent.pointerDown(divider, {button: 0, clientX: 100, pointerId: 1}); + fireEvent.pointerUp(divider, {pointerId: 1}); + // Would otherwise pin today's stylesheet default into localStorage as a chosen width — + // and the first click of a double-click reset would write the key the second removes. + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('a cap below the current width blocks growth but never yanks the panel back', () => { + const {divider, onResize, onCommit} = renderResizer({getMaxWidth: () => 230}); + // Pointer: a rightward drag must hold the current 280, not snap back to the 230 cap. + fireEvent.pointerDown(divider, {button: 0, clientX: 100, pointerId: 1}); + fireEvent.pointerMove(divider, {clientX: 104, pointerId: 1}); + expect(onResize).not.toHaveBeenCalled(); + fireEvent.pointerUp(divider, {pointerId: 1}); + expect(onCommit).not.toHaveBeenCalled(); + // Keyboard: growth is a no-op, but shrinking still steps normally (280 → 264, not 230). + fireEvent.keyDown(divider, {key: 'ArrowRight'}); + expect(onCommit).not.toHaveBeenCalled(); + fireEvent.keyDown(divider, {key: 'ArrowLeft'}); + expect(onCommit).toHaveBeenLastCalledWith(264); + }); + + it('keeps aria-valuenow live during a drag (state only commits on release)', () => { + const {divider} = renderResizer(); + fireEvent.pointerDown(divider, {button: 0, clientX: 100, pointerId: 1}); + fireEvent.pointerMove(divider, {clientX: 140, pointerId: 1}); + expect(divider).toHaveAttribute('aria-valuenow', '320'); + }); + + it('commits the dragged width if unmounted mid-drag', () => { + // The rail can close under ⌘⇧\ while its divider is held: no pointerup will ever arrive, + // so the unmount path must both commit the width the DOM shows and drop the body class. + const {divider, onCommit, unmount} = renderResizer(); + fireEvent.pointerDown(divider, {button: 0, clientX: 100, pointerId: 1}); + fireEvent.pointerMove(divider, {clientX: 140, pointerId: 1}); + unmount(); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith(320); + expect(document.body).not.toHaveClass('panel-resizing'); + }); +}); diff --git a/src/components/PanelResizer.tsx b/src/components/PanelResizer.tsx new file mode 100644 index 0000000..77c1734 --- /dev/null +++ b/src/components/PanelResizer.tsx @@ -0,0 +1,187 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import type {KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent} from 'react'; + +import './PanelResizer.css'; + +// One shared range for both panels (the rail and the list read as one family; per-panel ranges +// would make equal-feeling drags stop at different places). The stylesheet defaults (200/280 in +// Workspace.css) sit comfortably inside it. +export const PANEL_MIN_WIDTH = 160; +export const PANEL_MAX_WIDTH = 480; + +/** Arrow-key resize step on a focused divider. */ +const KEYBOARD_STEP = 16; + +/** Marks a live divider drag on : app-wide col-resize cursor + no text selection. */ +const DRAGGING_BODY_CLASS = 'panel-resizing'; + +/** + * Clamp a panel width into the shared range, optionally tightened by a gesture-time `max` (the + * "leave the editor room" cap). A cap below the minimum loses: a tiny window must not wedge the + * divider into an undraggable dead state. Rounded — a fractional clientX delta must not commit a + * fractional width (symmetric with parsePanelWidth, which rounds what it reads back). + */ +export function clampPanelWidth(width: number, max = PANEL_MAX_WIDTH): number { + return Math.round( + Math.min( + Math.max(width, PANEL_MIN_WIDTH), + Math.max(Math.min(max, PANEL_MAX_WIDTH), PANEL_MIN_WIDTH), + ), + ); +} + +/** + * Parse a persisted panel width. Anything non-numeric → null (= stylesheet default), anything + * numeric is clamped into range — localStorage contents are user-editable and must not be able + * to break the layout. + */ +export function parsePanelWidth(raw: string | null): number | null { + if (raw === null || raw.trim() === '') return null; + const value = Number(raw); + if (!Number.isFinite(value)) return null; + return clampPanelWidth(Math.round(value)); +} + +interface PanelResizerProps { + /** Accessible name for the separator ("Resize folder rail" / "Resize note list"). */ + label: string; + /** Committed width of the panel this divider resizes — the panel to its LEFT. */ + width: number; + /** + * Gesture-time upper bound, sampled once at drag start (and per keypress): how wide the panel + * may get before the editor drops under its minimum. Omitted → the shared max alone. + */ + getMaxWidth?: () => number; + /** Live width during a drag, every pointer move — apply it, don't persist it. */ + onResize: (width: number) => void; + /** Final width — pointer release or a keyboard step. Persist here. */ + onCommit: (width: number) => void; + /** Double-click: back to the stylesheet default. */ + onReset: () => void; +} + +/** + * A draggable divider between two panes: a 7px hit strip straddling the 1px border the left + * panel already draws, consuming no layout width of its own. Follows the WAI-ARIA window + * splitter pattern — focusable, arrows resize, Home/End jump to the range edges — with + * double-click resetting to the default width. + */ +export function PanelResizer({ + label, + width, + getMaxWidth, + onResize, + onCommit, + onReset, +}: PanelResizerProps) { + const [dragging, setDragging] = useState(false); + // Gesture state lives in a ref: pointer moves must not re-render the divider, and the cap is + // frozen at drag start (mid-drag window resizes are not worth chasing). + const drag = useRef<{startX: number; startWidth: number; max: number; last: number} | null>( + null, + ); + + const gestureMax = useCallback( + () => (getMaxWidth ? getMaxWidth() : PANEL_MAX_WIDTH), + [getMaxWidth], + ); + + // The body class and a pending commit outlive the component only if it unmounts mid-drag + // (⌘⇧\ closes the rail, a ⌃R workspace switch): no pointerup will arrive, so the cleanup + // both drops the class and commits the width the DOM already shows — otherwise the inline + // var written during the drag survives with no matching state until a reload. On a normal + // release endDrag has already nulled the gesture, so the cleanup commit is a no-op. + // (onCommit is a useState setter in practice — stable, so this effect runs on drag edges.) + useEffect(() => { + if (!dragging) return undefined; + document.body.classList.add(DRAGGING_BODY_CLASS); + return () => { + document.body.classList.remove(DRAGGING_BODY_CLASS); + const pending = drag.current; + drag.current = null; + if (pending && pending.last !== pending.startWidth) onCommit(pending.last); + }; + }, [dragging, onCommit]); + + const handlePointerDown = (e: ReactPointerEvent) => { + if (e.button !== 0) return; + // Kill the native mousedown behaviors at the root: WebKit otherwise starts a TEXT + // SELECTION that outlives any later user-select:none (the body class lands an effect-tick + // too late), and a divider click must not steal focus from wherever the user is working. + e.preventDefault(); + // Optional call: jsdom lacks pointer capture. + e.currentTarget.setPointerCapture?.(e.pointerId); + // The editor-room cap stops GROWTH; it must never pull an already-wider panel back on the + // first move (a small window would otherwise snap the panel to the cap regardless of drag + // direction), so the current width always floors it. + drag.current = { + startX: e.clientX, + startWidth: width, + max: Math.max(gestureMax(), width), + last: width, + }; + setDragging(true); + }; + + const handlePointerMove = (e: ReactPointerEvent) => { + if (!drag.current) return; + const next = clampPanelWidth( + drag.current.startWidth + (e.clientX - drag.current.startX), + drag.current.max, + ); + if (next === drag.current.last) return; + drag.current.last = next; + // Keep the separator's value fresh for assistive tech mid-drag: state (and the rendered + // aria-valuenow) only commits on release, by design — so write the attribute directly, + // same as the width var. + e.currentTarget.setAttribute('aria-valuenow', String(next)); + onResize(next); + }; + + const endDrag = () => { + if (!drag.current) return; + const {startWidth, last} = drag.current; + drag.current = null; + setDragging(false); + // A stray click (zero movement) must not commit: it would pin today's stylesheet default + // into localStorage as if the user had chosen it — and the first click of a double-click + // reset would write the very key the second click removes. + if (last !== startWidth) onCommit(last); + }; + + const handleKeyDown = (e: ReactKeyboardEvent) => { + // Same floor as the drag path: a cap below the current width blocks growth but must not + // yank the panel backwards (ArrowLeft would otherwise overshoot its 16px step, and + // ArrowRight/End would shrink the panel they promise to grow). + const max = Math.max(gestureMax(), width); + let next: number | null = null; + if (e.key === 'ArrowLeft') next = clampPanelWidth(width - KEYBOARD_STEP, max); + else if (e.key === 'ArrowRight') next = clampPanelWidth(width + KEYBOARD_STEP, max); + else if (e.key === 'Home') next = PANEL_MIN_WIDTH; + else if (e.key === 'End') next = clampPanelWidth(PANEL_MAX_WIDTH, max); + if (next === null) return; + e.preventDefault(); + if (next !== width) onCommit(next); + }; + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a FOCUSABLE separator with aria-valuenow is the WAI-ARIA "window splitter" widget, interactive by spec; jsx-a11y only knows the static variant +
+ ); +} diff --git a/src/components/Workspace.css b/src/components/Workspace.css index 5345f21..e590f3d 100644 --- a/src/components/Workspace.css +++ b/src/components/Workspace.css @@ -1,5 +1,7 @@ .workspace { - /* Width of the notes list column, and of the folder rail beside it. */ + /* Default widths of the notes list column and the folder rail beside it. The dividers + (PanelResizer) override both inline on this element; the numbers are mirrored as + *_DEFAULT_WIDTH in Workspace.tsx — keep them in step. */ --sidebar-width: 280px; --rail-width: 200px; display: flex; @@ -93,13 +95,20 @@ /* Collapsed: the sidebar leaves the flow as an off-screen overlay so the editor fills the width. It stays mounted, but visibility:hidden keeps it out of the tab order until peeked. The visibility change is delayed (0s duration, 0.15s delay) so on CLOSE the sidebar stays visible - through the slide-out and only then hides. */ + through the slide-out and only then hides. + + The overlay's width is EXPLICIT — the same sum the panes produce in flow (each +1px for its + border; the dividers net to zero). Left to shrink-to-fit, WebKit underestimates the absolute + flex row's intrinsic width and paints the box-shadow for a box narrower than the laid-out + panes, so the peeked overlay casts no shadow at its right edge. Pinning the width also keeps + the shadow tracking live divider drags. */ .workspace__body_collapsed .workspace__sidebar { position: absolute; left: 0; top: 0; bottom: 0; z-index: 5; + width: calc(var(--sidebar-width) + 1px); transform: translateX(-100%); visibility: hidden; transition: @@ -108,6 +117,10 @@ box-shadow: 0 0 16px rgba(0, 0, 0, 0.25); } +.workspace__body_collapsed .workspace__sidebar_with-rail { + width: calc(var(--rail-width) + var(--sidebar-width) + 2px); +} + /* Peeked (⌘\): slide the overlay in and make it focusable. visibility flips to visible IMMEDIATELY (0s, no delay) — animating/delaying it would leave the row unfocusable at the instant the peek effect calls .focus(), stranding the cursor in the editor. The rule sits AFTER diff --git a/src/components/Workspace.test.tsx b/src/components/Workspace.test.tsx index d8dd1e3..cd7aa50 100644 --- a/src/components/Workspace.test.tsx +++ b/src/components/Workspace.test.tsx @@ -967,3 +967,90 @@ describe('Workspace — legacy note-appearance migration', () => { expect(localStorage.getItem('gravity-notes:test-ws:note:Gone.md:appearance')).toBeNull(); }); }); + +describe('Workspace — resizable panels', () => { + afterEach(() => { + localStorage.clear(); + }); + + const workspaceStyle = () => (document.querySelector('.workspace') as HTMLElement).style; + + // jsdom lays nothing out (clientWidth 0), which reads as a zero-width window — the + // "leave the editor room" cap would floor every drag. Give the body a real width. + const layOutBody = (width: number) => { + Object.defineProperty( + document.querySelector('.workspace__body') as HTMLElement, + 'clientWidth', + { + configurable: true, + value: width, + }, + ); + }; + + it('drags the note-list divider live and persists the width on release', async () => { + renderWorkspace(); + await screen.findByRole('option', {name: /Alpha/}); + layOutBody(1200); + const divider = screen.getByRole('separator', {name: 'Resize note list'}); + + fireEvent.pointerDown(divider, {button: 0, clientX: 280, pointerId: 1}); + fireEvent.pointerMove(divider, {clientX: 340, pointerId: 1}); + // Mid-drag: the live width is on the element, nothing persisted yet. + expect(workspaceStyle().getPropertyValue('--sidebar-width')).toBe('340px'); + expect(localStorage.getItem('gravity-notes:test-ws:sidebar-width')).toBeNull(); + + fireEvent.pointerUp(divider, {pointerId: 1}); + expect(localStorage.getItem('gravity-notes:test-ws:sidebar-width')).toBe('340'); + expect(workspaceStyle().getPropertyValue('--sidebar-width')).toBe('340px'); + }); + + it('restores a persisted width on mount and clamps garbage', async () => { + localStorage.setItem('gravity-notes:test-ws:sidebar-width', '333'); + renderWorkspace(); + await screen.findByRole('option', {name: /Alpha/}); + expect(workspaceStyle().getPropertyValue('--sidebar-width')).toBe('333px'); + // The untouched rail width stays on the stylesheet default (no inline override). + expect(workspaceStyle().getPropertyValue('--rail-width')).toBe(''); + }); + + it('double-click resets the panel to its default and clears the key', async () => { + localStorage.setItem('gravity-notes:test-ws:sidebar-width', '333'); + renderWorkspace(); + await screen.findByRole('option', {name: /Alpha/}); + const divider = screen.getByRole('separator', {name: 'Resize note list'}); + + fireEvent.doubleClick(divider); + expect(workspaceStyle().getPropertyValue('--sidebar-width')).toBe(''); + expect(localStorage.getItem('gravity-notes:test-ws:sidebar-width')).toBeNull(); + }); + + it('shows the rail divider only when the rail is open', async () => { + const user = userEvent.setup(); + renderWorkspace(); + await screen.findByRole('option', {name: /Alpha/}); + expect(screen.queryByRole('separator', {name: 'Resize folder rail'})).toBeNull(); + + await user.click(screen.getByRole('button', {name: 'Folders'})); + expect(await screen.findByRole('separator', {name: 'Resize folder rail'})).toBeVisible(); + }); + + it('renders no dividers in the mobile single-pane layout', async () => { + // Phone-sim: the ≤700px width query matches while the hover query still reports a mouse. + // No divider belongs there — the rail is a drawer and the list fills the width, so a drag + // could only rewrite the DESKTOP widths sight unseen. + const original = window.matchMedia; + window.matchMedia = ((query: string) => ({ + ...original(query), + matches: query.includes('max-width'), + })) as typeof window.matchMedia; + try { + renderWorkspace(); + await screen.findByRole('option', {name: /Alpha/}); + expect(document.querySelector('.workspace__body_mobile')).not.toBeNull(); + expect(screen.queryAllByRole('separator')).toHaveLength(0); + } finally { + window.matchMedia = original; + } + }); +}); diff --git a/src/components/Workspace.tsx b/src/components/Workspace.tsx index b0cd63b..e045557 100644 --- a/src/components/Workspace.tsx +++ b/src/components/Workspace.tsx @@ -1,4 +1,5 @@ import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import type {CSSProperties} from 'react'; import {Eye} from '@gravity-ui/icons'; import {Icon, Label, Text, useToaster} from '@gravity-ui/uikit'; @@ -41,6 +42,7 @@ import {EditorPane, type EditorPaneHandle} from './EditorPane'; import {FolderRail, type FolderRailHandle} from './FolderRail'; import {MoveToDialog} from './MoveToDialog'; import {NoteList, type NoteListHandle} from './NoteList'; +import {PanelResizer, parsePanelWidth} from './PanelResizer'; import {SettingsDialog} from './SettingsDialog'; import {ShortcutsDialog} from './ShortcutsDialog'; import {TopBar} from './TopBar'; @@ -97,6 +99,46 @@ const SEARCH_DEBOUNCE_MS = 120; // workspace keeps its own sidebar/rail arrangement across switches and windows. const nsKey = (workspaceId: string, suffix: string) => `gravity-notes:${workspaceId}:${suffix}`; +// The stylesheet's panel widths (Workspace.css `--rail-width` / `--sidebar-width`) — the resting +// state a divider double-click returns to. `null` width state = "use these". +const RAIL_DEFAULT_WIDTH = 200; +const SIDEBAR_DEFAULT_WIDTH = 280; +// What a divider drag must always leave the editor, no matter the window: enough for a readable +// column of text. Computed against the panels' width VARIABLES — their 1px borders shave a couple +// of pixels off in practice — so it's a comfortable floor, not a pixel-exact one. +const EDITOR_MIN_WIDTH = 320; + +/** + * One dragged panel width (folder rail / note list), per-workspace like the rest of the layout. + * `width` null = the stylesheet default (and no stored key — a reset must not pin today's default + * forever); `effective` resolves it. Note windows keep the defaults and never persist, like the + * rest of their transient layout. No legacy keys to migrate: the feature postdates workspaces. + */ +function usePanelWidth( + workspaceId: string, + suffix: 'rail-width' | 'sidebar-width', + defaultWidth: number, + noteWindow: boolean, +) { + const [width, setWidth] = useState(() => + noteWindow ? null : parsePanelWidth(localStorage.getItem(nsKey(workspaceId, suffix))), + ); + useEffect(() => { + if (noteWindow) return; + if (width === null) localStorage.removeItem(nsKey(workspaceId, suffix)); + else localStorage.setItem(nsKey(workspaceId, suffix), String(width)); + }, [noteWindow, workspaceId, suffix, width]); + return {width, effective: width ?? defaultWidth, set: setWidth}; +} + +/** Inline overrides for the panel-width variables — only the dragged ones; absent = stylesheet. */ +function panelWidthVars(railWidth: number | null, sidebarWidth: number | null): CSSProperties { + return { + ...(railWidth !== null && {'--rail-width': `${railWidth}px`}), + ...(sidebarWidth !== null && {'--sidebar-width': `${sidebarWidth}px`}), + } as CSSProperties; +} + // The pre-workspace (un-namespaced) UI-state keys. Consumed once — as the defaults for the first // workspace opened after the upgrade (the migrated one) — then deleted, so a later "set back to // default" in that workspace can't fall through to a stale global value. @@ -125,6 +167,14 @@ function readWorkspaceKey(workspaceId: string, suffix: string, legacyKey: string return legacy; } +/** + * The sidebar's class list: the collapsed-overlay width rule needs to know whether the rail's + * width belongs in the sum — see Workspace.css. + */ +function sidebarClassName(railOpen: boolean): string { + return 'workspace__sidebar' + (railOpen ? ' workspace__sidebar_with-rail' : ''); +} + /** Re-prefix a folder path (or note id) when its `from` ancestor folder moves/renames to `to`. */ function reprefixPath(path: string, from: string, to: string): string { return path === from || path.startsWith(`${from}/`) ? to + path.slice(from.length) : path; @@ -321,6 +371,44 @@ export function Workspace({ }, [noteWindow, workspaceId, railOpen]); const toggleRail = useCallback(() => setRailOpen((open) => !open), []); + // The dividers' dragged widths (see usePanelWidth above). + const rail = usePanelWidth(workspaceId, 'rail-width', RAIL_DEFAULT_WIDTH, noteWindow); + const sidebar = usePanelWidth(workspaceId, 'sidebar-width', SIDEBAR_DEFAULT_WIDTH, noteWindow); + const setRailWidth = rail.set; + const setSidebarWidth = sidebar.set; + + // During a divider drag the live width goes straight onto the root element's CSS variable — + // a pointer-rate re-render of the whole workspace (editor included) is real jank — and the + // committed value lands in state on release, matching what the DOM already shows. + const workspaceRootRef = useRef(null); + // Written by attachBodyEl (a callback ref, so the element also lands in state for swipe-back). + const bodyRef = useRef(null); + const setPanelVar = useCallback((name: '--rail-width' | '--sidebar-width', width: number) => { + workspaceRootRef.current?.style.setProperty(name, `${width}px`); + }, []); + // Reset also clears the live inline var: with state already null there is no re-render to + // sweep up a value a drag wrote directly to the DOM. + const resetRailWidth = useCallback(() => { + setRailWidth(null); + workspaceRootRef.current?.style.removeProperty('--rail-width'); + }, [setRailWidth]); + const resetSidebarWidth = useCallback(() => { + setSidebarWidth(null); + workspaceRootRef.current?.style.removeProperty('--sidebar-width'); + }, [setSidebarWidth]); + // Drag caps, sampled at gesture start: however wide the OTHER panel currently sits, the + // editor keeps at least EDITOR_MIN_WIDTH of the body row. + const railMaxWidth = useCallback(() => { + const body = bodyRef.current; + if (!body) return Number.MAX_SAFE_INTEGER; + return body.clientWidth - sidebar.effective - EDITOR_MIN_WIDTH; + }, [sidebar.effective]); + const sidebarMaxWidth = useCallback(() => { + const body = bodyRef.current; + if (!body) return Number.MAX_SAFE_INTEGER; + return body.clientWidth - (railOpen ? rail.effective : 0) - EDITOR_MIN_WIDTH; + }, [railOpen, rail.effective]); + // Drive list MODE (ranked search vs folder scope) off the debounced query, so the list flips in // step with the results it shows — not a keystroke ahead of them. const searching = debouncedQuery.trim().length > 0; @@ -624,6 +712,11 @@ export function Workspace({ // note pushes to 'editor', the top bar's Back button returns to 'list'. Ignored on wider // viewports, where the desktop multi-pane layout (collapsed/peeked overlay) applies instead. const isNarrow = useIsNarrow(); + // The desktop column layout is the only one with resizable panels — mobile turns the rail into + // a drawer and stretches the list to the full width, so a divider there would ride over the + // drawer's edge and silently rewrite the DESKTOP widths. Mirrors the body-className branch + // (note windows keep the desktop layout at any size). + const resizableColumns = !isNarrow || noteWindow; const [mobilePane, setMobilePane] = useState<'list' | 'editor'>('list'); // Reveal-coordination state (see revealNote): the note the mobile pane should push to once it has // loaded, plus stable reads of isNarrow / the open note for the callback. @@ -704,6 +797,12 @@ export function Workspace({ // must re-run once the nodes mount). const [bodyEl, setBodyEl] = useState(null); const [sidebarEl, setSidebarEl] = useState(null); + // One body element, two consumers: swipe-back needs it in STATE (the hook re-subscribes when + // it mounts), the divider caps just read clientWidth at gesture start — the ref above suffices. + const attachBodyEl = useCallback((el: HTMLDivElement | null) => { + bodyRef.current = el; + setBodyEl(el); + }, []); // Return to the list from the editor pane (Back button / Escape) and land keyboard focus on the // selected row once the list is on screen — mirrors the desktop Esc-out-of-editor behavior. A @@ -1328,7 +1427,14 @@ export function Workspace({ return ( -
+
-