mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-09-02 16:52:31 +00:00
functional undo-redo
This commit is contained in:
16
website/package-lock.json
generated
16
website/package-lock.json
generated
@@ -13,11 +13,11 @@
|
|||||||
"chart.js": "^4.4.2",
|
"chart.js": "^4.4.2",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"gpx": "file:../gpx",
|
"gpx": "file:../gpx",
|
||||||
"immer": "^10.1.1",
|
|
||||||
"lucide-svelte": "^0.365.0",
|
"lucide-svelte": "^0.365.0",
|
||||||
"mapbox-gl": "^3.2.0",
|
"mapbox-gl": "^3.2.0",
|
||||||
"mode-watcher": "^0.3.0",
|
"mode-watcher": "^0.3.0",
|
||||||
"sortablejs": "^1.15.2",
|
"sortablejs": "^1.15.2",
|
||||||
|
"structurajs": "^0.12.0",
|
||||||
"svelte-i18n": "^4.0.0",
|
"svelte-i18n": "^4.0.0",
|
||||||
"svelte-sonner": "^0.3.22",
|
"svelte-sonner": "^0.3.22",
|
||||||
"tailwind-merge": "^2.2.2",
|
"tailwind-merge": "^2.2.2",
|
||||||
@@ -3357,15 +3357,6 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||||
@@ -5135,6 +5126,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/subtag": {
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/subtag/-/subtag-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/subtag/-/subtag-0.5.0.tgz",
|
||||||
|
@@ -46,11 +46,11 @@
|
|||||||
"chart.js": "^4.4.2",
|
"chart.js": "^4.4.2",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"gpx": "file:../gpx",
|
"gpx": "file:../gpx",
|
||||||
"immer": "^10.1.1",
|
|
||||||
"lucide-svelte": "^0.365.0",
|
"lucide-svelte": "^0.365.0",
|
||||||
"mapbox-gl": "^3.2.0",
|
"mapbox-gl": "^3.2.0",
|
||||||
"mode-watcher": "^0.3.0",
|
"mode-watcher": "^0.3.0",
|
||||||
"sortablejs": "^1.15.2",
|
"sortablejs": "^1.15.2",
|
||||||
|
"structurajs": "^0.12.0",
|
||||||
"svelte-i18n": "^4.0.0",
|
"svelte-i18n": "^4.0.0",
|
||||||
"svelte-sonner": "^0.3.22",
|
"svelte-sonner": "^0.3.22",
|
||||||
"tailwind-merge": "^2.2.2",
|
"tailwind-merge": "^2.2.2",
|
||||||
|
@@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import type { GPXFile } from 'gpx';
|
import type { GPXFile } from 'gpx';
|
||||||
import type { Immutable } from 'immer';
|
import type { FreezedObject } from 'structurajs';
|
||||||
|
|
||||||
export let file: Writable<Immutable<GPXFile>> | undefined;
|
export let file: Writable<FreezedObject<GPXFile>> | undefined;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
@@ -39,6 +39,10 @@
|
|||||||
setMode($settings.mode);
|
setMode($settings.mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let undoRedo = filestore.undoRedo;
|
||||||
|
let undoDisabled = derived(undoRedo, ($undoRedo) => !$undoRedo.canUndo);
|
||||||
|
let redoDisabled = derived(undoRedo, ($undoRedo) => !$undoRedo.canRedo);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
|
<div class="absolute top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
|
||||||
@@ -90,12 +94,12 @@
|
|||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger>{$_('menu.edit')}</Menubar.Trigger>
|
<Menubar.Trigger>{$_('menu.edit')}</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Item>
|
<Menubar.Item on:click={$undoRedo.undo} disabled={$undoDisabled}>
|
||||||
<Undo2 size="16" class="mr-1" />
|
<Undo2 size="16" class="mr-1" />
|
||||||
{$_('menu.undo')}
|
{$_('menu.undo')}
|
||||||
<Shortcut key="Z" ctrl={true} />
|
<Shortcut key="Z" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item>
|
<Menubar.Item on:click={$undoRedo.redo} disabled={$redoDisabled}>
|
||||||
<Redo2 size="16" class="mr-1" />
|
<Redo2 size="16" class="mr-1" />
|
||||||
{$_('menu.redo')}
|
{$_('menu.redo')}
|
||||||
<Shortcut key="Z" ctrl={true} shift={true} />
|
<Shortcut key="Z" ctrl={true} shift={true} />
|
||||||
@@ -211,6 +215,12 @@
|
|||||||
exportSelectedFiles();
|
exportSelectedFiles();
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
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)) {
|
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
filestore.deleteAllFiles();
|
filestore.deleteAllFiles();
|
||||||
|
@@ -26,13 +26,19 @@
|
|||||||
// remove controls for deleted files
|
// remove controls for deleted files
|
||||||
routingControls.forEach((controls, fileId) => {
|
routingControls.forEach((controls, fileId) => {
|
||||||
if (!get(filestore).find((file) => file._data.id === fileId)) {
|
if (!get(filestore).find((file) => file._data.id === fileId)) {
|
||||||
|
console.log('remove controls for deleted file', fileId);
|
||||||
controls.remove();
|
controls.remove();
|
||||||
routingControls.delete(fileId);
|
routingControls.delete(fileId);
|
||||||
|
|
||||||
|
if (selectedId === fileId) {
|
||||||
|
selectedId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($map && $selectedFiles) {
|
$: if ($map && $selectedFiles) {
|
||||||
|
console.log('selectedFiles', $selectedFiles);
|
||||||
// update selected file
|
// update selected file
|
||||||
if ($selectedFiles.size == 0 || $selectedFiles.size > 1 || !active) {
|
if ($selectedFiles.size == 0 || $selectedFiles.size > 1 || !active) {
|
||||||
if (selectedId) {
|
if (selectedId) {
|
||||||
@@ -48,12 +54,14 @@
|
|||||||
selectedId = newSelectedId;
|
selectedId = newSelectedId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log('selectedId', selectedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($map && selectedId) {
|
$: if ($map && selectedId) {
|
||||||
if (!routingControls.has(selectedId)) {
|
if (!routingControls.has(selectedId)) {
|
||||||
let selectedFileStore = filestore.getFileStore(selectedId);
|
let selectedFileStore = filestore.getFileStore(selectedId);
|
||||||
if (selectedFileStore) {
|
if (selectedFileStore) {
|
||||||
|
console.log('add controls for selected file', selectedId);
|
||||||
routingControls.set(
|
routingControls.set(
|
||||||
selectedId,
|
selectedId,
|
||||||
new RoutingControls(get(map), selectedFileStore, popup, popupElement)
|
new RoutingControls(get(map), selectedFileStore, popup, popupElement)
|
||||||
|
@@ -1,12 +1,17 @@
|
|||||||
import { writable, get, type Readable, type Writable } from "svelte/store";
|
import { writable, get, type Readable, type Writable } from "svelte/store";
|
||||||
import { GPXFile } from "gpx";
|
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";
|
import { fileOrder, selectedFiles } from "./stores";
|
||||||
|
|
||||||
enableMapSet();
|
export type UndoRedoStore = {
|
||||||
enablePatches();
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export type GPXFileStore = Readable<GPXFile[]> & {
|
export type GPXFileStore = Readable<GPXFile[]> & {
|
||||||
|
undoRedo: Writable<UndoRedoStore>;
|
||||||
add: (file: GPXFile) => void;
|
add: (file: GPXFile) => void;
|
||||||
addMultiple: (files: GPXFile[]) => void;
|
addMultiple: (files: GPXFile[]) => void;
|
||||||
applyToFile: (id: string, callback: (file: GPXFile) => void) => void;
|
applyToFile: (id: string, callback: (file: GPXFile) => void) => void;
|
||||||
@@ -14,17 +19,13 @@ export type GPXFileStore = Readable<GPXFile[]> & {
|
|||||||
duplicateSelectedFiles: () => void;
|
duplicateSelectedFiles: () => void;
|
||||||
deleteSelectedFiles: () => void;
|
deleteSelectedFiles: () => void;
|
||||||
deleteAllFiles: () => void;
|
deleteAllFiles: () => void;
|
||||||
getFileStore: (id: string) => Writable<Immutable<GPXFile>> | undefined;
|
getFileStore: (id: string) => Writable<FreezedObject<GPXFile>> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGPXFileStore(): GPXFileStore {
|
export function createGPXFileStore(): GPXFileStore {
|
||||||
let files: Immutable<Map<string, GPXFile>> = new Map();
|
let files: ReadonlyMap<string, FreezedObject<GPXFile>> = new Map();
|
||||||
let subscribers: Set<Function> = new Set();
|
let subscribers: Set<Function> = new Set();
|
||||||
|
|
||||||
let filestores = new Map<string, Writable<Immutable<GPXFile>>>();
|
|
||||||
|
|
||||||
let patches = [];
|
|
||||||
|
|
||||||
function notifySubscriber(run: Function) {
|
function notifySubscriber(run: Function) {
|
||||||
run(Array.from(files.values()));
|
run(Array.from(files.values()));
|
||||||
}
|
}
|
||||||
@@ -35,33 +36,79 @@ export function createGPXFileStore(): GPXFileStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let filestores = new Map<string, Writable<FreezedObject<GPXFile>>>();
|
||||||
|
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<UndoRedoStore> = 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<string, GPXFile>) => void) {
|
function applyToGlobalStore(callback: (files: Map<string, GPXFile>) => void) {
|
||||||
const [newFiles, patch, inversePatch] = produceWithPatches(files, callback);
|
const [newFiles, patch, inversePatch] = produceWithPatches(files, callback);
|
||||||
files = newFiles;
|
files = newFiles;
|
||||||
patches.push({
|
appendPatches(patch, inversePatch, true);
|
||||||
patch,
|
|
||||||
inversePatch,
|
|
||||||
global: true
|
|
||||||
});
|
|
||||||
console.log(patches[patches.length - 1]);
|
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyToFiles(fileIds: string[], callback: (file: GPXFile) => void) {
|
function applyToFiles(fileIds: string[], callback: (file: UnFreezedObject<FreezedObject<GPXFile>>) => void) {
|
||||||
const [newFiles, patch, inversePatch] = produceWithPatches(files, (draft) => {
|
const [newFiles, patch, inversePatch] = produceWithPatches(files, (draft) => {
|
||||||
fileIds.forEach((fileId) => {
|
fileIds.forEach((fileId) => {
|
||||||
if (draft.has(fileId)) {
|
callback(draft.get(fileId));
|
||||||
callback(draft.get(fileId));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
files = newFiles;
|
files = newFiles;
|
||||||
patches.push({
|
appendPatches(patch, inversePatch, false);
|
||||||
patch,
|
|
||||||
inversePatch,
|
|
||||||
global: false
|
|
||||||
});
|
|
||||||
console.log(patches[patches.length - 1]);
|
|
||||||
fileIds.forEach((fileId) => {
|
fileIds.forEach((fileId) => {
|
||||||
let filestore = filestores.get(fileId), newFile = newFiles.get(fileId);
|
let filestore = filestores.get(fileId), newFile = newFiles.get(fileId);
|
||||||
if (filestore && newFile) {
|
if (filestore && newFile) {
|
||||||
@@ -93,6 +140,7 @@ export function createGPXFileStore(): GPXFileStore {
|
|||||||
subscribers.delete(run);
|
subscribers.delete(run);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
undoRedo,
|
||||||
add: (file: GPXFile) => {
|
add: (file: GPXFile) => {
|
||||||
file._data.id = getLayerId();
|
file._data.id = getLayerId();
|
||||||
applyToGlobalStore((draft) => {
|
applyToGlobalStore((draft) => {
|
||||||
|
@@ -14,6 +14,22 @@ export const fileOrder = writable<string[]>([]);
|
|||||||
export const selectedFiles = writable<Set<string>>(new Set());
|
export const selectedFiles = writable<Set<string>>(new Set());
|
||||||
export const selectFiles = writable<{ [key: string]: (fileId?: string) => void }>({});
|
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());
|
export const gpxData = writable(new GPXFiles([]).getTrackPointsAndStatistics());
|
||||||
|
|
||||||
function updateGPXData() {
|
function updateGPXData() {
|
||||||
|
Reference in New Issue
Block a user