☰
open-pencil Vue SDK 渐变编辑器原语解析:GradientEditorStop 的状态模型、键盘交互与无障碍实现
2026/9/29 3:26:14 网站建设 项目流程
  • 前端
  • 桌面应用
  • AI 应用
  • MCP 服务

【免费下载链接】open-pencil

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

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

GradientEditorStop是 open-pencil 的@open-pencil/vue包提供的无头(headless)渐变停靠点(gradient stop)原语,用于在自定义渐变编辑器中渲染单个可选中、可拖拽、可删除的渐变点。本文以 packages/docs/it/programmable/sdk/api/components/gradient-editor-stop.md 为主线,结合packages/vue/src/primitives/GradientEditor/下的真实实现与 open-pencil 编辑器内的实际集成(src/components/fill-picker/GradientEditor.vue),完整讲解该组件的 Props、事件、插槽协议、ARIA 暴露与键盘交互,并给出可直接运行的 Vue 示例。读完本文,你将能够基于该原语搭建具备完整可访问性(屏幕阅读器 + 键盘)的自定义渐变编辑器,或在其上扩展出样式化的停靠点行(stop row)。

GradientEditorStop 在渐变编辑器中的地位

在 open-pencil 的渐变编辑能力中,组件被拆分为三个可组合的无头原语,GradientEditorStop是其中最细粒度的"点"原语:

  • GradientEditorRoot:无头根原语,负责活动停靠点状态、渐变子类型切换、停靠点增删改逻辑、活动颜色编辑与派生出的渐变条背景;
  • GradientEditorBar:无头可拖拽条原语,处理停靠点在条上的拖拽与选中;
  • GradientEditorStop:渲染一个多态(polymorphic)停靠点,记录并暴露选中与拖拽状态。

该原语源码位于 packages/vue/src/primitives/GradientEditor/GradientEditorStop.vue,类型定义在 packages/vue/src/primitives/GradientEditor/types.ts,并统一从 packages/vue/src/primitives/GradientEditor/index.ts 与 packages/vue/src/index.ts 对外导出,供@open-pencil/vue消费者直接import。

底层数据模型:GradientStop 与 Fill

在深入组件之前,先明确它操作的数据类型。GradientEditorStop接受一个GradientStop对象,其结构定义在 packages/scene-graph/src/types.ts:

export interface GradientStop { color: Color position: number // 0 ~ 1 的小数表示,位于渐变条上的比例位置 }

停靠点(stops)挂在渐变填充Fill的gradientStops字段上(见同文件Fill接口,type: FillType支持GRADIENT_LINEAR、GRADIENT_RADIAL、GRADIENT_ANGULAR、GRADIENT_DIAMOND等渐变子类型)。GradientEditorStop本身只负责"渲染与交互一个点",而点的增删改由根原语通过useGradientStops组合式函数统一管理。

Props 全量解析

组件的 Props 定义来自GradientEditorStopProps(继承 reka-ui 的PrimitiveProps),源码见 types.ts:

Prop类型默认值说明
stopGradientStop必填当前停靠点的颜色与位置
indexnumber必填停靠点在数组中的索引,事件回调与无障碍标签会用到它
activeboolean必填是否选中;选中状态通过data-selected属性暴露,供样式化
draggingbooleanfalse是否正在拖拽;通过data-dragging属性暴露
interactivebooleantrue是否为可交互停靠点(见下文"两种渲染模式")
removablebooleantrue是否允许通过 Delete/Backspace 删除
positionStepnumber1方向键微调步长(单位:百分比),按住 Shift 时放大 10 倍
labelstring自动生成ARIA 标签;不传时自动生成Gradient stop {index + 1}
as/asChild继承自PrimitiveProps'div'/false多态渲染目标元素

其中as/asChild来自 reka-ui 的Primitive组件(见 GradientEditorStop.vue 中的import { Primitive } from 'reka-ui'),意味着同一个原语既可以渲染成<div>,也可以通过asChild把交互行为注入到你自己的根元素上——这正是"无头原语"多态能力的来源。

事件协议

GradientEditorStop通过以下事件向上传递变更意图,payload 一律携带index,便于父级在数组中找到目标停靠点(源码见 GradientEditorStop.vue):

事件payload触发时机
selectindex: number点击或聚焦时选中该停靠点
updatePositionindex, position键盘微调或外部调用actions.updatePosition后位置变化
updateColorindex, hex更新停靠点颜色(hex 字符串)
updateOpacityindex, opacity更新停靠点透明度(百分比)
removeindexDelete/Backspace 或外部调用actions.remove时删除

注意updatePosition携带的position是百分比(0–100)而非底层模型中的小数(0–1),转换逻辑发生在useGradientStops中(见下文)。

插槽协议与 actions

GradientEditorStop是典型的"渲染内容完全交由消费者"的原语:它自身不绘制任何视觉元素,而是通过默认插槽把状态与动作暴露出来。插槽 props 定义于GradientEditorStopSlotProps(见 types.ts):

{ stop: GradientStop // 原始数据 index: number active: boolean // 与 selected 等价 selected: boolean dragging: boolean positionPercent: number // 位置百分比(0–100,已取整) opacityPercent: number // 透明度百分比(0–100,已取整) hex: string // 颜色的 hex 原始串 css: string // 颜色的 CSS 表示,可直接用于 background actions: GradientEditorStopActions }

actions是GradientEditorStopActions接口的实例(见 types.ts):

export interface GradientEditorStopActions { select: () => void updatePosition: (position: number) => void updateColor: (hex: string) => void updateOpacity: (opacity: number) => void remove: () => void }

这些 action 已经绑定了index,因此插槽内部调用时无需再传索引。派生值(positionPercent、opacityPercent、hex、css)在 GradientEditorStop.vue 中通过computed生成,颜色转换复用@open-pencil/scene-graph/color的colorToHexRaw与colorToCSS。

两种渲染模式:interactive 与 composite row

原语设计了两个使用场景,由interactive属性区分:

  • 交互式停靠点(默认,interactive=true):用于渐变条(bar)上的"手柄"。它会以role="slider"进入 Tab 键顺序,并通过 ARIA 暴露百分比位置。方向键以positionStep微调、按住 Shift 使用 10 倍步长、Home/End 跳转到边界、Delete/Backspace 在removable时触发remove。被处理过的按键会同时preventDefault()与stopPropagation(),从而阻断编辑器层面的删除与移动快捷键,避免双重触发(见 GradientEditorStop.vue)。
  • 非交互式(interactive=false):用于"停靠点列表行"这种复合结构。行内的每个字段(位置输入、色板、hex 输入、透明度、删除按钮)各自独立聚焦,此时整个行不进入 slider 的 Tab 顺序,但插槽 actions 与data-selected/data-dragging属性照常暴露。

两种模式都通过data-slot="stop"、data-selected、data-dragging属性暴露状态,配合 Tailwind CSS 属性选择器即可完成样式化。

无障碍细节:ARIA 暴露

交互式模式下,原语会设置完整的 slider 无障碍契约(见 GradientEditorStop.vue):

role="slider" tabindex="0" aria-label="<label 或 'Gradient stop N'>" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<positionPercent>" aria-valuetext="<positionPercent>%"

其中aria-valuenow直接使用Math.round(stop.position * 100)计算的位置百分比。这让屏幕阅读器用户能够准确感知停靠点当前所处位置;同时"原生 Tab 顺序在停靠点之间循环"的设计(每个交互式停靠点都是tabindex=0的独立可聚焦元素)保证键盘用户无需鼠标即可逐一访问所有停靠点。

官方示例:最小可用用法

英文权威文档 gradient-editor-stop.md 给出了可直接运行的 twoslash 示例,展示了"非受控渲染 + 事件监听"的最小用法:

<script setup lang="ts"> import type { GradientStop } from '@open-pencil/scene-graph' import { GradientEditorStop } from '@open-pencil/vue' const stop: GradientStop = { color: { r: 0.4, g: 0.2, b: 0.9, a: 1 }, position: 0.5 } </script> <template> <GradientEditorStop :stop="stop" :index="0" active label="Middle gradient stop" @update-position="(_index, position) => console.log(position)" /> </template>

真实项目中,GradientEditorStop通常与GradientEditorRoot配合使用,由根原语统一提供停靠点数组与各update*动作(见 GradientEditorRoot.vue,它内部通过useGradientStops组合函数管理全部状态)。

编辑器内真实集成:两种模式的完整拼装

open-pencil 编辑器自带的渐变面板 src/components/fill-picker/GradientEditor.vue 是GradientEditorStop的完整生产级用例,演示了如何在一条代码里同时使用两种模式:

渐变条上的交互式停靠点(拖拽手柄):

<GradientEditorBar :stops="root.stops" :active-stop-index="root.activeStopIndex" :bar-background="root.barBackground" @select-stop="root.actions.selectStop" @drag-stop="root.actions.dragStop" v-slot="bar" > <GradientEditorStop v-for="(stop, idx) in bar.stops" :key="idx" :stop="stop" :index="idx" :active="idx === bar.activeStopIndex" :dragging="idx === bar.draggingIndex" :removable="bar.stops.length > 2" :style="{ left: `${stop.position * 100}%`, background: colorToCSS(stop.color) }" @select="root.actions.selectStop" @update-position="root.actions.updateStopPosition" @remove="root.actions.removeStop" @pointerdown.stop="bar.actions.stopPointerDown(idx, $event)" /> </GradientEditorBar>

注意这里的细节:left定位由父级样式负责(原语不强制视觉布局);@pointerdown.stop防止点击手柄时与条形拖拽事件冲突;removable与当前停靠点数量联动(少于等于 2 个时不允许删除)。

停靠点列表行(interactive=false复合行):

<GradientEditorStop v-for="(stop, idx) in root.stops" :key="idx" :stop="stop" :index="idx" :active="idx === root.activeStopIndex" :removable="root.stops.length > 2" :interactive="false" v-slot="s" > <NumberField :model-value="s.positionPercent" :min="0" :max="100" @update:model-value="s.actions.updatePosition(Number($event))" /> <button :style="{ background: s.css }" @click.stop="s.actions.select" /> <input :value="s.hex" maxlength="6" @change="s.actions.updateColor(inputValue($event))" /> <NumberField :model-value="s.opacityPercent" :min="0" :max="100" @update:model-value="s.actions.updateOpacity(Number($event))" /> <IconButton v-if="root.stops.length > 2" @click.stop="s.actions.remove" /> </GradientEditorStop>

这正是"行内各字段各自聚焦、整行不进入 slider Tab 顺序"的典型拼装:位置、颜色、透明度分别用独立输入控件编辑,直接消费插槽暴露的positionPercent、css、hex、opacityPercent与actions。

底层逻辑:useGradientStops 如何驱动这些动作

GradientEditorRoot的所有插槽属性与动作均来自 useGradientStops(源码见 packages/vue/src/primitives/GradientEditor/useGradientStops.ts)。理解它有助于把GradientEditorStop的事件正确接回到数据模型:

  • updateStopPosition(index, position):接收GradientEditorStop传来的百分比,除以 100 后夹取到[0, 1],写回GradientStop.position;
  • updateStopOpacity(index, opacity):同样以百分比输入、夹取到[0, 1]后写入color.a;
  • updateStopColor(index, hex):内部先selectStop(index)选中目标点,再通过useColorModel的updateHex走统一颜色模型链路;
  • addStop():在最后两个停靠点位置的中点插入新点(不足两个点时取 0.5),并排序后自动选中新点;
  • removeStop(index):当停靠点数量> 2时才允许删除,删除后活动索引回落到安全范围内——这正是面板里:removable="stops.length > 2"约束的根源;
  • barBackground:派生自所有停靠点的linear-gradient(to right, ...)字符串,作为渐变条的 CSS 背景。

GradientEditorStop自身只做"渲染 + 意图传递",不持有任何数据——数据始终单向流动:Fill.gradientStops→ Root 派生 → Stop 展示 → 事件回调 →useGradientStops更新 → 通过@update事件把新的Fill对象交还给应用。这与 GradientEditorRoot 文档中"接收fill、发出update"的约定完全一致。

测试验证:键盘微调与删除行为

open-pencil 的端到端测试 tests/e2e/color-picker/basic.spec.ts 直接验证了本原语的键盘契约,可作为行为规格参考:

// 添加一个停靠点后,条上应出现 3 个 slider 角色元素 const stops = page.getByTestId('fill-picker-gradient-bar').getByRole('slider') await expect(stops).toHaveCount(3) // 聚焦第一个停靠点,按方向键 → aria-valuenow +1 const first = stops.first() await first.focus() const before = Number(await first.getAttribute('aria-valuenow')) await first.press('ArrowRight') await expect(first).toHaveAttribute('aria-valuenow', String(Math.min(100, before + 1))) // 按 Delete → 停靠点数量减一 await first.press('Delete') await expect(stops).toHaveCount(2)

这条测试同时印证了三件事:交互式停靠点确实以slider角色暴露、方向键以positionStep=1微调(并夹取到 100 上限)、removable时 Delete 键确实触发删除。配套的fill-picker-add-stop与fill-picker-gradient-bar测试标识分别对应面板中的"添加停靠点"按钮与渐变条(见 GradientEditor.vue 中的data-test-id)。

相关 API 索引

  • GradientEditorRoot:渐变编辑根原语,负责状态与动作
  • GradientEditorBar:可拖拽渐变条原语
  • useGradientStops:停靠点状态与变更逻辑的组合式函数
  • useColorModel:颜色编辑所复用的统一颜色模型
  • 类型与导出:types.ts、index.ts
  • 前端
  • 桌面应用
  • AI 应用
  • MCP 服务

【免费下载链接】open-pencil

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

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

相关推荐

上一篇:用 WeChatMsg 三步把微信聊天记录导出成可搜索的私人档案
下一篇:B站大会员4K视频免费下载指南:一个开源工具完整搞定充电专属内容

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

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

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

立即咨询