This commit is contained in:
vcoppe
2026-10-04 14:49:53 +02:00
parent 1d534881f8
commit 55f1d275b7
8 changed files with 758 additions and 154 deletions
+65 -108
View File
@@ -53,7 +53,7 @@
import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
import { defaultOverlays } from '$lib/assets/layers';
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list';
import { ListLevel } from '$lib/components/file-list/file-list';
import Export from '$lib/components/export/Export.svelte';
import { mode, setMode } from 'mode-watcher';
import { i18n } from '$lib/i18n.svelte';
@@ -62,17 +62,17 @@
import { settings } from '$lib/logic/settings';
import {
createFile,
fileActions,
loadFiles,
pasteSelection,
triggerFileInput,
} from '$lib/logic/file-actions';
import { fileStateCollection } from '$lib/logic/file-state';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { copied, selection } from '$lib/logic/selection';
import { allHidden } from '$lib/logic/hidden';
import { engine } from '$lib/engine';
import { allHidden } from '$lib/all-hidden';
import { selectionSize } from '$lib/selection-helpers';
// TODO the clipboard and centering on the selection still work on the previous implementation
import { copied, selection as oldSelection } from '$lib/logic/selection';
import { boundsManager } from '$lib/logic/bounds';
import { tick, onMount } from 'svelte';
import { onMount } from 'svelte';
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
const {
@@ -91,8 +91,22 @@
routing,
} = settings;
const canUndo = fileActionManager.canUndo;
const canRedo = fileActionManager.canRedo;
const { files, selection, canUndo, canRedo } = engine;
let selectionCount = $derived(selectionSize($selection));
let noFiles = $derived($files.size === 0);
// metadata and style can be edited for files and tracks only
let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track');
let selectionLevel = $derived(
{
empty: undefined,
file: ListLevel.FILE,
track: ListLevel.TRACK,
segment: ListLevel.SEGMENT,
waypoints: ListLevel.WAYPOINTS,
waypoint: ListLevel.WAYPOINT,
}[$selection.type]
);
function switchBasemaps() {
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
@@ -153,27 +167,21 @@
<Shortcut key="O" ctrl={true} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
onclick={fileActions.duplicateSelection}
disabled={$selection.size == 0}
>
<Menubar.Item onclick={() => engine.duplicate()} disabled={selectionCount == 0}>
<Copy size="16" />
{i18n._('menu.duplicate')}
<Shortcut key="D" ctrl={true} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
onclick={() => tick().then(fileActions.deleteSelectedFiles)}
disabled={$selection.size == 0}
onclick={() => engine.delete(true)}
disabled={selectionCount == 0}
>
<FileX size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
</Menubar.Item>
<Menubar.Item
onclick={fileActions.deleteAllFiles}
disabled={fileStateCollection.size == 0}
>
<Menubar.Item onclick={() => engine.deleteAll()} disabled={noFiles}>
<FileX size="16" />
{i18n._('menu.delete_all')}
<Shortcut key="⌫" ctrl={true} shift={true} />
@@ -181,7 +189,7 @@
<Menubar.Separator />
<Menubar.Item
onclick={() => (exportState.current = ExportState.SELECTION)}
disabled={$selection.size == 0}
disabled={selectionCount == 0}
>
<Download size="16" />
{i18n._('menu.export')}
@@ -189,7 +197,7 @@
</Menubar.Item>
<Menubar.Item
onclick={() => (exportState.current = ExportState.ALL)}
disabled={fileStateCollection.size == 0}
disabled={noFiles}
>
<Download size="16" />
{i18n._('menu.export_all')}
@@ -203,26 +211,19 @@
<span class="hidden md:block">{i18n._('menu.edit')}</span>
</Menubar.Trigger>
<Menubar.Content class="border-none">
<Menubar.Item onclick={() => fileActionManager.undo()} disabled={!$canUndo}>
<Menubar.Item onclick={() => engine.undo()} disabled={!$canUndo}>
<Undo2 size="16" />
{i18n._('menu.undo')}
<Shortcut key="Z" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={() => fileActionManager.redo()} disabled={!$canRedo}>
<Menubar.Item onclick={() => engine.redo()} disabled={!$canRedo}>
<Redo2 size="16" />
{i18n._('menu.redo')}
<Shortcut key="Z" ctrl={true} shift={true} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
disabled={$selection.size !== 1 ||
!$selection
.getSelected()
.every(
(item) =>
item instanceof ListFileItem ||
item instanceof ListTrackItem
)}
disabled={selectionCount !== 1 || !filesOrTracksSelected}
onclick={() => (editMetadata.current = true)}
>
<Info size="16" />
@@ -230,28 +231,15 @@
<Shortcut key="I" ctrl={true} />
</Menubar.Item>
<Menubar.Item
disabled={$selection.size === 0 ||
!$selection
.getSelected()
.every(
(item) =>
item instanceof ListFileItem ||
item instanceof ListTrackItem
)}
disabled={selectionCount === 0 || !filesOrTracksSelected}
onclick={() => (editStyle.current = true)}
>
<PaintBucket size="16" />
{i18n._('menu.style.button')}
</Menubar.Item>
<Menubar.Item
onclick={() => {
if ($allHidden) {
fileActions.setHiddenToSelection(false);
} else {
fileActions.setHiddenToSelection(true);
}
}}
disabled={$selection.size == 0}
onclick={() => engine.setSelectionHidden(!$allHidden)}
disabled={selectionCount == 0}
>
{#if $allHidden}
<Eye size="16" />
@@ -263,31 +251,20 @@
<Shortcut key="H" ctrl={true} />
</Menubar.Item>
{#if $treeFileView}
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
{#if $selection.type === 'file'}
<Menubar.Separator />
<Menubar.Item
onclick={() =>
fileActions.addNewTrack(
$selection.getSelected()[0].getFileId()
)}
disabled={$selection.size !== 1}
onclick={() => engine.newTrack()}
disabled={selectionCount !== 1}
>
<Plus size="16" />
{i18n._('menu.new_track')}
</Menubar.Item>
{:else if $selection
.getSelected()
.some((item) => item instanceof ListTrackItem)}
{:else if $selection.type === 'track'}
<Menubar.Separator />
<Menubar.Item
onclick={() => {
let item = $selection.getSelected()[0];
fileActions.addNewSegment(
item.getFileId(),
item.getTrackIndex()
);
}}
disabled={$selection.size !== 1}
onclick={() => engine.newTrackSegment()}
disabled={selectionCount !== 1}
>
<Plus size="16" />
{i18n._('menu.new_segment')}
@@ -295,21 +272,18 @@
{/if}
{/if}
<Menubar.Separator />
<Menubar.Item
onclick={() => selection.selectAll()}
disabled={fileStateCollection.size == 0}
>
<Menubar.Item onclick={() => engine.selectAll()} disabled={noFiles}>
<FileStack size="16" />
{i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</Menubar.Item>
<Menubar.Item
onclick={() => {
if ($selection.size > 0) {
if (selectionCount > 0) {
boundsManager.centerMapOnSelection();
}
}}
disabled={$selection.size == 0}
disabled={selectionCount == 0}
>
<Maximize size="16" />
{i18n._('menu.center')}
@@ -318,16 +292,16 @@
{#if $treeFileView}
<Menubar.Separator />
<Menubar.Item
onclick={() => selection.copySelection()}
disabled={$selection.size === 0}
onclick={() => oldSelection.copySelection()}
disabled={selectionCount === 0}
>
<ClipboardCopy size="16" />
{i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} />
</Menubar.Item>
<Menubar.Item
onclick={() => selection.cutSelection()}
disabled={$selection.size === 0}
onclick={() => oldSelection.cutSelection()}
disabled={selectionCount === 0}
>
<Scissors size="16" />
{i18n._('menu.cut')}
@@ -336,10 +310,8 @@
<Menubar.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
($selection.size > 0 &&
!allowedPastes[$copied[0].level].includes(
$selection.getSelected().pop()!.level
))}
(selectionLevel !== undefined &&
!allowedPastes[$copied[0].level].includes(selectionLevel))}
onclick={pasteSelection}
>
<ClipboardPaste size="16" />
@@ -348,10 +320,7 @@
</Menubar.Item>
{/if}
<Menubar.Separator />
<Menubar.Item
onclick={() => tick().then(fileActions.deleteSelection)}
disabled={$selection.size == 0}
>
<Menubar.Item onclick={() => engine.delete()} disabled={selectionCount == 0}>
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
@@ -585,16 +554,16 @@
triggerFileInput();
e.preventDefault();
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
fileActions.duplicateSelection();
engine.duplicate();
e.preventDefault();
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
selection.copySelection();
oldSelection.copySelection();
e.preventDefault();
}
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
selection.cutSelection();
oldSelection.cutSelection();
e.preventDefault();
}
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
@@ -604,41 +573,36 @@
}
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
if (fileStateCollection.size > 0) {
if (!noFiles) {
exportState.current = ExportState.ALL;
}
} else if ($selection.size > 0) {
} else if (selectionCount > 0) {
exportState.current = ExportState.SELECTION;
}
e.preventDefault();
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
fileActionManager.redo();
engine.redo();
} else {
fileActionManager.undo();
engine.undo();
}
e.preventDefault();
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
if (e.shiftKey) {
fileActions.deleteAllFiles();
engine.deleteAll();
} else {
fileActions.deleteSelection();
engine.delete();
}
e.preventDefault();
}
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
selection.selectAll();
engine.selectAll();
e.preventDefault();
}
} else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
if (
$selection.size === 1 &&
$selection
.getSelected()
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
) {
if (selectionCount === 1 && filesOrTracksSelected) {
editMetadata.current = true;
}
e.preventDefault();
@@ -649,14 +613,10 @@
$treeFileView = !$treeFileView;
e.preventDefault();
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
if ($allHidden) {
fileActions.setHiddenToSelection(false);
} else {
fileActions.setHiddenToSelection(true);
}
engine.setSelectionHidden(!$allHidden);
e.preventDefault();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
if ($selection.size > 0) {
if (selectionCount > 0) {
boundsManager.centerMapOnSelection();
}
} else if (e.key === 'F1') {
@@ -681,10 +641,7 @@
e.key === 'ArrowUp'
) {
if (!targetInput) {
selection.updateFromKey(
e.key === 'ArrowRight' || e.key === 'ArrowDown',
e.shiftKey
);
engine.arrowSelect(e.key === 'ArrowRight' || e.key === 'ArrowDown', e.shiftKey);
e.preventDefault();
}
}
@@ -260,7 +260,7 @@
{/if}
{/if}
{#if level !== ListLevel.WAYPOINTS}
<ContextMenu.Item onclick={() => engine.selectAllSiblings(node)}>
<ContextMenu.Item onclick={() => engine.selectAll()}>
<FileStack size="16" />
{i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} />
+27 -33
View File
@@ -197,6 +197,8 @@ class Engine {
private _visibility = new Map<string, Visibility>();
private _files = writable<Map<string, Writable<FileState>>>(new Map());
private _selection = writable<Selection>({ type: 'empty' });
private _canUndo = writable(false);
private _canRedo = writable(false);
private _statistics = writable<SelectionStatistics>(EMPTY_SELECTION_STATISTICS);
/** Identifies the statistics currently in the engine's buffers. */
private _statisticsVersion = 0;
@@ -205,6 +207,8 @@ class Engine {
readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
/** Statistics of what is currently selected. */
readonly statistics: Readable<SelectionStatistics> = { subscribe: this._statistics.subscribe };
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.subscribe };
/** What is currently selected. */
readonly selection: Readable<Selection> = { subscribe: this._selection.subscribe };
/**
@@ -239,8 +243,12 @@ class Engine {
return this.run((w) => w.duplicate());
}
delete() {
return this.run((w) => w._delete());
/**
* Deletes the selected elements. With `wholeFiles`, the files holding the selected elements
* are deleted instead, even if only a track or a waypoint is selected.
*/
delete(wholeFiles = false) {
return this.run((w) => w._delete(wholeFiles));
}
deleteAll() {
@@ -263,8 +271,22 @@ class Engine {
return this.run((w) => w.select(idsToBytes(fileIds), selectMode(w, mode)));
}
/**
* Selects all the elements of the same kind as the selected ones, in the same place: all the
* files, the tracks of the file, the segments of the track, the waypoints of the file. All
* the files when nothing is selected.
*/
selectAll() {
return this.select(get(this._order));
return this.run((w) => w.select_all());
}
/**
* Moves the selection to the next (`down`) or previous element of the same kind, like the
* arrow keys do. With `add` (shift + arrow), the element is added to the selection, otherwise
* it replaces it.
*/
arrowSelect(down: boolean, add: boolean) {
return this.run((w) => w.arrow_select(down, add));
}
// Selecting elements inside a file. Ids that do not exist are ignored.
@@ -316,36 +338,6 @@ class Engine {
}
}
/** Selects the node and its siblings (all the files, the tracks of a file, and so on). */
selectAllSiblings(node: FileTreeNode) {
const state = get(this._files).get(node.fileId);
if (node.type === 'file' || !state) {
return this.selectAll();
}
const { tracks, waypoints } = get(state).structure;
switch (node.type) {
case 'track':
return this.selectTracks(
node.fileId,
tracks.map((track) => track.id)
);
case 'segment':
return this.selectSegments(
node.fileId,
node.trackId,
tracks.find((track) => track.id === node.trackId)?.segments.map((s) => s.id) ??
[]
);
case 'waypoint':
return this.selectWaypoints(
node.fileId,
waypoints.map((waypoint) => waypoint.id)
);
case 'waypoints':
return this.selectWaypointGroup(node.fileId);
}
}
// Edits of the selection
newTrack() {
@@ -414,6 +406,8 @@ class Engine {
/** Reads what the last action changed. */
private sync(wasm: Wasm) {
const update = wasm.last_update();
this._canUndo.set(wasm.can_undo());
this._canRedo.set(wasm.can_redo());
if (update.selectionChanged) {
this._selection.set(wasm.selection());
}