- 前端
- 桌面应用
- AI 应用
- MCP 服务
【免费下载链接】open-pencil
AI-native design editor. Open-source Figma alternative.
BindableValue 是 OpenPencil 设计编辑器 SDK 中负责"变量 / 设计 Token 绑定"的核心原语,它把字段值与编辑器存储解耦,让任意自定义控件都能通过BindingProvider获得绑定能力。读完本文,你将掌握BindableValueRoot / Trigger / Picker三个部件的职责划分、三种编辑策略(detach-on-edit、readonly-when-bound、edit-variable)的事务语义,以及如何从零实现一个可运行、可复用的BindingProvider。
一、BindableValue 是什么
按官方文档(bindable-value.md)的定义:
BindableValue composes variable or token binding with fields without coupling the field to a specific editor store. Applications supply a
BindingProvider; NumberField consumes the context automatically when nested beneathBindableValueRoot.
它解决的核心问题是:控件(如 NumberField、颜色字段)只负责"展示与编辑",绑定关系(当前值是否来自某个变量 / 外部 Token)完全由应用注入的BindingProvider决定。这样字段组件不感知具体编辑器存储,多个编辑器外壳(Shell)可以复用同一套无头(headless)绑定状态。
在 OpenPencil 的属性面板体系中,这一原语被明确推荐为"绑定感知字段"的标准做法。官方指南 property-panels.md 要求:能引用变量或外部设计 Token 的字段,应当用BindableValueRoot包裹。
二、Anatomy:三个部件的职责
BindableValue 由三个组件组合而成,对应源码位于 packages/vue/src/primitives/BindableValue/ 目录:
| 部件 | 职责 | 源码 |
|---|---|---|
BindableValueRoot | 绑定状态、策略、解析值、Picker 状态与全部动作 | BindableValueRoot.vue |
BindableValueTrigger | 多态(polymorphic)绑定选择器触发器 | BindableValueTrigger.vue |
BindableValuePicker | 基于 Reka UI Combobox 的无渲染(renderless)组合 | BindableValuePicker.vue |
BindableValueRoot
Root 是无渲染组件,唯一的插槽default接收完整的"渲染契约"(见 types.ts 中的BindableValueSlotProps),把以下状态与动作全部交给使用方自由渲染:
- 绑定状态
state、绑定变量variable、解析值resolvedValue; - 策略
policy、Picker 开关open、搜索词searchTerm、变量列表variables; - 状态属性
stateAttrs(见下)与一组动作actions。
Root 的 props 定义在 types.ts:
provider:绑定实现,缺省时回退到最近注入的 Provider;targets:参与本次绑定的 "节点/属性" 键值对数组;value:目标未被一致绑定时的字段直接值;policy:字段被一致绑定时的编辑行为,默认'detach-on-edit';batchLabel:一次字段交互事务的 Undo 标签,默认'Edit bound value'。
对应实现见 BindableValueRoot.vue(policy: policyProp = 'detach-on-edit'、batchLabel = 'Edit bound value')。
stateAttrs会生成一组data-*语义属性(types.ts):
data-unresolved/data-unbound/data-bound/data-mixed:四种绑定状态;data-picker-open:Picker 是否打开;data-policy:当前策略值。
这让 CSS 可以纯靠属性选择器(如data-[bound]:text-...)区分视觉态,示例见 States.vue。
BindableValueTrigger
Trigger 是一个基于 Reka UIPrimitive的多态按钮(BindableValueTrigger.vue),默认渲染为button,也可通过as/asChild换成任意元素。它自动透传stateAttrs,并补充可访问性语义:
aria-expanded:绑定 Picker 打开状态;aria-haspopup="listbox":声明弹出的是列表框;- 点击时调用
ctx.actions.togglePicker()。
BindableValuePicker
Picker 是对 Reka UIComboboxRoot的薄封装(BindableValuePicker.vue):
:model-value绑定当前选中的变量;:ignore-filter="true"关闭内置过滤,由 Provider 的filterVariables(searchTerm)负责检索;- 选中项(含
id的对象)会触发ctx.actions.bind(value.id)完成绑定; open/update:open与 Root 的 Picker 状态双向同步。
值得注意:Picker 打开与关闭本身是非破坏性的,不会解绑任何目标。
三、绑定状态机:四种状态
BindingState定义在 binding-provider/types.ts,共四种:
| 状态 | 含义 |
|---|---|
unbound | 所有目标均无绑定 ID |
bound | 所有目标绑定到同一变量且均可解析出值 |
mixed | 多个目标绑定 ID 不一致,或解析值不一致 |
unresolved | 目标绑定的变量 ID 找不到对应变量(如变量已被删除),或值无法解析 |
getState由 Provider 自行实现,Root 只消费结果(BindableValueRoot.vue)。官方文档的 Provider 示例给出了一个典型实现:收集所有目标的绑定 ID 集合,0 个或仅含undefined视为unbound,多于 1 个视为mixed,随后检查变量存在性与解析值。
设计上,bindingId(存储的变量身份)在变量被删除后仍然保留(见 types.ts 的注释),这是unresolved状态得以呈现的前提:字段仍知道"曾绑定了什么",只是暂时无法解析。
四、BindingProvider 接口全解析
BindingProvider<V>是绑定能力的核心契约,完整定义在 binding-provider/types.ts:
必选方法:
listVariables(): Variable[]— 列出全部可绑定变量;filterVariables(term): Variable[]— 按搜索词过滤变量(Picker 检索依赖它);getBindingId(target)— 读取目标的绑定变量 ID;getBound(target)— 按目标返回已绑定的Variable对象(Variable类型来自@open-pencil/scene-graph);getState(targets)— 计算多目标绑定状态;resolve(variableId, target?)— 将变量解析为具体值;bind(target, variableId)— 建立绑定;unbind(target)— 解除绑定。
可选能力(渐进增强):
revision?: Readonly<Ref<unknown>>— 响应式修订号,Root 在计算状态与解析值时读取它(见 BindableValueRoot.vue),保证绑定变更能驱动 UI 更新;create?(target, value, name)— 创建新变量并绑定到目标;prepareEdit?(variableId, target)— 为edit-variable策略准备编辑句柄,返回BindingValueEdit<V>;runBatch?(label, action)— 以"立即执行"方式包裹一次批量操作(用于 bind/unbind/create);beginBatch?(label)/commitBatch?()/rollbackBatch?()— 开启 / 提交 / 回滚一次事务批次。
BindingValueEdit<V>(types.ts)包含:稳定的编辑键key、当前值value、写入器set(next)与恢复回调restore(),是edit-variable策略"不改变目标、只改变量"的基石。
五、三种编辑策略(Policies)
BoundEditPolicy定义在 binding-provider/types.ts,官方文档对三者语义有精确描述:
detach-on-edit(默认)
在第一次值变更时解除目标的绑定,并把完整交互放进一个 Provider Undo 批次中。用户修改字段后,目标转为普通字段值;绑定关系被"分离"但整个交互可一次撤销。
readonly-when-bound
阻止字段编辑、指针拖拽(scrub)与键盘步进。当字段被一致绑定时,beginMutation直接返回false(BindableValueRoot.vue),交互不会启动。
edit-variable
使用provider.prepareEdit()捕获稳定的编辑键、当前值、setter 与恢复回调,不改动目标的值,而是直接编辑变量本身。这对应"直接改设计 Token"的创作模式。若 Provider 未实现prepareEdit,该策略下绑定的字段无法编辑(BindableValueRoot.vue)。
交互触发条件
官方文档强调:聚焦绑定字段或打开 Picker 是非破坏性的;策略只在以下三种实际变更时启动:
- 用户输入了不同的草稿值;
- 用户步进(step)了值;
- 指针拖拽(scrub)越过阈值。
提交未变更的字段不会产生 Undo 条目;取消操作会回滚已打开的 Provider 批次;不支持 Undo 的 Provider 仍然会收到绑定变更,且会尽量恢复绑定快照(见下节)。
六、交互事务:begin / apply / commit / cancel
Root 的整个交互流程由 BindableValueRoot.vue 中的四个动作驱动,BindableValueActions定义见 types.ts:
beginMutation(source):启动一次交互(source为BindingMutationSource,即'edit' | 'scrub' | 'step')。流程要点:unresolved状态拒绝启动;已绑定且策略为readonly-when-bound时拒绝;edit-variable需prepareEdit成功;未绑定目标会先snapshotBindings()快照绑定关系;支持批次时调用beginProviderBatch(batchLabel);detach-on-edit/mixed状态下对全部目标执行unbind;applyValue(next):在edit-variable交互中逐条调用edit.set(next)写入变量;commitMutation():提交批次并清理交互状态;cancelMutation():有批次支持时rollbackProviderBatch()整体回滚;否则走restoreWithoutRollback()—— 对分离交互按快照重新bind,对变量编辑逐条edit.restore()。
生命周期兜底同样完善:组件卸载(onBeforeUnmount)与停用(onDeactivated)都会执行cancelMutation并关闭 Picker(BindableValueRoot.vue),并配合useRetainedActivity在活动状态切换时自动收尾,避免悬挂事务。这正是官方文档"一次交互 = 一个 Provider Undo 批次"语义的源码级实现。
七、上下文注入与 NumberField 自动消费
三个部件通过 Vue 依赖注入共享上下文,实现见 context.ts:
BINDABLE_VALUE_KEY:以Symbol('BindableValue')作为注入键;provideBindableValue(context):由 Root 在建立时提供完整上下文;useBindableValue():Trigger / Picker 等后代组件读取上下文,不在 Root 内使用会抛出异常('[open-pencil] BindableValue part must be used inside BindableValueRoot');useOptionalBindableValue():可空版本,供"有绑定则感知、无绑定也可用"的控件消费。
NumberField正是这种可选消费的典型。在 NumberFieldRoot.vue 中,它通过useOptionalBindableValue<number>()读取外围绑定上下文,进而:
- 当
state === 'bound'且可解析时,直接展示resolvedValue(同一行号附近)——这就是官方文档所说"聚焦 NumberField 或打开 Picker 非破坏"的视觉基础; - 根据
policy推导实际编辑策略(readonly/detach-on-edit等); - 交互时调用
binding.actions.beginMutation(source)、applyValue(normalized)、commitMutation()/cancelMutation(),把字段交互完整委托给绑定层(NumberFieldRoot.vue)。
因此,只要把NumberFieldRoot嵌套在BindableValueRoot之下,它就会自动获得绑定感知能力,无需任何额外配置。
八、完整 Provider 实现示例
官方文档给出了一个可运行的BindingProvider<number>参考实现(bindable-value.md),下面完整保留并补充说明:
import type { Variable } from '@open-pencil/scene-graph' import type { BindingProvider, BindingTarget } from '@open-pencil/vue' const variable: Variable = { id: 'spacing/md', name: 'Spacing / Medium', type: 'FLOAT', collectionId: 'spacing', valuesByMode: { default: 16 }, description: '', hiddenFromPublishing: false } const values = new Map<string, number>([[variable.id, 16]]) const bindings = new Map<string, string>() const getBindingId = (target: BindingTarget) => bindings.get(`${target.nodeId}:${target.path}`) const resolve: BindingProvider<number>['resolve'] = id => values.get(id) const provider: BindingProvider<number> = { listVariables: () => [variable], filterVariables: term => variable.name.toLowerCase().includes(term.toLowerCase()) ? [variable] : [], getBindingId, getBound: target => getBindingId(target) === variable.id ? variable : undefined, getState: targets => { const ids = new Set(targets.map(getBindingId)) if (ids.size === 0 || (ids.size === 1 && ids.has(undefined))) return 'unbound' if (ids.size > 1) return 'mixed' if (!ids.has(variable.id)) return 'unresolved' const resolved = targets.map(target => resolve(variable.id, target)) if (resolved.some(value => value === undefined)) return 'unresolved' return new Set(resolved).size > 1 ? 'mixed' : 'bound' }, resolve, bind: (target: BindingTarget, variableId) => { bindings.set(`${target.nodeId}:${target.path}`, variableId) }, unbind: (target: BindingTarget) => { bindings.delete(`${target.nodeId}:${target.path}`) } }要点解读:
- 绑定关系以
`${nodeId}:${path}`为键存储——BindingTarget就是{ nodeId, path }二元组(binding-provider/types.ts),与场景图节点属性一一对应; getState必须覆盖unbound / mixed / unresolved / bound四种分支,多目标场景先比绑定 ID 再比解析值;- 该示例未实现可选能力(批次、prepareEdit、revision),因此只支持默认的
detach-on-edit策略,且无 Undo 批次——回退到快照恢复路径。
仓库中还提供了一个更完整的示例:演示组件 States.vue 的 Provider 额外实现了revision(每次绑定变更自增,驱动 UI 响应)、prepareEdit(返回含set/restore的BindingValueEdit)与create(创建新变量并绑定),覆盖了三种策略与多目标mixed场景。
九、组合使用:字段、触发器与 Picker
把三部件拼起来的标准形态如下(节选自 States.vue):
<BindableValueRoot v-slot="{ open, stateAttrs }" :provider="provider" :targets="pickerTarget" :value="pickerValue" > <div v-bind="stateAttrs" class="relative"> <BindableValueTrigger class="rounded bg-[var(--vp-c-bg-alt)] px-2 py-1 text-xs" aria-label="Choose binding" > Choose variable </BindableValueTrigger> <BindableValuePicker v-if="open" v-slot="{ variables: options, actions }"> <div class="absolute top-full left-0 z-10 mt-1 w-40 ..."> <button v-for="option in options" :key="option.id" type="button" @click="actions.bind(option.id)" > {{ option.name }} </button> </div> </BindableValuePicker> </div> </BindableValueRoot>而绑定字段的标准形态(以detach-on-edit为例)则是把NumberFieldRoot放入 Root 插槽,并把stateAttrs合并到字段容器上(States.vue),再通过@pointerdown="!editing && actions.startScrub($event)"把拖拽交互接入 NumberField。其余示例分别演示了policy="readonly-when-bound"与policy="edit-variable",以及把state直接渲染出来的mixed场景(States.vue)。
十、属性面板中的实践准则
官方 property-panels.md 为绑定感知字段总结了五条准则,均与该原语的源码语义一一对应:
- 字段空闲时展示变量身份,把解析值放到支撑性 UI(如 tooltip)中——对应
variable/resolvedValue的分离暴露;OpenPencil 应用皮肤在空闲时显示紫色变量名胶囊,进入编辑模式后 NumberField 才展示解析出的数值; - 聚焦或打开 Picker 不得解绑——
beginMutation的启动条件保证了这一点; - 仅在用户真正变更值时才应用三种策略——
detach-on-edit等在beginMutation后才生效; - 把显式解绑动作放在 Picker 中,而不是做成破坏性的一键字段图标——避免误触;
- 绑定替换、detach-on-edit 与多目标变更保持在同一 Provider 批次内——
runBatch/beginBatch统一事务边界,保证一次撤销。
十一、关于 Generated API Reference
文档底部标注"以下表格由文档构建时从 Vue 源码与 JSDoc 自动提取",其数据加载逻辑见 bindable-value.data.ts:通过#docs/sdk/component-meta的defineComponentMetaLoader读取三个源文件——BindableValueRoot.vue、BindableValueTrigger.vue、BindableValuePicker.vue——生成各组件 props / slots / 事件的 API 表格。也就是说,API 表并非手写,而是与源码保持同步的产物;若需核对最新签名,直接查阅 packages/vue/src/primitives/BindableValue/types.ts 即可。该原语同时通过 packages/vue/src/index.ts 从@open-pencil/vue对外导出。
小结
BindableValue 的架构可以浓缩为一句话:Root 持有状态与事务,Trigger / Picker 负责选择交互,Provider 提供存储与撤销能力,字段控件通过可选注入"自动感知"绑定。无论你是在 OpenPencil 内实现新的属性面板,还是搭建自定义编辑器外壳,都可以用这套原语在不解耦字段组件的前提下,获得与内置编辑器一致的变量 / Token 绑定体验。
- 前端
- 桌面应用
- AI 应用
- MCP 服务
【免费下载链接】open-pencil
AI-native design editor. Open-source Figma alternative.
相关推荐
OpenPencil 变量表集成指南:用 useVariablesTable 生成 TanStack 驱动的变量编辑器列定义
OpenPencil 变量表集成指南:用 useVariablesTable 生成 TanStack 驱动的变量编辑器列定义 useVariablesTable
前端桌面应用AI 应用MCP 服务OpenPencil Vue SDK 自定义编辑器外壳(Custom Editor Shell)实战指南
OpenPencil Vue SDK 自定义编辑器外壳(Custom Editor Shell)实战指南 OpenPencil 自带的应用界面只是众多可能的编辑
前端桌面应用AI 应用MCP 服务PhpSpreadsheet高级开发指南:如何编写自定义Reader、Writer与值绑定器
PhpSpreadsheet高级开发指南:如何编写自定义Reader、Writer与值绑定器 PhpSpreadsheet 是一款纯 PHP 的表格文件读写库,
后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考