tldraw 单形状选择限制:用 before-change 处理器改写 instance_page_state 实现
本篇介绍 tldraw 示例 "Prevent multi-shape selection" 的完整技术实现:如何在不改动 tldraw 内部逻辑的前提下,通过 editor.sideEffects.registerBeforeChangeHandler 拦截 instance_page_state 记录上的选择变更,把多选重写为单选。读完本文,你将掌握 tldraw 选择态的数据模型(selectedShapeIds 存储在哪里)、store 侧效(side effects)钩子的执行时机与返回值语义,以及"改写(rewrite)而不是拒绝(reject)"这一设计取舍背后的原因,并能把同一模式套用到其他"约束式"交互需求上。
示例目标与核心思路
tldraw 默认支持多选:Shift+单击、框选(brush selection)、Ctrl/Cmd+A 全选都会产生包含多个 id 的选择集合。而某些应用场景(比如"当前编辑目标只有一个"的单对象工作流)希望把选择限制为至多一个形状。
官方示例给出的核心思路一句话概括:当前选择存储在 instance_page_state 记录的 selectedShapeIds 字段上;对该记录注册一个 registerBeforeChangeHandler,当传入的变更会把选择变成多于一个形状时,返回一个只保留最后一个 id 的副本。由于 tldraw 内部所有选择方法最终都写入同一条记录,这一个处理器就能同时覆盖 Shift 单击、框选、全选以及你自己代码里 editor.select() 调用这四种入口。
完整示例代码见 PreventMultiShapeSelectionExample.tsx,说明文档见 README。
选择态的数据模型:instance_page_state 与 selectedShapeIds
要理解这个示例,先要弄清楚"当前选中了哪些形状"在 tldraw 中到底存在哪里。答案不是某个组件 state,而是 store 中的一条记录——instance_page_state。其类型定义在 TLPageState.ts:
export interface TLInstancePageState extends BaseRecord<
'instance_page_state',
TLInstancePageStateId
> {
pageId: RecordId<TLPage>
selectedShapeIds: TLShapeId[] // 当前页面的选区,就是这里
hintingShapeIds: TLShapeId[]
erasingShapeIds: TLShapeId[]
hoveredShapeId: TLShapeId | null
editingShapeId: TLShapeId | null
croppingShapeId: TLShapeId | null
focusedGroupId: TLShapeId | null
meta: JsonObject
}
几个关键事实:
selectedShapeIds是TLShapeId数组,空数组表示无选中,长度大于 1 即多选。示例的判断条件next.selectedShapeIds.length > 1正是直接作用在这个字段上。- 该记录的作用域是
session(见同文件中InstancePageStateRecordType的scope: 'session'配置),即每个页面、每个浏览器标签页各有一份,属于"每实例"的交互状态,不随文档内容共享。 - 记录里还带
hoveredShapeId、editingShapeId等字段,说明这条记录承载的是页面级别的整套瞬态 UI 状态——选择只是其中之一。
而写入这条记录的是编辑器统一的选择 API。从 Editor.ts 的实现可以看到,select() 等方法的最终动作就是:
const { selectedShapeIds: prevSelectedShapeIds } = this.getCurrentPageState()
// ...
this.store.put([{ ...this.getCurrentPageState(), selectedShapeIds: ids }])
也就是说,无论上层是哪种交互(Shift 单击、拖框、全选快捷键),最终都收敛为对 instance_page_state 记录的一次 store.put。这就是"一个处理器覆盖所有选择入口"这句话的底层依据:拦截点选在了所有写入路径的公共下游。
拦截机制:registerBeforeChangeHandler 的返回值语义
tldraw 的 store 内置了一套"侧效管理器"(StoreSideEffects,源码见 StoreSideEffects.ts),围绕记录的创建、变更、删除提供 before/after 钩子。本例使用的是 before-change 钩子,其类型签名为:
export type StoreBeforeChangeHandler<R extends UnknownRecord> = (
prev: R, // 当前 store 中的版本
next: R, // 拟写入的新版本
source: 'remote' | 'user'
) => R
返回值决定了这次变更的最终结果,有三种策略:
| 返回值 | 效果 |
|---|---|
return next |
原样放行(默认行为) |
return prev |
完全拒绝本次变更,记录保持旧值 |
return { ...next, ... } |
改写:以修改后的版本入库 |
handleBeforeChange 的内部逻辑(StoreSideEffects.ts)值得注意:同一记录类型上注册的多个 before-change 处理器按注册顺序串联执行,后一个 handler 收到的是前一个 handler 的输出:
handleBeforeChange(prev: R, next: R, source: 'remote' | 'user') {
if (!this._isEnabled) return next
const handlers = this._beforeChangeHandlers[next.typeName] as StoreBeforeChangeHandler<R>[]
if (handlers) {
let r = next
for (const handler of handlers) {
r = handler(prev, r, source)
}
return r
}
return next
}
这意味着本示例的处理器与 tldraw 内部可能存在的其他 instance_page_state 处理器是链式叠加的,改写结果会继续向下游传递。另外第三个参数 source 区分了变更来源:'user'(本地应用逻辑)与 'remote'(同步/外部来源)。本示例未做来源区分,即本地和远端的选择写入都会被约束为单选——如果你只想约束本地操作,可以在 handler 里判断 source。
所有 register*Handler 方法都会返回一个 dispose 回调用于注销该处理器。本示例在 onMount 中注册、未显式清理,对单页编辑器示例来说可以接受;在长期运行的应用中若需动态开关该约束,应保存返回的回调并在适当时机调用。
完整实现代码
下面是 PreventMultiShapeSelectionExample.tsx 的完整实现:
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function PreventMultiShapeSelectionExample() {
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor) => {
// Rewriting to the last id (rather than rejecting) keeps brushing and select-all usable.
editor.sideEffects.registerBeforeChangeHandler('instance_page_state', (prev, next) => {
if (
prev.selectedShapeIds !== next.selectedShapeIds &&
next.selectedShapeIds.length > 1
) {
return {
...next,
selectedShapeIds: [next.selectedShapeIds[next.selectedShapeIds.length - 1]],
}
}
return next
})
}}
/>
</div>
)
}
逐行拆解:
- 注册时机在
onMount:Tldraw组件的onMount回调提供已初始化的editor实例,此时注册 side effect 处理器,保证用户任何交互发生前拦截就已生效。 - 监听记录类型为字符串
'instance_page_state':registerBeforeChangeHandler的第一个参数是记录类型名,处理器只在该类型记录发生变更时被调用,不影响shape、page等其他记录。 - 第一层条件
prev.selectedShapeIds !== next.selectedShapeIds:只有当本次变更真正动了选区字段时才介入,避免对同一条记录上hoveredShapeId、editingShapeId等无关字段的更新做多余计算。 - 第二层条件
next.selectedShapeIds.length > 1:仅在"会变成多选"时触发。注意这是"变成"多选,而非"保持"多选——由于拦截的存在,store 里实际永远不会出现长度大于 1 的selectedShapeIds。 - 改写为
[next.selectedShapeIds[...length - 1]]:保留最后一个 id。对 Shift 单击和框选来说,最后一个 id 通常正是用户刚刚命中的那个形状,语义上符合直觉;示例代码注释也明确写道:Rewriting to the last id (rather than rejecting) keeps brushing and select-all usable. - 兜底
return next:一切非多选变更原样放行。
为什么是"改写"而不是"拒绝"?
这是本示例最值得借鉴的设计取舍。README 中明确点出:"Rewriting the change instead of rejecting it (by returning prev) means the user's action still does something sensible."
对比两种策略的实际效果(以框选三个形状 A、B、C 为例):
- 拒绝(
return prev):整个变更被丢弃,选区停留在之前的状态(可能是空)。用户拖了一圈框却看不到任何响应,操作像是"没生效",体验断裂。 - 改写(返回单元素数组):变更照常入库,选区变为
[C]。用户拖框之后仍然有一个形状被选中,动作有了可见反馈,约束在"静默降级"中完成。
同样的取舍也体现在"取最后一个"而非"取第一个"上:无论哪种交互,用户注意力都停留在最后命中的对象上,保留它能最大化"操作仍然有意义"的感觉。
一个处理器为何能覆盖所有选择入口
README 声称该处理器"covers shift-click, brush selection, select all, and editor.select() calls from your own code"。这不是营销式表述,而是由数据流决定的:如前所述,Editor.ts 中编辑器的选择 API 最终统一执行 this.store.put([{ ...this.getCurrentPageState(), selectedShapeIds: ids }])。在 store 层面,这些写入没有区别——都是对 instance_page_state 记录的一次 update,都会经过 handleBeforeChange 链。因此:
- Shift 单击追加选中 → 产生
[prev..., newId](长度 2)→ 被改写为[newId]; - 框选多个 → 产生多元素数组 → 被改写为最后一个;
- Ctrl/Cmd+A 全选 → 产生全页形状 id 列表 → 被改写为最后一个;
- 你的业务代码
editor.select([...])→ 走同一select()路径 → 同样被约束。
这种"在数据收敛点做拦截"的模式可以推广:凡是你想约束的编辑器行为,只要最终落到某条 store 记录的某个字段上,都可以用对应的 before-change 处理器实现,而无需为每种输入事件单独打补丁。
验证方式与行为边界
按 README 给出的步骤验证:创建几个形状,然后 Shift+单击或拖出选择框划过它们——最终只会有一个形状处于选中状态。
需要注意的行为边界与适用前提:
- 该约束是实例级的,因为
instance_page_state本身是session作用域(见 TLPageState.ts 中scope: 'session'),选择态不跨标签页、不作为文档内容持久化;约束只作用于当前编辑器实例。 - 远端来源同样受约束:handler 未判断
source,如果文档接入了协同同步('remote'来源的写入),远端选择写入也会被改写为单选。若不希望影响协同场景,可在 handler 内区分source === 'user'。 - 处理器可组合、可注销:多个
instance_page_state的 before-change 处理器会按注册顺序串联(见handleBeforeChange实现),返回值可被后续 handler 继续修改;registerBeforeChangeHandler返回的回调可用于注销。 - 如果你希望"完全禁止多选交互"(而不是降级为单选),返回
prev即可——那才是拒绝语义;本文示例选择的是降级语义,因为交互反馈更自然。
小结
这个不到 25 行的示例浓缩了 tldraw 中约束交互的一条通用路径:定位承载目标状态的数据记录(instance_page_state 的 selectedShapeIds)→ 在 store.sideEffects 上注册 before-change 处理器 → 用"改写"而非"拒绝"维持自然的交互反馈。得益于 tldraw 将所有选择入口收敛到同一条记录写入,这一方案对 Shift 单击、框选、全选和 API 调用一次性全部生效,是"在数据收敛点拦截"这一设计思路的典型落地。
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