首页
/ tldraw 单形状选择限制:用 before-change 处理器改写 instance_page_state 实现

tldraw 单形状选择限制:用 before-change 处理器改写 instance_page_state 实现

2026-09-07 18:00:32作者:瞿蔚英Wynne

本篇介绍 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
}

几个关键事实:

  • selectedShapeIdsTLShapeId 数组,空数组表示无选中,长度大于 1 即多选。示例的判断条件 next.selectedShapeIds.length > 1 正是直接作用在这个字段上。
  • 该记录的作用域是 session(见同文件中 InstancePageStateRecordTypescope: 'session' 配置),即每个页面、每个浏览器标签页各有一份,属于"每实例"的交互状态,不随文档内容共享。
  • 记录里还带 hoveredShapeIdeditingShapeId 等字段,说明这条记录承载的是页面级别的整套瞬态 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>
	)
}

逐行拆解:

  1. 注册时机在 onMountTldraw 组件的 onMount 回调提供已初始化的 editor 实例,此时注册 side effect 处理器,保证用户任何交互发生前拦截就已生效。
  2. 监听记录类型为字符串 'instance_page_state'registerBeforeChangeHandler 的第一个参数是记录类型名,处理器只在该类型记录发生变更时被调用,不影响 shapepage 等其他记录。
  3. 第一层条件 prev.selectedShapeIds !== next.selectedShapeIds:只有当本次变更真正动了选区字段时才介入,避免对同一条记录上 hoveredShapeIdeditingShapeId 等无关字段的更新做多余计算。
  4. 第二层条件 next.selectedShapeIds.length > 1:仅在"会变成多选"时触发。注意这是"变成"多选,而非"保持"多选——由于拦截的存在,store 里实际永远不会出现长度大于 1 的 selectedShapeIds
  5. 改写为 [next.selectedShapeIds[...length - 1]]:保留最后一个 id。对 Shift 单击和框选来说,最后一个 id 通常正是用户刚刚命中的那个形状,语义上符合直觉;示例代码注释也明确写道:Rewriting to the last id (rather than rejecting) keeps brushing and select-all usable.
  6. 兜底 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.tsscope: 'session'),选择态不跨标签页、不作为文档内容持久化;约束只作用于当前编辑器实例。
  • 远端来源同样受约束:handler 未判断 source,如果文档接入了协同同步('remote' 来源的写入),远端选择写入也会被改写为单选。若不希望影响协同场景,可在 handler 内区分 source === 'user'
  • 处理器可组合、可注销:多个 instance_page_state 的 before-change 处理器会按注册顺序串联(见 handleBeforeChange 实现),返回值可被后续 handler 继续修改;registerBeforeChangeHandler 返回的回调可用于注销。
  • 如果你希望"完全禁止多选交互"(而不是降级为单选),返回 prev 即可——那才是拒绝语义;本文示例选择的是降级语义,因为交互反馈更自然。

小结

这个不到 25 行的示例浓缩了 tldraw 中约束交互的一条通用路径:定位承载目标状态的数据记录(instance_page_stateselectedShapeIds)→ 在 store.sideEffects 上注册 before-change 处理器 → 用"改写"而非"拒绝"维持自然的交互反馈。得益于 tldraw 将所有选择入口收敛到同一条记录写入,这一方案对 Shift 单击、框选、全选和 API 调用一次性全部生效,是"在数据收敛点拦截"这一设计思路的典型落地。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388