# Build an interactive NodeView

Add a button to a persistent counter node and connect rendering, positions, transactions and cleanup.

A Node defines saved data. A NodeView defines editing interaction. This chapter uses the **DOM backend** to add a +1 button to a counter block. Canvas/SVG require [layout and selection adapters](/en/build/extension); this DOM button is not automatically a canvas node.

## 1. Store count in the document

`docsCounter` is a block atom with no editable children. Its `count` attribute defaults to 0. JSON stores the count; `renderHTML()` emits a static element rather than the editing button.

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

```

Install `createCounterNode()`, then insert `{ type: 'docsCounter', attrs: { count: 0 } }`. `atom: true` treats it as one unit; it does not install format conversion or layout support. This example supplies no Markdown, DOCX or AI contract.

## 2. Read the current position and node on click

Edits before the counter move its position. Each click calls `getPos()`, then `incrementDocsCounter(position)`. The command reads the current node and count from `tr.doc`, checks type, numeric bounds and editability, then updates attributes.

Do not capture the initial count and repeatedly write `initialCount + 1`, or cache the first `getPos()` result. Without `dispatch`, the command only validates availability, so `can().incrementDocsCounter(position)` does not increment.

## 3. Keep the view synchronized with content

### 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)` receives a new immutable node. It updates and returns true for the same type; false asks the view to rebuild for another type. `contentEditable=false` keeps the button outside text input, while `stopEvent` assigns button events to the control.

This atom has no `contentDOM`, so its own button changes can be ignored. A NodeView with editable children needs `contentDOM` and must allow editor-owned input and selection changes; do not reuse this example's unconditional `ignoreMutation()` there. `destroy()` removes the listener this view created.

## 4. Try it and inspect saved content

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

```

Click +1 twice to reach 2. Undo returns to 0 or the preceding history group. Download JSON and inspect `attrs.count`; it must not contain button DOM. Insert a paragraph before the counter and verify that clicking still changes the same node. The preview's source tab includes the mounting component; node and view sources can be read independently.

The example command enforces read-only mode so the button cannot modify a read-only document. More complex controls also need keyboard access, focus restoration, selected appearance, and cancellation/failure handling for external requests.
