☰
OpenPencil BindableValue 深度指南:Provider 驱动的值绑定原语与自定义编辑器控件
2026/10/9 1:47:13 网站建设 项目流程
  • 前端
  • 桌面应用
  • AI 应用
  • MCP 服务

【免费下载链接】open-pencil

AI-native design editor. Open-source Figma alternative.

项目地址:https://gitcode.com/gh_mirrors/op/open-pencil
点击查看免费下载

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 aBindingProvider; 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 是非破坏性的;策略只在以下三种实际变更时启动:

  1. 用户输入了不同的草稿值;
  2. 用户步进(step)了值;
  3. 指针拖拽(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 为绑定感知字段总结了五条准则,均与该原语的源码语义一一对应:

  1. 字段空闲时展示变量身份,把解析值放到支撑性 UI(如 tooltip)中——对应variable/resolvedValue的分离暴露;OpenPencil 应用皮肤在空闲时显示紫色变量名胶囊,进入编辑模式后 NumberField 才展示解析出的数值;
  2. 聚焦或打开 Picker 不得解绑——beginMutation的启动条件保证了这一点;
  3. 仅在用户真正变更值时才应用三种策略——detach-on-edit等在beginMutation后才生效;
  4. 把显式解绑动作放在 Picker 中,而不是做成破坏性的一键字段图标——避免误触;
  5. 绑定替换、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.

项目地址:https://gitcode.com/gh_mirrors/op/open-pencil
点击查看免费下载
上一篇:终极指南:DeepLabCut多尺度行为分析从微观到宏观的完整解决方案
下一篇:Magic 1-For-1故障排除手册:常见问题与解决方案大全

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

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

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

立即咨询