mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-12-02 10:02:12 +00:00
Compare commits
4 Commits
30981130c9
...
722cf58486
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
722cf58486 | ||
|
|
17e5347d55 | ||
|
|
2e828dfde3 | ||
|
|
1b035bcde3 |
11
website/package-lock.json
generated
11
website/package-lock.json
generated
@@ -62,6 +62,7 @@
|
|||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^5.33.18",
|
"svelte": "^5.33.18",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
|
"svelte-dnd-action": "^0.9.65",
|
||||||
"svelte-sonner": "^1.0.5",
|
"svelte-sonner": "^1.0.5",
|
||||||
"tailwind-variants": "^3.1.1",
|
"tailwind-variants": "^3.1.1",
|
||||||
"tailwindcss": "^4.1.8",
|
"tailwindcss": "^4.1.8",
|
||||||
@@ -8242,6 +8243,16 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svelte-dnd-action": {
|
||||||
|
"version": "0.9.65",
|
||||||
|
"resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.65.tgz",
|
||||||
|
"integrity": "sha512-GKFtrAtYAjcm27aMELoXOhkLtKA1AEoj2njjCReCer6jh1hnRtTHdEO4Kjfpayz+ZAvE0MMwIvLISW3tsiO9Qg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": ">=3.23.0 || ^5.0.0-next.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/svelte-eslint-parser": {
|
"node_modules/svelte-eslint-parser": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.2.0.tgz",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^5.33.18",
|
"svelte": "^5.33.18",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
|
"svelte-dnd-action": "^0.9.65",
|
||||||
"svelte-sonner": "^1.0.5",
|
"svelte-sonner": "^1.0.5",
|
||||||
"tailwind-variants": "^3.1.1",
|
"tailwind-variants": "^3.1.1",
|
||||||
"tailwindcss": "^4.1.8",
|
"tailwindcss": "^4.1.8",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
MIT © 2024 gpx.studio
|
MIT © 2025 gpx.studio
|
||||||
</Button>
|
</Button>
|
||||||
<LanguageSelect class="w-40 mt-3" />
|
<LanguageSelect class="w-40 mt-3" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
onMount(() => {
|
||||||
// Sortable updates selection
|
sortable = new SortableFileList(
|
||||||
let changed = getChangedIds();
|
container,
|
||||||
if (changed.length > 0) {
|
node,
|
||||||
selection.update(($selection) => {
|
item,
|
||||||
$selection.clear();
|
waypointRoot,
|
||||||
Object.entries(elements).forEach(([id, element]) => {
|
sortableLevel,
|
||||||
$selection.set(
|
orientation
|
||||||
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(() => {
|
|
||||||
createSortable();
|
|
||||||
destroyed = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterUpdate(() => {
|
|
||||||
elements = {};
|
|
||||||
container.childNodes.forEach((element) => {
|
|
||||||
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}
|
||||||
|
|
||||||
|
|||||||
@@ -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.close')}
|
|
||||||
{:else}
|
|
||||||
<Trash2 size="16" class="mr-1" />
|
|
||||||
{i18n._('menu.delete')}
|
{i18n._('menu.delete')}
|
||||||
{/if}
|
|
||||||
<Shortcut key="⌫" ctrl={true} />
|
<Shortcut key="⌫" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
import { Textarea } from '$lib/components/ui/textarea';
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
import { dbUtils } from '$lib/db';
|
|
||||||
import { Save } from '@lucide/svelte';
|
import { Save } from '@lucide/svelte';
|
||||||
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list';
|
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list';
|
||||||
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 { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
|
import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
node,
|
node,
|
||||||
@@ -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" />
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
dbUtils.applyToFile(item.getFileId(), (file) => {
|
fileActionManager.applyToFile(item.getFileId(), (file) => {
|
||||||
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
||||||
file.metadata.name = name;
|
file.metadata.name = name;
|
||||||
file.metadata.desc = description;
|
file.metadata.desc = description;
|
||||||
|
|||||||
289
website/src/lib/components/file-list/sortable-file-list.ts
Normal file
289
website/src/lib/components/file-list/sortable-file-list.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import { Slider } from '$lib/components/ui/slider';
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
import { dbUtils, getFile, settings } from '$lib/db';
|
|
||||||
import { Save } from '@lucide/svelte';
|
import { Save } from '@lucide/svelte';
|
||||||
import {
|
import {
|
||||||
ListFileItem,
|
ListFileItem,
|
||||||
@@ -12,10 +11,14 @@
|
|||||||
type ListItem,
|
type ListItem,
|
||||||
} from '$lib/components/file-list/file-list';
|
} from '$lib/components/file-list/file-list';
|
||||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
import { selection } from '../Selection';
|
|
||||||
import { gpxLayers } from '$lib/stores';
|
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import type { LineStyleExtension } from 'gpx';
|
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 { untrack } from 'svelte';
|
||||||
|
import { fileActions } from '$lib/logic/file-actions';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
item,
|
item,
|
||||||
@@ -40,8 +43,8 @@
|
|||||||
|
|
||||||
$selection.forEach((item) => {
|
$selection.forEach((item) => {
|
||||||
if (item instanceof ListFileItem) {
|
if (item instanceof ListFileItem) {
|
||||||
let file = getFile(item.getFileId());
|
let file = fileStateCollection.getFile(item.getFileId());
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
if (file && layer) {
|
if (file && layer) {
|
||||||
let style = file.getStyle();
|
let style = file.getStyle();
|
||||||
color = layer.layerColor;
|
color = layer.layerColor;
|
||||||
@@ -53,8 +56,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (item instanceof ListTrackItem) {
|
} else if (item instanceof ListTrackItem) {
|
||||||
let file = getFile(item.getFileId());
|
let file = fileStateCollection.getFile(item.getFileId());
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
if (file && layer) {
|
if (file && layer) {
|
||||||
color = layer.layerColor;
|
color = layer.layerColor;
|
||||||
let track = file.trk[item.getTrackIndex()];
|
let track = file.trk[item.getTrackIndex()];
|
||||||
@@ -81,7 +84,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($selection && open) {
|
if ($selection && open) {
|
||||||
setStyleInputs();
|
untrack(() => setStyleInputs());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,9 +105,9 @@
|
|||||||
if (widthChanged) {
|
if (widthChanged) {
|
||||||
style['gpx_style:width'] = width;
|
style['gpx_style:width'] = width;
|
||||||
}
|
}
|
||||||
dbUtils.setStyleToSelection(style);
|
fileActions.setStyleToSelection(style);
|
||||||
|
|
||||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
if (item instanceof ListFileItem && $selection.size === fileStateCollection.size) {
|
||||||
if (style['gpx_style:opacity']) {
|
if (style['gpx_style:opacity']) {
|
||||||
$defaultOpacity = style['gpx_style:opacity'];
|
$defaultOpacity = style['gpx_style:opacity'];
|
||||||
}
|
}
|
||||||
@@ -118,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')}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export class GPXLayerCollection {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLayer(fileId: string): GPXLayer | undefined {
|
||||||
|
return this._layers.get(fileId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const gpxLayers = new GPXLayerCollection();
|
export const gpxLayers = new GPXLayerCollection();
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Sortable from 'sortablejs/Sortable';
|
|
||||||
import { customBasemapUpdate } from './utils';
|
import { customBasemapUpdate } from './utils';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { dndzone } from 'svelte-dnd-action';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
customLayers,
|
customLayers,
|
||||||
@@ -55,12 +55,6 @@
|
|||||||
|
|
||||||
let selectedLayerId: string | undefined = $state(undefined);
|
let selectedLayerId: string | undefined = $state(undefined);
|
||||||
|
|
||||||
let basemapContainer: HTMLElement;
|
|
||||||
let overlayContainer: HTMLElement;
|
|
||||||
|
|
||||||
let basemapSortable: Sortable;
|
|
||||||
let overlaySortable: Sortable;
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if ($customBasemapOrder.length === 0) {
|
if ($customBasemapOrder.length === 0) {
|
||||||
$customBasemapOrder = Object.keys($customLayers).filter(
|
$customBasemapOrder = Object.keys($customLayers).filter(
|
||||||
@@ -72,34 +66,26 @@
|
|||||||
(id) => $customLayers[id].layerType === 'overlay'
|
(id) => $customLayers[id].layerType === 'overlay'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
basemapSortable = Sortable.create(basemapContainer, {
|
|
||||||
onSort: (e) => {
|
|
||||||
$customBasemapOrder = basemapSortable.toArray();
|
|
||||||
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
|
|
||||||
acc[id] = true;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
overlaySortable = Sortable.create(overlayContainer, {
|
|
||||||
onSort: (e) => {
|
|
||||||
$customOverlayOrder = overlaySortable.toArray();
|
|
||||||
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
|
|
||||||
acc[id] = true;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
basemapSortable.sort($customBasemapOrder);
|
let customBasemapItems: {
|
||||||
overlaySortable.sort($customOverlayOrder);
|
id: string;
|
||||||
});
|
name: string;
|
||||||
|
}[] = $derived(
|
||||||
onDestroy(() => {
|
$customBasemapOrder.map((id) => ({
|
||||||
basemapSortable.destroy();
|
id: id,
|
||||||
overlaySortable.destroy();
|
name: $customLayers[id].name,
|
||||||
});
|
}))
|
||||||
|
);
|
||||||
|
let customOverlayItems: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[] = $derived(
|
||||||
|
$customOverlayOrder.map((id) => ({
|
||||||
|
id: id,
|
||||||
|
name: $customLayers[id].name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
setDataFromSelectedLayer(selectedLayerId);
|
setDataFromSelectedLayer(selectedLayerId);
|
||||||
@@ -306,17 +292,37 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
bind:this={basemapContainer}
|
|
||||||
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
|
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
|
||||||
|
use:dndzone={{
|
||||||
|
items: customBasemapItems,
|
||||||
|
type: 'basemap',
|
||||||
|
dropTargetStyle: {},
|
||||||
|
transformDraggedElement: (element) => {
|
||||||
|
if (element) {
|
||||||
|
element.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onconsider={(e) => {
|
||||||
|
customBasemapItems = e.detail.items;
|
||||||
|
}}
|
||||||
|
onfinalize={(e) => {
|
||||||
|
customBasemapItems = e.detail.items;
|
||||||
|
$customBasemapOrder = customBasemapItems.map((item) => item.id);
|
||||||
|
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce((acc, item) => {
|
||||||
|
acc[item.id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#each $customBasemapOrder as id (id)}
|
{#each customBasemapItems as item (item.id)}
|
||||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Move size="12" />
|
<Move size="12" />
|
||||||
<span class="grow">{$customLayers[id].name}</span>
|
<span class="grow">{item.name}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onclick={() => (selectedLayerId = id)}
|
onclick={() => (selectedLayerId = item.id)}
|
||||||
class="p-1 h-7"
|
class="p-1 h-7"
|
||||||
>
|
>
|
||||||
<Pencil size="16" />
|
<Pencil size="16" />
|
||||||
@@ -324,7 +330,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onclick={() => deleteLayer(id)}
|
onclick={() => deleteLayer(item.id)}
|
||||||
class="p-1 h-7"
|
class="p-1 h-7"
|
||||||
>
|
>
|
||||||
<Trash2 size="16" />
|
<Trash2 size="16" />
|
||||||
@@ -342,17 +348,37 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
bind:this={overlayContainer}
|
|
||||||
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
|
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
|
||||||
|
use:dndzone={{
|
||||||
|
items: customOverlayItems,
|
||||||
|
type: 'overlay',
|
||||||
|
dropTargetStyle: {},
|
||||||
|
transformDraggedElement: (element) => {
|
||||||
|
if (element) {
|
||||||
|
element.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onconsider={(e) => {
|
||||||
|
customOverlayItems = e.detail.items;
|
||||||
|
}}
|
||||||
|
onfinalize={(e) => {
|
||||||
|
customOverlayItems = e.detail.items;
|
||||||
|
$customOverlayOrder = customOverlayItems.map((item) => item.id);
|
||||||
|
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce((acc, item) => {
|
||||||
|
acc[item.id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#each $customOverlayOrder as id (id)}
|
{#each customOverlayItems as item (item.id)}
|
||||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Move size="12" />
|
<Move size="12" />
|
||||||
<span class="grow">{$customLayers[id].name}</span>
|
<span class="grow">{item.name}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onclick={() => (selectedLayerId = id)}
|
onclick={() => (selectedLayerId = item.id)}
|
||||||
class="p-1 h-7"
|
class="p-1 h-7"
|
||||||
>
|
>
|
||||||
<Pencil size="16" />
|
<Pencil size="16" />
|
||||||
@@ -360,7 +386,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onclick={() => deleteLayer(id)}
|
onclick={() => deleteLayer(item.id)}
|
||||||
class="p-1 h-7"
|
class="p-1 h-7"
|
||||||
>
|
>
|
||||||
<Trash2 size="16" />
|
<Trash2 size="16" />
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export class FileActionManager {
|
|||||||
.filter((file) => file !== undefined) as GPXFile[];
|
.filter((file) => file !== undefined) as GPXFile[];
|
||||||
updatedFileIds = updatedFiles.map((file) => file._data.id);
|
updatedFileIds = updatedFiles.map((file) => file._data.id);
|
||||||
|
|
||||||
selection.update(updatedFiles, deletedFileIds);
|
selection.updateFiles(updatedFiles, deletedFileIds);
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return db.transaction('rw', db.fileids, db.files, async () => {
|
return db.transaction('rw', db.fileids, db.files, async () => {
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export class Selection {
|
|||||||
return this._selection.subscribe(run, invalidate);
|
return this._selection.subscribe(run, invalidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update(updater: (value: SelectionTreeType) => SelectionTreeType) {
|
||||||
|
this._selection.update(updater);
|
||||||
|
}
|
||||||
|
|
||||||
selectItem(item: ListItem) {
|
selectItem(item: ListItem) {
|
||||||
this._selection.update(($selection) => {
|
this._selection.update(($selection) => {
|
||||||
$selection.clear();
|
$selection.clear();
|
||||||
@@ -119,7 +123,7 @@ export class Selection {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
update(updatedFiles: GPXFile[], deletedFileIds: string[]) {
|
updateFiles(updatedFiles: GPXFile[], deletedFileIds: string[]) {
|
||||||
let removedItems: ListItem[] = [];
|
let removedItems: ListItem[] = [];
|
||||||
applyToOrderedItemsFromFile(get(this._selection).getSelected(), (fileId, level, items) => {
|
applyToOrderedItemsFromFile(get(this._selection).getSelected(), (fileId, level, items) => {
|
||||||
let file = updatedFiles.find((file) => file._data.id === fileId);
|
let file = updatedFiles.find((file) => file._data.id === fileId);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user