This commit is contained in:
vcoppe
2025-10-17 23:54:45 +02:00
parent 0733562c0d
commit a73da0d81d
62 changed files with 1343 additions and 1162 deletions

View File

@@ -2,37 +2,70 @@ import { db, type Database } from '$lib/db';
import { liveQuery } from 'dexie';
import type { GPXFile } from 'gpx';
import { applyPatches, produceWithPatches, type Patch, type WritableDraft } from 'immer';
import { fileStateCollection, type GPXFileStateCollection } from '$lib/logic/file-state.svelte';
import {
fileStateCollection,
GPXFileStateCollectionObserver,
type GPXFileStateCollection,
} from '$lib/logic/file-state';
import {
derived,
get,
writable,
type Readable,
type Unsubscriber,
type Writable,
} from 'svelte/store';
const MAX_PATCHES = 100;
export class FileActionManager {
private _db: Database;
private _files: Map<string, GPXFile>;
private _patchIndex: number;
private _patchMinIndex: number;
private _patchMaxIndex: number;
private _fileSubscriptions: Map<string, Unsubscriber>;
private _fileStateCollectionObserver: GPXFileStateCollectionObserver;
private _patchIndex: Writable<number>;
private _patchMinIndex: Writable<number>;
private _patchMaxIndex: Writable<number>;
private _canUndo: Readable<boolean>;
private _canRedo: Readable<boolean>;
constructor(db: Database, fileStateCollection: GPXFileStateCollection) {
this._db = db;
this._files = $derived.by(() => {
let files = new Map<string, GPXFile>();
fileStateCollection.files.forEach((state, id) => {
if (state.file) {
files.set(id, state.file);
this._files = new Map();
this._fileSubscriptions = new Map();
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
(fileId, fileState) => {
this._fileSubscriptions.set(
fileId,
fileState.subscribe((fileWithStatistics) => {
if (fileWithStatistics) {
this._files.set(fileId, fileWithStatistics.file);
}
})
);
},
(fileId) => {
let unsubscribe = this._fileSubscriptions.get(fileId);
if (unsubscribe) {
unsubscribe();
this._fileSubscriptions.delete(fileId);
}
});
return files;
});
this._files.delete(fileId);
},
() => {
this._fileSubscriptions.forEach((unsubscribe) => unsubscribe());
this._fileSubscriptions.clear();
this._files.clear();
}
);
this._patchIndex = $state(-1);
this._patchMinIndex = $state(0);
this._patchMaxIndex = $state(0);
this._patchIndex = writable(-1);
this._patchMinIndex = writable(0);
this._patchMaxIndex = writable(0);
liveQuery(() => db.settings.get('patchIndex')).subscribe((value) => {
if (value !== undefined) {
this._patchIndex = value;
this._patchIndex.set(value);
}
});
liveQuery(() =>
@@ -44,58 +77,51 @@ export class FileActionManager {
}
})
).subscribe((value) => {
this._patchMinIndex = value.min;
this._patchMaxIndex = value.max;
this._patchMinIndex.set(value.min);
this._patchMaxIndex.set(value.max);
});
this._canUndo = derived(
[this._patchIndex, this._patchMinIndex],
([$patchIndex, $patchMinIndex]) => {
return $patchIndex >= $patchMinIndex;
}
);
this._canRedo = derived(
[this._patchIndex, this._patchMaxIndex],
([$patchIndex, $patchMaxIndex]) => {
return $patchIndex < $patchMaxIndex - 1;
}
);
}
async store(patch: Patch[], inversePatch: Patch[]) {
this._db.patches.where(':id').above(this._patchIndex).delete(); // Delete all patches after the current patch to avoid redoing them
if (this._patchMaxIndex - this._patchMinIndex + 1 > MAX_PATCHES) {
this._db.patches
.where(':id')
.belowOrEqual(this._patchMaxIndex - MAX_PATCHES)
.delete();
}
this._db.transaction('rw', this._db.patches, this._db.settings, async () => {
let index = this._patchIndex + 1;
await this._db.patches.put(
{
patch,
inversePatch,
index,
},
index
);
await this._db.settings.put(index, 'patchIndex');
});
get canUndo(): Readable<boolean> {
return this._canUndo;
}
get canUndo(): boolean {
return this._patchIndex >= this._patchMinIndex;
}
get canRedo(): boolean {
return this._patchIndex < this._patchMaxIndex - 1;
get canRedo(): Readable<boolean> {
return this._canRedo;
}
undo() {
if (this.canUndo) {
this._db.patches.get(this._patchIndex).then((patch) => {
if (get(this.canUndo)) {
const patchIndex = get(this._patchIndex);
this._db.patches.get(patchIndex).then((patch) => {
if (patch) {
this.apply(patch.inversePatch);
this._db.settings.put(this._patchIndex - 1, 'patchIndex');
this._db.settings.put(patchIndex - 1, 'patchIndex');
}
});
}
}
redo() {
if (this.canRedo) {
this._db.patches.get(this._patchIndex + 1).then((patch) => {
if (get(this.canRedo)) {
const patchIndex = get(this._patchIndex) + 1;
this._db.patches.get(patchIndex).then((patch) => {
if (patch) {
this.apply(patch.patch);
this._db.settings.put(this._patchIndex + 1, 'patchIndex');
this._db.settings.put(patchIndex, 'patchIndex');
}
});
}
@@ -142,7 +168,7 @@ export class FileActionManager {
applyGlobal(callback: (files: Map<string, GPXFile>) => void) {
const [newFileCollection, patch, inversePatch] = produceWithPatches(this._files, callback);
this.store(patch, inversePatch);
this.storePatches(patch, inversePatch);
return this.commitFileStateChange(newFileCollection, patch);
}
@@ -160,7 +186,7 @@ export class FileActionManager {
}
);
this.store(patch, inversePatch);
this.storePatches(patch, inversePatch);
return this.commitFileStateChange(newFileCollection, patch);
}
@@ -188,10 +214,32 @@ export class FileActionManager {
}
);
this.store(patch, inversePatch);
this.storePatches(patch, inversePatch);
return this.commitFileStateChange(newFileCollection, patch);
}
async storePatches(patch: Patch[], inversePatch: Patch[]) {
this._db.patches.where(':id').above(get(this._patchIndex)).delete(); // Delete all patches after the current patch to avoid redoing them
if (get(this._patchMaxIndex) - get(this._patchMinIndex) + 1 > MAX_PATCHES) {
this._db.patches
.where(':id')
.belowOrEqual(get(this._patchMaxIndex) - MAX_PATCHES)
.delete();
}
this._db.transaction('rw', this._db.patches, this._db.settings, async () => {
let index = get(this._patchIndex) + 1;
await this._db.patches.put(
{
patch,
inversePatch,
index,
},
index
);
await this._db.settings.put(index, 'patchIndex');
});
}
}
// Get the file ids of the files that have changed in the patch

View File

@@ -1,8 +1,8 @@
import { fileStateCollection } from '$lib/logic/file-state.svelte';
import { fileActionManager } from '$lib/logic/file-action-manager.svelte';
import { selection } from '$lib/logic/selection.svelte';
import { tool, Tool } from '$lib/components/toolbar/utils.svelte';
import type { SplitType } from '$lib/components/toolbar/tools/scissors/utils.svelte';
import { fileStateCollection } from '$lib/logic/file-state';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { selection } from '$lib/logic/selection';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import type { SplitType } from '$lib/components/toolbar/tools/scissors/scissors';
import {
ListFileItem,
ListLevel,
@@ -27,13 +27,14 @@ import {
type LineStyleExtension,
type WaypointType,
} from 'gpx';
import { get } from 'svelte/store';
// Generate unique file ids, different from the ones in the database
export function getFileIds(n: number) {
let ids = [];
for (let index = 0; ids.length < n; index++) {
let id = `gpx-${index}`;
if (!fileStateCollection.files.has(id)) {
if (!fileStateCollection.getFile(id)) {
ids.push(id);
}
}
@@ -46,9 +47,8 @@ export function newGPXFile() {
let file = new GPXFile();
let maxNewFileNumber = 0;
fileStateCollection.files.forEach((fileState) => {
let file = fileState.file;
if (file && file.metadata.name && file.metadata.name.startsWith(newFileName)) {
fileStateCollection.forEach((fileId, file) => {
if (file.metadata.name && file.metadata.name.startsWith(newFileName)) {
let number = parseInt(file.metadata.name.split(' ').pop() ?? '0');
if (!isNaN(number) && number > maxNewFileNumber) {
maxNewFileNumber = number;
@@ -738,6 +738,11 @@ export const fileActions = {
// }
// });
},
deleteWaypoint: (fileId: string, waypointIndex: number) => {
fileActionManager.applyToFile(fileId, (file) =>
file.replaceWaypoints(waypointIndex, waypointIndex, [])
);
},
setStyleToSelection: (style: LineStyleExtension) => {
// if (get(selection).size === 0) {
// return;
@@ -926,7 +931,7 @@ export function pasteSelection() {
return;
}
let selected = selection.value.getSelected();
let selected = get(selection).getSelected();
if (selected.length === 0) {
selected = [new ListRootItem()];
}

View File

@@ -3,15 +3,16 @@ import { db, type Database } from '$lib/db';
import { liveQuery } from 'dexie';
import { GPXFile } from 'gpx';
import { GPXStatisticsTree, type GPXFileWithStatistics } from '$lib/logic/statistics';
import { settings } from '$lib/logic/settings.svelte';
import { settings } from '$lib/logic/settings';
import { get, writable, type Subscriber, type Writable } from 'svelte/store';
// Observe a single file from the database, and maintain its statistics
class GPXFileState {
private _file: GPXFileWithStatistics | undefined;
private _file: Writable<GPXFileWithStatistics | undefined>;
private _subscription: { unsubscribe: () => void } | undefined;
constructor(db: Database, fileId: string) {
this._file = $state(undefined);
this._file = writable(undefined);
let first = true;
this._subscription = liveQuery(() => db.files.get(fileId)).subscribe((value) => {
@@ -29,7 +30,7 @@ class GPXFileState {
first = false;
}
this._file = { file, statistics };
this._file.set({ file, statistics });
// if (get(selection).hasAnyChildren(new ListFileItem(id))) {
// updateAllHidden();
@@ -38,29 +39,36 @@ class GPXFileState {
});
}
subscribe(run: Subscriber<GPXFileWithStatistics | undefined>, invalidate?: () => void) {
return this._file.subscribe(run, invalidate);
}
destroy() {
this._subscription?.unsubscribe();
this._subscription = undefined;
this._file = undefined;
}
get file(): GPXFile | undefined {
return this._file?.file;
return get(this._file)?.file;
}
get statistics(): GPXStatisticsTree | undefined {
return this._file?.statistics;
return get(this._file)?.statistics;
}
}
// Observe the file ids in the database, and maintain a map of file states for the corresponding files
export class GPXFileStateCollection {
private _db: Database;
private _files: Map<string, GPXFileState>;
private _files: Writable<Map<string, GPXFileState>>;
constructor(db: Database) {
this._db = db;
this._files = $state(new Map());
this._files = writable(new Map());
}
subscribe(run: Subscriber<Map<string, GPXFileState>>, invalidate?: () => void) {
return this._files.subscribe(run, invalidate);
}
initialize(fitBounds: boolean) {
@@ -72,57 +80,101 @@ export class GPXFileStateCollection {
// }
initialize = false;
}
const currentFiles = get(this._files);
// Find new files to observe
let newFiles = dbFileIds
.filter((id) => !this._files.has(id))
.filter((id) => !currentFiles.has(id))
.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
// Find deleted files to stop observing
let deletedFiles = Array.from(this._files.keys()).filter(
let deletedFiles = Array.from(currentFiles.keys()).filter(
(id) => !dbFileIds.find((fileId) => fileId === id)
);
if (newFiles.length > 0 || deletedFiles.length > 0) {
// Update the map of file states
let files = new Map(this._files);
newFiles.forEach((id) => {
files.set(id, new GPXFileState(this._db, id));
this._files.update(($files) => {
newFiles.forEach((id) => {
$files.set(id, new GPXFileState(this._db, id));
});
deletedFiles.forEach((id) => {
$files.get(id)?.destroy();
$files.delete(id);
});
return $files;
});
deletedFiles.forEach((id) => {
files.get(id)?.destroy();
files.delete(id);
});
this._files = files;
// Update the file order
let fileOrder = settings.fileOrder.value.filter((id) => !deletedFiles.includes(id));
let fileOrder = get(settings.fileOrder).filter((id) => !deletedFiles.includes(id));
newFiles.forEach((id) => {
if (!fileOrder.includes(id)) {
fileOrder.push(id);
}
});
settings.fileOrder.value = fileOrder;
settings.fileOrder.set(fileOrder);
}
});
}
get files(): ReadonlyMap<string, GPXFileState> {
return this._files;
}
get size(): number {
return this._files.size;
return get(this._files).size;
}
getFile(fileId: string): GPXFile | undefined {
let fileState = this._files.get(fileId);
let fileState = get(this._files).get(fileId);
return fileState?.file;
}
getStatistics(fileId: string): GPXStatisticsTree | undefined {
let fileState = this._files.get(fileId);
let fileState = get(this._files).get(fileId);
return fileState?.statistics;
}
forEach(callback: (fileId: string, file: GPXFile) => void) {
get(this._files).forEach((fileState, fileId) => {
if (fileState.file) {
callback(fileId, fileState.file);
}
});
}
}
// Collection of all file states
export const fileStateCollection = new GPXFileStateCollection(db);
export type GPXFileStateCallback = (fileId: string, fileState: GPXFileState) => void;
export class GPXFileStateCollectionObserver {
private _fileIds: Set<string>;
private _onFileAdded: GPXFileStateCallback;
private _onFileRemoved: (fileId: string) => void;
private _onDestroy: () => void;
constructor(
onFileAdded: GPXFileStateCallback,
onFileRemoved: (fileId: string) => void,
onDestroy: () => void
) {
this._fileIds = new Set();
this._onFileAdded = onFileAdded;
this._onFileRemoved = onFileRemoved;
this._onDestroy = onDestroy;
fileStateCollection.subscribe((files) => {
this._fileIds.forEach((fileId) => {
if (!files.has(fileId)) {
this._onFileRemoved(fileId);
this._fileIds.delete(fileId);
}
});
files.forEach((file: GPXFileState, fileId: string) => {
if (!this._fileIds.has(fileId)) {
this._onFileAdded(fileId, file);
this._fileIds.add(fileId);
}
});
});
}
destroy() {
this._onDestroy();
}
}

View File

@@ -0,0 +1,140 @@
import type { ListItem } from '$lib/components/file-list/file-list';
export class SelectionTreeType {
item: ListItem;
selected: boolean;
children: {
[key: string | number]: SelectionTreeType;
};
size: number = 0;
constructor(item: ListItem) {
this.item = item;
this.selected = false;
this.children = {};
}
clear() {
this.selected = false;
for (let key in this.children) {
this.children[key].clear();
}
this.size = 0;
}
_setOrToggle(item: ListItem, value?: boolean) {
if (item.level === this.item.level) {
let newSelected = value === undefined ? !this.selected : value;
if (this.selected !== newSelected) {
this.selected = newSelected;
this.size += this.selected ? 1 : -1;
}
} else {
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (!this.children.hasOwnProperty(id)) {
this.children[id] = new SelectionTreeType(this.item.extend(id));
}
this.size -= this.children[id].size;
this.children[id]._setOrToggle(item, value);
this.size += this.children[id].size;
}
}
}
set(item: ListItem, value: boolean) {
this._setOrToggle(item, value);
}
toggle(item: ListItem) {
this._setOrToggle(item);
}
has(item: ListItem): boolean {
if (item.level === this.item.level) {
return this.selected;
} else {
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].has(item);
}
}
}
return false;
}
hasAnyParent(item: ListItem, self: boolean = true): boolean {
if (
this.selected &&
this.item.level <= item.level &&
(self || this.item.level < item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].hasAnyParent(item, self);
}
}
return false;
}
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
if (
this.selected &&
this.item.level >= item.level &&
(self || this.item.level > item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (ignoreIds === undefined || ignoreIds.indexOf(id) === -1) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].hasAnyChildren(item, self, ignoreIds);
}
}
} else {
for (let key in this.children) {
if (ignoreIds === undefined || ignoreIds.indexOf(key) === -1) {
if (this.children[key].hasAnyChildren(item, self, ignoreIds)) {
return true;
}
}
}
}
return false;
}
getSelected(selection: ListItem[] = []): ListItem[] {
if (this.selected) {
selection.push(this.item);
}
for (let key in this.children) {
this.children[key].getSelected(selection);
}
return selection;
}
forEach(callback: (item: ListItem) => void) {
if (this.selected) {
callback(this.item);
}
for (let key in this.children) {
this.children[key].forEach(callback);
}
}
getChild(id: string | number): SelectionTreeType | undefined {
return this.children[id];
}
deleteChild(id: string | number) {
if (this.children.hasOwnProperty(id)) {
this.size -= this.children[id].size;
delete this.children[id];
}
}
}

View File

@@ -1,228 +0,0 @@
import {
ListFileItem,
ListItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListLevel,
sortItems,
ListWaypointsItem,
} from '$lib/components/file-list/file-list';
import { SelectionTreeType } from '$lib/logic/selection';
import { fileStateCollection } from '$lib/logic/file-state.svelte';
import { settings } from '$lib/logic/settings.svelte';
import type { GPXFile } from 'gpx';
export class Selection {
private _selection: SelectionTreeType;
private _copied: ListItem[] | undefined;
private _cut: boolean;
constructor() {
this._selection = $state(new SelectionTreeType(new ListRootItem()));
this._copied = $state(undefined);
this._cut = $state(false);
}
get value(): SelectionTreeType {
return this._selection;
}
selectItem(item: ListItem) {
let selection = new SelectionTreeType(new ListRootItem());
selection.set(item, true);
this._selection = selection;
}
selectFile(fileId: string) {
this.selectItem(new ListFileItem(fileId));
}
addSelectItem(item: ListItem) {
this._selection.toggle(item);
}
addSelectFile(fileId: string) {
this.addSelectItem(new ListFileItem(fileId));
}
selectAll() {
let item: ListItem = new ListRootItem();
this._selection.forEach((i) => {
item = i;
});
let selection = new SelectionTreeType(new ListRootItem());
if (item instanceof ListRootItem || item instanceof ListFileItem) {
fileStateCollection.files.forEach((_file, fileId) => {
selection.set(new ListFileItem(fileId), true);
});
} else if (item instanceof ListTrackItem) {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
file.trk.forEach((_track, trackId) => {
selection.set(new ListTrackItem(item.getFileId(), trackId), true);
});
}
} else if (item instanceof ListTrackSegmentItem) {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
selection.set(
new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId),
true
);
});
}
} else if (item instanceof ListWaypointItem) {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
file.wpt.forEach((_waypoint, waypointId) => {
selection.set(new ListWaypointItem(item.getFileId(), waypointId), true);
});
}
}
this._selection = selection;
}
set(items: ListItem[]) {
let selection = new SelectionTreeType(new ListRootItem());
items.forEach((item) => {
selection.set(item, true);
});
this._selection = selection;
}
update(updatedFiles: GPXFile[], deletedFileIds: string[]) {
// TODO do it the other way around: get all selected items, and check if they still exist?
// let removedItems: ListItem[] = [];
// applyToOrderedItemsFromFile(selection.value.getSelected(), (fileId, level, items) => {
// let file = updatedFiles.find((file) => file._data.id === fileId);
// if (file) {
// items.forEach((item) => {
// if (item instanceof ListTrackItem) {
// let newTrackIndex = file.trk.findIndex(
// (track) => track._data.trackIndex === item.getTrackIndex()
// );
// if (newTrackIndex === -1) {
// removedItems.push(item);
// }
// } else if (item instanceof ListTrackSegmentItem) {
// let newTrackIndex = file.trk.findIndex(
// (track) => track._data.trackIndex === item.getTrackIndex()
// );
// if (newTrackIndex === -1) {
// removedItems.push(item);
// } else {
// let newSegmentIndex = file.trk[newTrackIndex].trkseg.findIndex(
// (segment) => segment._data.segmentIndex === item.getSegmentIndex()
// );
// if (newSegmentIndex === -1) {
// removedItems.push(item);
// }
// }
// } else if (item instanceof ListWaypointItem) {
// let newWaypointIndex = file.wpt.findIndex(
// (wpt) => wpt._data.index === item.getWaypointIndex()
// );
// if (newWaypointIndex === -1) {
// removedItems.push(item);
// }
// }
// });
// } else if (deletedFileIds.includes(fileId)) {
// items.forEach((item) => {
// removedItems.push(item);
// });
// }
// });
// if (removedItems.length > 0) {
// selection.update(($selection) => {
// removedItems.forEach((item) => {
// if (item instanceof ListFileItem) {
// $selection.deleteChild(item.getFileId());
// } else {
// $selection.set(item, false);
// }
// });
// return $selection;
// });
// }
}
getOrderedSelection(reverse: boolean = false): ListItem[] {
let selected: ListItem[] = [];
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
selected.push(...items);
}, reverse);
return selected;
}
copySelection(): boolean {
let selected = this._selection.getSelected();
if (selected.length > 0) {
this._copied = selected;
this._cut = false;
return true;
}
return false;
}
cutSelection() {
if (this.copySelection()) {
this._cut = true;
}
}
resetCopied() {
this._copied = undefined;
this._cut = false;
}
get copied(): ListItem[] | undefined {
return this._copied;
}
get cut(): boolean {
return this._cut;
}
}
export const selection = new Selection();
export function applyToOrderedItemsFromFile(
selectedItems: ListItem[],
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
settings.fileOrder.value.forEach((fileId) => {
let level: ListLevel | undefined = undefined;
let items: ListItem[] = [];
selectedItems.forEach((item) => {
if (item.getFileId() === fileId) {
level = item.level;
if (
item instanceof ListFileItem ||
item instanceof ListTrackItem ||
item instanceof ListTrackSegmentItem ||
item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem
) {
items.push(item);
}
}
});
if (items.length > 0) {
sortItems(items, reverse);
callback(fileId, level, items);
}
});
}
export function applyToOrderedSelectedItemsFromFile(
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
applyToOrderedItemsFromFile(selection.value.getSelected(), callback, reverse);
}

View File

@@ -1,140 +1,245 @@
import type { ListItem } from '$lib/components/file-list/file-list';
import {
ListFileItem,
ListItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListLevel,
sortItems,
ListWaypointsItem,
} from '$lib/components/file-list/file-list';
import { fileStateCollection } from '$lib/logic/file-state';
import { settings } from '$lib/logic/settings';
import type { GPXFile } from 'gpx';
import { get, writable, type Writable } from 'svelte/store';
import { SelectionTreeType } from '$lib/logic/selection-tree';
export class SelectionTreeType {
item: ListItem;
selected: boolean;
children: {
[key: string | number]: SelectionTreeType;
};
size: number = 0;
export class Selection {
private _selection: Writable<SelectionTreeType>;
private _copied: Writable<ListItem[] | undefined>;
private _cut: Writable<boolean>;
constructor(item: ListItem) {
this.item = item;
this.selected = false;
this.children = {};
constructor() {
this._selection = writable(new SelectionTreeType(new ListRootItem()));
this._copied = writable(undefined);
this._cut = writable(false);
}
clear() {
this.selected = false;
for (let key in this.children) {
this.children[key].clear();
}
this.size = 0;
subscribe(
run: (value: SelectionTreeType) => void,
invalidate?: (value?: SelectionTreeType) => void
) {
return this._selection.subscribe(run, invalidate);
}
_setOrToggle(item: ListItem, value?: boolean) {
if (item.level === this.item.level) {
let newSelected = value === undefined ? !this.selected : value;
if (this.selected !== newSelected) {
this.selected = newSelected;
this.size += this.selected ? 1 : -1;
}
} else {
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (!this.children.hasOwnProperty(id)) {
this.children[id] = new SelectionTreeType(this.item.extend(id));
selectItem(item: ListItem) {
this._selection.update(($selection) => {
$selection.clear();
$selection.set(item, true);
return $selection;
});
}
selectFile(fileId: string) {
this.selectItem(new ListFileItem(fileId));
}
addSelectItem(item: ListItem) {
this._selection.update(($selection) => {
$selection.toggle(item);
return $selection;
});
}
addSelectFile(fileId: string) {
this.addSelectItem(new ListFileItem(fileId));
}
selectAll() {
let item: ListItem = new ListRootItem();
get(this._selection).forEach((i) => {
item = i;
});
this._selection.update(($selection) => {
$selection.clear();
if (item instanceof ListRootItem || item instanceof ListFileItem) {
fileStateCollection.forEach((fileId, file) => {
$selection.set(new ListFileItem(fileId), true);
});
} else if (item instanceof ListTrackItem) {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
file.trk.forEach((_track, trackId) => {
$selection.set(new ListTrackItem(item.getFileId(), trackId), true);
});
}
this.size -= this.children[id].size;
this.children[id]._setOrToggle(item, value);
this.size += this.children[id].size;
}
}
}
set(item: ListItem, value: boolean) {
this._setOrToggle(item, value);
}
toggle(item: ListItem) {
this._setOrToggle(item);
}
has(item: ListItem): boolean {
if (item.level === this.item.level) {
return this.selected;
} else {
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].has(item);
} else if (item instanceof ListTrackSegmentItem) {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
$selection.set(
new ListTrackSegmentItem(
item.getFileId(),
item.getTrackIndex(),
segmentId
),
true
);
});
}
} else if (item instanceof ListWaypointItem) {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
file.wpt.forEach((_waypoint, waypointId) => {
$selection.set(new ListWaypointItem(item.getFileId(), waypointId), true);
});
}
}
return $selection;
});
}
set(items: ListItem[]) {
this._selection.update(($selection) => {
$selection.clear();
items.forEach((item) => {
$selection.set(item, true);
});
return $selection;
});
}
update(updatedFiles: GPXFile[], deletedFileIds: string[]) {
// TODO do it the other way around: get all selected items, and check if they still exist?
// let removedItems: ListItem[] = [];
// applyToOrderedItemsFromFile(selection.value.getSelected(), (fileId, level, items) => {
// let file = updatedFiles.find((file) => file._data.id === fileId);
// if (file) {
// items.forEach((item) => {
// if (item instanceof ListTrackItem) {
// let newTrackIndex = file.trk.findIndex(
// (track) => track._data.trackIndex === item.getTrackIndex()
// );
// if (newTrackIndex === -1) {
// removedItems.push(item);
// }
// } else if (item instanceof ListTrackSegmentItem) {
// let newTrackIndex = file.trk.findIndex(
// (track) => track._data.trackIndex === item.getTrackIndex()
// );
// if (newTrackIndex === -1) {
// removedItems.push(item);
// } else {
// let newSegmentIndex = file.trk[newTrackIndex].trkseg.findIndex(
// (segment) => segment._data.segmentIndex === item.getSegmentIndex()
// );
// if (newSegmentIndex === -1) {
// removedItems.push(item);
// }
// }
// } else if (item instanceof ListWaypointItem) {
// let newWaypointIndex = file.wpt.findIndex(
// (wpt) => wpt._data.index === item.getWaypointIndex()
// );
// if (newWaypointIndex === -1) {
// removedItems.push(item);
// }
// }
// });
// } else if (deletedFileIds.includes(fileId)) {
// items.forEach((item) => {
// removedItems.push(item);
// });
// }
// });
// if (removedItems.length > 0) {
// selection.update(($selection) => {
// removedItems.forEach((item) => {
// if (item instanceof ListFileItem) {
// $selection.deleteChild(item.getFileId());
// } else {
// $selection.set(item, false);
// }
// });
// return $selection;
// });
// }
}
getOrderedSelection(reverse: boolean = false): ListItem[] {
let selected: ListItem[] = [];
this.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
selected.push(...items);
}, reverse);
return selected;
}
applyToOrderedSelectedItemsFromFile(
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
applyToOrderedItemsFromFile(get(this._selection).getSelected(), callback, reverse);
}
copySelection(): boolean {
let selected = get(this._selection).getSelected();
if (selected.length > 0) {
this._copied.set(selected);
this._cut.set(false);
return true;
}
return false;
}
hasAnyParent(item: ListItem, self: boolean = true): boolean {
if (
this.selected &&
this.item.level <= item.level &&
(self || this.item.level < item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].hasAnyParent(item, self);
}
}
return false;
}
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
if (
this.selected &&
this.item.level >= item.level &&
(self || this.item.level > item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (ignoreIds === undefined || ignoreIds.indexOf(id) === -1) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].hasAnyChildren(item, self, ignoreIds);
}
}
} else {
for (let key in this.children) {
if (ignoreIds === undefined || ignoreIds.indexOf(key) === -1) {
if (this.children[key].hasAnyChildren(item, self, ignoreIds)) {
return true;
}
}
}
}
return false;
}
getSelected(selection: ListItem[] = []): ListItem[] {
if (this.selected) {
selection.push(this.item);
}
for (let key in this.children) {
this.children[key].getSelected(selection);
}
return selection;
}
forEach(callback: (item: ListItem) => void) {
if (this.selected) {
callback(this.item);
}
for (let key in this.children) {
this.children[key].forEach(callback);
cutSelection() {
if (this.copySelection()) {
this._cut.set(true);
}
}
getChild(id: string | number): SelectionTreeType | undefined {
return this.children[id];
resetCopied() {
this._copied.set(undefined);
this._cut.set(false);
}
deleteChild(id: string | number) {
if (this.children.hasOwnProperty(id)) {
this.size -= this.children[id].size;
delete this.children[id];
}
get copied(): ListItem[] | undefined {
return this._copied;
}
get cut(): boolean {
return this._cut;
}
}
export const selection = new Selection();
export function applyToOrderedItemsFromFile(
selectedItems: ListItem[],
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
settings.fileOrder.value.forEach((fileId) => {
let level: ListLevel | undefined = undefined;
let items: ListItem[] = [];
selectedItems.forEach((item) => {
if (item.getFileId() === fileId) {
level = item.level;
if (
item instanceof ListFileItem ||
item instanceof ListTrackItem ||
item instanceof ListTrackSegmentItem ||
item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem
) {
items.push(item);
}
}
});
if (items.length > 0) {
sortItems(items, reverse);
callback(fileId, level, items);
}
});
}

View File

@@ -11,36 +11,44 @@ import {
type CustomLayer,
} from '$lib/assets/layers';
import { browser } from '$app/environment';
import { get, writable, type Writable } from 'svelte/store';
export class Setting<V> {
private _db: Database;
private _key: string;
private _value: V;
private _value: Writable<V>;
constructor(db: Database, key: string, initial: V) {
this._db = db;
this._key = key;
this._value = $state(initial);
this._value = writable(initial);
let first = true;
liveQuery(() => db.settings.get(key)).subscribe((value) => {
if (value === undefined) {
if (!first) {
this._value = value;
this._value.set(value);
}
} else {
this._value = value;
this._value.set(value);
}
first = false;
});
}
get value(): V {
return this._value;
subscribe(run: (value: V) => void, invalidate?: (value?: V) => void) {
return this._value.subscribe(run, invalidate);
}
set value(newValue: V) {
if (newValue !== this._value) {
set(newValue: V) {
if (typeof newValue === 'object' || newValue !== get(this._value)) {
this._db.settings.put(newValue, this._key);
}
}
update(callback: (value: any) => any) {
let newValue = callback(get(this._value));
if (typeof newValue === 'object' || newValue !== get(this._value)) {
this._db.settings.put(newValue, this._key);
}
}
@@ -49,34 +57,41 @@ export class Setting<V> {
export class SettingInitOnFirstRead<V> {
private _db: Database;
private _key: string;
private _value: V | undefined;
private _value: Writable<V | undefined>;
constructor(db: Database, key: string, initial: V) {
this._db = db;
this._key = key;
this._value = $state(undefined);
this._value = writable(undefined);
let first = true;
liveQuery(() => db.settings.get(key)).subscribe((value) => {
if (value === undefined) {
if (first) {
this._value = initial;
this._value.set(initial);
} else {
this._value = value;
this._value.set(value);
}
} else {
this._value = value;
this._value.set(value);
}
first = false;
});
}
get value(): V | undefined {
return this._value;
subscribe(run: (value: V | undefined) => void, invalidate?: (value?: V | undefined) => void) {
return this._value.subscribe(run, invalidate);
}
set value(newValue: V) {
if (newValue !== this._value) {
set(newValue: V) {
if (typeof newValue === 'object' || newValue !== get(this._value)) {
this._db.settings.put(newValue, this._key);
}
}
update(callback: (value: any) => any) {
let newValue = callback(get(this._value));
if (typeof newValue === 'object' || newValue !== get(this._value)) {
this._db.settings.put(newValue, this._key);
}
}