# Add product features to Classic

Distinguish content extensions from interface plugins, then add actions, sidebars and file services.

The note group changes document content. Toolbars, sidebars and file menus belong to product plugins. This chapter reuses Classic's interface and adds “My product” and a product inspector.

A custom workspace can compose its own components. Classic plugin declarations require a Classic instance; putting them into Core's `extensions` does not create a toolbar.

## Try the feature

Find “My product” on the Home ribbon. Insert a note and observe the content change. Open the inspector to read product state; closing it does not change content.

### 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. Declare actions

`plugin/notebook.ts` defines the plugin, ribbon group and actions. The group's `tabId` chooses an existing tab; each action's `groupId` chooses its group.

Actions receive the current `editor`, product `instance` and `ui`. Use Core commands to change content and `ui` to open panels. The same action can be placed elsewhere without another content command system.

Availability, executability, active state and disabled reasons are separate queries. Classic uses them to refresh controls when selection or read-only state changes.

## 2. Create and update a sidebar

`plugin/panel.ts` connects Classic to Vue; `plugin/panel.vue` renders the inspector. Mounting returns `element`, `update` and `destroy` for DOM, context refresh and cleanup.

Create the Vue root on open, update it when locale or selection changes, and release it on close. Do not recreate subscriptions on every plugin update.

## 3. Register a file service

The example registers `notebook-backup`. The download action calls `instance.exportDocument('notebook-backup')`, then hands its data, filename and MIME to the browser.

Services own conversion. File-menu placement is declared separately, allowing the same service with different menu layouts.

## File responsibilities

| File | Purpose |
| --- | --- |
| `plugin/notebook.ts` | Plugin, groups, actions and file service |
| `plugin/panel.ts` | Connect sidebar lifecycle to Vue |
| `plugin/panel.vue` | Inspector interface |
| `plugin/editor.vue` | Create Classic and install the plugin |

### 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>

```

See [Classic plugins](/en/classic/plugins) and [Classic API](/en/classic/api). For failures and asynchronous loading, continue with [files](/en/build/files).
