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:
parent
f084212ce8
commit
492d9dfa75
10
bun.lock
10
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
127
packages/vue/src/composables/use-layer-drag.ts
Normal file
127
packages/vue/src/composables/use-layer-drag.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
220
src/components/LayerRow.vue
Normal 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>
|
||||
|
|
@ -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
43
src/utils/layer-icons.ts
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue