mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-19 09:22:12 +00:00
fixes
This commit is contained in:
1 parent
5373d0f0d4
commit
3012f6bf73
8 files changed
+138
-69
No files matched your search
@@ -28,6 +28,16 @@ export class CollapsibleTreeState {
|
||||
}
|
||||
return this._open[id];
|
||||
}
|
||||
|
||||
/** The node `from` is now `to` (it was renamed or moved): it stays open or closed, and so do the nodes in it. */
|
||||
move(from: string, to: string) {
|
||||
for (const id of Object.keys(this._open)) {
|
||||
if (id === from || id.startsWith(`${from}.`)) {
|
||||
this._open[to + id.slice(from.length)] = this._open[id];
|
||||
delete this._open[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { setContext, untrack } from 'svelte';
|
||||
import { CollapsibleTree, CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||
import { CollapsibleTreeState } from '$lib/components/collapsible-tree/utils.svelte';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
||||
import ExplorerTree from './ExplorerTree.svelte';
|
||||
import ExplorerRow from './ExplorerRow.svelte';
|
||||
import { clickedInExplorer, explorerSelection, renaming, treeRows } from './state.svelte';
|
||||
import {
|
||||
clickedInExplorer,
|
||||
explorerFoldersKey,
|
||||
explorerSelection,
|
||||
renaming,
|
||||
treeRows,
|
||||
type ExplorerFolders,
|
||||
} from './state.svelte';
|
||||
import { openPaths } from '$lib/actions/file-actions';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { engine } from '$lib/engine';
|
||||
import { selectedFileIds } from '$lib/engine/selection-helpers';
|
||||
import { folderName, subfolder } from '$lib/engine/workspace';
|
||||
import { folderName, parentOf, subfolder } from '$lib/engine/workspace';
|
||||
|
||||
const { workspace, selection } = engine;
|
||||
|
||||
@@ -57,15 +64,7 @@
|
||||
* row may not be there yet, because the content of a folder is read when it is opened.
|
||||
*/
|
||||
function reveal(folder: string, path: string) {
|
||||
if (!trees) {
|
||||
return;
|
||||
}
|
||||
trees.root.get('root.folder').current = true;
|
||||
let id = 'root';
|
||||
for (const name of subfolder(folder, path)?.split('/').filter(Boolean) ?? []) {
|
||||
id += `.${name}`;
|
||||
trees.folders.get(id).current = true;
|
||||
}
|
||||
openFolder(parentOf(path));
|
||||
let frame = 0;
|
||||
let attempts = 0;
|
||||
const scroll = () => {
|
||||
@@ -80,6 +79,38 @@
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the node of a folder of the tree in `trees.folders`: the names of the folders it is
|
||||
* in, and its own (see `CollapsibleTreeNode`). `undefined` for the open folder itself, which is
|
||||
* in `trees.root`, and for what is not in it.
|
||||
*/
|
||||
function nodeId(path: string): string | undefined {
|
||||
const names = subfolder(folder, `${path}/_`)?.split('/').filter(Boolean);
|
||||
return names && names.length > 0 ? ['root', ...names].join('.') : undefined;
|
||||
}
|
||||
|
||||
/** Opens a folder of the tree, and the folders it is in. */
|
||||
function openFolder(path: string) {
|
||||
if (!trees) return;
|
||||
trees.root.get('root.folder').current = true;
|
||||
const id = nodeId(path);
|
||||
if (id === undefined) return;
|
||||
const names = id.split('.');
|
||||
names.forEach((_, i) => {
|
||||
if (i > 0) trees!.folders.get(names.slice(0, i + 1).join('.')).current = true;
|
||||
});
|
||||
}
|
||||
|
||||
setContext<ExplorerFolders>(explorerFoldersKey, {
|
||||
open: openFolder,
|
||||
moved(from, to) {
|
||||
const [fromId, toId] = [nodeId(from), nodeId(to)];
|
||||
if (trees && fromId !== undefined && toId !== undefined) {
|
||||
trees.folders.move(fromId, toId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** Enter opens the selected files for good. */
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (renaming.path !== undefined || event.key !== 'Enter') return;
|
||||
@@ -121,8 +152,6 @@
|
||||
name={folderName(folder)}
|
||||
kind="root"
|
||||
class="h-7"
|
||||
onOpenFolder={() =>
|
||||
trees && (trees.root.get('root.folder').current = true)}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content(opened)}
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
Plus,
|
||||
Trash2,
|
||||
} from '@lucide/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getContext, type Snippet } from 'svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { engine } from '$lib/engine';
|
||||
import { editableName, fileIdAt, isWithin, parentOf } from '$lib/engine/workspace';
|
||||
import { editableName, fileIdAt, isWithin, parentOf, relocated } from '$lib/engine/workspace';
|
||||
import { closeFiles, closeFolder, openPaths, showEngineError } from '$lib/actions/file-actions';
|
||||
import { isMac } from '$lib/utils';
|
||||
import NameInput from './NameInput.svelte';
|
||||
@@ -20,16 +20,17 @@
|
||||
clickedInExplorer,
|
||||
creating,
|
||||
dragging,
|
||||
explorerFoldersKey,
|
||||
explorerSelection,
|
||||
renaming,
|
||||
treeRows,
|
||||
type ExplorerFolders,
|
||||
} from './state.svelte';
|
||||
|
||||
let {
|
||||
path,
|
||||
name,
|
||||
kind,
|
||||
onOpenFolder,
|
||||
icon,
|
||||
class: className = '',
|
||||
style = '',
|
||||
@@ -39,14 +40,13 @@
|
||||
name: string;
|
||||
/** The open folder itself, a folder in it, or a GPX file. */
|
||||
kind: 'root' | 'folder' | 'file';
|
||||
/** Opens the folder of the row, to show what was made in it. */
|
||||
onOpenFolder?: () => void;
|
||||
icon?: Snippet;
|
||||
class?: string;
|
||||
style?: string;
|
||||
} = $props();
|
||||
|
||||
const { workspace } = engine;
|
||||
const folders = getContext<ExplorerFolders>(explorerFoldersKey);
|
||||
|
||||
let row: HTMLElement | undefined = $state();
|
||||
|
||||
@@ -119,7 +119,8 @@
|
||||
/** Asks for the name of a file or a folder to make in the folder of the row (see `creating`). */
|
||||
function create(what: 'file' | 'folder') {
|
||||
if (folder === undefined) return;
|
||||
onOpenFolder?.();
|
||||
// where what is being named is shown
|
||||
folders.open(folder);
|
||||
creating.kind = what;
|
||||
creating.parent = folder;
|
||||
}
|
||||
@@ -155,7 +156,11 @@
|
||||
renaming.path = undefined;
|
||||
const name_ = value.trim();
|
||||
if (name_ === '' || name_ === editableName(name, kind !== 'file')) return;
|
||||
// the folder stays open or closed (before its new row is read, back if it is not renamed)
|
||||
const to = relocated(path, parentOf(path), name_);
|
||||
folders.moved(path, to);
|
||||
if (!(await engine.renamePath(path, name_))) {
|
||||
folders.moved(to, path);
|
||||
showEngineError();
|
||||
}
|
||||
}
|
||||
@@ -207,7 +212,15 @@
|
||||
event.preventDefault();
|
||||
const paths = dragging.paths;
|
||||
ondragend();
|
||||
if (paths.length > 0 && !(await engine.movePaths(paths, dropFolder))) {
|
||||
if (paths.length === 0) return;
|
||||
// the folders stay open or closed (before their new rows are read, back if they are not moved)
|
||||
const moves = paths.map((dragged) => [dragged, relocated(dragged, dropFolder)]);
|
||||
moves.forEach(([from, to]) => folders.moved(from, to));
|
||||
if (await engine.movePaths(paths, dropFolder)) {
|
||||
// where they went is shown
|
||||
folders.open(dropFolder);
|
||||
} else {
|
||||
moves.forEach(([from, to]) => folders.moved(to, from));
|
||||
showEngineError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +27,15 @@
|
||||
const level = getContext<StickyLevel>(stickyKey);
|
||||
|
||||
let entries: FolderEntry[] = $state([]);
|
||||
// the nodes of the folders, to open them when something is made in them
|
||||
let nodes: Record<string, CollapsibleTreeNode | undefined> = $state({});
|
||||
|
||||
// the folder of the list, where what is being made goes
|
||||
let folder = $derived(path ?? $workspace?.folder);
|
||||
let pending = $derived(
|
||||
creating.parent !== undefined && creating.parent === folder ? creating.kind : undefined
|
||||
);
|
||||
// the files come after the folders
|
||||
let firstFile = $derived(entries.find((entry) => !entry.isDir));
|
||||
// the folders come first, then the files (and what is being made goes at the top of its kind)
|
||||
let folders = $derived(entries.filter((entry) => entry.isDir));
|
||||
let files = $derived(entries.filter((entry) => !entry.isDir));
|
||||
|
||||
/**
|
||||
* Makes the file or folder that was asked for, called `value`: nothing is made without a name,
|
||||
@@ -113,48 +112,38 @@
|
||||
{#if pending === 'folder'}
|
||||
{@render named('folder')}
|
||||
{/if}
|
||||
{#each entries as entry (entry.path)}
|
||||
{#if pending === 'file' && entry === firstFile}
|
||||
{@render named('file')}
|
||||
{/if}
|
||||
{#if entry.isDir}
|
||||
<CollapsibleTreeNode id={entry.name} bind:this={nodes[entry.path]}>
|
||||
{#snippet trigger()}
|
||||
<ExplorerRow
|
||||
path={entry.path}
|
||||
name={entry.name}
|
||||
kind="folder"
|
||||
class="h-7"
|
||||
onOpenFolder={() => nodes[entry.path]?.openNode()}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content(opened)}
|
||||
<!-- the content of a folder is only read when it is opened -->
|
||||
{#if opened}
|
||||
<div class="ml-4">
|
||||
<ExplorerTree path={entry.path} />
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</CollapsibleTreeNode>
|
||||
{:else}
|
||||
<!-- the files that are not open are greyed -->
|
||||
<ExplorerRow
|
||||
path={entry.path}
|
||||
name={entry.name}
|
||||
kind="file"
|
||||
class="w-full h-7 px-1 text-sm hover:bg-muted {fileIdAt($workspace, entry.path) ===
|
||||
undefined
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
style="scroll-margin-top: {level.top}px"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<FileText size="16" class="shrink-0" />
|
||||
{/snippet}
|
||||
</ExplorerRow>
|
||||
{/if}
|
||||
{#each folders as entry (entry.path)}
|
||||
<CollapsibleTreeNode id={entry.name}>
|
||||
{#snippet trigger()}
|
||||
<ExplorerRow path={entry.path} name={entry.name} kind="folder" class="h-7" />
|
||||
{/snippet}
|
||||
{#snippet content(opened)}
|
||||
<!-- the content of a folder is only read when it is opened -->
|
||||
{#if opened}
|
||||
<div class="ml-4">
|
||||
<ExplorerTree path={entry.path} />
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</CollapsibleTreeNode>
|
||||
{/each}
|
||||
{#if pending === 'file' && firstFile === undefined}
|
||||
{#if pending === 'file'}
|
||||
{@render named('file')}
|
||||
{/if}
|
||||
{#each files as entry (entry.path)}
|
||||
<!-- the files that are not open are greyed -->
|
||||
<ExplorerRow
|
||||
path={entry.path}
|
||||
name={entry.name}
|
||||
kind="file"
|
||||
class="w-full h-7 px-1 text-sm hover:bg-muted {fileIdAt($workspace, entry.path) ===
|
||||
undefined
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
style="scroll-margin-top: {level.top}px"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<FileText size="16" class="shrink-0" />
|
||||
{/snippet}
|
||||
</ExplorerRow>
|
||||
{/each}
|
||||
@@ -31,8 +31,12 @@
|
||||
{value}
|
||||
{placeholder}
|
||||
{@attach (input) => {
|
||||
input.focus();
|
||||
input.select();
|
||||
// once it is shown: it can be in a folder that is opening
|
||||
const frame = requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}}
|
||||
{onkeydown}
|
||||
onkeyup={(event) => event.stopPropagation()}
|
||||
|
||||
@@ -28,6 +28,15 @@ export const dragging: { paths: string[]; target: string | undefined } = $state(
|
||||
target: undefined,
|
||||
});
|
||||
|
||||
/** What the rows of the explorer can do to the folders of its tree (see `Explorer`). */
|
||||
export type ExplorerFolders = {
|
||||
/** Opens a folder of the tree, and the folders it is in. */
|
||||
open(path: string): void;
|
||||
/** A folder was renamed or moved: it stays open or closed, and so does what is in it. */
|
||||
moved(from: string, to: string): void;
|
||||
};
|
||||
export const explorerFoldersKey = 'explorer-folders';
|
||||
|
||||
/** The rows of the explorer that `element` is in, as they are shown: their path and kind. */
|
||||
export function treeRows(element: Element | null | undefined): { path: string; kind: string }[] {
|
||||
const tree = element?.closest('[data-explorer]');
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
folderName,
|
||||
isWithin,
|
||||
parentOf,
|
||||
relocated,
|
||||
subfolder,
|
||||
} from '$lib/engine/workspace';
|
||||
|
||||
@@ -62,6 +63,14 @@ describe('parentOf', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('relocated', () => {
|
||||
it('is where a path is once moved or renamed', () => {
|
||||
assert.equal(relocated('/me/tracks/a.gpx', '/me/trips'), '/me/trips/a.gpx');
|
||||
assert.equal(relocated('/me/tracks', '/me', 'rides'), '/me/rides');
|
||||
assert.equal(relocated('C:\\me\\tracks', 'C:\\me', 'rides'), 'C:\\me\\rides');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWithin', () => {
|
||||
it('is the folder itself or what is in it', () => {
|
||||
assert.ok(isWithin('/home/me/tracks', '/home/me/tracks'));
|
||||
|
||||
@@ -41,6 +41,12 @@ export function parentOf(path: string): string {
|
||||
return index > 0 ? path.slice(0, index) : path;
|
||||
}
|
||||
|
||||
/** Where `path` is once moved into `folder`, renamed `name` if it is given. */
|
||||
export function relocated(path: string, folder: string, name?: string): string {
|
||||
const parent = parentOf(path);
|
||||
return `${folder}${path[parent.length]}${name ?? path.slice(parent.length + 1)}`;
|
||||
}
|
||||
|
||||
/** Whether `path` is `folder` or something in it. */
|
||||
export function isWithin(path: string, folder: string): boolean {
|
||||
return path === folder || (path.startsWith(folder) && /[\\/]/.test(path[folder.length]));
|
||||
|
||||
Reference in new issue
Block a user