From 760e8290da496f46453ad1ab36f943c2506502c4 Mon Sep 17 00:00:00 2001 From: Yuriy <17292315+ykamendrovskiy@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:11:43 +0300 Subject: [PATCH 1/2] feat: resizable side panels (folder rail + note list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag the divider on either edge — rail↔list and list↔editor — to resize the panel to its left; widths persist per workspace. Double-click resets to the defaults. The dividers are WAI-ARIA window splitters: focusable, arrow keys step the width, Home/End jump the range. One shared 160–480 range for both panels, with a drag-time cap that always leaves the editor at least 320px. The pointerdown is canceled at the root so WebKit cannot start a text selection mid-drag (and a divider click never steals focus). When the list is dragged tight (<250px), the New button folds to its icon via a container query so the sort select keeps a readable width. Co-Authored-By: Claude Fable 5 --- README.md | 2 - src/components/NoteList.css | 18 +++ src/components/NoteList.tsx | 10 +- src/components/PanelResizer.css | 52 ++++++++ src/components/PanelResizer.test.tsx | 138 ++++++++++++++++++++++ src/components/PanelResizer.tsx | 157 +++++++++++++++++++++++++ src/components/Workspace.css | 17 ++- src/components/Workspace.test.tsx | 68 +++++++++++ src/components/Workspace.tsx | 170 +++++++++++++++++++++++---- 9 files changed, 607 insertions(+), 25 deletions(-) create mode 100644 src/components/PanelResizer.css create mode 100644 src/components/PanelResizer.test.tsx create mode 100644 src/components/PanelResizer.tsx diff --git a/README.md b/README.md index fca09a1..ba1d8a7 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,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/src/components/NoteList.css b/src/components/NoteList.css index 7519a4f..1a6abed 100644 --- a/src/components/NoteList.css +++ b/src/components/NoteList.css @@ -6,6 +6,9 @@ flex-shrink: 0; border-right: 1px solid var(--g-color-line-generic); background-color: var(--g-color-base-background); + /* Lets the toolbar below adapt to the list's width — a container query tracks the LIVE + width during a divider drag, which React state (committed on release) cannot. */ + container-type: inline-size; } /* Filter + New, above the list. */ @@ -18,6 +21,21 @@ flex-shrink: 0; } +/* Dragged tight: the New button folds to its icon so the sort select keeps a readable width + (below ~250px the full button squeezes it under ~110px, where "Title (A→Z)" clips). 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: 249px) { + .note-list__new { + width: 28px; + padding-inline: 0; + } + + .note-list__new .g-button__text { + display: none; + } +} + .note-list__sort { flex: 1; min-width: 0; 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..ec20a3e --- /dev/null +++ b/src/components/PanelResizer.css @@ -0,0 +1,52 @@ +/* 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, +.panel-resizer:focus-visible::after { + opacity: 1; + transition-delay: 0s; +} + +/* 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..5bebc16 --- /dev/null +++ b/src/components/PanelResizer.test.tsx @@ -0,0 +1,138 @@ +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(); + renderWithProviders(); + return {divider: screen.getByRole('separator', {name: 'Resize note list'}), ...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'); + }); +}); diff --git a/src/components/PanelResizer.tsx b/src/components/PanelResizer.tsx new file mode 100644 index 0000000..e05086b --- /dev/null +++ b/src/components/PanelResizer.tsx @@ -0,0 +1,157 @@ +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. + */ +export function clampPanelWidth(width: number, max = PANEL_MAX_WIDTH): number { + return 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 outlives the component only if it unmounts mid-drag (e.g. the rail closes + // under a ⌘-shortcut) — clean it up. + useEffect(() => { + if (!dragging) return undefined; + document.body.classList.add(DRAGGING_BODY_CLASS); + return () => document.body.classList.remove(DRAGGING_BODY_CLASS); + }, [dragging]); + + 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); + drag.current = {startX: e.clientX, startWidth: width, max: gestureMax(), 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; + onResize(next); + }; + + const endDrag = () => { + if (!drag.current) return; + const {last} = drag.current; + drag.current = null; + setDragging(false); + onCommit(last); + }; + + const handleKeyDown = (e: ReactKeyboardEvent) => { + let next: number | null = null; + if (e.key === 'ArrowLeft') next = clampPanelWidth(width - KEYBOARD_STEP, gestureMax()); + else if (e.key === 'ArrowRight') + next = clampPanelWidth(width + KEYBOARD_STEP, gestureMax()); + else if (e.key === 'Home') next = PANEL_MIN_WIDTH; + else if (e.key === 'End') next = clampPanelWidth(PANEL_MAX_WIDTH, gestureMax()); + 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..8510590 100644 --- a/src/components/Workspace.test.tsx +++ b/src/components/Workspace.test.tsx @@ -967,3 +967,71 @@ 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(); + }); +}); 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 ( -
+
-