371 lines
10 KiB
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>
|