tldraw editor.overlays 实战:用 OverlayManager 读取指针悬停的 Overlay 并做点命中测试 📅 发布时间:2026/9/8 20:04:48 👁 浏览次数: tldraw editor.overlays 实战用 OverlayManager 读取指针悬停的 Overlay 并做点命中测试【免费下载链接】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 官方示例 hovered-overlay 展开讲解如何通过editor.overlays上的OverlayManager读取当前指针下的画布 OverlaygetHoveredOverlay()并对任意页面坐标做命中测试getOverlayAtPoint(point)。读完本文你将能理解 Overlay 的类型结构与 z-order 命中规则、hover 状态的内部更新链路并掌握用useValue将悬停 Overlay 的 type/id 响应式展示到自定义面板的完整可运行示例。Overlay 是什么TLOverlay 的数据结构在 tldraw 中Overlay 是渲染在画布之上的临时性 UI 元素——选择句柄selection handles、缩放角点、旋转手柄、形状句柄、画笔预览等它们不进入文档数据只随编辑状态选中、指针位置、工具状态临时存在。Overlay 的数据模型定义在 OverlayUtil.tsexport interface TLOverlayProps Recordstring, unknown { /** * 全局唯一 id跨所有 overlay util 唯一。 * 命中测试与 hover 查找仅依据 id因此 util 必须为 id 加命名空间 * 例如 selection_fg:top_left、handle:shapeId:handleId */ id: string /** 产生该实例的 overlay util 类型 */ type: string /** 该 Overlay 的任意属性句柄 id、角点名称等 */ props: Props }两点值得注意id 必须全局唯一源码注释明确说明 hover 查找与命中测试都只按id匹配不同 util 之间的 id 冲突会导致查找错乱因此内置 util 都采用selection_fg:top_left、handle:shapeId:handleId这类带前缀的命名空间。type 是 util 的静态类型字符串OverlayUtil基类要求每个子类声明static type管理器据此建立类型到 util 实例的映射见 OverlayManager.registerUtil。每种 Overlay 由一个OverlayUtil子类定义。基类OverlayUtil.ts要求子类回答五个问题方法作用isActive()响应式判断该类 Overlay 当前是否应该存在例如是否有选中getOverlays()仅当isActive()为 true 时调用产出当前所有 Overlay 实例getGeometry(overlay)返回页面坐标系下的命中测试几何返回null表示该 Overlay 不可交互如涂鸦笔迹、吸附指示线getCursor(overlay)悬停在该 Overlay 上时显示的鼠标样式render(ctx, overlays)把活跃 Overlay 绘制到画布 2D 上下文上下文已应用相机变换此外还有可选钩子onPointerDown指针按下时的中断式接管、renderMinimap小地图渲染与dispose清理。所有 util 通过options.zIndex控制绘制与命中顺序数值越大越靠上、优先命中同值时保持注册顺序默认0内置 util 使用 100、200 等留有间隔的整数方便自定义 util 插入中间层。OverlayManager 的核心 API管理器实现位于 OverlayManager.ts挂在editor.overlays上。其公开方法如下方法说明getHoveredOverlay(): TLOverlay \| null当前指针下的 Overlay无则为null。响应式指针移动时自动更新getHoveredOverlayId(): string \| null同上只取 idgetOverlayAtPoint(point, margin 0): TLOverlay \| null对任意页面坐标做命中测试返回最顶层命中的 OverlaygetCurrentOverlays(): TLOverlay[]当前所有活跃 Overlay 的响应式列表按绘制顺序zIndex 升序排列getOverlayGeometry(overlay)取某 Overlay 的命中测试几何按 Overlay 实例缓存getOverlayUtil(type \| overlay)按类型字符串支持泛型或 Overlay 实例查回对应 utilgetOverlayUtilsInZOrder(): OverlayUtil[]全部已注册 util按 zIndex 升序稳定排序getHoveredOverlay 的实现atom 查找getHoveredOverlay()的实现OverlayManager.ts#L141-L156非常直白private _hoveredOverlayId atomstring | null(hoveredOverlayId, null) getHoveredOverlayId(): string | null { return this._hoveredOverlayId.get() } getHoveredOverlay(): TLOverlay | null { const id this._hoveredOverlayId.get() if (!id) return null return this.getCurrentOverlays().find((o) o.id id) ?? null } setHoveredOverlay(id: string | null) { if (id this._hoveredOverlayId.get()) return this._hoveredOverlayId.set(id) }hover 状态本质是一个atom内部只存 id轻量、去重写入返回对象时再从getCurrentOverlays()中查找。因为 atom 读取会被状态系统追踪任何在响应式上下文中调用getHoveredOverlay()的代码都会在指针悬停的 Overlay 变化时自动重算——这就是示例中“读一次、一直更新”的原理。getOverlayAtPoint 的命中顺序getOverlayAtPointOverlayManager.ts#L171-L184实现如下getOverlayAtPoint(point: VecLike, margin 0): TLOverlay | null { const entries this.getActiveOverlayEntries() for (let i entries.length - 1; i 0; i--) { const { overlays } entries[i] for (const overlay of overlays) { const geometry this.getOverlayGeometry(overlay) if (!geometry) continue if (geometry.hitTestPoint(point, geometry.isFilled ? 0 : margin, true)) { return overlay } } } return null }从源码结构看命中规则有三条util 之间按 zIndex 从高到低遍历——循环从entries.length - 1倒着走绘制在最上层的 util 优先命中同一 util 内部按getOverlays()数组顺序遍历第一个命中的即胜出因此 util 作者应把优先级高的 Overlay 放在数组前面无几何getGeometry返回null的 Overlay 直接跳过不可交互的 Overlay 永远不会被点到。margin是命中容差对描边型几何生效对填充型几何isFilled true传margin会被强制为 0因为填充区域本身就是精确判定。例如测试 OverlayManager.test.ts#L101-L111 验证了边句柄是填充多边形margin为 0 时边缘条带内命中selection_fg:top条带外不命中而角点测试同文件#L113-L119在角点处 corner 与 edge 几何重叠时corner 的selection_fg:top_left优先胜出。几何缓存指针移动时getOverlayAtPoint会被高频调用为跳过每次重新计算几何的开销管理器用WeakCache按 Overlay 实例缓存几何OverlayManager.ts#L119-L135private _geometryCache new WeakCacheTLOverlay, Geometry2d | null() getOverlayGeometry(overlay: TLOverlay): Geometry2d | null { return this._geometryCache.get(overlay, (o) this.getOverlayUtil(o).getGeometry(o)) }缓存的生命周期由响应式系统保证只要getActiveOverlayEntries()返回同一批 Overlay 实例缓存条目持续有效当编辑状态变化导致各 util 的getOverlays()产出新对象时旧实例失去引用WeakCache条目随之被 GC 回收无需手动失效。完整示例把悬停 Overlay 的 type 与 id 显示到顶部面板官方示例位于 HoveredOverlayExample.tsx全文如下import { TLComponents, Tldraw, useEditor, useValue } from tldraw import tldraw/tldraw.css import ./hovered-overlay.css // [1] function HoveredOverlayReadout() { const editor useEditor() const hovered useValue(hoveredOverlay, () editor.overlays.getHoveredOverlay(), [editor]) return ( div classNamehovered-overlay-readout {hovered ? ( div spantype/span code{hovered.type}/code /div div spanid/span code{hovered.id}/code /div / ) : ( div classNamehovered-overlay-readout__emptySelect a shape, then hover its handles/div )} /div ) } // [2] const components: TLComponents { TopPanel: HoveredOverlayReadout, } export default function HoveredOverlayExample() { return ( div classNametldraw__editor Tldraw components{components} / /div ) }代码的关键在于两个标注[1] 用useValue读取响应式 API。useValue的第一个参数是缓存键回调内调用editor.overlays.getHoveredOverlay()第三参数[editor]声明依赖。由于getHoveredOverlay()读取了一个 atomuseValue会自动追踪该依赖指针滑上某个句柄时回调重算、组件重渲染面板立刻显示该 Overlay 的type如selection_foreground、shape_handle与id如selection_fg:top_left滑出后回到null显示空态提示“Select a shape, then hover its handles”。这正是“先选中一个形状再悬停它的句柄即可看到读数更新”的实现方式。[2] 用组件插槽挂载。TLComponents的TopPanel插槽把读数组件替换进编辑器顶部工具栏区域无需改动编辑器本体。配套的 hovered-overlay.css 定义了面板样式值得注意的一点是pointer-events: none——读数面板悬浮在编辑器上方禁用指针事件可以避免它意外遮挡画布交互.hovered-overlay-readout { background: var(--color-panel); border: 1px solid var(--color-divider); border-radius: 8px; padding: 8px 12px; margin: 8px; font-size: 12px; color: var(--color-text); pointer-events: none; display: flex; flex-direction: column; gap: 4px; min-width: 220px; }示例文件末尾的引导注释还列出了四个常用方法getHoveredOverlay、getOverlayAtPoint(point, margin?)、getCurrentOverlays()、getOverlayGeometry(overlay)上文“核心 API”一表已逐一给出源码级说明。内部链路hover 状态是如何被更新的示例只“读”状态真正写入_hoveredOverlayId的是选择工具的指针移动逻辑。调用链如下选择工具的 Idle 子状态在指针事件时调用 updateHoveredOverlayId入口在 SelectTool/Idle.ts 的onPointerMoveCrop 子状态同样调用该函数取当前页面坐标与全局命中容差editor.getHitTestMargin()调用editor.overlays.getOverlayAtPoint(currentPagePoint, margin)做命中测试命中时setHoveredOverlay(overlay.id)写入 atom同时editor.setHoveredShape(null)清空形状 hoverOverlay 悬停优先于形状悬停并通过util.getCursor(overlay)设置对应鼠标样式未命中时若之前悬停在某 Overlay 上则把光标重置为default并setHoveredOverlay(null)。// packages/tldraw/src/lib/tools/selection-logic/updateHoveredOverlayId.ts export function updateHoveredOverlayId(editor: Editor): boolean { if (editor.isDisposed) return false const currentPagePoint editor.inputs.getCurrentPagePoint() const margin editor.getHitTestMargin() const overlay editor.overlays.getOverlayAtPoint(currentPagePoint, margin) const previousOverlayId editor.overlays.getHoveredOverlayId() if (overlay) { editor.overlays.setHoveredOverlay(overlay.id) editor.setHoveredShape(null) const util editor.overlays.getOverlayUtil(overlay) const cursor util.getCursor(overlay) if (cursor) { editor.setCursor({ type: cursor, rotation: editor.getSelectionRotation() }) } return true } if (previousOverlayId) { editor.setCursor({ type: default, rotation: 0 }) } editor.overlays.setHoveredOverlay(null) return false }该函数的返回值也很关键返回true表示指针正悬停在某 Overlay 上SelectTool 据此跳过形状 hover 更新。文件头部注释明确说明它必须在updateHoveredShapeId之前调用以保证 Overlay 悬停优先级。理解这条链路后getHoveredOverlay()的响应式特性就完整了SelectTool 每帧把命中结果写入 atom你的useValue订阅该 atom 的变化二者之间没有手动同步代码。活跃集合的计算getActiveOverlayEntriesgetOverlayAtPoint与getCurrentOverlays()都依赖getActiveOverlayEntries()OverlayManager.ts#L98-L105computed getActiveOverlayEntries(): TLOverlayEntry[] { const entries: TLOverlayEntry[] [] for (const util of this.getOverlayUtilsInZOrder()) { if (!util.isActive()) continue entries.push({ util, overlays: util.getOverlays() }) } return entries }它是computed遍历全部已注册 util按 zIndex 升序稳定排序跳过isActive()为 false 的再调用各活跃 util 的getOverlays()。命中测试与渲染两条路径共享这一份缓存扫描避免各自重复推导活跃集合。注释还提到一个边界即使某 util 的getOverlays()返回空数组只要它处于活跃状态也会被包含进来因为render()可能仍需绘制非交互 UI例如框选过程中的选择包围盒。自定义 Overlay 时的注意事项如果你要注册自己的OverlayUtil通过编辑器构造选项的overlayUtils从源码可以推断出几个实践要点子类必须声明static type否则registerUtil会抛出Overlay util ... is missing a static type propertyOverlayManager.ts#L31-L40id 必须加命名空间前缀避免与内置 Overlay 冲突通过OverlayUtil.configure(options)静态方法可以派生带自定义options含zIndex的子类例如const MyBrush BrushOverlayUtil.configure({ fill: rgba(0,0,255,0.1) })zIndex决定它与其他 util 的绘制/命中层序不可交互的 Overlay 直接让getGeometry返回null基类默认行为它会被命中测试跳过但依然参与render。测试依据OverlayManager 的行为有专门的测试覆盖见 OverlayManager.test.ts与本节论述对应的关键断言包括无交互 Overlay 时getOverlayAtPoint返回nullL69-L72选中图形后句柄几何中心点命中、远处点不命中L74-L99填充型边句柄在 margin 为 0 时按条带精确命中L101-L111角点重叠时 corner 优先L113-L119无几何的涂鸦 Overlay 被跳过L121 起。小结editor.overlays暴露的OverlayManager把画布上所有临时 UI 元素统一为一套“活跃集合 命中测试 悬停状态”的响应式模型getHoveredOverlay()以 atom 为底层让你用一行useValue就能把指针下的句柄信息渲染到任何 React 面板getOverlayAtPoint(point, margin?)则提供了对任意页面坐标做 z-order 感知命中测试的通道命中规则util 间按 zIndex 高到低、util 内按数组序、无几何即跳过有明确源码与测试支撑。基于官方示例 HoveredOverlayExample.tsx你可以快速搭建悬停读数面板或将其扩展为“悬停句柄时显示操作菜单”“自定义工具栏联动”等交互。【免费下载链接】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),仅供参考