npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 a DecorationSet for read-only viewers.

Installation

npm install @gaurussel/tiptap-diff-utility

Peer 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 baseline

Side-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 moved block 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–to span the changed block.
  • For deleted, the block no longer exists in the current doc, so from === to marks 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 the baseline option — 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)             // mixed

diffSnapshot 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, renderDeletedNode lets 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:

  1. Raw LCS diff over units.
  2. Mark-change detection — adjacent delete + insert with identical bare text (different marks) → mark_change type instead of a delete/insert pair.
  3. Open/close balancing — if a change covers a node_open without its matching node_close, the range is expanded to include the close.
  4. Simplification — equal runs shorter than minMatchLength are absorbed into surrounding changes.
  5. 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 single replace that 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 bulletList and orderedList with their listItem children.

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 without diff-deleted-block).
  • New option normalize runs 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. insertBeforePos now 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 modified with a structural-only attr_change. Its nodeA is the old run wrapped in the parent type (e.g. an orderedList), so it never has the type of nodeB and cannot be mistaken for a same-shape version to restore.
  • New option ignoreMarks strips 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 inside read-only repainted 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, cutDownMinKept tune 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 least cutDownMinWords (4) words, at least cutDownMinKept (80%) of them present on the other side, is now paired at the minSimilarity score, 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 hybrid narrowed it back. Short gaps next to a mark_change are 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 hybrid mode. 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-q3 shows 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 src or a link href changed 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 with Node.eq; a leaf block that differs only in attrs comes back as modified with a single whole-block attr_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 minSimilarity and used to read as deleted + inserted. Rows left over between the same anchors are now paired as modified when 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's moved change is now emitted (without inlineChanges) next to the changes found inside it.

1.2.8

  • A React renderer receives the deleted node's content. Entries of the react map are called as (attrs, content): for a node with children, content is 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 only attrs keep 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 replace whose 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 into replace/attr_change only when they touch; otherwise they stay a separate delete and insert, and the deletion renders before "kept". Consumers reading inlineChanges directly now get two entries in this case instead of one replace; the ranges of each entry are unchanged and point at the right side of the kept text.

1.2.6

  • editor.storage.diff.changes now carries the nodes behind each change, not just its range. Every entry gained nodeA/posA (the baseline side) and nodeB/posB (the current side), so a consumer driving its own UI can read the content, attrs and 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 existing type/from/to fields 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 alongside diff-deleted-block on every render path — the schema's DOM serialization, nodes mounted through a react renderer, 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 sensitivity modes. 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 the DOMObserver read 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. The setTimeout in release() 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_close units, 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-changed instead of highlighting the character. Changes now carry whether they cover only structural tokens, decided where the tokens are still known, and attr_change is 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 with hybridWordRatio (default 0.5) and hybridMinWordLength (default 4); 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. sensitivity still 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 a toDOM factory (so PM only builds the ones it actually mounts), and React roots are released through the widget's destroy callback 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.
  • react renderers 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 a span. A React renderer for a block node returns block markup, and the widget wrapper was always a span — 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 (div for block nodes, span for 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 react deleted-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 mode option ("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-deleted CSS class for block-level removals in the "deletions" pane.

1.0.5

  • Guard the baseline option against receiving a bare fragment array.
  • Rename the inline markChange type 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.