175 lines
4.8 KiB
Svelte
175 lines
4.8 KiB
Svelte
<script>
|
|
import TemplateIcon from './lib/TemplateIcon.svelte'
|
|
|
|
export let nodes = []
|
|
export let expanded = {}
|
|
export let selectedNodeId = ''
|
|
export let level = 0
|
|
export let onSelect
|
|
export let onToggle
|
|
export let onContextMenu
|
|
export let onDrop
|
|
|
|
function iconKind(node) {
|
|
if (node.type === 'client' || node.template_id === 'client.default') return 'client'
|
|
if (node.type === 'project' || node.template_id === 'project.default') return 'project'
|
|
if (node.type === 'document' || node.template_id === 'document.default') return 'document'
|
|
if (node.type === 'recipe' || node.template_id === 'recipe.default') return 'recipe'
|
|
if (node.type === 'folder' || node.template_id === 'folder.default') return 'folder'
|
|
if (node.type === 'case') return 'case'
|
|
if (node.type === 'note') return 'note'
|
|
if (node.type === 'file') return 'file'
|
|
return 'generic'
|
|
}
|
|
|
|
function isContainer(node) {
|
|
return ['folder', 'project', 'client', 'document', 'recipe', 'case'].includes(node.type)
|
|
}
|
|
|
|
function hasLoadedChildren(node) {
|
|
return node.children !== undefined
|
|
}
|
|
|
|
function canDrop(target, draggedId) {
|
|
if (!target || !draggedId) return false
|
|
if (draggedId === target.id) return false
|
|
if (!isContainer(target)) return false
|
|
if (isDescendant(target, draggedId)) return false
|
|
return true
|
|
}
|
|
|
|
function isDescendant(node, ancestorId) {
|
|
if (!node.children) return false
|
|
for (const child of node.children) {
|
|
if (child.id === ancestorId) return true
|
|
if (isDescendant(child, ancestorId)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function getDraggedId(e) {
|
|
try { return e.dataTransfer.getData('text/plain') } catch { return '' }
|
|
}
|
|
|
|
function handleDragStart(e, node) {
|
|
e.stopPropagation()
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
e.dataTransfer.setData('text/plain', node.id)
|
|
}
|
|
|
|
function handleDragOver(e, node) {
|
|
const id = getDraggedId(e)
|
|
if (!canDrop(node, id)) return
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
}
|
|
|
|
function handleDrop(e, node) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const draggedId = getDraggedId(e)
|
|
if (!canDrop(node, draggedId)) return
|
|
if (onDrop) onDrop(draggedId, node.id)
|
|
}
|
|
</script>
|
|
|
|
{#each nodes as node}
|
|
<div class="tree-item"
|
|
class:selected={selectedNodeId === node.id}
|
|
style="padding-left: {level * 16 + 4}px"
|
|
draggable="true"
|
|
on:dragstart={(e) => handleDragStart(e, node)}
|
|
on:dragover={(e) => handleDragOver(e, node)}
|
|
on:drop={(e) => handleDrop(e, node)}
|
|
on:contextmenu|preventDefault={(e) => onContextMenu && onContextMenu(e, node)}>
|
|
{#if isContainer(node)}
|
|
<button class="tree-toggle" on:click|stopPropagation={() => onToggle && onToggle(node.id)}
|
|
on:contextmenu|preventDefault={(e) => e.stopPropagation()}>
|
|
{#if expanded[node.id]}
|
|
<span class="tree-arrow">▾</span>
|
|
{:else}
|
|
<span class="tree-arrow">▸</span>
|
|
{/if}
|
|
</button>
|
|
{:else}
|
|
<span class="tree-toggle-placeholder"></span>
|
|
{/if}
|
|
<span class="tree-icon"><TemplateIcon kind={iconKind(node)} size={16} /></span>
|
|
<span class="tree-label" on:click|stopPropagation={() => onSelect && onSelect(node)}
|
|
on:contextmenu|preventDefault={(e) => e.stopPropagation()}>
|
|
{node.title}
|
|
</span>
|
|
</div>
|
|
{#if expanded[node.id] && hasLoadedChildren(node)}
|
|
<svelte:self nodes={node.children || []} {expanded} {selectedNodeId} level={level + 1}
|
|
{onSelect} {onToggle} {onContextMenu} {onDrop} />
|
|
{/if}
|
|
{/each}
|
|
|
|
<style>
|
|
.tree-item {
|
|
display: flex;
|
|
align-items: center;
|
|
height: 32px;
|
|
padding-right: 8px;
|
|
cursor: default;
|
|
font-size: 13px;
|
|
color: #ccc;
|
|
user-select: none;
|
|
}
|
|
.tree-item:hover {
|
|
background: #222233;
|
|
}
|
|
.tree-item.selected {
|
|
background: #2a2a4a;
|
|
color: #fff;
|
|
font-weight: 500;
|
|
}
|
|
.tree-toggle {
|
|
background: none;
|
|
border: none;
|
|
color: #666;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
width: 20px;
|
|
height: 32px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
font-family: inherit;
|
|
line-height: 1;
|
|
font-size: 12px;
|
|
}
|
|
.tree-toggle:hover {
|
|
color: #888;
|
|
}
|
|
.tree-toggle-placeholder {
|
|
display: inline-block;
|
|
width: 20px;
|
|
flex-shrink: 0;
|
|
}
|
|
.tree-icon {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 32px;
|
|
flex-shrink: 0;
|
|
color: #888;
|
|
margin-right: 4px;
|
|
}
|
|
.tree-item.selected .tree-icon {
|
|
color: #a5b4fc;
|
|
}
|
|
.tree-label {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
line-height: 32px;
|
|
}
|
|
</style>
|