从零实现画布「双击创建节点」:一个 VueFlow 项目的交互全记录
做过画布类项目的朋友大概都写过"双击创建节点"这个功能。看起来就是绑个事件、往数组里 push 一个对象的事,但真做起来,坑比想象中多得多。
这篇博客不会先甩一堆概念,而是跟着代码一步步做------从绑定事件开始,遇到问题解决问题,最后把双击创建、右键菜单、键盘删除这一整套交互都串起来。项目用的是 Vue 3 + VueFlow + Pinia,但思路对任何画布框架都通用。
第一步:在模板上绑定双击事件
我们从最直觉的地方开始------给画布容器加一个双击监听:
html
<div ref="canvasShell" class="flow-canvas-shell"
@dblclick.capture="openStandaloneNodeMenuFromEvent">
<VueFlow
:zoom-on-double-click="false"
...
/>
</div>
两个地方值得注意:
.capture 修饰符 ------事件监听绑在捕获阶段而非冒泡阶段。VueFlow 内部会处理大量自己的双击逻辑(比如默认的双击缩放),如果我们等冒泡阶段才处理,VueFlow 可能已经"消费"了这个事件。用 .capture 可以在事件到达 VueFlow 之前拦截它。
:zoom-on-double-click="false"------既然我们自己接管了双击,就必须把 VueFlow 的默认双击缩放关掉。不关的话,双击一下既弹出菜单又缩放画布,体验很糟。
到这里,双击事件能触发了。接下来写处理函数。
第二步:写处理函数------先解决"点在哪"
用户双击了,我们得知道点在哪里,才能在正确的位置弹出菜单、创建节点。
先写个最简单的版本:
javascript
function openStandaloneNodeMenuFromEvent(event) {
// TODO: 获取点击位置
// TODO: 弹出菜单
// TODO: 用户选择类型后创建节点
}
点击位置可以直接从事件里拿 event.clientX 和 event.clientY,这就是鼠标在浏览器窗口中的像素坐标。看起来很够用。
但这里埋着一个大坑。我们稍后会回来解决它。先用屏幕坐标把流程跑通。
第三步:不是所有双击都该创建节点
在填充逻辑之前,我们得加一个关键的守卫判断------用户双击的地方,是不是画布的空白区域?
如果双击落在一个已有节点上,那应该触发的是"双击编辑节点"而不是"创建新节点"。双击落在连线上、控制按钮上、小地图上,也都不应该响应。
javascript
function isBlankCanvasEvent(event) {
const target = event?.target
// 事件目标必须在画布容器内
if (!target || !canvasShellRef.value?.contains(target)) return false
// 事件目标必须在画布空白区域(pane)上
if (!target.closest?.('.vue-flow__pane')) return false
// 排除节点、连线、手柄、控制面板、小地图、已有菜单
return !target.closest?.(
[
'.vue-flow__node',
'.vue-flow__edge',
'.vue-flow__handle',
'.vue-flow__controls',
'.vue-flow__minimap',
'.vue-flow__panel',
'.connection-node-menu',
'.canvas-context-menu',
].join(','),
)
}
为什么用 closest() 而不是直接看 target.className?因为 DOM 层级可能很深。用户双击的可能是节点内部的一个 <span> 标签,target 是这个 span,直接看 className 什么也看不出来。closest('.vue-flow__node') 会沿着 DOM 树向上找,找到就返回那个节点元素,找不到就返回 null。
把守卫加到处理函数开头:
javascript
function openStandaloneNodeMenuFromEvent(event) {
if (!isBlankCanvasEvent(event)) return // 不是空白画布,不管
event.preventDefault()
event.stopPropagation()
// 接下来:弹出菜单...
}
第四步:弹出节点类型选择菜单
双击空白处后,我们希望在这个位置弹出一个菜单让用户选类型:文本、图片、视频、音频。
菜单是一个 Vue 组件 ConnectionNodeMenu,用 position: fixed 定位,渲染在点击位置附近。
那么问题来了:菜单应该显示在哪个位置?
答案是:用屏幕坐标(clientX / clientY)。因为菜单是 position: fixed,它的定位就是相对于浏览器视口的,所以直接用鼠标位置就行。
但有一个细节------如果用户在屏幕右下角双击,菜单可能会超出视口边界,露出一截在外面。所以需要做一个边界钳制:
javascript
function getMenuScreenPosition(event, options = {}) {
const padding = 16
const menuWidth = options.width || 220
const menuHeight = options.height || 238
const offset = options.offset ?? 14
const clientX = Number(event?.clientX) || 0
const clientY = Number(event?.clientY) || 0
// 菜单不能超出视口,留出 padding 的安全边距
const maxX = Math.max(padding, window.innerWidth - menuWidth - padding)
const maxY = Math.max(padding, window.innerHeight - menuHeight - padding)
return {
x: Math.min(Math.max(clientX + offset, padding), maxX),
y: Math.min(Math.max(clientY + offset, padding), maxY),
}
}
逻辑很简单:算出菜单能放的最大 x 和 y(视口尺寸减去菜单尺寸再减去边距),然后用 Math.min / Math.max 把菜单位置夹在这个范围内。offset 是鼠标位置和菜单左上角之间的小偏移,避免菜单刚好挡在鼠标下面。
现在处理函数可以保存状态了:
javascript
function openStandaloneNodeMenuFromEvent(event) {
if (!isBlankCanvasEvent(event)) return
event.preventDefault()
event.stopPropagation()
standaloneNodeMenuState.value = {
position: getMenuScreenPosition(event, { width: 220, height: 196, offset: 10 }),
// 还需要保存一个东西,后面说...
}
}
模板那边根据 standaloneNodeMenuState 是否为 null 来决定是否渲染菜单:
html
<ConnectionNodeMenu
:visible="!!standaloneNodeMenuState"
:position="standaloneNodeMenuState?.position"
@select="createStandaloneNode"
@close="closeStandaloneNodeMenu"
/>
到这里,双击 → 弹出菜单的流程已经通了。
第五步:用户选了类型,在哪创建节点?
用户点击了"图片生成",触发 createStandaloneNode('image')。我们需要在画布上那个双击位置放一个节点。
问题来了:节点的 position 应该用什么坐标?
这里就是前面那个"大坑"浮出水面的时刻。
屏幕坐标为什么不行
VueFlow 中每个节点的 position: { x, y } 是画布坐标系下的值------也就是"世界坐标"。画布可以被用户平移和缩放,但节点的世界坐标不会因为视口变化而改变。
举个例子:画布上有个节点在 (500, 300),用户把画布往右拖了 200 像素,这个节点看起来在屏幕的 (700, 300) 位置了------但它的 position 还是 (500, 300),没变。
如果我们用 clientX = 700 去设置新节点的 position.x,那这个节点就会出现在世界坐标的 700 处,而不是用户点击的地方。用户平移得越远,偏差越大。缩放也是同理------2 倍缩放时,屏幕上 200 像素只对应画布坐标的 100。
所以我们需要把屏幕坐标转换成画布坐标。 在我的项目里,这个画布坐标叫 flowPoint。
坐标转换公式
javascript
function screenToFlowPoint(screenPoint, viewportState = viewport.value) {
const canvas = document.getElementById('flow-canvas')
const rect = canvas?.getBoundingClientRect()
const zoom = Number(viewportState?.zoom) || 1
const viewportX = Number(viewportState?.x) || 0
const viewportY = Number(viewportState?.y) || 0
return {
x: ((Number(screenPoint?.x) || 0) - (rect?.left || 0) - viewportX) / zoom,
y: ((Number(screenPoint?.y) || 0) - (rect?.top || 0) - viewportY) / zoom,
}
}
三步运算,每一步都有明确的物理含义:
-
减去
rect.left / rect.top:鼠标坐标是相对于浏览器窗口的,但画布元素本身可能不在(0, 0)------页面上方可能有导航栏,左侧可能有侧边栏。减去画布元素的getBoundingClientRect偏移,把坐标从"浏览器视口"转到"画布元素"。 -
减去
viewport.x / viewport.y:视口的x, y是画布被平移后的偏移量。减掉它就消除了平移的影响。 -
除以
zoom:缩放系数。2x 缩放时屏幕上 200 像素只对应画布坐标 100,除以 zoom 就还原了。
反过来也有 flowToScreenPoint,做逆运算,这里不展开了。
回到双击事件
在 openStandaloneNodeMenuFromEvent 里,除了保存菜单的屏幕位置,还需要把 flowPoint 一起保存下来------因为等用户选完类型后,我们要用这个 flowPoint 来决定节点放在哪。
javascript
function openStandaloneNodeMenuFromEvent(event) {
if (!isBlankCanvasEvent(event)) return
event.preventDefault()
event.stopPropagation()
// 从 DOM 事件中提取画布坐标
const sourceEvent = event?.event || event // VueFlow 的事件可能包了一层
const flowPoint = screenToFlowPoint({
x: Number(sourceEvent?.clientX) || 0,
y: Number(sourceEvent?.clientY) || 0,
})
standaloneNodeMenuState.value = {
flowPoint, // 画布坐标 → 给节点定位
position: getMenuScreenPosition(event, { width: 220, height: 196 }), // 屏幕坐标 → 给菜单定位
}
}
注意 event?.event || event 这个兼容处理。VueFlow 的事件回调有时候会把原生事件包在 event.event 里,如果直接读 event.clientX 可能拿到 undefined。
第六步:节点居中------一个容易忽略的细节
用户选了类型,现在执行 createStandaloneNode:
javascript
function createStandaloneNode(type) {
const state = standaloneNodeMenuState.value
if (!state) return
// 1. 拿到节点类型的默认尺寸
const nodeType = getNodeTypeFromOption(type) // 'image' → 'imageNode'
const size = getNodeSizeByType(nodeType) // → { width: 266, height: 440 }
// 2. 计算节点位置:让节点中心对齐点击位置
const position = createPositionFromPoint(state.flowPoint, size)
// 3. 创建节点
createNodeForOption(type, { position, select: true })
// 4. 关闭菜单
closeStandaloneNodeMenu()
}
第 2 步值得展开说。VueFlow 节点的 position 是其左上角 的坐标,而 flowPoint 是用户点击的位置。如果直接 position = flowPoint,节点会以"左上角对齐点击位置"的方式出现,视觉上偏向右下------这不太自然。
更好的做法是让节点居中出现在点击位置:
javascript
export function createPositionFromPoint(point, size) {
return {
x: Math.round((Number(point.x) || 0) - size.width / 2),
y: Math.round((Number(point.y) || 0) - size.height / 2),
}
}
减去宽高的一半,左上角就退到了点击位置的左上方,节点的中心刚好落在用户双击的点上。
第七步:在 Pinia Store 中创建节点
createNodeForOption 最终调用 store 的 action。以图片节点为例:
javascript
addImageNode(options = {}) {
// 1. 计算下一个可用的 ID 序号
this.imageNodeSeed = nextSeedFromNodes(this.nodes, 'image', this.imageNodeSeed)
const index = this.imageNodeSeed
this.imageNodeSeed += 1
// 2. 构造节点对象
const node = createImageNode(index, {
...options,
position: options.position || createCenteredPosition(this.viewport, IMAGE_NODE_SIZE),
})
if (options.position) {
node.position = normalizeNodePosition(options.position, index)
}
// 3. 加入画布
this.nodes.push(node)
// 4. 自动选中新节点
if (options.select !== false) {
this.setSelectedNodeId(node.id)
}
// 5. 触发保存
this.queuePersist()
return node
}
几个值得注意的设计:
ID 的唯一性保证 ------每种节点类型有独立的自增种子(imageNodeSeed、textNodeSeed 等),生成的 ID 形如 image-1、image-2。nextSeedFromNodes 会扫描现有节点中的最大序号取 +1,这样即使从后端加载了一个已有 image-5 的画布,下一个新建的图片节点也会是 image-6,不会冲突。
位置回退 ------如果调用时没传 position(比如从工具栏按钮创建,没有明确的点击位置),就自动把节点放在当前视口正中心:
javascript
export function createCenteredPosition(viewport, size) {
const { width, height } = getWindowSize()
const scale = viewport?.zoom || 1
const centerX = (width / 2 - (viewport?.x || 0)) / scale
const centerY = (height / 2 - (viewport?.y || 0)) / scale
return {
x: Math.round(centerX - size.width / 2),
y: Math.round(centerY - size.height / 2),
}
}
这里也用了一次坐标转换------窗口中心是屏幕坐标,需要除以缩放、减去视口偏移,转到画布坐标。
queuePersist() 做了什么------它同时记录了一条历史快照(用于撤销/重做),然后启动一个 600ms 的防抖定时器,到期后调用后端 API 保存整个画布快照。这意味着用户快速连续操作时不会每次都写数据库。
到这里,双击创建节点的完整链路已经跑通了
整理一下整个数据流:
css
用户双击画布空白处
│
├─ [捕获阶段] 触发 openStandaloneNodeMenuFromEvent
│ ├─ isBlankCanvasEvent → 确认点在 .vue-flow__pane 上
│ ├─ screenToFlowPoint → 屏幕坐标转画布坐标 flowPoint
│ ├─ getMenuScreenPosition → 屏幕坐标 + 边界钳制
│ └─ 保存 { flowPoint, position } 到 state
│
├─ ConnectionNodeMenu 渲染在 position 位置
│
└─ 用户选择类型 → createStandaloneNode
├─ getNodeSizeByType → 获取节点尺寸
├─ createPositionFromPoint → flowPoint 居中后得到 position
└─ store.addImageNode → push 到 nodes 数组 + 自动选中 + 防抖保存
接下来看右键菜单,它的模式和双击非常像,但功能更丰富。
第八步:右键菜单------画布上的快捷工具箱
右键菜单的入口和双击一样------也是在画布空白处触发,也需要 isBlankCanvasEvent 守卫,也需要同时保存 flowPoint 和屏幕位置。
VueFlow 提供了 @pane-context-menu 事件,我们用它来触发:
javascript
function handlePaneContextMenu(event) {
if (!isBlankCanvasEvent(event)) return
event.preventDefault() // 阻止浏览器默认右键菜单
event.stopPropagation()
setSelectedNodeId('') // 取消当前选中
setSelectedEdgeId('')
contextMenuState.value = {
flowPoint: getFlowPointFromEvent(event),
position: getMenuScreenPosition(event, { width: 224, height: 230, offset: 4 }),
}
}
和双击创建的区别:右键菜单会取消当前所有选中状态。右键画布空白处意味着"我要对画布做操作",而不是"对某个节点做操作"。
菜单里有什么
右键菜单 CanvasContextMenu.vue 提供五个功能:
html
<div class="canvas-context-menu" :style="menuStyle" role="menu">
<button @click="runAction('undo')">
<Undo2 :size="16" /> <span>撤销</span> <kbd>Ctrl+Z</kbd>
</button>
<button @click="runAction('redo')">
<Redo2 :size="16" /> <span>重做</span> <kbd>Shift+Ctrl+Z</kbd>
</button>
<span class="divider"></span>
<button @click="runAction('paste')">
<ClipboardPaste :size="16" /> <span>粘贴</span> <kbd>Ctrl+V</kbd>
</button>
<button @click="runAction('upload')">
<FileUp :size="16" /> <span>上传文件</span>
</button>
<button @click="runAction('add-node')">
<Plus :size="16" /> <span>添加节点</span>
</button>
</div>
撤销、重做、粘贴都比较直白。"上传文件"会打开一个隐藏的 <input type="file">,选完文件后在右键位置创建素材节点。
最值得说的是"添加节点"------它不直接创建节点 ,而是切换到前面那个节点类型选择菜单(ConnectionNodeMenu),并复用右键时保存的 flowPoint:
javascript
function openStandaloneNodeMenuFromContext() {
const state = contextMenuState.value
if (!state) return
standaloneNodeMenuState.value = {
flowPoint: state.flowPoint, // 直接复用右键时算好的画布坐标
position: getMenuScreenPosition({
x: Number(state.position?.x),
y: Number(state.position?.y),
}, { width: 220, height: 196, offset: 8 }),
}
closeContextMenu() // 关闭右键菜单
}
用户右键 → 点"添加节点" → 弹出类型菜单 → 选类型 → 节点出现在右键位置。flowPoint 在两个菜单状态之间传递,位置始终一致。
粘贴也用 flowPoint 定位
javascript
async function handleContextPaste() {
const state = contextMenuState.value
closeContextMenu()
await pasteNodes({ position: state?.flowPoint })
}
粘贴时,所有被粘贴的节点会按原始相对位置排列,整体偏移到右键点击的 flowPoint 附近。如果用 Ctrl+V 快捷键粘贴(没有右键位置),则默认偏移 (20, 20) 像素,避免和原节点完全重叠。
第九步:节点删除------为什么不用右键菜单
一个可能有争议的设计决策:我的项目里,节点删除不在右键菜单中,而是通过 Delete / Backspace 键完成。
理由是:删除是破坏性操作,需要一定的"摩擦成本"。键盘快捷键比右键菜单点一下更"刻意",降低误删概率。同时 Delete 也是 Photoshop、Figma 等图形软件的标准删除方式。
键盘快捷键的处理在一个独立的 composable useFlowKeyboardShortcuts 中:
javascript
function handleKeydown(event) {
// 关键守卫:如果焦点在输入框里,不拦截任何快捷键
if (isEditableTarget(event.target)) return
const key = String(event.key || '').toLowerCase()
const isModKey = event.metaKey || event.ctrlKey
if (isModKey && key === 'z') {
event.preventDefault()
event.shiftKey ? flow.redoHistory() : flow.undoHistory()
return
}
if (key === 'delete' || key === 'backspace') {
event.preventDefault()
if (!flow.selectedNodeId) return
flow.removeSelectedNodes()
}
}
这里有一个大坑:isEditableTarget 守卫。
我们的文本节点支持双击进入编辑模式,用户可以在里面打字。如果用户在输入框里按 Backspace 想删一个字符,结果你把整个节点删了------这体验是灾难性的。
所以必须判断:事件目标是不是一个可编辑的元素(<input>、<textarea>、<select>、contenteditable)。如果是,直接 return,把所有快捷键都交给浏览器默认行为。
javascript
function isEditableTarget(target) {
if (target.closest?.('[contenteditable="true"]')) return true
const tagName = target.tagName?.toLowerCase()
if (['input', 'textarea', 'select'].includes(tagName)) {
if (target.readOnly || target.disabled) return false
return true
}
return !!target.isContentEditable
}
删除时的级联清理
removeSelectedNodes 调用 removeNodes,后者调用 removeNodesAndEdges:
javascript
export function removeNodesAndEdges(nodes, edges, nodeIds = []) {
const ids = new Set(nodeIds.map(String).filter(Boolean))
if (!ids.size) return { nodes, edges }
return {
nodes: nodes.filter((node) => !ids.has(String(node.id))),
edges: edges.filter(
(edge) => !ids.has(String(edge.source)) && !ids.has(String(edge.target))
),
}
}
删节点的时候,所有以它为起点或终点的连线也会被一起删掉。如果不做这一步,就会产生"悬空边"------连线指向一个不存在的节点,后续渲染直接报错。
删完后,如果 selectedNodeId 指向了被删的节点,清空选中状态,同步所有节点的 selected 标志,再调用 queuePersist() 保存。
第十步:还有一条隐藏路径------连线拖拽释放
除了双击和右键菜单,其实还有一条创建节点的路径容易被忽略:从节点的连接手柄拖出一条线,在空白处松开。
这时系统会判断:连线没有被接受(没有拖到另一个有效节点的手柄上),于是在松开位置弹出节点类型菜单。用户选完类型后,新节点会出现在释放位置,并自动和起始节点连线。
这里的位置计算比双击复杂一点,因为要考虑连线方向:
javascript
function getConnectionNodePosition(point, size, direction, viewportState = viewport.value) {
const flowPoint = screenToFlowPoint(point, viewportState)
const zoom = Number(viewportState?.zoom) || 1
const handleGap = Math.max(10 / zoom, 8)
if (direction === 'upstream') {
// 上游节点:放在释放位置的左侧
return {
x: Math.round(flowPoint.x - size.width - handleGap),
y: Math.round(flowPoint.y - size.height / 4),
}
}
// 下游节点:放在释放位置的右侧
return {
x: Math.round(flowPoint.x + handleGap),
y: Math.round(flowPoint.y - size.height / 4),
}
}
从 source 手柄拖出来的线,用户想在右边创建下游节点,所以新节点放在释放位置的右侧。从 target 手柄拖出来的线则相反。
注意 handleGap = Math.max(10 / zoom, 8)------这是新节点和连线端点之间的间距。为什么要除以 zoom?因为 zoom 越大,同样的像素间距在视觉上越小。除以 zoom 后,无论缩放级别如何,间距看起来都是一致的。最小值 8 保证在缩得很小时间距不会太小。
三条路径,同一个设计核心
回头看这三种创建节点的方式------双击、右键 → 添加、连线拖拽释放------它们共享一个核心设计:
所有位置计算都基于画布坐标(flowPoint),只在最终渲染菜单时才用屏幕坐标。
- 双击时:屏幕坐标 →
screenToFlowPoint→ 保存 flowPoint → 选类型 → 用 flowPoint 定位节点。 - 右键时:屏幕坐标 →
screenToFlowPoint→ 保存 flowPoint → 粘贴/添加节点时复用。 - 连线释放时:屏幕坐标 →
screenToFlowPoint→ 按方向偏移 → 定位新节点。
如果在任何环节混用了屏幕坐标,节点就会在平移或缩放后出现在错误的位置。这个原则看似简单,但在一个有多个交互入口的系统里,很容易在某个新加的功能中忘记做坐标转换。
三个菜单的互斥管理
三种弹出菜单(节点类型菜单、右键菜单、连线菜单)在任何时刻最多只允许一个打开。每次打开新菜单时,显式关闭其他两个:
javascript
function openStandaloneNodeMenuFromEvent(event) {
// ...
closeConnectionMenu() // 关闭连线菜单
closeContextMenu() // 关闭右键菜单
standaloneNodeMenuState.value = { ... }
}
function handlePaneContextMenu(event) {
// ...
closeConnectionMenu() // 关闭连线菜单
closeStandaloneNodeMenu() // 关闭节点类型菜单
contextMenuState.value = { ... }
}
点击画布空白区域(@pane-click)时,关闭所有菜单并取消选中:
javascript
function handlePaneClick() {
closeContextMenu()
closeStandaloneNodeMenu()
setSelectedNodeId('')
setSelectedEdgeId('')
}
这种互斥设计看起来简单,但如果不做,就可能出现"右键菜单和节点类型菜单同时显示"的尴尬场面。
触摸事件的兼容处理
最后一个小细节:项目也需要支持平板设备,所以获取指针位置的函数要同时兼容鼠标和触摸:
javascript
function getPointerPoint(event) {
const sourceEvent = event?.event || event
const touchPoint = sourceEvent?.changedTouches?.[0] || sourceEvent?.touches?.[0]
return {
x: Number(touchPoint?.clientX ?? sourceEvent?.clientX) || 0,
y: Number(touchPoint?.clientY ?? sourceEvent?.clientY) || 0,
}
}
优先从 changedTouches 取(触摸抬起时的最终位置),回退到 touches(当前触摸位置),最后回退到 clientX / clientY(鼠标事件)。三级 fallback 覆盖了所有场景。
回顾
从一个 @dblclick.capture 开始,我们经历了:
- 事件捕获阶段拦截 → 避免和 VueFlow 的内部逻辑冲突
- 空白区域判断 → 排除节点、连线、手柄等非空白区域
- 菜单定位与边界钳制 → 确保菜单不超出视口
- 屏幕坐标 → 画布坐标转换 → 解决平移/缩放后节点位置偏差的核心问题
- 节点居中对齐 → 减去宽高一半,让点击位置成为节点中心
- Store 中的节点创建 → ID 唯一性、位置回退、自动选中、防抖保存
- 右键菜单 → 复用 flowPoint,菜单间状态传递
- 键盘删除 → isEditableTarget 守卫防误删,级联清理悬空边
- 连线拖拽释放 → 按方向偏移定位,handleGap 缩放补偿
这些逻辑单独看都不复杂,但组合在一起就构成了了一套完整的画布交互体系。希望这篇文章对正在做类似项目的朋友有所帮助------至少能少走几个我踩过的坑。