- 前端
- 桌面应用
- AI 应用
- MCP 服务
【免费下载链接】open-pencil
AI-native design editor. Open-source Figma alternative.
本文围绕 OpenPencil Vue SDK 的无头(headless)导航原语展开,讲解如何用PageListRoot/usePageList()搭建页面列表、用LayerTreeRoot搭建图层树,并结合useSelectionState驱动选择状态。读完本文,你将掌握侧边栏导航面板的完整构建思路:SDK 负责树结构与交互逻辑,应用自由决定样式与行组件,并可参考官方应用的 PagesPanel.vue 与 LayerTree 获得可落地的实现细节。
导航面板:页面导航与图层导航的组合
在设计工具中,侧边栏(sidebar)通常承担两类职责:
- 页面导航(page navigation):列出文档内的多个页面,支持切换、新建、重命名、删除与排序;
- 图层导航(layer navigation):以树形结构展示当前页面中的节点层级,支持选中、展开/折叠、可见性/锁定切换与重命名。
OpenPencil 的 Vue SDK(@open-pencil/vue)为这两类职责分别提供了无头原语(headless primitives):PageListRoot与LayerTreeRoot。所谓"无头",是指这些组件不携带任何预设外观——它们只负责维护数据、状态与交互行为,把pages、items、selectedIds等状态通过作用域插槽(v-slot)暴露出来,由应用自行决定 Markup 与样式。这正是 LayerTreeRoot.vue 中 "SDK-managed tree structure but app-owned presentation" 的设计意图。
页面导航:PageListRoot 与 usePageList()
PageListRoot 基础用法
原文档给出的最小示例清晰展示了PageListRoot的用法——通过v-slot解构出pages、currentPageId、switchPage、addPage:
<PageListRoot v-slot="{ pages, currentPageId, switchPage, addPage }"> <div> <button v-for="page in pages" :key="page.id" @click="switchPage(page.id)"> {{ page.name }} </button> <button @click="addPage()">New page</button> </div> </PageListRoot>PageListRoot本身不产出任何 DOM 结构,v-for与按钮完全由应用编写。页面数据来自当前编辑器场景图(scene graph),因此页面的增删、切换都会立即反映到插槽数据中。
插槽暴露的完整数据与动作
结合 PageListRoot.vue 的插槽实现,PageListRoot实际暴露:
| 插槽属性 | 类型 | 说明 |
|---|---|---|
pages | 页面数组 | 当前文档的全部页面(id、name、childIds等) |
currentPageId | string | 当前激活页面 id,可用于高亮当前页 |
isDivider | (page) => boolean | 判断某页是否为"分割线"页(无子节点且名称匹配分隔符模式) |
actions | 对象 | 页面操作的统一集合:add、switch、rename、delete、move |
actions与组件原生emit一一对应:add、switch、rename、delete、move。也就是说,即使不直接调用 composable,也能在插槽内通过actions.add()、actions.switch(pageId)等完成全部页面管理操作,这比原文档示例中的switchPage/addPage覆盖面更完整。
分割线(divider)识别机制
PageListRoot支持一个可选 propdividerPattern?: RegExp,用于识别作为视觉分隔符使用的"页面"。其默认模式为/^[-–—*\s]+$/,即名称仅由连字符、短破折号、长破折号、星号与空白组成的页面被视为分割线;同时要求该页childIds.length === 0(没有子节点)。应用可用isDivider在插槽内决定是否渲染分隔线样式——官方 PagesPanel.vue 中就用isDivider(pg)渲染一条细线并支持双击重命名。
usePageList():页面管理的 composable 形态
当不需要组件封装、只想在逻辑层直接操作页面时,可以使用usePageList()。其实现位于 packages/vue/src/primitives/PageList/usePageList.ts,返回:
const { editor, // Editor 实例 pages, // 响应式页面数组(useSceneComputed(editor.graph.getPages())) currentPageId, // computed,来自 editor.state.currentPageId switchPage, // editor.switchPage addPage, // editor.addPage deletePage, // editor.deletePage movePage, // editor.movePage renamePage // editor.renamePage } = usePageList()从源码可以看出,pages通过useSceneComputed包装editor.graph.getPages(),因而对场景图变更保持响应式;而switchPage、addPage等动作直接透传自 Editor 实例。也就是说PageListRoot与usePageList()共享同一套底层实现,前者只是多了插槽与 emit 的声明式包装。官方应用在 PagesPanel.vue 中直接消费插槽内的actions(actions.add()、actions.switch(pg.id)、actions.rename、actions.delete、actions.move),并结合useInlineRename实现双击行内重命名、useFlatReorderDrag实现拖拽排序。
图层导航:LayerTreeRoot
基础用法
当希望 SDK 管理树的构建与行为、应用负责渲染与样式时,使用LayerTreeRoot:
<LayerTreeRoot v-slot="{ items, selectedIds, select, toggleExpand, getKey, getChildren }"> <TreeView :items="items" :selected-ids="selectedIds" :get-key="getKey" :get-children="getChildren" @select="select" @toggle-expand="toggleExpand" /> </LayerTreeRoot>示例中的TreeView是应用自绘的树组件:items提供扁平节点数组,getKey/getChildren用于恢复层级,select与toggleExpand则回传用户交互。整个过程中 SDK 负责"树从哪来、选中怎么算、展开怎么存"。
插槽暴露的完整状态与动作
根据 LayerTreeRoot.vue,插槽实际提供:
| 插槽属性 | 说明 |
|---|---|
items | LayerNode[],树节点数组 |
flattenItems | 由底层 reka-uiTreeRoot提供的扁平化条目 |
visibleRows | 展开状态下可见的行({ node, level, hasChildren }),配合虚拟滚动 |
expanded | 当前展开的节点 id 数组(v-model 语义) |
treeVersion | 树重建版本号,用于强制刷新缓存 |
selectedIds | 当前选中的节点 id 集合(Set<string>) |
focused | 树是否聚焦 |
draggingId/instruction/instructionTargetId | 拖拽行、拖拽指令与目标 id |
actions | select、toggleExpand、setFocused、setVirtualizer |
树节点LayerNode的结构定义在 packages/vue/src/primitives/LayerTree/context.ts:id、name、type、layoutMode、visible、locked以及可选的children——恰好覆盖图层树行渲染所需的全部信息。
树模型构建与增量更新
LayerTreeRoot内部通过buildLayerTreeModel(editor.graph, editor.state.currentPageId)从当前页面构建树模型,并订阅了以下编辑器事件以保持同步(见 LayerTreeRoot.vue):
graph:replaced、page:changed→ 整体重建树;node:created、node:deleted、node:reparented、node:reordered→ 调度微任务重建;node:updated→ 只对name、type、layoutMode、visible、locked等可修补字段做局部 patch,避免整树重建;selection:changed→ 自动展开选中节点的祖先,并将选中项滚动到可见区域。
这种"全量重建 + 增量 patch"的组合,保证了图层树在大型文档下仍能保持流畅,也为虚拟滚动提供了稳定的行数据源。
选择行为:additive 与 range
LayerTreeRoot的select动作接收(id, selection),其中selection既可以是布尔值,也可以是{ additive, range }模式对象。官方应用 LayerTree.vue 中把鼠标事件转换为选择模式:按住 Meta/Ctrl 点击为增量选择(additive),按住 Shift 点击为范围选择(range)。底层还会通过layerSelectionForTarget依据可见行、当前选中集合与选择锚点计算最终选中集,并在非增量模式下同步画布容器作用域(syncCanvasScope)。
行渲染、虚拟滚动与拖拽
- 行组件:SDK 还导出配套的
LayerTreeItem行原语,以及useLayerTree()组合式函数用于在子组件中读取树上下文;在LayerTreeRoot之外调用useLayerTree()会抛出[open-pencil] useLayerTree() called outside <LayerTreeRoot>错误(见 context.ts)。 - 虚拟滚动:
LayerTreeRoot接受indentPerLevel(默认16,每层缩进像素)prop,并通过setVirtualizer让应用把 reka-uiTreeVirtualizer注册进来,配合visibleRows实现scrollToIndex定位(选中自动滚动即依赖此机制)。 - 拖拽:拖拽能力由
useLayerDrag提供,指令类型包括reorder-above、reorder-below、make-child,应用可据此渲染拖拽指示器并实现图层重排/嵌套。
官方应用对照
官方 LayerTree/LayerTree.vue 是LayerTreeRoot的生产级用法范例:它组合 reka-ui 的TreeItem、TreeVirtualizer与ContextMenu,用LayerTreeNodeRow.vue/LayerTreeRenameRow.vue作为行组件,并在右键时先选中目标节点再弹出上下文菜单。整个 LayersPanel.vue 再通过垂直 Splitter 把PagesPanel(页面列表)与图层树上下分区,构成了"页面上、图层下"的典型侧边栏。
用 useSelectionState 驱动面板状态
图层树与属性面板都依赖"当前选了什么"这一派生状态。useSelectionState()从当前编辑器读取选择并计算出一组响应式派生值,实现位于 packages/vue/src/editor/selection-state/use.ts:
| 返回字段 | 说明 |
|---|---|
selectedIds | 选中节点 id 集合(Set<string>) |
hasSelection | 是否至少选中一个节点 |
selectedNode | 主选中节点(首选节点) |
selectedCount | 选中节点数量 |
selectedNodeType | 主选中节点的类型(INSTANCE/COMPONENT/GROUP等) |
isInstance | 是否为实例(类型INSTANCE) |
isComponent | 是否为组件(类型COMPONENT) |
isGroup | 是否为编组(类型GROUP) |
canCreateComponentSet | 选中项 ≥ 2 且全部为COMPONENT时,可创建组件集 |
基础用法示例:
<script setup lang="ts"> import { useSelectionState } from '@open-pencil/vue' const { hasSelection, selectedCount, isInstance } = useSelectionState() </script> <template> <div class="text-xs text-muted"> <span v-if="!hasSelection">No selection</span> <span v-else> {{ selectedCount }} selected <span v-if="isInstance">· instance</span> </span> </div> </template>从源码看,selectedIds同样经useSceneComputed包装editor.state.selectedIds;canCreateComponentSet的判定逻辑是遍历选中集合、要求每个节点类型均为COMPONENT且数量不少于 2。这些派生值可以直接用于属性面板的启用/禁用状态、图层树右键菜单项显隐,以及与 useSelectionCapabilities、useEditorCommands 联动实现命令级操作。
典型布局:页面在上、图层在下
综合官方实践与 SDK 设计,一个完整的导航侧边栏通常遵循如下排布:
- 页面列表位于侧边栏顶部:用
PageListRoot渲染页面按钮/行,高亮currentPageId,提供新建、重命名、删除与拖拽排序; - 图层树位于其下:用
LayerTreeRoot渲染节点层级,行内显示可见性、锁定图标,支持多选、展开折叠与拖拽重排; - 详情与行内重命名嵌入行组件:详情信息(如类型、布局模式)与重命名输入框都放在行组件内部,官方应用通过
useInlineRename实现双击进入编辑态(参见 PagesPanel.vue 与 LayerTree.vue)。
这一模式的核心收益在于:结构、状态与行为由 SDK 保证一致性,而视觉与交互细节完全由应用掌控——无论是官方编辑器皮肤,还是完全自定义的编辑器外壳(custom editor shell),都可以复用同一套无头原语。
相关 API 与延伸阅读
- usePageList:页面管理 composable 的完整文档
- PageListRoot:页面列表无头组件文档
- LayerTreeRoot:图层树无头组件文档
- useSelectionState:选择派生状态 composable 文档
- Property Panels 指南:与导航面板配套的属性面板构建思路
- Custom Editor Shell 指南:自定义编辑器外壳的组装方式
- Vue SDK 入口:
PageListRoot、LayerTreeRoot、LayerTreeItem、usePageList等公共导出的统一出口 - Getting Started:SDK 初始化与编辑器上下文注入
- 前端
- 桌面应用
- AI 应用
- MCP 服务
【免费下载链接】open-pencil
AI-native design editor. Open-source Figma alternative.
相关推荐
用 PageListRoot 构建无样式的页面导航面板:OpenPencil Vue SDK 页面列表演示原语
用 PageListRoot 构建无样式的页面导航面板:OpenPencil Vue SDK 页面列表演示原语 PageListRoot 是 OpenPenci
前端桌面应用AI 应用MCP 服务OpenPencil 图层与页面完全指南:层级树、多页面管理与属性面板实战
OpenPencil 图层与页面完全指南:层级树、多页面管理与属性面板实战 本文是 OpenPencil(开源、可编程、AI 原生的设计编辑器)用户指南系列的一
前端桌面应用AI 应用MCP 服务OpenPencil 图层与页面管理实战:图层树、多页面切换与属性面板全解析
OpenPencil 图层与页面管理实战:图层树、多页面切换与属性面板全解析 导读 本文以 OpenPencil 官方用户指南( packages/docs/e
前端桌面应用AI 应用MCP 服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考