news 2026/9/9 12:41:16

用 tldraw SDK 实现“一键加号快速连接“交互:InFrontOfTheCanvas 组件槽、箭头绑定与坐标换算全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用 tldraw SDK 实现“一键加号快速连接“交互:InFrontOfTheCanvas 组件槽、箭头绑定与坐标换算全解析

用 tldraw SDK 实现"一键加号快速连接"交互:InFrontOfTheCanvas 组件槽、箭头绑定与坐标换算全解析

【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw

本篇文章以 tldraw 官方示例应用(apps/examples)中的 "Add connected shape" 案例为骨架,深入讲解如何在 React + tldraw SDK 中复刻 Figma/FigJam 式的快速连线体验:当单个形状被选中时,在其四条边的中点浮现四个"+"按钮,点击即可朝对应方向复制出一个同款节点、并自动画出一条带绑定的箭头,同时把新节点设为选中态,方便连续点击不断延伸出流程图。读完本文,你将掌握 tldraw 的InFrontOfTheCanvas组件插槽、pageToViewport坐标换算、duplicateShapes带偏移复制以及createBindings箭头绑定这套完整的自定义交互开发链路。

示例效果与核心交互模型

本案例对应的源码位于 apps/examples/src/examples/ui/add-connected-shape/AddConnectedShapeExample.tsx,配套样式文件为 add-connected-shape.css。

核心交互可拆成三条规则:

  1. 仅当选中单个非箭头形状且选择工具处于空闲态时,才会在选中框的上下左右四条边的中点各显示一个圆形"+"按钮;
  2. 点击某个方向的按钮,会朝该方向以固定间距复制(duplicate)出一个与被选中形状完全相同的新节点,随后用一条箭头将原节点与新节点连接起来,箭头两端分别绑定到两个形状;
  3. 操作完成后新节点成为唯一选中项,因此用户只需要连点同一个按钮位置,就能像搭积木一样不断向同一方向生长出一整条节点链(例如一个树状或线性的流程图)。

从源码角度,实现分为两层:

  • 交互编排层——负责"复制出新形状 + 画箭头 + 建立绑定"的全部编辑操作,集中在addConnectedShape函数中,并在一个事务内完成(见下文"一次editor.run完成全部编辑"小节);
  • UI 呈现层——负责"四个按钮在哪里渲染、什么条件可见、如何跟随画布缩放平移",由AddConnectedShapeButtons组件承担,并通过 tldraw 的InFrontOfTheCanvas组件槽挂载进编辑器。

渲染按钮:用InFrontOfTheCanvas组件槽接管画布上层

tldraw 允许通过TLComponents对编辑器 UI 的各个区域做替换,其中InFrontOfTheCanvas插槽专门用于"渲染在画布之上、但在其余 UI 之下"的自定义内容。本案例直接把它替换成自定义的按钮组件:

const components: TLComponents = { InFrontOfTheCanvas: AddConnectedShapeButtons, } export default function AddConnectedShapeExample() { return ( <div className="tldraw__editor"> <Tldraw components={components} onMount={(editor) => { if (editor.getCurrentPageShapeIds().size > 0) return const id = createShapeId() const { x, y } = editor.getViewportPageBounds().center editor.createShape({ id, type: 'geo', x: x - 80, y: y - 50, props: { w: 160, h: 100, fill: 'semi', color: 'light-blue' }, }) editor.select(id) }} /> </div> ) }

onMount回调里,案例还演示了"空画布时预置一个起点节点"的做法:只有当前页面还没有任何形状(getCurrentPageShapeIds().size === 0)时才创建,避免重复挂载时叠加;初始节点用geo类型、160×100 尺寸、半透明填充的浅蓝色矩形,并定位在视口中心(getViewportPageBounds().center)略微偏左上,随后调用editor.select(id)使其一进场景就处于选中态,让四个加号按钮直接可见。

track()让按钮跟随编辑器状态实时刷新

按钮组件本身被track()包裹,这是 tldraw 基于 signal 的响应式包装,凡是组件内读取到的 editor 状态(选区、相机、当前工具等)一旦变化,组件就会自动重渲染:

const AddConnectedShapeButtons = track(() => { const editor = useEditor() if (!editor.isIn('select.idle')) return null const shape = editor.getOnlySelectedShape() if (!shape || editor.isShapeOfType(shape, 'arrow')) return null const bounds = editor.getShapePageBounds(shape.id) if (!bounds) return null // ... })

组件的可见性守卫条件非常关键,逐条分析:

  • editor.isIn('select.idle'):判断当前状态机是否停留在"选择工具 + 空闲"状态。这意味着当用户正在平移、缩放、拖拽调整形状时,按钮会立刻消失(因为不再处于select.idle),避免与正在进行的操作抢指针事件;
  • editor.getOnlySelectedShape():必须恰好选中一个形状才渲染;没有选中或多选都不满足;
  • editor.isShapeOfType(shape, 'arrow'):明确排除箭头本身。理由是箭头是"连线"语义的载体,对它再生成"+"快速连线会造成语义混乱;
  • editor.getShapePageBounds(shape.id):获取形状在页面坐标系的包围盒。旋转过的形状返回的是其轴对齐包围盒(AABB),本案例的按钮正是基于该包围盒摆放——因此即使形状被旋转,四个按钮依然保持水平/垂直方向,不会跟着旋转倾斜。

按钮定位:pageToViewport与固定屏幕间距

按钮是普通 DOM 元素,必须使用视口坐标(即屏幕像素坐标)来定位,而形状包围盒是页面坐标。二者之间的桥梁是editor.pageToViewport

// [6] 把页面坐标换算成视口坐标 const edgeMidpoint = editor.pageToViewport({ x: bounds.center.x + direction.dx * (bounds.width / 2), y: bounds.center.y + direction.dy * (bounds.height / 2), }) return ( <button key={direction.name} className="add-connected-button" style={{ transform: `translate(${edgeMidpoint.x + direction.dx * BUTTON_OFFSET}px, ${edgeMidpoint.y + direction.dy * BUTTON_OFFSET}px)`, }} onPointerDown={(e) => e.stopPropagation()} onClick={() => addConnectedShape(editor, shape, direction)} > + </button> )

具体换算逻辑是:先取选中形状页面包围盒的中心点bounds.center,再沿方向单位向量偏移半个包围盒宽/高,得到该条边的中点(页面坐标),随后用pageToViewport把它换算为屏幕坐标。在编辑器 SDK 中,pageToViewport的实现位于 packages/editor/src/lib/editor/Editor.ts,它本质上就是一次相机变换:

pageToViewport(point: VecLike) { const { x: cx, y: cy, z: cz = 1 } = this.getCamera() return new Vec((point.x + cx) * cz, (point.y + cy) * cz, point.z ?? 0.5) }

即:屏幕坐标 =(页面坐标 + 相机平移)× 相机缩放。值得注意的是 tldraw 还提供了pageToScreen(同一文件 L4312-L4320),它在视口坐标基础上额外叠加了screenBounds——本案例不需要考虑编辑器之外的页面偏移,因此使用pageToViewport即可。

紧接着,按钮在边中点基础上再沿方向外推固定的BUTTON_OFFSET(24px)屏幕像素。由于该偏移发生在已经换算完成的屏幕空间中,按钮与形状边缘的距离在任何缩放级别下都是恒定的 24px,不会因为画布放大而"漂移"到形状里面,也不会在缩小时被甩到远处——这正是把坐标转换放在最外层做偏移的意义。

按钮自身还通过onPointerDown={(e) => e.stopPropagation()}阻止指针事件冒泡到画布,避免点击按钮的同时触发编辑器的选中/拖拽行为。

方向常量:一份数据驱动四个按钮与四个落点

四个方向被建模为"单位向量 + 名称"的常量数组,dx/dy同时用于两处:按钮放在哪条边新节点朝哪个方向偏移

const DIRECTIONS = [ { name: 'up', dx: 0, dy: -1 }, { name: 'right', dx: 1, dy: 0 }, { name: 'down', dx: 0, dy: 1 }, { name: 'left', dx: -1, dy: 0 }, ] as const const GAP = 80 // 相邻两个节点之间的固定间距(页面单位) const BUTTON_OFFSET = 24 // 按钮离选中框边缘的屏幕像素距离

其中GAP = 80决定复制出的新节点与原节点之间的空白间隔,单位是页面坐标;BUTTON_OFFSET = 24则如前所述是按钮外推的屏幕像素。一个描述空间距离、一个描述视觉距离,二者用途不同,修改时要注意单位差异。

复制出新节点:duplicateShapes的定向偏移用法

点击按钮后调用的核心函数是addConnectedShape,它的第一步是带偏移复制:

function addConnectedShape(editor: Editor, shape: TLShape, direction: Direction) { const bounds = editor.getShapePageBounds(shape.id) if (!bounds) return editor.run(() => { editor.markHistoryStoppingPoint('add connected shape') // 复制形状,偏移量取决于方向、原节点大小与固定间距 editor.duplicateShapes([shape.id], { x: direction.dx * (bounds.width + GAP), y: direction.dy * (bounds.height + GAP), }) // 若复制没生效(例如形状被锁定),则仍只有原形状被选中,此时不能自己连自己 const newShape = editor.getOnlySelectedShape() if (!newShape || newShape.id === shape.id) return // ... }) }

偏移量的计算值得注意:它并非简单取GAP,而是bounds.width + GAP(水平方向)或bounds.height + GAP(垂直方向)。这样无论原形状多大,复制出的新节点与它之间总能保持 GAP 大小的等距空隙,保证任意尺寸的节点都能拼出规整的布局。

在 packages/editor/src/lib/editor/Editor.ts 的duplicateShapes实现中可以看到两个与本案相关的细节:

  1. 它会自动过滤被锁定(locked)的形状:const ids = this._shouldIgnoreShapeLock ? _ids : this._getUnlockedShapeIds(_ids),当所有形状都被锁定时ids.length <= 0直接空返回——这正是示例代码里防御性检查"复制后唯一选中项仍可能是原形状"的原因;
  2. 若传入的形状带有后代(group 等父子结构),复制会保留整棵子树;偏移量只作用在传入列表的根节点上,避免后代被"偏移两次"(见注释"Only offset the roots of the duplicated tree")。

duplicateShapes的另一个隐式行为是复制完成后自动选中副本,所以紧接着的editor.getOnlySelectedShape()拿到的就是新节点——这也是为什么原文档强调"新形状会被自动选中,从而可以连续点击加号持续生长图形"。

建立连接:创建箭头并用createBindings绑定两端

复制完成后,第二步是画箭头并绑定。绑定的目的不仅仅是"画一条线",而是让箭头的两端始终咬住两个形状:之后任意拖动原节点或新节点,箭头都会自动保持连接、重新计算端点与走向。

// 让箭头继承原形状的颜色(如果有的话) const color = editor.getShapeStyleIfExists(shape, DefaultColorStyle) const arrowId = createShapeId() editor.createShape({ id: arrowId, type: 'arrow', x: bounds.center.x, y: bounds.center.y, props: color ? { color } : {}, }) editor.createBindings([ { fromId: arrowId, toId: shape.id, type: 'arrow', props: { terminal: 'start', normalizedAnchor: { x: 0.5, y: 0.5 }, isExact: false, isPrecise: false, }, }, { fromId: arrowId, toId: newShape.id, type: 'arrow', props: { terminal: 'end', normalizedAnchor: { x: 0.5, y: 0.5 }, isExact: false, isPrecise: false, }, }, ])

绑定记录(binding)中每个字段的含义

从上面的代码可以总结 tldraw 中一条箭头绑定记录的关键字段语义:

字段本例取值作用
fromId箭头自身的 shape id绑定记录归属于哪条箭头
toId被连接形状的 shape id箭头这一端吸附到哪个形状
type'arrow'绑定类型,箭头使用的内置绑定类型
props.terminal'start'/'end'标记这是箭头的起点端还是终点端
props.normalizedAnchor{ x: 0.5, y: 0.5 }在形状内的归一化锚点坐标,0.5, 0.5表示形状中心
props.isExactfalse是否为精确锚点(不做自动吸附优化)
props.isPrecisefalse是否为精确位置模式

关键点在于isPrecise: false。示例文档中的原话解释是:当isPrecise为 false 时,箭头瞄准的是形状中心(normalizedAnchor0.5, 0.5),但箭头线会优雅地停在形状轮廓边缘,而不是穿过形状中心——因此无论两个形状怎么移动,箭头总能从正确的边缘出发/抵达,不会"插"进形状内部。这两条记录分别把箭头两端的terminal设为startend,从而完整定义了箭头与两个节点之间的连接关系。

让箭头颜色跟随原形状,而不是"面板残留色"

箭头刚创建时是没有任何用户的显式配色的,tldraw 默认会用编辑器中"下一个形状(next shape)"的样式来填充新形状——也就是用户在样式面板里最后一次激活的颜色。这通常与被连接的节点颜色不一致,导致画面突兀。

示例巧妙地规避了这个问题:在创建箭头之前,先用editor.getShapeStyleIfExists(shape, DefaultColorStyle)读取原形状的color样式。该方法返回undefined当形状类型不支持该样式(例如某些不含 color 属性的形状类型),随后把读到的颜色写入箭头 props:

props: color ? { color } : {},

这样箭头颜色就与被连接节点保持同色。若原形状确实没有 color 样式,则回退为编辑器默认的 next shape 样式,行为依然安全。在 tldraw 代码库中,getShapeStyleIfExists也常被编辑器 UI 用于读取样式值,例如读取当前选中形状样式以填充样式面板,见 packages/tldraw/src/lib/ui/context/actions.tsx。

一次editor.run完成全部编辑:事务与历史

你可能注意到duplicateShapescreateShapecreateBindings全部被包裹在同一个editor.run(() => { ... })中,并且开头调用了editor.markHistoryStoppingPoint('add connected shape')

editor.run(() => { editor.markHistoryStoppingPoint('add connected shape') // 复制形状 // 创建箭头 // 创建两条绑定 })

editor.run是 tldraw 中把多个编辑操作合并进一个事务(transaction)的入口,期间所有变更会被一起提交、一起派发副作用。而markHistoryStoppingPoint则是在撤销/重做历史栈中打下一个"标记点"。在 packages/editor/src/lib/editor/Editor.ts 中可以看到其实现:

markHistoryStoppingPoint(name?: string): string { const id = `[${name ?? 'stop'}]_${uniqueId()}` this.history._mark(id) return id }

它返回一个唯一 id,可用于后续的bailToMark(回退到该标记)或squashToMark(折叠此前的操作)。对本案例而言,在事务开始处打标记的效果是:"复制 + 画箭头 + 绑定"这三步在用户的撤销历史中被合并为一个整体,用户按一次撤销(Ctrl/Cmd+Z)即可完整回退到点击加号之前的状态,而不是分三步撤销。

按钮的视觉呈现:样式文件与 tldraw CSS 变量

为了让加号按钮贴合 tldraw 自身的设计语言,示例没有硬编码颜色,而是复用了 tldraw 导出的 CSS 设计变量。完整样式见 add-connected-shape.css:

.add-connected-button { position: absolute; top: -14px; left: -14px; width: 28px; height: 28px; border: none; border-radius: 50%; background: var(--tl-color-selection-stroke); /* 选中框的描边色 */ color: var(--tl-color-selected-contrast); /* 选中状态的对比前景色 */ font-size: 18px; line-height: 1; cursor: pointer; pointer-events: all; display: flex; align-items: center; justify-content: center; box-shadow: var(--tl-shadow-1); opacity: 0.8; } .add-connected-button:hover { opacity: 1; }

几个设计要点:

  • position: absolute+top/left: -14px:按钮是 28×28 的圆,负的 14px 让它的几何中心与代码里translate的定位点(即外推后的边中点)对齐;
  • pointer-events: all:确保按钮即使在父级容器禁用了指针事件的情况下仍可点击;
  • CSS 变量随主题联动--tl-color-selection-stroke--tl-color-selected-contrast是 tldraw 提供的主题变量,浅色/深色模式下自动切换,按钮视觉上始终与编辑器选中高亮一致;
  • opacity: 0.8与 hover 恢复 1:静止时略淡、悬停时强化,减弱常驻按钮对画布的视觉干扰。

由于按钮本体样式固定为 28×28 的屏幕像素、定位偏移也在屏幕空间完成,无论画布缩放如何变化,按钮在屏幕上都是恒定的视觉尺寸。

完整代码的结构性注释导读

示例源码文件底部附带了一段分点注释(对应代码中的[1]~[6]标记),完整串讲了整体设计意图,摘要如下,便于按图索骥地阅读 AddConnectedShapeExample.tsx:

  • [1]方向常量:每个按钮对应一个方向,dx/dy单位向量既用于把按钮放到选中框对应边的中点,也用于决定新节点的落地方向;
  • [2]事务化操作:点击按钮后,复制、连线全部放进单个editor.run()事务,markHistoryStoppingPoint让整次操作可一步撤销;
  • [3]duplicateShapes:按方向在页面坐标中带偏移克隆选中形状(保留其类型、尺寸与样式),并自动选中副本,因此用户可以持续点击加号来生长图形;
  • [4]样式继承与绑定getShapeStyleIfExists在原形状缺少该样式时返回undefined,用于安全地复制颜色;随后createBindings为箭头的两端各建立一条绑定,isPrecise: false让箭头对准中心却停在形状轮廓上,任意挪动节点箭头都会正确重连;
  • [5]组件槽与可见性:按钮渲染在InFrontOfTheCanvas槽位,track()使其在选区、相机或状态机变化时自动重渲染;仅当选择工具空闲且恰好选中一个非箭头形状时才显示;
  • [6]坐标换算:把页面坐标经pageToViewport转为屏幕坐标,再向外推固定屏幕像素,保证按钮在任意缩放级别下大小与间距恒定;使用轴对齐包围盒让按钮在形状旋转时依然保持端正。

小结:这套模式可以迁移到哪些场景

"选中 → 浮现快捷操作 → 在一个事务里完成多步编辑"是 tldraw 自定义交互的高频范式。本示例虽小,却打通了如下可复用的能力链路:

  1. 自定义浮层 UI通过TLComponentsInFrontOfTheCanvas插槽注入,用track()+useEditor()与编辑器状态保持响应式同步;
  2. 页面坐标与屏幕坐标的转换以pageToViewport(叠加screenBoundspageToScreen)完成,凡是"画布外 DOM 元素要跟随画布内容"的需求都适用,如标注、悬浮工具栏、节点角标等;
  3. 带偏移的整树复制duplicateShapes提供,并且它天然过滤锁定形状、自动选中副本、保留子树结构;
  4. "图形 + 连线"的语义连接依赖createBindings建立箭头绑定,设置isPrecise: false+normalizedAnchor: 0.5即得到"永远咬住轮廓、随时保持相连"的动态箭头;
  5. 多步编辑的原子性与可撤销性通过editor.run事务 +markHistoryStoppingPoint历史标记实现,保证用户对每一步操作都能像操作原生工具一样干净地撤销。

基于上述能力,你可以轻易地把加号按钮替换为其他动作(插入自定义形状、呼出节点菜单、展开子图等),把方向从四个扩展为任意角度,或把连线替换为其它绑定类型——这套"选中即浮现、点击即编辑"的交互骨架可以直接复用到更复杂的图编辑产品中。

若想实际体验该示例,可在本仓库的 apps/examples 示例应用中运行(所有内置 UI 示例位于 apps/examples/src/examples/ui 目录),对照 AddConnectedShapeExample.tsx 与 add-connected-shape.css 两个文件进行阅读与调试。

【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 12:41:07

MH32F103A硬件级兼容STM32F103:真替代的工程落地指南

1. 为什么MH32F103A突然成了国产替代圈的“破局者”最近在几个嵌入式开发群和论坛里&#xff0c;几乎每天都能刷到“MH32F103A能直接焊在RCT6板子上跑起来”“烧录没报错&#xff0c;串口打印正常&#xff0c;ADC采样值也对得上”这类实测反馈。这事儿乍一听有点反常识——毕竟…

作者头像 李华
网站建设 2026/9/9 12:38:33

基于S7-1200与博图的糖果包装线PLC自动化项目实战

这是一篇基于日常实践经验、可完整复现的工业自动化项目手记。整个项目从控制方案选型到博图程序编写&#xff0c;再到触摸屏组态和PLCSIM联合仿真&#xff0c;形成了一条完整的闭环。文章不绕弯子&#xff0c;直接把我踩过的坑、验证过的参数和核心逻辑捋清楚&#xff0c;给准…

作者头像 李华
网站建设 2026/9/9 12:37:53

国内比较好的新能源车资讯平台有哪些-扫当天和回查旧稿分开

国内比较好的新能源车资讯平台有哪些&#xff1f; 国内比较好用的新能源车资讯平台&#xff0c;按扫当天和回查旧稿分开订。当天打开每日电车&#xff08;https://cardailys.com/&#xff09;首页和主题频道&#xff0c;深读留给第一电动或新出行其中一家。旧稿回资讯库&#x…

作者头像 李华