# 添加自己的内容类型

以提示组为例，逐步理解节点、插入命令、显示和选区。

假设你在做知识库，需要一种“提示组”：里面可以有多个段落，用户能输入、选中、复制，并把它和正文一起保存。这已经超出了给段落换个颜色的范围——提示组是文档中一种有名称、有子内容的新结构。

本章把这种结构命名为 `docsNote`。先理解内容和命令，再看 Canvas/SVG 额外需要的显示与选区配置。

## 1. 定义它保存什么

在 `feature/node.ts` 中通过 `Node.create()` 定义提示组。下面是决定文档结构的部分：

```ts [feature/node.ts · 节点结构]
const DocsNote = Node.create({
  name: 'docsNote',
  group: 'block',
  content: 'block+',
  isolating: true,
})
```

`name` 是保存到 JSON 中的节点名。`group: 'block'` 让它像段落一样出现在正文中。`content: 'block+'` 表示里面至少有一个块级节点，例如段落；`isolating` 则让通用编辑操作尊重这个容器的边界。

完整文件还定义了 HTML 的读取与输出规则：提示组对应 `aside[data-docs-note]`，子内容放在其中。这样复制为 HTML 时保留它的结构。以上片段只解释结构字段，完整定义在本页末尾按文件查阅。

## 2. 提供一个插入命令

界面应当调用“插入提示组”，不需要知道它内部如何拼接 JSON。在 `feature/extension.ts` 中提供 `insertDocsNote(text)`，命令的关键部分是：

```ts [feature/extension.ts · 插入内容]
insertDocsNote: (text: string) => ({ commands }) =>
  commands.insertContent({
    type: 'docsNote',
    content: [{
      type: 'paragraph',
      content: [{ type: 'text', text }],
    }],
  })
```

它复用 `insertContent()`，所以仍经过 Core 的事务与撤销历史。完整文件为命令补充 TypeScript 声明，并通过 `addExtensions()` 安装节点。安装 `DocsNoteFeature` 后，按钮可以这样调用：

```ts [在按钮中调用]
editor.chain().focus().insertDocsNote('这里是提示内容').run()
```

现在，“新增节点”和“通过界面插入它”已经连起来了。`can().insertDocsNote(...)` 也可以用来检查按钮是否可用。

## 3. 告诉 Canvas/SVG 怎样排列子内容

DOM 可以根据 HTML 生成页面元素；Canvas/SVG 使用 QuireKit 的排版结果，所以仅有 `renderHTML()` 还不够。提示组还需要一个布局提供者（layout provider）。

`feature/layout.ts` 使用引擎已有的子块排版服务：先在可用宽度内排列子段落，再把这些段落放进提示组的区域。它不自己测量浏览器 DOM，也不重新实现文字排版。`addLayoutProviders()` 把这项能力交给编辑器。

这一步使提示组在 Canvas 和 SVG 中都能显示。布局接口涉及尺寸、文档范围和分页，完整参数在 [扩展 API](/api/extensions)中查阅；第一次接入可以先沿用此例的普通容器布局。

## 4. 让选区理解容器边界

用户在组内拖选时，应该像选择普通文字一样工作；从组外拖到组内时，编辑器还要知道哪些子节点属于这次选择。

`feature/selection.ts` 定义这两种行为：组内交给文字选区，跨越边界时按子节点处理。`addSelectionAdapters()` 注册它。显示与选择分开定义，才能让复制、替换和跨容器选择使用相同的内容范围。

## 试用并检查结果

点击“插入提示组”，在里面输入文字，拖选组内和组外的内容，再撤销。切到 SVG 后内容应继续存在；下载 JSON 可以看到 `docsNote` 和它包含的段落。

### product/generic.vue

```vue
<script setup lang="ts">
import Workspace from "./workspace.vue";
defineProps<{ /** Interface locale only. */ locale?: "zh-CN" | "en" }>();
</script>
<template><Workspace :locale="locale" model="generic-v1" /></template>

```

### product/workspace.vue

```vue
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
import "@quirekit/view/styles.css";
import { defaultTheme } from "@quirekit/ui";
import { QuireButton, useQuireKitTheme } from "@quirekit/ui/vue";
import { downloadJson } from "../shared/download";
import type { ProductModel, ProductRuntime, ProductState } from "./types";
const props = withDefaults(defineProps<{
  /** Interface language; it never translates existing content. */ locale?: "zh-CN" | "en";
  /** Immutable create-time schema family. */ model?: ProductModel;
}>(), { locale: "zh-CN", model: "word-v1" });
const viewport = ref<HTMLElement>(); const surface = ref<HTMLElement>(); const fileInput = ref<HTMLInputElement>();
const wordInput = ref<HTMLInputElement>();
const instance = shallowRef<ProductRuntime>(); const state = shallowRef<ProductState>();
const error = ref<string | null>(null); const busy = ref(false); const pages = ref(0); const mode = ref<"canvas" | "svg">("canvas");
const zoom = ref(70); const width = ref(794); const height = ref(1123); const theme = useQuireKitTheme();
let disposed = false; let release: (() => void) | undefined; let resize: ResizeObserver | undefined;

const english = computed(() => props.locale === "en");
const copy = computed(() => english.value ? {
  title: "My document workspace", bold: "Bold", undo: "Undo", redo: "Redo", add: "Insert paragraph", note: "Insert note",
  save: "Save in this browser", restore: "Restore saved", backup: "Download backup", open: "Open backup", read: "Read only", edit: "Resume editing",
  loading: "Preparing the workspace…", dirty: "Unsaved edits", saved: "No pending edits", saving: "Saving…", reload: "Reload page",
  hint: "Edit, save, switch Canvas/SVG, or restore your saved version after reloading. Opening another file requires saving current edits.",
} : {
  title: "我的文档工作区", bold: "加粗", undo: "撤销", redo: "重做", add: "新增段落", note: "插入提示组",
  save: "保存到此浏览器", restore: "恢复已保存版本", backup: "下载备份", open: "打开备份", read: "只读", edit: "继续编辑",
  loading: "正在准备工作区…", dirty: "有未保存的修改", saved: "无待保存修改", saving: "保存中…", reload: "重新加载页面",
  hint: "编辑、保存、切换 Canvas/SVG，或刷新后恢复已保存版本。打开其他文件前须先保存当前修改。",
});
/** Fits the host's presentation width; page geometry remains owned by the document profile. */
function fit(): void { if (viewport.value) zoom.value = Math.round(Math.min(1, Math.max(.25, (viewport.value.clientWidth - 24) / width.value)) * 100); }
/** @param next - Public registered view mode. */
function changeMode(next: "canvas" | "svg"): void { instance.value?.changeMode(next); mode.value = next; }
/** Downloads an explicit current snapshot, not a mirror updated while typing. */
function backup(): void { if (instance.value) downloadJson(instance.value.session.backup(), "quirekit-product.json"); }
/** @param event - Native host file selection. */
async function open(event: Event): Promise<void> {
  const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = "";
  if (file && instance.value) await instance.value.session.open(() => file.text());
}
/** @param event - A complete Word file selected by the host. */
async function openDocx(event: Event): Promise<void> {
  const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = "";
  const product = instance.value; if (!file || !product || busy.value) return;
  busy.value = true; error.value = null;
  let messages: string[] = [];
  const accepted = await product.session.open(async () => {
    const { readWord } = await import("./files/docx");
    const result = await readWord(product.editor, new Uint8Array(await file.arrayBuffer()));
    messages = [...result.warnings, ...result.diagnostics].map(item => item.message);
    return JSON.stringify({ version: 1, model: "word-v1", content: result.document });
  });
  if (!disposed) { busy.value = false; if (accepted && messages.length) error.value = messages.join("; "); }
}
/** Downloads a prepared binary artifact through a short-lived browser URL. */
async function exportDocx(): Promise<void> {
  const product = instance.value; if (!product || busy.value) return;
  const captured = product.editor.getJSON();
  busy.value = true; error.value = null;
  try {
    const { exportWord } = await import("./files/docx");
    if (disposed) return;
    const result = await exportWord(product.editor, captured);
    if (disposed) return;
    const url = URL.createObjectURL(new Blob([new Uint8Array(result.data)], { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }));
    const anchor = document.createElement("a"); anchor.href = url; anchor.download = "my-product.docx"; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
    const messages = result.diagnostics.filter(item => item.severity !== "info").map(item => item.message);
    if (messages.length) error.value = messages.join("; ");
  } catch (failure) { if (!disposed) error.value = String(failure); }
  finally { if (!disposed) busy.value = false; }
}
/** Browser-only resource acquisition; late imports cannot resurrect an unmounted workspace. */
onMounted(async () => {
  try {
    const [{ createProduct }, { browserRepository }, { assembly }] = await Promise.all([
      import("./runtime"), import("./files/repository"), props.model === "word-v1" ? import("./assembly/word") : import("./assembly/generic"),
    ]);
    if (disposed || !surface.value || !viewport.value) return;
    const product = createProduct(surface.value, viewport.value, assembly, theme?.value ?? defaultTheme,
      browserRepository(window.localStorage, assembly.id), props.locale, (count, nextWidth, nextHeight) => {
        pages.value = count; width.value = Math.max(1, nextWidth); height.value = Math.max(1, nextHeight);
      });
    instance.value = product; release = product.session.subscribe(value => { state.value = value; });
    resize = new ResizeObserver(fit); resize.observe(viewport.value); fit();
  } catch (failure) { if (!disposed) error.value = String(failure); }
});
onBeforeUnmount(() => { disposed = true; resize?.disconnect(); release?.(); instance.value?.destroy(); });
</script>
<template>
  <section class="product-example" :data-model="model" :data-ready="Boolean(instance)">
    <header class="product-heading"><strong>{{ copy.title }}</strong><span>{{ model === 'word-v1' ? 'Word · Canvas / SVG' : 'Core · Canvas / SVG' }}</span></header>
    <div class="product-tools" role="toolbar" :aria-label="english ? 'Document commands' : '文档命令'">
      <QuireButton variant="outline" :aria-pressed="state?.bold" :disabled="!state?.canBold" @click="instance?.editor.chain().focus().toggleBold().run()">{{ copy.bold }}</QuireButton>
      <QuireButton variant="outline" :disabled="!state?.canUndo" @click="instance?.editor.chain().focus().undo().run()">{{ copy.undo }}</QuireButton>
      <QuireButton variant="outline" :disabled="!state?.canRedo" @click="instance?.editor.chain().focus().redo().run()">{{ copy.redo }}</QuireButton>
      <QuireButton variant="outline" :disabled="!state?.editable" @click="instance?.editor.chain().focus().insertContent({ type: 'paragraph', content: [{ type: 'text', text: english ? 'A new paragraph.' : '新增的段落。' }] }).run()">{{ copy.add }}</QuireButton>
      <QuireButton v-if="model === 'generic-v1'" variant="outline" :disabled="!state?.editable" @click="instance?.editor.chain().focus().insertDocsNote(english ? 'Note: this grouped content is a custom node.' : '提示：这组内容由自定义节点拥有。').run()">{{ copy.note }}</QuireButton>
      <QuireButton variant="outline" :disabled="!instance" :aria-pressed="state && !state.editable" @click="instance?.session.setEditable(!state?.editable)">{{ state?.editable ? copy.read : copy.edit }}</QuireButton>
    </div>
    <div class="product-tools product-file-tools">
      <QuireButton :disabled="!instance || state?.saving" @click="instance?.session.save()">{{ state?.saving ? copy.saving : copy.save }}</QuireButton>
      <QuireButton variant="outline" :disabled="!instance || state?.dirty || state?.saving || !state?.editable" @click="instance?.session.restore()">{{ copy.restore }}</QuireButton>
      <QuireButton variant="outline" :disabled="!instance" @click="backup">{{ copy.backup }}</QuireButton>
      <QuireButton variant="outline" :disabled="!instance || state?.dirty || state?.saving || !state?.editable" @click="fileInput?.click()">{{ copy.open }}</QuireButton>
      <QuireButton v-if="model === 'word-v1'" variant="outline" :disabled="!instance || busy" @click="exportDocx">{{ busy ? 'DOCX…' : (english ? 'Export DOCX' : '导出 DOCX') }}</QuireButton>
      <QuireButton v-if="model === 'word-v1'" variant="outline" :disabled="!instance || busy || state?.dirty || state?.saving || !state?.editable" @click="wordInput?.click()">{{ english ? 'Open DOCX' : '打开 DOCX' }}</QuireButton>
      <input ref="fileInput" type="file" accept=".json,application/json" hidden :aria-label="copy.open" @change="open">
      <input ref="wordInput" type="file" accept=".docx" hidden :aria-label="english ? 'Open DOCX' : '打开 DOCX'" @change="openDocx">
    </div>
    <p class="product-hint">{{ copy.hint }}</p>
    <p v-if="error || state?.error" class="product-error" role="alert">{{ error || state?.error }}</p>
    <p v-if="!instance" class="product-loading" role="status">{{ error ? (english ? 'Reload this page to retry.' : '请重新加载页面后重试。') : copy.loading }}</p>
    <div ref="viewport" class="product-viewport" :aria-label="english ? 'Document workspace' : '文档工作区'">
      <div class="product-stage" :style="{ width: `${width * zoom / 100}px`, minHeight: `${height * zoom / 100}px` }">
        <div ref="surface" class="product-surface" :style="{ zoom: zoom / 100, width: `${width}px` }" />
      </div>
    </div>
    <footer class="product-status">
      <span role="status">{{ state?.saving ? copy.saving : state?.dirty ? copy.dirty : copy.saved }}</span>
      <div><QuireButton variant="ghost" :aria-pressed="mode === 'canvas'" :disabled="!instance" @click="changeMode('canvas')">Canvas</QuireButton><QuireButton variant="ghost" :aria-pressed="mode === 'svg'" :disabled="!instance" @click="changeMode('svg')">SVG</QuireButton><QuireButton variant="ghost" :disabled="zoom <= 25" @click="zoom -= 10">−</QuireButton><span>{{ zoom }}%</span><QuireButton variant="ghost" :disabled="zoom >= 150" @click="zoom += 10">+</QuireButton><span>{{ pages }} {{ english ? 'pages' : '页' }}</span></div>
    </footer>
  </section>
</template>
<style scoped>
.product-example { min-width: 0; text-align: start; background: var(--qk-color-surface-page); }
.product-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 16px; border-bottom: 1px solid var(--qk-color-border-subtle); font-size: 14px; }
.product-heading span, .product-hint, .product-status { color: var(--qk-color-text-secondary); font-size: 12px; }
.product-tools { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 16px 0; }
.product-hint { margin: 0; padding: 12px 16px; line-height: 1.7; }
.product-error { margin: 0; padding: 12px 16px; color: var(--qk-color-status-danger-text, var(--qk-color-text-primary)); overflow-wrap: anywhere; }
.product-loading { padding: 20px; }
.product-viewport { height: 540px; overflow: auto; padding: 12px; background: var(--qk-color-surface-subtle); border-block: 1px solid var(--qk-color-border-default); }
.product-stage { margin-inline: auto; position: relative; }
.product-surface { position: relative; background: var(--qk-color-surface-page); }
.product-status { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding: 8px 16px; }
.product-status > div { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
@media (max-width: 600px) { .product-tools, .product-heading, .product-status { padding-inline: 12px; } .product-viewport { height: 440px; padding: 8px; } }
</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/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/files/docx.ts

```ts
import type { Editor, JSONContent } from "@quirekit/core";
import { createWorkspaceLitDocxCodec, exportDocxDocument, importDocxDocument,
  normalizeQuireJsonMarkOrder } from "@quirekit/classic-docx/docx";
/**
 * Exports a captured Word-model document. The caller displays returned compatibility messages.
 * @param editor - Editor assembled with Word codecs, not a generic document schema.
 * @param content - Complete content captured when the host's export action began.
 * @returns Binary DOCX and deterministic compatibility diagnostics.
 */
export async function exportWord(editor: Editor, content: JSONContent) {
  return exportDocxDocument(createWorkspaceLitDocxCodec(), content, {
    manager: editor.docx, schema: editor.schema, lossPolicy: "best-effort",
  });
}
/**
 * Prepares a Word import without writing to the live editor.
 * @param editor - Target Word schema and codec registry.
 * @param bytes - Complete file bytes.
 * @returns A validated candidate and compatibility diagnostics; the host owns freshness and commit.
 */
export async function readWord(editor: Editor, bytes: Uint8Array) {
  const result = await importDocxDocument(createWorkspaceLitDocxCodec(), bytes, { manager: editor.docx });
  return { ...result, document: normalizeQuireJsonMarkOrder(result.document, editor.schema.marks) };
}

```

### product/runtime.ts

```ts
import { Editor } from "@quirekit/core";
import type { QuireKitTheme } from "@quirekit/ui";
import { createProductSession } from "./session";
import type { ProductAssembly, ProductRepository, ProductRuntime } from "./types";

/**
 * Creates a product out of public Core, assembly and host ports. It never creates Classic's shell.
 * @param element - Mount receiving View-owned children.
 * @param viewport - One product-owned scroll container.
 * @param assembly - Schema, initial content, and adapters selected together.
 * @param theme - Presentation theme.
 * @param repository - Explicit persistence port.
 * @param locale - Initial document copy only.
 * @param onLayout - Committed page/size facts; the host does not calculate pagination.
 * @returns One lifetime owning Editor, mounted View and session subscriptions.
 */
export function createProduct(element: HTMLElement, viewport: HTMLElement, assembly: ProductAssembly, theme: QuireKitTheme,
  repository: ProductRepository, locale: "zh-CN" | "en", onLayout: (pages: number, width: number, height: number) => void): ProductRuntime {
  const editor = new Editor({ element, mode: "canvas", extensions: [...assembly.createExtensions({ viewport, theme, onLayout })],
    content: assembly.createDocument(locale) });
  const session = createProductSession(editor, assembly.id, repository, assembly.isBold);
  let disposed = false;
  return {
    editor, session,
    changeMode(mode) { editor.changeMode(mode); },
    destroy() { if (disposed) return; disposed = true; session.dispose(); editor.destroy(); },
  };
}

```

### product/session.ts

```ts
import type { Editor } from "@quirekit/core";
import { createBackup, readBackup } from "./files/backup";
import type { ProductModel, ProductRepository, ProductSession, ProductState } from "./types";

/**
 * Owns host workflows while borrowing a single Editor. No JSON is computed during typing.
 * @param editor - Canonical state and command authority.
 * @param model - Installed schema version.
 * @param repository - Host-owned persistence.
 * @param isBold - Model-owned effective formatting query; generic schemas use a semantic mark.
 * @returns A disposable, bounded UI projection and explicit file workflows.
 */
export function createProductSession(editor: Editor, model: ProductModel, repository: ProductRepository,
  isBold: (instance: Editor) => boolean = instance => instance.isActive("bold")): ProductSession {
  let acceptedDocument = editor.state.doc;
  let projectedState = editor.state.pm;
  let disposed = false;
  let generation = 0;
  let saving = false;
  let error: string | null = null;
  const listeners = new Set<(state: ProductState) => void>();
  /** @returns Fixed-size control state; dry runs never focus or write content. */
  function project(): ProductState {
    return { dirty: editor.state.doc !== acceptedDocument, saving, error,
      bold: isBold(editor), editable: editor.isEditable,
      canBold: editor.isEditable && editor.can().toggleBold(),
      canUndo: editor.isEditable && editor.can().undo(), canRedo: editor.isEditable && editor.can().redo() };
  }
  let state = project();
  /** Publishes a host-state change or one newly accepted PM state. */
  function publish(): void {
    if (disposed) return;
    projectedState = editor.state.pm;
    state = project();
    listeners.forEach(listener => listener(state));
  }
  /** Same-state duplicate event entries do not project controls again. */
  function transaction(): void { if (editor.state.pm !== projectedState) publish(); }
  editor.on("transaction", transaction);
  /** @param failure - Failed host operation. */
  function reject(failure: unknown): void { error = failure instanceof Error ? failure.message : String(failure); publish(); }
  /**
   * Commits a whole-document replacement only while the original operation is current.
   * @param read - Deferred file or repository read.
   * @param saved - Whether this source is an acknowledged repository version.
   * @returns Whether a safe replacement completed.
   */
  async function load(read: () => Promise<string | null>, saved: boolean): Promise<boolean> {
    if (disposed) return false;
    const operation = ++generation;
    const original = editor.state.doc;
    try {
      if (!editor.isEditable || state.dirty || saving) throw new Error("Save current edits before opening another document");
      const source = await read();
      if (disposed || operation !== generation) return false;
      if (editor.state.doc !== original) throw new Error("Document changed while loading; keep the current draft and try again");
      if (!editor.isEditable) throw new Error("Document became read only while loading");
      if (source === null) throw new Error("No saved document in this browser");
      const content = readBackup(source, model, editor.schema);
      if (!editor.commands.replaceDocument(content, { errorOnInvalidContent: true })) throw new Error("Document replacement was rejected");
      if (saved) acceptedDocument = editor.state.doc;
      error = null; publish(); return true;
    } catch (failure) { if (!disposed && operation === generation) reject(failure); return false; }
  }
  return {
    get state() { return state; },
    subscribe(listener) { listeners.add(listener); listener(state); return () => { listeners.delete(listener); }; },
    async save() {
      if (disposed || saving) return false;
      const captured = editor.state.doc;
      const source = JSON.stringify(createBackup(editor, model));
      saving = true; error = null; publish();
      try {
        await repository.write(source);
        if (disposed) return false;
        // A successful old save never acknowledges characters typed while it was running.
        acceptedDocument = captured;
        return true;
      } catch (failure) { if (!disposed) reject(failure); return false; }
      finally { saving = false; publish(); }
    },
    restore() { return load(() => repository.read(), true); },
    open(read) { return load(read, false); },
    backup() { return createBackup(editor, model); },
    setEditable(editable) { if (!disposed) { editor.setEditable(editable, false); publish(); } },
    dispose() { if (disposed) return; disposed = true; generation += 1; editor.off("transaction", transaction); listeners.clear(); },
  };
}

```

### 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/files/repository.ts

```ts
import type { ProductModel, ProductRepository } from "../types";
/**
 * Creates an explicit browser persistence port; access errors remain visible to the caller.
 * @param storage - Host-provided Storage, acquired only in the browser.
 * @param model - Separate key for each canonical schema.
 * @returns Replaceable persistence adapter.
 */
export function browserRepository(storage: Pick<Storage, "getItem" | "setItem">, model: ProductModel): ProductRepository {
  const key = `quirekit-product-example:${model}`;
  return {
    async read() { return storage.getItem(key); },
    async write(source) { storage.setItem(key, source); },
  };
}

```

### product/assembly/word.ts

```ts
import { createClassicWordDefaultDocument, createClassicWordPreset } from "@quirekit/classic-docx/vue";
import { readEffectiveDirectEmphasisState } from "@quirekit/extension-text-style";
import type { ProductAssembly } from "../types";
/** Word schema, document-profile facets and views resolved without creating a Classic shell. */
export const assembly: ProductAssembly = {
  id: "word-v1",
  isBold: editor => readEffectiveDirectEmphasisState(editor.state, "bold") === true,
  createDocument: createClassicWordDefaultDocument,
  createExtensions({ viewport, theme, onLayout }) {
    const preset = createClassicWordPreset({ proofing: false });
    const runtime = { width: 794, height: 1123, scrollSource: "viewport" as const, scrollViewport: viewport,
      onRender: ({ renderResult }: import("@quirekit/view").QuireMountedViewRenderEvent) => {
        const snapshot = renderResult.layoutSnapshot;
        onLayout(snapshot.pages?.length ?? 0, snapshot.documentRect.width, snapshot.documentRect.height);
      } };
    return preset.resolveExtensions({ features: Object.fromEntries(Object.entries(preset.features).map(([key, value]) => [key, value === true])), theme, workspaceViewport: viewport, backend: "canvas", canvasRuntime: runtime, svgRuntime: runtime });
  },
};

```

### product/assembly/generic.ts

```ts
import { StarterKit } from "@quirekit/starter-kit";
import { canvasViewExtension, svgViewExtension } from "@quirekit/view";
import { DocsNoteFeature } from "../feature/extension";
import type { ProductAssembly } from "../types";
/** Continuous-flow assembly using the neutral profile and a feature-owned note container. */
export const assembly: ProductAssembly = {
  id: "generic-v1",
  isBold: editor => editor.isActive("bold"),
  createDocument(locale) {
    return { type: "doc", content: [
      { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: locale === "en" ? "My document product" : "我的文档产品" }] },
      { type: "paragraph", content: [{ type: "text", text: locale === "en" ? "This workspace owns its interface. Core owns content; View owns input and geometry." : "工作区拥有自己的界面，Core 拥有正文，View 拥有输入和几何。" }] },
    ] };
  },
  createExtensions({ viewport, onLayout, theme }) {
    const runtime = { width: 720, height: 900, pageStack: false, scrollSource: "viewport" as const, scrollViewport: viewport,
      surfaceConfig: { selection: { fillStyle: theme.color.selection.document } },
      onRender: ({ renderResult }: import("@quirekit/view").QuireMountedViewRenderEvent) => {
        const snapshot = renderResult.layoutSnapshot;
        onLayout(snapshot.pages?.length ?? 0, snapshot.documentRect.width, snapshot.documentRect.height);
      } };
    return [StarterKit, DocsNoteFeature, canvasViewExtension({ runtime }), svgViewExtension({ runtime: { ...runtime, textMeasurementBackend: "browser" } })];
  },
};

```

### product/feature/extension.ts

```ts
import { Extension } from "@quirekit/core";
import { DocsNote } from "./node";
import { noteLayout } from "./layout";
import { noteSelection } from "./selection";

declare module "@quirekit/core" {
  /** The same typed command registry used by direct, chain and can modes. */
  interface Commands<ReturnType> {
    /** Feature-owned commands. */
    docsNote: {
      /** @param text - Paragraph text inside a new note. @returns Whether insertion can run or was handled. */
      insertDocsNote: (text: string) => ReturnType;
    };
  }
}
/** One installable feature owns schema, command, retained layout and structured selection. */
export const DocsNoteFeature = Extension.create({
  name: "docsNoteFeature",
  /** @returns Schema owner; no second document is maintained. */
  addExtensions() { return [DocsNote]; },
  /** @returns Typed raw command factories, also safe in can() dry runs. */
  addCommands() {
    return {
      insertDocsNote: (text: string) => ({ commands }) => commands.insertContent({ type: "docsNote",
        content: [{ type: "paragraph", content: [{ type: "text", text }] }] }),
    };
  },
  /** @returns Renderer-neutral geometry owner. */
  addLayoutProviders() { return [noteLayout]; },
  /** @returns Structured crossing semantics, independently of hit testing. */
  addSelectionAdapters() { return [noteSelection]; },
});

```

### product/feature/node.ts

```ts
import { Node, mergeAttributes } from "@quirekit/core";
/** Source structure shared by JSON, HTML and registered retained-view layout. */
export const DocsNote = Node.create({
  name: "docsNote", group: "block", content: "block+", isolating: true,
  /** @returns HTML parser ownership for this exact custom node. */
  parseHTML() { return [{ tag: "aside[data-docs-note]" }]; },
  /** @param input - Current serialized attributes. @returns HTML plus one child-content hole. */
  renderHTML({ HTMLAttributes }) { return ["aside", mergeAttributes(HTMLAttributes, { "data-docs-note": "" }), 0]; },
});

```

### product/feature/layout.ts

```ts
import { createQuireLayoutProviderRegistration, type QuireLayoutProvider } from "@quirekit/view-engine/layout-provider";
/** Editable grouped content; a provider consumes source and constraints, never mounted DOM. */
const provider: QuireLayoutProvider = {
  id: "docs-note-layout", nodeTypes: ["docsNote"],
  capabilities: ["child-flow", "fragmentation", "selection-domain", "semantic-export"],
  regionContract: { rootKind: "block-container", regionKind: "block-container",
    capabilities: { editable: true, selectable: true, focusable: true, copyable: true, commandable: true, accessible: true },
    selectionPolicy: "delegate-to-text" },
  /** @param context - Source identity, document coordinates and engine services. @returns Retained layout products for both Canvas and SVG. */
  layout(context) {
    if (!context.source.range) throw new Error("Note layout requires a source range");
    // Measure source children at the accepted width before placing any retained geometry.
    const children = context.services.collectBlockInputs({ node: context.sourceNode, contentStart: context.sourceContentStart,
      path: context.sourcePath, inlineSize: context.constraints.inlineSize });
    const source = context.services.layoutBlockContainer({ id: `${context.source.sourceId}:source`, range: context.source.range,
      rect: { x: 0, y: 0, width: context.constraints.inlineSize, height: context.constraints.blockHeight },
      blockHeight: context.constraints.blockHeight, blockGap: context.constraints.blockGap, children });
    const rect = { ...context.constraints.origin, width: context.constraints.inlineSize, height: Math.max(context.constraints.blockHeight, source.rect.height) };
    // Place the measured flow through one owner-relative track; source order and coordinate space stay authoritative.
    const owner = { root: context.root, region: context.region };
    const flow = context.services.flowBlockChildren({ id: `${context.source.sourceId}:flow`, owner, source,
      tracks: [{ id: `${context.source.sourceId}:track`, rect, coordinateSpace: context.constraints.coordinateSpace,
        region: context.region, source: context.source, capabilities: context.root.capabilities }],
      dependencySignature: `${context.source.sourceId}:${rect.width}:${rect.height}` });
    if (!flow.complete) throw new Error("Note layout must cover all measured children");
    const exportFallbacks = [{ kind: "native" as const, targetFormat: "html" as const, reason: "Children retain their source order." }];
    return { providerId: context.providerId, snapshotVersion: context.snapshotVersion, source: context.source,
      layout: context.services.createContribution({ id: context.source.sourceId, range: context.source.range, rect, owner, fragments: flow.fragments, exportFallbacks }),
      declarations: [context.services.declareRegionProducts({ declaration: context.services.declarePrimaryRegion(rect), owner,
        semantics: { role: "group", label: "Note", search: { kind: "source-text" } }, exportFallbacks })],
      fragments: flow.fragments, continuation: null };
  },
};
/** Exact concrete provider depends only on immutable source plus accepted constraints. */
export const noteLayout = { ...createQuireLayoutProviderRegistration(provider), layoutDependency: { kind: "document" as const } };

```

### product/feature/selection.ts

```ts
import { createQuireSelectionAdapterRegistration, type QuireSelectionAdapter } from "@quirekit/view-engine/selection-adapter";
/** Crossing the group owns complete children; editing inside continues to delegate to text. */
const adapter: QuireSelectionAdapter = {
  version: 1, id: "docs-note-selection", nodeTypes: ["docsNote"],
  modes: { internal: "delegate", entry: "semantic-units", exit: "semantic-units", fullyCrossed: "owner" },
  commandDomain: "docs-note", clipboardDomain: "docs-note", mappingFallback: "text", replacementFallback: "text",
  /** @param context - Canonical owner node. @returns Child ranges relative to its opening token. */
  compileUnits(context) {
    let offset = 1;
    return Array.from({ length: context.node.childCount }, (_, index) => {
      const child = context.node.child(index);
      const unit = { id: `child-${index}`, kind: "note-child", range: { from: offset, to: offset + child.nodeSize } };
      offset += child.nodeSize;
      return unit;
    });
  },
};
/** Registration whose mutable dependencies are absent by construction. */
export const noteSelection = { ...createQuireSelectionAdapterRegistration(adapter), layoutDependency: { kind: "document" as const } };

```

## 按职责查阅完整文件

| 文件 | 什么时候看 | 它负责什么 |
| --- | --- | --- |
| `feature/node.ts` | 要增加字段或修改允许的子内容 | 节点结构、HTML 读取与输出 |
| `feature/extension.ts` | 要安装功能或调整插入动作 | 命令类型、命令实现、能力注册 |
| `feature/layout.ts` | 要改变容器的排列方式 | Canvas/SVG 中子内容的尺寸和位置 |
| `feature/selection.ts` | 要改变跨容器选择行为 | 容器内部与边界的选区规则 |

下面一次只显示你选择的文件。先读 `node.ts` 和 `extension.ts`，需要调整排版时再读另外两个。

### product/feature/extension.ts

```ts
import { Extension } from "@quirekit/core";
import { DocsNote } from "./node";
import { noteLayout } from "./layout";
import { noteSelection } from "./selection";

declare module "@quirekit/core" {
  /** The same typed command registry used by direct, chain and can modes. */
  interface Commands<ReturnType> {
    /** Feature-owned commands. */
    docsNote: {
      /** @param text - Paragraph text inside a new note. @returns Whether insertion can run or was handled. */
      insertDocsNote: (text: string) => ReturnType;
    };
  }
}
/** One installable feature owns schema, command, retained layout and structured selection. */
export const DocsNoteFeature = Extension.create({
  name: "docsNoteFeature",
  /** @returns Schema owner; no second document is maintained. */
  addExtensions() { return [DocsNote]; },
  /** @returns Typed raw command factories, also safe in can() dry runs. */
  addCommands() {
    return {
      insertDocsNote: (text: string) => ({ commands }) => commands.insertContent({ type: "docsNote",
        content: [{ type: "paragraph", content: [{ type: "text", text }] }] }),
    };
  },
  /** @returns Renderer-neutral geometry owner. */
  addLayoutProviders() { return [noteLayout]; },
  /** @returns Structured crossing semantics, independently of hit testing. */
  addSelectionAdapters() { return [noteSelection]; },
});

```

### product/feature/node.ts

```ts
import { Node, mergeAttributes } from "@quirekit/core";
/** Source structure shared by JSON, HTML and registered retained-view layout. */
export const DocsNote = Node.create({
  name: "docsNote", group: "block", content: "block+", isolating: true,
  /** @returns HTML parser ownership for this exact custom node. */
  parseHTML() { return [{ tag: "aside[data-docs-note]" }]; },
  /** @param input - Current serialized attributes. @returns HTML plus one child-content hole. */
  renderHTML({ HTMLAttributes }) { return ["aside", mergeAttributes(HTMLAttributes, { "data-docs-note": "" }), 0]; },
});

```

### product/feature/layout.ts

```ts
import { createQuireLayoutProviderRegistration, type QuireLayoutProvider } from "@quirekit/view-engine/layout-provider";
/** Editable grouped content; a provider consumes source and constraints, never mounted DOM. */
const provider: QuireLayoutProvider = {
  id: "docs-note-layout", nodeTypes: ["docsNote"],
  capabilities: ["child-flow", "fragmentation", "selection-domain", "semantic-export"],
  regionContract: { rootKind: "block-container", regionKind: "block-container",
    capabilities: { editable: true, selectable: true, focusable: true, copyable: true, commandable: true, accessible: true },
    selectionPolicy: "delegate-to-text" },
  /** @param context - Source identity, document coordinates and engine services. @returns Retained layout products for both Canvas and SVG. */
  layout(context) {
    if (!context.source.range) throw new Error("Note layout requires a source range");
    // Measure source children at the accepted width before placing any retained geometry.
    const children = context.services.collectBlockInputs({ node: context.sourceNode, contentStart: context.sourceContentStart,
      path: context.sourcePath, inlineSize: context.constraints.inlineSize });
    const source = context.services.layoutBlockContainer({ id: `${context.source.sourceId}:source`, range: context.source.range,
      rect: { x: 0, y: 0, width: context.constraints.inlineSize, height: context.constraints.blockHeight },
      blockHeight: context.constraints.blockHeight, blockGap: context.constraints.blockGap, children });
    const rect = { ...context.constraints.origin, width: context.constraints.inlineSize, height: Math.max(context.constraints.blockHeight, source.rect.height) };
    // Place the measured flow through one owner-relative track; source order and coordinate space stay authoritative.
    const owner = { root: context.root, region: context.region };
    const flow = context.services.flowBlockChildren({ id: `${context.source.sourceId}:flow`, owner, source,
      tracks: [{ id: `${context.source.sourceId}:track`, rect, coordinateSpace: context.constraints.coordinateSpace,
        region: context.region, source: context.source, capabilities: context.root.capabilities }],
      dependencySignature: `${context.source.sourceId}:${rect.width}:${rect.height}` });
    if (!flow.complete) throw new Error("Note layout must cover all measured children");
    const exportFallbacks = [{ kind: "native" as const, targetFormat: "html" as const, reason: "Children retain their source order." }];
    return { providerId: context.providerId, snapshotVersion: context.snapshotVersion, source: context.source,
      layout: context.services.createContribution({ id: context.source.sourceId, range: context.source.range, rect, owner, fragments: flow.fragments, exportFallbacks }),
      declarations: [context.services.declareRegionProducts({ declaration: context.services.declarePrimaryRegion(rect), owner,
        semantics: { role: "group", label: "Note", search: { kind: "source-text" } }, exportFallbacks })],
      fragments: flow.fragments, continuation: null };
  },
};
/** Exact concrete provider depends only on immutable source plus accepted constraints. */
export const noteLayout = { ...createQuireLayoutProviderRegistration(provider), layoutDependency: { kind: "document" as const } };

```

### product/feature/selection.ts

```ts
import { createQuireSelectionAdapterRegistration, type QuireSelectionAdapter } from "@quirekit/view-engine/selection-adapter";
/** Crossing the group owns complete children; editing inside continues to delegate to text. */
const adapter: QuireSelectionAdapter = {
  version: 1, id: "docs-note-selection", nodeTypes: ["docsNote"],
  modes: { internal: "delegate", entry: "semantic-units", exit: "semantic-units", fullyCrossed: "owner" },
  commandDomain: "docs-note", clipboardDomain: "docs-note", mappingFallback: "text", replacementFallback: "text",
  /** @param context - Canonical owner node. @returns Child ranges relative to its opening token. */
  compileUnits(context) {
    let offset = 1;
    return Array.from({ length: context.node.childCount }, (_, index) => {
      const child = context.node.child(index);
      const unit = { id: `child-${index}`, kind: "note-child", range: { from: offset, to: offset + child.nodeSize } };
      offset += child.nodeSize;
      return unit;
    });
  },
};
/** Registration whose mutable dependencies are absent by construction. */
export const noteSelection = { ...createQuireSelectionAdapterRegistration(adapter), layoutDependency: { kind: "document" as const } };

```

## 保存与其他格式

提示组的 JSON 与正文一起保存，恢复时必须再次安装此扩展。本例没有实现 DOCX 转换或 AI 能力；需要这些功能时，要分别通过 `addDocx()` 和 `addAiCapabilities()` 补充规则。能保存 JSON 并不代表其他文件格式已经认识这种节点。

如果你的需求是增加工具栏或侧栏，而不是新的正文结构，继续阅读[产品插件](/build/product-plugin)。
