diff --git a/website/package-lock.json b/website/package-lock.json index 4a123af3..a8256e90 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -13,11 +13,11 @@ "chart.js": "^4.4.2", "clsx": "^2.1.0", "gpx": "file:../gpx", - "immer": "^10.1.1", "lucide-svelte": "^0.365.0", "mapbox-gl": "^3.2.0", "mode-watcher": "^0.3.0", "sortablejs": "^1.15.2", + "structurajs": "^0.12.0", "svelte-i18n": "^4.0.0", "svelte-sonner": "^0.3.22", "tailwind-merge": "^2.2.2", @@ -3357,15 +3357,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -5135,6 +5126,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/structurajs": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/structurajs/-/structurajs-0.12.0.tgz", + "integrity": "sha512-vadDl3zCv6OM2dXfzelUH3RLhNu9pmIEZU7zS0jIgMR3BDKIk44fDd/X9KK9LE27MMU+/aw3ibBTQQgBH0zfiw==" + }, "node_modules/subtag": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/subtag/-/subtag-0.5.0.tgz", diff --git a/website/package.json b/website/package.json index f5d37cbe..cce37183 100644 --- a/website/package.json +++ b/website/package.json @@ -46,11 +46,11 @@ "chart.js": "^4.4.2", "clsx": "^2.1.0", "gpx": "file:../gpx", - "immer": "^10.1.1", "lucide-svelte": "^0.365.0", "mapbox-gl": "^3.2.0", "mode-watcher": "^0.3.0", "sortablejs": "^1.15.2", + "structurajs": "^0.12.0", "svelte-i18n": "^4.0.0", "svelte-sonner": "^0.3.22", "tailwind-merge": "^2.2.2", diff --git a/website/src/lib/components/FileListItem.svelte b/website/src/lib/components/FileListItem.svelte index 84b2b0cf..81f41aea 100644 --- a/website/src/lib/components/FileListItem.svelte +++ b/website/src/lib/components/FileListItem.svelte @@ -9,9 +9,9 @@ import { _ } from 'svelte-i18n'; import type { GPXFile } from 'gpx'; - import type { Immutable } from 'immer'; + import type { FreezedObject } from 'structurajs'; - export let file: Writable> | undefined; + export let file: Writable> | undefined; diff --git a/website/src/lib/components/Menu.svelte b/website/src/lib/components/Menu.svelte index 58be4f6e..c72d69c8 100644 --- a/website/src/lib/components/Menu.svelte +++ b/website/src/lib/components/Menu.svelte @@ -39,6 +39,10 @@ setMode($settings.mode); } } + + let undoRedo = filestore.undoRedo; + let undoDisabled = derived(undoRedo, ($undoRedo) => !$undoRedo.canUndo); + let redoDisabled = derived(undoRedo, ($undoRedo) => !$undoRedo.canRedo);
@@ -90,12 +94,12 @@ {$_('menu.edit')} - + {$_('menu.undo')} - + {$_('menu.redo')} @@ -211,6 +215,12 @@ exportSelectedFiles(); } e.preventDefault(); + } else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) { + if (e.shiftKey) { + $undoRedo.redo(); + } else { + $undoRedo.undo(); + } } else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) { if (e.shiftKey) { filestore.deleteAllFiles(); diff --git a/website/src/lib/components/toolbar/tools/routing/Routing.svelte b/website/src/lib/components/toolbar/tools/routing/Routing.svelte index 6fa81022..a59820c1 100644 --- a/website/src/lib/components/toolbar/tools/routing/Routing.svelte +++ b/website/src/lib/components/toolbar/tools/routing/Routing.svelte @@ -26,13 +26,19 @@ // remove controls for deleted files routingControls.forEach((controls, fileId) => { if (!get(filestore).find((file) => file._data.id === fileId)) { + console.log('remove controls for deleted file', fileId); controls.remove(); routingControls.delete(fileId); + + if (selectedId === fileId) { + selectedId = null; + } } }); } $: if ($map && $selectedFiles) { + console.log('selectedFiles', $selectedFiles); // update selected file if ($selectedFiles.size == 0 || $selectedFiles.size > 1 || !active) { if (selectedId) { @@ -48,12 +54,14 @@ selectedId = newSelectedId; } } + console.log('selectedId', selectedId); } $: if ($map && selectedId) { if (!routingControls.has(selectedId)) { let selectedFileStore = filestore.getFileStore(selectedId); if (selectedFileStore) { + console.log('add controls for selected file', selectedId); routingControls.set( selectedId, new RoutingControls(get(map), selectedFileStore, popup, popupElement) diff --git a/website/src/lib/filestore.ts b/website/src/lib/filestore.ts index e6d7d0d0..fa694244 100644 --- a/website/src/lib/filestore.ts +++ b/website/src/lib/filestore.ts @@ -1,12 +1,17 @@ import { writable, get, type Readable, type Writable } from "svelte/store"; import { GPXFile } from "gpx"; -import { produceWithPatches, enableMapSet, enablePatches, type Immutable } from "immer"; +import { produceWithPatches, type FreezedObject, type UnFreezedObject, applyPatches, type Patch } from "structurajs"; import { fileOrder, selectedFiles } from "./stores"; -enableMapSet(); -enablePatches(); +export type UndoRedoStore = { + canUndo: boolean; + canRedo: boolean; + undo: () => void; + redo: () => void; +} export type GPXFileStore = Readable & { + undoRedo: Writable; add: (file: GPXFile) => void; addMultiple: (files: GPXFile[]) => void; applyToFile: (id: string, callback: (file: GPXFile) => void) => void; @@ -14,17 +19,13 @@ export type GPXFileStore = Readable & { duplicateSelectedFiles: () => void; deleteSelectedFiles: () => void; deleteAllFiles: () => void; - getFileStore: (id: string) => Writable> | undefined; + getFileStore: (id: string) => Writable> | undefined; } export function createGPXFileStore(): GPXFileStore { - let files: Immutable> = new Map(); + let files: ReadonlyMap> = new Map(); let subscribers: Set = new Set(); - let filestores = new Map>>(); - - let patches = []; - function notifySubscriber(run: Function) { run(Array.from(files.values())); } @@ -35,33 +36,79 @@ export function createGPXFileStore(): GPXFileStore { }); } + let filestores = new Map>>(); + let patches: { patch: Patch[], inversePatch: Patch[], global: boolean }[] = []; + let patchIndex = -1; + + function updateUndoRedo() { + undoRedo.update($undoRedo => { + $undoRedo.canUndo = patchIndex >= 0; + $undoRedo.canRedo = patchIndex < patches.length - 1; + return $undoRedo; + }); + } + + function appendPatches(patch: Patch[], inversePatch: Patch[], global: boolean) { + patches = patches.slice(0, patchIndex + 1); + patches.push({ + patch, + inversePatch, + global + }); + patchIndex++; + updateUndoRedo(); + } + + let undoRedo: Writable = writable({ + canUndo: false, + canRedo: false, + undo: () => { + if (patchIndex >= 0) { + applyPatch(patches[patchIndex].inversePatch, patches[patchIndex].global); + patchIndex--; + } + }, + redo: () => { + if (patchIndex < patches.length - 1) { + patchIndex++; + applyPatch(patches[patchIndex].patch, patches[patchIndex].global); + } + }, + }); + + function applyPatch(patch: Patch[], global: boolean) { + files = applyPatches(files, patch); + for (let p of patch) { + let fileId = p.p?.toString(); + if (fileId) { + let filestore = filestores.get(fileId), newFile = files.get(fileId); + if (filestore && newFile) { + filestore.set(newFile); + } + } + } + if (global) { + console.log("Global patch", patch); + notify(); + } + updateUndoRedo(); + } + function applyToGlobalStore(callback: (files: Map) => void) { const [newFiles, patch, inversePatch] = produceWithPatches(files, callback); files = newFiles; - patches.push({ - patch, - inversePatch, - global: true - }); - console.log(patches[patches.length - 1]); + appendPatches(patch, inversePatch, true); notify(); } - function applyToFiles(fileIds: string[], callback: (file: GPXFile) => void) { + function applyToFiles(fileIds: string[], callback: (file: UnFreezedObject>) => void) { const [newFiles, patch, inversePatch] = produceWithPatches(files, (draft) => { fileIds.forEach((fileId) => { - if (draft.has(fileId)) { - callback(draft.get(fileId)); - } + callback(draft.get(fileId)); }); }); files = newFiles; - patches.push({ - patch, - inversePatch, - global: false - }); - console.log(patches[patches.length - 1]); + appendPatches(patch, inversePatch, false); fileIds.forEach((fileId) => { let filestore = filestores.get(fileId), newFile = newFiles.get(fileId); if (filestore && newFile) { @@ -93,6 +140,7 @@ export function createGPXFileStore(): GPXFileStore { subscribers.delete(run); } }, + undoRedo, add: (file: GPXFile) => { file._data.id = getLayerId(); applyToGlobalStore((draft) => { diff --git a/website/src/lib/stores.ts b/website/src/lib/stores.ts index 85c9b6ac..68411d1e 100644 --- a/website/src/lib/stores.ts +++ b/website/src/lib/stores.ts @@ -14,6 +14,22 @@ export const fileOrder = writable([]); export const selectedFiles = writable>(new Set()); export const selectFiles = writable<{ [key: string]: (fileId?: string) => void }>({}); +filestore.subscribe((files) => { // Update selectedFiles if a file is deleted + let deletedFileIds: string[] = []; + get(selectedFiles).forEach((fileId) => { + if (!files.find((f) => f._data.id === fileId)) { + deletedFileIds.push(fileId); + } + }); + + if (deletedFileIds.length > 0) { + selectedFiles.update((selectedFiles) => { + deletedFileIds.forEach((fileId) => selectedFiles.delete(fileId)); + return selectedFiles; + }); + } +}); + export const gpxData = writable(new GPXFiles([]).getTrackPointsAndStatistics()); function updateGPXData() {