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

@ -47,7 +47,7 @@ export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLay
polygon.on("contextmenu", (e) => { polygon.on("contextmenu", (e) => {
L.DomEvent.preventDefault(e as unknown as Event); L.DomEvent.preventDefault(e as unknown as Event);
suppressObserverRef.current = true; suppressObserverRef.current = true;
noGoAreas.delete(i, 1); doc.transact(() => noGoAreas.delete(i, 1), "local");
suppressObserverRef.current = false; suppressObserverRef.current = false;
syncLayers(); syncLayers();
}); });
@ -101,7 +101,7 @@ export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLay
yMap.set("points", points); yMap.set("points", points);
doc.transact(() => { doc.transact(() => {
noGoAreas.push([yMap]); noGoAreas.push([yMap]);
}); }, "local");
// Exit draw mode // Exit draw mode
onToggle(); onToggle();

View file

@ -42,7 +42,7 @@ export function NotesPanel({ yjs }: NotesPanelProps) {
yjs.doc.transact(() => { yjs.doc.transact(() => {
yjs.notes.delete(0, yjs.notes.length); yjs.notes.delete(0, yjs.notes.length);
yjs.notes.insert(0, newValue); yjs.notes.insert(0, newValue);
}); }, "local");
isLocalChange.current = false; isLocalChange.current = false;
}, },
[yjs.notes, yjs.doc], [yjs.notes, yjs.doc],

View file

@ -294,23 +294,26 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
const addWaypoint = useCallback( const addWaypoint = useCallback(
(lat: number, lng: number) => { (lat: number, lng: number) => {
const yMap = new Y.Map(); yjs.doc.transact(() => {
yMap.set("lat", lat); const yMap = new Y.Map();
yMap.set("lon", lng); yMap.set("lat", lat);
yjs.waypoints.push([yMap]); yMap.set("lon", lng);
yjs.waypoints.push([yMap]);
}, "local");
}, },
[yjs.waypoints], [yjs.doc, yjs.waypoints],
); );
const insertWaypointAtSegment = useCallback( const insertWaypointAtSegment = useCallback(
(segmentIndex: number, lat: number, lon: number) => { (segmentIndex: number, lat: number, lon: number) => {
const yMap = new Y.Map(); yjs.doc.transact(() => {
yMap.set("lat", lat); const yMap = new Y.Map();
yMap.set("lon", lon); yMap.set("lat", lat);
// Insert after the segment's start waypoint yMap.set("lon", lon);
yjs.waypoints.insert(segmentIndex + 1, [yMap]); yjs.waypoints.insert(segmentIndex + 1, [yMap]);
}, "local");
}, },
[yjs.waypoints], [yjs.doc, yjs.waypoints],
); );
const handleRouteInsert = useCallback( const handleRouteInsert = useCallback(
@ -329,7 +332,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
yjs.doc.transact(() => { yjs.doc.transact(() => {
yMap.set("lat", lat); yMap.set("lat", lat);
yMap.set("lon", lng); yMap.set("lon", lng);
}); }, "local");
} }
}, },
[yjs.waypoints, yjs.doc], [yjs.waypoints, yjs.doc],
@ -337,7 +340,9 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
const deleteWaypoint = useCallback( const deleteWaypoint = useCallback(
(index: number) => { (index: number) => {
yjs.waypoints.delete(index, 1); yjs.doc.transact(() => {
yjs.waypoints.delete(index, 1);
}, "local");
}, },
[yjs.waypoints], [yjs.waypoints],
); );
@ -395,7 +400,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
yMap.set("points", area.points); yMap.set("points", area.points);
yjs.noGoAreas.push([yMap]); yjs.noGoAreas.push([yMap]);
} }
}); }, "local");
} catch { } catch {
onImportError?.(t("importGpxError")); onImportError?.(t("importGpxError"));
} }
@ -449,6 +454,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
}, },
dragstart: () => { dragstart: () => {
waypointDraggingRef.current = true; waypointDraggingRef.current = true;
yjs.undoManager.stopCapturing();
routeInteractionSuspendedRef.current = true; routeInteractionSuspendedRef.current = true;
}, },
dragend: (e) => { dragend: (e) => {

View file

@ -5,6 +5,7 @@ import type { TFunction } from "i18next";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { useYjs, type YjsState } from "~/lib/use-yjs"; import { useYjs, type YjsState } from "~/lib/use-yjs";
import { useRouting, type RouteError } from "~/lib/use-routing"; import { useRouting, type RouteError } from "~/lib/use-routing";
import { useUndo, useUndoShortcuts } from "~/lib/use-undo";
import { ProfileSelector } from "~/components/ProfileSelector"; import { ProfileSelector } from "~/components/ProfileSelector";
import { ExportButton } from "~/components/ExportButton"; import { ExportButton } from "~/components/ExportButton";
import { SaveToJournalButton } from "~/components/SaveToJournalButton"; import { SaveToJournalButton } from "~/components/SaveToJournalButton";
@ -187,6 +188,8 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]); useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]);
const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas); const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas);
const { computing, routeError, routeStats, requestRoute } = useRouting(yjs); 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 [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
const { toasts, addToast } = useToasts(); const { toasts, addToast } = useToasts();
useAwarenessToasts(yjs, t, addToast); useAwarenessToasts(yjs, t, addToast);
@ -225,6 +228,24 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
</Link> </Link>
<ProfileSelector yjs={yjs} /> <ProfileSelector yjs={yjs} />
<ParticipantList 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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{callbackUrl && callbackToken && ( {callbackUrl && callbackToken && (

View file

@ -36,8 +36,10 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
}, [yjs.waypoints]); }, [yjs.waypoints]);
const deleteWaypoint = useCallback( const deleteWaypoint = useCallback(
(index: number) => yjs.waypoints.delete(index, 1), (index: number) => {
[yjs.waypoints], yjs.doc.transact(() => yjs.waypoints.delete(index, 1), "local");
},
[yjs.doc, yjs.waypoints],
); );
const moveWaypoint = useCallback( const moveWaypoint = useCallback(
@ -53,7 +55,7 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
yMap.set("lon", data.lon); yMap.set("lon", data.lon);
if (data.name) yMap.set("name", data.name); if (data.name) yMap.set("name", data.name);
yjs.waypoints.insert(to, [yMap]); yjs.waypoints.insert(to, [yMap]);
}); }, "local");
}, },
[yjs.waypoints, yjs.doc], [yjs.waypoints, yjs.doc],
); );

View 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([]);
});
});

View 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]);
}

View file

@ -34,6 +34,7 @@ export interface YjsState {
routeData: Y.Map<unknown>; routeData: Y.Map<unknown>;
noGoAreas: Y.Array<Y.Map<unknown>>; noGoAreas: Y.Array<Y.Map<unknown>>;
notes: Y.Text; notes: Y.Text;
undoManager: Y.UndoManager;
awareness: WebsocketProvider["awareness"]; awareness: WebsocketProvider["awareness"];
connected: boolean; connected: boolean;
setUserName: (name: string) => void; 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) => { const updateState = (connected: boolean) => {
setState({ setState({
doc, doc,
@ -127,6 +133,7 @@ export function useYjs(
routeData, routeData,
noGoAreas, noGoAreas,
notes, notes,
undoManager,
awareness: provider.awareness, awareness: provider.awareness,
connected, connected,
setUserName, setUserName,
@ -143,6 +150,7 @@ export function useYjs(
clearInterval(saveInterval); clearInterval(saveInterval);
// Clear localStorage on clean disconnect (session close) // Clear localStorage on clean disconnect (session close)
try { localStorage.removeItem(storageKey); } catch { /* ignore */ } try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
undoManager.destroy();
provider.destroy(); provider.destroy();
doc.destroy(); doc.destroy();
providerRef.current = null; providerRef.current = null;

View file

@ -1,8 +1,8 @@
## 1. Core: UndoManager Setup ## 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`) - [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`)
- [ ] 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.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.3 Add `"local"` origin to all mutation sites:
- `PlannerMap.tsx`: wrap `addWaypoint`, `insertWaypointAtSegment`, `moveWaypoint`, `deleteWaypoint` in `doc.transact(() => { ... }, "local")` - `PlannerMap.tsx`: wrap `addWaypoint`, `insertWaypointAtSegment`, `moveWaypoint`, `deleteWaypoint` in `doc.transact(() => { ... }, "local")`
- `WaypointSidebar.tsx`: wrap `deleteWaypoint` and `moveWaypoint` 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 - `NoGoAreaLayer.tsx`: add `"local"` origin to the `pm:create` and contextmenu delete transactions
@ -11,24 +11,24 @@
## 2. Keyboard Shortcuts ## 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()`. - [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()`.
- [ ] 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.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. 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. - [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.
- [ ] 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.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. 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. 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. 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). - [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).
- [ ] 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.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.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).

View file

@ -22,6 +22,8 @@ export default {
exportRouteDesc: "GPX-Track für jede App", exportRouteDesc: "GPX-Track für jede App",
exportPlan: "Plan exportieren", exportPlan: "Plan exportieren",
exportPlanDesc: "Mit Wegpunkten und Sperrzonen", exportPlanDesc: "Mit Wegpunkten und Sperrzonen",
"undo.tooltip": "Rückgängig (Strg+Z)",
"redo.tooltip": "Wiederholen (Strg+Umschalt+Z)",
importGpx: "GPX importieren", importGpx: "GPX importieren",
importGpxError: "GPX-Datei konnte nicht gelesen werden. Bitte Dateiformat prüfen.", importGpxError: "GPX-Datei konnte nicht gelesen werden. Bitte Dateiformat prüfen.",
dropGpxHere: "GPX-Datei hier ablegen", dropGpxHere: "GPX-Datei hier ablegen",

View file

@ -22,6 +22,8 @@ export default {
exportRouteDesc: "Clean GPX track for any app", exportRouteDesc: "Clean GPX track for any app",
exportPlan: "Export Plan", exportPlan: "Export Plan",
exportPlanDesc: "Includes waypoints and no-go areas", exportPlanDesc: "Includes waypoints and no-go areas",
"undo.tooltip": "Undo (Ctrl+Z)",
"redo.tooltip": "Redo (Ctrl+Shift+Z)",
importGpx: "Import GPX", importGpx: "Import GPX",
importGpxError: "Could not read GPX file. Please check the file format.", importGpxError: "Could not read GPX file. Please check the file format.",
dropGpxHere: "Drop GPX file here", dropGpxHere: "Drop GPX file here",