copy paste file items

This commit is contained in:
vcoppe
2024-06-20 15:18:21 +02:00
parent c062908000
commit cc92ccc193
10 changed files with 289 additions and 44 deletions

View File

@@ -25,7 +25,7 @@
<Toaster richColors />
{#if !$verticalFileView}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<FileList orientation="horizontal" class="pointer-events-auto" />
<FileList orientation="horizontal" />
</div>
{/if}
</div>

View File

@@ -45,7 +45,13 @@
toggleSelectionVisibility,
updateSelectionFromKey
} from '$lib/stores';
import { selectAll, selection } from '$lib/components/file-list/Selection';
import {
copySelection,
cutSelection,
pasteSelection,
selectAll,
selection
} from '$lib/components/file-list/Selection';
import { derived } from 'svelte/store';
import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db';
import { anySelectedLayer } from '$lib/components/layer-control/utils';
@@ -368,6 +374,15 @@
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
dbUtils.duplicateSelection();
e.preventDefault();
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
copySelection();
e.preventDefault();
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
cutSelection();
e.preventDefault();
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
pasteSelection();
e.preventDefault();
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
exportAllFiles();

View File

@@ -1,11 +1,15 @@
<script lang="ts">
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import * as ContextMenu from '$lib/components/ui/context-menu';
import FileListNode from './FileListNode.svelte';
import { fileObservers, settings } from '$lib/db';
import { setContext } from 'svelte';
import { ListFileItem, ListRootItem } from './FileList';
import { selection } from './Selection';
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
import { copied, pasteSelection, selection } from './Selection';
import { ClipboardPaste, Plus } from 'lucide-svelte';
import Shortcut from '$lib/components/Shortcut.svelte';
import { _ } from 'svelte-i18n';
import { createFile } from '$lib/stores';
export let orientation: 'vertical' | 'horizontal';
export let recursive = false;
@@ -40,12 +44,39 @@
</script>
<ScrollArea
class="shrink-0 {orientation === 'vertical' ? 'p-1 pr-3' : 'h-10 px-1'}"
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
{orientation}
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
>
<div class="flex {orientation === 'vertical' ? 'flex-col' : 'flex-row'} {$$props.class ?? ''}">
<div
class="flex {orientation === 'vertical'
? 'flex-col py-1 pl-1 min-h-screen'
: 'flex-row'} {$$props.class ?? ''}"
>
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
{#if orientation === 'vertical'}
<ContextMenu.Root>
<ContextMenu.Trigger class="grow" />
<ContextMenu.Content>
<ContextMenu.Item on:click={createFile}>
<Plus size="16" class="mr-1" />
{$_('menu.new_file')}
<Shortcut key="+" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
on:click={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
{/if}
</div>
</ScrollArea>

View File

@@ -1,6 +1,6 @@
import { dbUtils } from "$lib/db";
import { dbUtils, getFile, getFileIds } from "$lib/db";
import { castDraft, freeze } from "immer";
import { Track, TrackSegment, Waypoint } from "gpx";
import { GPXFile, Track, TrackSegment, Waypoint } from "gpx";
import { selection } from "./Selection";
import { newGPXFile } from "$lib/stores";
@@ -13,6 +13,24 @@ export enum ListLevel {
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 {
level: ListLevel;
@@ -24,6 +42,7 @@ export abstract class ListItem {
abstract getFullId(): string;
abstract getIdAtLevel(level: ListLevel): string | number | undefined;
abstract getFileId(): string;
abstract getParent(): ListItem;
abstract extend(id: string | number): ListItem;
}
@@ -48,6 +67,10 @@ export class ListRootItem extends ListItem {
return '';
}
getParent(): ListItem {
return this;
}
extend(id: string): ListFileItem {
return new ListFileItem(id);
}
@@ -82,6 +105,10 @@ export class ListFileItem extends ListItem {
return this.fileId;
}
getParent(): ListItem {
return new ListRootItem();
}
extend(id: number | 'waypoints'): ListTrackItem | ListWaypointsItem {
if (id === 'waypoints') {
return new ListWaypointsItem(this.fileId);
@@ -128,6 +155,10 @@ export class ListTrackItem extends ListItem {
return this.trackIndex;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListTrackSegmentItem {
return new ListTrackSegmentItem(this.fileId, this.trackIndex, id);
}
@@ -178,6 +209,10 @@ export class ListTrackSegmentItem extends ListItem {
return this.segmentIndex;
}
getParent(): ListItem {
return new ListTrackItem(this.fileId, this.trackIndex);
}
extend(): ListTrackSegmentItem {
return this;
}
@@ -214,6 +249,10 @@ export class ListWaypointsItem extends ListItem {
return this.fileId;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListWaypointItem {
return new ListWaypointItem(this.fileId, id);
}
@@ -258,6 +297,10 @@ export class ListWaypointItem extends ListItem {
return this.waypointIndex;
}
getParent(): ListItem {
return new ListWaypointsItem(this.fileId);
}
extend(): ListWaypointItem {
return this;
}
@@ -279,12 +322,37 @@ export function sortItems(items: ListItem[], reverse: boolean = false) {
}
}
export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: ListItem[], toItems: ListItem[]) {
sortItems(fromItems, true);
export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: ListItem[], toItems: ListItem[], remove: boolean = true) {
if (fromItems.length === 0) {
return;
}
sortItems(fromItems, remove && !(fromParent instanceof ListRootItem));
sortItems(toItems, false);
dbUtils.applyEachToFilesAndGlobal([fromParent.getFileId(), toParent.getFileId()], [
(file, context: (Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
let context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[] = [];
if (!remove || fromParent instanceof ListRootItem) {
fromItems.forEach((item) => {
let file = getFile(item.getFileId());
if (file) {
if (item instanceof ListFileItem) {
context.push(file.clone());
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
context.push(file.trk[item.getTrackIndex()].clone());
} else if (item instanceof ListTrackSegmentItem && item.getTrackIndex() < file.trk.length && item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length) {
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
} else if (item instanceof ListWaypointsItem) {
context.push(file.wpt.map((wpt) => wpt.clone()));
} else if (item instanceof ListWaypointItem && item.getWaypointIndex() < file.wpt.length) {
context.push(file.wpt[item.getWaypointIndex()].clone());
}
}
});
}
let files = [fromParent.getFileId(), toParent.getFileId()];
let callbacks = [
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
let newFile = file;
fromItems.forEach((item) => {
if (item instanceof ListTrackItem) {
@@ -308,7 +376,7 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
context.reverse();
return newFile;
},
(file, context: (Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
let newFile = file;
toItems.forEach((item, i) => {
if (item instanceof ListTrackItem) {
@@ -339,10 +407,27 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
});
return newFile;
}
], (files, context: (Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
];
if (fromParent instanceof ListRootItem) {
files = [];
callbacks = [];
} else if (!remove) {
files.splice(0, 1);
callbacks.splice(0, 1);
}
dbUtils.applyEachToFilesAndGlobal(files, callbacks, (files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
toItems.forEach((item, i) => {
if (item instanceof ListFileItem) {
if (context[i] instanceof Track) {
if (context[i] instanceof GPXFile) {
let newFile = context[i];
if (remove) {
files.delete(newFile._data.id);
}
newFile._data.id = item.getFileId();
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof Track) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
if (context[i].name) {
@@ -360,7 +445,7 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
}
}
});
}, []);
}, context);
selection.update(($selection) => {
$selection.clear();

View File

@@ -1,13 +1,4 @@
<script lang="ts" context="module">
let pull: 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]
};
let dragging: Writable<ListLevel | null> = writable(null);
let updating = false;
@@ -21,7 +12,7 @@
import { get, writable, type Readable, type Writable } from 'svelte/store';
import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte';
import { ListLevel, ListRootItem, moveItems, type ListItem } from './FileList';
import { ListLevel, ListRootItem, allowedMoves, moveItems, type ListItem } from './FileList';
import { selection } from './Selection';
import { _ } from 'svelte-i18n';
@@ -132,7 +123,7 @@
sortable = Sortable.create(container, {
group: {
name: sortableLevel,
pull: pull[sortableLevel],
pull: allowedMoves[sortableLevel],
put: true
},
direction: orientation,
@@ -261,7 +252,7 @@
: parseInt(id);
}
$: canDrop = $dragging !== null && pull[$dragging].includes(sortableLevel);
$: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel);
</script>
<div

View File

@@ -18,16 +18,27 @@
Trash2,
Waypoints,
Eye,
EyeOff
EyeOff,
ClipboardCopy,
ClipboardPaste,
Scissors
} from 'lucide-svelte';
import {
ListFileItem,
ListLevel,
ListTrackItem,
ListWaypointItem,
allowedPastes,
type ListItem
} from './FileList';
import { selectItem, selection } from './Selection';
import {
copied,
copySelection,
cutSelection,
pasteSelection,
selectItem,
selection
} from './Selection';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import { gpxLayers, map, toggleSelectionVisibility } from '$lib/stores';
@@ -198,7 +209,7 @@
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
'vertical'
? 'h-fit'
: 'h-9 px-1.5 shadow-md'}"
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<Popover.Root bind:open={openEditMetadata}>
@@ -416,12 +427,36 @@
<ContextMenu.Separator />
{/if}
{/if}
{#if item.level !== ListLevel.WAYPOINTS}
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
<Copy size="16" class="mr-1" />
{$_('menu.duplicate')}
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
>
{#if $verticalFileView || item.level !== ListLevel.WAYPOINTS}
{#if item.level !== ListLevel.WAYPOINTS}
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
<Copy size="16" class="mr-1" />
{$_('menu.duplicate')}
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
>
{/if}
{#if $verticalFileView}
<ContextMenu.Item on:click={copySelection}>
<ClipboardCopy size="16" class="mr-1" />
{$_('menu.copy')}
<Shortcut key="C" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item on:click={cutSelection}>
<Scissors size="16" class="mr-1" />
{$_('menu.cut')}
<Shortcut key="X" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Item
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(item.level)}
on:click={pasteSelection}
>
<ClipboardPaste size="16" class="mr-1" />
{$_('menu.paste')}
<Shortcut key="V" ctrl={true} />
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
{/if}
<ContextMenu.Item on:click={dbUtils.deleteSelection}>

View File

@@ -1,6 +1,6 @@
import { get, writable } from "svelte/store";
import { ListFileItem, ListItem, ListRootItem, ListTrackItem, ListTrackSegmentItem, ListWaypointItem, type ListLevel, sortItems, ListWaypointsItem } from "./FileList";
import { fileObservers, getFile, settings } from "$lib/db";
import { ListFileItem, ListItem, ListRootItem, ListTrackItem, ListTrackSegmentItem, ListWaypointItem, ListLevel, sortItems, ListWaypointsItem, moveItems } from "./FileList";
import { fileObservers, getFile, getFileIds, settings } from "$lib/db";
export class SelectionTreeType {
item: ListItem;
@@ -222,4 +222,89 @@ export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback:
export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
}
export const copied = writable<ListItem[] | undefined>(undefined);
const cut = writable(false);
export function copySelection(): boolean {
let selected = get(selection).getSelected();
if (selected.length > 0) {
copied.set(selected);
cut.set(false);
return true;
}
return false;
}
export function cutSelection() {
if (copySelection()) {
cut.set(true);
}
}
function resetCopied() {
copied.set(undefined);
cut.set(false);
}
export function pasteSelection() {
let fromItems = get(copied);
if (fromItems === undefined || fromItems.length === 0) {
return;
}
let selected = get(selection).getSelected();
if (selected.length === 0) {
selected = [new ListRootItem()];
}
let fromParent = fromItems[0].getParent();
let toParent = selected[selected.length - 1];
let startIndex: number | undefined = undefined;
if (fromItems[0].level === toParent.level) {
if (toParent instanceof ListTrackItem || toParent instanceof ListTrackSegmentItem || toParent instanceof ListWaypointItem) {
startIndex = toParent.getId() + 1;
}
toParent = toParent.getParent();
}
let toItems: ListItem[] = [];
if (toParent.level === ListLevel.ROOT) {
let fileIds = getFileIds(fromItems.length);
fileIds.forEach((fileId) => {
toItems.push(new ListFileItem(fileId));
});
} else {
let toFile = getFile(toParent.getFileId());
if (toFile) {
fromItems.forEach((item, index) => {
if (toParent instanceof ListFileItem) {
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
toItems.push(new ListTrackItem(toParent.getFileId(), (startIndex ?? toFile.trk.length) + index));
} else if (item instanceof ListWaypointsItem) {
toItems.push(new ListWaypointsItem(toParent.getFileId()));
} else if (item instanceof ListWaypointItem) {
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
}
} else if (toParent instanceof ListTrackItem) {
if (item instanceof ListTrackSegmentItem) {
let toTrackIndex = toParent.getTrackIndex();
toItems.push(new ListTrackSegmentItem(toParent.getFileId(), toTrackIndex, (startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index));
}
} else if (toParent instanceof ListWaypointsItem) {
if (item instanceof ListWaypointItem) {
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
}
}
});
}
}
if (fromItems.length === toItems.length) {
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
resetCopied();
}
}

View File

@@ -102,7 +102,7 @@ export const settings = {
distanceMarkers: dexieSettingStore('distanceMarkers', false),
stravaHeatmapColor: dexieSettingStore('stravaHeatmapColor', 'bluered'),
fileOrder: dexieSettingStore<string[]>('fileOrder', []),
defaultOpacity: dexieSettingStore('defaultOpacity', 0.6),
defaultOpacity: dexieSettingStore('defaultOpacity', 0.7),
defaultWeight: dexieSettingStore('defaultWeight', 5),
};

View File

@@ -134,14 +134,14 @@ export const currentTool = writable<Tool | null>(null);
export const splitAs = writable(SplitType.FILES);
export function newGPXFile() {
const newFileName = get(_)("menu.new_filename");
const newFileName = get(_)("menu.new_file");
let file = new GPXFile();
let maxNewFileNumber = 0;
get(fileObservers).forEach((f) => {
let file = get(f)?.file;
if (file && file.metadata.name.startsWith(newFileName)) {
if (file && file.metadata.name && file.metadata.name.startsWith(newFileName)) {
let number = parseInt(file.metadata.name.split(' ').pop() ?? '0');
if (!isNaN(number) && number > maxNewFileNumber) {
maxNewFileNumber = number;

View File

@@ -1,12 +1,15 @@
{
"menu": {
"new": "New",
"new_filename": "New file",
"new_file": "New file",
"new_track": "New track",
"new_segment": "New segment",
"load_desktop": "Load...",
"load_drive": "Load from Google Drive...",
"duplicate": "Duplicate",
"copy": "Copy",
"paste": "Paste",
"cut": "Cut",
"export": "Export...",
"export_all": "Export all...",
"edit": "Edit",