Refactor LayerTree: Atlaskit DnD, split LayerRow, shared icons

Replace manual pointer-capture drag with @atlaskit/pragmatic-drag-and-drop:
- Tree-aware hitbox (reorder-above, reorder-below, make-child)
- Native drag preview via Atlaskit adapter
- No more manual DOM measurement for drop zones

Split into focused files:
- LayerTree.vue (164 lines) — tree data, expand, selection
- LayerRow.vue (175 lines) — per-row: icon, rename, lock/visibility, DnD setup
- utils/layer-icons.ts (43 lines) — icon maps shared across components
- use-layer-drag.ts (127 lines) — Atlaskit DnD composable in SDK

Add toggleNodeVisibility/toggleNodeLock per-node methods to editor.
This commit is contained in:
Danila Poyarkov 2026-03-17 08:46:23 +03:00
parent f084212ce8
commit 492d9dfa75
9 changed files with 478 additions and 415 deletions

View file

@ -10,6 +10,8 @@
"@ai-sdk/openai": "^3.0.37",
"@ai-sdk/valibot": "^2.0.17",
"@ai-sdk/vue": "^3.0.105",
"@atlaskit/pragmatic-drag-and-drop": "^1.7.9",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@open-pencil/cli": "workspace:*",
"@open-pencil/core": "workspace:*",
"@open-pencil/vue": "workspace:*",
@ -216,6 +218,10 @@
"@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.6", "", { "dependencies": { "json-schema": "^0.4.0", "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA=="],
"@atlaskit/pragmatic-drag-and-drop": ["@atlaskit/pragmatic-drag-and-drop@1.7.9", "", { "dependencies": { "@babel/runtime": "^7.0.0", "bind-event-listener": "^3.0.0", "raf-schd": "^4.0.3" } }, "sha512-m/bcw5flyjfcF/rdX4JeomtIBrWuDNOwcQieiywHv7zkfIRmUC34Q9ZLeNGVoz73UiGsRqxysMuw4tC7lSJ89g=="],
"@atlaskit/pragmatic-drag-and-drop-hitbox": ["@atlaskit/pragmatic-drag-and-drop-hitbox@1.1.0", "", { "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.6.0", "@babel/runtime": "^7.0.0" } }, "sha512-JWt6eVp6Br2FPHRM8s0dUIHQk/jFInGP1f3ti5CdtM1Ji5/pt8Akm44wDC063Gv2i5RGseixtbW0z/t6RYtbdg=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
@ -1214,6 +1220,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"bind-event-listener": ["bind-event-listener@3.0.0", "", {}, "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q=="],
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="],
@ -2118,6 +2126,8 @@
"race-signal": ["race-signal@1.1.3", "", {}, "sha512-Mt2NznMgepLfORijhQMncE26IhkmjEphig+/1fKC0OtaKwys/gpvpmswSjoN01SS+VO951mj0L4VIDXdXsjnfA=="],
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],

View file

@ -34,6 +34,8 @@
"@ai-sdk/openai": "^3.0.37",
"@ai-sdk/valibot": "^2.0.17",
"@ai-sdk/vue": "^3.0.105",
"@atlaskit/pragmatic-drag-and-drop": "^1.7.9",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@open-pencil/cli": "workspace:*",
"@open-pencil/core": "workspace:*",
"@open-pencil/vue": "workspace:*",

View file

@ -345,20 +345,24 @@ export function createStructureActions(ctx: EditorContext) {
ctx.requestRender()
}
function toggleNodeVisibility(id: string) {
const node = ctx.graph.getNode(id)
if (!node) return
ctx.graph.updateNode(id, { visible: !node.visible })
}
function toggleNodeLock(id: string) {
const node = ctx.graph.getNode(id)
if (!node) return
ctx.graph.updateNode(id, { locked: !node.locked })
}
function toggleVisibility() {
for (const id of ctx.state.selectedIds) {
const node = ctx.graph.getNode(id)
if (!node) continue
ctx.graph.updateNode(id, { visible: !node.visible })
}
for (const id of ctx.state.selectedIds) toggleNodeVisibility(id)
}
function toggleLock() {
for (const id of ctx.state.selectedIds) {
const node = ctx.graph.getNode(id)
if (!node) continue
ctx.graph.updateNode(id, { locked: !node.locked })
}
for (const id of ctx.state.selectedIds) toggleNodeLock(id)
}
function moveToPage(pageId: string) {
@ -386,6 +390,8 @@ export function createStructureActions(ctx: EditorContext) {
ungroupSelected,
bringToFront,
sendToBack,
toggleNodeVisibility,
toggleNodeLock,
toggleVisibility,
toggleLock,
moveToPage,

View file

@ -0,0 +1,127 @@
import { ref, watchEffect, type Ref } from 'vue'
import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
import {
attachInstruction,
extractInstruction,
type Instruction,
type ItemMode
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
import type { Editor } from '@open-pencil/core/editor'
interface DragItem {
id: string
level: number
hasChildren: boolean
parentId: string | null
}
type TreeInstruction = Extract<
Instruction,
{ type: 'reorder-above' | 'reorder-below' | 'make-child' }
>
export function useLayerDrag(
editor: Editor,
indentPerLevel = 16
) {
const draggingId = ref<string | null>(null)
const instruction = ref<TreeInstruction | null>(null)
const instructionTargetId = ref<string | null>(null)
function setupItem(
el: Ref<HTMLElement | null>,
item: () => DragItem
) {
watchEffect((onCleanup) => {
const element = el.value
if (!element) return
const data = item()
const mode: ItemMode = data.hasChildren
? 'expanded'
: 'standard'
const cleanup = combine(
draggable({
element,
getInitialData: () => ({ id: data.id }),
onDragStart: () => { draggingId.value = data.id },
onDrop: () => { draggingId.value = null }
}),
dropTargetForElements({
element,
getData: ({ input, element: el }) =>
attachInstruction({ id: data.id }, {
input,
element: el,
indentPerLevel,
currentLevel: data.level,
mode,
block: []
}),
canDrop: ({ source }) => source.data.id !== data.id,
onDrag: ({ self }) => {
const inst = extractInstruction(self.data) as TreeInstruction | null
instruction.value = inst
instructionTargetId.value = inst ? data.id : null
},
onDragLeave: () => {
instruction.value = null
instructionTargetId.value = null
},
onDrop: () => {
instruction.value = null
instructionTargetId.value = null
},
getIsSticky: () => true
})
)
onCleanup(cleanup)
})
}
monitorForElements({
onDrop: ({ source, location }) => {
const target = location.current.dropTargets[0]
if (!target) return
const sourceId = source.data.id as string
const targetId = target.data.id as string
const inst = extractInstruction(target.data) as TreeInstruction | null
if (!inst || !sourceId || !targetId) return
if (editor.graph.isDescendant(targetId, sourceId)) return
const targetNode = editor.graph.getNode(targetId)
if (!targetNode) return
const targetParentId = targetNode.parentId ?? editor.state.currentPageId
const targetParent = editor.graph.getNode(targetParentId)
if (!targetParent) return
const targetIndex = targetParent.childIds.indexOf(targetId)
if (inst.type === 'reorder-above') {
editor.reorderChildWithUndo(sourceId, targetParentId, targetIndex)
} else if (inst.type === 'reorder-below') {
editor.reorderChildWithUndo(sourceId, targetParentId, targetIndex + 1)
} else if (inst.type === 'make-child') {
const container = editor.graph.getNode(targetId)
editor.reorderChildWithUndo(sourceId, targetId, container?.childIds.length ?? 0)
}
draggingId.value = null
instruction.value = null
instructionTargetId.value = null
}
})
return {
draggingId,
instruction,
instructionTargetId,
setupItem
}
}

View file

@ -12,6 +12,7 @@ export { useCanvasDrop, extractImageFilesFromClipboard } from './composables/use
export { useNodeProps, useNodeProps as useMultiProps, MIXED } from './composables/use-node-props'
export type { MixedValue } from './composables/use-node-props'
export { useInlineRename } from './composables/use-inline-rename'
export { useLayerDrag } from './composables/use-layer-drag'
export { useNodeFontStatus } from './composables/use-font-status'
export { toast } from './toast'
export type { Toast, ToastVariant } from './toast'

View file

@ -71,10 +71,7 @@ function onContextMenu(e: MouseEvent) {
class="pointer-events-none absolute inset-0 z-40 border-2 border-dashed border-accent/60 bg-accent/5"
/>
</Transition>
<Transition
leave-active-class="transition-opacity duration-300"
leave-to-class="opacity-0"
>
<Transition leave-active-class="transition-opacity duration-300" leave-to-class="opacity-0">
<div
v-if="store.state.loading"
data-test-id="canvas-loading"

220
src/components/LayerRow.vue Normal file
View file

@ -0,0 +1,220 @@
<script setup lang="ts">
import { ref, type Component, type Ref } from 'vue'
import { TreeItem } from 'reka-ui'
import { nodeIcon, COMPONENT_TYPES } from '@/utils/layer-icons'
import type { EditorStore } from '@/stores/editor'
import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
interface LayerNode {
id: string
name: string
type: string
layoutMode: string
visible: boolean
locked: boolean
}
type TreeInstruction = Extract<
Instruction,
{ type: 'reorder-above' | 'reorder-below' | 'make-child' }
>
const {
itemValue,
level,
hasChildren,
rename,
store,
draggingId,
instruction,
instructionTargetId,
indentPerLevel,
setupItem
} = defineProps<{
itemValue: LayerNode
level: number
hasChildren: boolean
rename: {
editingId: Ref<string | null>
focusInput: (el: HTMLInputElement | null) => void
start: (id: string, name: string) => void
commit: (id: string, el: HTMLInputElement) => void
onKeydown: (e: KeyboardEvent) => void
}
store: EditorStore
draggingId: string | null
instruction: TreeInstruction | null
instructionTargetId: string | null
indentPerLevel: number
setupItem: (
el: Ref<HTMLElement | null>,
item: () => { id: string; level: number; hasChildren: boolean; parentId: string | null }
) => void
}>()
const emit = defineEmits<{
select: [ev: CustomEvent]
toggleExpand: [id: string]
}>()
const rowEl = ref<HTMLElement | null>(null)
setupItem(rowEl, () => ({
id: itemValue.id,
level,
hasChildren,
parentId: store.graph.getNode(itemValue.id)?.parentId ?? null
}))
defineExpose({ rowEl })
const icon = nodeIcon(itemValue) as Component
const isComponent = COMPONENT_TYPES.has(itemValue.type)
const isTarget = () => instructionTargetId === itemValue.id
const padLeft = `${8 + (level - 1) * indentPerLevel}px`
</script>
<template>
<div ref="rowEl" :data-node-id="itemValue.id">
<TreeItem
v-slot="{ isExpanded }"
:value="itemValue"
:level="level"
as-child
@select="emit('select', $event)"
@toggle="
(e: CustomEvent) => {
if (e.detail.originalEvent?.type === 'click') e.preventDefault()
}
"
>
<!-- Rename input -->
<div
v-if="rename.editingId.value === itemValue.id"
class="flex w-full items-center gap-1 py-1"
:style="{ paddingLeft: padLeft }"
>
<span
v-if="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="emit('toggleExpand', itemValue.id)"
>
<icon-lucide-chevron-right class="size-3" />
</span>
<span v-else class="w-4 shrink-0" />
<component :is="icon" class="size-3 shrink-0 opacity-70" />
<input
:ref="
(el) => {
if (el) rename.focusInput(el as HTMLInputElement)
}
"
data-layer-edit
data-test-id="layers-item-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="itemValue.name"
@blur="rename.commit(itemValue.id, $event.target as HTMLInputElement)"
@keydown="rename.onKeydown"
/>
</div>
<!-- Normal row -->
<button
v-else
data-test-id="layers-item"
class="group/row relative flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 pr-1 text-left text-xs"
:class="[
store.state.selectedIds.has(itemValue.id)
? 'bg-accent text-white'
: 'bg-transparent text-surface hover:bg-hover',
draggingId === itemValue.id ? 'opacity-30' : '',
isTarget() && instruction?.type === 'make-child' ? 'ring-2 ring-accent ring-inset' : '',
!itemValue.visible ? 'opacity-50' : ''
]"
:style="{ paddingLeft: padLeft }"
@dblclick="rename.start(itemValue.id, itemValue.name)"
>
<span
v-if="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="emit('toggleExpand', itemValue.id)"
>
<icon-lucide-chevron-right class="size-3" />
</span>
<span v-else class="w-4 shrink-0" />
<component
:is="icon"
class="size-3 shrink-0"
:class="isComponent ? 'text-component opacity-100' : 'opacity-70'"
/>
<span class="min-w-0 flex-1 truncate">{{ itemValue.name }}</span>
<!-- Lock + visibility -->
<span
class="flex shrink-0 items-center gap-0.5"
:class="
!itemValue.locked && itemValue.visible ? 'opacity-0 group-hover/row:opacity-100' : ''
"
>
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
:title="itemValue.locked ? 'Unlock' : 'Lock'"
@pointerdown.stop
@click.stop="store.toggleNodeLock(itemValue.id)"
>
<icon-lucide-lock
v-if="itemValue.locked"
class="size-3"
:class="store.state.selectedIds.has(itemValue.id) ? 'text-white' : 'text-surface'"
/>
<icon-lucide-unlock
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="
store.state.selectedIds.has(itemValue.id) ? 'text-white/80' : 'text-surface/70'
"
/>
</span>
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
:title="itemValue.visible ? 'Hide' : 'Show'"
@pointerdown.stop
@click.stop="store.toggleNodeVisibility(itemValue.id)"
>
<icon-lucide-eye-off
v-if="!itemValue.visible"
class="size-3"
:class="store.state.selectedIds.has(itemValue.id) ? 'text-white' : 'text-surface'"
/>
<icon-lucide-eye
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="
store.state.selectedIds.has(itemValue.id) ? 'text-white/80' : 'text-surface/70'
"
/>
</span>
</span>
<!-- Drop indicators -->
<div
v-if="isTarget() && instruction && instruction.type !== 'make-child'"
class="pointer-events-none absolute h-0.5 bg-accent"
:class="{
'bottom-0': instruction.type === 'reorder-below',
'top-0': instruction.type === 'reorder-above'
}"
:style="{
left: `${(level - 1) * indentPerLevel}px`,
width: `calc(100% - ${(level - 1) * indentPerLevel}px)`
}"
/>
</button>
</TreeItem>
</div>
</template>

View file

@ -1,31 +1,25 @@
<script setup lang="ts">
import { nextTick, onUnmounted, ref, watch } from 'vue'
import { useEventListener } from '@vueuse/core'
import { TreeRoot, TreeItem, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
import { nextTick, ref, watch } from 'vue'
import { TreeRoot, ContextMenuRoot, ContextMenuTrigger, ContextMenuPortal } from 'reka-ui'
import { useInlineRename } from '@open-pencil/vue'
import IconCircle from '~icons/lucide/circle'
import IconComponent from '~icons/lucide/diamond'
import IconComponentSet from '~icons/lucide/component'
import IconColumns from '~icons/lucide/columns-3'
import IconFrame from '~icons/lucide/frame'
import IconGrid from '~icons/lucide/grid-3x3'
import IconGroup from '~icons/lucide/group'
import IconMinus from '~icons/lucide/minus'
import IconPenTool from '~icons/lucide/pen-tool'
import IconRows from '~icons/lucide/rows-3'
import IconSection from '~icons/lucide/layout-grid'
import IconSquare from '~icons/lucide/square'
import IconType from '~icons/lucide/type'
import { useInlineRename, useLayerDrag } from '@open-pencil/vue'
import { useEditorStore } from '@/stores/editor'
import LayerRow from './LayerRow.vue'
import NodeContextMenuContent from './NodeContextMenuContent.vue'
const INDENT_PER_LEVEL = 16
const store = useEditorStore()
const rename = useInlineRename((id, name) => store.renameNode(id, name))
const { draggingId, instruction, instructionTargetId, setupItem } = useLayerDrag(
store,
INDENT_PER_LEVEL
)
// Layer node type
interface LayerNode {
id: string
name: string
@ -36,54 +30,7 @@ interface LayerNode {
children?: LayerNode[]
}
const nodeIcons: Partial<Record<string, typeof IconSquare>> = {
SECTION: IconSection,
ELLIPSE: IconCircle,
FRAME: IconFrame,
GROUP: IconGroup,
COMPONENT: IconComponent,
COMPONENT_SET: IconComponentSet,
INSTANCE: IconComponent,
LINE: IconMinus,
TEXT: IconType,
VECTOR: IconPenTool,
RECTANGLE: IconSquare
}
const autoLayoutIcons: Partial<Record<string, typeof IconSquare>> = {
VERTICAL: IconRows,
HORIZONTAL: IconColumns,
GRID: IconGrid
}
function nodeIcon(node: LayerNode) {
if (node.type === 'FRAME' && node.layoutMode !== 'NONE') {
return autoLayoutIcons[node.layoutMode] ?? IconFrame
}
return nodeIcons[node.type] ?? IconSquare
}
const COMPONENT_TYPES = new Set(['COMPONENT', 'COMPONENT_SET', 'INSTANCE'])
function toggleNodeVisibility(id: string) {
const node = store.graph.getNode(id)
if (node)
store.updateNodeWithUndo(
id,
{ visible: !node.visible },
node.visible ? 'Hide layer' : 'Show layer'
)
}
function toggleNodeLock(id: string) {
const node = store.graph.getNode(id)
if (node)
store.updateNodeWithUndo(
id,
{ locked: !node.locked },
node.locked ? 'Unlock layer' : 'Lock layer'
)
}
// Tree data
function buildTree(parentId: string): LayerNode[] {
const parent = store.graph.getNode(parentId)
@ -104,13 +51,19 @@ function buildTree(parentId: string): LayerNode[] {
const items = ref(buildTree(store.state.currentPageId))
const treeKey = ref(0)
const expanded = ref<string[]>([])
watch([() => store.state.sceneVersion, () => store.state.currentPageId], () => {
items.value = buildTree(store.state.currentPageId)
treeKey.value++
})
const expanded = ref<string[]>([])
const rowRefs = new Map<string, HTMLElement>()
function setRowRef(id: string, el: HTMLElement | null) {
if (el) rowRefs.set(id, el)
else rowRefs.delete(id)
}
watch(
() => store.state.selectedIds,
@ -123,18 +76,27 @@ watch(
node = store.graph.getNode(node.parentId)
}
}
if (toExpand.size > expanded.value.length) {
expanded.value = [...toExpand]
}
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' })
if (first) rowRefs.get(first)?.scrollIntoView({ block: 'nearest' })
})
}
)
// Selection
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])
syncCanvasScope(node.id)
}
}
function syncCanvasScope(nodeId: string) {
const node = store.graph.getNode(nodeId)
if (!node) return
@ -150,356 +112,51 @@ function syncCanvasScope(nodeId: string) {
store.state.enteredContainerId = null
}
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])
syncCanvasScope(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])
if (!row?.dataset.nodeId) return
if (!store.state.selectedIds.has(row.dataset.nodeId)) {
store.select([row.dataset.nodeId])
}
}
function toggleExpand(id: string) {
const idx = expanded.value.indexOf(id)
if (idx !== -1) {
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
onUnmounted(() => {
stopMove?.()
stopUp?.()
})
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.reorderChildWithUndo(dragNodeId.value, parentId, index)
}
} 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?.()
}
interface InsertPosition {
parentId: string
index: number
y: number
depth: number
}
function resolveInsertPosition(
row: HTMLElement,
rowId: string,
rect: DOMRect,
listRect: DOMRect,
scrollTop: number,
edge: 'before' | 'after'
): InsertPosition | null {
const rowNode = store.graph.getNode(rowId)
if (!rowNode) return null
const parentId = rowNode.parentId ?? store.state.currentPageId
const parent = store.graph.getNode(parentId)
if (!parent) return null
const idx = parent.childIds.indexOf(rowId)
const level = parseInt(row.dataset.level ?? '0', 10)
if (edge === 'before') {
return {
parentId,
index: Math.max(0, idx),
y: rect.top - listRect.top + scrollTop,
depth: level
}
}
return {
parentId,
index: idx + 1,
y: rect.bottom - listRect.top + scrollTop,
depth: level
}
}
type DropZone = 'top' | 'mid-container' | 'bottom'
function classifyDropZone(mouseY: number, rect: DOMRect, isContainer: boolean): DropZone {
const topZone = rect.top + rect.height * 0.25
const bottomZone = rect.top + rect.height * 0.75
if (mouseY > topZone && mouseY < bottomZone && isContainer) return 'mid-container'
if (mouseY <= rect.top + rect.height / 2) return 'top'
return 'bottom'
}
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: InsertPosition | 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 || rowId === dragNodeId.value) continue
const rowNode = store.graph.getNode(rowId)
if (!rowNode) continue
const rect = row.getBoundingClientRect()
const zone = classifyDropZone(mouseY, rect, store.graph.isContainer(rowId))
if (zone === 'mid-container') {
bestInto = { nodeId: rowId }
bestInsertBefore = null
break
}
if (zone === 'top') {
bestInsertBefore = resolveInsertPosition(row, rowId, rect, listRect, list.scrollTop, 'before')
break
}
if (i === rows.length - 1) {
bestInsertBefore = resolveInsertPosition(row, rowId, rect, listRect, list.scrollTop, 'after')
}
}
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
}
if (idx !== -1) expanded.value = expanded.value.filter((e) => e !== id)
else expanded.value = [...expanded.value, id]
}
</script>
<template>
<ContextMenuRoot :modal="false">
<ContextMenuTrigger as-child @contextmenu="onLayerRightClick">
<div ref="listRef" class="relative scrollbar-thin flex-1 overflow-y-auto px-1">
<div class="relative scrollbar-thin flex-1 overflow-y-auto px-1">
<TreeRoot
:key="treeKey"
v-slot="{ flattenItems }"
v-model:expanded="expanded"
:items="items"
:get-key="(v: LayerNode) => v.id"
:get-children="(v: LayerNode) => v.children"
v-model:expanded="expanded"
>
<div
<LayerRow
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"
@toggle="
(e: CustomEvent) => {
if (e.detail.originalEvent?.type === 'click') e.preventDefault()
}
"
>
<div
v-if="rename.editingId.value === item.value.id"
class="flex w-full items-center gap-1 py-1"
:style="{ paddingLeft: `${8 + (item.level - 1) * 16}px` }"
>
<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="nodeIcon(item.value)" class="size-3 shrink-0 opacity-70" />
<input
:ref="
(el) => {
if (el) rename.focusInput(el as HTMLInputElement)
}
"
data-layer-edit
data-test-id="layers-item-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none"
:value="item.value.name"
@blur="rename.commit(item.value.id, $event.target as HTMLInputElement)"
@keydown="rename.onKeydown"
/>
</div>
<button
v-else
data-test-id="layers-item"
class="group/row flex w-full cursor-pointer items-center gap-1 rounded border-none py-1 pr-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)"
@dblclick="rename.start(item.value.id, item.value.name)"
>
<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="nodeIcon(item.value)"
class="size-3 shrink-0"
:class="
COMPONENT_TYPES.has(item.value.type)
? 'text-component opacity-100'
: 'opacity-70'
"
/>
<span class="min-w-0 flex-1 truncate">{{ item.value.name }}</span>
<span
class="flex shrink-0 items-center gap-0.5"
:class="
!item.value.locked && item.value.visible
? 'opacity-0 group-hover/row:opacity-100'
: ''
"
>
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
:title="item.value.locked ? 'Unlock' : 'Lock'"
@pointerdown.stop
@click.stop="toggleNodeLock(item.value.id)"
>
<icon-lucide-lock
v-if="item.value.locked"
class="size-3"
:class="
store.state.selectedIds.has(item.value.id) ? 'text-white' : 'text-surface'
"
/>
<icon-lucide-unlock
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="
store.state.selectedIds.has(item.value.id)
? 'text-white/80'
: 'text-surface/70'
"
/>
</span>
<span
class="flex size-4 items-center justify-center rounded hover:bg-white/15"
:title="item.value.visible ? 'Hide' : 'Show'"
@pointerdown.stop
@click.stop="toggleNodeVisibility(item.value.id)"
>
<icon-lucide-eye-off
v-if="!item.value.visible"
class="size-3"
:class="
store.state.selectedIds.has(item.value.id) ? 'text-white' : 'text-surface'
"
/>
<icon-lucide-eye
v-else
class="size-3 opacity-0 group-hover/row:opacity-100"
:class="
store.state.selectedIds.has(item.value.id)
? 'text-white/80'
: 'text-surface/70'
"
/>
</span>
</span>
</button>
</TreeItem>
</div>
:ref="(comp: any) => setRowRef(item.value.id, comp?.rowEl ?? null)"
:item-value="item.value"
:level="item.level"
:has-children="item.hasChildren"
:rename="rename"
:store="store"
:dragging-id="draggingId"
:instruction="instruction"
:instruction-target-id="instructionTargetId"
:indent-per-level="INDENT_PER_LEVEL"
:setup-item="setupItem"
@select="onSelect"
@toggle-expand="toggleExpand"
/>
</TreeRoot>
<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>

43
src/utils/layer-icons.ts Normal file
View file

@ -0,0 +1,43 @@
import IconCircle from '~icons/lucide/circle'
import IconColumns from '~icons/lucide/columns-3'
import IconComponentSet from '~icons/lucide/component'
import IconComponent from '~icons/lucide/diamond'
import IconFrame from '~icons/lucide/frame'
import IconGrid from '~icons/lucide/grid-3x3'
import IconGroup from '~icons/lucide/group'
import IconSection from '~icons/lucide/layout-grid'
import IconMinus from '~icons/lucide/minus'
import IconPenTool from '~icons/lucide/pen-tool'
import IconRows from '~icons/lucide/rows-3'
import IconSquare from '~icons/lucide/square'
import IconType from '~icons/lucide/type'
export const NODE_ICONS: Partial<Record<string, typeof IconSquare>> = {
SECTION: IconSection,
ELLIPSE: IconCircle,
FRAME: IconFrame,
GROUP: IconGroup,
COMPONENT: IconComponent,
COMPONENT_SET: IconComponentSet,
INSTANCE: IconComponent,
LINE: IconMinus,
TEXT: IconType,
VECTOR: IconPenTool,
RECTANGLE: IconSquare
}
export const AUTO_LAYOUT_ICONS: Partial<Record<string, typeof IconSquare>> = {
VERTICAL: IconRows,
HORIZONTAL: IconColumns,
GRID: IconGrid
}
export const COMPONENT_TYPES = new Set(['COMPONENT', 'COMPONENT_SET', 'INSTANCE'])
export { IconFrame, IconSquare }
export function nodeIcon(node: { type: string; layoutMode: string }) {
if (node.type === 'FRAME' && node.layoutMode !== 'NONE')
return AUTO_LAYOUT_ICONS[node.layoutMode] ?? IconFrame
return NODE_ICONS[node.type] ?? IconSquare
}