This commit is contained in:
vcoppe
2025-10-26 12:12:23 +01:00
parent 17e5347d55
commit 722cf58486
12 changed files with 401 additions and 385 deletions

View File

@@ -51,11 +51,7 @@
import { anySelectedLayer } from '$lib/components/map/layer-control/utils'; import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
import { defaultOverlays } from '$lib/assets/layers'; import { defaultOverlays } from '$lib/assets/layers';
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte'; import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
import { import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list';
allowedPastes,
ListFileItem,
ListTrackItem,
} from '$lib/components/file-list/file-list';
import Export from '$lib/components/export/Export.svelte'; import Export from '$lib/components/export/Export.svelte';
import { mode, setMode } from 'mode-watcher'; import { mode, setMode } from 'mode-watcher';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
@@ -75,6 +71,7 @@
import { allHidden } from '$lib/logic/hidden'; import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds'; import { boundsManager } from '$lib/logic/bounds';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
const { const {
distanceUnits, distanceUnits,

View File

@@ -34,9 +34,10 @@
<Collapsible.Trigger class="w-full"> <Collapsible.Trigger class="w-full">
<Button <Button
variant="ghost" variant="ghost"
class="w-full flex flex-row {side === 'right' size="icon"
class="w-full flex flex-row gap-1 {side === 'right'
? 'justify-between' ? 'justify-between'
: 'justify-start'} p-0 has-[>svg]:px-0 h-fit {nohover : 'justify-start pl-1'} h-fit {nohover
? 'hover:bg-background' ? 'hover:bg-background'
: ''} pointer-events-none" : ''} pointer-events-none"
> >
@@ -60,9 +61,10 @@
{:else} {:else}
<Button <Button
variant="ghost" variant="ghost"
class="w-full flex flex-row {side === 'right' size="icon"
class="w-full flex flex-row gap-1 {side === 'right'
? 'justify-between' ? 'justify-between'
: 'justify-start'} p-0 has-[>svg]:px-0 h-fit {nohover ? 'hover:bg-background' : ''}" : 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
> >
{#if side === 'left'} {#if side === 'left'}
<Collapsible.Trigger> <Collapsible.Trigger>
@@ -85,7 +87,6 @@
{/if} {/if}
</Button> </Button>
{/if} {/if}
<Collapsible.Content> <Collapsible.Content>
{@render props.content()} {@render props.content()}
</Collapsible.Content> </Collapsible.Content>

View File

@@ -2,15 +2,15 @@
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 FileListNode from './FileListNode.svelte';
import { setContext } from 'svelte'; import { onMount, setContext } from 'svelte';
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './file-list'; import { ListFileItem, ListLevel, ListRootItem } 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 { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state'; import { fileStateCollection } from '$lib/logic/file-state';
import { createFile, pasteSelection } from '$lib/logic/file-actions'; import { createFile, pasteSelection } from '$lib/logic/file-actions';
import { selection, copied } from '$lib/logic/selection'; import { selection, copied } from '$lib/logic/selection';
import { allowedPastes } from './sortable-file-list';
let { let {
orientation, orientation,
@@ -27,30 +27,28 @@
setContext('orientation', orientation); setContext('orientation', orientation);
setContext('recursive', recursive); setContext('recursive', recursive);
const { treeFileView } = settings; onMount(() => {
if (orientation === 'vertical') {
// treeFileView.subscribe(($vertical) => { selection.update(($selection) => {
// if ($vertical) { $selection.forEach((item) => {
// selection.update(($selection) => { if ($selection.hasAnyChildren(item, false)) {
// $selection.forEach((item) => { $selection.toggle(item);
// if ($selection.hasAnyChildren(item, false)) { }
// $selection.toggle(item); });
// } return $selection;
// }); });
// return $selection; } else {
// }); selection.update(($selection) => {
// } else { $selection.forEach((item) => {
// selection.update(($selection) => { if (!(item instanceof ListFileItem)) {
// $selection.forEach((item) => { $selection.toggle(item);
// if (!(item instanceof ListFileItem)) { $selection.set(new ListFileItem(item.getFileId()), true);
// $selection.toggle(item); }
// $selection.set(new ListFileItem(item.getFileId()), true); });
// } return $selection;
// }); });
// return $selection; }
// }); });
// }
// });
</script> </script>
<ScrollArea <ScrollArea
@@ -71,7 +69,7 @@
<ContextMenu.Trigger class="grow" /> <ContextMenu.Trigger class="grow" />
<ContextMenu.Content> <ContextMenu.Content>
<ContextMenu.Item onclick={createFile}> <ContextMenu.Item onclick={createFile}>
<Plus size="16" class="mr-1" /> <Plus size="16" />
{i18n._('menu.new_file')} {i18n._('menu.new_file')}
<Shortcut key="+" ctrl={true} /> <Shortcut key="+" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
@@ -80,7 +78,7 @@
onclick={() => selection.selectAll()} onclick={() => selection.selectAll()}
disabled={$fileStateCollection.size === 0} disabled={$fileStateCollection.size === 0}
> >
<FileStack size="16" class="mr-1" /> <FileStack size="16" />
{i18n._('menu.select_all')} {i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
@@ -91,7 +89,7 @@
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)} !allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
onclick={pasteSelection} onclick={pasteSelection}
> >
<ClipboardPaste size="16" class="mr-1" /> <ClipboardPaste size="16" />
{i18n._('menu.paste')} {i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>

View File

@@ -58,15 +58,15 @@
const { treeFileView } = settings; const { treeFileView } = settings;
function openIfSelectedChild() { // function openIfSelectedChild() {
if (collapsible && treeFileView.value && $selection.hasAnyChildren(item, false)) { // if (collapsible && $treeFileView && $selection.hasAnyChildren(item, false)) {
collapsible.openNode(); // collapsible.openNode();
} // }
} // }
if ($selection) { // if ($selection) {
openIfSelectedChild(); // openIfSelectedChild();
} // }
// afterUpdate(openIfSelectedChild); // afterUpdate(openIfSelectedChild);
</script> </script>
@@ -83,7 +83,7 @@
<FileListNodeLabel {node} {item} {label} /> <FileListNodeLabel {node} {item} {label} />
{/snippet} {/snippet}
{#snippet content()} {#snippet content()}
<div class="ml-2"> <div class="ml-4">
{#key node} {#key node}
<FileListNodeContent {node} {item} /> <FileListNodeContent {node} {item} />
{/key} {/key}

View File

@@ -1,28 +1,13 @@
<script lang="ts" context="module">
let dragging: Writable<ListLevel | null> = writable(null);
let updating = false;
</script>
<script lang="ts"> <script lang="ts">
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx'; import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
import { getContext, onDestroy, onMount } from 'svelte'; import { getContext, onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable'; import { type Readable } from 'svelte/store';
import { get, writable, type Readable, type Writable } from 'svelte/store';
import FileListNodeStore from './FileListNodeStore.svelte'; import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte'; import FileListNode from './FileListNode.svelte';
import { import FileListNodeContent from './FileListNodeContent.svelte';
ListFileItem, import { ListFileItem, ListLevel, ListWaypointsItem, type ListItem } from './file-list';
ListLevel,
ListRootItem,
ListWaypointsItem,
allowedMoves,
type ListItem,
} from './file-list';
import { isMac } from '$lib/utils';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree'; import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { settings } from '$lib/logic/settings'; import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
import { getFileIds, moveItems } from '$lib/logic/file-actions';
let { let {
node, node,
@@ -32,13 +17,13 @@
node: node:
| Map<string, Readable<GPXFileWithStatistics | undefined>> | Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement> | GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint; | Waypoint;
item: ListItem; item: ListItem;
waypointRoot?: boolean; waypointRoot?: boolean;
} = $props(); } = $props();
let container: HTMLElement; let container: HTMLElement;
let elements: { [id: string]: HTMLElement } = {};
let sortableLevel: ListLevel = let sortableLevel: ListLevel =
node instanceof Map node instanceof Map
? ListLevel.FILE ? ListLevel.FILE
@@ -51,253 +36,26 @@
: node instanceof Track : node instanceof Track
? ListLevel.SEGMENT ? ListLevel.SEGMENT
: ListLevel.WAYPOINT; : ListLevel.WAYPOINT;
let sortable: Sortable;
let orientation = getContext<'vertical' | 'horizontal'>('orientation'); let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let destroyed = false; let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
let lastUpdateStart = 0;
function updateToSelection(e) {
if (destroyed) {
return;
}
lastUpdateStart = Date.now(); let sortable: SortableFileList;
setTimeout(() => {
if (Date.now() - lastUpdateStart >= 40) {
if (updating) {
return;
}
updating = true;
// Sortable updates selection
let changed = getChangedIds();
if (changed.length > 0) {
selection.update(($selection) => {
$selection.clear();
Object.entries(elements).forEach(([id, element]) => {
$selection.set(
item.extend(getRealId(id)),
element.classList.contains('sortable-selected')
);
});
if (
e.originalEvent &&
!(
e.originalEvent.ctrlKey ||
e.originalEvent.metaKey ||
e.originalEvent.shiftKey
) &&
($selection.size > 1 ||
!$selection.has(item.extend(getRealId(changed[0]))))
) {
// Fix bug that sometimes causes a single select to be treated as a multi-select
$selection.clear();
$selection.set(item.extend(getRealId(changed[0])), true);
}
return $selection;
});
}
updating = false;
}
}, 50);
}
function updateFromSelection() {
if (destroyed || updating) {
return;
}
updating = true;
// Selection updates sortable
let changed = getChangedIds();
for (let id of changed) {
let element = elements[id];
if (element) {
if ($selection.has(item.extend(id))) {
Sortable.utils.select(element);
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
} else {
Sortable.utils.deselect(element);
}
}
}
updating = false;
}
$: if ($selection) {
updateFromSelection();
}
function syncFileOrder(order: string[]) {
if (!sortable || sortableLevel !== ListLevel.FILE) {
return;
}
const currentOrder = sortable.toArray();
if (currentOrder.length !== order.length) {
sortable.sort(order);
} else {
for (let i = 0; i < currentOrder.length; i++) {
if (currentOrder[i] !== order[i]) {
sortable.sort(order);
break;
}
}
}
}
const { fileOrder } = settings;
$effect(() => syncFileOrder(fileOrder.value));
function createSortable() {
sortable = Sortable.create(container, {
group: {
name: sortableLevel,
pull: allowedMoves[sortableLevel],
put: true,
},
direction: orientation,
forceAutoScrollFallback: true,
multiDrag: true,
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
avoidImplicitDeselect: true,
onSelect: updateToSelection,
onDeselect: updateToSelection,
onStart: () => {
dragging.set(sortableLevel);
},
onEnd: () => {
dragging.set(null);
},
onSort: (e) => {
if (sortableLevel === ListLevel.FILE) {
let newFileOrder = sortable.toArray();
if (newFileOrder.length !== fileOrder.value.length) {
fileOrder.value = newFileOrder;
} else {
for (let i = 0; i < newFileOrder.length; i++) {
if (newFileOrder[i] !== fileOrder.value[i]) {
fileOrder.value = newFileOrder;
break;
}
}
}
}
let fromItem = Sortable.get(e.from)._item;
let toItem = Sortable.get(e.to)._item;
if (item === toItem && !(fromItem instanceof ListRootItem)) {
// Event is triggered on source and destination list, only handle it once
let fromItems = [];
let toItems = [];
if (Sortable.get(e.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);
fromItems = oldIndices.map((i) => fromItem.extend(i));
}
if (Sortable.get(e.from)._waypointRoot && Sortable.get(e.to)._waypointRoot) {
toItems = [toItem.extend('waypoints')];
} else {
if (Sortable.get(e.to)._waypointRoot) {
toItem = toItem.extend('waypoints');
}
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 (toItem instanceof ListRootItem) {
let newFileIds = getFileIds(newIndices.length);
toItems = newIndices.map((i, index) => {
fileOrder.value.splice(i, 0, newFileIds[index]);
return item.extend(newFileIds[index]);
});
} else {
toItems = newIndices.map((i) => toItem.extend(i));
}
}
moveItems(fromItem, toItem, fromItems, toItems);
}
},
});
Object.defineProperty(sortable, '_item', {
value: item,
writable: true,
});
Object.defineProperty(sortable, '_waypointRoot', {
value: waypointRoot,
writable: true,
});
}
onMount(() => { onMount(() => {
createSortable(); sortable = new SortableFileList(
destroyed = false; container,
}); node,
item,
afterUpdate(() => { waypointRoot,
elements = {}; sortableLevel,
container.childNodes.forEach((element) => { orientation
if (element instanceof HTMLElement) { );
let attr = element.getAttribute('data-id');
if (attr) {
if (node instanceof Map && !node.has(attr)) {
element.remove();
} else {
elements[attr] = element;
}
}
}
});
syncFileOrder();
updateFromSelection();
}); });
onDestroy(() => { onDestroy(() => {
destroyed = true; sortable.destroy();
}); });
function getChangedIds() {
let changed: (string | number)[] = [];
Object.entries(elements).forEach(([id, element]) => {
let realId = getRealId(id);
let realItem = item.extend(realId);
let inSelection = get(selection).has(realItem);
let isSelected = element.classList.contains('sortable-selected');
if (inSelection !== isSelected) {
changed.push(realId);
}
});
return changed;
}
function getRealId(id: string | number) {
return sortableLevel === ListLevel.FILE || sortableLevel === ListLevel.WAYPOINTS
? id
: parseInt(id);
}
$: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel);
</script> </script>
<div <div
@@ -343,7 +101,7 @@
{#if node instanceof GPXFile && item instanceof ListFileItem} {#if node instanceof GPXFile && item instanceof ListFileItem}
{#if !waypointRoot} {#if !waypointRoot}
<svelte:self {node} {item} waypointRoot={true} /> <FileListNodeContent {node} {item} waypointRoot={true} />
{/if} {/if}
{/if} {/if}

View File

@@ -17,31 +17,31 @@
Maximize, Maximize,
Scissors, Scissors,
FileStack, FileStack,
FileX,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { import {
ListFileItem, ListFileItem,
ListLevel, ListLevel,
ListTrackItem, ListTrackItem,
ListWaypointItem, ListWaypointItem,
allowedPastes,
type ListItem, type ListItem,
} from './file-list'; } from './file-list';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { get } from 'svelte/store';
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx'; 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 './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 { waypointPopup } from '$lib/components/map/gpx-layer/GPXLayerPopup';
import { getSymbolKey, symbols } from '$lib/assets/symbols'; import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { selection, copied, cut } from '$lib/logic/selection'; import { selection, copied, cut } from '$lib/logic/selection';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { fileActions, pasteSelection } from '$lib/logic/file-actions'; import { fileActions, pasteSelection } from '$lib/logic/file-actions';
import { allHidden } from '$lib/logic/hidden'; import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds'; import { boundsManager } from '$lib/logic/bounds';
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 { let {
node, node,
@@ -58,13 +58,15 @@
let singleSelection = $derived($selection.size === 1); let singleSelection = $derived($selection.size === 1);
let nodeColors: string[] = []; /* $derived.by(() => { let nodeColors: string[] = $state([]);
$effect.pre(() => {
let colors: string[] = []; let colors: string[] = [];
if (node && map.value) { if (node && $map) {
if (node instanceof GPXFile) { if (node instanceof GPXFile) {
let defaultColor = undefined; let defaultColor = undefined;
let layer = gpxLayers.get(item.getFileId()); let layer = gpxLayers.getLayer(item.getFileId());
if (layer) { if (layer) {
defaultColor = layer.layerColor; defaultColor = layer.layerColor;
} }
@@ -78,23 +80,20 @@
} else if (node instanceof Track) { } else if (node instanceof Track) {
let style = node.getStyle(); let style = node.getStyle();
if (style) { if (style) {
if ( if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
style['gpx_style:color'] && colors.push(style['gpx_style:color']);
!nodeColors.includes(style['gpx_style:color'])
) {
nodeColors.push(style['gpx_style:color']);
} }
} }
if (colors.length === 0) { if (colors.length === 0) {
let layer = gpxLayers.get(item.getFileId()); let layer = gpxLayers.getLayer(item.getFileId());
if (layer) { if (layer) {
colors.push(layer.layerColor); colors.push(layer.layerColor);
} }
} }
} }
} }
return colors; nodeColors = colors;
});*/ });
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined); let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
@@ -117,8 +116,8 @@
<ContextMenu.Root <ContextMenu.Root
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) { if (open) {
if (!get(selection).has(item)) { if (!$selection.has(item)) {
selectItem(item); selection.selectItem(item);
} }
} }
}} }}
@@ -126,7 +125,7 @@
<ContextMenu.Trigger class="grow truncate"> <ContextMenu.Trigger class="grow truncate">
<Button <Button
variant="ghost" variant="ghost"
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation === class="relative w-full p-0 overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
'vertical' 'vertical'
? 'h-fit' ? 'h-fit'
: 'h-9 px-1.5 shadow-md'} pointer-events-auto" : 'h-9 px-1.5 shadow-md'} pointer-events-auto"
@@ -172,8 +171,8 @@
}} }}
onmouseenter={() => { onmouseenter={() => {
if (item instanceof ListWaypointItem) { if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId()); let layer = gpxLayers.getLayer(item.getFileId());
let file = getFile(item.getFileId()); let file = fileStateCollection.getFile(item.getFileId());
if (layer && file) { if (layer && file) {
let waypoint = file.wpt[item.getWaypointIndex()]; let waypoint = file.wpt[item.getWaypointIndex()];
if (waypoint) { if (waypoint) {
@@ -187,7 +186,7 @@
}} }}
onmouseleave={() => { onmouseleave={() => {
if (item instanceof ListWaypointItem) { if (item instanceof ListWaypointItem) {
let layer = gpxLayers.get(item.getFileId()); let layer = gpxLayers.getLayer(item.getFileId());
if (layer) { if (layer) {
waypointPopup?.setItem(null); waypointPopup?.setItem(null);
} }
@@ -195,13 +194,13 @@
}} }}
> >
{#if item.level === ListLevel.SEGMENT} {#if item.level === ListLevel.SEGMENT}
<Waypoints size="16" class="mr-1 shrink-0" /> <Waypoints size="16" class="mx-1 shrink-0" />
{:else if item.level === ListLevel.WAYPOINT} {:else if item.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="mr-1 shrink-0" /> <SymbolIcon size="16" class="mx-1 shrink-0" />
{:else} {:else}
<MapPin size="16" class="mr-1 shrink-0" /> <MapPin size="16" class="mx-1 shrink-0" />
{/if} {/if}
{/if} {/if}
<span <span
@@ -213,13 +212,10 @@
</span> </span>
{#if hidden} {#if hidden}
<EyeOff <EyeOff
size="12" size="10"
class="shrink-0 mt-1 ml-1 {orientation === 'vertical' class="shrink-0 size-3.5 ml-1 {orientation === 'vertical'
? 'mr-2'
: ''} {item.level === ListLevel.SEGMENT ||
item.level === ListLevel.WAYPOINT
? 'mr-3' ? 'mr-3'
: ''}" : 'mt-0.5'}"
/> />
{/if} {/if}
</span> </span>
@@ -231,12 +227,12 @@
disabled={!singleSelection} disabled={!singleSelection}
onclick={() => (editMetadata.current = true)} onclick={() => (editMetadata.current = true)}
> >
<Info size="16" class="mr-1" /> <Info size="16" />
{i18n._('menu.metadata.button')} {i18n._('menu.metadata.button')}
<Shortcut key="I" ctrl={true} /> <Shortcut key="I" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item onclick={() => (editStyle.current = true)}> <ContextMenu.Item onclick={() => (editStyle.current = true)}>
<PaintBucket size="16" class="mr-1" /> <PaintBucket size="16" />
{i18n._('menu.style.button')} {i18n._('menu.style.button')}
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
@@ -250,10 +246,10 @@
}} }}
> >
{#if $allHidden} {#if $allHidden}
<Eye size="16" class="mr-1" /> <Eye size="16" />
{i18n._('menu.unhide')} {i18n._('menu.unhide')}
{:else} {:else}
<EyeOff size="16" class="mr-1" /> <EyeOff size="16" />
{i18n._('menu.hide')} {i18n._('menu.hide')}
{/if} {/if}
<Shortcut key="H" ctrl={true} /> <Shortcut key="H" ctrl={true} />
@@ -265,7 +261,7 @@
disabled={!singleSelection} disabled={!singleSelection}
onclick={() => fileActions.addNewTrack(item.getFileId())} onclick={() => fileActions.addNewTrack(item.getFileId())}
> >
<Plus size="16" class="mr-1" /> <Plus size="16" />
{i18n._('menu.new_track')} {i18n._('menu.new_track')}
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
@@ -275,7 +271,7 @@
onclick={() => onclick={() =>
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())} fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
> >
<Plus size="16" class="mr-1" /> <Plus size="16" />
{i18n._('menu.new_segment')} {i18n._('menu.new_segment')}
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
@@ -283,30 +279,30 @@
{/if} {/if}
{#if item.level !== ListLevel.WAYPOINTS} {#if item.level !== ListLevel.WAYPOINTS}
<ContextMenu.Item onclick={() => selection.selectAll()}> <ContextMenu.Item onclick={() => selection.selectAll()}>
<FileStack size="16" class="mr-1" /> <FileStack size="16" />
{i18n._('menu.select_all')} {i18n._('menu.select_all')}
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
<ContextMenu.Item onclick={() => boundsManager.centerMapOnSelection()}> <ContextMenu.Item onclick={() => boundsManager.centerMapOnSelection()}>
<Maximize size="16" class="mr-1" /> <Maximize size="16" />
{i18n._('menu.center')} {i18n._('menu.center')}
<Shortcut key="⏎" ctrl={true} /> <Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.duplicateSelection}> <ContextMenu.Item onclick={fileActions.duplicateSelection}>
<Copy size="16" class="mr-1" /> <Copy size="16" />
{i18n._('menu.duplicate')} {i18n._('menu.duplicate')}
<Shortcut key="D" ctrl={true} /></ContextMenu.Item <Shortcut key="D" ctrl={true} />
> </ContextMenu.Item>
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
<ContextMenu.Item onclick={() => selection.copySelection()}> <ContextMenu.Item onclick={() => selection.copySelection()}>
<ClipboardCopy size="16" class="mr-1" /> <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={() => selection.cutSelection()}>
<Scissors size="16" class="mr-1" /> <Scissors size="16" />
{i18n._('menu.cut')} {i18n._('menu.cut')}
<Shortcut key="X" ctrl={true} /> <Shortcut key="X" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
@@ -316,20 +312,15 @@
!allowedPastes[$copied[0].level].includes(item.level)} !allowedPastes[$copied[0].level].includes(item.level)}
onclick={pasteSelection} onclick={pasteSelection}
> >
<ClipboardPaste size="16" class="mr-1" /> <ClipboardPaste size="16" />
{i18n._('menu.paste')} {i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
{/if} {/if}
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item onclick={fileActions.deleteSelection}> <ContextMenu.Item onclick={fileActions.deleteSelection}>
{#if item instanceof ListFileItem} <Trash2 size="16" />
<FileX size="16" class="mr-1" /> {i18n._('menu.delete')}
{i18n._('menu.close')}
{:else}
<Trash2 size="16" class="mr-1" />
{i18n._('menu.delete')}
{/if}
<Shortcut key="⌫" ctrl={true} /> <Shortcut key="⌫" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
</ContextMenu.Content> </ContextMenu.Content>

View File

@@ -7,24 +7,6 @@ export enum ListLevel {
WAYPOINT, WAYPOINT,
} }
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export abstract class ListItem { export abstract class ListItem {
[x: string]: any; [x: string]: any;
level: ListLevel; level: ListLevel;

View File

@@ -44,7 +44,7 @@
</script> </script>
<Popover.Root bind:open> <Popover.Root bind:open>
<Popover.Trigger /> <Popover.Trigger class="-mx-1" />
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3"> <Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
<Label for="name">{i18n._('menu.metadata.name')}</Label> <Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="font-semibold h-8" /> <Input bind:value={name} id="name" class="font-semibold h-8" />

View File

@@ -0,0 +1,289 @@
import { isMac } from '$lib/utils';
import Sortable, { type Direction } from 'sortablejs';
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';
const { fileOrder } = settings;
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
};
export const dragging = writable<ListLevel | null>(null);
export class SortableFileList {
private _node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint;
private _item: ListItem;
private _sortableLevel: ListLevel;
private _container: HTMLElement;
private _sortable: Sortable | null = null;
private _elements: { [id: string]: HTMLElement } = {};
private _unsubscribes: (() => void)[] = [];
constructor(
container: HTMLElement,
node:
| Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement>
| Waypoint[]
| Waypoint,
item: ListItem,
waypointRoot: boolean,
sortableLevel: ListLevel,
orientation: Direction
) {
this._node = node;
this._item = item;
this._sortableLevel = sortableLevel;
this._container = container;
this._sortable = Sortable.create(container, {
group: {
name: sortableLevel,
pull: allowedMoves[sortableLevel],
put: true,
},
direction: orientation,
forceAutoScrollFallback: true,
multiDrag: true,
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
avoidImplicitDeselect: true,
onSelect: (e) => this.updateToSelection(e),
onDeselect: (e) => this.updateToSelection(e),
onStart: () => {
dragging.set(sortableLevel);
},
onEnd: () => {
dragging.set(null);
},
onSort: (e: Sortable.SortableEvent) => {
this.updateToFileOrder();
const from = Sortable.get(e.from);
const to = Sortable.get(e.to);
if (!from || !to) {
return;
}
let fromItem = from._item;
let toItem = to._item;
console.log('onSort', e);
if (item === toItem && !(fromItem instanceof ListRootItem)) {
// Event is triggered on source and destination list, only handle it once
let fromItems = [];
let toItems = [];
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);
fromItems = oldIndices.map((i) => fromItem.extend(i));
}
if (from._waypointRoot && to._waypointRoot) {
toItems = [toItem.extend('waypoints')];
} else {
if (to._waypointRoot) {
toItem = toItem.extend('waypoints');
}
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 (toItem instanceof ListRootItem) {
let newFileIds = getFileIds(newIndices.length);
toItems = newIndices.map((i, index) => {
get(fileOrder).splice(i, 0, newFileIds[index]);
return item.extend(newFileIds[index]);
});
} else {
toItems = newIndices.map((i) => toItem.extend(i));
}
}
moveItems(fromItem, toItem, fromItems, toItems);
}
},
});
Object.defineProperty(this._sortable, '_item', {
value: item,
writable: true,
});
Object.defineProperty(this._sortable, '_waypointRoot', {
value: waypointRoot,
writable: true,
});
this._unsubscribes.push(selection.subscribe(() => this.updateFromSelection()));
this._unsubscribes.push(fileOrder.subscribe(() => this.updateFromFileOrder()));
}
updateToSelection(e: Sortable.SortableEvent) {
console.log('updateToSelection', e);
let changed = this.getChangedIds();
if (changed.length > 0) {
selection.update(($selection) => {
$selection.clear();
Object.entries(this._elements).forEach(([id, element]) => {
$selection.set(
this._item.extend(this.getRealId(id)),
element.classList.contains('sortable-selected')
);
});
if (
e.originalEvent &&
!(
e.originalEvent.ctrlKey ||
e.originalEvent.metaKey ||
e.originalEvent.shiftKey
) &&
($selection.size > 1 ||
!$selection.has(this._item.extend(this.getRealId(changed[0]))))
) {
// Fix bug that sometimes causes a single select to be treated as a multi-select
$selection.clear();
$selection.set(this._item.extend(this.getRealId(changed[0])), true);
}
return $selection;
});
}
}
updateFromSelection() {
console.log('updateFromSelection');
let changed = this.getChangedIds();
const selection_ = get(selection);
for (let id of changed) {
let element = this._elements[id];
if (element) {
if (selection_.has(this._item.extend(id))) {
Sortable.utils.select(element);
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
} else {
Sortable.utils.deselect(element);
}
}
}
}
updateFromFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
return;
}
console.log('updateFromFileOrder');
const fileOrder_ = get(fileOrder);
const sortableOrder = this._sortable.toArray();
if (
fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index])
) {
this._sortable.sort(fileOrder_);
}
}
updateToFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
return;
}
console.log('updateToFileOrder');
const fileOrder_ = get(fileOrder);
const sortableOrder = this._sortable.toArray();
if (
fileOrder_.length !== sortableOrder.length ||
fileOrder_.some((value, index) => value !== sortableOrder[index])
) {
fileOrder.set(sortableOrder);
}
}
updateElements() {
this._elements = {};
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)) {
element.remove();
} else {
this._elements[attr] = element;
}
}
}
});
// syncFileOrder();
// updateFromSelection();
}
destroy() {
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes = [];
}
getChangedIds() {
let changed: (string | number)[] = [];
const selection_ = get(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);
}
});
return changed;
}
getRealId(id: string | number) {
return this._sortableLevel === ListLevel.FILE || this._sortableLevel === ListLevel.WAYPOINTS
? id
: parseInt(id as string);
}
}

View File

@@ -121,7 +121,7 @@
</script> </script>
<Popover.Root bind:open> <Popover.Root bind:open>
<Popover.Trigger /> <Popover.Trigger class="-mx-1" />
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3"> <Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
<Label class="flex flex-row gap-2 items-center justify-between"> <Label class="flex flex-row gap-2 items-center justify-between">
{i18n._('menu.style.color')} {i18n._('menu.style.color')}

View File

@@ -32,8 +32,8 @@
} }
</script> </script>
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]"> <Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
<Card.Header class="p-0"> <Card.Header class="p-0 gap-0">
<Card.Title class="text-md"> <Card.Title class="text-md">
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href} {#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
<a href={waypoint.item.link.attributes.href} target="_blank"> <a href={waypoint.item.link.attributes.href} target="_blank">

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte'; import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte'; import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
// import FileList from '$lib/components/file-list/FileList.svelte'; import FileList from '$lib/components/file-list/FileList.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte'; import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import Map from '$lib/components/map/Map.svelte'; import Map from '$lib/components/map/Map.svelte';
import Menu from '$lib/components/Menu.svelte'; import Menu from '$lib/components/Menu.svelte';
@@ -106,7 +106,7 @@
<Toaster richColors /> <Toaster richColors />
{#if !$treeFileView} {#if !$treeFileView}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30"> <div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<!-- <FileList orientation="horizontal" /> --> <FileList orientation="horizontal" />
</div> </div>
{/if} {/if}
</div> </div>
@@ -140,7 +140,7 @@
</div> </div>
{#if $treeFileView} {#if $treeFileView}
<Resizer orientation="col" bind:after={$rightPanelSize} minAfter={100} maxAfter={400} /> <Resizer orientation="col" bind:after={$rightPanelSize} minAfter={100} maxAfter={400} />
<!-- <FileList orientation="vertical" recursive={true} style="width: {$rightPanelSize}px" /> --> <FileList orientation="vertical" recursive={true} style="width: {$rightPanelSize}px" />
{/if} {/if}
</div> </div>