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
@@ -17,6 +17,10 @@ impl Editor for Metadata<'_> {
fn file(&mut self, file: &mut File) -> Edit { fn file(&mut self, file: &mut File) -> Edit {
file.info.name = self.name.to_owned(); file.info.name = self.name.to_owned();
file.info.desc = Some(self.desc.to_owned()); file.info.desc = Some(self.desc.to_owned());
// a single track is named like the file
if let [track] = file.trk.as_mut_slice() {
track.info.name = Some(self.name.to_owned());
}
Edit::Changed Edit::Changed
} }
@@ -31,7 +35,7 @@ impl Editor for Metadata<'_> {
mod tests { mod tests {
use std::collections::HashSet; use std::collections::HashSet;
use crate::{New, Selection, engine::command::fixture::Fixture}; use crate::{New, NewTrack, Selection, engine::command::fixture::Fixture};
use super::*; use super::*;
@@ -83,4 +87,55 @@ mod tests {
assert_eq!(after.trk[0].info.name.as_deref(), Some("renamed")); assert_eq!(after.trk[0].info.name.as_deref(), Some("renamed"));
assert_eq!(after.trk[1].info, file.trk[1].info); assert_eq!(after.trk[1].info, file.trk[1].info);
} }
/// A new file with `nb_tracks` tracks (without name), selected.
fn file_with_tracks(fx: &mut Fixture, nb_tracks: usize) {
New { name: "file" }.apply(&mut fx.state()).unwrap();
for _ in 0..nb_tracks {
NewTrack.apply(&mut fx.state()).unwrap();
}
}
fn rename(fx: &mut Fixture) {
Metadata {
name: "renamed",
desc: "",
}
.apply(&mut fx.state())
.unwrap();
}
#[test]
fn test_single_track_is_renamed_like_the_file() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 1);
rename(&mut fx);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "renamed");
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
}
#[test]
fn test_single_track_with_a_name_is_renamed_too() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 1);
let id = *fx.files.keys().next().unwrap();
std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap()).trk[0]
.info
.name = Some("track".to_owned());
rename(&mut fx);
let file = &fx.files[&id];
assert_eq!(file.info.name, "renamed");
assert_eq!(file.trk[0].info.name.as_deref(), Some("renamed"));
}
#[test]
fn test_several_tracks_are_not_renamed() {
let mut fx = Fixture::default();
file_with_tracks(&mut fx, 2);
rename(&mut fx);
let file = fx.files.values().next().unwrap();
assert_eq!(file.info.name, "renamed");
assert!(file.trk.iter().all(|trk| trk.info.name.is_none()));
}
} }
@@ -12,6 +12,7 @@ use crate::{
pub struct FileStructure { pub struct FileStructure {
pub id: FileId, pub id: FileId,
pub name: String, pub name: String,
pub desc: Option<String>,
pub tracks: Vec<TrackNode>, pub tracks: Vec<TrackNode>,
pub waypoints: Vec<WaypointNode>, pub waypoints: Vec<WaypointNode>,
/// Changes whenever the waypoints of the file (hence their buffer) change. /// Changes whenever the waypoints of the file (hence their buffer) change.
@@ -22,6 +23,7 @@ pub struct FileStructure {
pub struct TrackNode { pub struct TrackNode {
pub id: TrackId, pub id: TrackId,
pub name: Option<String>, pub name: Option<String>,
pub desc: Option<String>,
/// Style of the track, only when the file defines it. /// Style of the track, only when the file defines it.
pub color: Option<String>, pub color: Option<String>,
pub opacity: Option<f64>, pub opacity: Option<f64>,
@@ -49,12 +51,14 @@ impl FileStructure {
Self { Self {
id: file.id, id: file.id,
name: file.info.name.clone(), name: file.info.name.clone(),
desc: file.info.desc.clone(),
tracks: file tracks: file
.trk .trk
.iter() .iter()
.map(|trk| TrackNode { .map(|trk| TrackNode {
id: trk.id, id: trk.id,
name: trk.info.name.clone(), name: trk.info.name.clone(),
desc: trk.info.desc.clone(),
color: trk.info.color.clone(), color: trk.info.color.clone(),
opacity: trk.info.opacity, opacity: trk.info.opacity,
width: trk.info.width, width: trk.info.width,
@@ -99,10 +103,13 @@ mod tests {
Load { data: &data }.apply(&mut fx.state()).unwrap(); Load { data: &data }.apply(&mut fx.state()).unwrap();
let file = &fx.files[&fx.order.0[0]]; let file = &fx.files[&fx.order.0[0]];
let node = FileStructure::new(file); let node = FileStructure::new(file);
assert_eq!((node.id, &node.name), (file.id, &file.info.name)); assert_eq!(
(node.id, &node.name, &node.desc),
(file.id, &file.info.name, &file.info.desc)
);
assert_eq!(node.tracks.len(), file.trk.len()); assert_eq!(node.tracks.len(), file.trk.len());
for (n, t) in node.tracks.iter().zip(&file.trk) { for (n, t) in node.tracks.iter().zip(&file.trk) {
assert_eq!((n.id, &n.name), (t.id, &t.info.name)); assert_eq!((n.id, &n.name, &n.desc), (t.id, &t.info.name, &t.info.desc));
assert_eq!( assert_eq!(
(&n.color, n.opacity, n.width), (&n.color, n.opacity, n.width),
(&t.info.color, t.info.opacity, t.info.width) (&t.info.color, t.info.opacity, t.info.width)
+8
View File
@@ -436,6 +436,7 @@ export interface FilesUpdate {
export interface FileStructure { export interface FileStructure {
id: string; id: string;
name: string; name: string;
desc?: string;
tracks: TrackNode[]; tracks: TrackNode[];
waypoints: WaypointNode[]; waypoints: WaypointNode[];
/** Changes when the waypoints of the file change: refetch their coordinates. */ /** Changes when the waypoints of the file change: refetch their coordinates. */
@@ -463,6 +464,7 @@ export interface FileStatistics {
export interface TrackNode { export interface TrackNode {
id: string; id: string;
name?: string; name?: string;
desc?: string;
/** Style of the track, only present when the file defines it. */ /** Style of the track, only present when the file defines it. */
color?: string; color?: string;
opacity?: number; opacity?: number;
@@ -520,11 +522,17 @@ fn named_node(id: uuid::Uuid, name: Option<&str>) -> Object {
fn structure_object(file: &engine::FileStructure) -> Object { fn structure_object(file: &engine::FileStructure) -> Object {
let node = named_node(file.id.0, Some(&file.name)); let node = named_node(file.id.0, Some(&file.name));
if let Some(desc) = &file.desc {
set(&node, "desc", desc.as_str());
}
set( set(
&node, &node,
"tracks", "tracks",
array(&file.tracks, |trk| { array(&file.tracks, |trk| {
let node = named_node(trk.id.0, trk.name.as_deref()); let node = named_node(trk.id.0, trk.name.as_deref());
if let Some(desc) = &trk.desc {
set(&node, "desc", desc.as_str());
}
if let Some(color) = &trk.color { if let Some(color) = &trk.color {
set(&node, "color", color.as_str()); set(&node, "color", color.as_str());
} }
+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"> <script lang="ts">
import { ScrollArea } from '$lib/components/ui/scroll-area/index'; import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import * as ContextMenu from '$lib/components/ui/context-menu'; 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 { onMount, setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem } from './file-list'; import { ListLevel } from './file-list';
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte'; import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
import Shortcut from '$lib/components/Shortcut.svelte'; import Shortcut from '$lib/components/Shortcut.svelte';
import { i18n } from '$lib/i18n.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 { 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'; import { allowedPastes } from './sortable-file-list';
let { let {
@@ -27,17 +29,12 @@
setContext('orientation', orientation); setContext('orientation', orientation);
setContext('recursive', recursive); setContext('recursive', recursive);
const { files, selection } = engine;
onMount(() => { onMount(() => {
if (orientation === 'horizontal') { if (orientation === 'horizontal' && $selection.type !== 'file') {
selection.update(($selection) => { // only files can be selected here
$selection.forEach((item) => { engine.select(selectedFileIds($selection));
if (!(item instanceof ListFileItem)) {
$selection.toggle(item);
$selection.set(new ListFileItem(item.getFileId()), true);
}
});
return $selection;
});
} }
}); });
</script> </script>
@@ -54,7 +51,7 @@
: 'flex-row'} {className ?? ''}" : 'flex-row'} {className ?? ''}"
{style} {style}
> >
<FileListNode node={$fileStateCollection} item={new ListRootItem()} /> <FileListNodeContent node={null} />
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
<ContextMenu.Root> <ContextMenu.Root>
<ContextMenu.Trigger class="grow" /> <ContextMenu.Trigger class="grow" />
@@ -66,8 +63,8 @@
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item <ContextMenu.Item
onclick={() => selection.selectAll()} onclick={() => engine.selectAll()}
disabled={$fileStateCollection.size === 0} disabled={$files.size === 0}
> >
<FileStack size="16" /> <FileStack size="16" />
{i18n._('menu.select_all')} {i18n._('menu.select_all')}
@@ -1,89 +1,93 @@
<script lang="ts"> <script lang="ts">
import {
GPXFile,
Track,
TrackSegment,
Waypoint,
type AnyGPXTreeElement,
type GPXTreeElement,
} from 'gpx';
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index'; import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { type Readable } from 'svelte/store';
import FileListNodeContent from './FileListNodeContent.svelte'; import FileListNodeContent from './FileListNodeContent.svelte';
import FileListNodeLabel from './FileListNodeLabel.svelte'; import FileListNodeLabel from './FileListNodeLabel.svelte';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import {
ListFileItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
type ListItem,
type ListTrackItem,
} from './file-list';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree'; import { engine, type FileState } from '$lib/engine';
import { selection } from '$lib/logic/selection'; import {
elementId,
hasSelectionWithin,
isSelected,
type FileTreeNode,
} from '$lib/selection-helpers';
let { let {
fileState,
node, node,
item,
}: { }: {
node: fileState: FileState;
| Map<string, Readable<GPXFileWithStatistics | undefined>> node: FileTreeNode;
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
item: ListItem;
} = $props(); } = $props();
let recursive = getContext<boolean>('recursive'); let recursive = getContext<boolean>('recursive');
let collapsible: CollapsibleTreeNode | undefined = $state(); let collapsible: CollapsibleTreeNode | undefined = $state();
let label = $derived( let label = $derived.by(() => {
node instanceof GPXFile && item instanceof ListFileItem const { structure } = fileState;
? node.metadata.name switch (node.type) {
: node instanceof Track case 'file':
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`) return structure.name;
: node instanceof TrackSegment case 'track': {
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}` const trackIndex = structure.tracks.findIndex((t) => t.id === node.trackId);
: node instanceof Waypoint return (
? (node.name ?? structure.tracks[trackIndex]?.name ?? `${i18n._('gpx.track')} ${trackIndex + 1}`
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`) );
: node instanceof GPXFile && item instanceof ListWaypointsItem }
? i18n._('gpx.waypoints') 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 { treeFileView } = settings;
const { selection } = engine;
$effect(() => { $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(); collapsible.openNode();
} }
}); });
</script> </script>
{#if node instanceof Map} {#if node.type === 'segment' || node.type === 'waypoint'}
<FileListNodeContent {node} {item} /> <FileListNodeLabel {fileState} {node} {label} />
{:else if node instanceof TrackSegment}
<FileListNodeLabel {node} {item} {label} />
{:else if node instanceof Waypoint}
<FileListNodeLabel {node} {item} {label} />
{:else if recursive} {:else if recursive}
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}> <CollapsibleTreeNode {id} bind:this={collapsible}>
{#snippet trigger()} {#snippet trigger()}
<FileListNodeLabel {node} {item} {label} /> <FileListNodeLabel {fileState} {node} {label} />
{/snippet} {/snippet}
{#snippet content()} {#snippet content()}
<div class="ml-4"> <div class="ml-4">
{#key node} <FileListNodeContent {fileState} {node} />
<FileListNodeContent {node} {item} />
{/key}
</div> </div>
{/snippet} {/snippet}
</CollapsibleTreeNode> </CollapsibleTreeNode>
{:else} {:else}
<FileListNodeLabel {node} {item} {label} /> <FileListNodeLabel {fileState} {node} {label} />
{/if} {/if}
@@ -1,41 +1,39 @@
<script lang="ts"> <script lang="ts">
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
import { getContext, onDestroy, onMount } from 'svelte'; import { getContext, onDestroy, onMount } from 'svelte';
import { type Readable } from 'svelte/store';
import FileListNodeStore from './FileListNodeStore.svelte'; import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte'; import FileListNode from './FileListNode.svelte';
import FileListNodeContent from './FileListNodeContent.svelte'; import FileListNodeContent from './FileListNodeContent.svelte';
import { ListFileItem, ListLevel, ListWaypointsItem, type ListItem } from './file-list'; import { ListLevel } from './file-list';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { allowedMoves, dragging, SortableFileList } from './sortable-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 { let {
fileState,
node, node,
item,
waypointRoot = false, waypointRoot = false,
}: { }: {
node: /** State of the file of the node (not needed for the root). */
| Map<string, Readable<GPXFileWithStatistics | undefined>> fileState?: FileState;
| GPXTreeElement<AnyGPXTreeElement> /** The node whose children are listed, `null` for the list of the files. */
| Waypoint[] node: FileTreeNode | null;
| Waypoint; /** List the node standing for the waypoints of the file, instead of its tracks. */
item: ListItem;
waypointRoot?: boolean; waypointRoot?: boolean;
} = $props(); } = $props();
const { files, order } = engine;
let container: HTMLElement; let container: HTMLElement;
let sortableLevel: ListLevel = let sortableLevel: ListLevel =
node instanceof Map node === null
? ListLevel.FILE ? ListLevel.FILE
: node instanceof GPXFile : node.type === 'file'
? waypointRoot ? waypointRoot
? ListLevel.WAYPOINTS ? ListLevel.WAYPOINTS
: item instanceof ListWaypointsItem : ListLevel.TRACK
? ListLevel.WAYPOINT : node.type === 'waypoints'
: ListLevel.TRACK ? ListLevel.WAYPOINT
: node instanceof Track : ListLevel.SEGMENT;
? ListLevel.SEGMENT
: ListLevel.WAYPOINT;
let orientation = getContext<'vertical' | 'horizontal'>('orientation'); let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel)); let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
@@ -43,18 +41,14 @@
let sortable: SortableFileList; let sortable: SortableFileList;
onMount(() => { onMount(() => {
sortable = new SortableFileList( sortable = new SortableFileList(container, node, waypointRoot, sortableLevel, orientation);
container,
node,
item,
waypointRoot,
sortableLevel,
orientation
);
}); });
$effect(() => { $effect(() => {
if (sortable && node) { // the elements of the list changed
$order;
fileState;
if (sortable) {
sortable.updateElements(); sortable.updateElements();
} }
}); });
@@ -70,45 +64,65 @@
? 'flex-col' ? 'flex-col'
: 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}" : 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}"
> >
{#if node instanceof Map} {#if node === null}
{#each node as [fileId, file] (fileId)} {#each $order as fileId (fileId)}
<div data-id={fileId}> {@const file = $files.get(fileId)}
<FileListNodeStore {file} /> {#if file}
</div> <div data-id={fileId}>
{/each} <FileListNodeStore {file} />
{: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')} />
</div> </div>
{/if} {/if}
{:else} {/each}
{#each node.children as child, i (child)} {:else if fileState}
<div data-id={i}> {#if node.type === 'file'}
<FileListNode node={child} item={item.extend(i)} /> {#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> </div>
{/each} {/each}
{/if} {/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} {/if}
</div> </div>
{#if node instanceof GPXFile && item instanceof ListFileItem} {#if fileState && node?.type === 'file' && !waypointRoot}
{#if !waypointRoot} <FileListNodeContent {fileState} {node} waypointRoot={true} />
<FileListNodeContent {node} {item} waypointRoot={true} />
{/if}
{/if} {/if}
<style lang="postcss"> <style lang="postcss">
@@ -18,86 +18,95 @@
Scissors, Scissors,
FileStack, FileStack,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { import { ListLevel } from './file-list';
ListFileItem,
ListLevel,
ListTrackItem,
ListWaypointItem,
type ListItem,
} from './file-list';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte'; import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte'; import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte'; import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
import { editStyle } from '$lib/components/file-list/style/utils.svelte'; import { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { selection, copied, cut } from '$lib/logic/selection'; import { engine, type FileState } from '$lib/engine';
import { fileActions, pasteSelection } from '$lib/logic/file-actions'; import { allHidden } from '$lib/all-hidden';
import { allHidden } from '$lib/logic/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 { boundsManager } from '$lib/logic/bounds';
import { gpxColors, gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers'; // import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
import { fileStateCollection } from '$lib/logic/file-state'; // import { fileStateCollection } from '$lib/logic/file-state';
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup'; // import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { allowedPastes } from './sortable-file-list'; import { allowedPastes } from './sortable-file-list';
let { let {
fileState,
node, node,
item,
label, label,
}: { }: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint; fileState: FileState;
item: ListItem; node: FileTreeNode;
label: string | undefined; label: string | undefined;
} = $props(); } = $props();
const { selection } = engine;
let orientation = getContext<'vertical' | 'horizontal'>('orientation'); let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding'); 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 nodeColors: string[] = $derived.by(() => {
let colors: string[] = []; const { tracks } = fileState.structure;
if (node) { if (node.type === 'file') {
if (node instanceof GPXFile) { // the colors defined by the tracks, or else the one of the file
let defaultColor = $gpxColors.get(item.getFileId()); const colors = [
let style = node.getStyle(defaultColor); ...new Set(
colors = style.color; tracks.flatMap((track) =>
} else if (node instanceof Track) { track.color !== undefined ? [normalizeColor(track.color)] : []
let style = node.getStyle(); )
if ( ),
style && ];
style['gpx_style:color'] && return colors.length > 0 ? colors : [fileState.color];
!colors.includes(style['gpx_style:color']) } else if (node.type === 'track') {
) { const color = tracks.find((track) => track.id === node.trackId)?.color;
colors.push(style['gpx_style:color']); return [color !== undefined ? normalizeColor(color) : fileState.color];
}
if (colors.length === 0) {
let defaultColor = $gpxColors.get(item.getFileId());
if (defaultColor) {
colors.push(defaultColor);
}
}
}
} }
return colors; return [];
}); });
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined); let symbolKey = $derived(
node.type === 'waypoint'
let openEditMetadata: boolean = $derived( ? getSymbolKey(fileState.structure.waypoints.find((w) => w.id === node.waypointId)?.sym)
editMetadata.current && singleSelection && $selection.has(item) : undefined
); );
let selected = $derived(isSelected($selection, node));
let openEditMetadata: boolean = $derived(editMetadata.current && singleSelection && selected);
let openEditStyle: boolean = $derived( let openEditStyle: boolean = $derived(
editStyle.current && editStyle.current &&
$selection.has(item) && selected &&
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0 selectedElementIds($selection)[0]?.ids[0] === elementId(node)
); );
let hidden = $derived( let hidden = $derived(isHidden(fileState, elementId(node)));
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden
);
</script> </script>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -105,8 +114,8 @@
<ContextMenu.Root <ContextMenu.Root
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) { if (open) {
if (!$selection.has(item)) { if (!selected) {
selection.selectItem(item); engine.selectNode(node);
} }
} }
}} }}
@@ -119,11 +128,11 @@
? 'h-7' ? 'h-7'
: 'h-9 px-1.5'} pointer-events-auto" : 'h-9 px-1.5'} pointer-events-auto"
> >
{#if item instanceof ListFileItem || item instanceof ListTrackItem} {#if node.type === 'file' || node.type === 'track'}
<MetadataDialog bind:open={openEditMetadata} {node} {item} /> <MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
<StyleDialog bind:open={openEditStyle} {item} /> <StyleDialog bind:open={openEditStyle} {fileState} {node} />
{/if} {/if}
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK} {#if level === ListLevel.FILE || level === ListLevel.TRACK}
<div <div
class="absolute {orientation === 'vertical' class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-0 w-1' ? 'top-0 bottom-0 right-0 w-1'
@@ -141,9 +150,7 @@
<span <span
class="grow text-left truncate ml-1 flex flex-row items-center {hidden class="grow text-left truncate ml-1 flex flex-row items-center {hidden
? 'text-muted-foreground' ? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId()) : ''} {/* TODO cut elements: the clipboard works on the previous implementation */ ''}"
? 'text-muted-foreground'
: ''}"
oncontextmenu={(e) => { oncontextmenu={(e) => {
if (embedding) { if (embedding) {
e.preventDefault(); e.preventDefault();
@@ -154,37 +161,35 @@
// Add to selection instead of opening context menu // Add to selection instead of opening context menu
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
$selection.toggle(item); engine.selectNode(node, 'toggle');
$selection = $selection;
} }
}} }}
onmouseenter={() => { onmouseenter={() => {
if (item instanceof ListWaypointItem) { // TODO waypoint popup: it needs the waypoint of the previous implementation
let layer = gpxLayers.getLayer(item.getFileId()); // if (node.type === 'waypoint') {
let file = fileStateCollection.getFile(item.getFileId()); // let layer = gpxLayers.getLayer(node.fileId);
if (layer && file) { // let file = fileStateCollection.getFile(node.fileId);
let waypoint = file.wpt[item.getWaypointIndex()]; // if (layer && file) {
if (waypoint && !waypoint._data.hidden) { // let waypoint = file.wpt[waypointIndex];
waypointPopup?.setItem({ // if (waypoint && !waypoint._data.hidden) {
item: waypoint, // waypointPopup?.setItem({ item: waypoint, fileId: node.fileId });
fileId: item.getFileId(), // }
}); // }
} // }
}
}
}} }}
onmouseleave={() => { onmouseleave={() => {
if (item instanceof ListWaypointItem) { // TODO waypoint popup
let layer = gpxLayers.getLayer(item.getFileId()); // if (node.type === 'waypoint') {
if (layer) { // let layer = gpxLayers.getLayer(node.fileId);
waypointPopup?.setItem(null); // if (layer) {
} // waypointPopup?.setItem(null);
} // }
// }
}} }}
> >
{#if item.level === ListLevel.SEGMENT} {#if level === ListLevel.SEGMENT}
<Waypoints size="16" class="mx-1 shrink-0" /> <Waypoints size="16" class="mx-1 shrink-0" />
{:else if item.level === ListLevel.WAYPOINT} {:else if level === ListLevel.WAYPOINT}
{#if symbolKey && symbols[symbolKey].icon} {#if symbolKey && symbols[symbolKey].icon}
{@const SymbolIcon = symbols[symbolKey].icon} {@const SymbolIcon = symbols[symbolKey].icon}
<SymbolIcon size="16" class="mx-1 shrink-0" /> <SymbolIcon size="16" class="mx-1 shrink-0" />
@@ -211,7 +216,7 @@
</Button> </Button>
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Content> <ContextMenu.Content>
{#if item instanceof ListFileItem || item instanceof ListTrackItem} {#if node.type === 'file' || node.type === 'track'}
<ContextMenu.Item <ContextMenu.Item
disabled={!singleSelection} disabled={!singleSelection}
onclick={() => (editMetadata.current = true)} onclick={() => (editMetadata.current = true)}
@@ -225,15 +230,7 @@
{i18n._('menu.style.button')} {i18n._('menu.style.button')}
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
<ContextMenu.Item <ContextMenu.Item onclick={() => engine.setSelectionHidden(!$allHidden)}>
onclick={() => {
if ($allHidden) {
fileActions.setHiddenToSelection(false);
} else {
fileActions.setHiddenToSelection(true);
}
}}
>
{#if $allHidden} {#if $allHidden}
<Eye size="16" /> <Eye size="16" />
{i18n._('menu.unhide')} {i18n._('menu.unhide')}
@@ -245,20 +242,16 @@
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
{#if item instanceof ListFileItem} {#if node.type === 'file'}
<ContextMenu.Item <ContextMenu.Item disabled={!singleSelection} onclick={() => engine.newTrack()}>
disabled={!singleSelection}
onclick={() => fileActions.addNewTrack(item.getFileId())}
>
<Plus size="16" /> <Plus size="16" />
{i18n._('menu.new_track')} {i18n._('menu.new_track')}
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
{:else if item instanceof ListTrackItem} {:else if node.type === 'track'}
<ContextMenu.Item <ContextMenu.Item
disabled={!singleSelection} disabled={!singleSelection}
onclick={() => onclick={() => engine.newTrackSegment()}
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
> >
<Plus size="16" /> <Plus size="16" />
{i18n._('menu.new_segment')} {i18n._('menu.new_segment')}
@@ -266,8 +259,8 @@
<ContextMenu.Separator /> <ContextMenu.Separator />
{/if} {/if}
{/if} {/if}
{#if item.level !== ListLevel.WAYPOINTS} {#if level !== ListLevel.WAYPOINTS}
<ContextMenu.Item onclick={() => selection.selectAll()}> <ContextMenu.Item onclick={() => engine.selectAllSiblings(node)}>
<FileStack size="16" /> <FileStack size="16" />
{i18n._('menu.select_all')} {i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
@@ -279,18 +272,18 @@
<Shortcut key="⏎" ctrl={true} /> <Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.duplicateSelection}> <ContextMenu.Item onclick={() => engine.duplicate()}>
<Copy size="16" /> <Copy size="16" />
{i18n._('menu.duplicate')} {i18n._('menu.duplicate')}
<Shortcut key="D" ctrl={true} /> <Shortcut key="D" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
<ContextMenu.Item onclick={() => selection.copySelection()}> <ContextMenu.Item onclick={() => oldSelection.copySelection()}>
<ClipboardCopy size="16" /> <ClipboardCopy size="16" />
{i18n._('menu.copy')} {i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} /> <Shortcut key="C" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item onclick={() => selection.cutSelection()}> <ContextMenu.Item onclick={() => oldSelection.cutSelection()}>
<Scissors size="16" /> <Scissors size="16" />
{i18n._('menu.cut')} {i18n._('menu.cut')}
<Shortcut key="X" ctrl={true} /> <Shortcut key="X" ctrl={true} />
@@ -298,7 +291,7 @@
<ContextMenu.Item <ContextMenu.Item
disabled={$copied === undefined || disabled={$copied === undefined ||
$copied.length === 0 || $copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)} !allowedPastes[$copied[0].level].includes(level)}
onclick={pasteSelection} onclick={pasteSelection}
> >
<ClipboardPaste size="16" /> <ClipboardPaste size="16" />
@@ -307,7 +300,7 @@
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.deleteSelection}> <ContextMenu.Item onclick={() => engine.delete()}>
<Trash2 size="16" /> <Trash2 size="16" />
{i18n._('menu.delete')} {i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} /> <Shortcut key="⌫" ctrl={true} />
@@ -4,24 +4,21 @@
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import type { Readable } from 'svelte/store'; import type { Readable } from 'svelte/store';
import { ListFileItem } from './file-list'; import type { FileState } from '$lib/engine';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
let { let {
file, file,
}: { }: {
file: Readable<GPXFileWithStatistics | undefined>; file: Readable<FileState>;
} = $props(); } = $props();
let recursive = getContext<boolean>('recursive'); let recursive = getContext<boolean>('recursive');
</script> </script>
{#if $file} {#if recursive}
{#if recursive} <CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}>
<CollapsibleTree side="left" defaultState="closed" slotInsideTrigger={false}> <FileListNode fileState={$file} node={{ type: 'file', fileId: $file.structure.id }} />
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} /> </CollapsibleTree>
</CollapsibleTree> {:else}
{:else} <FileListNode fileState={$file} node={{ type: 'file', fileId: $file.structure.id }} />
<FileListNode node={$file.file} item={new ListFileItem($file.file._data.id)} />
{/if}
{/if} {/if}
@@ -5,35 +5,31 @@
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import { Save } from '@lucide/svelte'; 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 { i18n } from '$lib/i18n.svelte';
import { editMetadata } from '$lib/components/file-list/metadata/utils.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 { let {
fileState,
node, node,
item,
open = $bindable(), open = $bindable(),
}: { }: {
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint; fileState: FileState;
item: ListItem; node: FileTreeNode;
open: boolean; open: boolean;
} = $props(); } = $props();
let track = $derived(
node.type === 'track'
? fileState.structure.tracks.find((track) => track.id === node.trackId)
: undefined
);
let name: string = $derived( let name: string = $derived(
node instanceof GPXFile node.type === 'file' ? fileState.structure.name : (track?.name ?? '')
? (node.metadata.name ?? '')
: node instanceof Track
? (node.name ?? '')
: ''
); );
let description: string = $derived( let description: string = $derived(
node instanceof GPXFile node.type === 'file' ? (fileState.structure.desc ?? '') : (track?.desc ?? '')
? (node.metadata.desc ?? '')
: node instanceof Track
? (node.desc ?? '')
: ''
); );
$effect(() => { $effect(() => {
@@ -53,18 +49,8 @@
<Button <Button
variant="outline" variant="outline"
onclick={() => { onclick={() => {
fileActionManager.applyToFile(item.getFileId(), (file) => { // the metadata of the selected elements are changed
if (item instanceof ListFileItem && node instanceof GPXFile) { engine.metadata(name, description);
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;
}
});
open = false; open = false;
}} }}
> >
@@ -1,15 +1,15 @@
import { isMac } from '$lib/utils'; import { isMac } from '$lib/utils';
import Sortable, { type Direction } from 'sortablejs/Sortable'; import Sortable, { type Direction } from 'sortablejs/Sortable';
import { ListItem, ListLevel, ListRootItem } from './file-list'; import { ListLevel } from './file-list';
import { selection } from '$lib/logic/selection'; import { get, writable } from 'svelte/store';
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 { tick } from 'svelte'; import { tick } from 'svelte';
import { engine } from '$lib/engine';
const { fileOrder } = settings; 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[]> = { export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [], [ListLevel.ROOT]: [],
@@ -31,13 +31,12 @@ export const allowedPastes: Record<ListLevel, ListLevel[]> = {
export const dragging = writable<ListLevel | null>(null); 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 { export class SortableFileList {
private _node: private _parent: FileTreeNode | null;
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
private _item: ListItem;
private _sortableLevel: ListLevel; private _sortableLevel: ListLevel;
private _container: HTMLElement; private _container: HTMLElement;
private _sortable: Sortable | null = null; private _sortable: Sortable | null = null;
@@ -47,26 +46,24 @@ export class SortableFileList {
constructor( constructor(
container: HTMLElement, container: HTMLElement,
node: parent: FileTreeNode | null,
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint,
item: ListItem,
waypointRoot: boolean, waypointRoot: boolean,
sortableLevel: ListLevel, sortableLevel: ListLevel,
orientation: Direction orientation: Direction
) { ) {
this._node = node; this._parent = parent;
this._item = item;
this._sortableLevel = sortableLevel; this._sortableLevel = sortableLevel;
this._container = container; 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, { this._sortable = Sortable.create(container, {
group: { group: {
name: sortableLevel, name: sortableLevel,
pull: allowedMoves[sortableLevel], pull: sortable ? allowedMoves[sortableLevel] : false,
put: true, put: sortable ? [ListLevel.FILE] : false,
}, },
sort: sortable,
direction: orientation, direction: orientation,
forceAutoScrollFallback: true, forceAutoScrollFallback: true,
multiDrag: true, multiDrag: true,
@@ -80,76 +77,103 @@ export class SortableFileList {
onEnd: () => dragging.set(null), onEnd: () => dragging.set(null),
onSort: (e: Sortable.SortableEvent) => this.onSort(e), onSort: (e: Sortable.SortableEvent) => this.onSort(e),
}); });
Object.defineProperty(this._sortable, '_item', {
value: item,
writable: true,
});
Object.defineProperty(this._sortable, '_waypointRoot', { Object.defineProperty(this._sortable, '_waypointRoot', {
value: waypointRoot, value: waypointRoot,
writable: true, writable: true,
}); });
this._unsubscribes.push( 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) { onSort(e: Sortable.SortableEvent) {
this.updateToFileOrder(); this.updateToFileOrder();
const from = Sortable.get(e.from); // TODO moving elements between parents: the engine has no command for it yet, the code
const to = Sortable.get(e.to); // below worked on the previous implementation
// onSort(e: Sortable.SortableEvent) {
// this.updateToFileOrder();
if (!from || !to) { // const from = Sortable.get(e.from);
return; // const to = Sortable.get(e.to);
}
let fromItem = from._item; // if (!from || !to) {
let toItem = to._item; // return;
// }
if (this._item === toItem && !(fromItem instanceof ListRootItem)) { // let fromItem = from._item;
// Event is triggered on source and destination list, only handle it once // let toItem = to._item;
let fromItems = [];
let toItems = [];
if (from._waypointRoot) { // if (this._item === toItem && !(fromItem instanceof ListRootItem)) {
fromItems = [fromItem.extend('waypoints')]; // // Event is triggered on source and destination list, only handle it once
} else { // let fromItems = [];
let oldIndices: number[] = // let toItems = [];
e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
oldIndices = oldIndices.filter((i) => i >= 0);
oldIndices.sort((a, b) => a - b);
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) { // fromItems = oldIndices.map((i) => fromItem.extend(i));
toItems = [toItem.extend('waypoints')]; // }
} else {
if (to._waypointRoot) {
toItem = toItem.extend('waypoints');
}
let newIndices: number[] = // if (from._waypointRoot && to._waypointRoot) {
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex]; // toItems = [toItem.extend('waypoints')];
newIndices = newIndices.filter((i) => i >= 0); // } else {
newIndices.sort((a, b) => a - b); // if (to._waypointRoot) {
// toItem = toItem.extend('waypoints');
// }
if (toItem instanceof ListRootItem) { // let newIndices: number[] =
let newFileIds = getFileIds(newIndices.length); // e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
toItems = newIndices.map((i, index) => { // newIndices = newIndices.filter((i) => i >= 0);
get(fileOrder).splice(i, 0, newFileIds[index]); // newIndices.sort((a, b) => a - b);
return this._item.extend(newFileIds[index]);
});
} else {
toItems = newIndices.map((i) => toItem.extend(i));
}
}
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() { updateFromSelection() {
@@ -157,11 +181,12 @@ export class SortableFileList {
if (changed.length === 0) { if (changed.length === 0) {
return; return;
} }
const selection_ = get(selection); const selection_ = get(engine.selection);
for (let id of changed) { for (let id of changed) {
let element = this._elements[id]; let element = this._elements[id];
if (element) { let node = this.childNode(id);
if (selection_.has(this._item.extend(id))) { if (element && node) {
if (isSelected(selection_, node)) {
Sortable.utils.select(element); Sortable.utils.select(element);
element.scrollIntoView({ element.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
@@ -183,27 +208,42 @@ export class SortableFileList {
this._updatingSelection = false; this._updatingSelection = false;
return; 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 ( let selected = Object.entries(this._elements)
e.originalEvent && .filter(([, element]) => element.classList.contains('sortable-selected'))
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) && .map(([id]) => id);
($selection.size > 1 ||
!$selection.has(this._item.extend(this.getRealId(changed[0])))) if (
) { e.originalEvent &&
// Fix bug that sometimes causes a single select to be treated as a multi-select !(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
$selection.clear(); (selected.length > 1 || !selected.includes(changed[0]))
$selection.set(this._item.extend(this.getRealId(changed[0])), true); ) {
// 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; this._updatingSelection = false;
} }
@@ -212,7 +252,7 @@ export class SortableFileList {
return; return;
} }
const fileOrder_ = get(fileOrder); const fileOrder_ = get(engine.order);
const sortableOrder = this._sortable.toArray(); const sortableOrder = this._sortable.toArray();
if ( if (
@@ -228,24 +268,25 @@ export class SortableFileList {
return; return;
} }
const fileOrder_ = get(fileOrder); const fileOrder_ = get(engine.order);
const sortableOrder = this._sortable.toArray(); const sortableOrder = this._sortable.toArray();
if ( if (
fileOrder_.length !== sortableOrder.length || fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index]) fileOrder_.some((value, index) => value !== sortableOrder[index])
) { ) {
fileOrder.set(sortableOrder); engine.reorder(sortableOrder, 0);
} }
} }
updateElements() { updateElements() {
this._elements = {}; this._elements = {};
const files = get(engine.files);
this._container.childNodes.forEach((element) => { this._container.childNodes.forEach((element) => {
if (element instanceof HTMLElement) { if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id'); let attr = element.getAttribute('data-id');
if (attr) { if (attr) {
if (this._node instanceof Map && !this._node.has(attr)) { if (this._sortableLevel === ListLevel.FILE && !files.has(attr)) {
element.remove(); element.remove();
} else { } else {
this._elements[attr] = element; this._elements[attr] = element;
@@ -262,23 +303,16 @@ export class SortableFileList {
} }
getChangedIds() { getChangedIds() {
let changed: (string | number)[] = []; let changed: string[] = [];
const selection_ = get(selection); const selection_ = get(engine.selection);
Object.entries(this._elements).forEach(([id, element]) => { Object.entries(this._elements).forEach(([id, element]) => {
let realId = this.getRealId(id); const node = this.childNode(id);
let realItem = this._item.extend(realId); let inSelection = node !== undefined && isSelected(selection_, node);
let inSelection = selection_.has(realItem); let isSelectedInList = element.classList.contains('sortable-selected');
let isSelected = element.classList.contains('sortable-selected'); if (inSelection !== isSelectedInList) {
if (inSelection !== isSelected) { changed.push(id);
changed.push(realId);
} }
}); });
return changed; 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 { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import { Save } from '@lucide/svelte'; 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 { editStyle } from '$lib/components/file-list/style/utils.svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import type { LineStyleExtension } from 'gpx';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { selection } from '$lib/logic/selection'; import { engine, type FileState } from '$lib/engine';
import { fileStateCollection } from '$lib/logic/file-state'; import { normalizeColor } from '$lib/file-colors';
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers'; import { selectedFileIds, type FileTreeNode } from '$lib/selection-helpers';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { fileActions } from '$lib/logic/file-actions';
let { let {
item, fileState,
node,
open = $bindable(), open = $bindable(),
}: { }: {
item: ListItem; fileState: FileState;
node: FileTreeNode;
open: boolean; open: boolean;
} = $props(); } = $props();
const { defaultOpacity, defaultWidth } = settings; const { defaultOpacity, defaultWidth } = settings;
const { files, selection } = engine;
let color: string = $state(''); let color: string = $state('');
let opacity: number = $state(0); let opacity: number = $state(0);
@@ -37,45 +33,24 @@
let opacityChanged = $state(false); let opacityChanged = $state(false);
let widthChanged = $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() { function setStyleInputs() {
opacity = $defaultOpacity; opacity = $defaultOpacity;
width = $defaultWidth; width = $defaultWidth;
color = fileState.color;
$selection.forEach((item) => { const { tracks } = fileState.structure;
if (item instanceof ListFileItem) { if (node.type === 'file') {
let file = fileStateCollection.getFile(item.getFileId()); opacity = tracks.find((track) => track.opacity !== undefined)?.opacity ?? opacity;
let layer = gpxLayers.getLayer(item.getFileId()); width = tracks.find((track) => track.width !== undefined)?.width ?? width;
if (file && layer) { } else if (node.type === 'track') {
let style = file.getStyle(); const track = tracks.find((track) => track.id === node.trackId);
color = layer.layerColor; if (track) {
if (style.opacity.length > 0) { color = track.color !== undefined ? normalizeColor(track.color) : color;
opacity = style.opacity[0]; opacity = track.opacity ?? opacity;
} width = track.width ?? width;
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'];
}
}
}
} }
}); }
colorChanged = false; colorChanged = false;
opacityChanged = false; opacityChanged = false;
@@ -95,24 +70,18 @@
}); });
function applyStyle() { function applyStyle() {
let style: LineStyleExtension = {}; engine.style({
if (colorChanged) { color: colorChanged ? color : undefined,
style['gpx_style:color'] = color; opacity: opacityChanged ? opacity : undefined,
} width: widthChanged ? width : undefined,
if (opacityChanged) { });
style['gpx_style:opacity'] = opacity;
}
if (widthChanged) {
style['gpx_style:width'] = width;
}
fileActions.setStyleToSelection(style);
if (item instanceof ListFileItem && $selection.size === fileStateCollection.size) { if (node.type === 'file' && selectedFileIds($selection).length === $files.size) {
if (style['gpx_style:opacity']) { if (opacityChanged) {
$defaultOpacity = style['gpx_style:opacity']; $defaultOpacity = opacity;
} }
if (style['gpx_style:width']) { if (widthChanged) {
$defaultWidth = style['gpx_style:width']; $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 { get, writable, type Readable, type Writable } from 'svelte/store';
import { FileColorAllocator, normalizeColor } from '$lib/file-colors'; import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
import { setHidden, type Visibility } from '$lib/file-visibility'; 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 { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type { FileStatistics, FileStructure, Selection } from 'gpx-rs'; import type { FileStatistics, FileStructure, Selection } from 'gpx-rs';
@@ -215,6 +216,78 @@ class Engine {
return this.run((w) => w.reorder(idsToBytes(fileIds), index)); 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 * 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 * 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 { describe, it } from 'node:test';
import assert from 'node:assert/strict'; 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 { import {
elementId,
hasSelectionWithin, hasSelectionWithin,
isCovered, isCovered,
isEmpty, isEmpty,
isSelected, isSelected,
isSelectionHidden,
selectedElementIds,
selectedFileIds, selectedFileIds,
selectionSize,
type FileTreeNode, type FileTreeNode,
} from './selection-helpers'; } from './selection-helpers';
@@ -63,6 +68,36 @@ describe('selection helpers', () => {
assert.deepEqual(selectedFileIds(waypoint('W1')), ['F1']); 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', () => { describe('isSelected', () => {
it('matches the selected nodes only', () => { it('matches the selected nodes only', () => {
assert.deepEqual(matching(empty, isSelected), []); assert.deepEqual(matching(empty, isSelected), []);
@@ -137,4 +172,48 @@ describe('selection helpers', () => {
assert.deepEqual(matching(files('F1'), hasSelectionWithin), ['F1']); 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 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: * An element of the file tree, to ask questions about the selection:
@@ -15,6 +16,25 @@ export type FileTreeNode =
| { type: 'waypoints'; fileId: string } | { type: 'waypoints'; fileId: string }
| { type: 'waypoint'; fileId: string; waypointId: 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 { export function isEmpty(selection: Selection): boolean {
return selection.type === 'empty'; 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. */ /** Whether the node itself is selected. */
export function isSelected(selection: Selection, node: FileTreeNode): boolean { export function isSelected(selection: Selection, node: FileTreeNode): boolean {
switch (node.type) { switch (node.type) {
@@ -107,3 +166,21 @@ export function hasSelectionWithin(selection: Selection, node: FileTreeNode): bo
return isSelected(selection, node); 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));
})
);
}