Layers panel: Reka UI Tree with expand/collapse

- TreeRoot/TreeItem from reka-ui for proper tree structure
- Click arrow to expand/collapse frames with children
- Leaf nodes (no children) show no arrow
- Drag reorder preserved with drop indicator
- Bigger arrow icons (text-xs) with hover highlight
This commit is contained in:
Danila Poyarkov 2026-02-28 01:01:30 +03:00
parent 2abd50733f
commit 9afabfe009

View file

@ -1,11 +1,19 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useEventListener } from '@vueuse/core'
import { TreeRoot, TreeItem } from 'reka-ui'
import { useEditorStore } from '../stores/editor'
const store = useEditorStore()
interface LayerNode {
id: string
name: string
type: string
children?: LayerNode[]
}
function nodeIcon(type: string) {
switch (type) {
case 'ELLIPSE':
@ -23,9 +31,47 @@ function nodeIcon(type: string) {
}
}
function hasChildren(nodeId: string) {
const node = store.graph.getNode(nodeId)
return node ? node.childIds.length > 0 : false
function buildTree(parentId: string): LayerNode[] {
const parent = store.graph.getNode(parentId)
if (!parent) return []
const result: LayerNode[] = []
for (const cid of parent.childIds) {
const node = store.graph.getNode(cid)
if (!node) continue
result.push({
id: node.id,
name: node.name,
type: node.type,
children: node.childIds.length > 0 ? buildTree(node.id) : undefined,
})
}
return result
}
const items = computed(() => {
void store.state.renderVersion
return buildTree(store.graph.rootId)
})
const expanded = ref<string[]>([])
function onSelect(ev: CustomEvent) {
ev.preventDefault()
const node = ev.detail.value as LayerNode
if (ev.detail.originalEvent?.shiftKey) {
store.select([node.id], true)
} else {
store.select([node.id])
}
}
function toggleExpand(id: string) {
const idx = expanded.value.indexOf(id)
if (idx >= 0) {
expanded.value = expanded.value.filter(e => e !== id)
} else {
expanded.value = [...expanded.value, id]
}
}
const listRef = ref<HTMLElement | null>(null)
@ -36,8 +82,6 @@ const indicatorDepth = ref(0)
const dropTarget = ref<{ parentId: string; index: number } | null>(null)
const dropIntoId = ref<string | null>(null)
const tree = computed(() => store.layerTree.value)
let stopMove: (() => void) | undefined
let stopUp: (() => void) | undefined
let dragStartY = 0
@ -58,8 +102,7 @@ function onPointerDown(e: PointerEvent, nodeId: string) {
stopUp = useEventListener(document, 'pointerup', () => {
if (didMove && dropTarget.value && dragNodeId.value) {
const { parentId, index } = dropTarget.value
const node = store.graph.getNode(dragNodeId.value)
if (node && parentId !== dragNodeId.value && !store.graph.isDescendant(parentId, dragNodeId.value)) {
if (parentId !== dragNodeId.value && !store.graph.isDescendant(parentId, dragNodeId.value)) {
store.graph.reorderChild(dragNodeId.value, parentId, index)
store.requestRender()
}
@ -104,33 +147,30 @@ function updateDropTarget(ev: PointerEvent) {
const rowNode = store.graph.getNode(rowId)
if (!rowNode) continue
// Drop into a container (frame/group) — middle zone
if (mouseY > topZone && mouseY < bottomZone && store.graph.isContainer(rowId)) {
bestInto = { nodeId: rowId }
bestInsertBefore = null
break
}
// Drop above this row
if (mouseY <= rowMid) {
const parentId = rowNode.parentId ?? store.graph.rootId
const parent = store.graph.getNode(parentId)
if (parent) {
const idx = parent.childIds.indexOf(rowId)
const treeItem = tree.value.find(t => t.node.id === rowId)
bestInsertBefore = { parentId, index: Math.max(0, idx), y: rect.top - listRect.top + list.scrollTop, depth: treeItem?.depth ?? 0 }
const level = parseInt(row.dataset.level ?? '0')
bestInsertBefore = { parentId, index: Math.max(0, idx), y: rect.top - listRect.top + list.scrollTop, depth: level }
}
break
}
// If last row and mouse is below mid
if (i === rows.length - 1 && mouseY > rowMid) {
const parentId = rowNode.parentId ?? store.graph.rootId
const parent = store.graph.getNode(parentId)
if (parent) {
const idx = parent.childIds.indexOf(rowId)
const treeItem = tree.value.find(t => t.node.id === rowId)
bestInsertBefore = { parentId, index: idx + 1, y: rect.bottom - listRect.top + list.scrollTop, depth: treeItem?.depth ?? 0 }
const level = parseInt(row.dataset.level ?? '0')
bestInsertBefore = { parentId, index: idx + 1, y: rect.bottom - listRect.top + list.scrollTop, depth: level }
}
}
}
@ -157,25 +197,50 @@ function updateDropTarget(ev: PointerEvent) {
<aside class="flex w-60 flex-col overflow-y-auto border-r border-border bg-panel">
<header class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted">Layers</header>
<div ref="listRef" class="relative flex-1 overflow-y-auto px-1">
<button
v-for="{ node, depth } in tree"
:key="node.id"
:data-node-id="node.id"
class="flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 text-left text-xs"
:class="[
store.state.selectedIds.has(node.id)
? 'bg-accent text-white'
: 'bg-transparent text-surface hover:bg-hover',
dragging && dragNodeId === node.id ? 'opacity-30' : '',
dropIntoId === node.id ? 'ring-2 ring-accent ring-inset' : ''
]"
:style="{ paddingLeft: `${8 + depth * 16}px` }"
@pointerdown.prevent="onPointerDown($event, node.id)"
<TreeRoot
v-slot="{ flattenItems }"
:items="items"
:get-key="(v: LayerNode) => v.id"
:get-children="(v: LayerNode) => v.children"
v-model:expanded="expanded"
>
<span v-if="hasChildren(node.id)" class="w-3 shrink-0 text-center text-[10px] opacity-50">▾</span>
<span class="w-3.5 shrink-0 text-center text-[11px] opacity-70">{{ nodeIcon(node.type) }}</span>
<span class="truncate">{{ node.name }}</span>
</button>
<div
v-for="item in flattenItems"
:key="item._id"
:data-node-id="item.value.id"
:data-level="item.level"
>
<TreeItem
v-slot="{ isExpanded }"
v-bind="item.bind"
as-child
@select="onSelect"
>
<button
class="flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 text-left text-xs"
:class="[
store.state.selectedIds.has(item.value.id)
? 'bg-accent text-white'
: 'bg-transparent text-surface hover:bg-hover',
dragging && dragNodeId === item.value.id ? 'opacity-30' : '',
dropIntoId === item.value.id ? 'ring-2 ring-accent ring-inset' : ''
]"
:style="{ paddingLeft: `${8 + (item.level - 1) * 16}px` }"
@pointerdown.prevent="onPointerDown($event, item.value.id)"
>
<span
v-if="item.hasChildren"
class="flex w-4 shrink-0 cursor-pointer items-center justify-center text-xs text-muted transition-transform hover:text-surface"
:class="isExpanded ? '' : '-rotate-90'"
@click.stop="toggleExpand(item.value.id)"
>▾</span>
<span v-else class="w-4 shrink-0" />
<span class="w-3.5 shrink-0 text-center text-[11px] opacity-70">{{ nodeIcon(item.value.type) }}</span>
<span class="truncate">{{ item.value.name }}</span>
</button>
</TreeItem>
</div>
</TreeRoot>
<!-- Drop indicator line -->
<div