ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用 registerExternalContentHandler 定制 tldraw 的粘贴行为:实现类 Figma 的画框错位粘贴

2026/9/8 16:43:59 拓冰建站 浏览量
用 registerExternalContentHandler 定制 tldraw 的粘贴行为:实现类 Figma 的画框错位粘贴 用 registerExternalContentHandler 定制 tldraw 的粘贴行为实现类 Figma 的画框错位粘贴【免费下载链接】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 把一个 tldraw 文档里复制的画框frame粘贴回画布时新副本会与原始画框完全重叠。本篇文章以仓库 apps/examples/src/examples/data/assets/custom-paste 目录下的完整示例为主线深入讲解如何通过editor.registerExternalContentHandler(tldraw, ...)替换 tldraw 内置的外部内容处理器实现粘贴的副本落到原始画框右侧的空闲位置、并自动让开路上其他画框的类 Figma 行为。读完你将掌握 tldraw 外部内容external content处理机制的入口、内置默认处理器defaultHandleExternalTldrawContent的实现细节以及如何用不到 60 行代码优雅地劫持并回退默认粘贴逻辑。一、问题背景粘贴副本为什么会叠在原件上先看示例文档README.md给出的核心说明Replace the built-in paste handler so a copied frame lands in free space beside the original. Pasted tldraw content goes through thetldrawexternal content handler. This example overrides it witheditor.registerExternalContentHandler(tldraw, ...)to add one rule: when the clipboard holds a single page-level frame, place the pasted copy to the right of the original (and past any other frames in the way), the way Figma does. Everything else falls through todefaultHandleExternalTldrawContent.它点出了三个关键事实从 tldraw 内部复制出来的内容在粘贴时走的是tldraw类型的 external content handler而不是普通文本、图片或文件粘贴所走的路径想让粘贴行为智能化最干净的做法不是自己重写整套粘贴流程而是注册一个同名处理器覆盖默认行为只在需要特殊处理的场景接管逻辑其余一律回退给内置的defaultHandleExternalTldrawContent示例想复刻的产品交互是 Figma 的做法把新副本放到原件右侧的空白处而不是盖在原件上方。示例的验证方法也写在 README 里创建一张画框然后连续按Cmd C、Cmd V几次每次粘贴出的副本都会依次排到右侧而不是原地堆叠。二、前置知识tldraw 的 external content 处理器机制2.1 处理器按内容类型注册粘贴/拖放统一分发在 tldraw 中一切从编辑器外部进入画布的内容——无论是粘贴的文本、拖入的图片、贴入的 URL、粘贴的 SVG还是从另一个 tldraw 页面复制来的图形——都会先被抽象为外部内容external content然后交给注册在对应type上的处理器。内置的默认注册函数是registerDefaultExternalContentHandlers位于 packages/tldraw/src/lib/defaultExternalContentHandlers.ts它一次性注册了这些类型类型注册位置同文件用途fileasset 处理器L96文件 → 图片/视频等 asseturlasset 处理器L101URL → 书签bookmarkassetsvg-textL106粘贴/拖入的 SVG 文本embedL111可嵌入内容iframe 等filesL116文件系统文件file-replaceL121替换图片等场景的文件textL126纯文本urlL131URL 内容tldrawL136tldraw 自身复制出的内容excalidrawL141从 excalidraw 粘贴的内容其中tldraw类型专门服务于tldraw 文档内部复制的内容——也就是本示例要拦截的目标。2.2registerExternalContentHandler的 API 与语义registerExternalContentHandler是Editor实例上的公开方法定义在 packages/editor/src/lib/editor/Editor.tsregisterExternalContentHandlerT extends TLExternalContentE[type], E( type: T, handler: | null | (( info: T extends TLExternalContentE[type] ? ExtractTLExternalContentE, { type: T } : TLExternalContentE ) void) ): this { this.externalContentHandlers[type] handler as any return this }结合其上的文档注释Editor.ts可以归纳出三条实用语义传入null即删除处理器。源码中externalContentHandlers初始映射表里每个类型默认都是null见 Editor.ts而registerDefaultExternalContentHandlers在挂载时把默认实现填进去。因此任何一次注册本质上都是替换当前生效的实现这一机制为覆盖默认行为提供了直接入口。泛型对 handler 做类型推导。例如registerExternalContentHandlerembed, MyEmbedType(embed, myHandler)这种形式可以针对自定义的 embed 类型传附加泛型参数对本示例而言tldraw对应载荷类型为TLTldrawExternalContent即{ type: tldraw; point?: VecLike; content: TLContent }这类结构。返回this可以链式调用便于在onMount中一次性注册多个类型。示例代码CustomPasteExample.tsx的注册方式即是在Tldraw onMount{...}回调中完成的export default function CustomPasteExample() { return ( div classNametldraw__editor Tldraw onMount{(editor) { // [1] editor.registerExternalContentHandler(tldraw, (content) handleCustomTldrawPaste(editor, content) ) }} / /div ) }文件底部注释对[1]的解释是registerExternalContentHandlerreplaces the handler for a content type.tldrawis the type used for content copied from tldraw itself, so this intercepts every internal paste.defaultHandleExternalTldrawContentis the built-in handler, which we fall back to for anything we dont want to special-case.即因为tldraw就是 tldraw 内部复制内容使用的类型注册后会拦截每一次内部粘贴而defaultHandleExternalTldrawContent则是内置处理器作为我们不想特判情况下的兜底出口。2.3 配套的回退出口内置默认处理器做了什么defaultHandleExternalTldrawContent的完整实现位于 packages/tldraw/src/lib/defaultExternalContentHandlers.ts。它本身就是一个很有参考价值的标准粘贴流水线概括如下在editor.run()事务中执行并调用editor.markHistoryStoppingPoint(paste)把历史记录切出一个粘贴断点便于用户一次undo撤销整批粘贴解锁锁定的根图形遍历content.shapes凡属于rootShapeIds的根图形粘贴时把isLocked置为false否则锁定的图形粘贴后会无法操作识别交互中途粘贴通过editor.isInAny(select.dragging_handle, select.translating, select.resizing, select.rotating)判断用户是否正处在拖拽手柄、平移、缩放或旋转的中途。若在交互中途粘贴不抢走选区否则会打断正在进行的图形操作例如箭头吸附的提示会消失此时select: false落位调用editor.putContentOntoCurrentPage(content, { point, select: !isMidInteraction })把剪贴板内容放到当前页。注意point参数——当粘贴带有明确的落点例如右键菜单触发的粘贴到此处时内容会落在point指定的位置示例正是抓住这一点来判断是否为普通键盘粘贴重叠提示若粘贴前后的选区边界发生碰撞selectionBoundsBefore?.collides(selectedBoundsAfter)则通过updateInstanceState({ isChangingStyle: true })触发一个短暂的 puff 视觉反馈150ms 后复位提示内容已粘贴见 defaultExternalContentHandlers.ts。理解这条内置流水线是理解示例设计的关键示例并没有重写粘贴本身而是先让默认处理器完成真正的粘贴与选中再对选中的新副本做位移修正。三、核心实现逐段剖析handleCustomTldrawPaste是示例的全部业务逻辑位于 CustomPasteExample.tsx。先看完整代码const SPACING_BETWEEN_FRAMES 50 function handleCustomTldrawPaste(editor: Editor, { content, point }: TLTldrawExternalContent) { // [2] const onlyCopiedShape content.rootShapeIds.length 1 ? content.shapes.find((shape) shape.id content.rootShapeIds[0]) : null const onlyCopiedFrame onlyCopiedShape?.type frame ? (onlyCopiedShape as TLFrameShape) : null // only use the special behavior if the frame will be a direct child of the page (its // parentId isnt a shape in the document) const willPasteOnCurrentPage onlyCopiedFrame ? !editor.getShape(onlyCopiedFrame.parentId) : false // [3] if (point || !onlyCopiedFrame || !willPasteOnCurrentPage) { defaultHandleExternalTldrawContent(editor, { content, point }) return } // [4] editor.putContentOntoCurrentPage(content, { select: true }) const newlyPastedFrame editor.getOnlySelectedShape() if (!newlyPastedFrame || !editor.isShapeOfType(newlyPastedFrame, frame)) return const siblingIds editor.getSortedChildIdsForParent(newlyPastedFrame.parentId) const pastedBounds editor.getShapePageBounds(newlyPastedFrame.id)! let targetPosition pastedBounds.minX const siblingBounds siblingIds .map((id) ({ id, bounds: editor.getShapePageBounds(id)! })) .sort((a, b) a.bounds.minX - b.bounds.minX) for (const sibling of siblingBounds) { if (sibling.id newlyPastedFrame.id) continue // if this sibling is above or below the copied frame, we dont need to take it into account if (sibling.bounds.minY pastedBounds.maxY || sibling.bounds.maxY pastedBounds.minY) continue // if the sibling is to the left of the copied frame, we dont need to take it into account if (sibling.bounds.maxX targetPosition) continue // if the sibling is to the right of where the pasted frame would end up, we dont care about it if (sibling.bounds.minX targetPosition pastedBounds.w) continue // otherwise, we need to shift our target right edge to the right of this sibling targetPosition sibling.bounds.maxX SPACING_BETWEEN_FRAMES } editor.nudgeShapes([newlyPastedFrame.id], { x: targetPosition - pastedBounds.minX, y: 0, }) }代码刻意做了先判型、再落位、后平移的三段式设计下面按底部注释的[2]–[4]编号逐段解读。3.1[2]判定剪贴板里是否恰好是一张独立的画框注释原文是Work out whether the clipboard holds exactly one root shape and that shape is a frame.TLContent中rootShapeIds表示剪贴板内容的根图形顶层图形集合shapes是包含嵌套子图形在内的全部图形数组。判定逻辑分两步第一步先判断content.rootShapeIds.length 1即剪贴板中只有一个根图形并在shapes中按根图形 id 找到它得到onlyCopiedShape。若剪贴板里同时复制了多张画框或其他图形直接不满足单画框条件第二步判断该图形的type frame确认它确实是一张 frame 画框得到onlyCopiedFrame第三步willPasteOnCurrentPage用!editor.getShape(onlyCopiedFrame.parentId)判断该画框的父级是页面本身还是另一个图形。这里getShape查的是当前文档中是否存在这个父图形——若原画框嵌套在另一个画框/编组内部那么parentId对应的是一个真实存在的 shapewillPasteOnCurrentPage为false只有当画框是页面的直接子级其父级不是文档里的任何 shape时才走特殊逻辑。这也与注释 only use the special behavior if the frame will be a direct child of the page 完全对应。3.2[3]回退三种情况一律走默认行为注释原文是If the paste has an explicitpoint(for example, a paste from the context menu, which lands at the pointer), or it isnt a lone page-level frame, use the default behavior.if (point || !onlyCopiedFrame || !willPasteOnCurrentPage) { defaultHandleExternalTldrawContent(editor, { content, point }) return }三个回退条件分别是条件含义为什么回退point存在粘贴带有显式落点例如右键菜单里的粘贴会把内容放到指针位置此时用户已明确指定放哪不应再被强行挪到右侧!onlyCopiedFrame剪贴板不是单张 frame多选、文本、图形组等场景不在本规则范围内!willPasteOnCurrentPage画框不是页面直接子级嵌套画框的摆放属于父容器内部布局简单右移会破坏嵌套关系注意这里回退时依然把原参数{ content, point }原样交给默认处理器因此默认行为完全不受影响——这也正是registerExternalContentHandler覆盖模式的精髓只拦截需要的子集其余 100% 透传。3.3[4]落位与避让先按默认位置粘贴再向右扫过重叠兄弟注释原文是Paste with the default handler first, then walk the frames siblings from left to right and slide the new frame past any that overlap it vertically, leaving a gap.策略上有一个精妙的取舍先调用editor.putContentOntoCurrentPage(content, { select: true })完成真正的粘贴让新副本落到与原件相同的位置并自动选中紧接着用editor.getOnlySelectedShape()拿到刚粘贴出来的那一个图形因为select: true且剪贴板只有一个根图形再用editor.isShapeOfType(newlyPastedFrame, frame)做一次保险校验。接下来是避让算法它是整个示例的智力核心可以拆成四个步骤① 收集同一父容器下的兄弟并排序const siblingIds editor.getSortedChildIdsForParent(newlyPastedFrame.parentId)getSortedChildIdsForParent返回父容器内按索引排序的子图形 id画框是页面直接子级这里拿到的就是页面上所有顶层图形。随后const siblingBounds siblingIds .map((id) ({ id, bounds: editor.getShapePageBounds(id)! })) .sort((a, b) a.bounds.minX - b.bounds.minX)用getShapePageBounds取每个兄弟的页面坐标包围盒并按minX左边缘从左到右排序——这样后续扫描天然是从原件方向向右推进。② 只关心与新副本有垂直重叠的兄弟if (sibling.bounds.minY pastedBounds.maxY || sibling.bounds.maxY pastedBounds.minY) continue若某兄弟完全位于新副本的上方或下方垂直范围无交集说明它不会挡路直接跳过。这个判断让右移只在画框水平带内起作用不会被页面上其他行、其他区域的图形干扰。③ 只关心会撞上新副本的兄弟if (sibling.bounds.maxX targetPosition) continue // 完全在目标位置左侧已让开 if (sibling.bounds.minX targetPosition pastedBounds.w) continue // 在新副本右边缘更右侧不冲突两个条件分别剔除已经在新副本目标位置的左边与在新副本右边缘更右边的兄弟——它们都不会与待放置的副本产生水平碰撞。④ 需要避让时把目标左边缘推到该兄弟右侧并留出间距targetPosition sibling.bounds.maxX SPACING_BETWEEN_FRAMES一旦某兄弟同时通过 ②③ 两道筛选即垂直重叠、水平范围内就把目标位置推到它的右边缘之外并额外加上SPACING_BETWEEN_FRAMES 50的间距。因为兄弟已按minX升序排列这个循环天然实现遇到一个挡路的就右移再看下一个等价于把新副本依次让过所有挡路画框。最后用nudgeShapes把刚粘贴出的副本沿 x 轴平移editor.nudgeShapes([newlyPastedFrame.id], { x: targetPosition - pastedBounds.minX, y: 0, })位移量是新目标位置与当前落位的差值y 方向保持为 0——即新副本只在水平方向移动与原件保持同一垂直高度。targetPosition的初值是pastedBounds.minX因此当没有任何兄弟挡路时位移量为 0副本留在原地但注意此时原件与副本位置相同——第一次粘贴会重叠吗这正是示例有意思的地方连续CmdC/CmdV多次时第一次粘贴的副本仍叠在原件上但它是当前被选中的、位于原件之上第二次粘贴时之前的副本已经作为一个兄弟存在新副本会右移到它右侧 50px从而逐渐排成一行。每次粘贴后新副本都处于选中态便于继续操作。四、边界行为与设计取舍从源码可以提炼出这个示例在哪些边界上刻意不做特殊处理这些取舍本身就是很好的设计参考带point的粘贴一律放行默认行为右键菜单粘贴、API 指定落点的场景保证用户指哪打哪的语义不被破坏只处理页面直接子级的画框嵌套在父画框或编组中的画框、多选复制、文本复制都走默认逻辑。示例判断父级是否为文档里的真实图形而非父级类型是否为页面写法上直接复用了getShape的存在性判断简洁且稳健避让计算以页面包围盒为准getShapePageBounds自动考虑了形状的旋转、缩放对实际占用区域的贡献而排序与推进都只看 x 轴保证最终只产生水平位移间距常量SPACING_BETWEEN_FRAMES 50是可调参数。把它独立提取为常量是示例特意留给使用者的扩展点——想要更紧凑或更疏朗的排布只需调整这一个值。仓库测试代码也为这套覆盖模式提供了佐证例如 packages/tldraw/src/test/commands/clipboardPaste.test.ts 在测试内部直接调用editor.registerExternalContentHandler(files, ...)来替换某类内容的粘贴实现packages/editor/src/lib/editor/Editor.test.ts 也通过注册text的 mock 处理器来验证外部内容处理流程。这印证了注册覆盖 回退默认是 tldraw 生态中针对粘贴/外部内容的标准定制套路。五、运行与验证示例归属于仓库的 examples 工程目录apps/examples可按该目录 README 的方式启动示例应用打开data / assets / custom-paste对应的示例页。验证步骤在画布上用画框工具创建一张 frame或直接绘制若干图形后用Shift把它们变成画框内的内容选中这张画框按下Cmd C连续按下若干次Cmd V观察粘贴出的副本默认情况下第一次粘贴会落在原件上并处于选中态随后每次粘贴的新副本都会依次排到已有画框的右侧、留出 50px 空隙最终形成一行水平排列的画框行为与 Figma 一致。若想验证回退分支可以用右键菜单粘贴此时携带point新副本会落在指针位置而非被右移。六、扩展思路把这个模式推广到更多粘贴场景示例展示的注册tldraw处理器 条件分支 回退defaultHandleExternalTldrawContent是一个可复用的骨架按同样的思路还可以定制多张画框的网格化粘贴把单个根图形改为根图形数组在粘贴后按行列把每个根图形错开摆放嵌套画框的粘贴去重对非页面级副本先提升detach到页面层级再避让或调用editor.bringForward等层次 API 处理遮挡其他内容类型的定制参考registerDefaultExternalContentHandlers的注册表defaultExternalContentHandlers.ts你可以同样覆盖text、files、svg-text、excalidraw等类型例如粘贴的图片自动落到某个固定区域或粘贴的文本自动拆成列表移除某类默认处理直接传null如editor.registerExternalContentHandler(files, null)可完全禁用某类外部内容的默认处理需自行承担后续行为变化。关键约束始终是两条只有tldraw类型会命中 tldraw 文档内部的复制粘贴对自己不关心的分支务必原样回退给defaultHandleExternalTldrawContent以免破坏编辑器默认能力。小结这个示例虽然短小却是理解 tldraw 外部内容处理机制的最佳切片从registerExternalContentHandler的注册与覆盖语义、defaultHandleExternalTldrawContent内置流水线中的历史断点/解锁/选中/重叠反馈设计到先判型回退、再粘贴、后按包围盒避让平移的实现策略一层层展示出 SDK 在可定制性与默认行为完整性之间的平衡。全文核心代码与配套说明可直接在仓库中查阅示例 READMEapps/examples/src/examples/data/assets/custom-paste/README.md示例源码apps/examples/src/examples/data/assets/custom-paste/CustomPasteExample.tsx默认外部内容处理器含tldraw默认实现packages/tldraw/src/lib/defaultExternalContentHandlers.tsregisterExternalContentHandler定义 packages/editor/src/lib/editor/Editor.ts【免费下载链接】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),仅供参考