mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 20:19:39 +00:00
Migration to new Rust-based engine (#401)
* wasm first steps * parsing start * parsing continued * progress * progress * progress * progress * progress * progress * progress * progress * fixes * reorganize * change type * fix * progress * refactoring * progress * progress * progress * keep style info * progress * progress * remove (copy) in name * progress * merge methods into 1 * splice * progress * progress * progress * cleaning * progress * progress * progress * visibility * progress * progress * progress * progress * progress * fixes * progress * fix markers * fix * progress * progress * progress * progress * progress * progress * small fix * use engine info * fix popups * small fix * progress * progress * progress * clean tool * handle self closing elements * reduce data exchanges across boundary * copy on wasm side to avoid any data corruption * speed fix * routing * refactoring * reverse * reverse * crop * split * merge * progress * time tool * fix bounds for waypoints * reduce * fix reactivity * handle corrupt time data * export * progress * remove gpx js package * remove todo * update readme * cleaning * progress * fix link fields * remove unused fields * refactoring and tests * import and export of routes, ref #192 * per-chunk categories storage * cross-tab sync * outdated state * refactoring * reorder methods * refactoring * fix * improve flow * renaming * progress * progress * fix * progress * per file history * keep original flag * use history variant * fix * progress * file explorer * progress * fix * sticky folders * improved sticky folders * fix sortable * fix sortable * fix * fix tab size * no file delete on desktop * fix routing on other file * fix sorting * remember language * open links in browser * commit missing changes * avoid selecting on resize * clarify filename * explorer actions * fix folder styling * drop anywhere in folder * cleaning * fixes * remove focus-ring * full screen * small fix * consistent highlight color * improved preview * performance improvements * sparser data transfers * sparser data transfers * show popup once data is loaded * fix routing control size * fix * sparse desktop build * remove custom ign styles * use new endpoint for openhikingmap * strip native menu * reorder context menu items * route full support * route - track conversion * create route file action * route export, filter out anchors at high zoom level * clarify name handling * move hide & center to view * routes continued * update docs * fix power format, closes #378 * lint fixes * fix display of additional data with gaps, closes #361 * fix linting issues * fix import modules * fix multidrag * change base to resolve * fix type errors * fix layer typings * fix last type error * each keys * improve typings * deploy to cloudflare --------- Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
1 parent
6a0d434371
commit
8fe7d8d64d
484 files changed
+53949
-41152
No files matched your search
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
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,
|
||||
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, parentOf, subfolder } from '$lib/engine/workspace';
|
||||
|
||||
const { workspace, selection } = engine;
|
||||
|
||||
let folder = $derived($workspace?.folder);
|
||||
|
||||
// which folders are open: new ones when another folder is opened, the folder itself is open at
|
||||
// first and its folders are closed
|
||||
let trees = $derived(
|
||||
folder
|
||||
? {
|
||||
root: new CollapsibleTreeState('open'),
|
||||
folders: new CollapsibleTreeState('closed'),
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
let container: HTMLElement | undefined = $state();
|
||||
|
||||
// the files that are selected, if they are in the folder
|
||||
let selectedPaths = $derived(
|
||||
selectedFileIds($selection)
|
||||
.map((id) => $workspace?.files[id]?.path)
|
||||
.filter((path): path is string => subfolder(folder, path) !== undefined)
|
||||
);
|
||||
let selectedPath = $derived(selectedPaths[0]);
|
||||
|
||||
$effect(() => {
|
||||
// not what the click that was just made depends on, only the selection and the folder
|
||||
const clicked = untrack(() => clickedInExplorer.path);
|
||||
if (clicked !== undefined && selectedPath === clicked) {
|
||||
return;
|
||||
}
|
||||
clickedInExplorer.path = undefined;
|
||||
// what is selected elsewhere (the file list, the map) is selected here
|
||||
explorerSelection.paths = untrack(() => selectedPaths);
|
||||
explorerSelection.anchor = selectedPath;
|
||||
if (folder && selectedPath !== undefined) {
|
||||
return reveal(folder, selectedPath);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens the folders that hold a file and scrolls to the file, if it is not in view already. Its
|
||||
* row may not be there yet, because the content of a folder is read when it is opened.
|
||||
*/
|
||||
function reveal(folder: string, path: string) {
|
||||
openFolder(parentOf(path));
|
||||
let frame = 0;
|
||||
let attempts = 0;
|
||||
const scroll = () => {
|
||||
const row = container?.querySelector(`[data-path="${CSS.escape(path)}"]`);
|
||||
if (row) {
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
} else if (attempts++ < 120) {
|
||||
frame = requestAnimationFrame(scroll);
|
||||
}
|
||||
};
|
||||
frame = requestAnimationFrame(scroll);
|
||||
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;
|
||||
const files = treeRows(container)
|
||||
.filter((row) => row.kind === 'file' && explorerSelection.paths.includes(row.path))
|
||||
.map((row) => row.path);
|
||||
if (files.length > 0) {
|
||||
openPaths(files);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
The tree of the open folder: its folders and GPX files, one level read at a time. The files are
|
||||
opened from it (the file list has the ones that are open).
|
||||
-->
|
||||
{#if folder && trees}
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="px-2 py-1 text-sm font-semibold shrink-0">{i18n._('menu.explorer')}</div>
|
||||
<ScrollArea class="grow min-h-0">
|
||||
<div
|
||||
bind:this={container}
|
||||
data-explorer
|
||||
role="tree"
|
||||
tabindex="0"
|
||||
class="flex flex-col pl-1 pr-3 pb-1 outline-none"
|
||||
{onkeydown}
|
||||
>
|
||||
<!-- starts over when another folder is opened -->
|
||||
{#key folder}
|
||||
<!-- the folder itself -->
|
||||
<CollapsibleTree side="left" treeState={trees.root} sticky>
|
||||
<CollapsibleTreeNode id="folder">
|
||||
{#snippet trigger()}
|
||||
<ExplorerRow
|
||||
path={folder}
|
||||
name={folderName(folder)}
|
||||
kind="root"
|
||||
class="h-7"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content(opened)}
|
||||
<!-- what is in it is only read when it is open -->
|
||||
{#if opened}
|
||||
<div class="ml-4">
|
||||
<CollapsibleTree side="left" treeState={trees.folders}>
|
||||
<ExplorerTree />
|
||||
</CollapsibleTree>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</CollapsibleTreeNode>
|
||||
</CollapsibleTree>
|
||||
{/key}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
@reference "../../../app.css";
|
||||
|
||||
/* the color of the file list: a file and a folder (the whole row of its node, with its
|
||||
chevron) under the pointer, or selected */
|
||||
[data-explorer] :global([data-kind='file']:hover),
|
||||
[data-explorer] :global([data-slot='button']:hover),
|
||||
[data-explorer] :global([data-kind='file'][aria-selected='true']),
|
||||
[data-explorer] :global([data-slot='button']:has([aria-selected='true'])) {
|
||||
@apply bg-[var(--selection)];
|
||||
}
|
||||
|
||||
/* the folder where what is dragged would go */
|
||||
[data-explorer] :global([data-slot='button']:has([data-drop-target])) {
|
||||
@apply ring-1 ring-primary bg-[var(--selection)];
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,342 @@
|
||||
<script lang="ts">
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
FileX,
|
||||
FolderOpen,
|
||||
FolderX,
|
||||
PenLine,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from '@lucide/svelte';
|
||||
import { getContext, type Snippet } from 'svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { engine } from '$lib/engine';
|
||||
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';
|
||||
import {
|
||||
clickedInExplorer,
|
||||
creating,
|
||||
dragging,
|
||||
explorerFoldersKey,
|
||||
explorerSelection,
|
||||
renaming,
|
||||
treeRows,
|
||||
type ExplorerFolders,
|
||||
} from './state.svelte';
|
||||
|
||||
let {
|
||||
path,
|
||||
name,
|
||||
kind,
|
||||
icon,
|
||||
class: className = '',
|
||||
style = '',
|
||||
}: {
|
||||
path: string;
|
||||
/** What the row shows, the name of the file with its extension. */
|
||||
name: string;
|
||||
/** The open folder itself, a folder in it, or a GPX file. */
|
||||
kind: 'root' | 'folder' | 'file';
|
||||
icon?: Snippet;
|
||||
class?: string;
|
||||
style?: string;
|
||||
} = $props();
|
||||
|
||||
const { workspace } = engine;
|
||||
const folders = getContext<ExplorerFolders>(explorerFoldersKey);
|
||||
|
||||
let row: HTMLElement | undefined = $state();
|
||||
|
||||
// the open file of the row
|
||||
let fileId = $derived(kind === 'file' ? fileIdAt($workspace, path) : undefined);
|
||||
let selected = $derived(explorerSelection.paths.includes(path));
|
||||
let editing = $derived(renaming.path === path);
|
||||
let target = $derived(dragging.target === path);
|
||||
|
||||
/** What the menu and dragging act on: the selection, if the row is in it. */
|
||||
function targets(): string[] {
|
||||
return selected ? explorerSelection.paths : [path];
|
||||
}
|
||||
|
||||
function select(paths: string[]) {
|
||||
explorerSelection.paths = paths;
|
||||
explorerSelection.anchor = path;
|
||||
}
|
||||
|
||||
function onclick(event: MouseEvent) {
|
||||
if (editing || kind === 'root') {
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
// added to the selection, or taken out of it, without opening anything
|
||||
select(
|
||||
selected
|
||||
? explorerSelection.paths.filter((other) => other !== path)
|
||||
: [...explorerSelection.paths, path]
|
||||
);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (event.shiftKey && explorerSelection.anchor !== undefined) {
|
||||
// everything shown between the row where the selection began and this one
|
||||
const paths = treeRows(row)
|
||||
.filter((other) => other.kind !== 'root')
|
||||
.map((other) => other.path);
|
||||
const [from, to] = [paths.indexOf(explorerSelection.anchor), paths.indexOf(path)];
|
||||
if (from >= 0 && to >= 0) {
|
||||
explorerSelection.paths = paths.slice(Math.min(from, to), Math.max(from, to) + 1);
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (event.detail <= 1) {
|
||||
select([path]);
|
||||
if (kind === 'file') {
|
||||
// it is in view, the explorer does not have to scroll to it
|
||||
clickedInExplorer.path = path;
|
||||
if (fileId !== undefined) {
|
||||
engine.select([fileId]);
|
||||
} else {
|
||||
openPaths([path], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ondblclick() {
|
||||
if (kind === 'file' && !editing) {
|
||||
clickedInExplorer.path = path;
|
||||
openPaths([path], false);
|
||||
}
|
||||
}
|
||||
|
||||
// the menu
|
||||
|
||||
/** The folder where what is made from the menu goes: the folder of the row. */
|
||||
let folder = $derived(kind === 'file' ? undefined : path);
|
||||
|
||||
/**
|
||||
* Asks for the name of a file or a folder to make in the folder of the row (see `creating`).
|
||||
* With `route`, the file starts with an empty route.
|
||||
*/
|
||||
function create(what: 'file' | 'folder', route = false) {
|
||||
if (folder === undefined) return;
|
||||
// where what is being named is shown
|
||||
folders.open(folder);
|
||||
creating.kind = what;
|
||||
creating.route = route;
|
||||
creating.parent = folder;
|
||||
}
|
||||
|
||||
function openSelected() {
|
||||
const files = treeRows(row)
|
||||
.filter((other) => other.kind === 'file' && targets().includes(other.path))
|
||||
.map((other) => other.path);
|
||||
if (files.length > 0) {
|
||||
openPaths(files);
|
||||
}
|
||||
}
|
||||
|
||||
/** The open files among what the menu acts on. */
|
||||
function openIds(): string[] {
|
||||
return targets()
|
||||
.map((other) => fileIdAt($workspace, other))
|
||||
.filter((id): id is string => id !== undefined);
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const paths = targets();
|
||||
explorerSelection.paths = [];
|
||||
if (!(await engine.deletePaths(paths))) {
|
||||
showEngineError();
|
||||
}
|
||||
}
|
||||
|
||||
// renaming, in the row
|
||||
|
||||
async function commit(value: string) {
|
||||
if (renaming.path !== path) return;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// dragging into folders
|
||||
|
||||
function ondragstart(event: DragEvent) {
|
||||
if (kind === 'root' || editing) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const paths = targets();
|
||||
dragging.paths = paths;
|
||||
event.dataTransfer?.setData('text/plain', paths.join('\n'));
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
}
|
||||
|
||||
function ondragend() {
|
||||
dragging.paths = [];
|
||||
dragging.target = undefined;
|
||||
}
|
||||
|
||||
/** The folder where what is dropped on the row goes: the folder of a file, among its files. */
|
||||
let dropFolder = $derived(kind === 'file' ? parentOf(path) : path);
|
||||
|
||||
function ondragover(event: DragEvent) {
|
||||
if (dragging.paths.length === 0) return;
|
||||
// not into itself, nor where it is already
|
||||
if (dragging.paths.some((dragged) => isWithin(dropFolder, dragged))) return;
|
||||
if (dragging.paths.every((dragged) => parentOf(dragged) === dropFolder)) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
// the row of the folder shows it
|
||||
dragging.target = dropFolder;
|
||||
}
|
||||
|
||||
function ondragleave(event: DragEvent) {
|
||||
// to another row, which tells where it would go (it was entered first)
|
||||
const to = event.relatedTarget as Element | null;
|
||||
if (to?.closest?.('[data-explorer-path]')) return;
|
||||
dragging.target = undefined;
|
||||
}
|
||||
|
||||
async function ondrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
const paths = dragging.paths;
|
||||
ondragend();
|
||||
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();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ContextMenu.Root
|
||||
onOpenChange={(open) => {
|
||||
if (open && fileId !== undefined) {
|
||||
// the file is open for good once its menu is
|
||||
engine.keepPreview([fileId]);
|
||||
}
|
||||
if (open && !selected && kind !== 'root') {
|
||||
select([path]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenu.Trigger class="grow min-w-0 pointer-events-auto outline-none">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={row}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
tabindex="-1"
|
||||
data-explorer-path={path}
|
||||
data-path={path}
|
||||
data-kind={kind}
|
||||
title={path}
|
||||
draggable={kind !== 'root' && !editing}
|
||||
data-drop-target={target || undefined}
|
||||
class="flex flex-row items-center gap-1.5 min-w-0 rounded-md outline-none {className}"
|
||||
{style}
|
||||
{onclick}
|
||||
{ondblclick}
|
||||
{ondragstart}
|
||||
{ondragend}
|
||||
{ondragover}
|
||||
ondragenter={ondragover}
|
||||
{ondragleave}
|
||||
{ondrop}
|
||||
>
|
||||
{@render icon?.()}
|
||||
{#if editing}
|
||||
<NameInput
|
||||
value={editableName(name, kind !== 'file')}
|
||||
oncommit={commit}
|
||||
oncancel={() => (renaming.path = undefined)}
|
||||
/>
|
||||
{:else}
|
||||
<span class="grow truncate text-left">{name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<!-- the name being edited keeps the focus, which the menu would give back to the row -->
|
||||
<ContextMenu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (renaming.path !== undefined || creating.parent !== undefined) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if kind !== 'file'}
|
||||
<ContextMenu.Item onclick={() => create('file')}>
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.explorer_menu.new_file')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onclick={() => create('file', true)}>
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.explorer_menu.new_route_file')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onclick={() => create('folder')}>
|
||||
<Plus size="16" />
|
||||
{i18n._('menu.explorer_menu.new_folder')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{:else}
|
||||
<ContextMenu.Item onclick={openSelected}>
|
||||
<FolderOpen size="16" />
|
||||
{i18n._('menu.explorer_menu.open')}
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Item onclick={() => engine.revealPath(path)}>
|
||||
<ArrowUpRight size="16" />
|
||||
{i18n._(isMac() ? 'menu.explorer_menu.reveal_finder' : 'menu.explorer_menu.reveal')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{#if kind !== 'root'}
|
||||
<ContextMenu.Item
|
||||
disabled={targets().length !== 1}
|
||||
onclick={() => (renaming.path = path)}
|
||||
>
|
||||
<PenLine size="16" />
|
||||
{i18n._('menu.explorer_menu.rename')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
{#if kind === 'root'}
|
||||
<ContextMenu.Item onclick={closeFolder}>
|
||||
<FolderX size="16" />
|
||||
{i18n._('menu.close_folder')}
|
||||
</ContextMenu.Item>
|
||||
{:else}
|
||||
{#if kind === 'file' && openIds().length > 0}
|
||||
<ContextMenu.Item onclick={() => closeFiles(openIds())}>
|
||||
<FileX size="16" />
|
||||
{i18n._('menu.close')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
<ContextMenu.Item onclick={remove}>
|
||||
<Trash2 size="16" />
|
||||
{i18n._('menu.delete')}
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||
import { FileText, Folder, Route } from '@lucide/svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { stickyKey, type StickyLevel } from '$lib/components/collapsible-tree/sticky';
|
||||
import ExplorerTree from './ExplorerTree.svelte';
|
||||
import ExplorerRow from './ExplorerRow.svelte';
|
||||
import { engine, type FolderEntry } from '$lib/engine';
|
||||
import { editableName, fileIdAt } from '$lib/engine/workspace';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { showEngineError } from '$lib/actions/file-actions';
|
||||
import NameInput from './NameInput.svelte';
|
||||
import { creating, explorerSelection } from './state.svelte';
|
||||
|
||||
let {
|
||||
path,
|
||||
}: {
|
||||
/** The folder whose content is listed, the open folder if it is not given. */
|
||||
path?: string;
|
||||
} = $props();
|
||||
|
||||
const { workspace, folderVersion } = engine;
|
||||
// the rows of the folders that stick above the files of this one
|
||||
const level = getContext<StickyLevel>(stickyKey);
|
||||
|
||||
let entries: FolderEntry[] = $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 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,
|
||||
* nor with the name of something that is there (the user is told, and can change it). A new
|
||||
* file is selected, for the routing tool.
|
||||
*/
|
||||
async function create(value: string, retry: boolean) {
|
||||
const kind = pending;
|
||||
const parent = folder;
|
||||
const route = creating.route;
|
||||
if (kind === undefined || parent === undefined) return;
|
||||
const name = value.trim();
|
||||
const taken = entries.some(
|
||||
(entry) =>
|
||||
entry.isDir === (kind === 'folder') &&
|
||||
editableName(entry.name, entry.isDir).toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
if (name !== '' && taken) {
|
||||
toast.error(i18n._('menu.metadata.name_taken'));
|
||||
if (retry) return;
|
||||
}
|
||||
creating.parent = undefined;
|
||||
if (name === '' || taken) return;
|
||||
let created: string | undefined;
|
||||
if (kind === 'file') {
|
||||
const id = await engine.newFileIn(name, parent, route);
|
||||
created = id === undefined ? undefined : get(workspace)?.files[id]?.path;
|
||||
if (created !== undefined) {
|
||||
currentTool.set(Tool.ROUTING);
|
||||
}
|
||||
} else {
|
||||
created = await engine.createFolder(parent, name);
|
||||
}
|
||||
if (created === undefined) {
|
||||
showEngineError();
|
||||
return;
|
||||
}
|
||||
explorerSelection.paths = [created];
|
||||
explorerSelection.anchor = created;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// read again when something changed on the file system
|
||||
$folderVersion;
|
||||
let current = true;
|
||||
engine.listFolder(path).then((list) => {
|
||||
if (current) {
|
||||
entries = list;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- the name of what is being made, which it is made with -->
|
||||
{#snippet named(kind: 'file' | 'folder')}
|
||||
<div class="flex flex-row items-center h-7 pl-1 pr-1 {kind === 'file' ? 'gap-1.5' : 'gap-1'}">
|
||||
{#if kind === 'file' && creating.route}
|
||||
<Route size="16" class="shrink-0" />
|
||||
{:else if kind === 'file'}
|
||||
<FileText size="16" class="shrink-0" />
|
||||
{:else}
|
||||
<Folder size="16" class="shrink-0" />
|
||||
{/if}
|
||||
<NameInput
|
||||
placeholder={i18n._(
|
||||
kind === 'folder'
|
||||
? 'menu.explorer_menu.new_folder'
|
||||
: creating.route
|
||||
? 'menu.explorer_menu.new_route_file'
|
||||
: 'menu.explorer_menu.new_file'
|
||||
)}
|
||||
oncommit={create}
|
||||
oncancel={() => (creating.parent = undefined)}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if pending === 'folder'}
|
||||
{@render named('folder')}
|
||||
{/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'}
|
||||
{@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 {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}
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
value = '',
|
||||
placeholder,
|
||||
oncommit,
|
||||
oncancel,
|
||||
}: {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
/** The name is given: with Enter (`enter`), or by leaving the field. */
|
||||
oncommit: (value: string, enter: boolean) => void;
|
||||
/** Escape: no name is given. */
|
||||
oncancel: () => void;
|
||||
} = $props();
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
// not for the row or the explorer it is in (a folder opens with Enter or Space, the selected
|
||||
// files open with Enter)
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') {
|
||||
oncommit((event.currentTarget as HTMLInputElement).value, true);
|
||||
} else if (event.key === 'Escape') {
|
||||
oncancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- the name of a file or a folder of the explorer, being given or changed -->
|
||||
<input
|
||||
class="h-6 grow min-w-0 px-1 text-sm font-normal not-italic bg-background border rounded outline-none"
|
||||
{value}
|
||||
{placeholder}
|
||||
{@attach (input) => {
|
||||
// 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()}
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onblur={(event) => oncommit(event.currentTarget.value, false)}
|
||||
/>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Explorer } from './Explorer.svelte';
|
||||
@@ -0,0 +1,49 @@
|
||||
/** The file that the user just selected by clicking it in the explorer, until the selection changes. */
|
||||
export const clickedInExplorer: { path: string | undefined } = $state({ path: undefined });
|
||||
|
||||
/**
|
||||
* What is selected in the explorer (files and folders, by path), which the menu and dragging act
|
||||
* on, and where a range selected with shift begins.
|
||||
*/
|
||||
export const explorerSelection: { paths: string[]; anchor: string | undefined } = $state({
|
||||
paths: [],
|
||||
anchor: undefined,
|
||||
});
|
||||
|
||||
/** The file or folder whose name is being edited in the explorer. */
|
||||
export const renaming: { path: string | undefined } = $state({ path: undefined });
|
||||
|
||||
/**
|
||||
* The file or folder that is being made in a folder (`parent`) of the explorer: it is only made once
|
||||
* the user gave it a name. With `route`, the file starts with an empty route.
|
||||
*/
|
||||
export const creating: { parent: string | undefined; kind: 'file' | 'folder'; route: boolean } =
|
||||
$state({
|
||||
parent: undefined,
|
||||
kind: 'file',
|
||||
route: false,
|
||||
});
|
||||
|
||||
/** What is dragged in the explorer, and the folder it would be dropped in. */
|
||||
export const dragging: { paths: string[]; target: string | undefined } = $state({
|
||||
paths: [],
|
||||
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]');
|
||||
return [...(tree?.querySelectorAll<HTMLElement>('[data-explorer-path]') ?? [])].map((row) => ({
|
||||
path: row.dataset.explorerPath!,
|
||||
kind: row.dataset.kind!,
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user