# 产品插件

装配 Classic 的文件、专业能力与界面插件，或开发自己的产品功能。

Classic 产品插件通过实例的 `plugins` 安装，组合功能区、菜单、侧栏和文件服务。普通 Core Extension 放在 `extensions` 中，拥有文档结构与行为；两种合同不能互相替代。普通插件的接入与效果见[插件目录](/plugins)。

## 按产品能力装配

| 能力 | 相关产品包 | 接入边界 |
| --- | --- | --- |
| Word 预设与 DOCX | `@quirekit/classic-docx` | 预设装配文档能力，DOCX plugin 注册文件服务 |
| 审阅与表单 | `@quirekit/classic-pro` | 专业版界面与相应产品线单独装配 |
| AI | `@quirekit/classic-ai` | Classic 专用接线，宿主提供所需 AI 服务 |
| PDF / OFD | `@quirekit/classic-pdf`、`@quirekit/classic-ofd` | 安装导出适配器，并确认导出产品线 |
| 业务功能 | 自定义 `ClassicEditorPlugin` | 声明产品动作、界面和服务，正文修改仍经 Core 命令 |

## 各入口的配置

| 入口 | 装配位置 | 配置参考 |
| --- | --- | --- |
| Word 社区与专业预设 | `preset` | [社区预设](/plugins/reference/create-classic-word-preset)、[专业预设](/plugins/reference/create-classic-word-pro-preset) |
| Word 文件服务 | `plugins` | [DOCX / DOTX](/plugins/reference/create-classic-word-docx-plugin) |
| 固定布局导出 | `plugins` | [PDF 与打印](/plugins/reference/create-classic-editor-pdf-plugin)、[OFD](/plugins/reference/create-classic-editor-ofd-plugin) |
| AI | `plugins` | [完整 AI 装配](/plugins/reference/create-classic-ai)、[工作区装配](/plugins/reference/create-classic-ai-plugin)、[宿主侧栏接口](/plugins/reference/create-classic-ai-sidebar-plugin) |
| 文档模板库 | `plugins` | [模板目录与新建回调](/plugins/reference/create-classic-document-template-plugin) |
| 图形与数据 | `extensions` | [流程图](/plugins/reference/classic-flowchart)、[思维导图](/plugins/reference/classic-mind-map)、[白板](/plugins/reference/classic-whiteboard)、[文本绘图](/plugins/reference/classic-text-diagram)、[数据视图](/plugins/reference/classic-data-view) |

图形与数据这些入口仍是 Core Extension，只是额外提供 Classic 产品控件。每页列出自己的宿主端口和默认值；完整清单在[全部插件](/plugins)。

包被授权不代表已安装；预设中的 `resolveExtensions()` 结果也不包含完整产品注册表。完整产品应由 Classic 挂载，不能只取出扩展数组后宣称文件菜单与许可反馈已经接入。

## 实际开发示例

下面使用同一个已验证的产品插件例程：增加功能区分组、产品检查器、正文插入动作与 JSON 备份服务。可打开检查器、修改正文并下载当前备份；源码包含完整本地依赖。

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

```

插件 ID 与 contribution ID 在实例内保持唯一。动作的可用性只读取事实，正文修改调用 Core 命令；面板按挂载、更新、销毁管理资源。

分步实现见[开发产品插件](/build/product-plugin)，完整合同见 [Classic API](/classic/api)。专业版、AI 与导出能力不包含在本例安装范围中；产品线选择见[付费使用](/classic/commercial)。
