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:
parent
52bd063a2a
commit
743169550e
14 changed files with 236 additions and 32 deletions
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
189
openspec/changes/archive/2026-04-04-undo-redo/design.md
Normal file
189
openspec/changes/archive/2026-04-04-undo-redo/design.md
Normal 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.
|
||||
61
openspec/changes/archive/2026-04-04-undo-redo/proposal.md
Normal file
61
openspec/changes/archive/2026-04-04-undo-redo/proposal.md
Normal 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
|
||||
34
openspec/changes/archive/2026-04-04-undo-redo/tasks.md
Normal file
34
openspec/changes/archive/2026-04-04-undo-redo/tasks.md
Normal 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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue