diff --git a/gpx-rs/engine/src/engine/command/edit/metadata.rs b/gpx-rs/engine/src/engine/command/edit/metadata.rs index 0b88bba06..4009d1cc0 100644 --- a/gpx-rs/engine/src/engine/command/edit/metadata.rs +++ b/gpx-rs/engine/src/engine/command/edit/metadata.rs @@ -17,6 +17,10 @@ impl Editor for Metadata<'_> { fn file(&mut self, file: &mut File) -> Edit { file.info.name = self.name.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 } @@ -31,7 +35,7 @@ impl Editor for Metadata<'_> { mod tests { use std::collections::HashSet; - use crate::{New, Selection, engine::command::fixture::Fixture}; + use crate::{New, NewTrack, Selection, engine::command::fixture::Fixture}; use super::*; @@ -83,4 +87,55 @@ mod tests { assert_eq!(after.trk[0].info.name.as_deref(), Some("renamed")); 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())); + } } diff --git a/gpx-rs/engine/src/engine/derived/file_structure.rs b/gpx-rs/engine/src/engine/derived/file_structure.rs index 98ebd6426..613ab4214 100644 --- a/gpx-rs/engine/src/engine/derived/file_structure.rs +++ b/gpx-rs/engine/src/engine/derived/file_structure.rs @@ -12,6 +12,7 @@ use crate::{ pub struct FileStructure { pub id: FileId, pub name: String, + pub desc: Option, pub tracks: Vec, pub waypoints: Vec, /// Changes whenever the waypoints of the file (hence their buffer) change. @@ -22,6 +23,7 @@ pub struct FileStructure { pub struct TrackNode { pub id: TrackId, pub name: Option, + pub desc: Option, /// Style of the track, only when the file defines it. pub color: Option, pub opacity: Option, @@ -49,12 +51,14 @@ impl FileStructure { Self { id: file.id, name: file.info.name.clone(), + desc: file.info.desc.clone(), tracks: file .trk .iter() .map(|trk| TrackNode { id: trk.id, name: trk.info.name.clone(), + desc: trk.info.desc.clone(), color: trk.info.color.clone(), opacity: trk.info.opacity, width: trk.info.width, @@ -99,10 +103,13 @@ mod tests { Load { data: &data }.apply(&mut fx.state()).unwrap(); let file = &fx.files[&fx.order.0[0]]; 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()); 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!( (&n.color, n.opacity, n.width), (&t.info.color, t.info.opacity, t.info.width) diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 384cf96dc..dff664211 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -436,6 +436,7 @@ export interface FilesUpdate { export interface FileStructure { id: string; name: string; + desc?: string; tracks: TrackNode[]; waypoints: WaypointNode[]; /** Changes when the waypoints of the file change: refetch their coordinates. */ @@ -463,6 +464,7 @@ export interface FileStatistics { export interface TrackNode { id: string; name?: string; + desc?: string; /** Style of the track, only present when the file defines it. */ color?: string; opacity?: number; @@ -520,11 +522,17 @@ fn named_node(id: uuid::Uuid, name: Option<&str>) -> Object { fn structure_object(file: &engine::FileStructure) -> Object { let node = named_node(file.id.0, Some(&file.name)); + if let Some(desc) = &file.desc { + set(&node, "desc", desc.as_str()); + } set( &node, "tracks", array(&file.tracks, |trk| { 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 { set(&node, "color", color.as_str()); } diff --git a/website/src/lib/all-hidden.ts b/website/src/lib/all-hidden.ts new file mode 100644 index 000000000..74a42113a --- /dev/null +++ b/website/src/lib/all-hidden.ts @@ -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(); + const unsubscribes = new Map 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(); + }; +}); diff --git a/website/src/lib/components/file-list/FileList.svelte b/website/src/lib/components/file-list/FileList.svelte index de054baa8..f65e0e893 100644 --- a/website/src/lib/components/file-list/FileList.svelte +++ b/website/src/lib/components/file-list/FileList.svelte @@ -1,15 +1,17 @@ @@ -54,7 +51,7 @@ : 'flex-row'} {className ?? ''}" {style} > - + {#if orientation === 'vertical'} @@ -66,8 +63,8 @@ selection.selectAll()} - disabled={$fileStateCollection.size === 0} + onclick={() => engine.selectAll()} + disabled={$files.size === 0} > {i18n._('menu.select_all')} diff --git a/website/src/lib/components/file-list/FileListNode.svelte b/website/src/lib/components/file-list/FileListNode.svelte index 2561a2f72..31c96b02b 100644 --- a/website/src/lib/components/file-list/FileListNode.svelte +++ b/website/src/lib/components/file-list/FileListNode.svelte @@ -1,89 +1,93 @@ -{#if node instanceof Map} - -{:else if node instanceof TrackSegment} - -{:else if node instanceof Waypoint} - +{#if node.type === 'segment' || node.type === 'waypoint'} + {:else if recursive} - + {#snippet trigger()} - + {/snippet} {#snippet content()}
- {#key node} - - {/key} +
{/snippet}
{:else} - + {/if} diff --git a/website/src/lib/components/file-list/FileListNodeContent.svelte b/website/src/lib/components/file-list/FileListNodeContent.svelte index fe6926cc3..63cb8972d 100644 --- a/website/src/lib/components/file-list/FileListNodeContent.svelte +++ b/website/src/lib/components/file-list/FileListNodeContent.svelte @@ -1,41 +1,39 @@ @@ -105,8 +114,8 @@ { 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} - - + {#if node.type === 'file' || node.type === 'track'} + + {/if} - {#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK} + {#if level === ListLevel.FILE || level === ListLevel.TRACK}
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} - {:else if item.level === ListLevel.WAYPOINT} + {:else if level === ListLevel.WAYPOINT} {#if symbolKey && symbols[symbolKey].icon} {@const SymbolIcon = symbols[symbolKey].icon} @@ -211,7 +216,7 @@ - {#if item instanceof ListFileItem || item instanceof ListTrackItem} + {#if node.type === 'file' || node.type === 'track'} (editMetadata.current = true)} @@ -225,15 +230,7 @@ {i18n._('menu.style.button')} {/if} - { - if ($allHidden) { - fileActions.setHiddenToSelection(false); - } else { - fileActions.setHiddenToSelection(true); - } - }} - > + engine.setSelectionHidden(!$allHidden)}> {#if $allHidden} {i18n._('menu.unhide')} @@ -245,20 +242,16 @@ {#if orientation === 'vertical'} - {#if item instanceof ListFileItem} - fileActions.addNewTrack(item.getFileId())} - > + {#if node.type === 'file'} + engine.newTrack()}> {i18n._('menu.new_track')} - {:else if item instanceof ListTrackItem} + {:else if node.type === 'track'} - fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())} + onclick={() => engine.newTrackSegment()} > {i18n._('menu.new_segment')} @@ -266,8 +259,8 @@ {/if} {/if} - {#if item.level !== ListLevel.WAYPOINTS} - selection.selectAll()}> + {#if level !== ListLevel.WAYPOINTS} + engine.selectAllSiblings(node)}> {i18n._('menu.select_all')} @@ -279,18 +272,18 @@ - + engine.duplicate()}> {i18n._('menu.duplicate')} {#if orientation === 'vertical'} - selection.copySelection()}> + oldSelection.copySelection()}> {i18n._('menu.copy')} - selection.cutSelection()}> + oldSelection.cutSelection()}> {i18n._('menu.cut')} @@ -298,7 +291,7 @@ @@ -307,7 +300,7 @@ {/if} - + engine.delete()}> {i18n._('menu.delete')} diff --git a/website/src/lib/components/file-list/FileListNodeStore.svelte b/website/src/lib/components/file-list/FileListNodeStore.svelte index f6c9b4469..734a7f527 100644 --- a/website/src/lib/components/file-list/FileListNodeStore.svelte +++ b/website/src/lib/components/file-list/FileListNodeStore.svelte @@ -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; + file: Readable; } = $props(); let recursive = getContext('recursive'); -{#if $file} - {#if recursive} - - - - {:else} - - {/if} +{#if recursive} + + + +{:else} + {/if} diff --git a/website/src/lib/components/file-list/metadata/MetadataDialog.svelte b/website/src/lib/components/file-list/metadata/MetadataDialog.svelte index 2af578ead..b7219471e 100644 --- a/website/src/lib/components/file-list/metadata/MetadataDialog.svelte +++ b/website/src/lib/components/file-list/metadata/MetadataDialog.svelte @@ -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 | 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 @@