
tldraw 实战用 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导读本文基于 apps/examples/src/examples/ui/selection-ui 这一官方示例完整讲解如何在 tldraw 中通过InFrontOfTheCanvas组件插槽在画布之上渲染一套“始终固定大小、跟随选择集移动并匹配其旋转角度”的定向复制按钮朝上/下/左/右复制选中图形。你将学到 tldraw 渲染层的划分原理、useValue派生状态订阅、getSelectionRotatedScreenBounds/getSelectionRotation的坐标语义以及用射线与包围盒求交来自动计算复制偏移量的几何算法可直接迁移到悬停工具栏、选区角标、测量标注等任意自定义 UI 场景。示例速览它解决了什么问题当你在画布中选中一个图形、将它旋转后再点按按钮tldraw 会在按钮所指方向上紧贴着选区复制一份新图形。示例在 SelectionUiExample.tsx 中实现页面级 READMEREADME.md把它的核心思路概括为使用InFrontOfTheCanvas插槽在选区周围添加“按方向复制”按钮。InFrontOfTheCanvas组件以屏幕空间渲染于画布之上因此放入其中的任何内容都会在相机缩放/平移时保持固定尺寸。示例在useValue中读取editor.getSelectionRotatedScreenBounds()与editor.getSelectionRotation()在选区四周定位四个与之同步旋转的按钮每个按钮调用editor.duplicateShapes偏移量则由选区的旋转包围盒计算得出。这个案例之所以典型在于它同时踩中了两个 tldraw 开发中高频出现的问题UI 该挂在哪一层——既要浮在画布内容之上又不能随相机一起缩放变形几何坐标系如何换算——屏幕像素坐标、页面坐标、旋转包围盒三者之间的关系。为什么需要InFrontOfTheCanvas画布之上的独立渲染层tldraw 的画布内容图形层tl-shapes会随着相机的平移与缩放被整体施加transform见 DefaultCanvas.tsx 中监听相机并调用getHtmlLayerTransform的逻辑。如果直接把按钮渲染进这个 HTML 层它们会跟着图形一起缩放——相机拉远时按钮会小到不可点。InFrontOfTheCanvas是编辑器内置的一个“组件插槽”slot与OnTheCanvas、Background、SvgDefs等同属useEditorComponents提供的一组可覆盖部件见 useEditorComponents.tsx。它在默认画布DefaultCanvas的最外层被渲染为独立的 wrapper渲染位置DefaultCanvas.tsx 中的InFrontOfTheCanvasWrapperwrapper 的 className 为tl-canvas__in-front其样式见 editor.css为position: absolute; inset: 0; pointer-events: none并通过z-index: var(--tl-layer-canvas-in-front)浮在图形层之上wrapper 会在onPointerDown/onPointerUp/onTouchStart/onTouchEnd上调用editor.markEventAsHandled从源头标记事件已被处理。这里的pointer-events: none意味着整层默认不拦截画布事件而这正是示例代码注释 SelectionUiExample.tsx 强调的关键细节因为 wrapper 本身不吃指针事件自定义内容必须自己用pointerEvents: all把按钮容器“重新打开”按钮才能被点击。最终在应用侧只需把自定义组件写进传给Tldraw的components对象即可完成插槽注入const components: TLComponents { InFrontOfTheCanvas: SelectionUi, } export default function SelectionUiExample() { return ( div classNametldraw__editor Tldraw persistenceKeyselection-ui-example components{components} / /div ) }其中persistenceKeyselection-ui-example用于为该示例隔离本地持久化状态避免与其它 demo 互相污染。在useValue中派生选区几何信息SelectionUi的第一步是把“选区当前的屏幕包围盒与旋转角”变成可订阅的响应式派生值。代码见 SelectionUiExample.tsxconst info useValue( selection bounds, () { const screenBounds editor.getViewportScreenBounds() const rotation editor.getSelectionRotation() const rotatedScreenBounds editor.getSelectionRotatedScreenBounds() if (!rotatedScreenBounds) return return { x: rotatedScreenBounds.x - screenBounds.x, y: rotatedScreenBounds.y - screenBounds.y, width: rotatedScreenBounds.width, height: rotatedScreenBounds.height, rotation, } }, [editor] ) if (!info) return null逐行拆解其语义useValue来自tldraw的状态系统基于tldraw/state首个参数是派生函数它读取的每个响应式数据发生变化时都会自动重算并触发重渲染。因此当选区移动、旋转或相机平移、缩放时这套 UI 都会被“粘”在选区上。与 React 的useState useEffect手动同步相比这是官方推荐的响应式派生写法。editor.getViewportScreenBounds()整个 tldraw 容器在浏览器窗口中的屏幕坐标Box。editor.getSelectionRotatedScreenBounds()选区在屏幕空间中、已经按选区旋转角度旋转过的包围盒。底层实现见 Editor.ts它先用页面包围盒叠加相机偏移与缩放得到屏幕包围盒即x (bounds.x cx) * zoom screenBounds.x。做减法getSelectionRotatedScreenBounds返回的坐标是相对整个浏览器窗口的而我们要把这个absolute容器放进 tldraw 组件内部定位所以减去screenBounds得到相对 tldraw 容器左上角的坐标。三个 getter 都是computed派生值分别见 Editor.ts、Editor.ts、Editor.ts读取成本低且带缓存失效可以放心放进useValue的读取函数里高频执行。容器的关键定位技巧先平移到包围盒再整体旋转拿到信息后外层容器并非逐个换算四个角点而是用一次 CSS 变换“平移 旋转”把整组按钮带到正确位置见 SelectionUiExample.tsxreturn ( div style{{ position: absolute, top: 0, left: 0, transformOrigin: top left, transform: translate(${info.x}px, ${info.y}px) rotate(${info.rotation}rad), pointerEvents: all, }} DuplicateInDirectionButton y{-40} x{info.width / 2 - 16} rotation{-Math.PI / 2} / DuplicateInDirectionButton y{info.height / 2 - 16} x{info.width 8} rotation{0} / DuplicateInDirectionButton y{info.height 8} x{info.width / 2 - 16} rotation{Math.PI / 2} / DuplicateInDirectionButton y{info.height / 2 - 16} x{-40} rotation{Math.PI} / /div )这里的数学思路很干净容器的原点被设到选区屏幕包围盒的左上角transformOrigin: top left并整体rotate(info.rotation)于是在这个被旋转过的局部坐标系里选区的“上下左右”与数学坐标系的四个方向对齐几何问题被大大简化于是四个按钮的位置可直接用width / height表达上方按钮在x width/2 - 16, y -40右侧按钮在x width 8, y height/2 - 16依次类推。数值32是按钮边长40与8是留白按钮自身的rotation同时决定其 CSS 旋转角度与复制方向0 表示朝右-PI/2朝上PI朝左PI/2朝下——即“按钮箭头指向的方向 复制的偏移方向”。这正是官方实现注释 SelectionUiExample.tsx 点明的设计一个 rotation 值双复用避免方向语义在 JS 与 CSS 之间重复维护。按“刚好一个选区宽 间距”的方向计算复制偏移这是整套控件最精妙的部分要保证复制出来的图形贴在原图形边上而不是固定距离或飞到视野外。DuplicateInDirectionButton的完整实现见 SelectionUiExample.tsx。首先准备好两套数据页面空间因为duplicateShapes的 offset 作用于页面坐标const selectionRotation editor.getSelectionRotation() ?? 0 const rotatedPageBounds editor.getSelectionRotatedPageBounds() if (!rotatedPageBounds) return editor.markHistoryStoppingPoint(duplicate in direction) const PADDING 32getSelectionRotatedPageBounds()选区在当前页面坐标下、随旋转对齐的包围盒定义见 Editor.ts其核心getShapesRotatedPageBounds在 Editor.ts 中会把每个形状的几何按公共旋转角对齐后求并集markHistoryStoppingPoint(duplicate in direction)在历史记录中打一个“停止点”让整批复制成为一次可撤销操作用户按一次 CtrlZ 即可整体回退撤销栈相关实现可进一步查看 Editor.ts 中history相关方法。随后用射线与多边形求交来计算“沿目标方向、从中心出发、穿出膨胀后的选区边界”的那段距离const center Vec.Rot(rotatedPageBounds.center, selectionRotation) const int intersectLineSegmentPolygon( center, Vec.Add(center, new Vec(100000, 0).rot(selectionRotation rotation)), rotatedPageBounds .clone() .expandBy(PADDING) .corners.map((c) c.rot(selectionRotation)) ) if (!int?.[0]) return const delta Vec.Sub(int[0], center) const offset delta.uni().mul(delta.len() * 2) editor.duplicateShapes(editor.getSelectedShapes(), offset)对这段代码的几何拆解示例源码注释 [4]见 SelectionUiExample.tsx求选中区域的“真实中心”Vec.Rot(rotatedPageBounds.center, selectionRotation)把未旋转包围盒的中心点按选区旋转角绕原点转一次。因为rotatedPageBounds是旋转对齐后重新算出的 box其中心在旋转坐标系中即选区的质心发射射线从中心向目标方向selectionRotation rotation旋转后的 X 方向射出一根长度 100000 的虚拟射线。示例直接使用编辑器公开导出的Vec与intersectLineSegmentPolygon两个几何工具无需任何第三方库膨胀并旋转包围盒把rotatedPageBounds用expandBy(PADDING)均匀外扩 32px保证复制物与原图形之间留出间距再用corners.map((c) c.rot(selectionRotation))把四个角点旋转回页面角度形成一个真正的旋转矩形求射线出口点intersectLineSegmentPolygon返回线段与多边形边界的交点int[0]是离起点最近的出口点delta即“中心 → 出口”的向量乘以 2 作为最终偏移delta.uni().mul(delta.len() * 2)保留方向、把距离翻倍使新副本完全越过原图形、恰好落在其外侧PADDING间距随之翻成 64px 的空隙视觉上正好“贴着一个按钮宽度的距离”。最后调用editor.duplicateShapes(editor.getSelectedShapes(), offset)完成复制。该 API 的签名与语义见 Editor.ts为第一个参数接受TLShapeId[]或TLShape[]这里直接传入editor.getSelectedShapes()第二个可选参数offset是“要应用到副本上的像素偏移”VecLike实现内部会跳过被锁定的图形_getUnlockedShapeIds、通过getShapeAndDescendantIds处理组/父子结构并为每个副本生成新 id 后一次性写入文档因此复制嵌套图形或带绑定的箭头时也保持一致行为。前后端联动验证插槽的层级由默认画布真正挂载为确保上面的用法不是孤例可以在仓库中交叉验证这一插槽的“挂载契约”DefaultCanvas.tsx 在画布根节点之后渲染InFrontOfTheCanvasWrapper /测试 EditorPortal.test.tsx 与 InFrontOfTheCanvas.test.tsx 覆盖了相关层级的渲染行为tldraw 包在 TldrawUi.tsx 中也会组合这些槽位说明自定义插槽适用于 SDK 层Tldraw与完整 UI 层TldrawUi两套入口。值得一提的是InFrontOfTheCanvas是 tldraw 最常用的自定义 UI 落点之一。在 apps/examples/src/examples/ui 目录下可以找到多个基于同一插槽的姊妹示例适合对照阅读、巩固理解contextual-toolbar/README.md在选区周围渲染悬浮上下文工具栏用getSelectionBounds定位add-connected-shape/README.md在选区旁放置按钮并创建带绑定的箭头drag-and-drop-tray/README.md把整个可拖拽的图形托盘放进InFrontOfTheCanvas。运行与动手实验本示例位于官方 examples 应用Vite 项目中。在仓库根目录安装依赖后运行 examples 应用并打开对应路由即可交互验证选中图形 → 旋转 → 点击四个方向箭头观察复制结果yarn yarn dev --filter examples也可以直接阅读 SelectionUiExample.tsx 底部的长篇注释[1][4]与代码中的编号一一对应它逐段解释了插槽层、屏幕坐标换算、方向复用与射线求交四步思路是理解本示例最快的入口。小结可从本例带走的四个通用模式给画布加固定尺寸控件选InFrontOfTheCanvas插槽——它独立于相机 transform层级在图形之上z-index: var(--tl-layer-canvas-in-front)默认pointer-events: none记得对自己可点击的子树开启pointerEvents: all证据见 DefaultCanvas.tsx 与 editor.css。屏幕坐标减掉getViewportScreenBounds()即可得到相对 tldraw 容器的坐标可直接用于absolute子元素定位。优先使用官方computed派生 getter useValue表达选区几何信息选区或相机变化时 UI 自动跟手无需手动监听事件。“射线求交包围盒”是排版“贴着选区”的可复用算法先向外膨胀包围盒留白再从中心向目标方向打射线取最近出口点按需把该距离作为duplicateShapes的 offset——这个套路同样适用于实现“在选区旁边生成箭头”“沿边缘平铺副本”等功能。【免费下载链接】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),仅供参考