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
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
105
apps/planner/app/lib/use-undo.test.ts
Normal file
105
apps/planner/app/lib/use-undo.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
58
apps/planner/app/lib/use-undo.ts
Normal file
58
apps/planner/app/lib/use-undo.ts
Normal file
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ export interface YjsState {
|
|||
routeData: Y.Map<unknown>;
|
||||
noGoAreas: Y.Array<Y.Map<unknown>>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 `<input>`, `<textarea>`, or `[contenteditable]` so browser-native undo works in text fields (especially the notes textarea).
|
||||
- [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
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] 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).
|
||||
- [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
|
||||
|
||||
- [ ] 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.
|
||||
- [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
|
||||
|
||||
- [ ] 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.
|
||||
- [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
|
||||
|
||||
- [ ] 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).
|
||||
- [ ] 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.
|
||||
- [ ] 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).
|
||||
- [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).
|
||||
|
|
@ -22,6 +22,8 @@ export default {
|
|||
exportRouteDesc: "GPX-Track für jede App",
|
||||
exportPlan: "Plan exportieren",
|
||||
exportPlanDesc: "Mit Wegpunkten und Sperrzonen",
|
||||
"undo.tooltip": "Rückgängig (Strg+Z)",
|
||||
"redo.tooltip": "Wiederholen (Strg+Umschalt+Z)",
|
||||
importGpx: "GPX importieren",
|
||||
importGpxError: "GPX-Datei konnte nicht gelesen werden. Bitte Dateiformat prüfen.",
|
||||
dropGpxHere: "GPX-Datei hier ablegen",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ export default {
|
|||
exportRouteDesc: "Clean GPX track for any app",
|
||||
exportPlan: "Export Plan",
|
||||
exportPlanDesc: "Includes waypoints and no-go areas",
|
||||
"undo.tooltip": "Undo (Ctrl+Z)",
|
||||
"redo.tooltip": "Redo (Ctrl+Shift+Z)",
|
||||
importGpx: "Import GPX",
|
||||
importGpxError: "Could not read GPX file. Please check the file format.",
|
||||
dropGpxHere: "Drop GPX file here",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue