diff --git a/apps/planner/app/components/NoGoAreaLayer.tsx b/apps/planner/app/components/NoGoAreaLayer.tsx index 2111e7d..b6508ee 100644 --- a/apps/planner/app/components/NoGoAreaLayer.tsx +++ b/apps/planner/app/components/NoGoAreaLayer.tsx @@ -47,7 +47,7 @@ export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLay polygon.on("contextmenu", (e) => { L.DomEvent.preventDefault(e as unknown as Event); suppressObserverRef.current = true; - noGoAreas.delete(i, 1); + doc.transact(() => noGoAreas.delete(i, 1), "local"); suppressObserverRef.current = false; syncLayers(); }); @@ -101,7 +101,7 @@ export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLay yMap.set("points", points); doc.transact(() => { noGoAreas.push([yMap]); - }); + }, "local"); // Exit draw mode onToggle(); diff --git a/apps/planner/app/components/NotesPanel.tsx b/apps/planner/app/components/NotesPanel.tsx index 2c870bf..caaf9bb 100644 --- a/apps/planner/app/components/NotesPanel.tsx +++ b/apps/planner/app/components/NotesPanel.tsx @@ -42,7 +42,7 @@ export function NotesPanel({ yjs }: NotesPanelProps) { yjs.doc.transact(() => { yjs.notes.delete(0, yjs.notes.length); yjs.notes.insert(0, newValue); - }); + }, "local"); isLocalChange.current = false; }, [yjs.notes, yjs.doc], diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 73d8a86..f1986d6 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -294,23 +294,26 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr const addWaypoint = useCallback( (lat: number, lng: number) => { - const yMap = new Y.Map(); - yMap.set("lat", lat); - yMap.set("lon", lng); - yjs.waypoints.push([yMap]); + yjs.doc.transact(() => { + const yMap = new Y.Map(); + yMap.set("lat", lat); + yMap.set("lon", lng); + yjs.waypoints.push([yMap]); + }, "local"); }, - [yjs.waypoints], + [yjs.doc, yjs.waypoints], ); const insertWaypointAtSegment = useCallback( (segmentIndex: number, lat: number, lon: number) => { - const yMap = new Y.Map(); - yMap.set("lat", lat); - yMap.set("lon", lon); - // Insert after the segment's start waypoint - yjs.waypoints.insert(segmentIndex + 1, [yMap]); + yjs.doc.transact(() => { + const yMap = new Y.Map(); + yMap.set("lat", lat); + yMap.set("lon", lon); + yjs.waypoints.insert(segmentIndex + 1, [yMap]); + }, "local"); }, - [yjs.waypoints], + [yjs.doc, yjs.waypoints], ); const handleRouteInsert = useCallback( @@ -329,7 +332,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr yjs.doc.transact(() => { yMap.set("lat", lat); yMap.set("lon", lng); - }); + }, "local"); } }, [yjs.waypoints, yjs.doc], @@ -337,7 +340,9 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr const deleteWaypoint = useCallback( (index: number) => { - yjs.waypoints.delete(index, 1); + yjs.doc.transact(() => { + yjs.waypoints.delete(index, 1); + }, "local"); }, [yjs.waypoints], ); @@ -395,7 +400,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr yMap.set("points", area.points); yjs.noGoAreas.push([yMap]); } - }); + }, "local"); } catch { onImportError?.(t("importGpxError")); } @@ -449,6 +454,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr }, dragstart: () => { waypointDraggingRef.current = true; + yjs.undoManager.stopCapturing(); routeInteractionSuspendedRef.current = true; }, dragend: (e) => { diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 0964f79..6cea400 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -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, +
+ + +
{callbackUrl && callbackToken && ( diff --git a/apps/planner/app/components/WaypointSidebar.tsx b/apps/planner/app/components/WaypointSidebar.tsx index a041581..bc98ac9 100644 --- a/apps/planner/app/components/WaypointSidebar.tsx +++ b/apps/planner/app/components/WaypointSidebar.tsx @@ -36,8 +36,10 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) { }, [yjs.waypoints]); const deleteWaypoint = useCallback( - (index: number) => yjs.waypoints.delete(index, 1), - [yjs.waypoints], + (index: number) => { + yjs.doc.transact(() => yjs.waypoints.delete(index, 1), "local"); + }, + [yjs.doc, yjs.waypoints], ); const moveWaypoint = useCallback( @@ -53,7 +55,7 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) { yMap.set("lon", data.lon); if (data.name) yMap.set("name", data.name); yjs.waypoints.insert(to, [yMap]); - }); + }, "local"); }, [yjs.waypoints, yjs.doc], ); diff --git a/apps/planner/app/lib/use-undo.test.ts b/apps/planner/app/lib/use-undo.test.ts new file mode 100644 index 0000000..0ec0fe0 --- /dev/null +++ b/apps/planner/app/lib/use-undo.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; + +describe("Y.UndoManager with tracked origins", () => { + it("tracks mutations with 'local' origin", () => { + const doc = new Y.Doc(); + const arr = doc.getArray("test"); + const um = new Y.UndoManager([arr], { trackedOrigins: new Set(["local"]) }); + + doc.transact(() => arr.push(["a"]), "local"); + expect(arr.toArray()).toEqual(["a"]); + expect(um.undoStack.length).toBe(1); + + um.undo(); + expect(arr.toArray()).toEqual([]); + expect(um.redoStack.length).toBe(1); + + um.redo(); + expect(arr.toArray()).toEqual(["a"]); + }); + + it("does not track mutations without 'local' origin", () => { + const doc = new Y.Doc(); + const arr = doc.getArray("test"); + const um = new Y.UndoManager([arr], { trackedOrigins: new Set(["local"]) }); + + // No origin + doc.transact(() => arr.push(["init"])); + expect(arr.toArray()).toEqual(["init"]); + expect(um.undoStack.length).toBe(0); + + // Different origin + doc.transact(() => arr.push(["remote"]), "remote"); + expect(arr.toArray()).toEqual(["init", "remote"]); + expect(um.undoStack.length).toBe(0); + }); + + it("groups rapid changes within captureTimeout", async () => { + const doc = new Y.Doc(); + const arr = doc.getArray("test"); + const um = new Y.UndoManager([arr], { + trackedOrigins: new Set(["local"]), + captureTimeout: 100, + }); + + doc.transact(() => arr.push(["a"]), "local"); + doc.transact(() => arr.push(["b"]), "local"); + // Both within timeout — should be one undo step + expect(um.undoStack.length).toBe(1); + + um.undo(); + expect(arr.toArray()).toEqual([]); + }); + + it("stopCapturing separates undo steps", () => { + const doc = new Y.Doc(); + const arr = doc.getArray("test"); + const um = new Y.UndoManager([arr], { + trackedOrigins: new Set(["local"]), + captureTimeout: 10000, + }); + + doc.transact(() => arr.push(["a"]), "local"); + um.stopCapturing(); + doc.transact(() => arr.push(["b"]), "local"); + + expect(um.undoStack.length).toBe(2); + + um.undo(); + expect(arr.toArray()).toEqual(["a"]); + + um.undo(); + expect(arr.toArray()).toEqual([]); + }); + + it("tracks multiple shared types", () => { + const doc = new Y.Doc(); + const waypoints = doc.getArray("waypoints"); + const noGoAreas = doc.getArray("noGoAreas"); + const notes = doc.getText("notes"); + const um = new Y.UndoManager([waypoints, noGoAreas, notes], { + trackedOrigins: new Set(["local"]), + }); + + doc.transact(() => waypoints.push(["wp1"]), "local"); + um.stopCapturing(); + doc.transact(() => notes.insert(0, "hello"), "local"); + um.stopCapturing(); + doc.transact(() => noGoAreas.push(["nogo1"]), "local"); + + expect(um.undoStack.length).toBe(3); + + // Undo in reverse order + um.undo(); // undo noGoAreas + expect(noGoAreas.toArray()).toEqual([]); + expect(notes.toString()).toBe("hello"); + + um.undo(); // undo notes + expect(notes.toString()).toBe(""); + expect(waypoints.toArray()).toEqual(["wp1"]); + + um.undo(); // undo waypoints + expect(waypoints.toArray()).toEqual([]); + }); +}); diff --git a/apps/planner/app/lib/use-undo.ts b/apps/planner/app/lib/use-undo.ts new file mode 100644 index 0000000..f4d1fa3 --- /dev/null +++ b/apps/planner/app/lib/use-undo.ts @@ -0,0 +1,58 @@ +import { useState, useEffect, useCallback } from "react"; +import * as Y from "yjs"; + +export function useUndo(undoManager: Y.UndoManager | null): { canUndo: boolean; canRedo: boolean; undo: () => void; redo: () => void } { + 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]); + + const undo = useCallback(() => undoManager?.undo(), [undoManager]); + const redo = useCallback(() => undoManager?.redo(), [undoManager]); + + return { canUndo, canRedo, undo, redo }; +} + +export function useUndoShortcuts(undoManager: Y.UndoManager | null) { + useEffect(() => { + if (!undoManager) return; + + const handler = (e: KeyboardEvent) => { + // Suppress in text inputs — let browser-native undo handle those + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable) { + return; + } + + const isMac = navigator.platform.includes("Mac"); + const mod = isMac ? e.metaKey : e.ctrlKey; + if (!mod) return; + + if (e.key === "z" && !e.shiftKey) { + e.preventDefault(); + undoManager.undo(); + } else if ((e.key === "z" && e.shiftKey) || e.key === "y") { + e.preventDefault(); + undoManager.redo(); + } + }; + + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [undoManager]); +} diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts index bec224d..95135dd 100644 --- a/apps/planner/app/lib/use-yjs.ts +++ b/apps/planner/app/lib/use-yjs.ts @@ -34,6 +34,7 @@ export interface YjsState { routeData: Y.Map; noGoAreas: Y.Array>; notes: Y.Text; + undoManager: Y.UndoManager; awareness: WebsocketProvider["awareness"]; connected: boolean; setUserName: (name: string) => void; @@ -119,6 +120,11 @@ export function useYjs( }); } + const undoManager = new Y.UndoManager([waypoints, noGoAreas, notes], { + captureTimeout: 500, + trackedOrigins: new Set(["local"]), + }); + const updateState = (connected: boolean) => { setState({ doc, @@ -127,6 +133,7 @@ export function useYjs( routeData, noGoAreas, notes, + undoManager, awareness: provider.awareness, connected, setUserName, @@ -143,6 +150,7 @@ export function useYjs( clearInterval(saveInterval); // Clear localStorage on clean disconnect (session close) try { localStorage.removeItem(storageKey); } catch { /* ignore */ } + undoManager.destroy(); provider.destroy(); doc.destroy(); providerRef.current = null; diff --git a/openspec/changes/undo-redo/.openspec.yaml b/openspec/changes/archive/2026-04-04-undo-redo/.openspec.yaml similarity index 100% rename from openspec/changes/undo-redo/.openspec.yaml rename to openspec/changes/archive/2026-04-04-undo-redo/.openspec.yaml diff --git a/openspec/changes/undo-redo/design.md b/openspec/changes/archive/2026-04-04-undo-redo/design.md similarity index 100% rename from openspec/changes/undo-redo/design.md rename to openspec/changes/archive/2026-04-04-undo-redo/design.md diff --git a/openspec/changes/undo-redo/proposal.md b/openspec/changes/archive/2026-04-04-undo-redo/proposal.md similarity index 100% rename from openspec/changes/undo-redo/proposal.md rename to openspec/changes/archive/2026-04-04-undo-redo/proposal.md diff --git a/openspec/changes/undo-redo/tasks.md b/openspec/changes/archive/2026-04-04-undo-redo/tasks.md similarity index 76% rename from openspec/changes/undo-redo/tasks.md rename to openspec/changes/archive/2026-04-04-undo-redo/tasks.md index bde74b7..5c138d8 100644 --- a/openspec/changes/undo-redo/tasks.md +++ b/openspec/changes/archive/2026-04-04-undo-redo/tasks.md @@ -1,8 +1,8 @@ ## 1. Core: UndoManager Setup -- [ ] 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`) -- [ ] 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. -- [ ] 1.3 Add `"local"` origin to all mutation sites: +- [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 @@ -11,24 +11,24 @@ ## 2. Keyboard Shortcuts -- [ ] 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()`. -- [ ] 2.2 Suppress the shortcut when the active element is ``, `