This commit is contained in:
vcoppe
2026-10-04 11:00:46 +02:00
parent caba9388af
commit f458a1104f
15 changed files with 797 additions and 455 deletions
+49
View File
@@ -0,0 +1,49 @@
import { readable } from 'svelte/store';
import { engine, FileStateCollectionObserver, type FileState, type Selection } from '$lib/engine';
import { isSelectionHidden } from '$lib/selection-helpers';
/**
* Whether all the selected elements are hidden. It follows the selection and, through the
* observer of the files, the state of every file (hiding, editing).
*/
export const allHidden = readable(false, (set) => {
let selection: Selection = { type: 'empty' };
const states = new Map<string, FileState>();
const unsubscribes = new Map<string, () => void>();
const update = () => set(isSelectionHidden(selection, states));
const unsubscribeSelection = engine.selection.subscribe(($selection) => {
selection = $selection;
update();
});
const observer = new FileStateCollectionObserver(
(files) => {
files.forEach((store, fileId) => {
unsubscribes.set(
fileId,
store.subscribe(($file) => {
states.set(fileId, $file);
update();
})
);
});
},
(fileId) => {
unsubscribes.get(fileId)?.();
unsubscribes.delete(fileId);
states.delete(fileId);
update();
},
() => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
unsubscribes.clear();
states.clear();
}
);
return () => {
unsubscribeSelection();
observer.destroy();
};
});
@@ -1,15 +1,17 @@
<script lang="ts">
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import * as ContextMenu from '$lib/components/ui/context-menu';
import FileListNode from './FileListNode.svelte';
import FileListNodeContent from './FileListNodeContent.svelte';
import { onMount, setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem } from './file-list';
import { ListLevel } from './file-list';
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
import Shortcut from '$lib/components/Shortcut.svelte';
import { i18n } from '$lib/i18n.svelte';
import { fileStateCollection } from '$lib/logic/file-state';
import { engine } from '$lib/engine';
import { selectedFileIds } from '$lib/selection-helpers';
import { createFile, pasteSelection } from '$lib/logic/file-actions';
import { selection, copied } from '$lib/logic/selection';
// TODO the clipboard still works on the previous implementation
import { copied } from '$lib/logic/selection';
import { allowedPastes } from './sortable-file-list';
let {
@@ -27,17 +29,12 @@
setContext('orientation', orientation);
setContext('recursive', recursive);
const { files, selection } = engine;
onMount(() => {
if (orientation === 'horizontal') {
selection.update(($selection) => {
$selection.forEach((item) => {
if (!(item instanceof ListFileItem)) {
$selection.toggle(item);
$selection.set(new ListFileItem(item.getFileId()), true);
}
});
return $selection;
});
if (orientation === 'horizontal' && $selection.type !== 'file') {
// only files can be selected here
engine.select(selectedFileIds($selection));
}
});
</script>
@@ -54,7 +51,7 @@
: 'flex-row'} {className ?? ''}"
{style}
>
<FileListNode node={$fileStateCollection} item={new ListRootItem()} />
<FileListNodeContent node={null} />
{#if orientation === 'vertical'}
<ContextMenu.Root>
<ContextMenu.Trigger class="grow" />
@@ -66,8 +63,8 @@
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
onclick={() => selection.selectAll()}
disabled={$fileStateCollection.size === 0}
onclick={() => engine.selectAll()}
disabled={$files.size === 0}
>
<FileStack size="16" />
{i18n._('menu.select_all')}
@@ -1,89 +1,93 @@
<script lang="ts">
import {
GPXFile,
Track,
TrackSegment,
Waypoint,
type AnyGPXTreeElement,
type GPXTreeElement,
} from 'gpx';
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { type Readable } from 'svelte/store';
import FileListNodeContent from './FileListNodeContent.svelte';
import FileListNodeLabel from './FileListNodeLabel.svelte';
import { getContext } from 'svelte';
import {
ListFileItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
type ListItem,
type ListTrackItem,
} from './file-list';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { selection } from '$lib/logic/selection';
import { engine, type FileState } from '$lib/engine';
import {
elementId,
hasSelectionWithin,
isSelected,
type FileTreeNode,
} from '$lib/selection-helpers';
let {
fileState,
node,
item,
}: {
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
item: ListItem;
fileState: FileState;
node: FileTreeNode;
} = $props();
let recursive = getContext<boolean>('recursive');
let collapsible: CollapsibleTreeNode | undefined = $state();
let label = $derived(
node instanceof GPXFile && item instanceof ListFileItem
? node.metadata.name
: node instanceof Track
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
: node instanceof TrackSegment
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
: node instanceof Waypoint
? (node.name ??
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
: node instanceof GPXFile && item instanceof ListWaypointsItem
? i18n._('gpx.waypoints')
: ''
);
let label = $derived.by(() => {
const { structure } = fileState;
switch (node.type) {
case 'file':
return structure.name;
case 'track': {
const trackIndex = structure.tracks.findIndex((t) => t.id === node.trackId);
return (
structure.tracks[trackIndex]?.name ?? `${i18n._('gpx.track')} ${trackIndex + 1}`
);
}
case 'segment': {
const track = structure.tracks.find((t) => t.id === node.trackId);
const segmentIndex =
track?.segments.findIndex((s) => s.id === node.segmentId) ?? -1;
return `${i18n._('gpx.segment')} ${segmentIndex + 1}`;
}
case 'waypoints':
return i18n._('gpx.waypoints');
case 'waypoint': {
const waypointIndex = structure.waypoints.findIndex(
(w) => w.id === node.waypointId
);
return (
structure.waypoints[waypointIndex]?.name ??
`${i18n._('gpx.waypoint')} ${waypointIndex + 1}`
);
}
}
});
// identifies the node in the tree
let id = $derived(elementId(node));
const { treeFileView } = settings;
const { selection } = engine;
$effect(() => {
if (collapsible && $treeFileView && $selection.hasAnyChildren(item, false)) {
// open the node when something below it is selected
if (
collapsible &&
$treeFileView &&
hasSelectionWithin($selection, node) &&
!isSelected($selection, node)
) {
collapsible.openNode();
}
});
</script>
{#if node instanceof Map}
<FileListNodeContent {node} {item} />
{:else if node instanceof TrackSegment}
<FileListNodeLabel {node} {item} {label} />
{:else if node instanceof Waypoint}
<FileListNodeLabel {node} {item} {label} />
{#if node.type === 'segment' || node.type === 'waypoint'}
<FileListNodeLabel {fileState} {node} {label} />
{:else if recursive}
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
<CollapsibleTreeNode {id} bind:this={collapsible}>
{#snippet trigger()}
<FileListNodeLabel {node} {item} {label} />
<FileListNodeLabel {fileState} {node} {label} />
{/snippet}
{#snippet content()}
<div class="ml-4">
{#key node}
<FileListNodeContent {node} {item} />
{/key}
<FileListNodeContent {fileState} {node} />
</div>
{/snippet}
</CollapsibleTreeNode>
{:else}
<FileListNodeLabel {node} {item} {label} />
<FileListNodeLabel {fileState} {node} {label} />
{/if}
@@ -1,41 +1,39 @@
<script lang="ts">
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
import { getContext, onDestroy, onMount } from 'svelte';
import { type Readable } from 'svelte/store';
import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte';
import FileListNodeContent from './FileListNodeContent.svelte';
import { ListFileItem, ListLevel, ListWaypointsItem, type ListItem } from './file-list';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { ListLevel } from './file-list';
import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
import { engine, type FileState } from '$lib/engine';
import type { FileTreeNode } from '$lib/selection-helpers';
let {
fileState,
node,
item,
waypointRoot = false,
}: {
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
item: ListItem;
/** State of the file of the node (not needed for the root). */
fileState?: FileState;
/** The node whose children are listed, `null` for the list of the files. */
node: FileTreeNode | null;
/** List the node standing for the waypoints of the file, instead of its tracks. */
waypointRoot?: boolean;
} = $props();
const { files, order } = engine;
let container: HTMLElement;
let sortableLevel: ListLevel =
node instanceof Map
node === null
? ListLevel.FILE
: node instanceof GPXFile
: node.type === 'file'
? waypointRoot
? ListLevel.WAYPOINTS
: item instanceof ListWaypointsItem
? ListLevel.WAYPOINT
: ListLevel.TRACK
: node instanceof Track
? ListLevel.SEGMENT
: ListLevel.WAYPOINT;
: ListLevel.TRACK
: node.type === 'waypoints'
? ListLevel.WAYPOINT
: ListLevel.SEGMENT;
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
@@ -43,18 +41,14 @@
let sortable: SortableFileList;
onMount(() => {
sortable = new SortableFileList(
container,
node,
item,
waypointRoot,
sortableLevel,
orientation
);
sortable = new SortableFileList(container, node, waypointRoot, sortableLevel, orientation);
});
$effect(() => {
if (sortable && node) {
// the elements of the list changed
$order;
fileState;
if (sortable) {
sortable.updateElements();
}
});
@@ -70,45 +64,65 @@
? 'flex-col'
: 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}"
>
{#if node instanceof Map}
{#each node as [fileId, file] (fileId)}
<div data-id={fileId}>
<FileListNodeStore {file} />
</div>
{/each}
{:else if node instanceof GPXFile}
{#if item instanceof ListWaypointsItem}
{#each node.wpt as wpt, i (wpt)}
<div data-id={i} class="ml-1">
<FileListNode node={wpt} item={item.extend(i)} />
</div>
{/each}
{:else if waypointRoot}
{#if node.wpt.length > 0}
<div data-id="waypoints">
<FileListNode {node} item={item.extend('waypoints')} />
{#if node === null}
{#each $order as fileId (fileId)}
{@const file = $files.get(fileId)}
{#if file}
<div data-id={fileId}>
<FileListNodeStore {file} />
</div>
{/if}
{:else}
{#each node.children as child, i (child)}
<div data-id={i}>
<FileListNode node={child} item={item.extend(i)} />
{/each}
{:else if fileState}
{#if node.type === 'file'}
{#if waypointRoot}
{#if fileState.structure.waypoints.length > 0}
<div data-id="waypoints">
<FileListNode
{fileState}
node={{ type: 'waypoints', fileId: node.fileId }}
/>
</div>
{/if}
{:else}
{#each fileState.structure.tracks as track (track.id)}
<div data-id={track.id}>
<FileListNode
{fileState}
node={{ type: 'track', fileId: node.fileId, trackId: track.id }}
/>
</div>
{/each}
{/if}
{:else if node.type === 'waypoints'}
{#each fileState.structure.waypoints as waypoint (waypoint.id)}
<div data-id={waypoint.id} class="ml-1">
<FileListNode
{fileState}
node={{ type: 'waypoint', fileId: node.fileId, waypointId: waypoint.id }}
/>
</div>
{/each}
{:else if node.type === 'track'}
{#each fileState.structure.tracks.find((t) => t.id === node.trackId)?.segments ?? [] as segment (segment.id)}
<div data-id={segment.id} class="ml-1">
<FileListNode
{fileState}
node={{
type: 'segment',
fileId: node.fileId,
trackId: node.trackId,
segmentId: segment.id,
}}
/>
</div>
{/each}
{/if}
{:else if node instanceof Track}
{#each node.children as child, i (child)}
<div data-id={i} class="ml-1">
<FileListNode node={child} item={item.extend(i)} />
</div>
{/each}
{/if}
</div>
{#if node instanceof GPXFile && item instanceof ListFileItem}
{#if !waypointRoot}
<FileListNodeContent {node} {item} waypointRoot={true} />
{/if}
{#if fileState && node?.type === 'file' && !waypointRoot}
<FileListNodeContent {fileState} {node} waypointRoot={true} />
{/if}
<style lang="postcss">
@@ -18,86 +18,95 @@
Scissors,
FileStack,
} from '@lucide/svelte';
import {
ListFileItem,
ListLevel,
ListTrackItem,
ListWaypointItem,
type ListItem,
} from './file-list';
import { ListLevel } from './file-list';
import { getContext } from 'svelte';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { i18n } from '$lib/i18n.svelte';
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { selection, copied, cut } from '$lib/logic/selection';
import { fileActions, pasteSelection } from '$lib/logic/file-actions';
import { allHidden } from '$lib/logic/hidden';
import { engine, type FileState } from '$lib/engine';
import { allHidden } from '$lib/all-hidden';
import { normalizeColor } from '$lib/file-colors';
import { isHidden } from '$lib/file-visibility';
import {
elementId,
isSelected,
selectedElementIds,
selectionSize,
type FileTreeNode,
} from '$lib/selection-helpers';
// TODO the clipboard, centering on the selection and the waypoint popup still work on the
// previous implementation
import { copied, selection as oldSelection } from '$lib/logic/selection';
import { pasteSelection } from '$lib/logic/file-actions';
import { boundsManager } from '$lib/logic/bounds';
import { gpxColors, gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { fileStateCollection } from '$lib/logic/file-state';
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
// import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
// import { fileStateCollection } from '$lib/logic/file-state';
// import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { allowedPastes } from './sortable-file-list';
let {
fileState,
node,
item,
label,
}: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
item: ListItem;
fileState: FileState;
node: FileTreeNode;
label: string | undefined;
} = $props();
const { selection } = engine;
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding');
let singleSelection = $derived($selection.size === 1);
const levels = {
file: ListLevel.FILE,
track: ListLevel.TRACK,
segment: ListLevel.SEGMENT,
waypoints: ListLevel.WAYPOINTS,
waypoint: ListLevel.WAYPOINT,
};
let level = $derived(levels[node.type]);
let singleSelection = $derived(selectionSize($selection) === 1);
let nodeColors: string[] = $derived.by(() => {
let colors: string[] = [];
if (node) {
if (node instanceof GPXFile) {
let defaultColor = $gpxColors.get(item.getFileId());
let style = node.getStyle(defaultColor);
colors = style.color;
} else if (node instanceof Track) {
let style = node.getStyle();
if (
style &&
style['gpx_style:color'] &&
!colors.includes(style['gpx_style:color'])
) {
colors.push(style['gpx_style:color']);
}
if (colors.length === 0) {
let defaultColor = $gpxColors.get(item.getFileId());
if (defaultColor) {
colors.push(defaultColor);
}
}
}
const { tracks } = fileState.structure;
if (node.type === 'file') {
// the colors defined by the tracks, or else the one of the file
const colors = [
...new Set(
tracks.flatMap((track) =>
track.color !== undefined ? [normalizeColor(track.color)] : []
)
),
];
return colors.length > 0 ? colors : [fileState.color];
} else if (node.type === 'track') {
const color = tracks.find((track) => track.id === node.trackId)?.color;
return [color !== undefined ? normalizeColor(color) : fileState.color];
}
return colors;
return [];
});
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
let openEditMetadata: boolean = $derived(
editMetadata.current && singleSelection && $selection.has(item)
let symbolKey = $derived(
node.type === 'waypoint'
? getSymbolKey(fileState.structure.waypoints.find((w) => w.id === node.waypointId)?.sym)
: undefined
);
let selected = $derived(isSelected($selection, node));
let openEditMetadata: boolean = $derived(editMetadata.current && singleSelection && selected);
let openEditStyle: boolean = $derived(
editStyle.current &&
$selection.has(item) &&
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0
selected &&
selectedElementIds($selection)[0]?.ids[0] === elementId(node)
);
let hidden = $derived(
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden
);
let hidden = $derived(isHidden(fileState, elementId(node)));
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -105,8 +114,8 @@
<ContextMenu.Root
onOpenChange={(open) => {
if (open) {
if (!$selection.has(item)) {
selection.selectItem(item);
if (!selected) {
engine.selectNode(node);
}
}
}}
@@ -119,11 +128,11 @@
? 'h-7'
: 'h-9 px-1.5'} pointer-events-auto"
>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
<StyleDialog bind:open={openEditStyle} {item} />
{#if node.type === 'file' || node.type === 'track'}
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
<StyleDialog bind:open={openEditStyle} {fileState} {node} />
{/if}
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
{#if level === ListLevel.FILE || level === ListLevel.TRACK}
<div
class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-0 w-1'
@@ -141,9 +150,7 @@
<span
class="grow text-left truncate ml-1 flex flex-row items-center {hidden
? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
? 'text-muted-foreground'
: ''}"
: ''} {/* TODO cut elements: the clipboard works on the previous implementation */ ''}"
oncontextmenu={(e) => {
if (embedding) {
e.preventDefault();
@@ -154,37 +161,35 @@
// Add to selection instead of opening context menu
e.preventDefault();
e.stopPropagation();
$selection.toggle(item);
$selection = $selection;
engine.selectNode(node, 'toggle');
}
}}
onmouseenter={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.getLayer(item.getFileId());
let file = fileStateCollection.getFile(item.getFileId());
if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint && !waypoint._data.hidden) {
waypointPopup?.setItem({
item: waypoint,
fileId: item.getFileId(),
});
}
}
}
// TODO waypoint popup: it needs the waypoint of the previous implementation
// if (node.type === 'waypoint') {
// let layer = gpxLayers.getLayer(node.fileId);
// let file = fileStateCollection.getFile(node.fileId);
// if (layer && file) {
// let waypoint = file.wpt[waypointIndex];
// if (waypoint && !waypoint._data.hidden) {
// waypointPopup?.setItem({ item: waypoint, fileId: node.fileId });
// }
// }
// }
}}
onmouseleave={() => {
if (item instanceof ListWaypointItem) {
let layer = gpxLayers.getLayer(item.getFileId());
if (layer) {
waypointPopup?.setItem(null);
}
}
// TODO waypoint popup
// if (node.type === 'waypoint') {
// let layer = gpxLayers.getLayer(node.fileId);
// if (layer) {
// waypointPopup?.setItem(null);
// }
// }
}}
>
{#if item.level === ListLevel.SEGMENT}
{#if level === ListLevel.SEGMENT}
<Waypoints size="16" class="mx-1 shrink-0" />
{:else if item.level === ListLevel.WAYPOINT}
{:else if level === ListLevel.WAYPOINT}
{#if symbolKey && symbols[symbolKey].icon}
{@const SymbolIcon = symbols[symbolKey].icon}
<SymbolIcon size="16" class="mx-1 shrink-0" />
@@ -211,7 +216,7 @@
</Button>
</ContextMenu.Trigger>
<ContextMenu.Content>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
{#if node.type === 'file' || node.type === 'track'}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() => (editMetadata.current = true)}
@@ -225,15 +230,7 @@
{i18n._('menu.style.button')}
</ContextMenu.Item>
{/if}
<ContextMenu.Item
onclick={() => {
if ($allHidden) {
fileActions.setHiddenToSelection(false);
} else {
fileActions.setHiddenToSelection(true);
}
}}
>
<ContextMenu.Item onclick={() => engine.setSelectionHidden(!$allHidden)}>
{#if $allHidden}
<Eye size="16" />
{i18n._('menu.unhide')}
@@ -245,20 +242,16 @@
</ContextMenu.Item>
<ContextMenu.Separator />
{#if orientation === 'vertical'}
{#if item instanceof ListFileItem}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() => fileActions.addNewTrack(item.getFileId())}
>
{#if node.type === 'file'}
<ContextMenu.Item disabled={!singleSelection} onclick={() => engine.newTrack()}>
<Plus size="16" />
{i18n._('menu.new_track')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else if item instanceof ListTrackItem}
{:else if node.type === 'track'}
<ContextMenu.Item
disabled={!singleSelection}
onclick={() =>
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
onclick={() => engine.newTrackSegment()}
>
<Plus size="16" />
{i18n._('menu.new_segment')}
@@ -266,8 +259,8 @@
<ContextMenu.Separator />
{/if}
{/if}
{#if item.level !== ListLevel.WAYPOINTS}
<ContextMenu.Item onclick={() => selection.selectAll()}>
{#if level !== ListLevel.WAYPOINTS}
<ContextMenu.Item onclick={() => engine.selectAllSiblings(node)}>
<FileStack size="16" />
{i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} />
@@ -279,18 +272,18 @@
<Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.duplicateSelection}>
<ContextMenu.Item onclick={() => engine.duplicate()}>
<Copy size="16" />
{i18n._('menu.duplicate')}
<Shortcut key="D" ctrl={true} />
</ContextMenu.Item>
{#if orientation === 'vertical'}
<ContextMenu.Item onclick={() => selection.copySelection()}>
<ContextMenu.Item onclick={() => oldSelection.copySelection()}>
<ClipboardCopy size="16" />
{i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item onclick={() => selection.cutSelection()}>
<ContextMenu.Item onclick={() => oldSelection.cutSelection()}>
<Scissors size="16" />
{i18n._('menu.cut')}
<Shortcut key="X" ctrl={true} />
@@ -298,7 +291,7 @@
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)}
!allowedPastes[$copied[0].level].includes(level)}
onclick={pasteSelection}
>
<ClipboardPaste size="16" />
@@ -307,7 +300,7 @@
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.deleteSelection}>
<ContextMenu.Item onclick={() => engine.delete()}>
<Trash2 size="16" />
{i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} />
@@ -4,24 +4,21 @@
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import { ListFileItem } from './file-list';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import type { FileState } from '$lib/engine';
let {
file,
}: {
file: Readable<GPXFileWithStatistics | undefined>;
file: Readable<FileState>;
} = $props();
let recursive = getContext<boolean>('recursive');
</script>
{#if $file}
{#if recursive}
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
</CollapsibleTree>
{:else}
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
{/if}
{#if recursive}
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
<FileListNode fileState={$file} node={{ type: 'file', fileId: $file.structure.id }} />
</CollapsibleTree>
{:else}
<FileListNode fileState={$file} node={{ type: 'file', fileId: $file.structure.id }} />
{/if}
@@ -5,35 +5,31 @@
import { Label } from '$lib/components/ui/label/index.js';
import * as Popover from '$lib/components/ui/popover';
import { Save } from '@lucide/svelte';
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { i18n } from '$lib/i18n.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { engine, type FileState } from '$lib/engine';
import type { FileTreeNode } from '$lib/selection-helpers';
let {
fileState,
node,
item,
open = $bindable(),
}: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
item: ListItem;
fileState: FileState;
node: FileTreeNode;
open: boolean;
} = $props();
let track = $derived(
node.type === 'track'
? fileState.structure.tracks.find((track) => track.id === node.trackId)
: undefined
);
let name: string = $derived(
node instanceof GPXFile
? (node.metadata.name ?? '')
: node instanceof Track
? (node.name ?? '')
: ''
node.type === 'file' ? fileState.structure.name : (track?.name ?? '')
);
let description: string = $derived(
node instanceof GPXFile
? (node.metadata.desc ?? '')
: node instanceof Track
? (node.desc ?? '')
: ''
node.type === 'file' ? (fileState.structure.desc ?? '') : (track?.desc ?? '')
);
$effect(() => {
@@ -53,18 +49,8 @@
<Button
variant="outline"
onclick={() => {
fileActionManager.applyToFile(item.getFileId(), (file) => {
if (item instanceof ListFileItem && node instanceof GPXFile) {
file.metadata.name = name;
file.metadata.desc = description;
if (file.trk.length === 1) {
file.trk[0].name = name;
}
} else if (item instanceof ListTrackItem && node instanceof Track) {
file.trk[item.getTrackIndex()].name = name;
file.trk[item.getTrackIndex()].desc = description;
}
});
// the metadata of the selected elements are changed
engine.metadata(name, description);
open = false;
}}
>
@@ -1,15 +1,15 @@
import { isMac } from '$lib/utils';
import Sortable, { type Direction } from 'sortablejs/Sortable';
import { ListItem, ListLevel, ListRootItem } from './file-list';
import { selection } from '$lib/logic/selection';
import { getFileIds, moveItems } from '$lib/logic/file-actions';
import { get, writable, type Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import type { AnyGPXTreeElement, GPXTreeElement, Waypoint } from 'gpx';
import { ListLevel } from './file-list';
import { get, writable } from 'svelte/store';
import { tick } from 'svelte';
const { fileOrder } = settings;
import { engine } from '$lib/engine';
import { isSelected, type FileTreeNode } from '$lib/selection-helpers';
// TODO moving elements between parents is not available in the engine yet
// import { ListItem, ListRootItem } from './file-list';
// import { getFileIds, moveItems } from '$lib/logic/file-actions';
// import { settings } from '$lib/logic/settings';
// const { fileOrder } = settings;
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
@@ -31,13 +31,12 @@ export const allowedPastes: Record<ListLevel, ListLevel[]> = {
export const dragging = writable<ListLevel | null>(null);
/**
* Makes a list of the file tree sortable and selectable. The elements of the list are the
* children of `parent` (the files when `parent` is null), their ids are in their `data-id`.
*/
export class SortableFileList {
private _node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
private _item: ListItem;
private _parent: FileTreeNode | null;
private _sortableLevel: ListLevel;
private _container: HTMLElement;
private _sortable: Sortable | null = null;
@@ -47,26 +46,24 @@ export class SortableFileList {
constructor(
container: HTMLElement,
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint,
item: ListItem,
parent: FileTreeNode | null,
waypointRoot: boolean,
sortableLevel: ListLevel,
orientation: Direction
) {
this._node = node;
this._item = item;
this._parent = parent;
this._sortableLevel = sortableLevel;
this._container = container;
// TODO only the order of the files can be changed for now: the other lists neither sort
// nor exchange elements (allowedMoves, to be used again once the engine can move elements)
const sortable = sortableLevel === ListLevel.FILE;
this._sortable = Sortable.create(container, {
group: {
name: sortableLevel,
pull: allowedMoves[sortableLevel],
put: true,
pull: sortable ? allowedMoves[sortableLevel] : false,
put: sortable ? [ListLevel.FILE] : false,
},
sort: sortable,
direction: orientation,
forceAutoScrollFallback: true,
multiDrag: true,
@@ -80,76 +77,103 @@ export class SortableFileList {
onEnd: () => dragging.set(null),
onSort: (e: Sortable.SortableEvent) => this.onSort(e),
});
Object.defineProperty(this._sortable, '_item', {
value: item,
writable: true,
});
Object.defineProperty(this._sortable, '_waypointRoot', {
value: waypointRoot,
writable: true,
});
this._unsubscribes.push(
selection.subscribe(() => tick().then(() => this.updateFromSelection()))
engine.selection.subscribe(() => tick().then(() => this.updateFromSelection()))
);
this._unsubscribes.push(fileOrder.subscribe(() => this.updateFromFileOrder()));
this._unsubscribes.push(engine.order.subscribe(() => this.updateFromFileOrder()));
}
/** The node of a child of the list. */
childNode(id: string): FileTreeNode | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case ListLevel.FILE:
return { type: 'file', fileId: id };
case ListLevel.TRACK:
return parent ? { type: 'track', fileId: parent.fileId, trackId: id } : undefined;
case ListLevel.SEGMENT:
return parent?.type === 'track'
? {
type: 'segment',
fileId: parent.fileId,
trackId: parent.trackId,
segmentId: id,
}
: undefined;
case ListLevel.WAYPOINTS:
return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined;
case ListLevel.WAYPOINT:
return parent
? { type: 'waypoint', fileId: parent.fileId, waypointId: id }
: undefined;
}
}
onSort(e: Sortable.SortableEvent) {
this.updateToFileOrder();
const from = Sortable.get(e.from);
const to = Sortable.get(e.to);
// TODO moving elements between parents: the engine has no command for it yet, the code
// below worked on the previous implementation
// onSort(e: Sortable.SortableEvent) {
// this.updateToFileOrder();
if (!from || !to) {
return;
}
// const from = Sortable.get(e.from);
// const to = Sortable.get(e.to);
let fromItem = from._item;
let toItem = to._item;
// if (!from || !to) {
// return;
// }
if (this._item === toItem && !(fromItem instanceof ListRootItem)) {
// Event is triggered on source and destination list, only handle it once
let fromItems = [];
let toItems = [];
// let fromItem = from._item;
// let toItem = to._item;
if (from._waypointRoot) {
fromItems = [fromItem.extend('waypoints')];
} else {
let oldIndices: number[] =
e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
oldIndices = oldIndices.filter((i) => i >= 0);
oldIndices.sort((a, b) => a - b);
// if (this._item === toItem && !(fromItem instanceof ListRootItem)) {
// // Event is triggered on source and destination list, only handle it once
// let fromItems = [];
// let toItems = [];
fromItems = oldIndices.map((i) => fromItem.extend(i));
}
// if (from._waypointRoot) {
// fromItems = [fromItem.extend('waypoints')];
// } else {
// let oldIndices: number[] =
// e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
// oldIndices = oldIndices.filter((i) => i >= 0);
// oldIndices.sort((a, b) => a - b);
if (from._waypointRoot && to._waypointRoot) {
toItems = [toItem.extend('waypoints')];
} else {
if (to._waypointRoot) {
toItem = toItem.extend('waypoints');
}
// fromItems = oldIndices.map((i) => fromItem.extend(i));
// }
let newIndices: number[] =
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
newIndices = newIndices.filter((i) => i >= 0);
newIndices.sort((a, b) => a - b);
// if (from._waypointRoot && to._waypointRoot) {
// toItems = [toItem.extend('waypoints')];
// } else {
// if (to._waypointRoot) {
// toItem = toItem.extend('waypoints');
// }
if (toItem instanceof ListRootItem) {
let newFileIds = getFileIds(newIndices.length);
toItems = newIndices.map((i, index) => {
get(fileOrder).splice(i, 0, newFileIds[index]);
return this._item.extend(newFileIds[index]);
});
} else {
toItems = newIndices.map((i) => toItem.extend(i));
}
}
// let newIndices: number[] =
// e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
// newIndices = newIndices.filter((i) => i >= 0);
// newIndices.sort((a, b) => a - b);
moveItems(fromItem, toItem, fromItems, toItems);
}
// if (toItem instanceof ListRootItem) {
// let newFileIds = getFileIds(newIndices.length);
// toItems = newIndices.map((i, index) => {
// get(fileOrder).splice(i, 0, newFileIds[index]);
// return this._item.extend(newFileIds[index]);
// });
// } else {
// toItems = newIndices.map((i) => toItem.extend(i));
// }
// }
// moveItems(fromItem, toItem, fromItems, toItems);
// }
// }
}
updateFromSelection() {
@@ -157,11 +181,12 @@ export class SortableFileList {
if (changed.length === 0) {
return;
}
const selection_ = get(selection);
const selection_ = get(engine.selection);
for (let id of changed) {
let element = this._elements[id];
if (element) {
if (selection_.has(this._item.extend(id))) {
let node = this.childNode(id);
if (element && node) {
if (isSelected(selection_, node)) {
Sortable.utils.select(element);
element.scrollIntoView({
behavior: 'smooth',
@@ -183,27 +208,42 @@ export class SortableFileList {
this._updatingSelection = false;
return;
}
selection.update(($selection) => {
$selection.clear();
Object.entries(this._elements).forEach(([id, element]) => {
$selection.set(
this._item.extend(this.getRealId(id)),
element.classList.contains('sortable-selected')
);
});
if (
e.originalEvent &&
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
($selection.size > 1 ||
!$selection.has(this._item.extend(this.getRealId(changed[0]))))
) {
// Fix bug that sometimes causes a single select to be treated as a multi-select
$selection.clear();
$selection.set(this._item.extend(this.getRealId(changed[0])), true);
let selected = Object.entries(this._elements)
.filter(([, element]) => element.classList.contains('sortable-selected'))
.map(([id]) => id);
if (
e.originalEvent &&
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
(selected.length > 1 || !selected.includes(changed[0]))
) {
// Fix bug that sometimes causes a single select to be treated as a multi-select
selected = [changed[0]];
}
const parent = this._parent;
if (selected.length === 0 || !this.childNode(selected[0])) {
engine.select([]);
} else {
switch (this._sortableLevel) {
case ListLevel.FILE:
engine.select(selected);
break;
case ListLevel.TRACK:
engine.selectTracks(parent!.fileId, selected);
break;
case ListLevel.SEGMENT:
engine.selectSegments(parent!.fileId, (parent as any).trackId, selected);
break;
case ListLevel.WAYPOINTS:
engine.selectWaypointGroup(parent!.fileId);
break;
case ListLevel.WAYPOINT:
engine.selectWaypoints(parent!.fileId, selected);
break;
}
return $selection;
});
}
this._updatingSelection = false;
}
@@ -212,7 +252,7 @@ export class SortableFileList {
return;
}
const fileOrder_ = get(fileOrder);
const fileOrder_ = get(engine.order);
const sortableOrder = this._sortable.toArray();
if (
@@ -228,24 +268,25 @@ export class SortableFileList {
return;
}
const fileOrder_ = get(fileOrder);
const fileOrder_ = get(engine.order);
const sortableOrder = this._sortable.toArray();
if (
fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index])
) {
fileOrder.set(sortableOrder);
engine.reorder(sortableOrder, 0);
}
}
updateElements() {
this._elements = {};
const files = get(engine.files);
this._container.childNodes.forEach((element) => {
if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id');
if (attr) {
if (this._node instanceof Map && !this._node.has(attr)) {
if (this._sortableLevel === ListLevel.FILE && !files.has(attr)) {
element.remove();
} else {
this._elements[attr] = element;
@@ -262,23 +303,16 @@ export class SortableFileList {
}
getChangedIds() {
let changed: (string | number)[] = [];
const selection_ = get(selection);
let changed: string[] = [];
const selection_ = get(engine.selection);
Object.entries(this._elements).forEach(([id, element]) => {
let realId = this.getRealId(id);
let realItem = this._item.extend(realId);
let inSelection = selection_.has(realItem);
let isSelected = element.classList.contains('sortable-selected');
if (inSelection !== isSelected) {
changed.push(realId);
const node = this.childNode(id);
let inSelection = node !== undefined && isSelected(selection_, node);
let isSelectedInList = element.classList.contains('sortable-selected');
if (inSelection !== isSelectedInList) {
changed.push(id);
}
});
return changed;
}
getRealId(id: string | number) {
return this._sortableLevel === ListLevel.FILE || this._sortableLevel === ListLevel.WAYPOINTS
? id
: parseInt(id as string);
}
}
@@ -5,30 +5,26 @@
import { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover';
import { Save } from '@lucide/svelte';
import {
ListFileItem,
ListTrackItem,
type ListItem,
} from '$lib/components/file-list/file-list';
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { i18n } from '$lib/i18n.svelte';
import type { LineStyleExtension } from 'gpx';
import { settings } from '$lib/logic/settings';
import { selection } from '$lib/logic/selection';
import { fileStateCollection } from '$lib/logic/file-state';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { engine, type FileState } from '$lib/engine';
import { normalizeColor } from '$lib/file-colors';
import { selectedFileIds, type FileTreeNode } from '$lib/selection-helpers';
import { untrack } from 'svelte';
import { fileActions } from '$lib/logic/file-actions';
let {
item,
fileState,
node,
open = $bindable(),
}: {
item: ListItem;
fileState: FileState;
node: FileTreeNode;
open: boolean;
} = $props();
const { defaultOpacity, defaultWidth } = settings;
const { files, selection } = engine;
let color: string = $state('');
let opacity: number = $state(0);
@@ -37,45 +33,24 @@
let opacityChanged = $state(false);
let widthChanged = $state(false);
// TODO the inputs used to come from the last selected item, they now come from this node
function setStyleInputs() {
opacity = $defaultOpacity;
width = $defaultWidth;
color = fileState.color;
$selection.forEach((item) => {
if (item instanceof ListFileItem) {
let file = fileStateCollection.getFile(item.getFileId());
let layer = gpxLayers.getLayer(item.getFileId());
if (file && layer) {
let style = file.getStyle();
color = layer.layerColor;
if (style.opacity.length > 0) {
opacity = style.opacity[0];
}
if (style.width.length > 0) {
width = style.width[0];
}
}
} else if (item instanceof ListTrackItem) {
let file = fileStateCollection.getFile(item.getFileId());
let layer = gpxLayers.getLayer(item.getFileId());
if (file && layer) {
color = layer.layerColor;
let track = file.trk[item.getTrackIndex()];
let style = track.getStyle();
if (style) {
if (style['gpx_style:color']) {
color = style['gpx_style:color'];
}
if (style['gpx_style:opacity']) {
opacity = style['gpx_style:opacity'];
}
if (style['gpx_style:width']) {
width = style['gpx_style:width'];
}
}
}
const { tracks } = fileState.structure;
if (node.type === 'file') {
opacity = tracks.find((track) => track.opacity !== undefined)?.opacity ?? opacity;
width = tracks.find((track) => track.width !== undefined)?.width ?? width;
} else if (node.type === 'track') {
const track = tracks.find((track) => track.id === node.trackId);
if (track) {
color = track.color !== undefined ? normalizeColor(track.color) : color;
opacity = track.opacity ?? opacity;
width = track.width ?? width;
}
});
}
colorChanged = false;
opacityChanged = false;
@@ -95,24 +70,18 @@
});
function applyStyle() {
let style: LineStyleExtension = {};
if (colorChanged) {
style['gpx_style:color'] = color;
}
if (opacityChanged) {
style['gpx_style:opacity'] = opacity;
}
if (widthChanged) {
style['gpx_style:width'] = width;
}
fileActions.setStyleToSelection(style);
engine.style({
color: colorChanged ? color : undefined,
opacity: opacityChanged ? opacity : undefined,
width: widthChanged ? width : undefined,
});
if (item instanceof ListFileItem && $selection.size === fileStateCollection.size) {
if (style['gpx_style:opacity']) {
$defaultOpacity = style['gpx_style:opacity'];
if (node.type === 'file' && selectedFileIds($selection).length === $files.size) {
if (opacityChanged) {
$defaultOpacity = opacity;
}
if (style['gpx_style:width']) {
$defaultWidth = style['gpx_style:width'];
if (widthChanged) {
$defaultWidth = width;
}
}
+73
View File
@@ -2,6 +2,7 @@ import { browser } from '$app/environment';
import { get, writable, type Readable, type Writable } from 'svelte/store';
import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
import { setHidden, type Visibility } from '$lib/file-visibility';
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type { FileStatistics, FileStructure, Selection } from 'gpx-rs';
@@ -215,6 +216,78 @@ class Engine {
return this.run((w) => w.reorder(idsToBytes(fileIds), index));
}
/** Selects a node of the file tree. */
selectNode(node: FileTreeNode, mode: SelectMode = 'replace') {
switch (node.type) {
case 'file':
return this.select([node.fileId], mode);
case 'track':
return this.selectTracks(node.fileId, [node.trackId], mode);
case 'segment':
return this.selectSegments(node.fileId, node.trackId, [node.segmentId], mode);
case 'waypoints':
return this.selectWaypointGroup(node.fileId, mode);
case 'waypoint':
return this.selectWaypoints(node.fileId, [node.waypointId], mode);
}
}
/** 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() {
return this.run((w) => w.new_track());
}
newTrackSegment() {
return this.run((w) => w.new_track_segment());
}
metadata(name: string, desc: string) {
return this.run((w) => w.metadata(name, desc));
}
/** Only the given fields are changed. */
style(style: { color?: string; opacity?: number; width?: number }) {
return this.run((w) => w.style(style.color, style.opacity, style.width));
}
/** Hides or shows the selected elements. */
setSelectionHidden(hidden: boolean) {
selectedElementIds(get(this._selection)).forEach(({ fileId, ids }) =>
this.setHidden(fileId, ids, hidden)
);
}
/**
* Hides or shows elements of a file (see `FileState.visibility`). What was set explicitly
* below these elements is reset, so that hiding a file then showing one of its segments
+80 -1
View File
@@ -1,12 +1,17 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { Selection } from 'gpx-rs';
import type { FileStructure, Selection } from 'gpx-rs';
import type { VisibilityState } from './file-visibility';
import {
elementId,
hasSelectionWithin,
isCovered,
isEmpty,
isSelected,
isSelectionHidden,
selectedElementIds,
selectedFileIds,
selectionSize,
type FileTreeNode,
} from './selection-helpers';
@@ -63,6 +68,36 @@ describe('selection helpers', () => {
assert.deepEqual(selectedFileIds(waypoint('W1')), ['F1']);
});
it('gives the id of the element of a node', () => {
assert.deepEqual(
Object.keys(nodes).map((name) => elementId(nodes[name])),
['F1', 'F2', 'T1', 'T2', 'S1', 'S2', 'S3', 'F1:waypoints', 'W1', 'W2', 'F2:waypoints']
);
});
it('counts the selected elements', () => {
assert.equal(selectionSize(empty), 0);
assert.equal(selectionSize(files('F1', 'F2')), 2);
assert.equal(selectionSize(tracks('T1')), 1);
assert.equal(selectionSize(segments('T1', 'S1', 'S2')), 2);
assert.equal(selectionSize(waypoints), 1);
assert.equal(selectionSize(waypoint('W1', 'W2')), 2);
});
it('lists the ids of the selected elements by file', () => {
assert.deepEqual(selectedElementIds(empty), []);
assert.deepEqual(selectedElementIds(files('F1', 'F2')), [
{ fileId: 'F1', ids: ['F1'] },
{ fileId: 'F2', ids: ['F2'] },
]);
assert.deepEqual(selectedElementIds(tracks('T1', 'T2')), [
{ fileId: 'F1', ids: ['T1', 'T2'] },
]);
assert.deepEqual(selectedElementIds(segments('T1', 'S1')), [{ fileId: 'F1', ids: ['S1'] }]);
assert.deepEqual(selectedElementIds(waypoints), [{ fileId: 'F1', ids: ['F1:waypoints'] }]);
assert.deepEqual(selectedElementIds(waypoint('W1')), [{ fileId: 'F1', ids: ['W1'] }]);
});
describe('isSelected', () => {
it('matches the selected nodes only', () => {
assert.deepEqual(matching(empty, isSelected), []);
@@ -137,4 +172,48 @@ describe('selection helpers', () => {
assert.deepEqual(matching(files('F1'), hasSelectionWithin), ['F1']);
});
});
describe('isSelectionHidden', () => {
const segment = (id: string) => ({ id, rev: id, length: 2 });
const structure: FileStructure = {
id: 'F1',
name: 'file',
waypointsRev: 'rev',
waypoints: [{ id: 'W1' }, { id: 'W2' }],
tracks: [
{ id: 'T1', segments: [segment('S1'), segment('S2')] },
{ id: 'T2', segments: [segment('S3')] },
],
};
const states = (...hidden: string[]) =>
new Map<string, VisibilityState>([
[
'F1',
{ structure, visibility: new Map(hidden.map((id) => [id, false] as const)) },
],
]);
it('is false when nothing is selected', () => {
assert.equal(isSelectionHidden(empty, states('F1')), false);
});
it('is true when everything selected is hidden', () => {
assert.equal(isSelectionHidden(files('F1'), states('F1')), true);
assert.equal(isSelectionHidden(tracks('T1', 'T2'), states('T1', 'T2')), true);
// through a hidden parent
assert.equal(isSelectionHidden(segments('T1', 'S1', 'S2'), states('T1')), true);
assert.equal(isSelectionHidden(waypoint('W1', 'W2'), states('F1:waypoints')), true);
assert.equal(isSelectionHidden(waypoints, states('F1:waypoints')), true);
});
it('is false as soon as one selected element is visible', () => {
assert.equal(isSelectionHidden(files('F1'), states()), false);
assert.equal(isSelectionHidden(tracks('T1', 'T2'), states('T1')), false);
assert.equal(isSelectionHidden(waypoint('W1', 'W2'), states('W1')), false);
});
it('is false for files that are not known', () => {
assert.equal(isSelectionHidden(files('F1', 'F2'), states('F1')), false);
});
});
});
+77
View File
@@ -1,4 +1,5 @@
import type { Selection } from 'gpx-rs';
import { isHidden, waypointsKey, type VisibilityState } from '$lib/file-visibility';
/**
* An element of the file tree, to ask questions about the selection:
@@ -15,6 +16,25 @@ export type FileTreeNode =
| { type: 'waypoints'; fileId: string }
| { type: 'waypoint'; fileId: string; waypointId: string };
/**
* The id of the element of a node, as used by the visibility (`waypointsKey` for the waypoints
* node).
*/
export function elementId(node: FileTreeNode): string {
switch (node.type) {
case 'file':
return node.fileId;
case 'track':
return node.trackId;
case 'segment':
return node.segmentId;
case 'waypoints':
return waypointsKey(node.fileId);
case 'waypoint':
return node.waypointId;
}
}
export function isEmpty(selection: Selection): boolean {
return selection.type === 'empty';
}
@@ -31,6 +51,45 @@ export function selectedFileIds(selection: Selection): string[] {
}
}
/** Number of selected elements (the waypoints node counts for one). */
export function selectionSize(selection: Selection): number {
switch (selection.type) {
case 'empty':
return 0;
case 'file':
return selection.fileIds.length;
case 'track':
return selection.trackIds.length;
case 'segment':
return selection.segmentIds.length;
case 'waypoints':
return 1;
case 'waypoint':
return selection.waypointIds.length;
}
}
/**
* The ids of the selected elements, by file, as used by the visibility (a file, its tracks, its
* segments, its waypoints, or `waypointsKey` for the waypoints node).
*/
export function selectedElementIds(selection: Selection): { fileId: string; ids: string[] }[] {
switch (selection.type) {
case 'empty':
return [];
case 'file':
return selection.fileIds.map((fileId) => ({ fileId, ids: [fileId] }));
case 'track':
return [{ fileId: selection.fileId, ids: selection.trackIds }];
case 'segment':
return [{ fileId: selection.fileId, ids: selection.segmentIds }];
case 'waypoints':
return [{ fileId: selection.fileId, ids: [waypointsKey(selection.fileId)] }];
case 'waypoint':
return [{ fileId: selection.fileId, ids: selection.waypointIds }];
}
}
/** Whether the node itself is selected. */
export function isSelected(selection: Selection, node: FileTreeNode): boolean {
switch (node.type) {
@@ -107,3 +166,21 @@ export function hasSelectionWithin(selection: Selection, node: FileTreeNode): bo
return isSelected(selection, node);
}
}
/**
* Whether all the selected elements are hidden (false when nothing is selected). `states` holds
* the files by id: an element of an unknown file is not hidden.
*/
export function isSelectionHidden(
selection: Selection,
states: ReadonlyMap<string, VisibilityState>
): boolean {
const selected = selectedElementIds(selection);
return (
selected.length > 0 &&
selected.every(({ fileId, ids }) => {
const state = states.get(fileId);
return state !== undefined && ids.every((id) => isHidden(state, id));
})
);
}