mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-07 06:14:07 +00:00
progress
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user