From 492d9dfa75b424f74e7596c8b0edb40fab8fe7c3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 17 Mar 2026 08:46:23 +0300 Subject: [PATCH] Refactor LayerTree: Atlaskit DnD, split LayerRow, shared icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bun.lock | 10 + package.json | 2 + packages/core/src/editor/structure.ts | 26 +- .../vue/src/composables/use-layer-drag.ts | 127 +++++ packages/vue/src/index.ts | 1 + src/components/EditorCanvas.vue | 5 +- src/components/LayerRow.vue | 220 +++++++++ src/components/LayerTree.vue | 459 +++--------------- src/utils/layer-icons.ts | 43 ++ 9 files changed, 478 insertions(+), 415 deletions(-) create mode 100644 packages/vue/src/composables/use-layer-drag.ts create mode 100644 src/components/LayerRow.vue create mode 100644 src/utils/layer-icons.ts diff --git a/bun.lock b/bun.lock index 400d83a2d..f7480ea53 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index f03ceb06c..a2c1f08a3 100644 --- a/package.json +++ b/package.json @@ -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:*", diff --git a/packages/core/src/editor/structure.ts b/packages/core/src/editor/structure.ts index cddb523b4..2588fa47c 100644 --- a/packages/core/src/editor/structure.ts +++ b/packages/core/src/editor/structure.ts @@ -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, diff --git a/packages/vue/src/composables/use-layer-drag.ts b/packages/vue/src/composables/use-layer-drag.ts new file mode 100644 index 000000000..75f460165 --- /dev/null +++ b/packages/vue/src/composables/use-layer-drag.ts @@ -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(null) + const instruction = ref(null) + const instructionTargetId = ref(null) + + function setupItem( + el: Ref, + 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 + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index bc3cdba53..701b27e01 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -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' diff --git a/src/components/EditorCanvas.vue b/src/components/EditorCanvas.vue index fa037094d..556288b35 100644 --- a/src/components/EditorCanvas.vue +++ b/src/components/EditorCanvas.vue @@ -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" /> - +
+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 + 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, + item: () => { id: string; level: number; hasChildren: boolean; parentId: string | null } + ) => void +}>() + +const emit = defineEmits<{ + select: [ev: CustomEvent] + toggleExpand: [id: string] +}>() + +const rowEl = ref(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` + + + diff --git a/src/components/LayerTree.vue b/src/components/LayerTree.vue index 91bc0809a..0b64904de 100644 --- a/src/components/LayerTree.vue +++ b/src/components/LayerTree.vue @@ -1,31 +1,25 @@