
用 tldraw 构建数学练习册式嵌入式画布Education Canvas 示例深度拆解【免费下载链接】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/tldrawtldraw SDK 不仅能制作全屏自由绘图应用也能嵌入到「左侧题目、右侧答题画布」这样的真实页面布局中。本文以仓库 apps/examples 中的education-canvas教育画布用例为骨架逐段拆解它是如何用options.camera相机约束把学生锁定在一个 600×600 的坐标网格内、用toolsUI 覆写精简工具栏、用maxPages: 1隐藏页面菜单、再通过OnTheCanvas槽渲染一块随相机平移缩放的 SVG 网格。读完本文你将掌握把 tldraw 改造成受约束的课堂答题工具的完整套路并能迁移到测验、签名板、工程设计纸等同类场景。一、示例全景一页可提交的数学作业关联文档与示例位于仓库的 use-cases 目录文档apps/examples/src/examples/use-cases/education-canvas/README.md组件源码apps/examples/src/examples/use-cases/education-canvas/EducationCanvasExample.tsx样式apps/examples/src/examples/use-cases/education-canvas/education-canvas.css整个页面是一个flex双栏布局左侧是带说明卡片的几何题面板右侧是一个被描边圆角容器包裹的Tldraw /画布。这与绝大多数 tldraw 演示「画布撑满视口」不同——示例把 tldraw 组件作为页面中的一个有边界的控件来嵌入更贴近真实教育产品的形态。页面结构用两组 div 组织.education-container外层 flex、.question-panel题目栏与.canvas-panel/.canvas-container画布栏EducationCanvasExample.tsx中的返回 JSX 完整呈现了这两栏的分工return ( div classNameeducation-container div classNamequestion-panel {/* 标题、题目卡片、答题输入框、Submit 按钮、Instructions 卡片 */} /div div classNamecanvas-panel div classNamecanvas-container Tldraw options{options} persistenceKeyeducation-canvas components{components} overrides{overrides} onMount{handleMount} / /div /div /div )题目是典型中学几何题三角形ABC顶点A(2,3)、B(6,3)、C(4,7)。Part A 要求学生在网格上画出三角形Part B 计算面积Part C 找出使ABCD构成平行四边形的点D的坐标。学生使用受约束的画布作答在左侧输入框键入 Part B / Part C 的结果并点击提交。布局与响应式细节见 education-canvas.cssmedia (max-width: 1000px)时双栏切换为纵向堆叠、画布固定 60vh 高度保证平板与窄屏课堂上画布依然可用。二、把相机锁进坐标系options.camera 约束详解这是整个示例技术含量最高的一处。为了让学生的画笔始终停留在网格范围内不滑出坐标系、不被任意平移拖走、初始视图恰好完整显示网格示例通过TldrawOptions.camera.constraints设置了相机约束const GRID_SIZE 600 const options: PartialTldrawOptions { maxPages: 1, camera: { constraints: { initialZoom: fit-max, baseZoom: fit-max, bounds: { x: 0, y: 0, w: GRID_SIZE, h: GRID_SIZE }, behavior: { x: contain, y: contain }, padding: { x: 100, y: 100 }, origin: { x: 0.5, y: 0.5 }, }, }, }这段配置在示例源码中标注为[1]其注释总结了三件事bounds就是网格本身页面空间里一块 600×600 的区域原点在(0,0)behavior: contain让相机在缩放低于「刚好填满视口」的临界值时把网格固定在视口内阻止学生把画布平移到网格之外fit-max让初始缩放自动适配使整张网格恰好完整可见并保留约 100px 的留白。2.1 各字段的真实语义来自 editor 包类型定义相机相关类型定义在 packages/editor/src/lib/editor/types/misc-types.tsTLCameraOptions包含isLocked、panSpeed、zoomSpeed、zoomSteps、wheelBehavior以及可选的constraintsTLCameraConstraints则由bounds页面空间中受约束区域的边界、padding视口内的屏幕空间留白、origin网格在视口内的定位原点以及两类缩放策略构成initialZoom初始缩放也用于相机被 reset 时。取值包括default100%、fit-x、fit-y、fit-min、fit-max以及各自带-100后缀的变体取 fit 值与 100% 中较小的一个baseZoom缩放步进系统的基准同样支持上述取值behavior支持free忽略边界、fixed按 origin 把 bounds 定位在视口内、contain低于填满缩放下使用 fixed高于该缩放则退化为 inside、insidebounds 完全留在视口内、outsidebounds 始终贴着视口等行为。2.2 约束在编辑器内部如何生效约束不是装饰品它真实参与了编辑器的相机计算。在 packages/editor/src/lib/editor/Editor.ts 中编辑器通过getInitialZoom()、getBaseZoom()读取constraints?.initialZoom ?? default与constraints?.baseZoom ?? default并由_getFitZoom把诸如fit-max的语义换算成具体的缩放系数缩放上限与下限也以baseZoom为基准乘上zoomSteps的最大/最小项例如zoomMax * baseZoom、zoomMin * baseZoom。换句话说你设置的initialZoom/baseZoom会直接影响zoomToFit、缩放步进和相机 reset 后的默认画面。另外相机选项的出厂默认值定义在 packages/editor/src/lib/constants.ts 的DEFAULT_CAMERA_OPTIONS中TldrawOptions接口及其全部默认值含maxPages: 40、maxShapesPerPage: 4000等集中在 packages/editor/src/lib/options.ts 的defaultTldrawOptions配置为PartialTldrawOptions即可只覆盖你关心的键。2.3 一个小而关键的坑reset 才应用初始缩放示例在onMount里还有一句容易被忽略的代码const handleMount useCallback((editor: Editor) { rEditor.current editor // Camera options only set the constraints; a reset applies the initial zoom. editor.setCamera(editor.getCamera(), { reset: true }) }, [])代码注释明确指出options.camera里的约束只是设置规则真正把initialZoom落到画面上的是带{ reset: true }的相机设置。editor.setCamera(camera, { reset: true })的reset标志在Editor.ts的相机 API 中被定义为「将相机重置到其默认位置与缩放」——也就是依照约束重新计算并应用一次初始视角。这一点在迁移到自己的项目时极易踩坑如果只配 constraints 而不做 reset初始画面可能仍停留在默认的 100% 缩放而不是按fit-max适配整张网格。三、用 tools UI 覆写精简工具栏课堂画布不需要全套工具。示例通过TLUiOverrides的tools钩子把所有工具遍历一遍并删掉不在白名单里的项const overrides: TLUiOverrides { tools: (_editor, tools) { const allowedTools [select, hand, draw, eraser, line, text] for (const key in tools) { if (!allowedTools.includes(key)) { delete tools[key] } } return tools }, }这段代码源码注释[2]说明了两层机制tools覆写回调会收到当前注册的全部工具以工具 id 为 key 的对象直接在返回对象上delete不需要的工具会同时把它们从工具栏和键盘快捷键中移除——这正是练习册要的效果学生只能使用与几何作图相关的「选择、抓手、画笔、橡皮、直线、文本」六种工具无法切换到箭头、框架、便签等不相关的形状。TLUiOverrides是 tldraw 官方 UI 层的覆写机制tools 只是其中一类钩子同套机制还支持 actions、menus、keyboard shortcuts 等自定义。整体在packages/tldraw包的 tldraw 组件中被消费组件本身定义于 packages/tldraw/src/lib/Tldraw.tsx。在界面层示例的 Instructions 卡片还用kbd标注了快捷键方便学生直接上手画笔D、直线L、文本T。这也与「删除未允许工具即清除其快捷键」的行为互相印证——保留的工具仍能通过快捷键快速激活。四、maxPages: 1作业只有一页options里的maxPages: 1与相机约束并列const options: PartialTldrawOptions { maxPages: 1, camera: { ... }, }页面系统的上限默认是maxPages: 40见 packages/editor/src/lib/options.ts 的defaultTldrawOptions。把它压到 1 后UI 层会自动隐藏页面菜单与翻页入口——源码注释[1]说得很直白学生只会拿到唯一一张可交的纸无法新建页面或在多个页面间分散作答。对教师端来说这也意味着回收/评分时永远只面对单页内容数据结构上更简单。把「限制」当作「功能」是本示例的设计哲学不是禁用某个能力而是把能力边界收缩到恰好匹配教学流程。五、OnTheCanvas让 SVG 网格跟相机一起动坐标网格不能是普通的页面背景——它需要画在页面空间里随用户的缩放与平移一起移动并位于图形之下作为参照。示例把坐标系渲染成一块独立 SVG注入到OnTheCanvas槽位const components: TLComponents { OnTheCanvas: CartesianGrid, }5.1 SVG 坐标系如何绘制CartesianGrid是一个memo过的纯 SVG 组件源码注释[3]const TICKS 8 const step 600 / (TICKS * 2)网格以原点(0,0)居中横向、纵向各 8 格共 17 条线单位步长由600 / (TICKS * 2)计算得出坐标轴主线i TICKS所在行列透明度为1其余网格线透明度仅0.16突出主轴的视觉权重刻度值范围是-8 … 8纵轴数值反向标注页面坐标的 y 向上为正值区并在每个整点处绘制小刻度线line与文字-TICKS index对应每个line使用独立的key避免 React 复用告警。需要强调这块 SVG 的定位由 CSS 负责见 education-canvas.css 中的.cartesian-gridposition: absolute; width/height: 600px; overflow: visible且渲染在 tldraw 的OnTheCanvas槽内。5.2 OnTheCanvas 槽的内部机制从 editor 包实现看OnTheCanvas是TLComponents众多槽位之一。画布组件在渲染 shape 层的同时会挂载OnTheCanvasWrapper见 packages/editor/src/lib/components/default-components/DefaultCanvas.tsx它从useEditorComponents()取出OnTheCanvas组件并渲染未提供则渲染null。TLComponents的类型定义在 packages/editor/src/lib/hooks/EditorComponentsContext.tsx 的EditorComponentsContext中OnTheCanvas?: ComponentType | null。「画在 OnTheCanvas 槽里」意味着该 SVG 处于页面坐标系天然随相机平移与缩放又因为它是「画布组件内容」的一部分层级上低于可交互的图形正好作为学生画三角的坐标底纸。六、作答交互受控输入、容错校验与反馈左侧题目面板是一个完全受控的 React 表单。示例维护两份答案状态const [answers, setAnswers] useState({ partB: , // 面积数值 partC: , // 坐标 (x, y) })输入框通过handleAnswerChange(part, value)更新对应字段。提交逻辑handleSubmit做了两件值得借鉴的事6.1 宽容的答案归一化学生可能输入8、8 square units、8 units²、(0,7)、(0, 7)或0,7。示例用两步归一化处理这种差异const normalizeAnswer (answer: string) answer.toLowerCase().replace(/[^a-z0-9(),.-]/g, )先转小写、剔除标点空格等干扰字符再做包含判断Part B 要求包含8且出现square/unit字样或就是裸的8Part C 要求同时包含0与7并满足括号坐标、逗号坐标或顺序无关的0…7/7…0形态之一。这种策略把「判断对错」从硬编码精确字符串中解放出来贴近真实学生作答时的各种输入习惯。6.2 分档反馈校验结果分三档提示全对 →alert(Good job! Both answers are correct!)只对一部分 → 指出哪一问正确、哪一问需要检查如Check your area calculation for Part B.全错 → 提示重新检查后提交。示例还贴心地把每条提示与具体题干关联让学生能定位到自己的薄弱点。这套「归一化 → 匹配 → 分档反馈」的骨架稍加改造即可套用到任何填空题、坐标题、单位换算题的自动批改中。七、从练习到提交onMount 里的 editor 引用与导出示例的最后一块拼图是如何把「学生画的图」与「键入的答案」打包带走。核心做法是把编辑器实例保存在ref中见handleMount并在提交时取出。源码在handleSubmit的末尾预留了真实场景的钩子// [4] const editor rEditor.current if (editor) { // e.g. await editor.toImage([...editor.getCurrentPageShapeIds()]) for part A }注释[4]说明了设计取舍提交时才需要访问编辑器因此不必把编辑器放进触发频繁重渲染的 React state一个由onMount写入的useRefEditor | null就足够。若要做成真正的线上作业系统这里正是调用editor.toImage把 Part A 的作答图形导出为图片如 PNG/JPEG/SVG再与answers.partB、answers.partC一起 POST 到后端的注入点。onMount回调签名接收完整的Editor实例此处同时承担了「应用相机初始视角」的职责见上文 2.3这是 tldraw 惯用的「实例就绪后做一次性初始化」入口persistenceKeyeducation-canvas则让画布内容持久化到本地存储刷新页面后学生的草稿不会丢失。八、技术要点小结嵌入式使用Tldraw /可以放在任何有尺寸的容器里配合flex或 grid 布局做成「题目 画布」的双栏产品界面不必全屏。相机约束options.camera.constraints用bounds behavior padding origin initialZoom/baseZoom把画布限制在一个有限坐标系内记得用editor.setCamera(camera, { reset: true })触发初始视角fit-max使整张 600×600 网格完整入画。UI 覆写TLUiOverrides.tools里delete工具对象成员可同步从工具栏与快捷键中移除工具快速定制适合教学的最小工具集。单页强制maxPages: 1隐藏页面菜单确保作业只有一张「可交的纸」默认上限 40 页。背景网格用components.OnTheCanvas槽渲染页面空间内的 SVG 坐标系随相机一起缩放平移、置于图形下层。收尾流程onMount将Editor存入ref提交时调用editor.toImage导出图形与答案文本一并上传。上述示例运行于仓库的 apps/examples 应用内可直接在该目录下启动本地示例环境查看完整组件与样式分别见 EducationCanvasExample.tsx 与 education-canvas.css。如果你正在规划在线作业、自动批改或「受限画布答题」类产品这份用例从约束、工具集到网格与提交导出提供了一条可以直接照搬的完整参考链路。【免费下载链接】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),仅供参考