@gaurussel/tiptap-diff-utility
v1.3.6
Published
TipTap/ProseMirror extension that diffs two document versions and renders inserted, deleted, moved, and inline changes as visual decorations. Live and snapshot modes.
Maintainers
Readme
tiptap-diff-utility
A TipTap extension that compares two versions of a ProseMirror document and renders the differences as visual decorations: inserted blocks highlighted green, deleted content shown inline in red, moved blocks marked, and inline text changes highlighted at word or character level.
Works in two modes:
- Live mode (
DiffExtension) — plugin-based, tracks typing in real time against a baseline snapshot. - Snapshot mode (
diffSnapshot) — pure function, compares two frozen documents and returns aDecorationSetfor read-only viewers.
Installation
npm install @gaurussel/tiptap-diff-utilityPeer dependencies: @tiptap/core, @tiptap/pm
Live mode — DiffExtension
The extension snapshots the document on mount and diffs every subsequent edit against that baseline.
import { DiffExtension } from "@gaurussel/tiptap-diff-utility";
import StarterKit from "@tiptap/starter-kit";
const editor = new Editor({
extensions: [StarterKit, DiffExtension],
content: myDocument,
});Options
DiffExtension.configure({
sensitivity: "word", // "word" | "character" | "hybrid"
minMatchLength: 3, // equal-run length below which adjacent changes are merged
debounceMs: 300, // ms to wait after last keystroke; 0 = synchronous
enabled: true, // start enabled or disabled
})| Option | Default | Description |
| ---------------- | -------- | ------------------------------------------------------------------------------------ |
| sensitivity | "word" | Inline diff granularity. "word" diffs by word; "character" diffs by character; "hybrid" diffs by word but reports small edits down to the letter. See Inline granularity. |
| hybridWordRatio | 0.5 | "hybrid" only. Fraction of a word's characters that must change before the whole word is highlighted rather than just the changed letters. |
| hybridMinWordLength | 4 | "hybrid" only. Words at or below this length are always highlighted whole. |
| minMatchLength | 3 | Equal spans shorter than this are absorbed into surrounding changes to reduce noise. |
| minSimilarity | 0.3 | Minimum Jaccard word-similarity to pair two blocks as a modified change. |
| stopWordMinBlocks | 16 | Candidate blocks for pairing are found through shared words; a word shared by more than 20% of the unmatched new blocks is skipped as a stop word, but only once it appears in more than this many blocks. |
| cutDownMinWords | 4 | A paragraph cut down in place still pairs as modified when its shorter side has at least this many words… |
| cutDownMinKept | 0.8 | …and at least this share of them is found on the other side. Such a pair ranks below any regular match. |
| normalize | — | Applied to both documents before diffing, for state the editor derives and the saved form does not keep. Must not change node sizes. |
| ignoreMarks | [] | Mark type names stripped from both documents before diffing, for annotations that are not content (e.g. comments). Returned nodes come without them. |
| debounceMs | 300 | Recompute delay after typing. Set to 0 for programmatic updates. |
| enabled | true | Whether decorations are active. |
| mode | "unified" | Which side of the diff to render. "unified" shows everything in one editor; "additions" / "deletions" drive a two-pane side-by-side view. See below. |
| baseline | — | Document to diff against. Defaults to the editor content at mount time. See below. |
| react | — | React renderers for deleted nodes, keyed by node type name. See below. |
Baseline
By default the editor snapshots its own content on mount and diffs every later edit
against that snapshot. To compare against a specific document instead, pass baseline:
DiffExtension.configure({
baseline: originalDoc, // JSONContent
})Change it at runtime — for example when the user picks a different version to compare against:
editor.commands.setDiffBaseline(anotherVersion);Rendering deleted nodes
By default, a deleted node is rendered with the document schema's own serialization
(each node type's renderHTML / toDOM), so it looks the same when deleted as it does
live. It's wrapped in an element carrying the diff-deleted-block class for styling.
To render specific node types with React, pass a react map keyed by node type name.
The deleted node's attrs are passed straight through as props, and rendering uses
TipTap's ReactRenderer:
DiffExtension.configure({
react: {
image: (attrs) => <img src={attrs.src} alt={attrs.alt} className="diff-deleted-block" />,
callout: (attrs, content) => <Callout {...attrs}>{content}</Callout>,
},
})A node with children gets them as the second argument: its deleted content, rendered the
same way as any other deletion, ready to be placed inside the component. Leaf nodes get
undefined.
Node types without an entry fall back to the default schema serialization. The react
option requires react and react-dom (optional peer dependencies) and works only in
live mode — the pure diffSnapshot function has no editor and instead accepts a
renderDeletedNode callback that returns an HTMLElement.
Runtime toggle
editor.commands.setDiffEnabled(false); // clear decorations
editor.commands.setDiffEnabled(true); // rebuild against baselineSide-by-side mode
By default (mode: "unified") everything is rendered in a single editor: insertions
inline, deletions as in-place widgets showing the removed content. The mode option
splits that into two panes instead — one showing only what was added, the other only
what was removed:
| mode | Renders |
| ------------- | ----------------------------------------------------------------------- |
| "unified" | Everything in one editor (insertions inline, deletions as widgets). |
| "additions" | Only content that exists in the current document — inserted/modified/moved blocks and the inserted parts of modified blocks. Deletions are hidden. |
| "deletions" | The mirror image, for the pane that shows the old version. See below. |
To build a two-pane view, mount two editors. The right pane holds the current
document and diffs against the baseline with mode: "additions". The left pane is
the trick: it holds the baseline document and diffs against the current one
(an inverted diff), so removed content shows up as real, highlightable content in
that editor. mode: "deletions" applies the additions filter and re-styles those
highlights with deletion classes (red) so the pane reads as "removed content".
| | Left pane (removals) | Right pane (additions) |
| ------------ | ------------------------- | ---------------------- |
| content | baseline | current |
| baseline | current (inverted) | baseline |
| mode | "deletions" | "additions" |
| editable | false | true |
// Left: baseline document, shows removals in red
const left = useEditor({
editable: false,
extensions: [StarterKit, DiffExtension.configure({ mode: "deletions", baseline: currentDoc })],
content: baselineDoc,
});
// Right: current document, shows additions in green
const right = useEditor({
extensions: [StarterKit, DiffExtension.configure({ mode: "additions", baseline: baselineDoc })],
content: currentDoc,
});The "deletions" pane maps insertion classes to deletion classes, so make sure your
stylesheet defines .diff-deleted (block-level removals) alongside the usual classes:
.diff-deleted { background: rgba(220, 50, 50, 0.15); }A
movedblock is highlighted independently in each pane, so the two panes may mark different blocks as moved — a consequence of the inverted diff matching moves on its own side.
Reading changes from storage
Every time the diff is recomputed, the extension publishes the current change set
to editor.storage.diff.changes. Each entry is a range in the current document,
which is convenient for driving your own UI — for example VSCode-style change markers
in a gutter — independently of the built-in decorations.
interface DiffChange {
type: "inserted" | "deleted" | "moved" | "modified";
from: number; // absolute PM position of the range start in the current doc
to: number; // absolute PM position of the range end in the current doc
nodeA?: ProseMirrorNode; // the original node, from the baseline doc
posA?: number; // absolute PM position of nodeA in the baseline doc
nodeB?: ProseMirrorNode; // the node as it appears in the current doc
posB?: number; // absolute PM position of nodeB in the current doc (=== from)
}
const changes = editor.storage.diff.changes; // DiffChange[]- For
inserted/modified/moved,from–tospan the changed block. - For
deleted, the block no longer exists in the current doc, sofrom === tomarks the boundary where the deleted content used to be (draw a thin caret there, as VSCode does for pure deletions). - Entries are clamped to the document and sorted by
from.
Both sides of each change are published alongside the range, so you can inspect the
actual content — text, attrs, marks — without walking the documents yourself. Which
side is present follows from the change type:
| type | nodeA / posA | nodeB / posB |
| --- | --- | --- |
| inserted | — | ✓ |
| deleted | ✓ | — |
| modified | ✓ | ✓ |
| moved | ✓ | ✓ |
for (const change of editor.storage.diff.changes) {
if (change.type === "deleted") console.log("removed:", change.nodeA?.textContent);
if (change.type === "modified") console.log(change.nodeA?.textContent, "→", change.nodeB?.textContent);
}In
"deletions"mode the diff is inverted — the pane holds the baseline document and the current version is passed as thebaselineoption — so there the A side is the newer document and the B side is the pane's own doc.
The array is refreshed on every rebuild (after debounceMs) and cleared when diffs
are disabled or the baseline changes while disabled. A minimal gutter driver:
for (const change of editor.storage.diff.changes) {
const { top } = editor.view.coordsAtPos(change.from);
renderGutterMarker(top, change.type); // color the line by change.type
}Snapshot mode — diffSnapshot
Pure function, no plugin state. Compare two document versions and get a DecorationSet back.
import { diffSnapshot } from "@gaurussel/tiptap-diff-utility";
import { getSchema } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
const schema = getSchema([StarterKit]);
const decorations = diffSnapshot(docA, docB, schema, {
sensitivity: "word",
minMatchLength: 3,
});
// Use with a read-only editor:
const editor = new Editor({
extensions: [StarterKit],
content: docB,
editable: false,
});
// Apply decorations via a custom plugin or pass them to EditorView directly.Both docA and docB can be JSONContent or a pre-parsed ProseMirror Node.
diffSnapshot(jsonA, jsonB, schema) // JSONContent on both sides
diffSnapshot(nodeA, nodeB, schema) // PM Node on both sides
diffSnapshot(nodeA, jsonB, schema) // mixeddiffSnapshot accepts all the same diff options as DiffExtension, plus a renderDeletedNode callback for custom deleted-block rendering.
CSS classes
Add these to your stylesheet:
/* Inserted block */
.diff-inserted { background: rgba(0, 200, 100, 0.15); }
/* Deleted block widget (unified mode) */
.diff-deleted-block {
background: rgba(220, 50, 50, 0.15);
text-decoration: line-through;
color: #c00;
}
/* Deleted block highlight (side-by-side "deletions" pane) */
.diff-deleted { background: rgba(220, 50, 50, 0.15); }
/* Moved block */
.diff-moved { background: rgba(100, 150, 255, 0.15); }
/* Inline inserted text */
.diff-inline-inserted { background: rgba(0, 200, 100, 0.25); }
/* Inline deleted text widget */
.diff-inline-deleted {
background: rgba(220, 50, 50, 0.2);
text-decoration: line-through;
color: #c00;
}
/* Inline replaced text */
.diff-inline-replaced { background: rgba(255, 180, 0, 0.25); }
/* Mark changed (same text, different formatting) */
.diff-mark-changed { background: rgba(150, 100, 255, 0.2); }
/* Attribute change (e.g. heading level) */
.diff-attr-changed { outline: 2px solid rgba(100, 150, 255, 0.5); }Table support
Pass table extensions in the schema and the diff handles rows and cells correctly:
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableCell } from "@tiptap/extension-table-cell";
import { TableHeader } from "@tiptap/extension-table-header";
const schema = getSchema([StarterKit, Table, TableRow, TableCell, TableHeader]);- Diff runs at row granularity — a changed cell produces an inline decoration on its row, not a separate block change.
- Deleted table sub-nodes (rows, cells, headers) are serialized through the schema and rendered as bare
<tr>/<td>/<th>widgets placed inside the surrounding table, so the DOM stays valid inside<tbody>. A whole deleted table renders as its own<table>. - In snapshot mode,
renderDeletedNodelets you control exactly what the deleted-row widget looks like:
diffSnapshot(docA, docB, schema, {
renderDeletedNode: (node) => {
const tr = document.createElement("tr");
tr.className = "diff-deleted-row";
node.forEach((cell) => {
const td = document.createElement("td");
td.textContent = cell.textContent;
tr.appendChild(td);
});
return tr;
},
})How the diff works
Two-pass alignment
The alignment key used by LCS excludes marks and attrs — only node type name and text content. This means a bold-toggle or heading-level change is never treated as a delete + insert at block level. Marks and attrs are detected in a separate second pass.
Block-level diff (four steps)
Step 1 — LCS. Each block is fingerprinted by type and text content. The LCS of fingerprints is computed with patience sort + LIS (O(n log n)). Blocks in the LCS are equal — same content, same relative order — and get no decoration.
Step 2 — Exact moves. Unmatched blocks whose fingerprint appears unchanged on the other side are moved.
Step 3 — Similarity pairing. Remaining unmatched blocks of the same node type are paired by Jaccard word-similarity (shared words ÷ union of all words). Best matches first. Blocks with no suitable counterpart become inserted or deleted.
Step 4 — Position check. For each similarity pair, a bracket + LIS check determines whether the block stayed in order relative to surrounding equal anchors. If it stayed in place → modified (inline highlights only). If it shifted → moved with inline highlights.
Inline diff
For modified and moved+modified blocks, text is tokenized word-by-word (default) or character-by-character and diffed. The pipeline:
- Raw LCS diff over units.
- Mark-change detection — adjacent delete + insert with identical bare text (different marks) →
mark_changetype instead of a delete/insert pair. - Open/close balancing — if a change covers a
node_openwithout its matchingnode_close, the range is expanded to include the close. - Simplification — equal runs shorter than
minMatchLengthare absorbed into surrounding changes. - Narrowing (
"hybrid"only) — each changed word is re-diffed at character level, and the letter-level result is kept when the edit is small. Runs after simplification, where a word swap has been folded into the singlereplacethat carries text on both sides.
Inline granularity
sensitivity controls how finely inline changes are reported.
| Mode | коллекция → коллекции | world → planet |
| --- | --- | --- |
| "word" | whole word highlighted | whole word highlighted |
| "character" | я → и | wor → p, d → anet (split on the shared l) |
| "hybrid" | я → и | whole word highlighted |
"word" is the default: cheap and stable, but a one-letter fix repaints the entire
word. A word is a run of letters, digits and _; every punctuation character and
every whitespace character is a token of its own, so . → .under. is an insertion of
under. and read-only → read-nly repaints only only. "character" is precise but reports edits the way the algorithm sees them —
incidental shared letters fragment a rewritten word into pieces that read as noise.
"hybrid" aligns on words, then re-diffs each changed word at character level and keeps
the narrow result only when the edit is small. hybridWordRatio sets what counts as
small (the fraction of the word's characters that changed); hybridMinWordLength keeps
short words whole, where a single letter is already a large share of the word. Setting
hybridWordRatio to 0 reproduces "word"; setting it above 1 reproduces
"character".
Because "hybrid" aligns on words, the character pass only ever runs inside a single
changed word, and it costs roughly the same as "word" — see
Benchmark results.
Unicode-aware word segmentation
Words are segmented with /[\p{L}\p{N}]+[^\p{L}\p{N}]*|[^\p{L}\p{N}]+/gu — Unicode letters and digits form word units; punctuation and whitespace are separate units. Works correctly across Latin, Cyrillic, CJK, and other scripts.
Standalone diff engine
The diff engine is exported independently of the TipTap extension:
import { Diff, type BlockChange } from "@gaurussel/tiptap-diff-utility";
const changes: BlockChange[] = new Diff(docA, docB, schema, schema)
.diff({ sensitivity: "word", minMatchLength: 3 });BlockChange
interface BlockChange {
type: "inserted" | "deleted" | "moved" | "modified";
nodeB?: Node; // node as it appears in docB
posB?: number; // absolute PM position in docB
nodeA?: Node; // original node from docA
posA?: number; // absolute PM position in docA
containerNodeA?: Node; // parent container in docA (e.g. table for a deleted row)
containerNodeB?: Node; // parent container in docB (e.g. table for an inserted row)
insertBeforePos?: number; // position in docB where a deleted-block widget should go
inlineChanges?: InlineChange[];
}InlineChange
interface InlineChange {
type: "insert" | "delete" | "replace" | "attr_change" | "mark_change";
fromA: number; toA: number; // PM positions relative to nodeA start
fromB: number; toB: number; // PM positions relative to nodeB start
structuralOnly?: boolean; // change covers only node boundaries, no text
}Op — typed LCS output
The LCS function is also exported if you need the raw alignment:
import { lcs, type Op } from "@gaurussel/tiptap-diff-utility";
const ops: Op[] = lcs(keysA, keysB);
// Op: { type: "equal", aIndex, bIndex }
// | { type: "delete", aIndex }
// | { type: "insert", bIndex }Benchmark results
Run with npm run bench. word and hybrid are measured by default; add character
with BENCH_MODES=word,character,hybrid, and narrow a run with BENCH_SIZES=small or
BENCH_SCENARIOS=light,heavy. All modes in a run diff the same generated documents.
| size | blocks | words | scenario | sensitivity | changes | median | p95 | mean | ops/s | | ---- | ------ | ----- | -------- | ----------- | ------- | ------ | --- | ---- | ----- | | small | 20 | 1066 | light | word | 11 | 0.797ms | 0.975ms | 0.822ms | 1217 | | small | 20 | 1066 | light | hybrid | 11 | 0.853ms | 0.954ms | 0.866ms | 1154 | | small | 20 | 1066 | medium | word | 43 | 2.183ms | 2.369ms | 2.226ms | 449 | | small | 20 | 1066 | medium | hybrid | 43 | 2.673ms | 2.913ms | 2.716ms | 368 | | small | 20 | 1066 | heavy | word | 50 | 3.843ms | 4.056ms | 3.913ms | 256 | | small | 20 | 1066 | heavy | hybrid | 50 | 4.888ms | 5.089ms | 4.904ms | 204 | | medium | 200 | 7931 | light | word | 79 | 7.785ms | 8.477ms | 8.146ms | 123 | | medium | 200 | 7931 | light | hybrid | 79 | 8.337ms | 8.667ms | 8.359ms | 120 | | medium | 200 | 7931 | medium | word | 261 | 26.175ms | 28.602ms | 26.400ms | 38 | | medium | 200 | 7931 | medium | hybrid | 261 | 29.782ms | 30.265ms | 29.767ms | 34 | | medium | 200 | 7931 | heavy | word | 306 | 40.049ms | 41.375ms | 40.166ms | 25 | | medium | 200 | 7931 | heavy | hybrid | 306 | 47.255ms | 48.264ms | 47.306ms | 21 | | huge | 2000 | 77170 | light | word | 677 | 247.098ms | 249.574ms | 246.906ms | 4 | | huge | 2000 | 77170 | light | hybrid | 677 | 259.058ms | 268.760ms | 257.028ms | 4 | | huge | 2000 | 77170 | medium | word | 2364 | 1588.737ms | 1596.596ms | 1589.059ms | 1 | | huge | 2000 | 77170 | medium | hybrid | 2364 | 1625.850ms | 1644.447ms | 1625.433ms | 1 | | huge | 2000 | 77170 | heavy | word | 2854 | 1731.715ms | 1735.574ms | 1710.414ms | 1 | | huge | 2000 | 77170 | heavy | hybrid | 2854 | 1747.159ms | 1780.403ms | 1750.673ms | 1 |
hybrid costs 1.02–1.24x of word — most on small documents where the whole diff is
already sub-millisecond, and least on the large ones where it matters. For contrast,
"character" on small/heavy measures 21.6ms against word's 4.0ms (5.4x): it feeds
every character to a Myers diff, whose cost is O(N×D), while hybrid keeps alignment at
word level and only re-diffs inside a word that changed.
| Column | Meaning |
| ------ | ------- |
| size | A rough document-size category: small / medium / huge. This is just a row label. |
| blocks | Number of top-level blocks in the document, such as paragraphs, headings, and lists. This is the main scale parameter: 20 / 200 / 2000. |
| words | Total word count in document version A. This gives a sense of text volume: 1066 / 7931 / 77170 words. |
| scenario | The type of edits between versions A and B: light means roughly 1% of words changed, medium roughly 10%, and heavy roughly 30% plus block reordering or deletion. Heavier scenarios give the diff engine more work. |
| sensitivity | Inline granularity for the run — see Inline granularity. Rows sharing a size and scenario diff identical documents, so their times compare directly. changes is the same across modes: granularity changes the detail within a change, not how many blocks changed. |
| changes | Number of changes (BlockChange) returned by the engine for this case: the actual size of the diff result. It grows with the scenario weight. |
| median | Median time for one Diff.diff() call (50th percentile). This is the most honest typical value: half of runs are faster, half are slower. It is robust against outliers. |
| p95 | 95th percentile: the time within which 95% of runs complete. This shows the reasonable worst case, or distribution tail, under unlucky GC or scheduler timing. |
| mean | Arithmetic mean across all iterations. If mean is noticeably higher than median, heavy outliers are pulling the average up. Here mean is close to median, so the distribution is steady. |
| ops/s | Operations, meaning diffs, per second: 1000 / mean. This is the inverse of runtime and is useful for throughput. Values are rounded, so huge shows 1 and 4 even though real values may be fractional, for example 0.65 and 4. |
Container-aware decorations
Lists and tables are decorated as a unit when all children change:
- If all rows in a table are inserted or deleted, the whole table gets a single decoration.
- If only some rows changed, individual rows are decorated.
- Same logic applies to
bulletListandorderedListwith theirlistItemchildren.
Changelog
1.3.6
- A deleted table column is drawn as cells of its own. Whole deleted cells of a modified row
were rendered inline at the end of the neighbouring cell's paragraph, so they inherited the
table's borders and padding. They now go between the row's cells as widgets: the serialized
cell with the class
diff-deleted-cell(and withoutdiff-deleted-block). - New option
normalizeruns on both documents before diffing. Use it for state the editor derives on its own, such as a column width that a table plugin copies into every cell.
1.3.5
- Deletions come before insertions in the same gap. A deleted block was anchored to the next
matched block, so a paragraph typed where another one had been removed showed up above the
deletion.
insertBeforePosnow points at the first inserted block of the gap, as in a line diff; a gap at the end of a document or container is handled the same way. - Indenting or outdenting a list item is one structural change. Blocks are matched one level
at a time, so moving item 9 into item 8 reported item 8 as replaced and item 9 as deleted
although no text changed. A run of blocks whose text blocks, in order, equal those of one block
on the other side is now reported as
modifiedwith a structural-onlyattr_change. ItsnodeAis the old run wrapped in the parent type (e.g. anorderedList), so it never has the type ofnodeBand cannot be mistaken for a same-shape version to restore. - New option
ignoreMarksstrips the listed mark types from both documents before diffing.
1.3.4
- Punctuation is its own token in
"word"mode. Words were whatever lay between spaces, so text typed right after a period (backlog/.→backlog/.under.) replaced the period instead of being inserted after it, and a fix insideread-onlyrepainted the whole hyphenated word. Words are now runs of letters, digits and_; each other character is a separate token.
1.3.3
- New options
stopWordMinBlocks,cutDownMinWords,cutDownMinKepttune the two pairing rules below; the defaults are the values described there. - Repeated sections keep their pairs while one of them is edited. Candidate blocks were
gathered through words that appear in at most 20% of the unmatched new blocks, so in a small
article every shared word counted as a stop word and three copies of the same paragraph
found one candidate between them: typing into a neighbouring paragraph turned two of them
into "deleted + inserted". A word is now a stop word only when it also appears in more than
stopWordMinBlocks(16) blocks. - Formatting on part of a word no longer splits it for pairing. Block text for word similarity joined text nodes with a space, so a link with one bold letter inside became three words and no longer matched its old version. Inline nodes are now joined as they read, and only blocks and inline leaves (hard breaks, images) separate words.
- A paragraph cut down in place stays
modified. When most of a paragraph is deleted, the rest shares too few words with the old version for Jaccard similarity, and the paragraph came back as "deleted + inserted". A leaf block whose smaller side has at leastcutDownMinWords(4) words, at leastcutDownMinKept(80%) of them present on the other side, is now paired at theminSimilarityscore, below any regular match. - A formatting change is not absorbed into a nearby text edit. A letter typed one space
before a word that gained a mark merged both into one replacement across the two words, and
the mark change disappeared once
hybridnarrowed it back. Short gaps next to amark_changeare no longer absorbed.
1.3.2
- A word removed between two spaces reads as a deletion. Word-level alignment kept runs
of whitespace as one token, so removing "an" from "to an epic" and leaving two spaces
compared " an " against " " and came back as a replacement with the spaces highlighted.
Each whitespace character is now its own token, and the change is a plain
delete. - Letters typed into neighbouring words stay separate insertions in
hybridmode. Two small edits one space apart were absorbed into one replacement across both words, so typing a letter at the end of "views" and after "(" struck out "views (". A multi-word replacement whose separators are unchanged is now narrowed word by word; a word that only gained or lost text at one end is narrowed to that text regardless of its length.
1.3.1
- Edited blocks that stayed in place are no longer reported as moved. Candidate pairs of
edited blocks were ordered by similarity before the order check, so when a later pair
scored higher than an earlier one, one of two blocks that kept their order came back as
moved. Since 1.3.0 reports moved containers, two unrelated edits in an article could show a list or a paragraph as moved. Pairs are now ordered by their position in the old document before the check. - A deletion inside a marked run stays inside it. When the text on both sides of an
inline deletion shares a mark (inline code, bold), the deletion widget is placed inside
that mark instead of splitting the run, and the deleted text is not wrapped in its own copy
of the mark. Deleting one character from
2026-q3shows one code span with the character struck through, not three spans.
1.3.0
The same pair of documents can now yield more changes than before: each entry below reports something the diff used to miss.
- Attribute-only changes are reported. A heading level, an image
srcor a linkhrefchanged without touching the text used to produce no change at all: the alignment hash ignores attrs and marks, and the inline pass keys marks by type name only. Hash-equal pairs are now compared withNode.eq; a leaf block that differs only in attrs comes back asmodifiedwith a single whole-blockattr_change, and a container whose own attrs changed (say,orderedList.start) does too when none of its children did. - A table row with a rewritten cell is paired with its old version. When a cell's text is
replaced wholesale, the row shares too few words with its old version to pass
minSimilarityand used to read asdeleted+inserted. Rows left over between the same anchors are now paired asmodifiedwhen they keep at least one cell unchanged; rows with nothing in common still come back as a deletion and an insertion. - A moved container keeps its move when its content changed too. A list that moved and had
an item edited was paired as
moved, but recursing into it dropped the move and only the item edit came out. The container'smovedchange is now emitted (withoutinlineChanges) next to the changes found inside it.
1.2.8
- A React renderer receives the deleted node's content. Entries of the
reactmap are called as(attrs, content): for a node with children,contentis its deleted content, rendered like any other deletion, so a container such as a callout can be drawn with its own frame and its text inside. Renderers that take onlyattrskeep working. - A deleted inline node keeps its React rendering once the editor mounts. After mount the
plugin replaces the renderer built during
state.init, whose roots never rendered. Widgets kept their keys, so ProseMirror reused the old inline deletion span while the old roots were torn down, and the deleted node vanished from it — only surrounding deleted text was left. Widget keys now carry the renderer that built them, so a new renderer builds new widgets.
1.2.7
- Deleted text is drawn where it stood when kept text separates it from the insertion.
Rewriting "old kept" into "kept new" produced one
replacewhose baseline range ended before "kept" and whose current range started after it, so the deleted widget landed after the kept word. A delete and an insert are now paired intoreplace/attr_changeonly when they touch; otherwise they stay a separatedeleteandinsert, and the deletion renders before "kept". Consumers readinginlineChangesdirectly now get two entries in this case instead of onereplace; the ranges of each entry are unchanged and point at the right side of the kept text.
1.2.6
editor.storage.diff.changesnow carries the nodes behind each change, not just its range. Every entry gainednodeA/posA(the baseline side) andnodeB/posB(the current side), so a consumer driving its own UI can read the content,attrsand marks of what changed — and where it lived in the baseline — without re-walking both documents. Which side is present follows the change type: insertions have only B, deletions only A, modified/moved both. The existingtype/from/tofields are unchanged.
1.2.5
- Documents that repeat a block no longer report untouched content as deleted or moved. The block alignment claimed to compute an LCS but matched each block in A to the first still-unused identical block in B, which with duplicates matches backwards and forces the following pass to drop legitimate pairs. Inserting one section into a changelog whose subsection headings repeat in every release was enough: the new section showed up as inserted, and blocks next to it that were never edited showed up as deleted plus reinserted. Alignment is now a real LCS (Hunt–Szymanski), so a pure insertion reports only insertions.
1.2.4
- Removing one item from a nested list now shows a single deletion instead of the whole nested list being reported as deleted and reinserted. Container blocks (nested lists) are now paired by an overlap coefficient rather than Jaccard, so removing a child no longer breaks the match; leaf blocks keep their existing scoring.
- Deleted-block widgets now carry a
node-<nodeName>class. The rendered element for a deleted block gets a class based on its node type (e.g.node-paragraph,node-heading), mirroring TipTap's node-view convention, so you can target deleted blocks by their node type in CSS or scripts. The class is applied alongsidediff-deleted-blockon every render path — the schema's DOM serialization, nodes mounted through areactrenderer, and the serialized shell wrapping nodes that have React-rendered descendants.
1.2.2
- Formatting a word and editing it in the same revision now reports both changes.
Bolding a word showed the mark-change highlight, but deleting a letter from that same
word made the highlight disappear and left only the deleted character — the restyling
was lost from the diff. A delete/insert pair whose two sides are mostly the same text
is now reported as a mark-change over the text that survived, plus the characters that
actually changed. Applies to all three
sensitivitymodes. Unchanged text and plain text edits are unaffected, and the benchmarks show no cost.
1.2.1
- Nodes rendered by React node views (tables) no longer vanish from the document.
Destroying a widget's React root ran synchronously inside ProseMirror's
updateState, so theDOMObserverread the unmount as an edit and re-parsed the document from the DOM — dropping nodes whose DOM doesn't round-trip, which hosts then saved to disk. React roots are now released after the update cycle. ThesetTimeoutinrelease()is load-bearing.
1.2.0
- Deleted text now appears where it was deleted, not a word later. Removing a span
from the middle of a paragraph anchored its strikethrough widget at the end of the
first surviving word rather than the start, so the deleted text rendered after that
word and visibly tore it away from the sentence around it. The empty range that marks
a deletion now resolves to the following unit's start; the previous step-back to the
preceding unit's end is kept only for zero-width
node_closeunits, which are not valid widget targets and would otherwise push the widget outside its node. - One-character edits are no longer misreported as attribute changes. A change was
treated as structural when it spanned at most one position per side — indistinguishable
from replacing a single letter, which decorated the whole block with
diff-attr-changedinstead of highlighting the character. Changes now carry whether they cover only structural tokens, decided where the tokens are still known, andattr_changeis classified from that rather than from range width. This was invisible at"word"granularity, where single-character changes are rare, and affects every letter edit at"character". - New
"hybrid"inline granularity. Diffs by word, then re-diffs each changed word at character level and keeps the letter-level result when the edit is small — a typo fix reports the changed letter, a rewritten word reports the word. Tuned withhybridWordRatio(default0.5) andhybridMinWordLength(default4); see Inline granularity. Because alignment stays at word level, the character pass only runs inside one changed word, so the mode costs 1.02–1.24x of"word"rather than the ~5x that diffing a whole block by character costs — see Benchmark results.sensitivitystill defaults to"word"; existing output is unchanged.
1.1.4
- Deletion widgets are reused across rebuilds instead of remounted. The diff is
recomputed on every document change, but deletion widgets whose position and node
content are unchanged now keep their existing DOM (and any React root inside it)
rather than being torn down and rebuilt each time. This relies on ProseMirror's
native widget reuse: widgets carry a stable
key, their DOM is built lazily via atoDOMfactory (so PM only builds the ones it actually mounts), and React roots are released through the widget'sdestroycallback only when a widget truly disappears. The result is no flicker, no wasted re-rendering, and preserved component state in dynamic React deletion renderers on every keystroke. A new "Dynamic block" demo page shows a deleted React block whose live timer and click count survive edits elsewhere. reactrenderers may be rebuilt on every render. Passing a freshly built renderer map — the normal pattern when the render functions close over changing props such as loading state or a request context — now updates already-mounted deletion widgets in place: they re-render with the newest function rather than being stranded with the one they were created with (and without being remounted, so reuse still holds).- Block deletion widgets are wrapped in a
div, not aspan. A React renderer for a block node returns block markup, and the widget wrapper was always aspan— invalid HTML, which browsers repair by splitting the span apart, breaking both the layout and the widget's DOM. The wrapper now matches the node's own level (divfor block nodes,spanfor inline ones).
1.1.2
- Block nodes removed from a text container diff as blocks. A list item (or any container holding a paragraph next to a block node such as an image or horizontal rule) is now recognised as a container. Removing that block node emits a block-level deletion whose widget sits between blocks, instead of being pushed into the sibling paragraph's inline content — which produced an empty widget in an invalid, schema-violating position.
1.1.1
- React renderers now run for nested and initial deletions. Three fixes to the
reactdeleted-node renderers (Rendering deleted nodes):- Deletions present at editor mount (before any edit) no longer render as empty shells — React widgets built during plugin init are rebuilt once the editor's content component is ready, so initial removals appear without a stray edit.
- Deleted leaf nodes inside a modified block (e.g. an image removed from a list item) are now rendered instead of silently dropped by the inline deleted-node renderer.
- Deleting a whole container (e.g. a list item wrapping an image) now recurses into its children, so nested nodes still use their configured React renderer instead of falling back to plain DOM serialization.
1.1.0
- Side-by-side mode. New
modeoption ("unified"|"additions"|"deletions") lets you render the diff across two editors — one showing only additions, one showing only removals — instead of a single unified editor."unified"remains the default, so existing setups are unaffected. See Side-by-side mode. Adds the.diff-deletedCSS class for block-level removals in the"deletions"pane.
1.0.5
- Guard the
baselineoption against receiving a bare fragment array. - Rename the inline
markChangetype to snake_case (mark_change).
1.0.4
- Clamp positions in the inline deleted-text renderer to stay within node bounds.
1.0.3
- Add a benchmark script (
npm run bench) and document benchmark results. - Improve the edit-similarity calculation in block pairing for better performance and accuracy.
1.0.1 – 1.0.2
- Initial published releases.
