- 前端
- 桌面应用
- AI 应用
- MCP 服务
【免费下载链接】open-pencil
AI-native design editor. Open-source Figma alternative.
useLayerDrag是 open-pencil Vue SDK 中负责图层树(Layer Tree)拖拽交互的底层接线工具,它基于 pragmatic-drag-and-drop 把"拖到哪、怎么放"解析为具体的编辑器重排指令。本文以 packages/docs/fr/programmable/sdk/api/advanced/use-layer-drag.md 为骨架,结合 useLayerDrag.ts 源码与核心编辑器重排实现,带你掌握如何在自定义图层树中接入拖拽重排、跨容器移动和自动展开等能力。
一、useLayerDrag 是什么
useLayerDrag(options)为图层树的每一行提供拖拽所需的状态与动作。它计算三个关键信息:
- 拖放目标(drop target):指针当前悬停在哪一行上;
- 插入位置(position):落在目标行的"之前 / 内部 / 之后"(before / inside / after);
- 变更有效性(validity):该次拖放是否合法,例如不能把容器拖入自身的后代。
同时它负责把拖拽结果转换为editor的重排操作。值得注意的是,当一个对象跨容器移动时,其视觉位置会被保留——这是由底层reorderChildWithUndo实现的,稍后详述。
在 API 层级上,useLayerDrag属于 "Navigation & editing" 一组的进阶 API(见 packages/docs/fr/programmable/sdk/api/advanced/index.md),服务于那些"标准组件无法满足定制需求"的场景。
二、签名与返回值
函数签名
export function useLayerDrag( editor: Editor, indentPerLevel = 16, onMakeChildDrop?: (targetId: string) => void )参数说明:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
editor | Editor | — | 来自@open-pencil/core/editor的编辑器实例,提供graph、state与重排操作 |
indentPerLevel | number | 16 | 每一层缩进的像素数,用于计算命中区域的宽度,决定"上方 / 内部 / 下方"判定 |
onMakeChildDrop | (targetId: string) => void | — | 当元素被放成某容器子节点时回调(可用来展开该容器) |
返回值
{ draggingId: Ref<string | null> // 当前正在拖拽的行 id instruction: Ref<TreeInstruction | null> // 当前的放置指令 instructionTargetId: Ref<string | null> // 指令作用的目标行 id setupItem: (el: Ref<HTMLElement | null>, item: () => DragItem) => void // 把某行接上拖拽/放置能力 }其中TreeInstruction(即LayerDragInstruction)只有三种取值,定义于 packages/vue/src/primitives/LayerTree/context.ts:
export interface LayerDragInstruction { type: 'reorder-above' | 'reorder-below' | 'make-child' }DragItem是行数据的形状:
interface DragItem { id: string level: number // 当前层级,供缩进命中计算 hasChildren: boolean // 是否有子节点,决定 expanded / standard 模式 parentId: string | null }三、内部工作流:从拖拽到重排
整个流程由setupItem注册的若干 pragmatic-drag-and-drop 适配器,以及一个全局monitorForElements共同完成。
1. 行级接线:setupItem
setupItem(el, item)接收目标 DOM 元素与一个返回行数据的函数,通过watchEffect在元素可用时自动接线(useLayerDrag.ts):
function setupItem(el: Ref<HTMLElement | null>, item: () => DragItem) { watchEffect((onCleanup) => { const element = el.value if (!element) return const data = item() const isContainer = editor.graph.isContainer(data.id) const mode: ItemMode = data.hasChildren ? 'expanded' : 'standard' // ... draggable + dropTargetForElements onCleanup(cleanup) }) }draggable:让行可被拖起,getInitialData提供源节点 id,拖起/落下时维护draggingId;dropTargetForElements:让行可作为放置目标,其getData用attachInstruction根据指针位置、缩进宽度(indentPerLevel)、当前层级与模式计算放置指令。
放置模式与合法性由block数组控制,这是本工具最关键的一处策略:
block: isContainer ? ['reparent'] : ['make-child', 'reparent']含义是:
- 目标是容器时,允许把它作为"父容器"放子节点进去,但禁止把别的节点"重定位"到它之上/之下(
reparent被 block); - 目标是普通叶子节点时,既不允许做成它的子节点,也不允许跨级重定位,只能"插到它上方或下方"。
同时canDrop禁止拖到自身:
canDrop: ({ source }) => source.data.id !== data.idonDrag中通过extractInstruction取出指令,若为'instruction-blocked'(被 block 的非法操作)则清空指令与目标。getIsSticky: () => true保证拖拽悬停时目标行保持粘性。
2. 全局落点解析:monitorForElements
拖放结束时,全局 monitor 统一处理(useLayerDrag.ts):
const cleanupMonitor = monitorForElements({ onDrop: ({ source, location }) => { const target = location.current.dropTargets.at(0) if (!target) return // 解析 sourceId / targetId / rawInstruction ... if (editor.graph.isDescendant(targetId, sourceId)) return // 防拖入自身后代 const targetParentId = targetNode.parentId ?? editor.state.currentPageId 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 { const container = editor.graph.getNode(targetId) if (!container || !editor.graph.isContainer(targetId)) return editor.reorderChildWithUndo(sourceId, targetId, container.childIds.length) onMakeChildDrop?.(targetId) } } }) onScopeDispose(cleanupMonitor)三种指令的落点换算:
| 指令 | 换算后的重排调用 |
|---|---|
reorder-above | 插入到目标行在其父节点的当前位置(targetIndex) |
reorder-below | 插入到目标行之后(targetIndex + 1) |
make-child | 插入到目标容器的末尾(childIds.length),并触发onMakeChildDrop回调 |
两条安全校验值得注意:
editor.graph.isDescendant(targetId, sourceId)直接拦截"把节点拖进自己或自己后代"的非法操作;make-child分支再次用isContainer复核目标确实是容器。
四、视觉位置保留的底层原理
文档强调"对象更换容器时视觉位置被保留",这是核心编辑器层reorderChildWithUndo的职责,实现在 packages/core/src/editor/structure/reorder.ts:
function reorderChildWithUndo(nodeId: string, newParentId: string, insertIndex: number) { assertNodeEditable(ctx.graph, nodeId) assertNodeEditable(ctx.graph, newParentId) const node = ctx.graph.getNode(nodeId) if (!node) return const origParentId = node.parentId ?? ctx.state.currentPageId const origIndex = ctx.graph.getNode(origParentId)?.childIds.indexOf(nodeId) ?? 0 const origX = node.x const origY = node.y ctx.graph.reorderChild(nodeId, newParentId, insertIndex) ctx.runLayoutForNode(newParentId) if (origParentId !== newParentId) ctx.runLayoutForNode(origParentId) // ... 压入 undo 栈,forward/inverse 分别记录重做与撤销 }关键点:
- 它记录
origParentId、origIndex、origX、origY,并连同forward/inverse一起压入撤销栈,因此每次拖拽重排都可撤销; - 同时注意同文件中的
doReorderChild使用了getAbsolutePosition差值换算坐标,而reorderInAutoLayout专门处理自动布局(layoutMode !== 'NONE')父容器内的重排。useLayerDrag统一走reorderChildWithUndo,由内部runLayoutForNode负责新旧父容器的布局重算,从而维持画布上视觉位置的连续性与稳定性。
五、与 LayerTreeRoot / useLayerTree 的配合
useLayerDrag不是孤立 API,它被 LayerTreeRoot.vue 直接消费,并与上下文 API 深度绑定。
1. LayerTreeRoot 中的使用
LayerTreeRoot是 SDK 的无头结构原语(headless structural primitive),负责渲染图层树并提供树模型、展开状态、选择/可见性/锁定/重命名等交互接线,同时把useLayerDrag的结果注入上下文:
const { draggingId, instruction, instructionTargetId, setupItem } = useLayerDrag( editor, indentPerLevel, expandNode // 作为 onMakeChildDrop:拖成子节点时自动展开容器 )注意这里第三个参数传的是expandNode——当节点被拖成某容器的子节点时,该容器会被自动展开,这正是文档所说onMakeChildDrop回调的典型用途。
随后整个树在编辑器事件驱动下增量重建:
const unsubscribe = [ editor.onEditorEvent('graph:replaced', rebuildTree), editor.onEditorEvent('page:changed', rebuildTree), editor.onEditorEvent('node:created', scheduleTreeRebuild), editor.onEditorEvent('node:deleted', scheduleTreeRebuild), editor.onEditorEvent('node:reparented', scheduleTreeRebuild), editor.onEditorEvent('node:reordered', scheduleTreeRebuild), editor.onEditorEvent('node:updated', patchTreeNode), editor.onEditorEvent('selection:changed', onSelectionChanged) ]node:reparented与node:reordered都会触发树的微任务级重建(scheduleTreeRebuild合并同一轮改动),保证拖拽导致的层级变化立即反映在界面上。
2. 通过上下文提供
LayerTreeRoot把setupDrag: setupItem、draggingId、instruction、instructionTargetId等一并provideLayerTree(...)进上下文(见 context.ts 中的LayerTreeContext接口)。自定义行组件内部可用useLayerTree()(对应 use-layer-tree 文档)读取这些状态,并调用ctx.setupDrag(el, item)完成行级接线:
const ctx = useLayerTree() ctx.setupDrag(rowEl, () => ({ id: node.id, level: row.level, hasChildren: row.hasChildren, parentId: node.parentId }))3. 与 LayerTreeItem 的分工
- LayerTreeRoot:提供树结构与交互接线的无头根组件;
- LayerTreeItem:渲染单行并暴露选择、展开、可见性、锁定、重命名处理器(通过默认插槽);
useLayerDrag:提供拖拽状态与指令解析;useLayerTree:读取根组件注入的上下文。
因此,自定义拖拽 UI 的推荐做法是:在LayerTreeRoot的默认插槽内渲染自有行结构,行上调用setupItem(或上下文setupDrag)接线,拖拽状态从draggingId/instruction/instructionTargetId读取以驱动高亮与占位样式。
六、在自定义图层树中接入拖拽:最小示例
假设你要替换默认图层树行的拖拽样式,自定义行组件可以这样写:
<script setup lang="ts"> import { useLayerTree } from '#vue/primitives/LayerTree/context' const props = defineProps<{ nodeId: string; level: number; hasChildren: boolean }>() const ctx = useLayerTree() const rowEl = ref<HTMLElement | null>(null) ctx.setupDrag(rowEl, () => ({ id: props.nodeId, level: props.level, hasChildren: props.hasChildren, parentId: ctx.editor.graph.getNode(props.nodeId)?.parentId ?? null })) </script> <template> <div ref="rowEl" :class="{ 'drag-source': ctx.draggingId === nodeId, 'drop-above': ctx.instruction?.type === 'reorder-above' && ctx.instructionTargetId === nodeId, 'drop-below': ctx.instruction?.type === 'reorder-below' && ctx.instructionTargetId === nodeId, 'drop-inside': ctx.instruction?.type === 'make-child' && ctx.instructionTargetId === nodeId }" > <slot /> </div> </template>要点:
- 必须处于
LayerTreeRoot内部(否则useLayerTree会抛出'[open-pencil] useLayerTree() called outside <LayerTreeRoot>',见 context.ts); setupDrag的item回调应返回实时的id / level / hasChildren / parentId,拖拽命中计算依赖level与缩进宽度;- 用
draggingId标记正在拖拽的源行,用instruction+instructionTargetId组合渲染"上方 / 下方 / 内部"三种放置指示器; - 若希望在拖成子节点时展开目标容器,把展开函数作为
useLayerDrag的onMakeChildDrop传入(LayerTreeRoot正是这样做的)。
七、注意事项与最佳实践
- 合法性与自防呆:
block策略与canDrop(禁止拖到自身)、isDescendant(禁止拖入自身后代)三重防护共同保证了非法放置不会落库; - 缩进宽度影响判定:
indentPerLevel决定"above / inside / below"三种区域划分的宽度,默认 16px,若你的行高或缩进不同,应显式传入匹配值; - 可撤销:所有落点最终都进入
reorderChildWithUndo的撤销栈(reorder.ts),无需在 UI 层额外处理 undo; - 事件驱动刷新:拖拽引起的结构变化会触发
node:reparented/node:reordered事件,由LayerTreeRoot增量重建树,无需手动刷新; - 生命周期:
setupItem在元素卸载时通过onCleanup(cleanup)释放监听,全局 monitor 通过onScopeDispose清理,避免内存泄漏。
八、相关 API 导航
- LayerTreeRoot:无头树结构原语,负责上下文注入与模型构建;
- LayerTreeItem:单行渲染原语,暴露选择/展开/可见性/锁定/重命名处理器;
- useLayerTree:读取
LayerTreeRoot注入的上下文; - 源码:useLayerDrag.ts、LayerTreeRoot.vue、context.ts、reorder.ts。
- 前端
- 桌面应用
- AI 应用
- MCP 服务
【免费下载链接】open-pencil
AI-native design editor. Open-source Figma alternative.
相关推荐
Open-Pencil 图层树拖拽排序:useLayerDrag 组合式 API 原理与二次开发指南
Open Pencil 图层树拖拽排序:useLayerDrag 组合式 API 原理与二次开发指南 useLayerDrag 是 Open Pencil 设计
前端桌面应用AI 应用MCP 服务Open Pencil useLayerDrag 解析:把 pragmatic-drag-and-drop 拖放指令翻译成图层重排操作
Open Pencil useLayerDrag 解析:把 pragmatic drag and drop 拖放指令翻译成图层重排操作 useLayerDrag
前端桌面应用AI 应用MCP 服务Draggable 实战指南:使用 Sortable 实现列表拖拽排序与跨容器重排
Draggable 实战指南:使用 Sortable 实现列表拖拽排序与跨容器重排 本篇技术指南聚焦 Shopify Draggable 项目(仓库路径 gh_
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考