从零实现画布「双击创建节点」:一个 VueFlow 项目的交互全记录

从零实现画布「双击创建节点」:一个 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.clientXevent.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),
  }
}

逻辑很简单:算出菜单能放的最大 xy(视口尺寸减去菜单尺寸再减去边距),然后用 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,
  }
}

三步运算,每一步都有明确的物理含义:

  1. 减去 rect.left / rect.top :鼠标坐标是相对于浏览器窗口的,但画布元素本身可能不在 (0, 0)------页面上方可能有导航栏,左侧可能有侧边栏。减去画布元素的 getBoundingClientRect 偏移,把坐标从"浏览器视口"转到"画布元素"。

  2. 减去 viewport.x / viewport.y :视口的 x, y 是画布被平移后的偏移量。减掉它就消除了平移的影响。

  3. 除以 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 的唯一性保证 ------每种节点类型有独立的自增种子(imageNodeSeedtextNodeSeed 等),生成的 ID 形如 image-1image-2nextSeedFromNodes 会扫描现有节点中的最大序号取 +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 开始,我们经历了:

  1. 事件捕获阶段拦截 → 避免和 VueFlow 的内部逻辑冲突
  2. 空白区域判断 → 排除节点、连线、手柄等非空白区域
  3. 菜单定位与边界钳制 → 确保菜单不超出视口
  4. 屏幕坐标 → 画布坐标转换 → 解决平移/缩放后节点位置偏差的核心问题
  5. 节点居中对齐 → 减去宽高一半,让点击位置成为节点中心
  6. Store 中的节点创建 → ID 唯一性、位置回退、自动选中、防抖保存
  7. 右键菜单 → 复用 flowPoint,菜单间状态传递
  8. 键盘删除 → isEditableTarget 守卫防误删,级联清理悬空边
  9. 连线拖拽释放 → 按方向偏移定位,handleGap 缩放补偿

这些逻辑单独看都不复杂,但组合在一起就构成了了一套完整的画布交互体系。希望这篇文章对正在做类似项目的朋友有所帮助------至少能少走几个我踩过的坑。

相关推荐
YHL1 小时前
🚀 SSE 服务器发送事件与 BFF 层实战
前端·后端
今日无bug1 小时前
HTML5 Canvas:从画图到游戏开发
前端·canvas
cindershade1 小时前
用 Web Workers 优化前端重计算任务:避免主线程卡顿的实战方案
前端
爱丶不疚1 小时前
搞不清 CommonJS 与 ESM,你是否也有这些疑问🤔
前端·node.js
YIAN1 小时前
http无状态?State来展示!从基础路由到鉴权守卫,吃透 SPA 前端路由核心
前端·react.js
__zRainy__1 小时前
Node系列 · Node基础:全局变量与全局对象
开发语言·前端·javascript
码云骑士1 小时前
104-实战论文搜索引擎-ArXiv爬取-Milvus存储-RAG问答-Gradio前端
前端·python·搜索引擎·milvus
sunly_1 小时前
TypeScript总结:15、类型速查
前端·javascript·typescript
Brown.alexis1 小时前
es6知识点3-自备使用
前端·javascript·es6