tldraw `editor.zoomToBounds()` 详解:把相机程序化地对准任意页面空间区域
本文围绕 tldraw 示例库中的 zoom-to-bounds 示例展开,讲解 editor.zoomToBounds() 这一相机 API 的完整用法:如何用页面空间的 Box 或 { x, y, w, h } 把视口对准指定区域,如何用 inset、animation、targetZoom 等选项控制留白与过渡效果,并结合 Editor.ts 中的源码实现与 单元测试,说明缩放比例、默认内边距与边界钳制的底层计算逻辑。读完本文,你可以在 React 应用中实现“点击按钮聚焦到某个区域/某个图形集合”的常见交互。
核心概念:什么是 zoomToBounds
editor.zoomToBounds(bounds, opts) 的作用是把相机移动到指定范围(page-space box),使该范围完整落入视口。原始说明见示例文档 README.md:
- 传入的
bounds既可以是Box实例,也可以是任何{ x, y, w, h }形状的对象; - 相机保持视口的宽高比,因此实际可见区域在某一轴向上通常会比你传入的 box 更大;
inset在该 box 周围增加屏幕空间(像素)的留白,未指定时使用options.zoomToFitPadding作为默认值;animation让移动过程带缓动动画;不传则相机直接“跳”到目标位置。
这个 API 的典型使用场景包括:一键回到选中内容(zoomToSelection 的底层就是它)、展示某个模板/画布区域的“全景视角”、点击缩略图或深链接后定位到对应区域等。
完整示例:两个框 + 三种聚焦方式
仓库中的可运行示例位于 ZoomToBoundsExample.tsx,配套样式见 zoom-to-bounds.css。它的思路是:
- 在画布上画出两个锁定状态的矩形(紫色、蓝色),分别代表两个目标区域
zoomBox1、zoomBox2; - 通过自定义
TopPanel组件挂三个按钮,分别调用三种聚焦方式。
关键代码如下(摘自上述文件):
import { Box, createShapeId, Editor, TLComponents, Tldraw, TldrawUiButton, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
import './zoom-to-bounds.css'
const zoomBox1 = new Box(50, 100, 900, 720)
const zoomBox2 = new Box(1000, 500, 500, 400)
function ZoomControls() {
const editor = useEditor()
return (
<div className="tlui-menu control-panel">
{/* 直接跳转,inset 固定 72px */}
<TldrawUiButton type="normal" onClick={() => editor.zoomToBounds(zoomBox1, { inset: 72 })}>
Zoom to violet box
</TldrawUiButton>
{/* 200ms 动画 */}
<TldrawUiButton
type="normal"
onClick={() => editor.zoomToBounds(zoomBox2, { inset: 72, animation: { duration: 200 } })}
>
Zoom to blue box
</TldrawUiButton>
{/* 两个框的最小外接框 + 大留白 + 动画 */}
<TldrawUiButton
type="normal"
onClick={() =>
editor.zoomToBounds(Box.Common([zoomBox1, zoomBox2]), {
inset: 200,
animation: { duration: 200 },
})
}
>
Zoom to both boxes
</TldrawUiButton>
</div>
)
}
const components: TLComponents = {
TopPanel: ZoomControls,
}
function handleMount(editor: Editor) {
// 用 geo 图形在画布上“可视化”这两个 Box
editor.createShapes([
{
id: createShapeId(),
type: 'geo',
x: zoomBox1.x,
y: zoomBox1.y,
isLocked: true,
props: { w: zoomBox1.w, h: zoomBox1.h, color: 'violet' },
},
{
id: createShapeId(),
type: 'geo',
x: zoomBox2.x,
y: zoomBox2.y,
isLocked: true,
props: { w: zoomBox2.w, h: zoomBox2.h, color: 'blue' },
},
])
}
export default function ZoomToBoundsExample() {
return (
<div className="tldraw__editor">
<Tldraw onMount={handleMount} components={components} />
</div>
)
}
示例中体现的三个调用模式值得记住:
| 调用方式 | 效果 |
|---|---|
editor.zoomToBounds(zoomBox1, { inset: 72 }) |
立即跳到紫色框,四周留 72px 屏幕空间留白 |
editor.zoomToBounds(zoomBox2, { inset: 72, animation: { duration: 200 } }) |
用 200ms 缓动过渡到蓝色框 |
editor.zoomToBounds(Box.Common([zoomBox1, zoomBox2]), { inset: 200, animation: { duration: 200 } }) |
先算出两个框的最小外接框,再聚焦到该范围,留白加大到 200px |
其中 Box.Common([...boxes]) 是 Box 的静态方法,用于把多个框合并为一个最小外接框——这正是“聚焦整个内容”类交互(如 zoom to all)的常用组合手法。示例文件底部也内嵌了一段注释([1] 标记处),与 README 的说明一致:相机保持宽高比,所以可见区域通常比传入的 box 在一个轴向上更大。
选项参数:inset、animation、targetZoom
zoomToBounds 的签名(见 Editor.ts)为:
zoomToBounds(bounds: BoxLike, opts?: { targetZoom?: number; inset?: number } & TLCameraMoveOptions): this
各选项说明:
bounds:页面空间坐标下的区域,Box或{ x, y, w, h }均可。注意坐标是页面空间(page space),不是屏幕像素。inset:屏幕空间(像素)内边距,在目标 box 四周收缩有效聚焦范围。省略时,源码取Math.min(options.zoomToFitPadding, viewportWidth * 0.28)(见下文)。animation:传入TLCameraMoveOptions的动画配置(如{ duration: 200 });不传则相机瞬时跳转。targetZoom:最终缩放倍率的上限。源码中执行zoom = Math.min(opts.targetZoom, zoom)(Editor.ts),即“聚焦到 bounds 但不超过 targetZoom 的放大程度”,常用于防止内容过小时被无限放大。force(经由TLCameraMoveOptions透传):即使相机被锁定(isLocked)也强制执行。
官方文档注释中给出的三个典型调用(Editor.ts):
editor.zoomToBounds(myBounds)
editor.zoomToBounds(myBounds, { animation: { duration: 200 } })
editor.zoomToBounds(myBounds, { animation: { duration: 200 }, inset: 0, targetZoom: 1 })
第三个调用组合了动画、零留白和“最多放大到 100%”的约束,是“回到某个区域但不放大超过原始比例”的标准写法。
源码实现:缩放倍率是怎么算出来的
从 Editor.ts 的实现看,整个流程分四步:
- 相机锁定检查:若相机选项
isLocked为 true 且未传force,直接返回,不移动相机。这一点有专门测试覆盖(见下文)。 - 计算 inset:未显式指定时取
Math.min(this.options.zoomToFitPadding, viewportScreenBounds.width * 0.28),即默认内边距不会超过视口宽度的 28%(Editor.ts)。 - 求解缩放倍率:
let zoom = clamp(
Math.min(
(viewportScreenBounds.width - inset) / bounds.w,
(viewportScreenBounds.height - inset) / bounds.h
),
zoomMin * baseZoom,
zoomMax * baseZoom
)
即分别按宽、高两个方向求出“刚好装下 box”的倍率,取较小者——这正是“保持宽高比、另一轴多出空白”的来源;再钳制到当前相机的最小/最大缩放(zoomSteps 首尾值乘 baseZoom),保证不会超出允许范围。
4. 居中并 setCamera:把 box 中心放到视口中心:
this.setCamera(
new Vec(
-bounds.x + (viewportScreenBounds.width - bounds.w * zoom) / 2 / zoom,
-bounds.y + (viewportScreenBounds.height - bounds.h * zoom) / 2 / zoom,
zoom
),
opts
)
setCamera 接收同一个 opts,所以 animation 配置直接驱动相机的缓动过渡。
另外两点实现细节值得注意:
- 它是多个高级能力的底层实现:从源码结构看,
zoomToFit(Editor.ts)、zoomToSelection(Editor.ts)以及深链接定位(deep link bounds,Editor.ts)最终都收敛到zoomToBounds,因此理解它就等于理解了 tldraw 大部分“聚焦”类行为。 - 相机移动不产生撤销记录:
zoomToBounds只改相机、不改文档内容,undo/redo 对它无效(有测试佐证,见下)。
默认内边距:zoomToFitPadding
inset 缺省时依赖编辑器选项 zoomToFitPadding,定义于 options.ts:
/**
* The default padding (in pixels) used when zooming to fit content in the viewport.
* This affects methods like `zoomToFit()`, `zoomToSelection()`, and `zoomToBounds()`.
* The actual padding used is the minimum of this value and 28% of the viewport width.
* Defaults to 128 pixels.
*/
readonly zoomToFitPadding: number
默认值为 128 像素(options.ts 中 zoomToFitPadding: 128)。初始化 <Tldraw> 时可通过 options={{ zoomToFitPadding: 64 }} 覆盖;小视口下由于 28% 上限的存在,实际留白会随视口宽度缩小。
行为验证:单元测试给出的确定性结论
zoomToBounds.test.ts 用 1000×1000 的屏幕边界对关键行为做了断言,可以直接作为行为契约引用:
- 居中与倍率:对
new Box(200, 300, 300, 300),最终camera.z等于(1000 - padding) / 300,与源码公式完全吻合; - 缩放上限钳制:对 1×1 的极小 box,“does not zoom past max”——
getZoomLevel()被钳到 8(最大级别); - 缩放下限钳制:对 1,000,000×100,000 的巨型 box,
getZoomLevel()被钳到 0.05(最小级别); - 相机锁定时忽略调用:
setCameraOptions({ isLocked: true })后再调用zoomToBounds,视口中心不变; - undo/redo 无感:
markHistoryStoppingPoint()之后调用zoomToBounds再undo(),相机位置不受影响; - 自定义
zoomToFitPadding生效:传入options: { zoomToFitPadding: 64 }构造编辑器后,倍率变为(1000 - 64) / 300。
这些断言说明:zoomToBounds 的输入输出是完全确定性的,便于在应用层做可预期的区域聚焦逻辑。
小结与适用提示
editor.zoomToBounds() 是 tldraw 相机 API 中面向“区域聚焦”的基础能力:传入页面空间 box,得到一次居中、等比、带可选留白与动画的相机移动。实际开发中建议:
- 聚焦单个区域时显式传
inset,避免默认值随视口尺寸变化带来的留白抖动; - 需要平滑体验时始终搭配
animation: { duration: ... }; - 聚焦“内容集合”时先用
Box.Common(或各图形 bounds 合并)求出总包围盒再调用; - 不希望内容被过度放大时,用
targetZoom兜底。
由于 zoomToFit、zoomToSelection 与深链接定位都构建在它之上,掌握 zoomToBounds 的计算规则(inset 收缩 → 取宽高中较小倍率 → 钳制到 min/max → 居中 setCamera),基本就覆盖了 tldraw 中绝大多数程序化相机移动的底层原理。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00