# 给 Classic 增加产品功能

区分正文扩展和界面插件，添加动作、侧栏与文件服务。

前一章的提示组改变“正文里有什么”。如果你想增加工具栏按钮、侧栏或文件菜单，就需要产品层的插件。本章复用 Classic 的界面，加入“我的产品”分组和“产品检查器”面板。

如果完全自己实现工作区，可以直接组合自己的组件；Classic 插件声明需要由 Classic 产品实例消费，不能放进 Core 的 `extensions` 后期待出现工具栏。

## 先试一下要做的功能

在开始功能区找到“我的产品”，点击“插入说明”，观察正文变化。再打开“产品检查器”，查看当前产品状态；关闭面板不会修改正文。

### product/plugin/editor.vue

```vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
import type { ClassicEditorInstance } from "@quirekit/classic-editor";
import { defaultTheme } from "@quirekit/ui";
import { QuireButton, useQuireKitTheme } from "@quirekit/ui/vue";
import { downloadJson } from "../../shared/download";
const props = withDefaults(defineProps<{ /** Current product interface language. */ locale?: "zh-CN" | "en" }>(), { locale: "zh-CN" });
const host = ref<HTMLElement>(); const product = shallowRef<ClassicEditorInstance>(); const failure = ref(false);
const exportError = ref<string | null>(null); const exporting = ref(false);
const theme = useQuireKitTheme(); let disposed = false;
/** Calls the registered format service; the browser host owns artifact download. */
async function exportBackup(): Promise<void> {
  if (!product.value || exporting.value) return;
  exporting.value = true; exportError.value = null;
  try {
    const artifact = await product.value.exportDocument("notebook-backup");
    if (disposed) return;
    const source = typeof artifact.data === "string" ? artifact.data : new TextDecoder().decode(artifact.data);
    downloadJson(JSON.parse(source), artifact.fileName);
  } catch (error) { if (!disposed) exportError.value = String(error); }
  finally { if (!disposed) exporting.value = false; }
}
onMounted(async () => {
  try {
    const [classic, word, { createNotebookPlugin }] = await Promise.all([
      import("@quirekit/classic-editor/vue"), import("@quirekit/classic-docx/vue"), import("./notebook"),
      import("@quirekit/classic-editor/styles.css"), import("@quirekit/classic-docx/styles.css"),
    ]);
    if (disposed || !host.value) return;
    product.value = classic.createClassicEditor({ element: host.value, locale: props.locale, theme: theme?.value ?? defaultTheme,
      preset: word.createClassicWordPreset({ proofing: false }), document: word.createClassicWordDefaultDocument(props.locale),
      plugins: [createNotebookPlugin(), word.createClassicWordDocxPlugin()], webFonts: false,
      shell: { rightSidebar: false, leftSidebar: false, toolbar: false, ribbonOverflow: "scroll" } });
    product.value.ui.setZoomMode("fit-width");
  } catch { if (!disposed) failure.value = true; }
});
watch(() => props.locale, locale => product.value?.update({ locale }));
if (theme) watch(theme, value => product.value?.update({ theme: value }));
onBeforeUnmount(() => { disposed = true; product.value?.destroy(); });
</script>
<template>
  <p v-if="failure" role="alert">{{ locale === 'en' ? 'Reload this page to retry loading.' : '加载失败，请重新加载页面后重试。' }}</p>
  <section class="notebook-example" :data-ready="Boolean(product)">
    <div class="notebook-tools"><QuireButton variant="outline" :disabled="!product || exporting" @click="exportBackup">{{ locale === 'en' ? 'Download product backup' : '下载产品备份' }}</QuireButton></div>
    <p v-if="exportError" role="alert">{{ exportError }}</p>
    <div ref="host" class="notebook-host" />
  </section>
</template>
<style scoped>.notebook-example { min-width: 0; text-align: start; }.notebook-host { height: 650px; }.notebook-tools { padding: 12px; border-bottom: 1px solid var(--qk-color-border-subtle); }</style>

```

### shared/download.ts

```ts
/**
 * Downloads canonical JSON through a short-lived browser URL.
 * @param document - Serializable canonical content.
 * @param name - Download filename chosen by the host.
 */
export function downloadJson(document: unknown, name: string): void {
  const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" }));
  const link = window.document.createElement("a");
  link.href = url;
  link.download = name;
  link.click();
  window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}

```

### product/plugin/notebook.ts

```ts
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 }));
      },
    }],
  });
}

```

### product/files/backup.ts

```ts
import type { Editor, JSONContent } from "@quirekit/core";
import type { ProductBackup, ProductModel } from "../types";

/** @param value - Parsed JSON value. @returns Whether it is a non-array record. */
function record(value: unknown): value is Record<string, unknown> {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
 * Rejects unknown node, mark and attribute names before PM can discard them.
 * @param value - Candidate node JSON.
 * @param schema - Schema selected by this product's assembly.
 */
function checkNames(value: unknown, schema: Editor["schema"]): void {
  if (!record(value) || typeof value.type !== "string" || !schema.nodes[value.type]) throw new Error("Unknown document node");
  const type = schema.nodes[value.type]!;
  if (value.attrs !== undefined) {
    if (!record(value.attrs) || Object.keys(value.attrs).some(name => !(name in (type.spec.attrs ?? {})))) throw new Error("Unknown node attribute; migrate this document first");
  }
  if (value.content !== undefined) {
    if (!Array.isArray(value.content)) throw new Error("Invalid node content");
    value.content.forEach(child => checkNames(child, schema));
  }
  if (value.marks !== undefined) {
    if (!Array.isArray(value.marks)) throw new Error("Invalid marks");
    for (const mark of value.marks) {
      if (!record(mark) || typeof mark.type !== "string" || !schema.marks[mark.type]) throw new Error("Unknown document mark");
      const markType = schema.marks[mark.type]!;
      if (mark.attrs !== undefined && (!record(mark.attrs) || Object.keys(mark.attrs).some(name => !(name in (markType.spec.attrs ?? {}))))) throw new Error("Unknown mark attribute; migrate this document first");
    }
  }
}
/**
 * Validates envelope, schema names and the full content expression without changing Editor.
 * @param source - Original backup, retained by the caller on error.
 * @param model - Installed schema family/version.
 * @param schema - Installed, complete schema.
 * @returns Validated canonical content ready for setContent.
 */
export function readBackup(source: string, model: ProductModel, schema: Editor["schema"]): JSONContent {
  const value: unknown = JSON.parse(source);
  if (!record(value) || value.version !== 1 || value.model !== model) throw new Error("Unsupported backup model or version");
  checkNames(value.content, schema);
  const node = schema.nodeFromJSON(value.content);
  if (node.type !== schema.topNodeType) throw new Error("Invalid document root");
  node.check();
  return node.toJSON() as JSONContent;
}
/** @param editor - Current canonical authority. @param model - Installed schema. @returns Explicit serialization boundary. */
export function createBackup(editor: Editor, model: ProductModel): ProductBackup {
  return { version: 1, model, content: editor.getJSON() };
}

```

### product/types.ts

```ts
import type { Editor, QuireAnyExtension, JSONContent } from "@quirekit/core";
import type { QuireKitTheme } from "@quirekit/ui";

/** Canonical model/version namespace; backups from different schemas never mix. */
export type ProductModel = "generic-v1" | "word-v1";
/** Inputs supplied by a product host to its document/view assembly. */
export interface AssemblyContext {
  /** The one element whose scroll offset defines the visible workspace. */ readonly viewport: HTMLElement;
  /** Shared presentation theme, independent of persisted content. */ readonly theme: QuireKitTheme;
  /** Committed layout facts consumed by product chrome. */ readonly onLayout: (pages: number, width: number, height: number) => void;
}
/** Schema, initial content and view adapters selected together. */
export interface ProductAssembly {
  /** Stable schema namespace used by persistence. */ readonly id: ProductModel;
  /** @param locale - Initial copy only. @returns A fresh canonical document. */
  createDocument(locale: "zh-CN" | "en"): JSONContent;
  /** @param editor - Current canonical state. @returns Effective emphasis under this model's formatting rules. */
  isBold(editor: Editor): boolean;
  /** @param context - Host-owned viewport and presentation. @returns Fresh instance-owned extensions. */
  createExtensions(context: AssemblyContext): readonly QuireAnyExtension[];
}
/** Host persistence; production implementations can replace browser Storage with a service. */
export interface ProductRepository {
  /** @returns The last accepted backup, or null. */ read(): Promise<string | null>;
  /** @param source - Captured, versioned backup. @returns Completion for that exact capture. */ write(source: string): Promise<void>;
}
/** Versioned portable backup owned by the product, not the renderer. */
export interface ProductBackup {
  /** Backup envelope version. */ readonly version: 1;
  /** Canonical schema family and version. */ readonly model: ProductModel;
  /** Canonical content; no geometry or mounted DOM. */ readonly content: JSONContent;
}
/** Small state projection used by product controls. */
export interface ProductState {
  /** Whether content differs from the last acknowledged immutable snapshot. */ readonly dirty: boolean;
  /** One explicit save is in flight. */ readonly saving: boolean;
  /** Current mark state at the selection. */ readonly bold: boolean;
  /** Installed command can run against the current state. */ readonly canBold: boolean;
  /** History availability. */ readonly canUndo: boolean;
  /** Forward history availability. */ readonly canRedo: boolean;
  /** Canonical editability, not a UI-only checkbox. */ readonly editable: boolean;
  /** Recoverable operation error, or null. */ readonly error: string | null;
}
/** Host workflow and subscription lifetime around a borrowed Editor. */
export interface ProductSession {
  /** Latest bounded projection. */ readonly state: ProductState;
  /** @param listener - Visible UI consumer. @returns Its release function. */ subscribe(listener: (state: ProductState) => void): () => void;
  /** @returns Whether the captured snapshot was persisted. */ save(): Promise<boolean>;
  /** @returns Whether the saved version was loaded without overwriting edits. */ restore(): Promise<boolean>;
  /** @param read - Deferred file read. @returns Whether it was still safe to apply the result. */ open(read: () => Promise<string>): Promise<boolean>;
  /** @returns Complete versioned backup of the state at invocation time. */ backup(): ProductBackup;
  /** @param editable - Product edit policy. */ setEditable(editable: boolean): void;
  /** Releases listeners and invalidates asynchronous completions; the creator destroys Editor. */ dispose(): void;
}
/** Runtime resources owned by one mounted product. */
export interface ProductRuntime {
  /** Single canonical editor, also used by every toolbar command. */ readonly editor: Editor;
  /** Host persistence and state lifecycle. */ readonly session: ProductSession;
  /** @param mode - Registered view backend. */ changeMode(mode: "canvas" | "svg"): void;
  /** Destroys borrowed services, mounted views and the canonical editor once. */ destroy(): void;
}

```

### product/plugin/panel.ts

```ts
import { createApp, shallowRef } from "vue";
import type { ClassicEditorRenderContext, ClassicEditorMountedRenderable } from "@quirekit/classic-editor";
import Panel from "./panel.vue";
/**
 * Mounts a public product panel with explicit update and teardown ownership.
 * @param initial - Mounted product context, including its locale and UI controller.
 * @returns A retained renderable; listeners disappear with its Vue application.
 */
export function renderNotebookPanel(initial: ClassicEditorRenderContext): ClassicEditorMountedRenderable {
  const context = shallowRef(initial);
  const element = initial.ownerDocument.createElement("div");
  const app = createApp(Panel, { context }); app.mount(element);
  return { element, update(next) { context.value = next; }, destroy() { app.unmount(); } };
}

```

### product/plugin/panel.vue

```vue
<script setup lang="ts">
import type { ShallowRef } from "vue";
import type { ClassicEditorRenderContext } from "@quirekit/classic-editor";
import { QuireButton } from "@quirekit/ui/vue";
const props = defineProps<{ /** Current mounted public context, updated by its renderable owner. */ context: ShallowRef<ClassicEditorRenderContext> }>();
</script>
<template>
  <section class="notebook-panel">
    <p>{{ context.value.locale === 'en' ? 'A product plugin owns this panel. Its controls use the same Core commands as the ribbon.' : '此面板由产品插件拥有，控件与功能区调用同一份 Core 命令。' }}</p>
    <dl><dt>{{ context.value.locale === 'en' ? 'Current selection' : '当前选区' }}</dt><dd>{{ context.value.editor.state.pm.selection.from }} → {{ context.value.editor.state.pm.selection.to }}</dd></dl>
    <QuireButton variant="outline" @click="props.context.value.ui.closeSidebar('right')">{{ context.value.locale === 'en' ? 'Close panel' : '关闭面板' }}</QuireButton>
  </section>
</template>
<style scoped>.notebook-panel { padding: 16px; font-size: 13px; line-height: 1.8; color: var(--qk-color-text-primary); } dl { margin: 16px 0; } dd { margin: 0; }</style>

```

## 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 并安装该插件 |

### product/plugin/notebook.ts

```ts
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 }));
      },
    }],
  });
}

```

### product/files/backup.ts

```ts
import type { Editor, JSONContent } from "@quirekit/core";
import type { ProductBackup, ProductModel } from "../types";

/** @param value - Parsed JSON value. @returns Whether it is a non-array record. */
function record(value: unknown): value is Record<string, unknown> {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
 * Rejects unknown node, mark and attribute names before PM can discard them.
 * @param value - Candidate node JSON.
 * @param schema - Schema selected by this product's assembly.
 */
function checkNames(value: unknown, schema: Editor["schema"]): void {
  if (!record(value) || typeof value.type !== "string" || !schema.nodes[value.type]) throw new Error("Unknown document node");
  const type = schema.nodes[value.type]!;
  if (value.attrs !== undefined) {
    if (!record(value.attrs) || Object.keys(value.attrs).some(name => !(name in (type.spec.attrs ?? {})))) throw new Error("Unknown node attribute; migrate this document first");
  }
  if (value.content !== undefined) {
    if (!Array.isArray(value.content)) throw new Error("Invalid node content");
    value.content.forEach(child => checkNames(child, schema));
  }
  if (value.marks !== undefined) {
    if (!Array.isArray(value.marks)) throw new Error("Invalid marks");
    for (const mark of value.marks) {
      if (!record(mark) || typeof mark.type !== "string" || !schema.marks[mark.type]) throw new Error("Unknown document mark");
      const markType = schema.marks[mark.type]!;
      if (mark.attrs !== undefined && (!record(mark.attrs) || Object.keys(mark.attrs).some(name => !(name in (markType.spec.attrs ?? {}))))) throw new Error("Unknown mark attribute; migrate this document first");
    }
  }
}
/**
 * Validates envelope, schema names and the full content expression without changing Editor.
 * @param source - Original backup, retained by the caller on error.
 * @param model - Installed schema family/version.
 * @param schema - Installed, complete schema.
 * @returns Validated canonical content ready for setContent.
 */
export function readBackup(source: string, model: ProductModel, schema: Editor["schema"]): JSONContent {
  const value: unknown = JSON.parse(source);
  if (!record(value) || value.version !== 1 || value.model !== model) throw new Error("Unsupported backup model or version");
  checkNames(value.content, schema);
  const node = schema.nodeFromJSON(value.content);
  if (node.type !== schema.topNodeType) throw new Error("Invalid document root");
  node.check();
  return node.toJSON() as JSONContent;
}
/** @param editor - Current canonical authority. @param model - Installed schema. @returns Explicit serialization boundary. */
export function createBackup(editor: Editor, model: ProductModel): ProductBackup {
  return { version: 1, model, content: editor.getJSON() };
}

```

### product/types.ts

```ts
import type { Editor, QuireAnyExtension, JSONContent } from "@quirekit/core";
import type { QuireKitTheme } from "@quirekit/ui";

/** Canonical model/version namespace; backups from different schemas never mix. */
export type ProductModel = "generic-v1" | "word-v1";
/** Inputs supplied by a product host to its document/view assembly. */
export interface AssemblyContext {
  /** The one element whose scroll offset defines the visible workspace. */ readonly viewport: HTMLElement;
  /** Shared presentation theme, independent of persisted content. */ readonly theme: QuireKitTheme;
  /** Committed layout facts consumed by product chrome. */ readonly onLayout: (pages: number, width: number, height: number) => void;
}
/** Schema, initial content and view adapters selected together. */
export interface ProductAssembly {
  /** Stable schema namespace used by persistence. */ readonly id: ProductModel;
  /** @param locale - Initial copy only. @returns A fresh canonical document. */
  createDocument(locale: "zh-CN" | "en"): JSONContent;
  /** @param editor - Current canonical state. @returns Effective emphasis under this model's formatting rules. */
  isBold(editor: Editor): boolean;
  /** @param context - Host-owned viewport and presentation. @returns Fresh instance-owned extensions. */
  createExtensions(context: AssemblyContext): readonly QuireAnyExtension[];
}
/** Host persistence; production implementations can replace browser Storage with a service. */
export interface ProductRepository {
  /** @returns The last accepted backup, or null. */ read(): Promise<string | null>;
  /** @param source - Captured, versioned backup. @returns Completion for that exact capture. */ write(source: string): Promise<void>;
}
/** Versioned portable backup owned by the product, not the renderer. */
export interface ProductBackup {
  /** Backup envelope version. */ readonly version: 1;
  /** Canonical schema family and version. */ readonly model: ProductModel;
  /** Canonical content; no geometry or mounted DOM. */ readonly content: JSONContent;
}
/** Small state projection used by product controls. */
export interface ProductState {
  /** Whether content differs from the last acknowledged immutable snapshot. */ readonly dirty: boolean;
  /** One explicit save is in flight. */ readonly saving: boolean;
  /** Current mark state at the selection. */ readonly bold: boolean;
  /** Installed command can run against the current state. */ readonly canBold: boolean;
  /** History availability. */ readonly canUndo: boolean;
  /** Forward history availability. */ readonly canRedo: boolean;
  /** Canonical editability, not a UI-only checkbox. */ readonly editable: boolean;
  /** Recoverable operation error, or null. */ readonly error: string | null;
}
/** Host workflow and subscription lifetime around a borrowed Editor. */
export interface ProductSession {
  /** Latest bounded projection. */ readonly state: ProductState;
  /** @param listener - Visible UI consumer. @returns Its release function. */ subscribe(listener: (state: ProductState) => void): () => void;
  /** @returns Whether the captured snapshot was persisted. */ save(): Promise<boolean>;
  /** @returns Whether the saved version was loaded without overwriting edits. */ restore(): Promise<boolean>;
  /** @param read - Deferred file read. @returns Whether it was still safe to apply the result. */ open(read: () => Promise<string>): Promise<boolean>;
  /** @returns Complete versioned backup of the state at invocation time. */ backup(): ProductBackup;
  /** @param editable - Product edit policy. */ setEditable(editable: boolean): void;
  /** Releases listeners and invalidates asynchronous completions; the creator destroys Editor. */ dispose(): void;
}
/** Runtime resources owned by one mounted product. */
export interface ProductRuntime {
  /** Single canonical editor, also used by every toolbar command. */ readonly editor: Editor;
  /** Host persistence and state lifecycle. */ readonly session: ProductSession;
  /** @param mode - Registered view backend. */ changeMode(mode: "canvas" | "svg"): void;
  /** Destroys borrowed services, mounted views and the canonical editor once. */ destroy(): void;
}

```

### product/plugin/panel.ts

```ts
import { createApp, shallowRef } from "vue";
import type { ClassicEditorRenderContext, ClassicEditorMountedRenderable } from "@quirekit/classic-editor";
import Panel from "./panel.vue";
/**
 * Mounts a public product panel with explicit update and teardown ownership.
 * @param initial - Mounted product context, including its locale and UI controller.
 * @returns A retained renderable; listeners disappear with its Vue application.
 */
export function renderNotebookPanel(initial: ClassicEditorRenderContext): ClassicEditorMountedRenderable {
  const context = shallowRef(initial);
  const element = initial.ownerDocument.createElement("div");
  const app = createApp(Panel, { context }); app.mount(element);
  return { element, update(next) { context.value = next; }, destroy() { app.unmount(); } };
}

```

### product/plugin/panel.vue

```vue
<script setup lang="ts">
import type { ShallowRef } from "vue";
import type { ClassicEditorRenderContext } from "@quirekit/classic-editor";
import { QuireButton } from "@quirekit/ui/vue";
const props = defineProps<{ /** Current mounted public context, updated by its renderable owner. */ context: ShallowRef<ClassicEditorRenderContext> }>();
</script>
<template>
  <section class="notebook-panel">
    <p>{{ context.value.locale === 'en' ? 'A product plugin owns this panel. Its controls use the same Core commands as the ribbon.' : '此面板由产品插件拥有，控件与功能区调用同一份 Core 命令。' }}</p>
    <dl><dt>{{ context.value.locale === 'en' ? 'Current selection' : '当前选区' }}</dt><dd>{{ context.value.editor.state.pm.selection.from }} → {{ context.value.editor.state.pm.selection.to }}</dd></dl>
    <QuireButton variant="outline" @click="props.context.value.ui.closeSidebar('right')">{{ context.value.locale === 'en' ? 'Close panel' : '关闭面板' }}</QuireButton>
  </section>
</template>
<style scoped>.notebook-panel { padding: 16px; font-size: 13px; line-height: 1.8; color: var(--qk-color-text-primary); } dl { margin: 16px 0; } dd { margin: 0; }</style>

```

接入入口和类型见 [Classic 插件文档](/classic/plugins)与 [Classic API](/classic/api)。文件失败和异步加载的处理继续见[保存与打开](/build/files)。
