Add undo/redo to planner

Yjs UndoManager tracks waypoints, no-go areas, and notes. Only
local mutations (origin "local") are undoable — remote changes
from other users are not.

- Keyboard shortcuts: Ctrl/Cmd+Z (undo), Ctrl/Cmd+Shift+Z/Y (redo)
- Shortcuts suppressed in text inputs (browser-native undo there)
- Undo/redo buttons in header with disabled state
- stopCapturing on drag to isolate drag as one undo step
- All mutation sites wrapped with "local" origin
- 5 unit tests for UndoManager behavior
- Archived undo-redo change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-04 10:04:01 +01:00
parent 52bd063a2a
commit 743169550e
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
14 changed files with 236 additions and 32 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-28

View file

@ -0,0 +1,189 @@
## Context
The Planner uses Yjs for collaborative CRDT state with four shared types:
`waypoints` (Y.Array), `noGoAreas` (Y.Array), `routeData` (Y.Map), and
`notes` (Y.Text). All mutations happen through Yjs transactions. The editor
supports multiple simultaneous users via y-websocket.
Yjs provides a built-in `UndoManager` class that tracks changes to specified
shared types, scoped by transaction origin, with configurable capture
timeouts for grouping related changes.
## Goals / Non-Goals
**Goals:**
- Undo/redo for waypoint operations (add, delete, reorder, drag-move)
- Undo/redo for no-go area operations (add, delete)
- Undo/redo for notes text edits
- Keyboard shortcuts that work naturally (Ctrl+Z, Ctrl+Shift+Z, Ctrl+Y)
- Topbar buttons with visual disabled state
- Collaborative-safe: only undo your own changes, never other users'
- Drag operations grouped as a single undo step
**Non-Goals:**
- Undo for routeData (derived data, not user input)
- Undo for route options / color mode changes
- Persistent undo history across page reloads
- Customizable undo stack depth
## Decisions
### D1: Single UndoManager tracking waypoints, noGoAreas, and notes
Create one `Y.UndoManager` instance in `use-yjs.ts`, tracking all three
user-editable shared types: `waypoints`, `noGoAreas`, and `notes`.
```ts
const undoManager = new Y.UndoManager(
[waypoints, noGoAreas, notes],
{
captureTimeout: 500,
trackedOrigins: new Set(["local"]),
}
);
```
A single UndoManager means undo walks back through all user actions in order,
regardless of which shared type was modified. This matches user expectations:
"undo my last thing" not "undo my last waypoint thing."
The UndoManager is created inside the `useYjs` hook and exposed on the
`YjsState` interface so all components can access it.
### D2: Transaction origins for local vs. remote scoping
All local mutations must use the origin `"local"` so UndoManager can
distinguish them from remote changes arriving via y-websocket:
```ts
doc.transact(() => {
waypoints.push([yMap]);
}, "local");
```
Current code has some `doc.transact()` calls without an origin and some
direct mutations (e.g., `yjs.waypoints.push([yMap])`) outside a transaction.
All mutation sites need to be wrapped in `doc.transact(() => { ... }, "local")`.
Mutation sites to update:
- `PlannerMap.tsx`: `addWaypoint`, `insertWaypointAtSegment`, `moveWaypoint`,
`deleteWaypoint`
- `WaypointSidebar.tsx`: `deleteWaypoint`, `moveWaypoint`
- `NoGoAreaLayer.tsx`: no-go area creation (`pm:create` handler), no-go area
deletion (contextmenu handler)
- `NotesPanel.tsx`: `handleInput` (already uses `doc.transact`, needs origin)
- `use-yjs.ts`: initial waypoint seeding (use `"init"` origin, not `"local"`,
so it's not undoable)
### D3: Keyboard shortcuts
Register a global `keydown` listener (in a `useUndoShortcuts` hook or
similar) that handles:
- **Ctrl+Z** (or Cmd+Z on macOS): `undoManager.undo()`
- **Ctrl+Shift+Z** or **Ctrl+Y** (Cmd+Shift+Z on macOS): `undoManager.redo()`
The listener must suppress when the active element is an `<input>`,
`<textarea>`, or `[contenteditable]` -- these elements have their own
browser-native undo/redo behavior. The notes textarea is the main case.
However, since notes Y.Text is tracked by our UndoManager, we may want to
**not** suppress in the notes textarea and instead use the Yjs undo there
too. Decision: suppress for now and let the browser handle textarea undo
natively. This avoids complexity around cursor position restoration.
### D4: Topbar undo/redo buttons
Add two icon buttons to the header in `SessionView.tsx`, positioned in the
left group next to the participant list:
- Undo button: left-curved arrow icon, `title` with shortcut hint
- Redo button: right-curved arrow icon, `title` with shortcut hint
- Both disabled (grayed out) when their respective stack is empty
Use simple inline SVG or Unicode arrows (e.g., `\u21B6` / `\u21B7` or
custom SVG) to avoid adding an icon library dependency.
### D5: Undo granularity and drag grouping
The `captureTimeout` of 500ms means rapid successive changes (within 500ms)
are grouped into one undo step. This works well for:
- Quick waypoint additions (each click is >500ms apart, so separate steps)
- Text typing in notes (keystrokes within 500ms grouped together)
For **drag operations** (waypoint drag on map), the marker `dragstart` fires
once, then multiple `drag` events update position, then `dragend` fires. The
intermediate positions should not be separate undo steps.
Strategy: Call `undoManager.stopCapturing()` at `dragstart`. This forces the
next change to start a new capture group. Then all position updates during
the drag happen within the capture timeout window and merge into one step.
After `dragend`, no special action needed -- the next user action will
naturally start a new group after 500ms.
Note: Currently `moveWaypoint` in `PlannerMap.tsx` is only called on
`dragend`, not during drag, so intermediate positions are not stored in Yjs.
This means drag is already a single undo step. The `stopCapturing()` call is
still useful as a safeguard in case drag behavior changes later, and to
separate the drag from any preceding action within the capture window.
### D6: Stack state reactivity
`UndoManager` fires `stack-item-added`, `stack-item-popped`,
`stack-item-updated` events. Use these to maintain reactive `canUndo` /
`canRedo` booleans in a `useUndo` hook:
```ts
export function useUndo(undoManager: Y.UndoManager | null) {
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
useEffect(() => {
if (!undoManager) return;
const update = () => {
setCanUndo(undoManager.undoStack.length > 0);
setCanRedo(undoManager.redoStack.length > 0);
};
undoManager.on("stack-item-added", update);
undoManager.on("stack-item-popped", update);
undoManager.on("stack-item-updated", update);
update();
return () => {
undoManager.off("stack-item-added", update);
undoManager.off("stack-item-popped", update);
undoManager.off("stack-item-updated", update);
};
}, [undoManager]);
return { canUndo, canRedo };
}
```
The buttons read `canUndo` / `canRedo` and set `disabled` accordingly.
### D7: Notes undo interaction
`notes` (Y.Text) is included in the UndoManager's tracked types. However,
keyboard shortcuts are suppressed when the textarea is focused (D3), so:
- **In the textarea**: Browser-native undo (Ctrl+Z) handles text editing.
This is familiar and handles cursor position correctly.
- **Outside the textarea**: Yjs undo can revert notes changes as part of the
global undo stack. This means if you edit notes, click on the map, then
press Ctrl+Z, the notes edit will be undone via Yjs.
This is a pragmatic split. If it causes confusion (users expecting Ctrl+Z in
textarea to use the Yjs stack), we can revisit.
## Risks / Trade-offs
- **Undo after remote changes can be surprising** -- If another user adds a
waypoint between your two actions, undoing your second action still works
correctly (Yjs handles this), but the route may look unexpected because the
remote waypoint remains. This is inherent to collaborative undo and
acceptable.
- **No cursor restoration for notes** -- When undoing a notes change via the
Yjs UndoManager (from outside the textarea), the textarea content updates
but the cursor position is not restored. This is a minor UX gap.
- **captureTimeout grouping edge cases** -- Two rapid distinct actions
(e.g., delete waypoint then immediately add no-go area) within 500ms will
be grouped as one undo step. Unlikely in practice and acceptable.

View file

@ -0,0 +1,61 @@
## Why
Route editing mistakes in the Planner have no recovery path. Accidentally
deleting a waypoint, dragging to the wrong spot, or removing a no-go area
requires manually recreating the change. This is especially frustrating during
complex multi-waypoint edits where a single misclick can undo minutes of work.
Every desktop editing tool supports Ctrl+Z. Users expect it. Its absence is
the most obvious missing affordance in the Planner.
## What Changes
- Add undo/redo support to the Planner's route editor using Yjs's built-in
`UndoManager`
- Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z / Ctrl+Y (redo)
- Undo/redo buttons in the topbar with disabled state when the stack is empty
- Only the current user's changes are undone -- collaborative-safe by design.
Other participants' edits are never affected by your undo.
## Scope
Tracked types:
- `waypoints` (Y.Array) -- add, remove, reorder, drag-move
- `noGoAreas` (Y.Array) -- add, remove
- `notes` (Y.Text) -- text edits
`routeData` (Y.Map) is **not** tracked. It contains derived data (route
geometry, segment boundaries) computed by BRouter, not direct user input.
Undoing a waypoint change will trigger a re-route automatically.
## Non-Goals
- Infinite undo history -- UndoManager keeps a reasonable in-memory stack,
no persistence needed
- Undo across sessions -- the undo stack is in-memory and resets when
the page is closed or the WebSocket reconnects
- Undo for route options (profile selection, color mode) -- these are
infrequent, low-risk changes that don't warrant undo tracking
- Granular undo for individual keystrokes in notes -- Y.Text undo groups
by capture timeout, which is good enough
## Capabilities
### New Capabilities
- `undo-redo`: Undo/redo for route editing operations in the Planner via
keyboard shortcuts and topbar buttons, scoped to the local user's changes
### Modified Capabilities
(None)
## Impact
- **Files**: New hook (`use-undo.ts`), modified `use-yjs.ts` (transaction
origins), modified `SessionView.tsx` (buttons + keyboard handler), modified
mutation sites in `PlannerMap.tsx`, `WaypointSidebar.tsx`,
`NoGoAreaLayer.tsx`, `NotesPanel.tsx`
- **Dependencies**: None -- Yjs `UndoManager` is built into the `yjs` package
already in use
- **i18n**: Tooltip strings for undo/redo buttons

View file

@ -0,0 +1,34 @@
## 1. Core: UndoManager Setup
- [x] 1.1 Create `apps/planner/app/lib/use-undo.ts` hook that takes a `Y.UndoManager` and exposes `canUndo`, `canRedo`, `undo()`, `redo()` via UndoManager event listeners (`stack-item-added`, `stack-item-popped`, `stack-item-updated`)
- [x] 1.2 Create the `Y.UndoManager` in `use-yjs.ts`, tracking `[waypoints, noGoAreas, notes]` with `captureTimeout: 500` and `trackedOrigins: new Set(["local"])`. Expose it on the `YjsState` interface. Destroy it in the cleanup function.
- [x] 1.3 Add `"local"` origin to all mutation sites:
- `PlannerMap.tsx`: wrap `addWaypoint`, `insertWaypointAtSegment`, `moveWaypoint`, `deleteWaypoint` in `doc.transact(() => { ... }, "local")`
- `WaypointSidebar.tsx`: wrap `deleteWaypoint` and `moveWaypoint` in `doc.transact(() => { ... }, "local")`
- `NoGoAreaLayer.tsx`: add `"local"` origin to the `pm:create` and contextmenu delete transactions
- `NotesPanel.tsx`: add `"local"` origin to the existing `doc.transact()` call
- `use-yjs.ts`: use `"init"` (not `"local"`) as origin for initial waypoint seeding so it is not undoable
## 2. Keyboard Shortcuts
- [x] 2.1 Create a `useUndoShortcuts` hook (in `use-undo.ts` or separate file) that registers a global `keydown` listener for Ctrl+Z / Cmd+Z (undo) and Ctrl+Shift+Z / Cmd+Shift+Z / Ctrl+Y (redo). Calls `undoManager.undo()` / `undoManager.redo()` and calls `e.preventDefault()`.
- [x] 2.2 Suppress the shortcut when the active element is `<input>`, `<textarea>`, or `[contenteditable]` so browser-native undo works in text fields (especially the notes textarea).
## 3. UI: Topbar Buttons
- [x] 3.1 Add undo and redo icon buttons to the `SessionView.tsx` header, in the left group near the participant list. Use inline SVG arrows. Wire to `undo()` / `redo()` from the `useUndo` hook.
- [x] 3.2 Disable buttons when `canUndo` / `canRedo` is false (gray out, set `disabled` attribute). Add `title` tooltips showing the keyboard shortcut (platform-aware: Cmd on macOS, Ctrl elsewhere).
## 4. Drag Operation Grouping
- [x] 4.1 In `PlannerMap.tsx`, call `undoManager.stopCapturing()` before the drag transaction to ensure the drag is isolated as its own undo step (separated from any preceding action within the capture timeout window). Access `undoManager` from the `yjs` prop.
## 5. i18n
- [x] 5.1 Add translation keys for undo/redo button tooltips (`undo.tooltip`, `redo.tooltip`) in both English and German translation files for the planner namespace.
## 6. Testing
- [x] 6.1 Unit tests for `use-undo.ts`: verify `canUndo`/`canRedo` reactivity, verify `undo()` and `redo()` call through to UndoManager, verify state updates on stack events. Use a real `Y.Doc` + `Y.UndoManager` (not mocks).
- [x] 6.2 Unit test for transaction origins: create a Y.Doc with UndoManager, perform mutations with `"local"` origin, verify they appear on the undo stack. Perform mutations with no origin or `"init"` origin, verify they do not appear on the undo stack.
- [x] 6.3 E2E test: add two waypoints, press Ctrl+Z, verify last waypoint is removed. Press Ctrl+Shift+Z, verify it reappears. Test undo/redo buttons in the topbar (click, verify disabled state).