AppearanceControlsRoot 深度解析:用 Headless 原语构建 OpenPencil 外观属性面板

原创2026-10-08 10:02:081,995 阅读
文章标签:桌面应用开发工具前端人工智能AI 应用MCP 服务代码生成CLI插件系统

AppearanceControlsRoot 深度解析:用 Headless 原语构建 OpenPencil 外观属性面板

AppearanceControlsRoot 是 OpenPencil @open-pencil/vue 中一个无样式的 headless 根原语,它把 useAppearance() 返回的完整外观控制槽契约(透明度、可见性、混合模式、圆角半径与平滑度)以结构原语的形式暴露出来。本文将以该原语为核心,结合其 Vue 源码实现与引擎级测试,讲解如何在不重写编辑器逻辑的前提下,为属性面板搭建一套可复用、绑定感知的自定义外观控件。

定位:当属性面板需要"自定义外观"时

在 OpenPencil 的 Vue 属性面板体系中,属性面板指南给出了一条明确的取舍准则:

  • 若面板主要需要"选择派生的状态 + 更新动作",优先使用 composable(如 useAppearance()、useLayout()、usePosition());
  • 若面板需要可复用的列表/数组结构编排,才使用 PropertyListRoot 这类结构原语。

AppearanceControlsRoot 正好位于两者之间:它是一个把 composable 的槽契约"结构原语化"的组件。使用它,应用可以构建自己的透明度、可见性、圆角半径和平滑度控件,而完全不必重新实现编辑器的选择逻辑、混合值计算与撤销分组机制。

在 packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue 中,组件本体极其轻量——它只做三件事:调用 useAppearance() 获取上下文、声明 AppearanceControlsRootSlots 类型、然后通过一个默认插槽把所有状态与动作透传出去:

<script setup lang="ts">
import { useAppearance } from '#vue/controls/appearance/use'
import type { AppearanceControlsRootSlots } from '#vue/primitives/AppearanceControls/types'

const ctx = useAppearance()
defineSlots<AppearanceControlsRootSlots>()
const actions = {
  updateProp: ctx.updateProp,
  commitProp: ctx.commitProp,
  setBlendMode: ctx.setBlendMode,
  toggleVisibility: ctx.toggleVisibility,
  toggleIndependentCorners: ctx.toggleIndependentCorners,
  updateUniformRadius: ctx.updateUniformRadius,
  commitUniformRadius: ctx.commitUniformRadius,
  updateCornerProp: ctx.updateCornerProp,
  commitCornerProp: ctx.commitCornerProp
}
</script>

<template>
  <slot
    :node="ctx.node.value"
    :is-multi="ctx.isMulti.value"
    :active="ctx.active.value"
    :has-corner-radius="ctx.hasCornerRadius.value"
    :independent-corners="ctx.independentCorners.value"
    :show-independent-corners="ctx.showIndependentCorners.value"
    :corner-radius-value="ctx.cornerRadiusValue.value"
    :corner-radius-binding-paths="ctx.cornerRadiusBindingPaths.value"
    :corner-smoothing-percent="ctx.cornerSmoothingPercent.value"
    :opacity-percent="ctx.opacityPercent.value"
    :blend-mode-value="ctx.blendModeValue.value"
    :visibility-state="ctx.visibilityState.value"
    :actions="actions"
  />
</template>

这段实现清晰地印证了官方文档的描述:AppearanceControlsRoot 将 useAppearance() 暴露的槽契约作为结构原语公开,消费者拿到的是"状态 + 动作"两个命名空间,而呈现层(HTML 结构、样式、图标)完全由调用方决定。

槽契约:状态与动作两个命名空间

完整的槽契约定义位于 packages/vue/src/primitives/AppearanceControls/types.ts,这是所有自定义外观面板的"接口文档"。

默认插槽的状态属性(Slot Props)

属性 类型 说明
node SceneNode | null 当前选中节点;无选择时为 null
isMulti boolean 是否为多选状态
active boolean 当前是否处于可编辑状态
hasCornerRadius boolean 选中节点是否支持圆角(矩形/圆角矩形/画板/组件/实例类型)
independentCorners MixedValue<boolean> 是否显式启用了独立四角
showIndependentCorners boolean 是否应展示逐角编辑器(见下文判定规则)
cornerRadiusValue MixedValue<number> 当前圆角值(折叠状态下可能取四角共享值)
cornerRadiusBindingPaths Array<CornerRadiusKey | 'cornerRadius'> 当前圆角对应的绑定路径集合
cornerSmoothingPercent MixedValue<number> 归一化后的平滑度百分比,取值 0…100 或 MIXED
opacityPercent MixedValue<number> 归一化后的透明度百分比
blendModeValue MixedValue<BlendMode> 混合模式,多选不一致时为 MIXED
visibilityState 'visible' | 'hidden' | 'mixed' 可见性的三态呈现值

动作对象(Actions)

动作 签名 作用
updateProp (key: string, value: number) => void 实时更新属性(拖拽/输入过程中的预览更新)
commitProp (key: string, value: number, previous: number) => void 提交属性变更并写入撤销栈
setBlendMode (value: BlendMode) => void 设置混合模式,多选时批量更新
toggleVisibility () => void 切换可见性,多选时依据"是否全部可见"取反
toggleIndependentCorners () => void 切换统一/独立四角
updateUniformRadius (value: number) => void 统一圆角值的实时更新
commitUniformRadius () => void 提交统一圆角变更
updateCornerProp (key: CornerGeometryKey, value: number) => void 更新单个角或平滑度
commitCornerProp (key: CornerGeometryKey, value: number, previous: number) => void 提交单角/平滑度变更

从源码可以看到,AppearanceControlsRoot 的 actions 对象是直接从 useAppearance() 的返回值中取出并重组的,并没有引入任何额外的编辑器逻辑——这保证了"根原语"与底层 composable 的完全一致性。

选择派生的呈现决策:showIndependentCorners

官方文档强调了一个容易被忽视的设计要点:根原语拥有选择派生的呈现决策,包括 showIndependentCorners,消费者应当直接渲染该状态,而不是维护一个并行的本地展开 ref。

为什么需要这样一个派生状态?看 packages/vue/src/controls/appearance/helpers.ts 中 createAppearanceState 的实现,showIndependentCorners 由三个条件共同决定:

const showIndependentCorners = computed(() => {
  if (isMulti.value) return false
  const selected = node.value
  return selected
    ? expandedCornerNodeId?.value === selected.id ||
        hasUnequalCorners(selected) ||
        (selected.independentCorners && !cornersHaveEquivalentBindings(selected))
    : false
})

即满足以下任一条件即展开逐角编辑器:

  1. 用户显式展开了该节点的逐角编辑(通过 expandedCornerNodeId 记录);
  2. 节点四个圆角值不相等(hasUnequalCorners),即使 independentCorners 标记为 false;
  3. 节点显式启用了独立四角,且四个角并非绑定到同一个变量(cornersHaveEquivalentBindings 返回 false)。

其中的第二点对应官方文档所说的"导入节点包含不相等圆角值但 uniform 标记陈旧"(stale uniform flag)场景——例如从 Figma 导入的节点,圆角值已经各不相等但 independentCorners 仍是 false。引擎级测试 tests/engine/vue/controls/appearance.test.ts 对此有明确的断言:

test('expands imported unequal corners when the explicit flag is stale', () => {
  const node = rectangle()
  node.independentCorners = false
  node.topLeftRadius = 4
  node.topRightRadius = 12
  const state = appearanceState(node)
  expect(state.showIndependentCorners.value).toBe(true)
})

同一测试文件中还覆盖了另外几种判定分支:等值圆角保持折叠(expect(...).toBe(false))、显式 independentCorners = true 时展开、四个角共享同一绑定时折叠、绑定不同但值相等时仍保持展开等。这些测试共同保证了 showIndependentCorners 在各种边界输入下的稳定性。

归一化契约:cornerSmoothingPercent 与 0…1 写入

官方文档特别说明了 cornerSmoothingPercent 的取值约定:它暴露的是规范化后的场景值,范围 0…100 或 MIXED;而更新时需要通过 corner actions 传入规范化后的 0…1 数值。

对应的实现位于 createAppearanceState:

const cornerSmoothingPercent = computed(() => {
  const value = merged('cornerSmoothing')
  return value === MIXED ? MIXED : Math.round(Math.max(0, Math.min(value, 1)) * 100)
})

场景中存储的 cornerSmoothing 是 0…1 的浮点值,状态层在向外暴露前先钳制到 [0, 1] 再乘以 100 并取整。测试用例 presents normalized corner smoothing as a percentage 验证了这一点:

const node = rectangle()
node.cornerSmoothing = 0.735
const state = appearanceState(node)
expect(state.cornerSmoothingPercent.value).toBe(74)

写入侧的钳制同样存在。在 updateCornerProp 中:

const normalized = key === 'cornerSmoothing' ? Math.max(0, Math.min(value, 1)) : value

这形成了清晰的对外契约:UI 显示 0…100 的百分比,交互层负责把滑块的百分比换算为 0…1 后再调用动作;即便传入越界值(如测试中的 1.4),底层也会钳制为 1。同样的百分比归一化也应用于 opacityPercent(Math.round(v * 100))。

多选编辑与撤销分组:保留每个节点的原始值

官方文档最后一条要点是:多节点独立圆角切换、平滑度编辑和逐角提交会被分组到一个撤销条目中,同时保留每个节点的原始值。

这一行为由 createAppearanceActions 中的三处机制保证:

1. 事务批处理(undo batch)。 setBlendMode、toggleVisibility、toggleIndependentCorners、commitUniformRadius 在多选时都包裹在 editor.undo.runBatch(label, () => { ... }) 中。例如:

editor.undo.runBatch('Change blend mode', () => {
  for (const target of changed) {
    editor.updateNodeWithUndo(target.id, { blendMode: value }, 'Change blend mode')
  }
})

2. 原始值快照(snapshot map)。 连续的预览更新通过 updateProp/updateCornerProp 执行,但提交时才真正写入撤销记录。previousCornerValues 是一个 Map<CornerGeometryKey, Map<string, number>>,首次触碰某个角时记录下每个节点的原始值:

function updateCornerProp(key: CornerGeometryKey, value: number) {
  let snapshots = previousCornerValues.get(key)
  if (!snapshots) {
    snapshots = new Map()
    previousCornerValues.set(key, snapshots)
  }
  for (const target of cornerTargets()) {
    if (!snapshots.has(target.id)) snapshots.set(target.id, target[key])
    editor.updateNode(target.id, { [key]: normalized })
  }
}

3. 提交时按原始值回滚。 commitCornerProp 在提交时使用快照中的每个节点原始值作为"撤销前状态":

const commit = () => {
  for (const target of targets) {
    editor.commitNodeUpdate(
      target.id,
      { [key]: snapshots?.get(target.id) ?? previous } as Partial<SceneNode>,
      `Change ${key}`
    )
  }
}
if (targets.length > 1) editor.undo.runBatch(`Change ${key}`, commit)
else commit()

测试 restores each mixed smoothing value in one undo step 精确验证了这个语义:两个节点分别持有 0.2 和 0.8 的平滑度,一次多选编辑提交后执行一次 undo(),两个节点各自恢复为 0.2 与 0.8——而不是统一恢复为某个"共同值"。这正是"保留每个节点原始值"的实际含义。

另一个值得注意的细节是 toggleIndependentCorners 对"展开/折叠"与"真正修改场景"的区分:当节点四角相等且共享同一绑定时,切换只更新本地 expandedCornerNodeId,不产生撤销记录也不解绑变量(测试 collapsed bound corners can be expanded without detaching their bindings 验证了 editor.undo.canUndo === false 且 boundVariables.topLeftRadius 保持 'radius');只有当节点真正需要切换独立/统一模式时才进入 undo batch 并批量写入场景。

组合使用:从 useAppearance() 到 AppearanceControlsRoot

AppearanceControlsRoot 的底层是 useAppearance composable,位于 packages/vue/src/controls/appearance/use.ts:

export function useAppearance() {
  const editor = useEditor()
  const { nodes, node, active, isMulti, merged, updateProp, commitProp } = useNodeProps()
  const expandedCornerNodeId = ref<string | null>(null)
  const options = { node, nodes, isMulti, merged, expandedCornerNodeId }
  const appearanceState = createAppearanceState(options)
  const appearanceActions = createAppearanceActions({ editor, ...options })
  return { editor, nodes, node, active, isMulti, ...appearanceState, updateProp, commitProp, ...appearanceActions }
}

它向下依赖 useNodeProps() 提供的选择状态(节点集合、合并值 merged、更新/提交动作),向上产出外观领域的状态与动作。选择集完全由编辑器上下文驱动,因此任何挂载在编辑器内的组件都能拿到与主属性面板一致的外观状态。

在编写自定义外观面板时,有两条互补的路径:

  • 追求结构化复用:直接使用 AppearanceControlsRoot,通过 v-slot 拿到 showIndependentCorners、cornerSmoothingPercent、actions 等,自行排版。官方文档明确建议"从这个状态渲染,而非维护并行的本地展开 ref",因为只有它才能正确处理导入节点的不相等圆角与陈旧标记。
  • 追求轻量直连:在 setup 中直接解构 useAppearance()。官方示例(来自 useAppearance 文档):
const {
  visibilityState,
  opacityPercent,
  cornerRadiusValue,
  cornerSmoothingPercent,
  showIndependentCorners,
  toggleVisibility,
  toggleIndependentCorners,
} = useAppearance()

两种方式共享同一套状态与动作实现,区别仅在于 AppearanceControlsRoot 额外提供了类型化的槽契约与结构化的透传层。实践中可以按 属性面板指南 的取舍规则决定:直接控制逻辑用 composable,需要"结构编排与槽协调"时用原语。

一个最小可用的自定义外观面板

综合上文,一个基于 AppearanceControlsRoot 的最小属性面板可以这样组织:

<script setup lang="ts">
import { AppearanceControlsRoot } from '@open-pencil/vue'
</script>

<template>
  <AppearanceControlsRoot v-slot="{ opacityPercent, cornerSmoothingPercent, visibilityState, actions }">
    <section>
      <!-- 可见性:三态显示 -->
      <button @click="actions.toggleVisibility()">
        {{ visibilityState }}
      </button>

      <!-- 透明度:percent 显示,写入时换算为 0..1 -->
      <input
        type="range"
        min="0"
        max="100"
        :value="opacityPercent"
        @input="actions.updateProp('opacity', Number(($event.target as HTMLInputElement).value) / 100)"
        @change="actions.commitProp('opacity', Number(($event.target as HTMLInputElement).value) / 100, 1)"
      />

      <!-- 平滑度:percent 显示,写入 0..1 -->
      <input
        type="range"
        min="0"
        max="100"
        :value="cornerSmoothingPercent"
        @input="actions.updateCornerProp('cornerSmoothing', Number(($event.target as HTMLInputElement).value) / 100)"
        @change="actions.commitCornerProp('cornerSmoothing', Number(($event.target as HTMLInputElement).value) / 100, 0)"
      />
    </section>
  </AppearanceControlsRoot>
</template>

这个示例演示了关键约定:显示层消费 opacityPercent / cornerSmoothingPercent(0…100 或 MIXED),写入层则通过 updateProp / updateCornerProp 传入归一化的 0…1 数值,并以 commitProp / commitCornerProp 完成撤销分组。若需要逐角编辑,再通过 showIndependentCorners 条件渲染四个角输入框,并配合 cornerRadiusBindingPaths 处理绑定感知的字段展示。

小结

AppearanceControlsRoot 的价值在于把"外观属性编辑"这一高频面板需求封装为一个无样式的结构原语:

  • 状态层:选择派生、绑定感知,正确处理导入节点的陈旧 uniform 标记;
  • 动作层:统一/独立四角切换、单角与平滑度编辑、混合模式与可见性,全部内置撤销分组;
  • 归一化契约:场景值 0…1,UI 值 0…100,边界清晰;
  • 呈现自由:不携带任何样式,HTML 结构完全由消费者定义。

对于想要为 OpenPencil 打造自定义外观属性面板的开发者,直接从 AppearanceControlsRoot(或等价的 useAppearance())开始即可,底层逻辑已由 helpers.ts 与引擎级测试 appearance.test.ts 完整保障。

相关阅读:useAppearance 组合式 API、属性面板构建指南。

登录后查看全文
open-pencil