用 tldraw SDK 构建自由相机幻灯片演示:自定义 Slide Shape 与 zoomToBounds 相机动画全解析
2026/9/10 4:27:35 网站建设 项目流程

用 tldraw SDK 构建自由相机幻灯片演示:自定义 Slide Shape 与 zoomToBounds 相机动画全解析

【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw

tldraw 开源仓库中的slides示例展示了一种极具代表性的“编辑器自定义形状 + 相机控制”组合玩法:用 SDK 注册一个自定义slide图形作为“幻灯片”,把多张幻灯片自由平铺在无限画布上,再通过 UI 覆盖(Toolbar / HelperButtons / actions)把"上一页 / 下一页 / 点击跳转"等交互接到editor.zoomToBounds的 500ms 相机动画上。读完本文,你将掌握在 tldraw 中注册自定义ShapeUtil/工具、向默认工具栏与快捷键对话框注入新工具、通过 actions 覆盖绑定方向键,以及理解zoomToBounds相机动画底层计算逻辑的完整实现路径,可以直接套用到 PPT 编辑器、故事板、流程图演示等场景。

本文以 slides 示例的 README 为骨架,源码佐证均来自同目录实现文件与packages/editor的相机 API。仓库中还有一个名为Slideshow (fixed camera)的对照示例(见 slideshow 目录),它用"相机约束 + frame 图形"把视野锁定在当前页,本文的自由相机版则完全放开平移与缩放,二者互补。

一、示例效果与核心设计

README 用一句话概括了这个示例:

A slideshow built from a custom slide shape, with a free camera that animates between slides.

拆开来看,包含三层设计:

  1. 自定义slide图形:一个未填充的虚线矩形。可以用slide工具(快捷键S)框选拖画出来,把任意其他形状(文字、图形、便签)放进去,然后自由拖动位置。
  2. 幻灯片面板:挂在HelperButtons槽位(画布左上角、菜单下方)的SlidesPanel按页面顺序列出所有 slide;点击某一项,或按键盘/,都会调用editor.zoomToBounds让相机平滑飞向该幻灯片。
  3. 双击边框跳转:双击某张幻灯片的边框同样会触发跳转。由于矩形内部没有被填充,双击内部仍按常规逻辑创建文字,不会误选中幻灯片本身。

"自由相机"(free camera)是它与 fixed camera 版本的本质差异:相机不会被锁定在单张幻灯片上,你随时可以平移、缩放画布去查看任意角落;只是交互上通过相机动画把"当前页"带到视野中心。

二、示例文件构成与各职责

整个示例集中在apps/examples/src/examples/use-cases/slides/目录,文件职责划分非常清晰:

文件职责
SlidesExample.tsx示例入口:组装<Tldraw>,注入 shapeUtils/tools/components/overrides,启动时预置两张幻灯片
SlideShapeUtil.tsx自定义slide图形的ShapeUtil实现(渲染、几何、双击、旋转/缩放手势策略)
SlideShapeTool.tsx自定义工具类,继承BaseBoxShapeTool,实现"框选拖画"出幻灯片
useSlides.tsx响应式逻辑中枢:$currentSlideatom、getSlides排序查询、moveToSlide相机飞行
SlidesPanel.tsx渲染在HelperButtons槽位的幻灯片列表按钮面板
slides.css面板与幻灯片角标标签的样式
README.md示例说明文档(本文骨架来源)

这个分层值得借鉴:形状行为(Util/Tool)、UI 组件(Panel)、响应式状态与相机动作(useSlides)被拆到独立文件,主入口只做装配。

三、第一步:注册自定义slideShapeUtil

3.1 类型声明与 props

SlideShapeUtil.tsx首先通过模块增强声明slide形状的 props 只有wh两个数字字段:

declare module 'tldraw' { export interface TLGlobalShapePropsMap { [SLIDE_TYPE]: { w: number h: number } } } export type SlideShape = TLShape<typeof SLIDE_TYPE> export class SlideShapeUtil extends ShapeUtil<SlideShape> { static override type = SLIDE_TYPE static override props: RecordProps<SlideShape> = { w: T.number, h: T.number, }
  • static override type必须与declare module中的 key('slide')一致,它是图形在文档数据中的唯一标识;
  • props用 tldraw 自带的验证器T.number声明,与@tldraw/tlschema的运行时校验体系一致;
  • getDefaultProps返回w: 720, h: 480(16:9 演示比例),也就是你拖画或由代码创建幻灯片时的默认尺寸。

3.2 几何:未填充的矩形是关键设计

getGeometry(shape: SlideShape): Geometry2d { return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: false, }) }

isFilled: false是"内部空心、不拦截点击"的根源:几何体未填充时,点击矩形内部会命中"放在上面的其他图形",只有点中边框线条才命中 slide 本身。这正是 README 说的"the interior isn't filled, so double-clicking inside it creates text as usual"

3.3 禁用旋转与允许缩放

override canBind() { return false } override hideRotateHandle() { return true } // [1] 旋转被整体禁用 override onRotate(initial: SlideShape) { return initial } override onResize(shape: SlideShape, info: TLResizeInfo<SlideShape>) { return resizeBox(shape, info) }

从源码注释可以看到作者的三处刻意设计:

  • 幻灯片作为"页面容器"不允许被绑到箭头等连接线上(canBind: false);
  • 旋转手柄被隐藏(hideRotateHandle),同时onRotate直接返回初始形状——这样即使经由右键菜单的 rotate actions 等"其他路径"触发旋转,也只会是 no-op,保证幻灯片永远是横平竖直的 16:9 矩形;
  • 缩放仍通过内置的resizeBox工具函数处理,允许你把某张幻灯片整体拉大拉小。

3.4 双击跳转:onDoubleClick / onDoubleClickEdge

override onDoubleClick(shape: SlideShape) { moveToSlide(this.editor, shape) this.editor.selectNone() } override onDoubleClickEdge(shape: SlideShape) { moveToSlide(this.editor, shape) this.editor.selectNone() }

onDoubleClickEdge负责"双击边框"(因未填充,内部双击默认进入文字编辑,不在此路径上);onDoubleClick则覆盖几何中心被双击的情形。两者动作一致:让相机飞到该幻灯片并取消选择,避免"跳到另一页后还带着旧选区"的割裂感。

四、组件化渲染:虚线边框与"Slide N"角标

component方法负责在画布上绘制形状,这里做了三件事(见代码注释 [2]/[3]):

component(shape: SlideShape) { const bounds = this.editor.getShapeGeometry(shape).bounds // [2] 响应式读取当前缩放级别 const zoomLevel = useValue('zoom level', () => this.editor.getZoomLevel(), [this.editor]) const slides = useSlides() const index = slides.findIndex((s) => s.id === shape.id) const handleLabelPointerDown = useCallback(() => this.editor.select(shape.id), [shape.id]) return ( <> <div onPointerDown={handleLabelPointerDown} className="slide-shape-label"> {`Slide ${index + 1}`} </div> <SVGContainer> <g ... pointerEvents="none" strokeLinecap="round" strokeLinejoin="round"> {bounds.sides.map((side, i) => { const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( side[0].dist(side[1]), 1 / zoomLevel, { style: 'dashed', lengthRatio: 6, forceSolid: zoomLevel < 0.2, } ) return <line key={i} x1={...} ... /> })} </g> </SVGContainer> </> ) }

要点拆解:

  • 角标是可选中热区:左上角的Slide N标签通过handleLabelPointerDown把 pointerdown 落到editor.select(shape.id),是全形状上唯一"按下即可选中幻灯片本体"的地方。样式由 slides.css 中的.slide-shape-label提供,背景var(--tl-color-low)、文字var(--tl-color-text)等使用 tldraw 的 CSS 变量保证主题一致。
  • SVG 层 pointerEvents="none":虚线框不拦截任何指针事件,把命中判断完全交给Rectangle2d几何。
  • getPerfectDashProps的缩放补偿:虚线以"屏幕像素"为感知基准,dash 长度用side[0].dist(side[1])(页面坐标下的边长)除以1 / zoomLevel折算,因此无论放大缩小,虚线的视觉疏密都保持恒定;当zoomLevel < 0.2(缩得太远)时通过forceSolid: true退化为实线,避免远处看到一片噪点般的虚线。
  • 外部包一层SVGContainer、用<g>+<line>画四边,是 tldraw 中"与渲染绑定、可随形变同步"的标准写法。

getIndicatorPath则用Path2D返回一个整矩形,作为选中/悬停时的轮廓指示器:

getIndicatorPath(shape: SlideShape) { const path = new Path2D() path.rect(0, 0, shape.props.w, shape.props.h) return path }

五、自定义工具:继承 BaseBoxShapeTool

SlideShapeTool.tsx 是 tldraw 官方推荐的"简版盒子工具"写法:

import { BaseBoxShapeTool } from 'tldraw' export class SlideShapeTool extends BaseBoxShapeTool { static override id = 'slide' static override initial = 'idle' override shapeType = 'slide' as const }

BaseBoxShapeTool已经内置了"按下起点、拖拽出现预览框、松开落成形状"的完整交互状态机(idle → pointing → dragging),子类只需要声明id、初始状态'idle'以及shapeType指向自己注册的'slide'。工具最终通过入口文件传入<Tldraw tools={[SlideShapeTool]}>

六、响应式"当前页":atom + useValue 的组合

useSlides.tsx 是整条交互链路的数据中枢,只用了几行就完成了"当前页"的状态管理和跨模块同步:

export const $currentSlide = atom<SlideShape | null>('current slide', null) export function useSlides() { const editor = useEditor() return useValue<SlideShape[]>('slide shapes', () => getSlides(editor), [editor]) } export function useCurrentSlide() { return useValue($currentSlide) } export function getSlides(editor: Editor) { return editor .getSortedChildIdsForParent(editor.getCurrentPageId()) .map((id) => editor.getShape(id)) .filter((s) => s?.type === 'slide') as SlideShape[] }

值得注意的实现细节:

  • getSlides通过getSortedChildIdsForParent(editor.getCurrentPageId())拿到当前页面所有子形状的排序 id(渲染顺序即 z 序),再过滤出type === 'slide'的项。这就是"幻灯片列表始终按页面顺序排列"的实现来源——即使你在画布上把某张幻灯片拖到别处,面板顺序也不会变。
  • useSlides/useCurrentSlide分别订阅"文档中的 slide 形状集合"与"当前页 atom",任何增删幻灯片或切换当前页都会自动触发面板与角标重渲染。
  • $currentSlide是一个可独立于 React 外部读写的atom,这正是它在键盘 actions(非 React 组件内)中能被直接get()的前提。

七、moveToSlide 与 zoomToBounds 相机动画的底层原理

moveToSlide是全例的"相机动作"心脏(同文件实现):

export function moveToSlide(editor: Editor, slide: SlideShape) { const bounds = editor.getShapePageBounds(slide.id) if (!bounds) return $currentSlide.set(slide) editor.selectNone() editor.zoomToBounds(bounds, { inset: 0, animation: { duration: 500, easing: EASINGS.easeInOutCubic }, }) }

动作顺序是:取该幻灯片页面坐标系边界→ 记录"当前页" → 清空选择 → 让相机以 500ms、easeInOutCubic缓动飞过去。inset: 0表示相机目标视野与幻灯片矩形边缘零留白贴合。

zoomToBounds是编辑器核心 API(见 packages/editor/src/lib/editor/Editor.ts),阅读其源码能更准确地理解这个动画的数学含义:

zoomToBounds(bounds, opts?: { targetZoom?: number; inset?: number } & TLCameraMoveOptions) { // 相机被锁定时(除非 force)直接放弃 if (cameraOptions.isLocked && !opts?.force) return this // 默认 inset 取 zoomToFitPadding 或视口宽的 28% 中较小者 const inset = opts?.inset ?? Math.min(this.options.zoomToFitPadding, viewportScreenBounds.width * 0.28) // 目标 zoom = min(视口(宽,高)减去 inset 后与 bounds 的比值),并夹取在 zoomSteps 范围内 let zoom = clamp( Math.min((viewportScreenBounds.width - inset) / bounds.w, (viewportScreenBounds.height - inset) / bounds.h), zoomMin * baseZoom, zoomMax * baseZoom ) if (opts?.targetZoom !== undefined) zoom = Math.min(opts.targetZoom, zoom) // 反推相机坐标使 bounds 居中,交给 setCamera(animation 参数驱动内部补间) this.setCamera(new Vec( -bounds.x + (viewportScreenBounds.width - bounds.w * zoom) / 2 / zoom, -bounds.y + (viewportScreenBounds.height - bounds.h * zoom) / 2 / zoom, zoom ), opts) }

由此可以得出四个实用结论:

  1. fit 算法:缩放倍率取"让 bounds 恰好放进视口"所需的最小比例,再被zoomSteps(相机步进)与baseZoom限幅,保证不会缩到超出相机配置允许的范围;
  2. 居中公式setCamera的 x/y 是把目标矩形中心投影到视口中心所需的相机偏移,animation选项进入内部补间器后按duration+easing逐帧推进;
  3. inset: 0是"页面贴边"开关:默认值通常会留约 28% 视口宽的呼吸空间,示例刻意置零让幻灯片满屏呈现;
  4. 动画可被中断:配套的stopCameraAnimation通过派发stop-camera-animation事件终止进行中的补间——这在快速连按方向键时至关重要(见下文 actions 实现)。

八、把新工具接入默认 UI:components 与 overrides

tldraw 默认工具栏和快捷键面板不会自动包含第三方新工具,因此 SlidesExample.tsx 用components把两者替换为"默认内容 + 新增 slide 项":

const components: TLComponents = { HelperButtons: SlidesPanel, // 左上角挂上幻灯片列表 Minimap: null, // 关掉小地图,腾出演示视野 Toolbar: (props) => { const tools = useTools() const isSlideSelected = useIsToolSelected(tools['slide']) return ( <DefaultToolbar {...props}> <TldrawUiMenuItem {...tools['slide']} isSelected={isSlideSelected} /> <DefaultToolbarContent /> </DefaultToolbar> ) }, KeyboardShortcutsDialog: (props) => { const tools = useTools() return ( <DefaultKeyboardShortcutsDialog {...props}> <TldrawUiMenuItem {...tools['slide']} /> <DefaultKeyboardShortcutsDialogContent /> </DefaultKeyboardShortcutsDialog> ) }, }

配套地,overrides.toolsslide工具补充 UI 元数据(图标沿用内置'group',快捷键s):

tools(editor, tools) { tools.slide = { id: 'slide', icon: 'group', label: 'Slide', kbd: 's', onSelect: () => editor.setCurrentTool('slide'), } return tools }

这样useIsToolSelected(tools['slide'])TldrawUiMenuItem的选中态与快捷键高亮都有了数据来源。别忘了useTools()必须在替换组件内部调用(依赖Toolbar组件的上下文),这是 tldraw UI 覆盖的标准姿势。

九、方向键翻页:actions 覆盖 + computed 缓存

翻页逻辑注册为两个 UI action(next-slide/previous-slide),分别绑到right/left键:

const $slides = computed('slides', () => getSlides(editor)) actions(editor, actions) { return { ...actions, 'next-slide': { id: 'next-slide', label: 'Next slide', kbd: 'right', onSelect() { const slides = $slides.get() const currentSlide = $currentSlide.get() const index = slides.findIndex((s) => s.id === currentSlide?.id) const nextSlide = slides[index + 1] ?? currentSlide ?? slides[0] if (nextSlide) { editor.stopCameraAnimation() moveToSlide(editor, nextSlide) } }, }, 'previous-slide': { /* kbd: 'left',逻辑对称 */ }, } }

这段代码藏着三个容易忽略的工程细节(源码注释 [2] 有说明):

  1. computed 惰性缓存getSlides被包进computed('slides', ...),只有在底层形状数据真正变化时才重算排序列表,否则每次按键都命中缓存——不会因为"每次方向键都全量排序"造成掉帧;
  2. 越界回绕slides[index + 1] ?? currentSlide ?? slides[0]的意思是:有下一页就翻,没有则停在当前页(若连当前页都失效则回到第一页)。首/末页按下方向键不会报错或跳飞;
  3. 翻页前先stopCameraAnimation:如果上一次 500ms 动画还在进行就立刻触发下一次跳转,先中断旧补间再开新动画,连击方向键时相机动线干净利落,不会出现两段动画互相打架的抖动。

十、启动预置与持久化

SlidesExample.tsx的主组件把以上全部装配进<Tldraw>,并在onMount里为空白画布预置两张 16:9 幻灯片:

<Tldraw persistenceKey="slideshow_example" shapeUtils={shapeUtils} // [SlideShapeUtil] tools={tools} // [SlideShapeTool] components={components} overrides={overrides} onMount={(editor) => { if (getSlides(editor).length === 0) { editor.createShapes([ { type: 'slide', x: 100, y: 100, props: { w: 720, h: 480 } }, { type: 'slide', x: 900, y: 100, props: { w: 720, h: 480 } }, ]) } }} />
  • shapeUtils/tools把第 3~5 节的自定义图形和工具注册进编辑器;
  • 第 8、9 节的components/overrides接管 UI 与动作;
  • persistenceKey="slideshow_example"让画布内容持久化到 localStorage——这也是onMount里要先用getSlides(editor).length === 0判断的原因:只在用户没有任何历史内容时才创建示例幻灯片,避免每次刷新都重置用户已经摆好的讲稿。

十一、SlidesPanel:HelperButtons 槽位里的页签

SlidesPanel.tsx 用track包裹使组件对 atom 响应式;每个 slide 渲染一个TldrawUiButton,点击即调用moveToSlide

export const SlidesPanel = track(() => { const slides = useSlides() const currentSlide = useCurrentSlide() const selectedShapes = useValue('selected shapes', () => editor.getSelectedShapes(), [editor]) if (slides.length === 0) return null // 每个按钮: // - onClick={() => moveToSlide(editor, slide)} // - background: 当前页高亮为 var(--tl-color-background) // - outline: 被选中(框选到)的页画 1.5px 选中描边 })

面板本身加了一层交互细节:容器上onPointerDown={editor.markEventAsHandled}防止按下面板时触发画布上的形状交互;外层包装为<div className="tldraw__editor">,配合 slides.css 中.slides-panel的纵向 flex 布局、var(--tl-color-low)底色、var(--tl-radius-4)圆角与 50px 上下外边距,让列表悬浮于画布左侧。该示例没有选择把面板做成"跟随缩放"的画布内组件,而是放在固定屏幕坐标的 UI 槽位——这也是HelperButtons槽与OnTheCanvas组件(fixed camera 版用来插页的按钮用的就是后者)在使用场景上的典型分工。

十二、自由相机 vs 固定相机:两种演示形态的取舍

README 的结尾提醒读者与Slideshow (fixed camera)对照阅读,两个示例正好展示同一需求的两条技术路线:

维度本示例(free camera)slideshow(fixed camera)
幻灯片载体自定义slide图形(未填充虚线矩形)内置frame图形排成一行
相机控制自由,翻页仅做zoomToBounds动画setCameraOptions({ bounds, behavior: 'contain' })把相机锁在当前帧,无法平移/缩走
状态管理$currentSlideatom + computed小规模SlidesManager(atom + computed getters)
扩展操作面板点击 / 双击边框 / 左右方向键额外用副作用禁止帧被移动/选择/悬停,并提供画布内插页按钮

实际选型建议:如果演示者需要"在白板上来回看、在页与页之间自由游走",自由相机方案交互更轻盈、实现更少;如果产品形态是"严格的一页一屏、只能顺序放映",fixed camera 方案用约束替你把控边界,更能防呆。

十三、如何在本地运行与验证

该示例运行在 examples 应用(Vite)中。仓库以 package.json 的 workspaces 管理 monorepo,常见做法是在仓库根目录安装依赖后启动apps/examples

yarn install # 仓库使用 yarn(见 yarn.lock) yarn dev --filter @tldraw/examples # 或进入 apps/examples 后执行对应的 dev script

在启动后的示例浏览器里定位use-cases > slides即可交互验证:按S拖画新的幻灯片、往里画内容、点击左上角面板切页、按/连续翻页(观察 500ms 缓动与快速连按时动画被打断的行为)、双击某页边框直接跳转,再对比同列表下的slideshow(fixed camera)感受两种相机的差异。


延伸阅读(仓库内可继续深挖的关联实现):

  • 自定义形状的系统级注册与ShapeUtil生命周期:packages/editor/src/lib/editor/shapes 目录中的内置 Util 实现;resizeBoxgetPerfectDashProps等工具位于 packages/editor/src/lib/editor/shapes/shared;
  • 相机选项(zoomStepsconstraintsisLocked)定义见 options.ts;
  • 状态原语atom/computed/useValue/track的文档与源码见 packages/state 与 packages/state-react,是理解本示例响应式链路($currentSlide+ 缓存排序)的理论基础。

【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询