open-pencil 菜单模型实战:用 useMenuModel 从编辑器命令构建可渲染的应用菜单与画布右键菜单
【免费下载链接】open-pencil
AI-native design editor. Open-source Figma alternative.
导读
在 open-pencil(AI-native 设计编辑器,开源的 Figma 替代方案)中,UI 层并不直接绑定编辑器的内部方法,而是通过“命令注册表 + 选择状态”驱动菜单。useMenuModel() 正是这条链路中的高层封装:它把 useEditorCommands() 暴露的命令、当前选区能力与可用页面列表,转换为应用可以直接交给任意 UI 组件库渲染的菜单结构。读完本文,你将掌握 useMenuModel() 的返回值语义、appMenu/canvasMenu/selectionLabelMenu 三类菜单模型的组织方式,以及如何结合 useEditorCommands() 在自己的 Vue 组件里渲染出与编辑器主界面一致的菜单。
一、useMenuModel 是什么:命令到菜单模型的桥接层
官方文档(英文版)对它的定位非常清晰:useMenuModel() 在编辑器命令与选择状态之上构建更高层的菜单结构,适合那些希望直接拿到“开箱即渲染”的菜单分组、而不是手工逐条组装命令的场景。
换句话说,它与底层 useEditorCommands() 的分工是:
useEditorCommands()提供命令级原语:commands映射、menuItem(id, shortcut)生成单条菜单项、runCommand(id)直接执行、otherPages/moveSelectionToPage支撑跨页移动;useMenuModel()在其之上完成“编排”:把命令按编辑/视图/对象/排列分组,把画布右键菜单的上下文逻辑(组件、组件集、实例、编组、翻页)固化成可复用结构,并把选区状态翻译成 Hide/Show、Lock/Unlock 这类动态文案。
从实现上看,useMenuModel 定义在 packages/vue/src/editor/menu-model/use.ts,并作为公共 API 从 packages/vue/src/index.ts 导出,类型 MenuActionNode、MenuEntry、MenuSeparatorNode 也随之一并导出,供下游组件消费。
二、快速上手:最小用法与返回值
1. 基本用法
import { useMenuModel } from '@open-pencil/vue'
const { appMenu, canvasMenu, selectionLabelMenu } = useMenuModel()
最简场景只需要画布右键菜单:
const { canvasMenu } = useMenuModel()
然后把 canvasMenu.value 直接渲染进你的右键菜单组件即可。由于三个返回值都是 computed,任何驱动它们的选择状态或命令状态变化都会自动触发重算,UI 无需手动同步。
2. 三个返回值的职责
| 返回值 | 类型 | 职责 |
|---|---|---|
appMenu | computed<AppMenuEntry[]> | 顶层应用菜单(Edit / View / Object / Arrange 四组),用于顶部菜单栏 |
canvasMenu | computed<MenuEntry[]> | 画布右键菜单的完整条目序列,包含动态“移动到页面”子菜单 |
selectionLabelMenu | computed<{ visibility: string; lock: string }> | 基于当前选中节点的可见性/锁定状态,输出动态文案标签 |
3. 数据来源
useMenuModel() 内部只依赖三个来源(见 use.ts):
const editor = useEditor()
const { menuItem: commandMenuItem, otherPages, moveSelectionToPage } = useEditorCommands()
const selection = useSelectionState()
其中 menuItem 负责把命令 ID 翻译成带标签、快捷键、禁用状态和执行的菜单项,otherPages 提供除当前页之外的目标页面,moveSelectionToPage 执行真正的跨页移动(内部会先检查 canMoveToPage 能力,见 packages/vue/src/editor/commands/use.ts)。
三、菜单条目数据结构:MenuEntry 与节点类型
无论是 appMenu 的子项还是 canvasMenu,最终都归结为 MenuEntry 联合类型,定义在 packages/vue/src/editor/menu-model/types.ts:
interface MenuActionNode {
separator?: false
menuId?: string
id?: EditorCommandId // 命令 ID,用于图标/测试 ID 关联
label: string // 菜单显示文案
icon?: Component // 可选图标组件
shortcut?: string // 快捷键显示文本
paletteShortcut?: string
action?: () => void // 点击执行的函数
disabled?: boolean
testId?: TestId
checked?: boolean
onCheckedChange?: (checked: boolean) => void
palette?: {
icon?: Component
label?: string
description?: string
keywords?: string[]
}
sub?: MenuEntry[] // 子菜单
}
interface MenuSeparatorNode {
separator: true
}
type MenuEntry = MenuActionNode | MenuSeparatorNode
这份结构对渲染层极其友好:普通条目具备 label、shortcut、disabled、action;带 sub 的条目天然表示子菜单;separator: true 的条目用于分组分隔线。这意味着任何 UI 库(reka-ui、Element Plus、自研组件等)都可以无脑消费这份数据结构。
四、appMenu:应用级顶层菜单的组装逻辑
appMenu 把命令分组为四个顶层菜单(源码见 use.ts):
const appMenu = computed(() => [
{ label: t.value.edit, items: editMenu.value },
{ label: t.value.view, items: viewMenu.value },
{ label: t.value.object, items: objectMenu.value },
{ label: t.value.arrange, items: arrangeMenu.value }
])
各菜单项的构建分别由 buildEditMenu、buildViewMenu、buildObjectMenu 完成,实现位于 packages/vue/src/editor/menu-model/builders.ts,而具体命令分组定义在 packages/vue/src/editor/menu-model/command-groups.ts:
- Edit(编辑):
edit.undo/edit.redo、selection.duplicate/selection.delete、selection.selectAll,组与组之间自动插入分隔线; - View(视图):
view.zoom100、view.zoomFit、view.zoomSelection; - Object(对象):
selection.group/selection.ungroup、selection.createComponent/selection.createComponentSet/selection.detachInstance、selection.bringToFront/selection.sendToBack; - Arrange(排列):目前是单个命令
selection.wrapInAutoLayout(自动布局)。
commandGroupEntries 的核心逻辑是:遍历分组数组,第一个分组前不加分隔线,之后每个新分组前插入 { separator: true },再把组内每个命令 ID 通过 commandMenuItem(id) 展开成完整菜单项。因此,扩展顶层菜单的常规做法是修改 command-groups.ts 中的分组常量,而不是改 UI 组件。
五、canvasMenu:画布右键菜单与动态子菜单
canvasMenu 是 useMenuModel() 最具“上下文感知”能力的产物。它由 packages/vue/src/editor/menu-model/canvas.ts 中的 buildCanvasContextMenu 构建,输入包括 commandMenuItem、otherPages、moveSelectionToPage、selection 与 i18n 文案。
1. 固定分组与条件命令
画布菜单定义了六个分组(CANVAS_MENU_GROUPS):
selection.duplicate/selection.deleteselection.moveToPageWhenAvailable、selection.bringForward、selection.bringToFront、selection.sendBackward、selection.sendToBackselection.group、selection.frameSelection、selection.ungroupWhenGroup、selection.wrapInAutoLayout、selection.toggleMask、selection.flatten、selection.outlineText、selection.outlineStrokeselection.componentAction、selection.componentSetAction、selection.instanceActionsselection.toggleVisibility/selection.toggleLockselection.flipHorizontal/selection.flipVertical
其中一部分是“条件命令”,根据当前选区动态展开(conditionalCommand):
moveToPageWhenAvailable:仅当selection.hasSelection为真且存在其他页面(otherPages.length > 0)时出现,并展开为“Move to page”子菜单,每个子项对应一个目标页;componentAction:选中节点是组件(isComponent)时显示“创建实例”,否则显示“创建组件”;componentSetAction:仅在canCreateComponentSet为真时显示“创建组件集”;instanceActions:仅在isInstance为真时显示“跳转到主组件”与“分离实例”;ungroupWhenGroup:仅在选中是编组(isGroup)时显示“取消编组”。
2. 空分组自动折叠
buildCanvasContextMenu 会逐组收集条目,若某组展开后为空则整体跳过,只有非空分组之间才插入分隔线。这保证了右键菜单在空选区、选中编组、选中实例等不同状态下都能保持整洁,不出现“点了没反应”的空白项。
3. “移动到页面”子菜单的生成
对应文档中的示例(英文版文档),其底层逻辑在 canvas.ts:
function moveToPageItem({ otherPages, moveSelectionToPage, selection, t }: CanvasMenuOptions) {
if (!selection.hasSelection.value || otherPages.length === 0) return []
const sub = otherPages.map((page) => ({
label: page.name,
action: () => moveSelectionToPage(page.id)
}))
return [{ label: t.moveToPage, sub } satisfies MenuActionNode]
}
如果你不想用 useMenuModel() 的高层封装,也可以直接用 useEditorCommands() 手工构建同样的子菜单(use-editor-commands 文档):
const { otherPages, moveSelectionToPage } = useEditorCommands()
const items = otherPages.value.map(page => ({
label: page.name,
action: () => moveSelectionToPage(page.id),
}))
六、selectionLabelMenu:随选区变化的动态文案
selectionLabelMenu 解决的是菜单文案的“状态翻转”问题(use.ts):
const selectionLabelMenu = computed(() => ({
visibility: (editor.getSelectedNode()?.visible ?? true) ? t.value.hide : t.value.show,
lock: (editor.getSelectedNode()?.locked ?? false) ? t.value.unlock : t.value.lock
}))
- 节点当前可见 → 显示
Hide;节点被隐藏 → 显示Show; - 节点未锁定 → 显示
Lock;节点已锁定 → 显示Unlock; - 无选中节点时,按默认值(可见、未锁定)处理。
这与画布菜单第 5 组 selection.toggleVisibility / selection.toggleLock 形成呼应:菜单项命令统一由 toggleVisibility / toggleLock 执行,而显示文案则跟随状态实时翻转,让用户永远看到的是“下一步动作”,而不是静态描述。
七、生产环境中的真实使用:CanvasMenu.vue
useMenuModel() 并非仅仅存在于 SDK 文档中,open-pencil 主应用本身的画布右键菜单就是它的直接消费者。src/components/canvas/CanvasMenu.vue 中:
const { canvasMenu } = useMenuModel()
随后 canvasMenu 被交给 useCanvasContextMenu 组合成最终渲染用的上下文菜单模型,再用 reka-ui 的 ContextMenuContent / ContextMenuItem / ContextMenuSub 渲染。从 CanvasMenu.vue 的模板可以看到它对菜单模型的消费方式:遇到 item.separator 渲染 ContextMenuSeparator;遇到 item.sub 渲染带箭头的 ContextMenuSub 子菜单;普通条目则渲染 ContextMenuItem,并绑定 item.disabled、item.action、item.shortcut 与基于 item.id 的图标与测试 ID。这正是 MenuEntry 数据结构设计意图的完整落地。
同时,CanvasMenu.vue 也演示了 useMenuModel() 与底层 API 的协作:useSelectionState() 提供 selectedIds / hasSelection,useEditorCommands() 提供 getCommand,用于渲染复制、剪切、粘贴、粘贴替换等命令项,并借助 editorCommandMetadata 与 formatShortcut 格式化快捷键显示。
八、关联 API 与下一步学习
useMenuModel() 处于菜单体系的高层,深入理解它需要顺藤摸瓜阅读同一家族的 composable:
- useEditorCommands:命令级原语,提供
commands、menuItem、runCommand、otherPages、moveSelectionToPage,是useMenuModel的底层依赖;文档中列出了完整的EditorCommandId类型,覆盖编辑、选择、组件、视图等全部命令标识; useSelectionState:当前选中节点、isComponent、isInstance、isGroup、canCreateComponentSet等状态,驱动画布菜单的条件命令;useSelectionCapabilities:提供canMoveToPage等能力标记,决定跨页移动等操作是否可用;useEditor:编辑器上下文本身,用于读取getSelectedNode()、currentPageId等核心状态。
相关源码与文档索引:
- 核心实现:packages/vue/src/editor/menu-model/use.ts、builders.ts、canvas.ts、command-groups.ts、types.ts
- 公共导出:packages/vue/src/index.ts
- 应用内消费示例:src/components/canvas/CanvasMenu.vue
- 右键菜单用户指南:packages/docs/user-guide/context-menu.md
小结
useMenuModel() 用约 60 行代码,把命令注册表、选择状态和页面列表编排成三类可直接渲染的菜单模型:appMenu 负责顶层菜单的稳定分组,canvasMenu 负责右键菜单的上下文动态性(组件/实例/编组/翻页),selectionLabelMenu 负责随状态翻转的文案。理解它的关键在于记住一条主线:UI 只消费结构化的 MenuEntry,命令与状态的变化通过 computed 自动传导。当你想为自己的面板、工具栏或自定义右键菜单复用编辑器能力时,优先组合 useMenuModel() 与 useEditorCommands(),而不是直接调用编辑器内部方法。
【免费下载链接】open-pencil
AI-native design editor. Open-source Figma alternative.
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)