• 前端
  • 桌面应用
  • AI 应用
  • MCP 服务

【免费下载链接】open-pencil

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

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

导读

在 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. 三个返回值的职责

返回值类型职责
appMenucomputed<AppMenuEntry[]>顶层应用菜单(Edit / View / Object / Arrange 四组),用于顶部菜单栏
canvasMenucomputed<MenuEntry[]>画布右键菜单的完整条目序列,包含动态“移动到页面”子菜单
selectionLabelMenucomputed<{ 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):

  1. selection.duplicate / selection.delete
  2. selection.moveToPageWhenAvailable、selection.bringForward、selection.bringToFront、selection.sendBackward、selection.sendToBack
  3. selection.group、selection.frameSelection、selection.ungroupWhenGroup、selection.wrapInAutoLayout、selection.toggleMask、selection.flatten、selection.outlineText、selection.outlineStroke
  4. selection.componentAction、selection.componentSetAction、selection.instanceActions
  5. selection.toggleVisibility / selection.toggleLock
  6. selection.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 等核心状态。

相关源码与文档索引:

小结

useMenuModel() 用约 60 行代码,把命令注册表、选择状态和页面列表编排成三类可直接渲染的菜单模型:appMenu 负责顶层菜单的稳定分组,canvasMenu 负责右键菜单的上下文动态性(组件/实例/编组/翻页),selectionLabelMenu 负责随状态翻转的文案。理解它的关键在于记住一条主线:UI 只消费结构化的 MenuEntry,命令与状态的变化通过 computed 自动传导。当你想为自己的面板、工具栏或自定义右键菜单复用编辑器能力时,优先组合 useMenuModel() 与 useEditorCommands(),而不是直接调用编辑器内部方法。

  • 前端
  • 桌面应用
  • AI 应用
  • MCP 服务

【免费下载链接】open-pencil

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

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

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐