Skip to content

Unique ID

UniqueID automatically assigns unique IDs to block nodes. Every paragraph, heading, blockquote, list, and other configured node gets a UUID attribute. IDs are assigned on creation, and pasted content gets new IDs when duplicates are detected. Useful for collaborative editing, deep linking, change tracking, and content addressing.

Not included in StarterKit. Add it separately.

Use UniqueID when you need:

  • Stable random IDs on block nodes for deep-linking (#heading-id)
  • Anchor support for TableOfContents to scroll to specific headings
  • IDs that survive across renders, exports, and re-imports

Notes:

  • UniqueID reapplies after duplicate to keep IDs unique within the document
  • Configure types to control which node types receive IDs. The default covers 12 block types (see Default types); any array you pass replaces the default outright, it does not extend it
import { Document, Paragraph, Text, UniqueID } from '@domternal/core';
import { DomternalEditor } from '@domternal/vanilla';
import '@domternal/theme';
const dm = new DomternalEditor(document.getElementById('editor')!, {
extensions: [Document, Paragraph, Text, UniqueID],
});
// Each paragraph now has a unique id attribute
// <p id="a1b2c3d4-...">text</p>
OptionTypeDefaultDescription
typesstring[]12 node types (see below)Node types that receive unique IDs
attributeNamestring'id'HTML attribute name for the ID
generateID() => stringgenerateUUIDFunction to generate unique IDs
filterDuplicatesbooleantrueRegenerate IDs for duplicates when pasting

The default list covers all common block nodes:

paragraph, heading, blockquote, codeBlock, bulletList, orderedList, taskList, listItem, taskItem, image, horizontalRule, table

table is listed even though it lives in a separate package. A global attribute only installs on types the schema actually has, so naming it is inert when @domternal/extension-table is not loaded.

The built-in generator creates UUIDs in the format xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx. You can replace it with any function that returns a unique string:

UniqueID.configure({
generateID: () => `block-${Date.now()}-${counter++}`,
})

The built-in generator already prefers crypto.randomUUID() and falls back to a Math.random-based v4 UUID only where that API is missing (old jsdom, hostile shims). Replace it for shorter or domain-specific ids, not as a security upgrade.

Or shorter IDs:

UniqueID.configure({
generateID: () => Math.random().toString(36).slice(2, 10), // e.g. "k5f2m8n1"
})
UniqueID.configure({
attributeName: 'data-block-id', // renders as data-block-id="..." instead of id="..."
})
UniqueID.configure({
filterDuplicates: false, // let pasted ids reach the document unchanged
})

When content arrives with colliding ids, the plugin renames them so each node ends up unique. Which node keeps the id depends on where the collision came from:

  • Content loaded wholesale (setContent, a bulk insert of hand-written markup): no node held the id before, so the first occurrence in document order keeps it (since v0.7.0).
  • A collision created by an edit (pasting or duplicating a block that is still in the document): the node that already held the id keeps it, and the new copy is renamed, regardless of which one sits higher (since v0.14.0).

Before v0.14.0 both cases used the same first-in-document-order rule. First-in-document-order is still the fallback when no node held the id before the change, or when the position of the node that did cannot be mapped through it.

The second rule is what stops a copy pasted above an original from stealing its anchor: a stored deep link keeps resolving to the block it was made for.

Paste from outside the editor goes through transformPasted first; the duplicate-rename pass is a safety net for setContent and bulk inserts. The result: the document always maintains a unique-id space required for native #hash anchors and getElementById.

The TableOfContents extension REQUIRES UniqueID to be loaded - it reads UniqueID’s attributeName (default 'id') from heading nodes to build navigation anchors. TableOfContents does NOT create its own id attribute; it relies entirely on UniqueID’s.

Configuration validation warns if TOC’s anchorTypes are not in UniqueID’s types:

UniqueID.configure({ types: ['heading'] }), // TOC needs heading ids
TableOfContents.configure({ anchorTypes: ['heading'] }), // matches

UniqueID does not register any commands.

UniqueID does not register any keyboard shortcuts.

UniqueID does not register any input rules.

UniqueID does not register any toolbar items.

UniqueID assigns IDs at three points:

  1. Initial load: When the editor view is ready, a setTimeout(0) dispatches a transaction that walks the entire document and assigns IDs to any configured node that lacks one
  2. New nodes: The appendTransaction hook runs after every document change. It walks the new document and assigns IDs to any node that doesn’t have one yet (new paragraphs from Enter, new list items, etc.)
  3. Pasted content: The transformPasted prop intercepts paste operations before they are applied. The prop is only installed when filterDuplicates is enabled (the default); with filterDuplicates: false there is no paste hook at all and only points 1 and 2 run

UniqueID uses addGlobalAttributes() to inject the ID attribute into all configured node types:

addGlobalAttributes() {
return [{
types: this.options.types,
attributes: {
[this.options.attributeName]: {
default: null,
parseHTML: (element) => element.getAttribute(this.options.attributeName),
renderHTML: (attributes) => {
const id = attributes[this.options.attributeName];
if (!id) return null;
return { [this.options.attributeName]: id };
},
},
},
}];
}

The attribute is parsed from and rendered to HTML as a standard HTML attribute (not a style):

<p id="a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d">Paragraph with unique ID</p>

When filterDuplicates is true (default), pasting content triggers the transformPasted hook:

  1. Skips the pass entirely for a drag-move (since v0.14.0): if the drag was started as a move (view.dragging.move === true), the slice is returned untouched
  2. Collects the IDs already in the document, minus any that sit inside the ranges this paste is about to replace: a block on its way out is not an incumbent, and counting it renamed the very content replacing it. A replaced node’s whole subtree is skipped, since everything inside it is replaced too
  3. Walks every node in the pasted slice
  4. If a pasted node’s ID already exists in the document, generates a new ID
  5. If the ID is unique, keeps it and adds it to the tracking set (to catch duplicates within the pasted content itself)

This prevents duplicate IDs when users copy-paste blocks within the same editor.

ProseMirror runs transformPasted on the dragged slice before the source is deleted, so without the drag-move exemption a plain block drag inside the document looked like a duplicate and the block landed with a fresh ID, breaking every #hash anchor, table-of-contents deep link and block menu Copy link pointing at it. A copy drag (holding Ctrl, or Alt on macOS, when the drag starts, so move is false) still regenerates. If a move never completes, the duplicate-rename sweep that runs on every document change catches the collision.

The subtraction in step 1 is what makes replace-a-selection paste work. Select all and paste a copy of the document and every incoming block collides with an incumbent that the same paste is about to delete, so before the subtraction the pasted content landed with fresh IDs throughout and every #hash anchor, table-of-contents deep link and block menu Copy link pointing into it was orphaned. Pasting the same clipboard a second time kept its IDs, because by then the originals were gone, which is why the defect looked like it only struck every other paste. The ranges come from view.state.selection.ranges, and only ranges that actually cover something count, so a collapsed caret subtracts nothing.

A drop is exempt from the subtraction. view.dragging is set for a drop, and a drop inserts at the drop point and leaves the selection alone, so there the selection names content that survives and must keep being treated as an incumbent. A custom handlePaste that inserts somewhere other than the selection can still defeat the carve-out, since the plugin reads the selection rather than the eventual insertion point; the cost is one duplicate, which the rename sweep resolves on the next transaction.

The initial assignment uses setTimeout(0) to avoid dispatching a transaction during plugin initialization. The timeout callback creates a fresh transaction from editorView.state (not a stale reference) and only dispatches if the document actually changed. The timeout is cleaned up in the plugin’s destroy() method.

ID sweeps are bookkeeping, not edits, so they try to stay out of the undo stack, but only where that is safe.

  • The startup sweep always opts out. The transaction dispatched from the plugin’s view is marked tr.setMeta('addToHistory', false) before it is dispatched, and it is only dispatched at all if it actually changed the document. Without this, the first Ctrl+Z stripped every ID, the next sweep minted different ones, and every stored reference broke with no visible cause.
  • The per-change sweep opts out only when it has no user edit to ride along with. The appendTransaction sweep checks whether any transaction in the batch was itself a doc-changing edit that is being recorded; if one is, the sweep stays in history with it.

The second rule is deliberately narrow. prosemirror-history reads addToHistory per transaction, so a blanket opt-out would look correct there. y-prosemirror takes the flag from the LAST transaction of a batch and stamps the batch’s single Yjs transaction with it, and the sweep is always last, so a blanket opt-out would drop the user’s own edit out of collaborative undo: the block stays and Ctrl+Z does nothing.

The built-in generateUUID() prefers the platform crypto API and keeps a dependency-free RFC 4122 v4 fallback for environments where it is missing:

function generateUUID(): string {
if (typeof globalThis !== 'undefined') {
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

Node 20 and up (the supported floor) and every current browser provide crypto.randomUUID, so the Math.random branch only runs where the API is missing: test environments (jsdom prior to v22, very old shims) and browsers outside a secure context, since crypto.randomUUID is exposed only over HTTPS and on localhost. Replace generateID for shorter or domain-specific IDs, not as a security upgrade.

import { UniqueID, uniqueIDPluginKey } from '@domternal/core';
import type { UniqueIDOptions } from '@domternal/core';
ExportTypeDescription
UniqueIDExtensionThe unique ID extension
uniqueIDPluginKeyPluginKeyThe ProseMirror plugin key
UniqueIDOptionsTypeScript typeOptions for UniqueID.configure()

@domternal/core - UniqueID.ts

  • Table Of Contents - auto-generated table of contents from headings
  • Block Menu - Notion-style block UX (handle, slash, reorder)
  • Heading - H1-H6 headings with # markdown shortcuts