Papure/src/lib/components/FileTreeNode.svelte
2026-09-26 19:13:02 +02:00

168 lines
3.9 KiB
Svelte

<script lang="ts" module>
export interface Node {
path: string;
name: string;
kind: 'folder' | 'canvas';
children: Node[];
}
</script>
<script lang="ts">
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import Self from './FileTreeNode.svelte';
interface Props {
node: Node;
depth: number;
open: Set<string>;
renaming: string | null;
activePath: string | null;
onToggle: (path: string) => void;
onOpen: (path: string) => void;
onContext: (e: MouseEvent, n: Node) => void;
onRename: (path: string, kind: Node['kind'], name: string) => void;
onCancelRename: () => void;
onDrop: (targetDir: string, source: string) => void;
}
let props: Props = $props();
const { node, depth } = $derived(props);
const isOpen = $derived(props.open.has(node.path));
let dragOver = $state(false);
function focusSelect(el: HTMLInputElement) {
el.focus();
el.select();
}
function onKey(e: KeyboardEvent) {
const input = e.currentTarget as HTMLInputElement;
if (e.key === 'Enter') props.onRename(node.path, node.kind, input.value);
else if (e.key === 'Escape') props.onCancelRename();
}
function click() {
if (node.kind === 'folder') props.onToggle(node.path);
else props.onOpen(node.path);
}
</script>
<div
class="row"
class:active={node.kind === 'canvas' && props.activePath === node.path}
class:drag-over={dragOver}
style="padding-left: {8 + depth * 14}px"
role="treeitem"
aria-selected={props.activePath === node.path}
aria-expanded={node.kind === 'folder' ? isOpen : undefined}
tabindex="0"
draggable={props.renaming !== node.path}
onclick={click}
onkeydown={(e) => e.key === 'Enter' && props.renaming !== node.path && click()}
oncontextmenu={(e) => {
e.stopPropagation();
props.onContext(e, node);
}}
ondragstart={(e) => e.dataTransfer?.setData('text/x-papure-path', node.path)}
ondragover={(e) => {
if (node.kind !== 'folder') return;
e.preventDefault();
e.stopPropagation();
dragOver = true;
}}
ondragleave={() => (dragOver = false)}
ondrop={(e) => {
if (node.kind !== 'folder') return;
e.preventDefault();
e.stopPropagation();
dragOver = false;
props.onDrop(node.path, e.dataTransfer?.getData('text/x-papure-path') ?? '');
}}
>
{#if node.kind === 'folder'}
<span class="chev" class:open={isOpen}><ChevronRightIcon size={14} strokeWidth={2} /></span>
{/if}
{#if props.renaming === node.path}
<input
class="rename"
value={node.name}
use:focusSelect
onkeydown={onKey}
onclick={(e) => e.stopPropagation()}
onblur={(e) => props.onRename(node.path, node.kind, e.currentTarget.value)}
/>
{:else}
<span class="name" class:folder={node.kind === 'folder'}>{node.name}</span>
{/if}
</div>
{#if node.kind === 'folder' && isOpen}
<div class="children" style="--guide: {14 + depth * 14}px">
{#each node.children as child (child.path)}
<Self {...props} node={child} depth={depth + 1} />
{/each}
</div>
{/if}
<style>
.row {
display: flex;
align-items: center;
gap: 2px;
height: 28px;
padding-right: 8px;
border-radius: 4px;
cursor: pointer;
color: var(--muted);
user-select: none;
outline: none;
}
.row:hover,
.row:focus-visible {
background: var(--hover);
color: var(--text);
}
.row.active {
background: var(--accent-soft);
color: var(--text);
}
.row.drag-over {
box-shadow: inset 0 0 0 1px var(--accent);
}
.chev {
display: inline-grid;
place-items: center;
width: 16px;
color: var(--faint);
transition: transform 0.12s;
}
.chev.open {
transform: rotate(90deg);
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 4px;
}
.name.folder {
padding-left: 0;
}
.rename {
flex: 1;
min-width: 0;
height: 22px;
padding: 2px 6px;
}
.children {
position: relative;
}
.children::before {
content: '';
position: absolute;
left: var(--guide);
top: 0;
bottom: 0;
border-left: 1px solid var(--border);
}
</style>