# 自定义节点

从一个可点击的状态标记，理解语法、字段、命令和组件的完整接入。

## 体验字段修改

点击「待处理」将它切换为「已完成」，下方阅读预览同步改变。再读取 Markdown，确认按钮只改写 `[[todo]]` 中的状态，其他正文保持原样。撤销与重做同时更新编辑和阅读视图；切换只读后不能修改字段。

### status/editor.vue

```vue
<script setup lang="ts">
import { computed, h, provide, ref, shallowRef } from "vue";
import { useMsfeEditor, useMsfeState, VueNodeViewHost, VueNodeViewRenderer } from "@quirekit/msfe-vue";
import { StarterKit } from "@quirekit/msfe-starter-kit";
import { MdProse, withMdReading } from "@quirekit/md-editor/vue";
import { QuireButton, QuireSegmented, QuireProseSource } from "@quirekit/ui/vue";
import { Appearance } from "../shared/appearance";
import { Status } from "./status";
import StatusView from "./status-view.vue";
import type { StatusAttributes } from "./types";
import { exampleMessages } from "../shared/messages";
import { exampleLocaleKey } from "../shared/locale";
import type { ExampleLocale } from "../shared/types";

const props = withDefaults(defineProps<{ /** UI language supplied by the application. */ locale?: ExampleLocale }>(), { locale: "zh-CN" });
const text = computed(() => exampleMessages[props.locale]);
// Node views retain this provider without changing the persisted status values.
provide(exampleLocaleKey, computed(() => props.locale));

const parent = shallowRef<HTMLElement | null>(null);
const snapshot = ref<string>();
const mode = ref("live");
const items = computed(() => [{ value: "live", label: text.value.live }, { value: "source", label: text.value.source }, { value: "locked", label: text.value.locked }]);
// The application replaces presentation while inheriting grammar, validation and commands.
const StatusWithUI = withMdReading<StatusAttributes>(Status.extend({
  /** @returns A Vue renderer under the application's existing provider tree. */
  addNodeView() { return VueNodeViewRenderer(StatusView); },
}), {
  /** @param context - The same validated fields, with reading locale and no editing actions. @returns Semantic readonly content. */
  render(context) { return h("strong", exampleMessages[context.locale][context.attrs.value]); },
});
const { editor, host } = useMsfeEditor(parent, () => ({
  markdown: text.value.statusInitial,
  mode: "live", label: text.value.statusEditor, extensions: [StarterKit, StatusWithUI, Appearance],
}));
// Share the accepted snapshot; the Vue adapter owns subscription cleanup.
const state = useMsfeState(editor);
/** @param value - Requested mode; all modes retain the same source and history. */
function changeMode(value: string | null | undefined): void {
  if (value === "live" || value === "source" || value === "locked") { mode.value = value; editor.value?.setMode(value); }
}
/** Inserts through the same public command available to keyboard and menu consumers. */
function insert(): void { if (editor.value?.commands.insertStatus("todo")) editor.value.focus(); }
</script>
<template>
  <div class="grid gap-4 p-qk-surface">
    <div class="flex flex-wrap gap-2">
      <QuireSegmented :model-value="mode" :items="items" :label="text.modes" size="sm" @update:model-value="changeMode" />
      <QuireButton size="sm" variant="outline" :disabled="mode === 'locked'" @click="insert">{{ text.insertStatus }}</QuireButton>
      <QuireButton size="sm" variant="ghost" :disabled="mode === 'locked'" @click="editor?.undo()">{{ text.undo }}</QuireButton>
      <QuireButton size="sm" variant="ghost" :disabled="mode === 'locked'" @click="editor?.redo()">{{ text.redo }}</QuireButton>
      <QuireButton size="sm" variant="ghost" @click="snapshot = editor?.getMarkdown()">{{ text.readMarkdown }}</QuireButton>
    </div>
    <div ref="parent" class="overflow-hidden rounded-qk-control border border-qk-border-subtle" />
    <VueNodeViewHost :host="host" />
    <section v-if="state" :aria-label="text.readingPreview" class="rounded-qk-control border border-qk-border-subtle p-qk-surface">
      <p class="mb-3 text-sm text-qk-text-muted">{{ text.readingPreview }}</p>
      <MdProse :state="state" :locale="locale" />
    </section>
    <QuireProseSource v-if="snapshot !== undefined" :code="snapshot" language="markdown" filename="status.md" />
  </div>
</template>

```

### shared/appearance.ts

```ts
import { Extension } from "@quirekit/msfe-core";
import { EditorView } from "@quirekit/cm-view";
import { Prec } from "@quirekit/cm-state";
import { HighlightStyle, syntaxHighlighting } from "@quirekit/cm-language";
import { tags } from "@lezer/highlight";
import { themeVariableReferences as token } from "@quirekit/ui";

/** Consumer-owned theme; source semantics stay in the installed feature packages. */
export const Appearance = Extension.create({ name: "documentationAppearance",
  /** @returns Scoped typography, surfaces and syntax colors from the host design system. */
  addCodeMirrorExtensions() {
    return Prec.high([
      EditorView.theme({
        "&": { ...token.typography.body, color: token.color.text.primary, backgroundColor: token.color.surface.page, minHeight: "180px" },
        ".cm-scroller": { fontFamily: "inherit", lineHeight: "1.8" },
        ".cm-content": { padding: token.spacing.surface, caretColor: token.color.text.primary },
        ".cm-line": { padding: "0" },
        ".cm-gutters": { color: token.color.text.tertiary, backgroundColor: token.color.surface.subtle, border: "none" },
        ".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "transparent" },
        ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { backgroundColor: token.color.selection.document },
        ".cm-cursor": { borderLeftColor: token.color.text.primary },
        ".msfe-h1": { ...token.typography.pageTitle },
        ".msfe-h2, .msfe-h3": { ...token.typography.sectionTitle },
        ".msfe-heading": { paddingBlock: token.spacing.inline },
        ".msfe-link": { color: token.color.text.accent },
        ".msfe-code-line, .msfe-inline-code": { ...token.typography.code },
        ".consumer-status": { verticalAlign: "middle" },
        "&.cm-focused": { outline: "none" },
      }),
      syntaxHighlighting(HighlightStyle.define([
        { tag: tags.heading, color: token.color.text.primary, fontWeight: "600" },
        { tag: tags.strong, fontWeight: "700" },
        { tag: tags.emphasis, fontStyle: "italic" },
        { tag: [tags.link, tags.url], color: token.color.text.accent },
        { tag: tags.processingInstruction, color: token.color.text.tertiary },
        { tag: tags.monospace, color: token.color.text.secondary },
      ])),
    ]);
  },
});

```

### status/status.ts

```ts
import { Node, defineSource } from "@quirekit/msfe-core";
import type { StatusAttributes, StatusValue } from "./types";

/** @param value - Source or requested attribute. @returns Whether it has an exact source spelling. */
function validStatus(value: unknown): value is StatusValue { return value === "todo" || value === "done"; }

/** Grammar, fields and commands are independent of the consumer's component framework. */
export const Status = Node.create({ name: "status",
  /** @returns The one admitted, persistable field. */
  addAttributes() { return { value: { validate: validStatus } }; },
  /** @returns Bounded parsing and exact field updates, without a second document. */
  addSource() {
    return defineSource<StatusAttributes>({ nodes: ["StatusToken"], syntax: { name: "StatusToken", open: "[[", close: "]]" }, placement: "replace",
      /** @param document - Canonical Markdown. @param node - Confirmed token. @returns Attributes or a source fallback. */
      read(document, node) {
        const value = document.slice(node.from + 2, node.to - 2);
        return validStatus(value) ? { value } : null;
      },
      /** @param _document - Canonical source. @param node - Current valid range. @param patch - Requested fields. @returns A precise patch or refusal. */
      patch(_document, node, patch) {
        return validStatus(patch.value) ? { from: node.from + 2, to: node.to - 2, insert: patch.value } : null;
      },
    });
  },
  /** @returns Typed commands sharing the editor's transaction and history. */
  addCommands() { return { insertStatus: (value: StatusValue) => ({ tr }) => validStatus(value) && tr.insertText(`[[${value}]]`) }; },
});

```

### status/types.ts

```ts
/** Source-persisted value for the custom status syntax. */
export type StatusValue = "todo" | "done";

/** Attributes decoded from the canonical Markdown token. */
export interface StatusAttributes {
  /** Current task status. */
  readonly value: StatusValue;
}

declare module "@quirekit/msfe-core" {
  interface Commands<ReturnType> {
    /** Commands installed by this consumer extension. */
    status: {
      /** @param value - Persisted status. @returns Command outcome. */
      insertStatus: (value: StatusValue) => ReturnType;
    };
  }
}

```

### status/status-view.vue

```vue
<script setup lang="ts">
import { computed, inject } from "vue";
import type { MsfeNodeViewProps } from "@quirekit/msfe-core";
import { QuireButton } from "@quirekit/ui/vue";
import type { StatusAttributes } from "./types";
import { exampleMessages } from "../shared/messages";
import { exampleLocaleKey } from "../shared/locale";
const locale = inject(exampleLocaleKey, computed(() => "zh-CN"));
const text = computed(() => exampleMessages[locale.value]);
const props = defineProps<{ /** Derived fields and guarded source actions. */ nodeView: MsfeNodeViewProps<StatusAttributes> }>();
/** Changes one field; the editor checks the live range, permission and history. */
function toggle(): void { props.nodeView.updateAttributes({ value: props.nodeView.attrs.value === "todo" ? "done" : "todo" }); }
</script>
<template><QuireButton class="consumer-status" size="sm" variant="outline" :disabled="nodeView.readOnly" @click="toggle">{{ nodeView.attrs.value === 'done' ? text.done : text.todo }}</QuireButton></template>

```

### shared/messages.ts

```ts
const chinese = {
  editor: "Markdown 示例编辑器", modes: "编辑器模式", source: "源码", live: "实时预览", locked: "只读",
  undo: "撤销", redo: "重做", reset: "重置", readMarkdown: "读取 Markdown", readingPreview: "阅读预览", insertStatus: "插入状态", done: "已完成", todo: "待处理",
  nativeEditor: "原生 DOM 示例编辑器", statusEditor: "自定义状态编辑器",
  initial: "## 把想法写下来\n\n在这里编辑 **Markdown**，也可以切换到源码。\n\n- [x] 保留 Markdown 原文\n- [ ] 试试修改这一行\n",
  nativeInitial: "## 原生 DOM 接入\n\n这个实例直接使用 `createMsfeEditor()`。\n",
  statusInitial: "## 自定义状态\n\n设计评审：[[todo]]\n\n点击状态按钮，然后读取 Markdown 或撤销。\n",
};
const english: { [Key in keyof typeof chinese]: typeof chinese[Key] } = {
  editor: "Markdown example editor", modes: "Editor mode", source: "Source", live: "Live", locked: "Locked",
  undo: "Undo", redo: "Redo", reset: "Reset", readMarkdown: "Read Markdown", readingPreview: "Reading preview", insertStatus: "Insert status", done: "Done", todo: "Pending",
  nativeEditor: "Native DOM example editor", statusEditor: "Custom status editor",
  initial: "## Write your ideas\n\nEdit **Markdown** here, or switch to source.\n\n- [x] Preserve the Markdown source\n- [ ] Try editing this line\n",
  nativeInitial: "## Native DOM integration\n\nThis instance uses `createMsfeEditor()` directly.\n",
  statusInitial: "## Custom status\n\nDesign review: [[todo]]\n\nClick the status, then read the Markdown or undo.\n",
};
export const exampleMessages = { "zh-CN": chinese, en: english };

```

### shared/locale.ts

```ts
import type { ComputedRef, InjectionKey } from "vue";
import type { ExampleLocale } from "./types";

/** Host UI language shared through the existing Vue provider tree, never stored in Markdown. */
export const exampleLocaleKey: InjectionKey<ComputedRef<ExampleLocale>> = Symbol("example-locale");

```

### shared/types.ts

```ts
/** Example host language; independent of the documentation router. */
export type ExampleLocale = "zh-CN" | "en";

```

## 定义语法和字段

`status.ts` 使用 `Node.create()` 声明语义；`addAttributes()` 允许 `value` 字段并校验取值。`defineSource()` 的字面分隔符语法适合单行、不嵌套的 token。

`read(document, node)` 只读取已确认范围；不符合约定的值返回 `null`，继续显示源码。复杂或嵌套语法应通过 `addMarkdownExtensions()` 提供解析规则。

## 精确写回

`patch(document, node, patch)` 返回源码修改。这个例子保留两侧括号，只替换内部值。字段验证、只读、过期目标和撤销由编辑器统一处理。

### SourceDefinition

不依赖特定解析器的语义编解码器；渲染是可选能力，不负责持久化。

```ts
export interface SourceDefinition<Attrs extends object, Resource = unknown> {
    readonly nodes: readonly string[];
    readonly syntax?: InlineSyntax;
    readonly placement?: "replace" | "before";
    readonly showWhenActive?: boolean;
    readonly measureHeight?: boolean;
    readonly read: (document: SourceDocument, node: SourceNode) => Attrs | null;
    readonly source?: (document: SourceDocument, node: SourceNode) => string;
    readonly patch: (document: SourceDocument, node: SourceNode, patch: Partial<Attrs>) => SourceEdit | readonly SourceEdit[] | null;
    readonly load?: (attrs: Readonly<Attrs>, signal: AbortSignal) => MsfeNodeViewResource<Resource> | Promise<MsfeNodeViewResource<Resource>>;
    readonly renderer?: MsfeNodeViewRenderer;
}
```

**nodes** — 已确认的语法类型；提供 syntax 时使用对应的行内语法名称。

**syntax** — 此功能提供的可选简单语法声明。

**placement** — 替换单行源码，或在由源码管理的正文前放置不可编辑的控件。

**showWhenActive** — 选中源码时是否继续显示控件。

**measureHeight** — 在视口外保留已稳定替换视图的测量高度，包括错误回退；普通行内控件保持关闭。

**read** — 参数 document：权威源码。
参数 node：已确认的语法范围。
返回：从源码派生的语义字段，或回退结果。

**source** — 可选的有界源码指纹，必须覆盖所有可修改字段。

**patch** — 参数 document：权威源码。
参数 node：当前语法范围。
参数 patch：语义字段修改。
返回：精确的源码修改，或拒绝修改。

**load** — 可选的、可取消的资源加载器，由各类渲染器共享。

**renderer** — 原生渲染器；通过 addNodeView 提供组件渲染器时可省略。

## 添加命令

`addCommands()` 定义 `insertStatus()`；`types.ts` 的模块增强让 `editor.commands`、`editor.can()` 和 `editor.chain()` 获得类型提示。命令使用提供的 `tr`，避免自己提交另一笔事务。

```ts
editor.commands.insertStatus('todo')
editor.can().insertStatus('done')
editor.chain().insertStatus('todo').insertText(' ').run()
```

## 替换组件

应用用 `.extend({ addNodeView() { ... } })` 接入 `VueNodeViewRenderer()`。`status-view.vue` 接收 `nodeView.attrs` 和 `updateAttributes()`，无需自行计算源码偏移。

组件树必须包含 `VueNodeViewHost`。更换为 React 时保留同一语法扩展，使用对应的 renderer 与 host。

## 接入静态阅读

`MdProse` 使用 `withMdReading(extension, definition)` 接收第三方阅读贡献。先完成扩展的
`configure()` / `extend()`，再包装并安装到同一个编辑器。示例使用的公开入口是
`@quirekit/md-editor/vue`，不需要修改产品内部的语法分支。

```ts
import { h } from 'vue'
import { withMdReading } from '@quirekit/md-editor/vue'
import { Status } from './status'
import type { StatusAttributes } from './types'

const ReadableStatus = withMdReading<StatusAttributes>(Status, {
  render({ attrs }) {
    return h('strong', attrs.value === 'done' ? '已完成' : '待处理')
  },
})
```

`render(context, resource)` 的 `attrs` 来自原扩展安装后的 codec，字段默认值与校验只定义
一次。context 同时提供 `name`、`node`、`document` 和 `locale`，不提供编辑动作。
`renderInline(from?, to?)` 与 `renderBlocks(omitted?)` 将子内容交回公共阅读器；行内范围
必须在当前节点内且不能截断一个子语法节点，非法范围会触发局部源码回退。

`MdProse` 的 `state` 接收原编辑器的不可变快照。示例用 `const state = useMsfeState(editor)`
接入 Vue，公共 hook 自动管理订阅与清理，见 [Vue 状态订阅](/guide/vue#将编辑器状态接入-vue)。
每次输入不序列化 Markdown，纯选区与焦点变化不重复解码或加载。
阅读只依赖源码、解析树、已安装贡献、呈现配置与 locale；codec 不应读取选区或可变外部状态
来决定静态内容。文档变化可以保守重建阅读快照，这个接口不承诺逐节点增量更新。

## 阅读资源与失败回退

有 IO 的贡献可声明 `load(context, signal)`，返回 `ResourceLease<Resource>`，即
`{ value, release? }`，或相应 Promise。`render` 只在资源就绪后运行。取消信号在快照替换
或卸载时触发；即使加载方忽略取消，迟到资源仍会调用 `release()`。阅读资源与编辑
NodeView 的资源分别拥有生命周期，不自动重用 `addSource().load`。

待加载、失败和服务端渲染时保留原节点的精确源码；SSR 不启动加载。同步 renderer 可以
服务端渲染。Vue 子组件继承应用 provider，客户端子组件异常也隔离在本节点。
渲染器是宿主提供的可信代码，原始 Markdown 不会被当作 HTML 执行。

用 `<MdProse @error="reportReadingError" />` 接收 `MdReadingError`，其中包含 owner、
`read` / `load` / `render` / `dispose` 阶段、原始异常与捕获快照的 UTF-16 范围。
这个范围是诊断信息，不能用于异步写回当前编辑器。无错误监听器时，Vue 阅读入口只保留
可见源码；宿主可按需接入日志。资源释放失败会被报告，不替换已经成功显示的新内容。

| 场景 | 编辑 / 源码 | 静态阅读 |
| --- | --- | --- |
| 合法的自定义字段 | 由原扩展 patch 精确修改，可撤销/重做 | 使用同一个 codec 的默认值与校验结果 |
| 字段非法、未知或未闭合语法 | 保留原文 | 未命中贡献时保留内置展示或源码 |
| 代码块 / 行内代码中的 token | 仍是代码文本 | 不额外解析成自定义节点 |
| 同语法有多个语义候选 | 沿用安装顺序 | 首个 codec 返回非 null 的候选拥有该节点 |
| `render` 返回 undefined / null | 原文不变 | undefined 保留精确源码；null 明确不输出内容 |
| 加载等待、拒绝或渲染异常 | 原文不变，其他块正常 | 仅失败节点回退并可报告错误 |
| 只读模式 | 字段动作与历史写入被拒绝 | 继续展示同一份内容 |
| 没有 source codec / 重复阅读 owner | 明确拒绝安装 | 不创建无效阅读贡献 |
| React 或 Word/PDF/OFD 导出 | 保留中立语义扩展 | 此适配器只负责 Vue 阅读；导出需独立转换贡献 |

## 生命周期约束

离开视口、显露源码、换文档或销毁时，节点组件可能卸载。持久字段写回源码，临时 UI 状态按合适的生命周期管理；不要依赖组件永远挂载。

## read、patch 与字段校验的契约

`addAttributes()` 是允许写入的字段清单，默认值与 `validate` 不能替代源码编码。`read()` 返回 null 表示这个已识别语法不属于该语义形式，原文保留；它不应修复或标准化用户文本。

`patch()` 接收局部字段，不保证包含所有字段。返回 `{ from, to, insert }`、多个不重叠修改或 null。只替换需要的字段区域，保留空白、引号、其他属性和分隔符风格。如果新值包含分隔符，必须编码或拒绝，不能直接拼入语法。

只传入未知字段或不合法值时，运行时会拒绝。对多种源码拼写（例如单引号、双引号、参考定义）先定义可逆写回策略；不能写回的形式继续可读，不假装支持编辑。

## source 指纹何时需要

默认源码身份由相关范围确定。提供 `source(document, node)` 可给出更小的精确指纹，但必须覆盖所有会被 patch 修改的字段。若控件只修改代码围栏语言，指纹可以只覆盖 header；若控件同时修改正文，就不能仍只比较 header。

指纹用于防止过期组件修改不同内容，不能用作持久业务 ID，也不能用节点在视口内的 id 保存到数据库。需要业务 ID 时把它定义为可校验的源码字段。

## 从字段到异步组件

`defineSource<Attrs, Resource>()` 可增加 `load(attrs, signal)`。组件收到 `status: 'loading' | 'ready' | 'error'` 和可选 `resource`；字段变化和卸载由资源协议管理。组件仍通过 `updateAttributes()`、`deleteNode()`、`revealSource()` 等受控动作操作源码。先看 [异步资源](/extensions/resources)，再增加上传或服务器查询。

## 块节点应该如何选择

本页的字面语法是单行、不嵌套 token，NodeView 只拥有这个 token 的显示。`syntax` helper 不适合冒号嵌套容器或任意跨行语法。容器正文沿用原生 Markdown，见 [容器](/extensions/containers)；跨行静态预览见 [图表](/extensions/diagrams)；自定义解析见 [Markdown 插件](/extensions/markdown-plugins)。

## 检查新增节点是否完整

先验证合法、非法、未闭合和转义形式；再验证字段更新只改变对应源码、撤销恢复原样、只读动作拒绝、快速改字段时旧资源释放、组件卸载后动作失效。最后在窄屏和深浅主题下检视按钮与周围文本的基线、选区和焦点。
