前一章的提示组改变“正文里有什么”。如果你想增加工具栏按钮、侧栏或文件菜单,就需要产品层的插件。本章复用 Classic 的界面,加入“我的产品”分组和“产品检查器”面板。
如果完全自己实现工作区,可以直接组合自己的组件;Classic 插件声明需要由 Classic 产品实例消费,不能放进 Core 的 extensions 后期待出现工具栏。
先试一下要做的功能
在开始功能区找到“我的产品”,点击“插入说明”,观察正文变化。再打开“产品检查器”,查看当前产品状态;关闭面板不会修改正文。
1. 在插件中声明动作
plugin/notebook.ts 是插件入口。它定义分组和动作,并把它们放到功能区。分组的 tabId 指定已有页签,动作的 groupId 指定放在哪个分组。
动作运行时能取得当前 editor、产品 instance 和 ui。修改文字时调用 Core 命令;打开面板时使用 ui。这样同一个动作可以复用到其他工具栏位置,不需要再维护一套文字命令。
动作还可以分别提供“是否安装了这项能力”“当前能否执行”“是否已选中”和“禁用原因”。只读或选区变化后,Classic 使用这些查询更新界面。
2. 创建并更新侧栏
plugin/panel.ts 连接 Classic 与 Vue,plugin/panel.vue 显示面板内容。挂载结果包含 element、update、destroy:分别提供页面元素、刷新上下文、清理组件。
面板打开时创建自己的 Vue 根;语言和选区改变时更新现有界面;关闭时释放它。不要在插件的每次更新中重复创建订阅。
3. 注册文件服务
示例还注册了 notebook-backup 服务。点击“下载产品备份”时,应用调用 instance.exportDocument('notebook-backup'),取得生成的数据、文件名和 MIME,再交给浏览器下载。
服务负责格式读写;文件菜单中的位置需要另行声明。因此可以复用同一转换服务,而不强制使用同一种菜单布局。
文件分工
| 文件 | 用途 |
|---|---|
plugin/notebook.ts | 插件、分组、动作和文件服务声明 |
plugin/panel.ts | 将侧栏挂载生命周期连接到 Vue |
plugin/panel.vue | 产品检查器的界面 |
plugin/editor.vue | 创建 Classic 并安装该插件 |
import { defineClassicEditorPlugin, createClassicEditorRibbonAction, type ClassicEditorActionDefinition } from "@quirekit/classic-editor/vue";
import { readBackup, createBackup } from "../files/backup";
import { renderNotebookPanel } from "./panel";
/**
* Builds a fresh product plugin with a ribbon group, typed action, panel and backup service.
* @returns Declarations consumed by Classic's registry; they do not replace Core mutation semantics.
*/
export function createNotebookPlugin() {
const open: ClassicEditorActionDefinition = {
id: "notebook.inspect", label: { "zh-CN": "产品检查器", en: "Product inspector" }, order: 10,
run(context) { context.ui.openSidebarPanel("right", "notebook.panel"); return true; },
isActive(context) { return context.ui.state.rightSidebar.visible && context.ui.state.rightSidebar.activePanelId === "notebook.panel"; },
};
const insert: ClassicEditorActionDefinition = {
id: "notebook.insert", label: { "zh-CN": "插入说明", en: "Insert note text" }, order: 20,
canExecute(context) { return context.editor.isEditable && context.editor.can().insertContent({ type: "paragraph" }); },
run(context) { return context.editor.chain().focus().insertContent({ type: "paragraph", content: [{ type: "text",
text: context.locale === "en" ? "Inserted by a product plugin." : "由产品插件插入的说明。" }] }).run(); },
};
return defineClassicEditorPlugin({ id: "notebook",
contributions: [
{ kind: "ribbon-group", id: "notebook.group", tabId: "home", label: { "zh-CN": "我的产品", en: "My product" }, order: 1 },
createClassicEditorRibbonAction("notebook.inspect.control", "notebook.group", open),
createClassicEditorRibbonAction("notebook.insert.control", "notebook.group", insert),
],
sidebarPanels: [{ kind: "sidebar-panel", id: "notebook.panel", side: "right", label: { "zh-CN": "产品检查器", en: "Product inspector" }, order: 10, render: renderNotebookPanel }],
services: [{ id: "notebook-backup", label: { "zh-CN": "产品备份", en: "Product backup" },
format: { id: "notebook-json", extensions: [".json"], mimeTypes: ["application/json"] },
export(context) { return { data: JSON.stringify(createBackup(context.editor, "word-v1")), fileName: "notebook.json", mimeType: "application/json" }; },
import(context, input) {
if (!context.editor.isEditable) throw new Error("The product is read only");
const source = typeof input.data === "string" ? input.data : new TextDecoder().decode(input.data);
const document = readBackup(source, "word-v1", context.editor.schema);
context.commit(() => context.editor.commands.replaceDocument(document, { errorOnInvalidContent: true }));
},
}],
});
}
接入入口和类型见 Classic 插件文档与 Classic API。文件失败和异步加载的处理继续见保存与打开。