# 编写交互式 NodeView

给持久计数器节点增加一个按钮，理解显示、位置、事务和清理的关系。

Node 定义保存什么，NodeView 定义编辑时如何交互。本章使用 **DOM 后端**，给一个计数器块增加“+1”按钮。Canvas/SVG 的布局与选区适配见[内容扩展教程](/build/extension)，不能直接复用这里的 DOM 按钮作为画布节点。

## 1. 把 count 放进正文

`docsCounter` 是不包含可编辑子内容的块级 atom，`count` 是默认值为 0 的属性。JSON 保存计数；`renderHTML()` 输出一个静态元素，不输出编辑按钮。

### core/node-view/counter.ts

```ts
import { Node, mergeAttributes } from "@quirekit/core";
import { createCounterView } from "./view";

declare module "@quirekit/core" {
  /** Counter commands use the current canonical node, never a stale rendered value. */
  interface Commands<ReturnType> {
    /** Counter node behavior. */
    docsCounter: {
      /** @param position - Current position before the counter. @returns Whether increment is possible or accepted. */
      incrementDocsCounter: (position: number) => ReturnType;
    };
  }
}

/** @returns A fresh atom node; its count is saved, its button is not. */
export function createCounterNode() {
  return Node.create({
    name: "docsCounter", group: "block", atom: true,
    /** @returns Canonical count and defensive HTML parsing. */
    addAttributes() {
      return { count: { default: 0,
        /** @param element - External HTML. @returns A bounded nonnegative integer. */
        parseHTML(element: HTMLElement) {
          const value = Number(element.getAttribute("data-count"));
          return Number.isSafeInteger(value) && value >= 0 ? value : 0;
        },
        /** @param attrs - Canonical count. @returns Portable HTML attribute. */
        renderHTML: (attrs: Record<string, unknown>) => ({ "data-count": attrs.count }),
      } };
    },
    /** @returns Portable HTML matching rule. */
    parseHTML() { return [{ tag: "div[data-docs-counter]" }]; },
    /** @param props - Resolved attributes. @returns Static HTML independent of the interactive NodeView. */
    renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes(HTMLAttributes, { "data-docs-counter": "" }), String(HTMLAttributes["data-count"])]; },
    /** @returns One typed transaction command, with no side effects in can(). */
    addCommands() {
      return { incrementDocsCounter: (position: number) => ({ editor, tr, dispatch }) => {
        if (!editor.isEditable || !Number.isInteger(position) || position < 0 || position >= tr.doc.content.size) return false;
        const node = tr.doc.nodeAt(position);
        if (node?.type.name !== this.name || !Number.isSafeInteger(node.attrs.count) || node.attrs.count >= Number.MAX_SAFE_INTEGER) return false;
        if (dispatch) tr.setNodeMarkup(position, undefined, { ...node.attrs, count: node.attrs.count + 1 });
        return true;
      } };
    },
    /** @returns The DOM backend's interactive renderer. Canvas requires its own adapters. */
    addNodeView() { return createCounterView; },
  });
}

```

### core/node-view/view.ts

```ts
import type { QuireNodeViewRendererProps } from "@quirekit/core";
import type { Node as PMNode } from "@quirekit/pm";

/**
 * Creates one DOM-only button for a counter atom.
 * @param props - Current node, owning editor and live position resolver.
 * @returns A renderer whose update/destroy are owned by the mounted DOM view.
 */
export function createCounterView({ node, editor, view, getPos }: QuireNodeViewRendererProps) {
  const dom = view.dom.ownerDocument.createElement("div");
  const button = dom.ownerDocument.createElement("button");
  dom.dataset.docsCounterView = "";
  dom.contentEditable = "false";
  button.type = "button";
  dom.append(button);
  /** @param current - New immutable node from the document. */
  function paint(current: PMNode): void { button.textContent = `+1 · ${current.attrs.count}`; }
  /** Reads the position on every click; preceding edits can move this node. */
  function increment(): void {
    const position = getPos();
    if (typeof position === "number") editor.commands.incrementDocsCounter(position);
  }
  paint(node);
  button.addEventListener("click", increment);
  return {
    dom,
    /** @param next - Replacement node. @returns Whether this view can be reused. */
    update(next: PMNode): boolean {
      if (next.type !== node.type) return false;
      node = next; paint(next); return true;
    },
    /** @param event - Native control event. @returns Whether the counter owns the event. */
    stopEvent: (event: Event): boolean => !!event.target && button.contains(event.target as globalThis.Node),
    /** @returns Whether this atom's own DOM changes can be ignored; there is no contentDOM. */
    ignoreMutation: (): boolean => true,
    /** Releases exactly the listener created by this node view. */
    destroy(): void { button.removeEventListener("click", increment); },
  };
}

```

安装 `createCounterNode()`，再插入 `{ type: 'docsCounter', attrs: { count: 0 } }`。`atom: true` 表示正文把它视为一个整体，不意味着所有读取、导出、布局能力都自动存在。本例没有实现 Markdown、DOCX 或 AI 合同。

## 2. 点击时读取最新位置与节点

用户在计数器前输入内容后，节点位置会改变。按钮每次点击调用 `getPos()`，再调用 `incrementDocsCounter(position)`。命令从当前事务的 `tr.doc` 读取节点与 count，验证类型、数字范围和只读状态，然后修改属性。

不要闭包首次挂载时的数字，点击时再写 `initialCount + 1`；也不要保存首次 `getPos()` 的结果。`dispatch` 不存在时只检查可执行性，所以 `can().incrementDocsCounter(position)` 不会增加计数。

## 3. 让 View 跟随正文更新

### core/node-view/view.ts

```ts
import type { QuireNodeViewRendererProps } from "@quirekit/core";
import type { Node as PMNode } from "@quirekit/pm";

/**
 * Creates one DOM-only button for a counter atom.
 * @param props - Current node, owning editor and live position resolver.
 * @returns A renderer whose update/destroy are owned by the mounted DOM view.
 */
export function createCounterView({ node, editor, view, getPos }: QuireNodeViewRendererProps) {
  const dom = view.dom.ownerDocument.createElement("div");
  const button = dom.ownerDocument.createElement("button");
  dom.dataset.docsCounterView = "";
  dom.contentEditable = "false";
  button.type = "button";
  dom.append(button);
  /** @param current - New immutable node from the document. */
  function paint(current: PMNode): void { button.textContent = `+1 · ${current.attrs.count}`; }
  /** Reads the position on every click; preceding edits can move this node. */
  function increment(): void {
    const position = getPos();
    if (typeof position === "number") editor.commands.incrementDocsCounter(position);
  }
  paint(node);
  button.addEventListener("click", increment);
  return {
    dom,
    /** @param next - Replacement node. @returns Whether this view can be reused. */
    update(next: PMNode): boolean {
      if (next.type !== node.type) return false;
      node = next; paint(next); return true;
    },
    /** @param event - Native control event. @returns Whether the counter owns the event. */
    stopEvent: (event: Event): boolean => !!event.target && button.contains(event.target as globalThis.Node),
    /** @returns Whether this atom's own DOM changes can be ignored; there is no contentDOM. */
    ignoreMutation: (): boolean => true,
    /** Releases exactly the listener created by this node view. */
    destroy(): void { button.removeEventListener("click", increment); },
  };
}

```

`update(next)` 接收新的不可变节点，同类型就更新按钮并返回 true；不同类型返回 false，由视图重建。`contentEditable=false` 表示按钮不是正文输入区域，`stopEvent` 把按钮事件交给控件。

这个 atom 没有 `contentDOM`，自身按钮变化可以由 `ignoreMutation()` 忽略。包含可编辑子内容的 NodeView 需要提供 `contentDOM`，并让正文内的输入与选区变化交给编辑器；不能照搬这里“一律忽略”的规则。`destroy()` 移除本次创建的监听。

## 4. 试用与核对保存结果

### core/node-view/editor.vue

```vue
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
import { Editor } from "@quirekit/core";
import { StarterKit } from "@quirekit/starter-kit";
import { DomViewExtension } from "@quirekit/dom-view";
import { QuireButton } from "@quirekit/ui/vue";
import { createCounterNode } from "./counter";
import { downloadJson } from "../../shared/download";
import "@quirekit/pm-view/style/prosemirror.css";

const props = withDefaults(defineProps<{ /** Interface copy only. */ locale?: "zh-CN" | "en" }>(), { locale: "zh-CN" });
const host = ref<HTMLElement>();
const editor = shallowRef<Editor>();
const saved = ref(0);
const text = computed(() => props.locale === "en"
  ? { undo: "Undo", save: "Download JSON", hint: "Click +1, undo, then inspect the downloaded count." }
  : { undo: "撤销", save: "下载 JSON", hint: "点击 +1，再撤销，下载 JSON 查看实际保存的 count。" });
onMounted(() => {
  if (!host.value) return;
  editor.value = new Editor({ element: host.value, mode: "dom",
    extensions: [StarterKit, DomViewExtension, createCounterNode()],
    content: { type: "doc", content: [{ type: "docsCounter", attrs: { count: 0 } }, { type: "paragraph" }] },
    /** @param event - Current canonical content. */
    onUpdate: ({ editor: current }) => { saved.value = Number(current.state.doc.firstChild?.attrs.count ?? 0); },
  });
});
onBeforeUnmount(() => editor.value?.destroy());
</script>
<template>
  <div class="counter-example">
    <p>{{ text.hint }}</p>
    <div class="counter-example-actions">
      <QuireButton variant="outline" @click="editor?.commands.undo()">{{ text.undo }}</QuireButton>
      <QuireButton variant="outline" @click="editor && downloadJson(editor.getJSON(), 'counter.json')">{{ text.save }}</QuireButton>
      <output :aria-label="locale === 'en' ? 'Saved count' : '正文计数'">count: {{ saved }}</output>
    </div>
    <div ref="host" class="counter-example-document" />
  </div>
</template>
<style scoped>
.counter-example { padding: 20px; }
.counter-example-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0; }
.counter-example-document { padding: 16px; border: 1px solid var(--qk-color-border-default); border-radius: 6px; }
.counter-example-document :deep(.ProseMirror) { min-height: 120px; outline: none; }
.counter-example-document :deep(button) { padding: 8px 16px; border: 1px solid var(--qk-color-border-default); border-radius: 6px; background: var(--qk-color-surface-subtle); color: var(--qk-color-text-primary); cursor: pointer; }
</style>

```

### core/node-view/counter.ts

```ts
import { Node, mergeAttributes } from "@quirekit/core";
import { createCounterView } from "./view";

declare module "@quirekit/core" {
  /** Counter commands use the current canonical node, never a stale rendered value. */
  interface Commands<ReturnType> {
    /** Counter node behavior. */
    docsCounter: {
      /** @param position - Current position before the counter. @returns Whether increment is possible or accepted. */
      incrementDocsCounter: (position: number) => ReturnType;
    };
  }
}

/** @returns A fresh atom node; its count is saved, its button is not. */
export function createCounterNode() {
  return Node.create({
    name: "docsCounter", group: "block", atom: true,
    /** @returns Canonical count and defensive HTML parsing. */
    addAttributes() {
      return { count: { default: 0,
        /** @param element - External HTML. @returns A bounded nonnegative integer. */
        parseHTML(element: HTMLElement) {
          const value = Number(element.getAttribute("data-count"));
          return Number.isSafeInteger(value) && value >= 0 ? value : 0;
        },
        /** @param attrs - Canonical count. @returns Portable HTML attribute. */
        renderHTML: (attrs: Record<string, unknown>) => ({ "data-count": attrs.count }),
      } };
    },
    /** @returns Portable HTML matching rule. */
    parseHTML() { return [{ tag: "div[data-docs-counter]" }]; },
    /** @param props - Resolved attributes. @returns Static HTML independent of the interactive NodeView. */
    renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes(HTMLAttributes, { "data-docs-counter": "" }), String(HTMLAttributes["data-count"])]; },
    /** @returns One typed transaction command, with no side effects in can(). */
    addCommands() {
      return { incrementDocsCounter: (position: number) => ({ editor, tr, dispatch }) => {
        if (!editor.isEditable || !Number.isInteger(position) || position < 0 || position >= tr.doc.content.size) return false;
        const node = tr.doc.nodeAt(position);
        if (node?.type.name !== this.name || !Number.isSafeInteger(node.attrs.count) || node.attrs.count >= Number.MAX_SAFE_INTEGER) return false;
        if (dispatch) tr.setNodeMarkup(position, undefined, { ...node.attrs, count: node.attrs.count + 1 });
        return true;
      } };
    },
    /** @returns The DOM backend's interactive renderer. Canvas requires its own adapters. */
    addNodeView() { return createCounterView; },
  });
}

```

### core/node-view/view.ts

```ts
import type { QuireNodeViewRendererProps } from "@quirekit/core";
import type { Node as PMNode } from "@quirekit/pm";

/**
 * Creates one DOM-only button for a counter atom.
 * @param props - Current node, owning editor and live position resolver.
 * @returns A renderer whose update/destroy are owned by the mounted DOM view.
 */
export function createCounterView({ node, editor, view, getPos }: QuireNodeViewRendererProps) {
  const dom = view.dom.ownerDocument.createElement("div");
  const button = dom.ownerDocument.createElement("button");
  dom.dataset.docsCounterView = "";
  dom.contentEditable = "false";
  button.type = "button";
  dom.append(button);
  /** @param current - New immutable node from the document. */
  function paint(current: PMNode): void { button.textContent = `+1 · ${current.attrs.count}`; }
  /** Reads the position on every click; preceding edits can move this node. */
  function increment(): void {
    const position = getPos();
    if (typeof position === "number") editor.commands.incrementDocsCounter(position);
  }
  paint(node);
  button.addEventListener("click", increment);
  return {
    dom,
    /** @param next - Replacement node. @returns Whether this view can be reused. */
    update(next: PMNode): boolean {
      if (next.type !== node.type) return false;
      node = next; paint(next); return true;
    },
    /** @param event - Native control event. @returns Whether the counter owns the event. */
    stopEvent: (event: Event): boolean => !!event.target && button.contains(event.target as globalThis.Node),
    /** @returns Whether this atom's own DOM changes can be ignored; there is no contentDOM. */
    ignoreMutation: (): boolean => true,
    /** Releases exactly the listener created by this node view. */
    destroy(): void { button.removeEventListener("click", increment); },
  };
}

```

### 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);
}

```

点击两次 +1，计数应为 2；撤销后应为 0 或前一历史分组的值。下载 JSON 查看 `attrs.count`，里面不应保存按钮 DOM。再在计数器前插入段落，确认点击仍修改同一个节点。完整挂载组件在预览的源码页签中，节点和 View 可以分别查阅。

只读检查属于本例的写入命令，避免按钮在只读时仍修改正文。更复杂的控件还要处理键盘可达性、焦点恢复、选中态，以及与外部数据请求的取消和失败。
