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