Custom nodes

Your own inline node types (citations, mentions, merge fields) as DOCX content controls.

A custom node is your own inline element type, such as a citation or a merge field. The editor stores it as an inline content control (w:sdt) that other DOCX applications keep. Custom nodes require customNodesModule from @docx-editor.dev/pro, which uses the EigenPal Pro License.

Insert a custom node

Define the node once and register it in a stable module array. schema accepts any Standard Schema library, such as zod or valibot. Install your own.

// citation.ts
import { z } from 'zod';
import { customNodesModule, defineCustomNode } from '@docx-editor.dev/pro';

export const Citation = defineCustomNode({
  name: 'citation', // second segment of the tag
  tagPrefix: 'acme', // this definition claims acme:*
  schema: z.object({
    sourceId: z.string().min(1),
    author: z.string(),
    year: z.number().int(),
  }),
  text: (data) => `(${data.author} ${String(data.year)})`, // what the document shows
});

// Modules are read once, when the editor is created.
export const MODULES = [customNodesModule({ nodes: [Citation] })];

This example adds an insert button and the chip chrome. The button prevents default on mouse down, so it keeps the caret where the node goes:

import { insertCustomNode } from '@docx-editor.dev/pro';
import { CustomNodeChrome } from '@docx-editor.dev/pro/react';
import { DocxEditor, useDocxEditor } from '@docx-editor.dev/react';
import { Citation, MODULES } from './citation';

function CiteButton() {
  const editor = useDocxEditor(); // null until the content is mounted
  return (
    <button
      disabled={!editor}
      onMouseDown={(e) => e.preventDefault()}
      onClick={() => {
        const result = insertCustomNode(editor!, Citation, {
          data: { sourceId: 'smith-2024', author: 'Smith', year: 2024 },
        });
        if (!result.ok) console.warn(result.reason);
      }}
    >
      Cite
    </button>
  );
}

export function Editor({ bytes }: { bytes: Uint8Array }) {
  return (
    <DocxEditor.Root document={bytes} modules={MODULES}>
      <CiteButton />
      <DocxEditor.Viewport>
        <DocxEditor.Content />
        <CustomNodeChrome onNodeClick={(node) => console.log(Citation.dataOf(node))} />
      </DocxEditor.Viewport>
    </DocxEditor.Root>
  );
}

The button inserts (Smith 2024) at the caret as a chip. The schema validates the data first, and text receives the validated output. For every definition option, see Define and register nodes.

Update and remove nodes

updateCustomNode and removeCustomNode take the node ID. Each write is one transaction and one undo step, including the payload:

import { removeCustomNode, updateCustomNode } from '@docx-editor.dev/pro';

const result = updateCustomNode(editor, Citation, card.nodeId, { data: next });
if (result.ok && result.nodeId) setCard({ ...card, nodeId: result.nodeId });
removeCustomNode(editor, otherNodeId); // deletes the node and its payload

An update replaces the control, so store the returned nodeId. A refusal carries a reason, a code, and, for a rejected payload, issues.

For input fields and codes, see Write nodes.

Read typed payloads

customNodesOf lists nodes from every story. dataOf returns a node's validated payload for one definition:

import { customNodesOf } from '@docx-editor.dev/pro';

for (const node of customNodesOf(editor)) {
  const citation = Citation.dataOf(node);
  if (citation) index.add(citation.sourceId); // typed
}

Each call returns a fresh snapshot. No change event exists, so call it again after an edit.

dataOf returns undefined for a node without a valid payload. Missing or invalid payloads also report through the module's onDiagnostic option.

Add chip controls

Mount CustomNodeChrome and CustomNodeContextMenu inside the viewport:

import { DocxEditor } from '@docx-editor.dev/react';
import { CustomNodeChrome, CustomNodeContextMenu } from '@docx-editor.dev/pro/react';
import { openEditForm, openPopover } from './node-ui'; // your own handlers

<DocxEditor.Viewport>
  <DocxEditor.Content />
  <CustomNodeChrome onNodeClick={(node) => openPopover(node)} />
  <DocxEditor.ContextMenu>
    <CustomNodeContextMenu onEditNode={(node) => openEditForm(node)} />
  </DocxEditor.ContextMenu>
</DocxEditor.Viewport>;

An ActivatedCustomNode carries name, tag, attrs, and data (narrow it with dataOf). It also has a viewport-relative rect for your popover, and nodeId and text when they resolve.

Chips are content-locked by default, so you can type beside a chip but not inside it. A locked wrapper or viewing mode can refuse Remove. Pass onRemoveRefused(node, reason) to show the reason; otherwise the menu closes.

Save or export nodes

Use editor.save() for the copy you keep, and saveForExport for a copy without your custom-node markup:

import { saveForExport } from '@docx-editor.dev/pro';

await storage.put(docId, new Uint8Array(await editor.save()));
const outgoing = await saveForExport(editor);
if (!outgoing.ok) throw new Error(outgoing.reason);
download(outgoing.bytes);

preserveOnExport is true (keep the control, the default), 'text' (keep the words only), or false (remove the node). Export cannot be undone: unwrapped text never becomes a node again. Store the saved bytes, not the exported ones.

For server export and server authoring, see Export nodes and Author nodes on a server.

Limits

  • Modules register once, when the editor is created. To change definitions, remount the root.
  • Asynchronous schema validation is refused.
  • customNodesOf reads the body first, then headers, footers, footnotes, and endnotes.
  • Payloads in files from others are validated by your schema before your code reads them.
  • A reviewCard on a definition requires reviewModule() alongside customNodesModule.

See also