OpenPencil SDK 深度解析:AppearanceControlsRoot 无样式外观属性控件根组件

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

OpenPencil SDK 深度解析:AppearanceControlsRoot 无样式外观属性控件根组件

AppearanceControlsRoot 是 OpenPencil 可编程 SDK(@open-pencil/vue)中用于构建属性面板外观区块的「无内置样式」(headless)根组件:它把 useAppearance() 组合式函数导出的选中态与变更动作,通过默认插槽一次性暴露给自定义 UI。读完本文,你将掌握该组件的完整插槽契约、showIndependentCorners 的自动判定规则、圆角与平滑度的归一化约定、多选与撤销历史合并机制,并能据此在 OpenPencil 编辑器中写出自带外观、可复用、可正确回滚的外观控件。

组件定位:外观控件与属性面板的关系

在 OpenPencil 的 Vue 属性面板体系中,官方推荐两条路线(参见 属性面板指南):

  • 需要选中派生状态与变更动作时,优先使用组合式函数,如 usePosition()、useLayout()、useAppearance()、useTypography()、useExport();
  • 当复杂度主要来自可复用的列表、树或插槽结构协调时,使用无样式结构组件,如 PropertyListRoot。

AppearanceControlsRoot 正是后者的代表:它本身不渲染任何 DOM、不带任何 CSS,唯一职责是把 useAppearance() 返回的整套状态与动作「原封不动」地通过作用域插槽传递给调用方,让开发者自由决定可见性开关、透明度滑杆、混合模式下拉、圆角数字输入框等控件长什么样。官方对其定位的原文描述是:"exposes the slot contract returned by useAppearance() as a structural primitive. Use it when you want reusable appearance controls with custom presentation"(以结构原语的形式暴露 useAppearance() 的插槽契约,适合需要可复用外观控件与自定义表现层时使用)。

插槽契约:一组状态 + 一组动作

组件源码位于 packages/vue/src/primitives/AppearanceControls/AppearanceControlsRoot.vue,核心实现只有一段插槽转发:

<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>

组件从 @open-pencil/vue 公共入口导出(见 packages/vue/src/primitives/AppearanceControls/index.ts),其类型定义集中在 packages/vue/src/primitives/AppearanceControls/types.ts。

插槽状态属性(AppearanceControlsRootSlotProps)

属性 类型 说明
node SceneNode | null 当前选中的单个节点;无选中时为 null
isMulti boolean 是否处于多选状态,决定合并(mixed)逻辑是否生效
active boolean 当前选区是否激活(有可用节点)
hasCornerRadius boolean 选中对象是否支持圆角属性(见下文类型白名单)
independentCorners MixedValue<boolean> 是否启用独立圆角(多选时可能为 MIXED)
showIndependentCorners boolean 是否展示独立圆角编辑区,由组件自动判定(见专节)
cornerRadiusValue MixedValue<number> 当前统一圆角值;若四个角等效绑定且数值一致,取 topLeftRadius
cornerRadiusBindingPaths Array<CornerRadiusKey | 'cornerRadius'> 圆角可绑定的属性路径集合(topLeftRadius…或 cornerRadius)
cornerSmoothingPercent MixedValue<number> 圆角平滑度,SceneGraph 归一化值 0…1 换算为 0…100,或 MIXED
opacityPercent MixedValue<number> 不透明度百分比 0…100,或 MIXED
blendModeValue MixedValue<BlendMode> 当前混合模式(BlendMode 枚举),或 MIXED
visibilityState 'visible' | 'hidden' | 'mixed' 可见性三态字符串,多选不一致时为 'mixed'

MixedValue<T> 与 MIXED 哨兵值由 packages/vue/src/controls/node-props/use.ts 导出,表示「该属性在多个选中节点上取值不一致」的合并状态。

插槽动作(AppearanceControlsActions)

动作 签名 说明
updateProp (key: string, value: number) => void 非提交式(scrub)更新通用数值属性,用于拖动过程中的高频刷新
commitProp (key: string, value: number, previous: number) => void 提交一次数值变更并写入撤销历史,previous 为变更前值
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 提交单个角的变更,previous 为该角变更前值

其中 CornerGeometryKey 的定义见 packages/vue/src/controls/appearance/types.ts:

export type CornerRadiusKey =
  | 'topLeftRadius'
  | 'topRightRadius'
  | 'bottomRightRadius'
  | 'bottomLeftRadius'

export type CornerGeometryKey = CornerRadiusKey | 'cornerSmoothing'

showIndependentCorners:由选区自动推导,而非本地 ref

文档强调:showIndependentCorners 由组件根据当前选中状态自行计算,消费方应直接渲染该值,不要另行维护一个平行的本地展开 ref。 其完整判定逻辑位于 packages/vue/src/controls/appearance/helpers.ts:

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 与当前节点 id 一致(此时尚未真正产生不相等圆角,展开仅是预览);
  2. 四个角数值不相等(hasUnequalCorners:topLeftRadius === topRightRadius === bottomRightRadius === bottomLeftRadius 不成立);
  3. 显式启用独立模式但绑定不一致:节点 independentCorners 为真,且四个角并未等效绑定到同一变量(cornersHaveEquivalentBindings 会比较 boundVariables.topLeftRadius 与其余三个角的绑定变量 id 是否全部相同)。

第三条尤为重要——它覆盖了官方文档提到的「导入对象在遗留的统一圆角标记下携带了不相等半径」的场景:例如从 Figma 导入的节点可能 independentCorners 标记过时,但四个半径实际不同。此时组件会自动判定应当展示独立圆角控件,而无需消费方关心导入数据的脏状态。

顺带说明多选行为:isMulti 为真时 showIndependentCorners 恒为 false,避免在多选场景下展示误导性的「展开」交互。

圆角与平滑度:归一化约定必须成对使用

cornerSmoothingPercent 暴露的是 SceneGraph 归一化值(0…1)换算后的百分比(0…100),源码见 packages/vue/src/controls/appearance/helpers.ts:

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

取值会被钳制在 0…1 后乘以 100 并四舍五入;多选不一致时透传 MIXED。更新方向则相反:updateCornerProp('cornerSmoothing', value) 与 commitCornerProp 接收的必须是归一化 0…1 数值,同样在 helpers.ts 中做了 Math.max(0, Math.min(value, 1)) 钳制。

这意味着 UI 层的典型写法是:滑杆以百分比展示(cornerSmoothingPercent),拖动时除以 100 传给 updateCornerProp,松手时再以归一化值调用 commitCornerProp 提交。透明度(opacityPercent)与混合模式(blendModeValue)同理——前者为百分比展示,底层 opacity 是 0…1;后者仅在多选不一致时返回 MIXED,UI 需自行处理该哨兵值。

圆角能力白名单

hasCornerRadius 并非对所有节点类型都为真,CORNER_RADIUS_TYPES 白名单见 helpers.ts:

const CORNER_RADIUS_TYPES = new Set([
  'RECTANGLE',
  'ROUNDED_RECTANGLE',
  'FRAME',
  'COMPONENT',
  'INSTANCE'
])

多选时要求所有选中节点都在白名单内(nodes.value.every(...)),单选时仅判断当前节点。非白名单类型(如 ELLIPSE、POLYGON)不会渲染圆角相关控件,界面应据此隐藏圆角区块。

撤销历史:多选合并为一条记录,且保留每个对象的原始值

AppearanceControlsRoot 的文档明确指出三类操作会被合并为单条撤销记录:

  1. 多对象独立圆角切换(toggleIndependentCorners);
  2. 平滑度修改(updateCornerProp / commitCornerProp 作用于 cornerSmoothing);
  3. 单个角的独立提交(commitCornerProp)。

底层实现借助编辑器的 editor.undo.runBatch(label, fn) 与快照映射,见 helpers.ts。以多选独立圆角切换为例:

editor.undo.runBatch(
  makeIndependent ? 'Independent corner radii' : 'Uniform corner radius',
  () => {
    for (const target of targets) {
      if (makeIndependent) {
        if (target.independentCorners) continue
        editor.updateNodeWithUndo(target.id, {
          independentCorners: true,
          topLeftRadius: target.cornerRadius,
          topRightRadius: target.cornerRadius,
          bottomRightRadius: target.cornerRadius,
          bottomLeftRadius: target.cornerRadius
        } as Partial<SceneNode>, 'Independent corner radii')
      } else {
        const uniform = target.topLeftRadius
        editor.updateNodeWithUndo(target.id, {
          independentCorners: false,
          cornerRadius: uniform,
          topLeftRadius: uniform,
          topRightRadius: uniform,
          bottomRightRadius: uniform,
          bottomLeftRadius: uniform
        } as Partial<SceneNode>, 'Uniform corner radius')
      }
    }
  }
)

注意切换为「独立」时,四个角先统一取 cornerRadius(保证进入编辑态时角值一致);切换回「统一」时,以 topLeftRadius 作为统一值写回 cornerRadius 与四个角。由于每个节点在 updateCornerProp / updateUniformRadius 阶段会先在 previousCornerValues、previousUniformCorners 快照映射中记录自己的原始值(逐对象保存,而非全局单值),commitCornerProp(key, value, previous) 提交时会按节点 id 取回各自快照,因此一次拖拽或一次多选操作产生的历史记录能够准确还原到每个对象操作前的独立状态——这正是文档强调「Исходное значение каждого объекта сохраняется」(保留每个对象的原始值)的源码依据。

实战:构建一个自定义外观面板

下面给出一个完整的可运行示例,覆盖可见性、不透明度、混合模式与圆角(含独立圆角)四个区块。它只依赖 @open-pencil/vue 的公开导出,样式完全由你决定:

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

const blendModes: BlendMode[] = ['PASS_THROUGH', 'NORMAL', 'MULTIPLY', 'SCREEN', 'OVERLAY']
</script>

<template>
  <AppearanceControlsRoot
    v-slot="{
      visibilityState, opacityPercent, blendModeValue,
      hasCornerRadius, cornerRadiusValue, cornerSmoothingPercent,
      showIndependentCorners, independentCorners, actions
    }"
  >
    <section class="appearance-panel">
      <!-- 可见性:三态按钮 -->
      <button
        :class="{ muted: visibilityState === 'hidden' }"
        @click="actions.toggleVisibility"
      >
        {{ visibilityState === 'hidden' ? '显示图层' : '隐藏图层' }}
      </button>

      <!-- 不透明度:百分比滑杆 -->
      <label>
        不透明度 {{ opacityPercent }}%
        <input
          type="range"
          min="0" max="100"
          :value="opacityPercent === 'MIXED' ? 100 : opacityPercent"
          @input="
            actions.updateProp('opacity', Number(($event.target as HTMLInputElement).value) / 100)
          "
          @change="
            actions.commitProp(
              'opacity',
              Number(($event.target as HTMLInputElement).value) / 100,
              Number(($event.target as HTMLInputElement).value) / 100
            )
          "
        />
      </label>

      <!-- 混合模式 -->
      <select
        :value="blendModeValue === 'MIXED' ? '' : blendModeValue"
        @change="actions.setBlendMode(($event.target as HTMLSelectElement).value as BlendMode)"
      >
        <option v-if="blendModeValue === 'MIXED'" value="" disabled>混合</option>
        <option v-for="mode in blendModes" :key="mode" :value="mode">{{ mode }}</option>
      </select>

      <!-- 圆角区块:仅白名单节点显示 -->
      <template v-if="hasCornerRadius">
        <label>
          圆角 {{ cornerRadiusValue === 'MIXED' ? '混合' : cornerRadiusValue }}px
          <input
            type="number"
            min="0"
            :value="cornerRadiusValue === 'MIXED' ? 0 : cornerRadiusValue"
            @input="actions.updateUniformRadius(Number(($event.target as HTMLInputElement).value))"
            @change="actions.commitUniformRadius()"
          />
        </label>

        <label>
          圆角平滑度 {{ cornerSmoothingPercent === 'MIXED' ? '混合' : cornerSmoothingPercent }}%
          <input
            type="range"
            min="0" max="100"
            :value="cornerSmoothingPercent === 'MIXED' ? 0 : cornerSmoothingPercent"
            @input="
              actions.updateCornerProp(
                'cornerSmoothing',
                Number(($event.target as HTMLInputElement).value) / 100
              )
            "
            @change="
              actions.commitCornerProp(
                'cornerSmoothing',
                Number(($event.target as HTMLInputElement).value) / 100,
                Number(($event.target as HTMLInputElement).value) / 100
              )
            "
          />
        </label>

        <!-- 独立圆角:完全消费组件推导的 showIndependentCorners -->
        <button
          v-if="independentCorners !== 'MIXED' && !showIndependentCorners"
          @click="actions.toggleIndependentCorners()"
        >
          独立调整四个角
        </button>
        <template v-if="showIndependentCorners">
          <label v-for="key in ['topLeftRadius', 'topRightRadius', 'bottomRightRadius', 'bottomLeftRadius']" :key="key">
            {{ key }}
            <input
              type="number" min="0"
              @input="actions.updateCornerProp(key, Number(($event.target as HTMLInputElement).value))"
              @change="
                actions.commitCornerProp(
                  key,
                  Number(($event.target as HTMLInputElement).value),
                  Number(($event.target as HTMLInputElement).value)
                )
              "
            />
          </label>
        </template>
      </template>
    </section>
  </AppearanceControlsRoot>
</template>

示例中的要点:

  • 不透明度换算:UI 显示 opacityPercent(0–100),写入前除以 100 转成 SceneGraph 归一化值;
  • 平滑度归一化:滑杆值除以 100 后交给 updateCornerProp / commitCornerProp('cornerSmoothing', …);
  • 独立圆角渲染:按钮显隐直接依据 showIndependentCorners,不要自建 ref 与组件状态并行——单选时 toggleIndependentCorners 会先在内部切换 expandedCornerNodeId 做展开预览,多选时才真正批量切换各节点的 independentCorners 属性;
  • 提交与撤销:@input 用 update* 高频刷新,@change/@blur 用 commit* 落历史,保证一次拖动只产生一条撤销记录。

与 useAppearance 的关系及选用建议

AppearanceControlsRoot 并非独立实现,它内部直接调用 useAppearance()(源码在 packages/vue/src/controls/appearance/use.ts),而 useAppearance 又基于更底层的 useNodeProps()(负责 MIXED 合并检测、多选更新与 scrub/commit 语义)。二者的关系是:

  • 用 useAppearance():当你的外观控件直接嵌入在某个自定义组件模板里,不需要经过中间插槽层,希望直接解构出 visibilityState、opacityPercent、toggleVisibility、updateCornerProp 等;
  • 用 AppearanceControlsRoot:当你想把「外观控件」封装成一个独立可复用的组件(例如团队内部的 AppearanceSection.vue),或需要以插槽形式接收整套状态与动作时,根组件把 useAppearance() 的返回值标准化为稳定的 slot 契约,消费方只依赖 AppearanceControlsRootSlotProps 类型即可,耦合更低。

从源码结构看(packages/vue/src/primitives/AppearanceControls/ 目录同时存在根组件与 types.ts),该原语与 ToolbarRoot、PropertyListRoot 等 headless 组件遵循同一套设计模式:状态派生与变更逻辑收敛在 composables 层,结构协调与插槽契约收敛在 primitives 层,视觉表现完全交给应用。

相关 API 与延伸阅读

登录后查看全文
open-pencil