fix ctrl+click on tab, relates to #91

This commit is contained in:
vcoppe
2024-09-12 11:13:55 +02:00
parent dc76c71ae2
commit 3cc4d569f1
3 changed files with 350 additions and 331 deletions

View File

@@ -1,26 +1,27 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { isMac, isSafari } from '$lib/utils';
import { _ } from 'svelte-i18n'; import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
export let key: string; export let key: string;
export let shift: boolean = false; export let shift: boolean = false;
export let ctrl: boolean = false; export let ctrl: boolean = false;
export let click: boolean = false; export let click: boolean = false;
let isMac = false; let mac = false;
let isSafari = false; let safari = false;
onMount(() => { onMount(() => {
isMac = navigator.userAgent.toUpperCase().indexOf('MAC') >= 0; mac = isMac();
isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); safari = isSafari();
}); });
</script> </script>
<div <div
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline" class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
> >
<span>{shift ? '⇧' : ''}</span> <span>{shift ? '⇧' : ''}</span>
<span>{ctrl ? (isMac && !isSafari ? '⌘' : $_('menu.ctrl') + '+') : ''}</span> <span>{ctrl ? (mac && !safari ? '⌘' : $_('menu.ctrl') + '+') : ''}</span>
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span> <span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
<span>{click ? $_('menu.click') : ''}</span> <span>{click ? $_('menu.click') : ''}</span>
</div> </div>

View File

@@ -1,364 +1,374 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
let dragging: Writable<ListLevel | null> = writable(null); let dragging: Writable<ListLevel | null> = writable(null);
let updating = false; let updating = false;
</script> </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 { afterUpdate, getContext, onDestroy, onMount } from 'svelte'; import { afterUpdate, getContext, onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable'; import Sortable from 'sortablejs/Sortable';
import { getFileIds, settings, type GPXFileWithStatistics } from '$lib/db'; import { getFileIds, settings, type GPXFileWithStatistics } from '$lib/db';
import { get, writable, type Readable, type Writable } 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 {
ListFileItem, ListFileItem,
ListLevel, ListLevel,
ListRootItem, ListRootItem,
ListWaypointsItem, ListWaypointsItem,
allowedMoves, allowedMoves,
moveItems, moveItems,
type ListItem type ListItem
} from './FileList'; } from './FileList';
import { selection } from './Selection'; import { selection } from './Selection';
import { _ } from 'svelte-i18n'; import { isMac } from '$lib/utils';
import { _ } from 'svelte-i18n';
export let node: export let node:
| Map<string, Readable<GPXFileWithStatistics | undefined>> | Map<string, Readable<GPXFileWithStatistics | undefined>>
| GPXTreeElement<AnyGPXTreeElement> | GPXTreeElement<AnyGPXTreeElement>
| Waypoint; | Waypoint;
export let item: ListItem; export let item: ListItem;
export let waypointRoot: boolean = false; export let waypointRoot: boolean = false;
let container: HTMLElement; let container: HTMLElement;
let elements: { [id: string]: HTMLElement } = {}; let elements: { [id: string]: HTMLElement } = {};
let sortableLevel: ListLevel = let sortableLevel: ListLevel =
node instanceof Map node instanceof Map
? ListLevel.FILE ? ListLevel.FILE
: node instanceof GPXFile : node instanceof GPXFile
? waypointRoot ? waypointRoot
? ListLevel.WAYPOINTS ? ListLevel.WAYPOINTS
: item instanceof ListWaypointsItem : item instanceof ListWaypointsItem
? ListLevel.WAYPOINT ? ListLevel.WAYPOINT
: ListLevel.TRACK : ListLevel.TRACK
: node instanceof Track : node instanceof Track
? ListLevel.SEGMENT ? ListLevel.SEGMENT
: ListLevel.WAYPOINT; : ListLevel.WAYPOINT;
let sortable: Sortable; let sortable: Sortable;
let orientation = getContext<'vertical' | 'horizontal'>('orientation'); let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let destroyed = false; let destroyed = false;
let lastUpdateStart = 0; let lastUpdateStart = 0;
function updateToSelection(e) { function updateToSelection(e) {
if (destroyed) { if (destroyed) {
return; return;
} }
lastUpdateStart = Date.now(); lastUpdateStart = Date.now();
setTimeout(() => { setTimeout(() => {
if (Date.now() - lastUpdateStart >= 40) { if (Date.now() - lastUpdateStart >= 40) {
if (updating) { if (updating) {
return; return;
} }
updating = true; updating = true;
// Sortable updates selection // Sortable updates selection
let changed = getChangedIds(); let changed = getChangedIds();
if (changed.length > 0) { if (changed.length > 0) {
selection.update(($selection) => { selection.update(($selection) => {
$selection.clear(); $selection.clear();
Object.entries(elements).forEach(([id, element]) => { Object.entries(elements).forEach(([id, element]) => {
$selection.set( $selection.set(
item.extend(getRealId(id)), item.extend(getRealId(id)),
element.classList.contains('sortable-selected') element.classList.contains('sortable-selected')
); );
}); });
if ( if (
e.originalEvent && e.originalEvent &&
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) && !(
($selection.size > 1 || !$selection.has(item.extend(getRealId(changed[0])))) e.originalEvent.ctrlKey ||
) { e.originalEvent.metaKey ||
// Fix bug that sometimes causes a single select to be treated as a multi-select e.originalEvent.shiftKey
$selection.clear(); ) &&
$selection.set(item.extend(getRealId(changed[0])), true); ($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; return $selection;
}); });
} }
updating = false; updating = false;
} }
}, 50); }, 50);
} }
function updateFromSelection() { function updateFromSelection() {
if (destroyed || updating) { if (destroyed || updating) {
return; return;
} }
updating = true; updating = true;
// Selection updates sortable // Selection updates sortable
let changed = getChangedIds(); let changed = getChangedIds();
for (let id of changed) { for (let id of changed) {
let element = elements[id]; let element = elements[id];
if (element) { if (element) {
if ($selection.has(item.extend(id))) { if ($selection.has(item.extend(id))) {
Sortable.utils.select(element); Sortable.utils.select(element);
element.scrollIntoView({ element.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: 'nearest' block: 'nearest'
}); });
} else { } else {
Sortable.utils.deselect(element); Sortable.utils.deselect(element);
} }
} }
} }
updating = false; updating = false;
} }
$: if ($selection) { $: if ($selection) {
updateFromSelection(); updateFromSelection();
} }
const { fileOrder } = settings; const { fileOrder } = settings;
function syncFileOrder() { function syncFileOrder() {
if (!sortable || sortableLevel !== ListLevel.FILE) { if (!sortable || sortableLevel !== ListLevel.FILE) {
return; return;
} }
const currentOrder = sortable.toArray(); const currentOrder = sortable.toArray();
if (currentOrder.length !== $fileOrder.length) { if (currentOrder.length !== $fileOrder.length) {
sortable.sort($fileOrder); sortable.sort($fileOrder);
} else { } else {
for (let i = 0; i < currentOrder.length; i++) { for (let i = 0; i < currentOrder.length; i++) {
if (currentOrder[i] !== $fileOrder[i]) { if (currentOrder[i] !== $fileOrder[i]) {
sortable.sort($fileOrder); sortable.sort($fileOrder);
break; break;
} }
} }
} }
} }
$: if ($fileOrder) { $: if ($fileOrder) {
syncFileOrder(); syncFileOrder();
} }
function createSortable() { function createSortable() {
sortable = Sortable.create(container, { sortable = Sortable.create(container, {
group: { group: {
name: sortableLevel, name: sortableLevel,
pull: allowedMoves[sortableLevel], pull: allowedMoves[sortableLevel],
put: true put: true
}, },
direction: orientation, direction: orientation,
forceAutoScrollFallback: true, forceAutoScrollFallback: true,
multiDrag: true, multiDrag: true,
multiDragKey: 'Meta', multiDragKey: isMac() ? 'Meta' : 'Ctrl',
avoidImplicitDeselect: true, avoidImplicitDeselect: true,
onSelect: updateToSelection, onSelect: updateToSelection,
onDeselect: updateToSelection, onDeselect: updateToSelection,
onStart: () => { onStart: () => {
dragging.set(sortableLevel); dragging.set(sortableLevel);
}, },
onEnd: () => { onEnd: () => {
dragging.set(null); dragging.set(null);
}, },
onSort: (e) => { onSort: (e) => {
if (sortableLevel === ListLevel.FILE) { if (sortableLevel === ListLevel.FILE) {
let newFileOrder = sortable.toArray(); let newFileOrder = sortable.toArray();
if (newFileOrder.length !== get(fileOrder).length) { if (newFileOrder.length !== get(fileOrder).length) {
fileOrder.set(newFileOrder); fileOrder.set(newFileOrder);
} else { } else {
for (let i = 0; i < newFileOrder.length; i++) { for (let i = 0; i < newFileOrder.length; i++) {
if (newFileOrder[i] !== get(fileOrder)[i]) { if (newFileOrder[i] !== get(fileOrder)[i]) {
fileOrder.set(newFileOrder); fileOrder.set(newFileOrder);
break; break;
} }
} }
} }
} }
let fromItem = Sortable.get(e.from)._item; let fromItem = Sortable.get(e.from)._item;
let toItem = Sortable.get(e.to)._item; let toItem = Sortable.get(e.to)._item;
if (item === toItem && !(fromItem instanceof ListRootItem)) { if (item === toItem && !(fromItem instanceof ListRootItem)) {
// Event is triggered on source and destination list, only handle it once // Event is triggered on source and destination list, only handle it once
let fromItems = []; let fromItems = [];
let toItems = []; let toItems = [];
if (Sortable.get(e.from)._waypointRoot) { if (Sortable.get(e.from)._waypointRoot) {
fromItems = [fromItem.extend('waypoints')]; fromItems = [fromItem.extend('waypoints')];
} else { } else {
let oldIndices: number[] = let oldIndices: number[] =
e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex]; e.oldIndicies.length > 0
oldIndices = oldIndices.filter((i) => i >= 0); ? e.oldIndicies.map((i) => i.index)
oldIndices.sort((a, b) => a - b); : [e.oldIndex];
oldIndices = oldIndices.filter((i) => i >= 0);
oldIndices.sort((a, b) => a - b);
fromItems = oldIndices.map((i) => fromItem.extend(i)); fromItems = oldIndices.map((i) => fromItem.extend(i));
} }
if (Sortable.get(e.from)._waypointRoot && Sortable.get(e.to)._waypointRoot) { if (Sortable.get(e.from)._waypointRoot && Sortable.get(e.to)._waypointRoot) {
toItems = [toItem.extend('waypoints')]; toItems = [toItem.extend('waypoints')];
} else { } else {
if (Sortable.get(e.to)._waypointRoot) { if (Sortable.get(e.to)._waypointRoot) {
toItem = toItem.extend('waypoints'); toItem = toItem.extend('waypoints');
} }
let newIndices: number[] = let newIndices: number[] =
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex]; e.newIndicies.length > 0
newIndices = newIndices.filter((i) => i >= 0); ? e.newIndicies.map((i) => i.index)
newIndices.sort((a, b) => a - b); : [e.newIndex];
newIndices = newIndices.filter((i) => i >= 0);
newIndices.sort((a, b) => a - b);
if (toItem instanceof ListRootItem) { if (toItem instanceof ListRootItem) {
let newFileIds = getFileIds(newIndices.length); let newFileIds = getFileIds(newIndices.length);
toItems = newIndices.map((i, index) => { toItems = newIndices.map((i, index) => {
$fileOrder.splice(i, 0, newFileIds[index]); $fileOrder.splice(i, 0, newFileIds[index]);
return item.extend(newFileIds[index]); return item.extend(newFileIds[index]);
}); });
} else { } else {
toItems = newIndices.map((i) => toItem.extend(i)); toItems = newIndices.map((i) => toItem.extend(i));
} }
} }
moveItems(fromItem, toItem, fromItems, toItems); moveItems(fromItem, toItem, fromItems, toItems);
} }
} }
}); });
Object.defineProperty(sortable, '_item', { Object.defineProperty(sortable, '_item', {
value: item, value: item,
writable: true writable: true
}); });
Object.defineProperty(sortable, '_waypointRoot', { Object.defineProperty(sortable, '_waypointRoot', {
value: waypointRoot, value: waypointRoot,
writable: true writable: true
}); });
} }
onMount(() => { onMount(() => {
createSortable(); createSortable();
destroyed = false; destroyed = false;
}); });
afterUpdate(() => { afterUpdate(() => {
elements = {}; elements = {};
container.childNodes.forEach((element) => { container.childNodes.forEach((element) => {
if (element instanceof HTMLElement) { if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id'); let attr = element.getAttribute('data-id');
if (attr) { if (attr) {
if (node instanceof Map && !node.has(attr)) { if (node instanceof Map && !node.has(attr)) {
element.remove(); element.remove();
} else { } else {
elements[attr] = element; elements[attr] = element;
} }
} }
} }
}); });
syncFileOrder(); syncFileOrder();
updateFromSelection(); updateFromSelection();
}); });
onDestroy(() => { onDestroy(() => {
destroyed = true; destroyed = true;
}); });
function getChangedIds() { function getChangedIds() {
let changed: (string | number)[] = []; let changed: (string | number)[] = [];
Object.entries(elements).forEach(([id, element]) => { Object.entries(elements).forEach(([id, element]) => {
let realId = getRealId(id); let realId = getRealId(id);
let realItem = item.extend(realId); let realItem = item.extend(realId);
let inSelection = get(selection).has(realItem); let inSelection = get(selection).has(realItem);
let isSelected = element.classList.contains('sortable-selected'); let isSelected = element.classList.contains('sortable-selected');
if (inSelection !== isSelected) { if (inSelection !== isSelected) {
changed.push(realId); changed.push(realId);
} }
}); });
return changed; return changed;
} }
function getRealId(id: string | number) { function getRealId(id: string | number) {
return sortableLevel === ListLevel.FILE || sortableLevel === ListLevel.WAYPOINTS return sortableLevel === ListLevel.FILE || sortableLevel === ListLevel.WAYPOINTS
? id ? id
: parseInt(id); : parseInt(id);
} }
$: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel); $: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel);
</script> </script>
<div <div
bind:this={container} bind:this={container}
class="sortable {orientation} flex {orientation === 'vertical' class="sortable {orientation} flex {orientation === 'vertical'
? 'flex-col' ? 'flex-col'
: 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}" : 'flex-row gap-1'} {canDrop ? 'min-h-5' : ''}"
> >
{#if node instanceof Map} {#if node instanceof Map}
{#each node as [fileId, file] (fileId)} {#each node as [fileId, file] (fileId)}
<div data-id={fileId}> <div data-id={fileId}>
<FileListNodeStore {file} /> <FileListNodeStore {file} />
</div> </div>
{/each} {/each}
{:else if node instanceof GPXFile} {:else if node instanceof GPXFile}
{#if item instanceof ListWaypointsItem} {#if item instanceof ListWaypointsItem}
{#each node.wpt as wpt, i (wpt)} {#each node.wpt as wpt, i (wpt)}
<div data-id={i} class="ml-1"> <div data-id={i} class="ml-1">
<FileListNode node={wpt} item={item.extend(i)} /> <FileListNode node={wpt} item={item.extend(i)} />
</div> </div>
{/each} {/each}
{:else if waypointRoot} {:else if waypointRoot}
{#if node.wpt.length > 0} {#if node.wpt.length > 0}
<div data-id="waypoints"> <div data-id="waypoints">
<FileListNode {node} item={item.extend('waypoints')} /> <FileListNode {node} item={item.extend('waypoints')} />
</div> </div>
{/if} {/if}
{:else} {:else}
{#each node.children as child, i (child)} {#each node.children as child, i (child)}
<div data-id={i}> <div data-id={i}>
<FileListNode node={child} item={item.extend(i)} /> <FileListNode node={child} item={item.extend(i)} />
</div> </div>
{/each} {/each}
{/if} {/if}
{:else if node instanceof Track} {:else if node instanceof Track}
{#each node.children as child, i (child)} {#each node.children as child, i (child)}
<div data-id={i} class="ml-1"> <div data-id={i} class="ml-1">
<FileListNode node={child} item={item.extend(i)} /> <FileListNode node={child} item={item.extend(i)} />
</div> </div>
{/each} {/each}
{/if} {/if}
</div> </div>
{#if node instanceof GPXFile && item instanceof ListFileItem} {#if node instanceof GPXFile && item instanceof ListFileItem}
{#if !waypointRoot} {#if !waypointRoot}
<svelte:self {node} {item} waypointRoot={true} /> <svelte:self {node} {item} waypointRoot={true} />
{/if} {/if}
{/if} {/if}
<style lang="postcss"> <style lang="postcss">
.sortable > div { .sortable > div {
@apply rounded-md; @apply rounded-md;
@apply h-fit; @apply h-fit;
@apply leading-none; @apply leading-none;
} }
.vertical :global(button) { .vertical :global(button) {
@apply hover:bg-muted; @apply hover:bg-muted;
} }
.vertical :global(.sortable-selected button) { .vertical :global(.sortable-selected button) {
@apply hover:bg-accent; @apply hover:bg-accent;
} }
.vertical :global(.sortable-selected) { .vertical :global(.sortable-selected) {
@apply bg-accent; @apply bg-accent;
} }
.horizontal :global(button) { .horizontal :global(button) {
@apply bg-accent; @apply bg-accent;
@apply hover:bg-muted; @apply hover:bg-muted;
} }
.horizontal :global(.sortable-selected button) { .horizontal :global(.sortable-selected button) {
@apply bg-background; @apply bg-background;
} }
</style> </style>

View File

@@ -178,6 +178,14 @@ export function setScissorsCursor() {
setCursor(scissorsCursor); setCursor(scissorsCursor);
} }
export function isMac() {
return navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
}
export function isSafari() {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
}
export function getURLForLanguage(lang: string | null | undefined, path: string): string { export function getURLForLanguage(lang: string | null | undefined, path: string): string {
let newPath = path.replace(base, ''); let newPath = path.replace(base, '');
let languageInPath = newPath.split('/')[1]; let languageInPath = newPath.split('/')[1];