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

用 tldraw SDK 实现“一键加号快速连接“交互:InFrontOfTheCanvas 组件槽、箭头绑定与坐标换算全解析
用 tldraw SDK 实现一键加号快速连接交互InFrontOfTheCanvas 组件槽、箭头绑定与坐标换算全解析【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. Worlds 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。核心交互可拆成三条规则仅当选中单个非箭头形状且选择工具处于空闲态时才会在选中框的上下左右四条边的中点各显示一个圆形按钮点击某个方向的按钮会朝该方向以固定间距复制duplicate出一个与被选中形状完全相同的新节点随后用一条箭头将原节点与新节点连接起来箭头两端分别绑定到两个形状操作完成后新节点成为唯一选中项因此用户只需要连点同一个按钮位置就能像搭积木一样不断向同一方向生长出一整条节点链例如一个树状或线性的流程图。从源码角度实现分为两层交互编排层——负责复制出新形状 画箭头 建立绑定的全部编辑操作集中在addConnectedShape函数中并在一个事务内完成见下文一次editor.run完成全部编辑小节UI 呈现层——负责四个按钮在哪里渲染、什么条件可见、如何跟随画布缩放平移由AddConnectedShapeButtons组件承担并通过 tldraw 的InFrontOfTheCanvas组件槽挂载进编辑器。渲染按钮用InFrontOfTheCanvas组件槽接管画布上层tldraw 允许通过TLComponents对编辑器 UI 的各个区域做替换其中InFrontOfTheCanvas插槽专门用于渲染在画布之上、但在其余 UI 之下的自定义内容。本案例直接把它替换成自定义的按钮组件const components: TLComponents { InFrontOfTheCanvas: AddConnectedShapeButtons, } export default function AddConnectedShapeExample() { return ( div classNametldraw__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} classNameadd-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_OFFSET24px屏幕像素。由于该偏移发生在已经换算完成的屏幕空间中按钮与形状边缘的距离在任何缩放级别下都是恒定的 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实现中可以看到两个与本案相关的细节它会自动过滤被锁定locked的形状const ids this._shouldIgnoreShapeLock ? _ids : this._getUnlockedShapeIds(_ids)当所有形状都被锁定时ids.length 0直接空返回——这正是示例代码里防御性检查复制后唯一选中项仍可能是原形状的原因若传入的形状带有后代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箭头这一端吸附到哪个形状typearrow绑定类型箭头使用的内置绑定类型props.terminalstart/end标记这是箭头的起点端还是终点端props.normalizedAnchor{ x: 0.5, y: 0.5 }在形状内的归一化锚点坐标0.5, 0.5表示形状中心props.isExactfalse是否为精确锚点不做自动吸附优化props.isPrecisefalse是否为精确位置模式关键点在于isPrecise: false。示例文档中的原话解释是当isPrecise为 false 时箭头瞄准的是形状中心normalizedAnchor的0.5, 0.5但箭头线会优雅地停在形状轮廓边缘而不是穿过形状中心——因此无论两个形状怎么移动箭头总能从正确的边缘出发/抵达不会插进形状内部。这两条记录分别把箭头两端的terminal设为start与end从而完整定义了箭头与两个节点之间的连接关系。让箭头颜色跟随原形状而不是面板残留色箭头刚创建时是没有任何用户的显式配色的tldraw 默认会用编辑器中下一个形状next shape的样式来填充新形状——也就是用户在样式面板里最后一次激活的颜色。这通常与被连接的节点颜色不一致导致画面突兀。示例巧妙地规避了这个问题在创建箭头之前先用editor.getShapeStyleIfExists(shape, DefaultColorStyle)读取原形状的color样式。该方法返回undefined当形状类型不支持该样式例如某些不含 color 属性的形状类型随后把读到的颜色写入箭头 propsprops: color ? { color } : {},这样箭头颜色就与被连接节点保持同色。若原形状确实没有 color 样式则回退为编辑器默认的 next shape 样式行为依然安全。在 tldraw 代码库中getShapeStyleIfExists也常被编辑器 UI 用于读取样式值例如读取当前选中形状样式以填充样式面板见 packages/tldraw/src/lib/ui/context/actions.tsx。一次editor.run完成全部编辑事务与历史你可能注意到duplicateShapes、createShape、createBindings全部被包裹在同一个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/CmdZ即可完整回退到点击加号之前的状态而不是分三步撤销。按钮的视觉呈现样式文件与 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: absolutetop/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 自定义交互的高频范式。本示例虽小却打通了如下可复用的能力链路自定义浮层 UI通过TLComponents的InFrontOfTheCanvas插槽注入用track()useEditor()与编辑器状态保持响应式同步页面坐标与屏幕坐标的转换以pageToViewport叠加screenBounds即pageToScreen完成凡是画布外 DOM 元素要跟随画布内容的需求都适用如标注、悬浮工具栏、节点角标等带偏移的整树复制由duplicateShapes提供并且它天然过滤锁定形状、自动选中副本、保留子树结构图形 连线的语义连接依赖createBindings建立箭头绑定设置isPrecise: falsenormalizedAnchor: 0.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. Worlds best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

最新新闻

日新闻

周新闻

月新闻