Node 定义保存什么,NodeView 定义编辑时如何交互。本章使用 DOM 后端,给一个计数器块增加“+1”按钮。Canvas/SVG 的布局与选区适配见内容扩展教程,不能直接复用这里的 DOM 按钮作为画布节点。
1. 把 count 放进正文
docsCounter 是不包含可编辑子内容的块级 atom,count 是默认值为 0 的属性。JSON 保存计数;renderHTML() 输出一个静态元素,不输出编辑按钮。
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; },
});
}
安装 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 跟随正文更新
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. 试用与核对保存结果
点击两次 +1,计数应为 2;撤销后应为 0 或前一历史分组的值。下载 JSON 查看 attrs.count,里面不应保存按钮 DOM。再在计数器前插入段落,确认点击仍修改同一个节点。完整挂载组件在预览的源码页签中,节点和 View 可以分别查阅。
只读检查属于本例的写入命令,避免按钮在只读时仍修改正文。更复杂的控件还要处理键盘可达性、焦点恢复、选中态,以及与外部数据请求的取消和失败。