verstak/frontend/src/TreeNode.svelte

371 lines
10 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 = undefined
export let onToggle = undefined
export let onContextMenu = undefined
export let onDrop = undefined
let autoExpandTimers = {}
let scrollInterval = null
let draggedNodeId = ''
let dragOverNodeId = ''
const CONTAINER_TYPES = ['folder', 'project', 'client', 'document', 'recipe', 'case']
import { onDestroy } from 'svelte'
onDestroy(() => {
for (const key of Object.keys(autoExpandTimers)) {
clearTimeout(autoExpandTimers[key])
}
if (scrollInterval) clearInterval(scrollInterval)
})
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 CONTAINER_TYPES.includes(node.type)
}
function hasLoadedChildren(node) {
return node.children !== undefined
}
function shouldShowToggle(node) {
if (hasLoadedChildren(node)) {
return node.children.length > 0
}
return node.has_children === true
}
// Build a flat parent map by walking the full tree recursively.
// Key: node.id, Value: parent node id (or '' for roots)
function buildParentMap(allNodes) {
const map = {}
function walk(list, parentId) {
for (const n of list) {
map[n.id] = parentId
if (n.children) {
walk(n.children, n.id)
}
}
}
walk(allNodes, '')
return map
}
// Check if dropping draggedId onto target is allowed.
// Uses a parent map to detect cycles: walk from target up;
// if we hit draggedId, it would be a cycle.
function canDrop(target, draggedId, parentMap) {
if (!target || !draggedId) return false
if (draggedId === target.id) return false
if (!isContainer(target)) return false
// Walk from target up the parent chain; if we reach draggedId, reject (cycle).
let cur = target.id
while (cur) {
if (cur === draggedId) return false
cur = parentMap[cur] || ''
}
return true
}
// Flatten all nodes recursively for parent map construction
function flattenTree(list) {
const result = []
function walk(items) {
for (const n of items) {
result.push(n)
if (n.children) {
walk(n.children)
}
}
}
walk(list)
return result
}
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)
draggedNodeId = node.id
}
function handleDragOver(e, node) {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
if (dragOverNodeId !== node.id) {
dragOverNodeId = node.id
}
if (shouldShowToggle(node) && !expanded[node.id] && !autoExpandTimers[node.id]) {
autoExpandTimers[node.id] = setTimeout(() => {
if (onToggle) onToggle(node.id)
delete autoExpandTimers[node.id]
}, 600)
}
const area = e.currentTarget.closest('.workspace-tree-area')
if (area) {
const areaRect = area.getBoundingClientRect()
const threshold = 30
if (e.clientY - areaRect.top < threshold) {
if (!scrollInterval) scrollInterval = setInterval(() => { area.scrollTop -= 10 }, 50)
} else if (areaRect.bottom - e.clientY < threshold) {
if (!scrollInterval) scrollInterval = setInterval(() => { area.scrollTop += 10 }, 50)
} else {
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null }
}
}
}
function handleDragLeave(e, node) {
// Only clear if we're actually leaving this row, not moving to a child element
const related = e.relatedTarget
if (related && e.currentTarget.contains(related)) {
return
}
if (dragOverNodeId === node.id) {
dragOverNodeId = ''
}
if (autoExpandTimers[node.id]) {
clearTimeout(autoExpandTimers[node.id])
delete autoExpandTimers[node.id]
}
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null }
}
function handleDrop(e, node) {
e.preventDefault()
e.stopPropagation()
if (autoExpandTimers[node.id]) {
clearTimeout(autoExpandTimers[node.id])
delete autoExpandTimers[node.id]
}
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null }
const allNodes = flattenTree(nodes)
const parentMap = buildParentMap(allNodes)
const id = getDraggedId(e)
if (!canDrop(node, id, parentMap)) {
dragOverNodeId = ''
draggedNodeId = ''
return
}
if (onDrop) onDrop(id, node.id)
dragOverNodeId = ''
draggedNodeId = ''
}
function handleDragEnd() {
dragOverNodeId = ''
draggedNodeId = ''
for (const key of Object.keys(autoExpandTimers)) {
clearTimeout(autoExpandTimers[key])
delete autoExpandTimers[key]
}
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null }
}
function handleRowClick(e, node) {
if (e.target.closest('.tree-toggle') || e.target.closest('.tree-icon')) return
if (onSelect) onSelect(node)
}
function handleRowDblClick(e, node) {
if (shouldShowToggle(node) && onToggle) onToggle(node.id)
}
function handleRowKeydown(e, node) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (onSelect) onSelect(node)
}
}
function handleIconClick(e, node) {
e.stopPropagation()
if (shouldShowToggle(node) && onToggle) onToggle(node.id)
}
function handleIconKeydown(e, node) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
if (shouldShowToggle(node) && onToggle) onToggle(node.id)
}
}
function handleLabelKeydown(e, node) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (onSelect) onSelect(node)
}
}
function computeDropInfo(allNodes, draggedId, pMap) {
const info = {}
function walk(list) {
for (const n of list) {
info[n.id] = canDrop(n, draggedId, pMap)
if (n.children) walk(n.children)
}
}
walk(allNodes)
return info
}
$: flatNodes = flattenTree(nodes)
$: parentMap = buildParentMap(flatNodes)
$: dropAllowed = computeDropInfo(flatNodes, draggedNodeId, parentMap)
</script>
<svelte:window on:dragend={handleDragEnd}/>
{#each nodes as node (node.id)}
<div class="tree-item"
class:selected={selectedNodeId === node.id}
class:drop-valid={dragOverNodeId === node.id && dropAllowed[node.id]}
class:drop-invalid={dragOverNodeId === node.id && !dropAllowed[node.id]}
style="padding-left: {level * 16 + 4}px"
draggable="true"
role="button"
tabindex="0"
on:dragstart={(e) => handleDragStart(e, node)}
on:dragover={(e) => handleDragOver(e, node)}
on:dragleave={(e) => handleDragLeave(e, node)}
on:drop={(e) => handleDrop(e, node)}
on:click={(e) => handleRowClick(e, node)}
on:keydown={(e) => handleRowKeydown(e, node)}
on:dblclick={(e) => handleRowDblClick(e, node)}
on:contextmenu|preventDefault={(e) => onContextMenu && onContextMenu(e, node)}>
{#if shouldShowToggle(node)}
<button class="tree-toggle" on:click|stopPropagation={() => onToggle && onToggle(node.id)}>
<span class="tree-arrow">{expanded[node.id] ? '▾' : '▸'}</span>
</button>
{:else}
<span class="tree-toggle-placeholder"></span>
{/if}
<span class="tree-icon" role="button" tabindex="-1"
on:click={(e) => handleIconClick(e, node)}
on:keydown={(e) => handleIconKeydown(e, node)}
on:dblclick|stopPropagation>
<TemplateIcon kind={iconKind(node)} size={16} />
</span>
<span class="tree-label" role="button" tabindex="-1"
on:click|stopPropagation={() => onSelect && onSelect(node)}
on:keydown={(e) => handleLabelKeydown(e, node)}>
{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-item.drop-valid {
background: #1a3a1a;
outline: 1px solid #4ade80;
outline-offset: -1px;
}
.tree-item.drop-invalid {
background: #3a1a1a;
outline: 1px solid #ff6b6b;
outline-offset: -1px;
}
.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: #a5b4fc;
}
.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;
cursor: pointer;
}
.tree-icon:hover {
color: #a5b4fc;
}
.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>