@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
241 lines (188 loc) • 12.3 kB
Markdown
# `smart` diffType — design
> Status: implemented behind the `platform_editor_ai_smart_diff` feature gate (which itself
> requires the extended diff pipeline; see §8). This document describes the **final**
> block-first implementation, not the original spike.
## 1. Problem
`editor-plugin-show-diff` renders a visual diff from an original document plus the ProseMirror
`steps` that produced the new document. It already supports three `diffType`s, each with a
failure mode:
- **`inline`** — word/character precise. Too granular: a lightly-reworded paragraph becomes a
confetti of tiny highlights that is hard to read.
- **`block`** — whole top-level block. Too coarse: a one-word fix highlights the entire
paragraph.
- **`step`** — per ProseMirror step. Inherits both problems depending on step shape; a single
`ReplaceStep` whose slice spans multiple nodes is especially bad.
We want an **intelligent** diff that adapts granularity to *how much* changed at the sentence,
paragraph and node level.
## 2. Goal
Introduce a new `diffType: 'smart'` that, for each changed region, chooses the smallest
readable granularity:
- A couple of changed words in a sentence → **inline** word diff.
- Many changed words in a sentence → **whole-sentence** diff.
- A couple of changed sentences in a paragraph → **sentence** diffs inside the paragraph.
- Many changed sentences in a paragraph → **whole-paragraph** diff.
- Many changed children in a container (layout, table, list, panel, quote, …) → **node-level**
diff (old node shown deleted, new node shown inserted).
- A node whose *type/wrapping* changed (paragraph→heading, list→table, wrap in panel/quote/…) →
**node-level** diff.
Crucially, granularity must never **break document structure** (see §5).
## 3. Where it plugs in
The classifier is a pure `Change[] → Change[]` transform. It runs inside `getChanges` in
`calculateDiff/calculateDiffDecorations.ts`:
```ts
if (isExtendedEnabled(diffType)) {
if (diffType === 'smart' && fg('platform_editor_ai_smart_diff')) {
const changes = simplifyChanges(changeset.changes, tr.doc);
return classifySmartChanges({ changes, originalDoc, newDoc: tr.doc,
locale: intl.locale, thresholds: smartThresholds });
}
if (diffType === 'step') return diffBySteps(originalDoc, steps);
if (diffType === 'block') return groupChangesByBlock(changeset.changes, originalDoc, steppedDoc);
}
// otherwise inline:
return optimizeChanges(simplifyChanges(changeset.changes, tr.doc));
```
Key properties:
- **Input** is the same prosemirror-changeset `Change[]` (`simplifyChanges`) that `inline`
consumes. We do **not** re-diff — we re-*group* and re-*size* the existing changes.
- **Output** is a `Change[]` in exactly the same shape, so the entire downstream decoration
layer (inline highlights, block backgrounds, node/row deleted widgets, indicators) is reused
unchanged.
- If `platform_editor_ai_smart_diff` is off, `smart` **falls through to `inline`** so behaviour
degrades gracefully.
Each promoted change carries a `smartLevel` tag (`'sentence' | 'paragraph' | 'node'`) on its
span data, read downstream by `smartChangeLevel(change)` to drive node-level rendering choices
(skip inline highlight, place deleted content below the new content).
## 4. Algorithm — block-first (the key design decision)
The original spike was *change-first* (grouped by each raw change and walked outward). It
produced subtle position bugs — most visibly the "empty deleted list items" when a `bulletList`
became a `table` — because its ad-hoc block resolution did not match how the `block` diffType
groups. `block` handled these cases perfectly.
The final design is therefore **block-first, reusing the exact same grouping primitive as the
`block` diffType** (`getTopLevelBlockAt`). This makes smart's top-level grouping identical to
block's *by construction*.
Pipeline (`classifySmartChanges`):
1. **Group by top-level block** (`groupByTopLevelBlock`) using `getTopLevelBlockAt(docB, …)` and
`getTopLevelBlockAt(docA, …)`, keyed by the new-doc block start. Unlike `groupChangesByBlock`,
we keep each group's constituent raw changes so intra-block density can be measured.
2. **Classify each block group** (`classifyBlockGroup`):
- **Missing side** (block only in A or only in B) → whole-block change.
- **Type / wrapping change** (A block type ≠ B block type) → whole-block change. This is the
paragraph→heading, list→table, wrap-in-panel/quote case.
- **Textblock** (paragraph/heading on both sides) → `classifyTextblock` (§4.1).
- **Container** (layout, table, list, panel, expand, quote, …) → `classifyContainer` (§4.2).
3. **Merge overlaps** (`mergeOverlappingByNewDocRange`) and clamp to doc bounds.
### 4.1 Textblock — sentence vs paragraph vs inline
`classifyTextblock` segments both sides with `Intl.Segmenter` (graceful regex fallback — see §6),
treating inline non-text nodes as one opaque word that is **never** a sentence boundary.
- Compute `sentencesChanged / max(sentencesOld, sentencesNew)`.
- If `sentencesChanged >= paragraph.minChanged` **and** ratio `>= paragraph.ratio` →
**whole-paragraph** change (`smartLevel: 'paragraph'`).
- Otherwise, per changed sentence, compute `wordsChanged / max(wordsOld, wordsNew)`:
- `wordsChanged >= sentence.minChanged` **and** ratio `>= sentence.ratio` →
**whole-sentence** change (`smartLevel: 'sentence'`).
- else keep the raw **inline** change(s).
### 4.2 Container — recurse with escalation
`classifyContainer` measures density over **direct children**:
`changedChildren / max(childrenOld, childrenNew)`.
- If `>= node.ratio` (default 0.6) → **whole-node** change.
- Else, an alternative text-bearing trigger: if the fraction of text-bearing children
(paragraph/heading) that were themselves promoted to paragraph-level is `>= node.textBearingRatio`
→ **whole-node** change.
- Otherwise, recurse into each changed child (`classifyChild`), keeping the diff strictly inside.
Tables are special-cased (`classifyTable`): cell density is escalated to the **row**, and row
density to the **table** (see §5).
## 5. Structural integrity — the escalation rule
Some children are **structurally rigid**: deleting and re-inserting one on its own would break
the parent's structure. These are tracked in `RIGID_CHILD_TYPES`:
```
layoutColumn, tableCell, tableHeader
```
Rules (`classifyChild` / `classifyContainer` / `classifyTable`):
- **`layoutColumn`** — never a whole-block result on its own. A dense column does **not** become
"column deleted + column added" (that would break the `layoutSection`). Instead we either
promote the **whole `layoutSection`** (when overall section density ≥ `node.ratio`) or keep the
diff **inside** the column (recurse into its children).
- **`tableCell` / `tableHeader`** — same: a single dense cell never becomes a deleted+added cell.
Cell density escalates to the **`tableRow`** (a row *can* be shown deleted + re-inserted without
breaking the table), and row density escalates to the **whole `table`**. So even if one cell is
100% changed, if its row is < `node.ratio` changed overall, the diff stays inline inside the
cell.
- **`listItem`** — *not* rigid. A list item *can* be replaced wholesale (delete + re-insert at the
same position keeps the list valid), so it follows the normal container rule: promote the whole
item when dense, else recurse. And when ≥ `node.ratio` of items changed, the **whole list** is
promoted.
This is why the doc emphasises escalation over naive "promote the nearest container".
## 6. Configurable thresholds
`SmartDiffThresholds` (in `smart/thresholds.ts`) is passed from the `showDiff` command via
`smartThresholds` → plugin state → `calculateDiffDecorations` → `classifySmartChanges`.
Overrides are shallow-merged per level (`resolveThresholds`).
```ts
DEFAULT_SMART_THRESHOLDS = {
sentence: { ratio: 0.4, minChanged: 2 }, // unit = words (inline node = 1 word)
paragraph: { ratio: 0.4, minChanged: 2 }, // unit = sentences
node: { ratio: 0.6, textBearingRatio: 0.6 }, // unit = direct children
};
```
- **sentence** — numerator = words changed; denominator = `max(wordsOld, wordsNew)`;
`minChanged` = 2.
- **paragraph** — numerator = sentences changed; denominator = `max(sentencesOld, sentencesNew)`;
`minChanged` = 2.
- **node** — numerator = direct children changed; denominator = `max(childrenOld, childrenNew)`;
ratio = 0.6. The `textBearingRatio` alternative fires node-level when ≥ 60% of the text-bearing
children were themselves promoted to paragraph-level.
### Segmentation (`smart/segmentText.ts`)
- Primary: `Intl.Segmenter` for both sentence and word granularity (locale from `intl.locale`).
- **Graceful regex fallback** when `Intl.Segmenter` is unavailable: sentences split on terminator
punctuation, words on whitespace/word boundaries.
- Operates on a `(string | null)[]` char-view where `null` marks an **opaque inline token**
(date, mention, emoji, status, …). An inline token counts as exactly one word and is **never**
a sentence boundary.
## 7. Type/wrapping changes & attribute annotation
- A pure **attribute** change (same node type, same text) is annotated by the existing
`AttrStep`/`SetAttrsStep` path — not by smart.
- A **node-type or wrapping** change (paragraph→heading, list→table, wrap in panel/quote/
codeBlock) cannot be an attribute annotation, because the node types differ. Smart detects this
at the block-group level (A type ≠ B type) and emits a **whole-block** (node-level) change: old
node shown deleted, new node shown inserted.
## 8. Feature gating
`smart` requires **both** the extended diff pipeline behaviour **and** its own gate. The single
source of truth is `pm-plugins/isExtendedEnabled.ts`:
```ts
export const isExtendedEnabled = (diffType?: DiffType): boolean =>
expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true) ||
(diffType === 'smart' && fg('platform_editor_ai_smart_diff'));
```
Because the whole extended decoration pipeline was previously gated only by
`platform_editor_diff_plugin_extended`, `diffType` is **threaded through the entire decoration
layer** (inline/block/node/row decorations, `wrapBlockNodeView` and its helpers,
`decorationKeys`, `getScrollableDecorations`) so that every internal extended-gate check becomes
`isExtendedEnabled(diffType)`. This makes `smart` render the full extended shape even when
`platform_editor_diff_plugin_extended` is off — and only when `platform_editor_ai_smart_diff` is
on. Other diff types are unaffected (they pass `diffType` that is not `'smart'`, so the check
reduces to the extended gate alone).
## 9. Rendering specifics
- **Node- and paragraph-level** smart changes render the **new (purple) content on top** and the
**deleted (gray, strikethrough) content below** it (`placeBelow`), so the new content always
reads first.
- For **node-level** smart changes the redundant inline highlight is skipped (`isSmartNodeLevel`),
since the block/widget decorations already highlight the container.
## 10. Module layout
```
src/pm-plugins/
isExtendedEnabled.ts ← shared gate helper (diffType-aware)
calculateDiff/smart/
classifySmartChanges.ts ← block-first orchestrator (§4, §5)
thresholds.ts ← SmartDiffThresholds + defaults (§6)
segmentText.ts ← Intl.Segmenter + regex fallback (§6)
helpers.ts ← makePromotedChange, merge/overlap, smartLevel tag
```
## 11. Testing
- `segmentText` unit tests live in
`editor-plugin-show-diff-tests/src/__tests__/jest/segmentText.test.ts` and cover the
Intl.Segmenter path and the regex fallback (inline token = one word, never a sentence
boundary).
- Classifier behaviour is exercised end-to-end via the `examples/3-smart.tsx` gallery (scenarios
1a–C6) covering inline/sentence/paragraph promotion, list→table, table cells, layout columns,
panels, quotes, code blocks, and multi-step changes.
Run: `afm test unit packages/editor/editor-plugin-show-diff-tests/src/__tests__/jest/segmentText.test.ts --run-in-band`.