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

@ -5,6 +5,7 @@ import type { TFunction } from "i18next";
import * as Sentry from "@sentry/react";
import { useYjs, type YjsState } from "~/lib/use-yjs";
import { useRouting, type RouteError } from "~/lib/use-routing";
import { useUndo, useUndoShortcuts } from "~/lib/use-undo";
import { ProfileSelector } from "~/components/ProfileSelector";
import { ExportButton } from "~/components/ExportButton";
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
@ -187,6 +188,8 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]);
const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas);
const { computing, routeError, routeStats, requestRoute } = useRouting(yjs);
const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null);
useUndoShortcuts(yjs?.undoManager ?? null);
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
const { toasts, addToast } = useToasts();
useAwarenessToasts(yjs, t, addToast);
@ -225,6 +228,24 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
</Link>
<ProfileSelector yjs={yjs} />
<ParticipantList yjs={yjs} />
<div className="flex gap-1">
<button
onClick={undo}
disabled={!canUndo}
title={t("undo.tooltip")}
className="rounded px-1.5 py-1 text-gray-500 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
<button
onClick={redo}
disabled={!canRedo}
title={t("redo.tooltip")}
className="rounded px-1.5 py-1 text-gray-500 hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10"/></svg>
</button>
</div>
</div>
<div className="flex items-center gap-3">
{callbackUrl && callbackToken && (