Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?

Expand Down
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion docs/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 24 additions & 1 deletion src/components/NoteList.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,37 @@
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;
gap: 8px;
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 {
Expand Down
10 changes: 9 additions & 1 deletion src/components/NoteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -869,7 +869,15 @@ export const NoteList = forwardRef<NoteListHandle, NoteListProps>(function NoteL
{value: 'created', content: 'Created'},
]}
/>
<Button view="normal" size="m" onClick={() => onCreate()}>
{/* aria-label keeps the name stable when the tight-width container query (see
NoteList.css) hides the text and leaves the icon. */}
<Button
className="note-list__new"
view="normal"
size="m"
aria-label="New"
onClick={() => onCreate()}
>
<Icon data={Plus} />
New
</Button>
Expand Down
60 changes: 60 additions & 0 deletions src/components/PanelResizer.css
Original file line number Diff line number Diff line change
@@ -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 <body> 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;
}
183 changes: 183 additions & 0 deletions src/components/PanelResizer.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof PanelResizer>[0]> = {}) => {
const h = handlers();
const {unmount} = renderWithProviders(
<PanelResizer label="Resize note list" width={280} {...h} {...props} />,
);
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 <body> 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');
});
});
Loading
Loading