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