在 tldraw 中实现非全屏 Inset 嵌入式编辑器:容器测量原理与实战指南
tldraw 的 <Tldraw> 组件默认会"填满"它所在的父容器——而不是浏览器窗口。因此只要把组件放进一个尺寸不占满屏幕的容器(例如居中的卡片、被四周留白包裹的画布区域、弹窗或仪表盘面板),编辑器就会自动收缩到该区域,并且指针事件、快捷键、相机与 UI 的表现与全屏时完全一致。本文以仓库示例 Inset editor 为骨架,讲解这种"内嵌非全屏"布局的写法和背后的容器测量原理,并延伸到同页多编辑器的固定尺寸布局与纯 CSS 移动画布等进阶做法。
Inset 布局要解决的核心问题
在 tldraw 早期或很多"全屏白板"类产品中,编辑器总是铺满视口。但当你需要把白板作为页面的一部分嵌入时(例如文档详情页中央的批注画板、以 inset 缩进四周 100px 的内容编辑区、聊天工具里的示意白板),就会面临一个问题:编辑器究竟以谁为参照来确定"可视区域大小"?
<Tldraw> 的设计答案是——以自己所在的容器为参照:
Place the
Tldrawcomponent in a container that doesn't fill the screen.
只要容器本身有确定的尺寸,tldraw 就会把画布、UI 工具栏、菜单、相机缩放范围全部限制在这个容器之内。示例 README 的原话很关键:
The
Tldrawcomponent fills its container, and that container can be anywhere in your layout. ... the editor measures its own container rather than the window.
也就是说,编辑器测量的是自己的容器,而不是 window。这正是非全屏布局能稳定工作的基石。
最小实现:把一个编辑器放进"四周缩进 100px"的绝对定位盒子
仓库中的 InsetExample.tsx 用一个最小的例子演示了这一点,完整源码如下:
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function InsetExample() {
return (
<div style={{ position: 'absolute', inset: 100 }}>
<div className="tldraw__editor">
<Tldraw />
</div>
</div>
)
}
逐层解读这个结构:
- 外层绝对定位盒子:
position: absolute+inset: 100,让这个盒子在页面四周各留出100px的空隙。inset: 100等价于同时设置top/right/bottom/left: 100px。因为整个例子运行在 examples 应用的全屏舞台里,所以外层用绝对定位把它"钉"在页面中央。 - 中层
.tldraw__editor包裹层:这是 examples 应用在 styles.css 中提供的一层"充满父容器"的辅助样式:
.tldraw__editor {
position: absolute;
inset: 0px;
overflow: hidden;
overscroll-behavior: none;
touch-action: none;
}
它的作用是让编辑器视觉上铺满外层盒子,同时把 overscroll-behavior、touch-action 等设为 none,避免在画布上拖动时连带触发页面本身的滚动。源文件里还附了一句注释:这里的定位必须用 position: absolute(而不是 fixed),因为在 iOS Safari 18 / macOS 15 上 fixed 会带来多个渲染问题。这是自己搭非全屏页面时值得照搬的防坑配置。
3. 最内层 <Tldraw />:tldraw 自身渲染一个带 tl-container 类的根容器(见下节),并自动填满这层盒子。
在自己的应用中,中层那个 className="tldraw__editor" 并不是必需的——你完全可以直接给包裹层一个确定的高度,例如:
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function InsetExample() {
return (
<div style={{ position: 'relative', width: '100%', height: 400 }}>
<Tldraw />
</div>
)
}
两者殊途同归,核心只有一个:包裹 <Tldraw> 的容器必须有确定尺寸,因为 tldraw 自己的根容器样式是 width: 100%; height: 100%。
为什么缩进之后指针、相机与快捷键依然正常:容器测量原理
非全屏编辑器最容易翻车的地方是:点击时坐标错位、相机缩放中心跑偏、或者快捷键失效。tldraw 之所以能避开这些坑,是因为它的"可视区域"并不默认等于视口,而是一路从容器/画布元素上量出来的。结合源码可以梳理出下面这条链路。
1. 根容器是一个可被测量的 tl-container div
<Tldraw> / <TldrawEditor> 的渲染入口会输出一个根 div,其类名就是导出的常量 TL_CONTAINER_CLASS = 'tl-container'(见 TldrawEditor.tsx),渲染时还会带上 data-tldraw 版本号并设置 role="application":
<div
ref={setContainer}
data-tldraw={version}
draggable={false}
className={classNames(`${TL_CONTAINER_CLASS} tl-theme__light`, className)}
tabIndex={-1}
role="application"
>
在配套的 editor.css 中,这个容器被定义为:
.tl-container {
width: 100%;
height: 100%;
font-size: 12px;
...
}
这就是"<Tldraw> 填满容器"这句话在样式层面的直接体现——只要外层容器有尺寸,这个 tl-container 就会跟随它。
2. 画布 .tl-canvas 铺满容器,命中测试与渲染都在其内部
真正承载网格、图形与交互事件的画布元素 .tl-canvas,样式上同样相对容器铺满(见 editor.css):
.tl-canvas {
position: absolute;
inset: 0px;
height: 100%;
width: 100%;
color: var(--tl-color-text);
cursor: var(--tl-cursor);
overflow: clip;
content-visibility: auto;
touch-action: none;
contain: strict;
}
值得注意的细节是 .tl-canvas 设置了 contain: strict。由于 contain: strict 会建立独立的层叠/包含上下文,注释里也说明:background、grid、shapes、overlays、blocker 都是 .tl-canvas 的子节点,而协作者光标、水印等是它的兄弟节点,它们各自相对这个容器而非窗口排列。这从布局层面保证了缩进盒子不影响内部坐标体系。
3. 可视区域测量:ResizeObserver + 定时器 + 滚动父级监听
tldraw 通过 useScreenBounds 这个 hook 持续把"容器的实际屏幕位置与大小"同步给编辑器。它在 useScreenBounds.ts 中做了三件事:
const resizeObserver = new ResizeObserver((entries) => {
if (!entries[0].contentRect) return
updateBounds()
})
resizeObserver.observe(container)
// When the container's nearest scrollable parent scrolls, update the bounds
scrollingParent = getNearestScrollableContainer(container)
scrollingParent.addEventListener('scroll', updateBounds)
- 用
ResizeObserver观察容器元素自身的尺寸变化(而不是监听 window resize),因此容器被 CSS 重新排布或拖拽改变大小时,编辑器会跟随刷新可视区域; - 在容器是"可滚动区域的子节点"这一场景下,向上找到最近的滚动父级并监听其
scroll,保证编辑器嵌在可滚动页面里时,画布上的命中测试不会因为页面滚动而错位(getNearestScrollableContainer会沿 DOM 向上遍历,把overflow-y为auto/scroll/overlay且有可滚动内容的父元素判定为滚动容器,见同文件 useScreenBounds.ts); - 同时叠加
window.resize监听和约每秒一次的节流定时器作为兜底刷新。
每次刷新最终调用 editor.updateViewportScreenBounds(...)。这个函数既接受 Box 也接受 HTMLElement,对元素测量时会读取元素的包围盒;在编辑器初始化时,TldrawEditor.tsx 就执行过一次:
editor.updateViewportScreenBounds(canvasRef.current ?? container)
即"优先量画布元素,否则量容器"——画布和容器此刻都在缩进盒子内部,所以得到的就是那 100px 缩进后的真实可视区域,而指针坐标、ScreenToPage/PageToScreen 变换、相机边界都基于这套 bounds 计算。这也是示例 README 所说"Pointer events, keyboard shortcuts, camera, and UI all work the same as in a full-screen editor"的源码级解释。
4. UI 层的自适应
当可视区域变小后,默认 UI 并不是简单缩放,而是重新排布。要直观感受这种自适应,可以运行 Inset editor (fixed sizes) 这个相邻示例:它在同一页面里放了五个不同尺寸的编辑器,观察顶部工具栏、菜单和面板在空间缩窄时如何折叠、换行或收纳。这在实现"同一页面里嵌入多个小画板"的需求时非常实用。
同页嵌入多个固定尺寸编辑器:共享同一份文档
示例 README 结尾把"多个固定尺寸编辑器"指向了 inline/README.md。对应的 InlineExample.tsx 用共享的 persistenceKey 让五个编辑器持有同一份文档:
export default function InlineExample() {
return (
<>
<InlineEditor width={400} height={300} />
<InlineEditor width={500} height={300} />
<InlineEditor width={600} height={400} />
<InlineEditor width={700} height={500} />
<InlineEditor width={900} height={600} />
</>
)
}
function InlineEditor({ width, height }: { width: number; height: number }) {
const title = `${width} x ${height}`
return (
<section style={{ padding: '12px 32px' }}>
<h2>{title}</h2>
<div style={{ width, height }}>
<Tldraw persistenceKey="inset-size-example" />
</div>
</section>
)
}
这里有两个可复用的要点:
- 每个编辑器都只是一个带固定
width/height的 div +<Tldraw />,再一次印证"尺寸由外层容器决定"的规则;五组400×300、500×300、600×400、700×500、900×600覆盖了从小面板到接近全宽的多种视口,方便对比 UI 的自适应行为。 persistenceKey="inset-size-example"表示所有编辑器共享同一个本地持久化文档:数据按这个 key 存进浏览器本地存储(localStorage),因此在任意一个编辑器里画的内容会同步出现在其余四个里。这是演示"多视图查看同一文档"的轻量方案。
需要提醒的是:同一页面放置多个独立 <Tldraw> 意味着存在多份独立的 editor/UI 实例,内存与渲染开销会随之叠加;同时多个编辑器同时存在时,还要考虑焦点管理与 UI 裁剪问题(例如从一个编辑器直接切换到另一个时焦点落在谁身上)。仓库中 apps/examples/src/examples/layout 目录下的 inline-behavior、multiple 等示例正是围绕这类"嵌入多个编辑器时的公共实践"展开的,可作为进阶阅读材料。
更进一步:用纯 CSS 把画布从容器里"抠"出来
普通场景下,画布填满容器、UI 浮在画布之上。如果你连这个默认关系都想调整——比如让画布收缩到容器中央 50%,而 UI 工具栏仍停在原位置——tldraw 也留了口子,因为它测量的是 .tl-canvas 本身而不是容器。这是 Inset canvas 这个"非典型但能跑通"的示例想证明的事情。
其实现全部在 CSS 里(见 inset-canvas.css):
.tldraw__editor-with-inset-canvas .tl-canvas {
position: absolute;
inset: 25%;
width: 50%;
height: 50%;
}
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import './inset-canvas.css'
export default function InsetCanvasExample() {
return (
<div className="tldraw__editor tldraw__editor-with-inset-canvas">
<Tldraw />
</div>
)
}
通过覆盖 .tl-canvas 的定位,画布被缩到容器的中间 50%,而工具栏等 UI 仍在四周。由于编辑器测量的是画布元素自身,指针坐标、命中测试与相机依然正确对齐——这验证了"纯 CSS 就能搬动画布"的结论。示例 README 也明确提示这是不常见的布局,真正的价值在于告诉你:当需要把画布从编辑器 chrome 里重新摆放时,不必侵入 JS 逻辑,CSS 覆盖即可。
实践中的注意事项清单
把以上示例落到真实项目时,建议核对这几点:
- 容器必须有确定尺寸:
<Tldraw>自带的tl-container是width/height: 100%,若父容器高度为auto/无高度,编辑器可能塌陷为 0 高度。优先像示例那样给容器显式height,或用绝对定位inset撑开。 - 必须引入样式:
import 'tldraw/tldraw.css'(对应包导出tldraw.css,见 package.json),否则.tl-container、.tl-canvas等关键布局样式缺失,inset 布局会直接失效。 - 注意滚动联动:编辑器会自动检测最近的滚动父级并同步 bounds(见 useScreenBounds.ts),所以把编辑器放进可滚动页面是安全的;但若滚动容器结构非常规(如多层嵌套滚动),仍需实测命中测试是否漂移。
- 避免
fixed定位容器:仓库示例的辅助样式特意用absolute而不用fixed,因为 iOS Safari 18 / macOS 15 上fixed存在渲染问题(见 styles.css 的注释)。 - 逐个编辑器实例化成本:同页多个
<Tldraw>会各自创建独立的编辑器与 UI 实例。演示/对照场景没问题,生产环境建议按需挂载,并处理焦点与persistenceKey的隔离/共享策略。 - 把 README 当元数据用:这类示例 README 采用 YAML front matter 声明元信息,例如 inset 示例声明了
title: Inset editor、component: ./InsetExample.tsx、priority与keywords(inline, embedded, non-fullscreen, layout, container, positioning, inset, wrapper),正文则是面向读者的技术说明。当你浏览 examples 目录挑选可借鉴的代码时,可直接以每个 README 作为"示例目录"来导航。
小结
非全屏 inset 布局在 tldraw 中不是一个需要特殊开启的"模式",而是一种自然行为:<Tldraw> 永远填满并测量自己的容器。理解了根容器 tl-container 与画布 tl-canvas 的样式约定,以及 useScreenBounds.ts 中 ResizeObserver + 滚动监听 + 定时兜底这套测量机制,你就能放心地把编辑器放进页面上任意一块确定尺寸的区域,也可以按 inset-canvas 的思路用纯 CSS 微调画布位置。从单画布缩进、多画布同文档(inline)到画布重排,示例目录 apps/examples/src/examples/layout/ 下的这些案例构成了完整的嵌入式布局实践集,可直接作为自己项目的起点。
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 StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00