2024-05-21 13:22:14 +02:00
|
|
|
<script lang="ts">
|
2025-02-02 11:17:22 +01:00
|
|
|
import { Button } from '$lib/components/ui/button';
|
|
|
|
|
import * as ContextMenu from '$lib/components/ui/context-menu';
|
|
|
|
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
|
|
|
|
import {
|
|
|
|
|
Copy,
|
|
|
|
|
Info,
|
|
|
|
|
MapPin,
|
|
|
|
|
PaintBucket,
|
|
|
|
|
Plus,
|
|
|
|
|
Trash2,
|
|
|
|
|
Waypoints,
|
|
|
|
|
Eye,
|
|
|
|
|
EyeOff,
|
|
|
|
|
ClipboardCopy,
|
|
|
|
|
ClipboardPaste,
|
|
|
|
|
Maximize,
|
|
|
|
|
Scissors,
|
|
|
|
|
FileStack,
|
2025-06-21 21:07:36 +02:00
|
|
|
} from '@lucide/svelte';
|
2026-10-04 11:00:46 +02:00
|
|
|
import { ListLevel } from './file-list';
|
2025-02-02 11:17:22 +01:00
|
|
|
import { getContext } from 'svelte';
|
2025-06-21 21:07:36 +02:00
|
|
|
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';
|
2025-10-26 12:12:23 +01:00
|
|
|
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
|
2025-06-21 21:07:36 +02:00
|
|
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
2025-02-02 11:17:22 +01:00
|
|
|
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
2026-10-04 11:00:46 +02:00
|
|
|
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';
|
2025-10-19 16:14:05 +02:00
|
|
|
import { boundsManager } from '$lib/logic/bounds';
|
2026-10-04 11:00:46 +02:00
|
|
|
// 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';
|
2025-10-26 12:12:23 +01:00
|
|
|
import { allowedPastes } from './sortable-file-list';
|
2024-05-21 13:22:14 +02:00
|
|
|
|
2025-06-21 21:07:36 +02:00
|
|
|
let {
|
2026-10-04 11:00:46 +02:00
|
|
|
fileState,
|
2025-06-21 21:07:36 +02:00
|
|
|
node,
|
|
|
|
|
label,
|
|
|
|
|
}: {
|
2026-10-04 11:00:46 +02:00
|
|
|
fileState: FileState;
|
|
|
|
|
node: FileTreeNode;
|
2025-06-21 21:07:36 +02:00
|
|
|
label: string | undefined;
|
|
|
|
|
} = $props();
|
2024-05-21 13:22:14 +02:00
|
|
|
|
2026-10-04 11:00:46 +02:00
|
|
|
const { selection } = engine;
|
|
|
|
|
|
2025-02-02 11:17:22 +01:00
|
|
|
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
2025-10-05 19:34:05 +02:00
|
|
|
let embedding = getContext<boolean>('embedding');
|
2024-06-13 19:11:54 +02:00
|
|
|
|
2026-10-04 11:00:46 +02:00
|
|
|
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);
|
2024-06-18 15:32:54 +02:00
|
|
|
|
2026-01-30 21:17:59 +01:00
|
|
|
let nodeColors: string[] = $derived.by(() => {
|
2026-10-04 11:00:46 +02:00
|
|
|
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];
|
2025-02-02 11:17:22 +01:00
|
|
|
}
|
2026-10-04 11:00:46 +02:00
|
|
|
return [];
|
2025-10-26 12:12:23 +01:00
|
|
|
});
|
2024-06-27 18:23:11 +02:00
|
|
|
|
2026-10-04 11:00:46 +02:00
|
|
|
let symbolKey = $derived(
|
|
|
|
|
node.type === 'waypoint'
|
|
|
|
|
? getSymbolKey(fileState.structure.waypoints.find((w) => w.id === node.waypointId)?.sym)
|
|
|
|
|
: undefined
|
2025-10-17 23:54:45 +02:00
|
|
|
);
|
2026-10-04 11:00:46 +02:00
|
|
|
|
|
|
|
|
let selected = $derived(isSelected($selection, node));
|
|
|
|
|
let openEditMetadata: boolean = $derived(editMetadata.current && singleSelection && selected);
|
2025-10-17 23:54:45 +02:00
|
|
|
let openEditStyle: boolean = $derived(
|
|
|
|
|
editStyle.current &&
|
2026-10-04 11:00:46 +02:00
|
|
|
selected &&
|
|
|
|
|
selectedElementIds($selection)[0]?.ids[0] === elementId(node)
|
2025-10-17 23:54:45 +02:00
|
|
|
);
|
2025-06-21 21:07:36 +02:00
|
|
|
|
2026-10-04 11:00:46 +02:00
|
|
|
let hidden = $derived(isHidden(fileState, elementId(node)));
|
2024-05-21 13:22:14 +02:00
|
|
|
</script>
|
|
|
|
|
|
2025-06-21 21:07:36 +02:00
|
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
2024-05-23 14:44:07 +02:00
|
|
|
<ContextMenu.Root
|
2025-02-02 11:17:22 +01:00
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (open) {
|
2026-10-04 11:00:46 +02:00
|
|
|
if (!selected) {
|
|
|
|
|
engine.selectNode(node);
|
2025-02-02 11:17:22 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}}
|
2024-05-23 14:44:07 +02:00
|
|
|
>
|
2025-02-02 11:17:22 +01:00
|
|
|
<ContextMenu.Trigger class="grow truncate">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
2026-04-06 18:22:01 +02:00
|
|
|
class="relative w-full p-0 overflow-hidden border-none focus-visible:ring-0 focus-visible:ring-offset-0 flex flex-row {orientation ===
|
2025-02-02 11:17:22 +01:00
|
|
|
'vertical'
|
2026-04-06 18:22:01 +02:00
|
|
|
? 'h-7'
|
|
|
|
|
: 'h-9 px-1.5'} pointer-events-auto"
|
2025-02-02 11:17:22 +01:00
|
|
|
>
|
2026-10-04 11:00:46 +02:00
|
|
|
{#if node.type === 'file' || node.type === 'track'}
|
|
|
|
|
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
|
|
|
|
|
<StyleDialog bind:open={openEditStyle} {fileState} {node} />
|
2025-02-02 11:17:22 +01:00
|
|
|
{/if}
|
2026-10-04 11:00:46 +02:00
|
|
|
{#if level === ListLevel.FILE || level === ListLevel.TRACK}
|
2025-02-02 11:17:22 +01:00
|
|
|
<div
|
|
|
|
|
class="absolute {orientation === 'vertical'
|
2026-04-06 18:22:01 +02:00
|
|
|
? 'top-0 bottom-0 right-0 w-1'
|
2025-02-02 11:17:22 +01:00
|
|
|
: 'top-0 h-1 left-0 right-0'}"
|
|
|
|
|
style="background:linear-gradient(to {orientation === 'vertical'
|
|
|
|
|
? 'bottom'
|
|
|
|
|
: 'right'},{nodeColors
|
|
|
|
|
.map(
|
|
|
|
|
(c, i) =>
|
|
|
|
|
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
|
|
|
|
|
)
|
|
|
|
|
.join(',')})"
|
2025-06-08 16:32:41 +02:00
|
|
|
></div>
|
2025-02-02 11:17:22 +01:00
|
|
|
{/if}
|
|
|
|
|
<span
|
2026-04-06 18:22:01 +02:00
|
|
|
class="grow text-left truncate ml-1 flex flex-row items-center {hidden
|
2025-02-02 11:17:22 +01:00
|
|
|
? 'text-muted-foreground'
|
2026-10-04 11:00:46 +02:00
|
|
|
: ''} {/* TODO cut elements: the clipboard works on the previous implementation */ ''}"
|
2025-06-21 21:07:36 +02:00
|
|
|
oncontextmenu={(e) => {
|
2025-10-05 19:34:05 +02:00
|
|
|
if (embedding) {
|
2025-02-02 11:17:22 +01:00
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.ctrlKey) {
|
|
|
|
|
// Add to selection instead of opening context menu
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
2026-10-04 11:00:46 +02:00
|
|
|
engine.selectNode(node, 'toggle');
|
2025-02-02 11:17:22 +01:00
|
|
|
}
|
|
|
|
|
}}
|
2025-06-21 21:07:36 +02:00
|
|
|
onmouseenter={() => {
|
2026-10-04 11:00:46 +02:00
|
|
|
// 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 });
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
// }
|
2025-02-02 11:17:22 +01:00
|
|
|
}}
|
2025-06-21 21:07:36 +02:00
|
|
|
onmouseleave={() => {
|
2026-10-04 11:00:46 +02:00
|
|
|
// TODO waypoint popup
|
|
|
|
|
// if (node.type === 'waypoint') {
|
|
|
|
|
// let layer = gpxLayers.getLayer(node.fileId);
|
|
|
|
|
// if (layer) {
|
|
|
|
|
// waypointPopup?.setItem(null);
|
|
|
|
|
// }
|
|
|
|
|
// }
|
2025-02-02 11:17:22 +01:00
|
|
|
}}
|
|
|
|
|
>
|
2026-10-04 11:00:46 +02:00
|
|
|
{#if level === ListLevel.SEGMENT}
|
2025-10-26 12:12:23 +01:00
|
|
|
<Waypoints size="16" class="mx-1 shrink-0" />
|
2026-10-04 11:00:46 +02:00
|
|
|
{:else if level === ListLevel.WAYPOINT}
|
2025-02-02 11:17:22 +01:00
|
|
|
{#if symbolKey && symbols[symbolKey].icon}
|
2025-06-21 21:07:36 +02:00
|
|
|
{@const SymbolIcon = symbols[symbolKey].icon}
|
2025-10-26 12:12:23 +01:00
|
|
|
<SymbolIcon size="16" class="mx-1 shrink-0" />
|
2025-02-02 11:17:22 +01:00
|
|
|
{:else}
|
2025-10-26 12:12:23 +01:00
|
|
|
<MapPin size="16" class="mx-1 shrink-0" />
|
2025-02-02 11:17:22 +01:00
|
|
|
{/if}
|
|
|
|
|
{/if}
|
|
|
|
|
<span
|
|
|
|
|
class="grow select-none truncate {orientation === 'vertical'
|
|
|
|
|
? 'last:mr-2'
|
|
|
|
|
: ''}"
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</span>
|
|
|
|
|
{#if hidden}
|
|
|
|
|
<EyeOff
|
2025-10-26 12:12:23 +01:00
|
|
|
size="10"
|
|
|
|
|
class="shrink-0 size-3.5 ml-1 {orientation === 'vertical'
|
2025-02-02 11:17:22 +01:00
|
|
|
? 'mr-3'
|
2025-10-26 12:12:23 +01:00
|
|
|
: 'mt-0.5'}"
|
2025-02-02 11:17:22 +01:00
|
|
|
/>
|
|
|
|
|
{/if}
|
|
|
|
|
</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</ContextMenu.Trigger>
|
|
|
|
|
<ContextMenu.Content>
|
2026-10-04 11:00:46 +02:00
|
|
|
{#if node.type === 'file' || node.type === 'track'}
|
2025-06-21 21:07:36 +02:00
|
|
|
<ContextMenu.Item
|
|
|
|
|
disabled={!singleSelection}
|
|
|
|
|
onclick={() => (editMetadata.current = true)}
|
|
|
|
|
>
|
2025-10-26 12:12:23 +01:00
|
|
|
<Info size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.metadata.button')}
|
2025-02-02 11:17:22 +01:00
|
|
|
<Shortcut key="I" ctrl={true} />
|
|
|
|
|
</ContextMenu.Item>
|
2025-06-21 21:07:36 +02:00
|
|
|
<ContextMenu.Item onclick={() => (editStyle.current = true)}>
|
2025-10-26 12:12:23 +01:00
|
|
|
<PaintBucket size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.style.button')}
|
2025-02-02 11:17:22 +01:00
|
|
|
</ContextMenu.Item>
|
|
|
|
|
{/if}
|
2026-10-04 11:00:46 +02:00
|
|
|
<ContextMenu.Item onclick={() => engine.setSelectionHidden(!$allHidden)}>
|
2025-10-19 13:45:05 +02:00
|
|
|
{#if $allHidden}
|
2025-10-26 12:12:23 +01:00
|
|
|
<Eye size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.unhide')}
|
2025-02-02 11:17:22 +01:00
|
|
|
{:else}
|
2025-10-26 12:12:23 +01:00
|
|
|
<EyeOff size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.hide')}
|
2025-10-19 13:45:05 +02:00
|
|
|
{/if}
|
2025-02-02 11:17:22 +01:00
|
|
|
<Shortcut key="H" ctrl={true} />
|
|
|
|
|
</ContextMenu.Item>
|
|
|
|
|
<ContextMenu.Separator />
|
|
|
|
|
{#if orientation === 'vertical'}
|
2026-10-04 11:00:46 +02:00
|
|
|
{#if node.type === 'file'}
|
|
|
|
|
<ContextMenu.Item disabled={!singleSelection} onclick={() => engine.newTrack()}>
|
2025-10-26 12:12:23 +01:00
|
|
|
<Plus size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.new_track')}
|
2025-02-02 11:17:22 +01:00
|
|
|
</ContextMenu.Item>
|
|
|
|
|
<ContextMenu.Separator />
|
2026-10-04 11:00:46 +02:00
|
|
|
{:else if node.type === 'track'}
|
2025-02-02 11:17:22 +01:00
|
|
|
<ContextMenu.Item
|
|
|
|
|
disabled={!singleSelection}
|
2026-10-04 11:00:46 +02:00
|
|
|
onclick={() => engine.newTrackSegment()}
|
2025-02-02 11:17:22 +01:00
|
|
|
>
|
2025-10-26 12:12:23 +01:00
|
|
|
<Plus size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.new_segment')}
|
2025-02-02 11:17:22 +01:00
|
|
|
</ContextMenu.Item>
|
|
|
|
|
<ContextMenu.Separator />
|
|
|
|
|
{/if}
|
|
|
|
|
{/if}
|
2026-10-04 11:00:46 +02:00
|
|
|
{#if level !== ListLevel.WAYPOINTS}
|
2026-10-04 14:49:53 +02:00
|
|
|
<ContextMenu.Item onclick={() => engine.selectAll()}>
|
2025-10-26 12:12:23 +01:00
|
|
|
<FileStack size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.select_all')}
|
2025-02-02 11:17:22 +01:00
|
|
|
<Shortcut key="A" ctrl={true} />
|
|
|
|
|
</ContextMenu.Item>
|
|
|
|
|
{/if}
|
2025-10-19 16:14:05 +02:00
|
|
|
<ContextMenu.Item onclick={() => boundsManager.centerMapOnSelection()}>
|
2025-10-26 12:12:23 +01:00
|
|
|
<Maximize size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.center')}
|
2025-02-02 11:17:22 +01:00
|
|
|
<Shortcut key="⏎" ctrl={true} />
|
|
|
|
|
</ContextMenu.Item>
|
|
|
|
|
<ContextMenu.Separator />
|
2026-10-04 11:00:46 +02:00
|
|
|
<ContextMenu.Item onclick={() => engine.duplicate()}>
|
2025-10-26 12:12:23 +01:00
|
|
|
<Copy size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.duplicate')}
|
2025-10-26 12:12:23 +01:00
|
|
|
<Shortcut key="D" ctrl={true} />
|
|
|
|
|
</ContextMenu.Item>
|
2025-02-02 11:17:22 +01:00
|
|
|
{#if orientation === 'vertical'}
|
2026-10-04 11:00:46 +02:00
|
|
|
<ContextMenu.Item onclick={() => oldSelection.copySelection()}>
|
2025-10-26 12:12:23 +01:00
|
|
|
<ClipboardCopy size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.copy')}
|
2025-02-02 11:17:22 +01:00
|
|
|
<Shortcut key="C" ctrl={true} />
|
|
|
|
|
</ContextMenu.Item>
|
2026-10-04 11:00:46 +02:00
|
|
|
<ContextMenu.Item onclick={() => oldSelection.cutSelection()}>
|
2025-10-26 12:12:23 +01:00
|
|
|
<Scissors size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.cut')}
|
2025-02-02 11:17:22 +01:00
|
|
|
<Shortcut key="X" ctrl={true} />
|
|
|
|
|
</ContextMenu.Item>
|
|
|
|
|
<ContextMenu.Item
|
2025-10-18 09:36:55 +02:00
|
|
|
disabled={$copied === undefined ||
|
|
|
|
|
$copied.length === 0 ||
|
2026-10-04 11:00:46 +02:00
|
|
|
!allowedPastes[$copied[0].level].includes(level)}
|
2025-06-21 21:07:36 +02:00
|
|
|
onclick={pasteSelection}
|
2025-02-02 11:17:22 +01:00
|
|
|
>
|
2025-10-26 12:12:23 +01:00
|
|
|
<ClipboardPaste size="16" />
|
2025-06-21 21:07:36 +02:00
|
|
|
{i18n._('menu.paste')}
|
2025-02-02 11:17:22 +01:00
|
|
|
<Shortcut key="V" ctrl={true} />
|
|
|
|
|
</ContextMenu.Item>
|
|
|
|
|
{/if}
|
|
|
|
|
<ContextMenu.Separator />
|
2026-10-04 11:00:46 +02:00
|
|
|
<ContextMenu.Item onclick={() => engine.delete()}>
|
2025-10-26 12:12:23 +01:00
|
|
|
<Trash2 size="16" />
|
|
|
|
|
{i18n._('menu.delete')}
|
2025-02-02 11:17:22 +01:00
|
|
|
<Shortcut key="⌫" ctrl={true} />
|
|
|
|
|
</ContextMenu.Item>
|
|
|
|
|
</ContextMenu.Content>
|
2024-05-21 17:47:08 +02:00
|
|
|
</ContextMenu.Root>
|