369 lines
12 KiB
Vue
369 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, nextTick } from 'vue'
|
|
import { useEventListener } from '@vueuse/core'
|
|
import { TreeRoot, TreeItem, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
|
|
|
|
import NodeContextMenuContent from './NodeContextMenuContent.vue'
|
|
|
|
import IconCircle from '~icons/lucide/circle'
|
|
import IconComponent from '~icons/lucide/diamond'
|
|
import IconComponentSet from '~icons/lucide/component'
|
|
import IconFrame from '~icons/lucide/frame'
|
|
import IconGroup from '~icons/lucide/group'
|
|
import IconInstance from '~icons/lucide/diamond'
|
|
import IconMinus from '~icons/lucide/minus'
|
|
import IconPenTool from '~icons/lucide/pen-tool'
|
|
import IconSection from '~icons/lucide/layout-grid'
|
|
import IconSquare from '~icons/lucide/square'
|
|
import IconType from '~icons/lucide/type'
|
|
|
|
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
|
|
|
|
import AppMenu from './AppMenu.vue'
|
|
import PagesPanel from './PagesPanel.vue'
|
|
import { useEditorStore } from '@/stores/editor'
|
|
|
|
const store = useEditorStore()
|
|
|
|
interface LayerNode {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
visible: boolean
|
|
children?: LayerNode[]
|
|
}
|
|
|
|
const nodeIcons: Record<string, typeof IconSquare> = {
|
|
SECTION: IconSection,
|
|
ELLIPSE: IconCircle,
|
|
FRAME: IconFrame,
|
|
GROUP: IconGroup,
|
|
COMPONENT: IconComponent,
|
|
COMPONENT_SET: IconComponentSet,
|
|
INSTANCE: IconInstance,
|
|
LINE: IconMinus,
|
|
TEXT: IconType,
|
|
VECTOR: IconPenTool,
|
|
RECTANGLE: IconSquare
|
|
}
|
|
|
|
const COMPONENT_TYPES = new Set(['COMPONENT', 'COMPONENT_SET', 'INSTANCE'])
|
|
|
|
function buildTree(parentId: string): LayerNode[] {
|
|
const parent = store.graph.getNode(parentId)
|
|
if (!parent) return []
|
|
return parent.childIds
|
|
.map((cid) => store.graph.getNode(cid))
|
|
.filter((n): n is NonNullable<typeof n> => !!n)
|
|
.map((node) => ({
|
|
id: node.id,
|
|
name: node.name,
|
|
type: node.type,
|
|
visible: node.visible,
|
|
children: node.childIds.length > 0 ? buildTree(node.id) : undefined
|
|
}))
|
|
}
|
|
|
|
const items = ref(buildTree(store.state.currentPageId))
|
|
const treeKey = ref(0)
|
|
|
|
watch([() => store.state.sceneVersion, () => store.state.currentPageId], () => {
|
|
items.value = buildTree(store.state.currentPageId)
|
|
treeKey.value++
|
|
})
|
|
|
|
const expanded = ref<string[]>([])
|
|
|
|
watch(
|
|
() => store.state.selectedIds,
|
|
(ids) => {
|
|
const toExpand = new Set(expanded.value)
|
|
for (const id of ids) {
|
|
let node = store.graph.getNode(id)
|
|
while (node?.parentId && node.parentId !== store.state.currentPageId) {
|
|
toExpand.add(node.parentId)
|
|
node = store.graph.getNode(node.parentId)
|
|
}
|
|
}
|
|
if (toExpand.size > expanded.value.length) {
|
|
expanded.value = [...toExpand]
|
|
}
|
|
nextTick(() => {
|
|
const first = [...ids][0]
|
|
if (!first) return
|
|
const el = listRef.value?.querySelector<HTMLElement>(`[data-node-id="${first}"]`)
|
|
el?.scrollIntoView({ block: 'nearest' })
|
|
})
|
|
}
|
|
)
|
|
|
|
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 onLayerRightClick(e: MouseEvent) {
|
|
const row = (e.target as HTMLElement).closest<HTMLElement>('[data-node-id]')
|
|
if (!row) return
|
|
const nodeId = row.dataset.nodeId
|
|
if (!nodeId) return
|
|
if (!store.state.selectedIds.has(nodeId)) {
|
|
store.select([nodeId])
|
|
}
|
|
}
|
|
|
|
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)
|
|
const dragging = ref(false)
|
|
const dragNodeId = ref<string | null>(null)
|
|
const indicatorY = ref(-1)
|
|
const indicatorDepth = ref(0)
|
|
const dropTarget = ref<{ parentId: string; index: number } | null>(null)
|
|
const dropIntoId = ref<string | null>(null)
|
|
|
|
let stopMove: (() => void) | undefined
|
|
let stopUp: (() => void) | undefined
|
|
let dragStartY = 0
|
|
let didMove = false
|
|
|
|
function onPointerDown(e: PointerEvent, nodeId: string) {
|
|
dragStartY = e.clientY
|
|
didMove = false
|
|
dragNodeId.value = nodeId
|
|
|
|
stopMove = useEventListener(document, 'pointermove', (ev: PointerEvent) => {
|
|
if (!didMove && Math.abs(ev.clientY - dragStartY) < 4) return
|
|
didMove = true
|
|
dragging.value = true
|
|
updateDropTarget(ev)
|
|
})
|
|
|
|
stopUp = useEventListener(document, 'pointerup', () => {
|
|
if (didMove && dropTarget.value && dragNodeId.value) {
|
|
const { parentId, index } = dropTarget.value
|
|
if (parentId !== dragNodeId.value && !store.graph.isDescendant(parentId, dragNodeId.value)) {
|
|
store.graph.reorderChild(dragNodeId.value, parentId, index)
|
|
store.requestRender()
|
|
}
|
|
} else if (!didMove && dragNodeId.value) {
|
|
store.select([dragNodeId.value])
|
|
}
|
|
cleanup()
|
|
})
|
|
}
|
|
|
|
function cleanup() {
|
|
dragging.value = false
|
|
dragNodeId.value = null
|
|
indicatorY.value = -1
|
|
dropTarget.value = null
|
|
dropIntoId.value = null
|
|
stopMove?.()
|
|
stopUp?.()
|
|
}
|
|
|
|
function updateDropTarget(ev: PointerEvent) {
|
|
const list = listRef.value
|
|
if (!list || !dragNodeId.value) return
|
|
|
|
const rows = list.querySelectorAll<HTMLElement>('[data-node-id]')
|
|
const listRect = list.getBoundingClientRect()
|
|
const mouseY = ev.clientY
|
|
|
|
let bestInsertBefore: { parentId: string; index: number; y: number; depth: number } | null = null
|
|
let bestInto: { nodeId: string } | null = null
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows[i]
|
|
const rowId = row.dataset.nodeId
|
|
if (!rowId) continue
|
|
if (rowId === dragNodeId.value) continue
|
|
|
|
const rect = row.getBoundingClientRect()
|
|
const rowMid = rect.top + rect.height / 2
|
|
const topZone = rect.top + rect.height * 0.25
|
|
const bottomZone = rect.top + rect.height * 0.75
|
|
|
|
const rowNode = store.graph.getNode(rowId)
|
|
if (!rowNode) continue
|
|
|
|
if (mouseY > topZone && mouseY < bottomZone && store.graph.isContainer(rowId)) {
|
|
bestInto = { nodeId: rowId }
|
|
bestInsertBefore = null
|
|
break
|
|
}
|
|
|
|
if (mouseY <= rowMid) {
|
|
const parentId = rowNode.parentId ?? store.state.currentPageId
|
|
const parent = store.graph.getNode(parentId)
|
|
if (parent) {
|
|
const idx = parent.childIds.indexOf(rowId)
|
|
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 (i === rows.length - 1 && mouseY > rowMid) {
|
|
const parentId = rowNode.parentId ?? store.state.currentPageId
|
|
const parent = store.graph.getNode(parentId)
|
|
if (parent) {
|
|
const idx = parent.childIds.indexOf(rowId)
|
|
const level = parseInt(row.dataset.level ?? '0')
|
|
bestInsertBefore = {
|
|
parentId,
|
|
index: idx + 1,
|
|
y: rect.bottom - listRect.top + list.scrollTop,
|
|
depth: level
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bestInto) {
|
|
dropIntoId.value = bestInto.nodeId
|
|
indicatorY.value = -1
|
|
const container = store.graph.getNode(bestInto.nodeId)
|
|
dropTarget.value = container
|
|
? { parentId: bestInto.nodeId, index: container.childIds.length }
|
|
: null
|
|
} else if (bestInsertBefore) {
|
|
dropIntoId.value = null
|
|
indicatorY.value = bestInsertBefore.y
|
|
indicatorDepth.value = bestInsertBefore.depth
|
|
dropTarget.value = { parentId: bestInsertBefore.parentId, index: bestInsertBefore.index }
|
|
} else {
|
|
dropIntoId.value = null
|
|
indicatorY.value = -1
|
|
dropTarget.value = null
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<aside
|
|
data-test-id="layers-panel"
|
|
class="flex min-w-0 flex-1 flex-col overflow-hidden border-r border-border bg-panel"
|
|
style="contain: paint layout style"
|
|
>
|
|
<AppMenu />
|
|
<SplitterGroup direction="vertical" auto-save-id="layers-layout" class="flex-1 overflow-hidden">
|
|
<SplitterPanel
|
|
:default-size="30"
|
|
:min-size="10"
|
|
:max-size="60"
|
|
class="flex flex-col overflow-hidden"
|
|
>
|
|
<PagesPanel />
|
|
</SplitterPanel>
|
|
<SplitterResizeHandle class="group relative z-10 -my-1 h-2 cursor-row-resize">
|
|
<div
|
|
class="pointer-events-none absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-border"
|
|
/>
|
|
</SplitterResizeHandle>
|
|
<SplitterPanel :default-size="70" :min-size="20" class="flex flex-col overflow-hidden">
|
|
<header
|
|
data-test-id="layers-header"
|
|
class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted"
|
|
>
|
|
Layers
|
|
</header>
|
|
<ContextMenuRoot :modal="false">
|
|
<ContextMenuTrigger as-child @contextmenu="onLayerRightClick">
|
|
<div
|
|
ref="listRef"
|
|
data-test-id="layers-tree"
|
|
class="relative flex-1 overflow-y-auto px-1"
|
|
>
|
|
<TreeRoot
|
|
:key="treeKey"
|
|
v-slot="{ flattenItems }"
|
|
:items="items"
|
|
:get-key="(v: LayerNode) => v.id"
|
|
:get-children="(v: LayerNode) => v.children"
|
|
v-model:expanded="expanded"
|
|
>
|
|
<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
|
|
data-test-id="layers-item"
|
|
class="group/row 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' : '',
|
|
!item.value.visible ? 'opacity-50' : ''
|
|
]"
|
|
: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-muted transition-transform hover:text-surface"
|
|
:class="isExpanded ? 'rotate-90' : 'rotate-0'"
|
|
@click.stop="toggleExpand(item.value.id)"
|
|
>
|
|
<icon-lucide-chevron-right class="size-3" />
|
|
</span>
|
|
<span v-else class="w-4 shrink-0" />
|
|
<component
|
|
:is="nodeIcons[item.value.type] ?? IconSquare"
|
|
class="size-3 shrink-0"
|
|
:class="
|
|
COMPONENT_TYPES.has(item.value.type)
|
|
? 'text-[#9747ff] opacity-100'
|
|
: 'opacity-70'
|
|
"
|
|
/>
|
|
<span class="min-w-0 flex-1 truncate">{{ item.value.name }}</span>
|
|
<icon-lucide-eye-off
|
|
v-if="!item.value.visible"
|
|
class="mr-1 size-3 shrink-0 text-muted"
|
|
/>
|
|
</button>
|
|
</TreeItem>
|
|
</div>
|
|
</TreeRoot>
|
|
|
|
<!-- Drop indicator line -->
|
|
<div
|
|
v-if="dragging && indicatorY >= 0"
|
|
class="pointer-events-none absolute right-1 left-1 h-0.5 bg-accent"
|
|
:style="{ top: `${indicatorY}px`, marginLeft: `${indicatorDepth * 16}px` }"
|
|
/>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuPortal>
|
|
<NodeContextMenuContent />
|
|
</ContextMenuPortal>
|
|
</ContextMenuRoot>
|
|
</SplitterPanel>
|
|
</SplitterGroup>
|
|
</aside>
|
|
</template>
|