@atlaskit/editor-plugin-show-diff 10.1.22 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/cjs/pm-plugins/calculateDiff/calculateDiffDecorations.js +132 -31
  3. package/dist/cjs/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -2
  4. package/dist/cjs/pm-plugins/calculateDiff/smart/classifySmartChanges.js +841 -0
  5. package/dist/cjs/pm-plugins/calculateDiff/smart/helpers.js +135 -0
  6. package/dist/cjs/pm-plugins/calculateDiff/smart/segmentText.js +331 -0
  7. package/dist/cjs/pm-plugins/calculateDiff/smart/thresholds.js +46 -0
  8. package/dist/cjs/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
  9. package/dist/cjs/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
  10. package/dist/cjs/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
  11. package/dist/cjs/pm-plugins/decorations/createNodeChangedDecorationWidget.js +30 -17
  12. package/dist/cjs/pm-plugins/decorations/decorationKeys.js +8 -5
  13. package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -30
  14. package/dist/cjs/pm-plugins/getScrollableDecorations.js +6 -6
  15. package/dist/cjs/pm-plugins/isExtendedEnabled.js +20 -0
  16. package/dist/cjs/pm-plugins/main.js +21 -16
  17. package/dist/es2019/pm-plugins/calculateDiff/calculateDiffDecorations.js +119 -29
  18. package/dist/es2019/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -3
  19. package/dist/es2019/pm-plugins/calculateDiff/smart/classifySmartChanges.js +689 -0
  20. package/dist/es2019/pm-plugins/calculateDiff/smart/helpers.js +106 -0
  21. package/dist/es2019/pm-plugins/calculateDiff/smart/segmentText.js +283 -0
  22. package/dist/es2019/pm-plugins/calculateDiff/smart/thresholds.js +45 -0
  23. package/dist/es2019/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
  24. package/dist/es2019/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
  25. package/dist/es2019/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
  26. package/dist/es2019/pm-plugins/decorations/createNodeChangedDecorationWidget.js +31 -16
  27. package/dist/es2019/pm-plugins/decorations/decorationKeys.js +9 -5
  28. package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -34
  29. package/dist/es2019/pm-plugins/getScrollableDecorations.js +6 -6
  30. package/dist/es2019/pm-plugins/isExtendedEnabled.js +12 -0
  31. package/dist/es2019/pm-plugins/main.js +21 -16
  32. package/dist/esm/pm-plugins/calculateDiff/calculateDiffDecorations.js +132 -31
  33. package/dist/esm/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -3
  34. package/dist/esm/pm-plugins/calculateDiff/smart/classifySmartChanges.js +834 -0
  35. package/dist/esm/pm-plugins/calculateDiff/smart/helpers.js +128 -0
  36. package/dist/esm/pm-plugins/calculateDiff/smart/segmentText.js +325 -0
  37. package/dist/esm/pm-plugins/calculateDiff/smart/thresholds.js +39 -0
  38. package/dist/esm/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
  39. package/dist/esm/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
  40. package/dist/esm/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
  41. package/dist/esm/pm-plugins/decorations/createNodeChangedDecorationWidget.js +30 -17
  42. package/dist/esm/pm-plugins/decorations/decorationKeys.js +9 -5
  43. package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -30
  44. package/dist/esm/pm-plugins/getScrollableDecorations.js +6 -6
  45. package/dist/esm/pm-plugins/isExtendedEnabled.js +14 -0
  46. package/dist/esm/pm-plugins/main.js +21 -16
  47. package/dist/types/entry-points/show-diff-plugin-type.d.ts +1 -1
  48. package/dist/types/pm-plugins/calculateDiff/calculateDiffDecorations.d.ts +3 -1
  49. package/dist/types/pm-plugins/calculateDiff/groupChangesByBlock.d.ts +9 -0
  50. package/dist/types/pm-plugins/calculateDiff/smart/classifySmartChanges.d.ts +26 -0
  51. package/dist/types/pm-plugins/calculateDiff/smart/helpers.d.ts +44 -0
  52. package/dist/types/pm-plugins/calculateDiff/smart/segmentText.d.ts +32 -0
  53. package/dist/types/pm-plugins/calculateDiff/smart/thresholds.d.ts +44 -0
  54. package/dist/types/pm-plugins/decorations/createBlockChangedDecoration.d.ts +3 -2
  55. package/dist/types/pm-plugins/decorations/createChangedRowDecorationWidgets.d.ts +3 -2
  56. package/dist/types/pm-plugins/decorations/createInlineChangedDecoration.d.ts +3 -2
  57. package/dist/types/pm-plugins/decorations/createNodeChangedDecorationWidget.d.ts +4 -2
  58. package/dist/types/pm-plugins/decorations/decorationKeys.d.ts +5 -3
  59. package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeView.d.ts +6 -4
  60. package/dist/types/pm-plugins/getScrollableDecorations.d.ts +2 -1
  61. package/dist/types/pm-plugins/isExtendedEnabled.d.ts +11 -0
  62. package/dist/types/pm-plugins/main.d.ts +11 -1
  63. package/dist/types/showDiffPluginType.d.ts +20 -1
  64. package/docs/smart-diff-design.md +240 -0
  65. package/package.json +8 -3
@@ -0,0 +1,240 @@
1
+ # `smart` diffType — design
2
+
3
+ > Status: implemented behind the `platform_editor_ai_smart_diff` feature gate (which itself
4
+ > requires the extended diff pipeline; see §8). This document describes the **final**
5
+ > block-first implementation, not the original spike.
6
+
7
+ ## 1. Problem
8
+
9
+ `editor-plugin-show-diff` renders a visual diff from an original document plus the ProseMirror
10
+ `steps` that produced the new document. It already supports three `diffType`s, each with a
11
+ failure mode:
12
+
13
+ - **`inline`** — word/character precise. Too granular: a lightly-reworded paragraph becomes a
14
+ confetti of tiny highlights that is hard to read.
15
+ - **`block`** — whole top-level block. Too coarse: a one-word fix highlights the entire
16
+ paragraph.
17
+ - **`step`** — per ProseMirror step. Inherits both problems depending on step shape; a single
18
+ `ReplaceStep` whose slice spans multiple nodes is especially bad.
19
+
20
+ We want an **intelligent** diff that adapts granularity to *how much* changed at the sentence,
21
+ paragraph and node level.
22
+
23
+ ## 2. Goal
24
+
25
+ Introduce a new `diffType: 'smart'` that, for each changed region, chooses the smallest
26
+ readable granularity:
27
+
28
+ - A couple of changed words in a sentence → **inline** word diff.
29
+ - Many changed words in a sentence → **whole-sentence** diff.
30
+ - A couple of changed sentences in a paragraph → **sentence** diffs inside the paragraph.
31
+ - Many changed sentences in a paragraph → **whole-paragraph** diff.
32
+ - Many changed children in a container (layout, table, list, panel, quote, …) → **node-level**
33
+ diff (old node shown deleted, new node shown inserted).
34
+ - A node whose *type/wrapping* changed (paragraph→heading, list→table, wrap in panel/quote/…) →
35
+ **node-level** diff.
36
+
37
+ Crucially, granularity must never **break document structure** (see §5).
38
+
39
+ ## 3. Where it plugs in
40
+
41
+ The classifier is a pure `Change[] → Change[]` transform. It runs inside `getChanges` in
42
+ `calculateDiff/calculateDiffDecorations.ts`:
43
+
44
+ ```ts
45
+ if (isExtendedEnabled(diffType)) {
46
+ if (diffType === 'smart' && fg('platform_editor_ai_smart_diff')) {
47
+ const changes = simplifyChanges(changeset.changes, tr.doc);
48
+ return classifySmartChanges({ changes, originalDoc, newDoc: tr.doc,
49
+ locale: intl.locale, thresholds: smartThresholds });
50
+ }
51
+ if (diffType === 'step') return diffBySteps(originalDoc, steps);
52
+ if (diffType === 'block') return groupChangesByBlock(changeset.changes, originalDoc, steppedDoc);
53
+ }
54
+ // otherwise inline:
55
+ return optimizeChanges(simplifyChanges(changeset.changes, tr.doc));
56
+ ```
57
+
58
+ Key properties:
59
+
60
+ - **Input** is the same prosemirror-changeset `Change[]` (`simplifyChanges`) that `inline`
61
+ consumes. We do **not** re-diff — we re-*group* and re-*size* the existing changes.
62
+ - **Output** is a `Change[]` in exactly the same shape, so the entire downstream decoration
63
+ layer (inline highlights, block backgrounds, node/row deleted widgets, indicators) is reused
64
+ unchanged.
65
+ - If `platform_editor_ai_smart_diff` is off, `smart` **falls through to `inline`** so behaviour
66
+ degrades gracefully.
67
+
68
+ Each promoted change carries a `smartLevel` tag (`'sentence' | 'paragraph' | 'node'`) on its
69
+ span data, read downstream by `smartChangeLevel(change)` to drive node-level rendering choices
70
+ (skip inline highlight, place deleted content below the new content).
71
+
72
+ ## 4. Algorithm — block-first (the key design decision)
73
+
74
+ The original spike was *change-first* (grouped by each raw change and walked outward). It
75
+ produced subtle position bugs — most visibly the "empty deleted list items" when a `bulletList`
76
+ became a `table` — because its ad-hoc block resolution did not match how the `block` diffType
77
+ groups. `block` handled these cases perfectly.
78
+
79
+ The final design is therefore **block-first, reusing the exact same grouping primitive as the
80
+ `block` diffType** (`getTopLevelBlockAt`). This makes smart's top-level grouping identical to
81
+ block's *by construction*.
82
+
83
+ Pipeline (`classifySmartChanges`):
84
+
85
+ 1. **Group by top-level block** (`groupByTopLevelBlock`) using `getTopLevelBlockAt(docB, …)` and
86
+ `getTopLevelBlockAt(docA, …)`, keyed by the new-doc block start. Unlike `groupChangesByBlock`,
87
+ we keep each group's constituent raw changes so intra-block density can be measured.
88
+ 2. **Classify each block group** (`classifyBlockGroup`):
89
+ - **Missing side** (block only in A or only in B) → whole-block change.
90
+ - **Type / wrapping change** (A block type ≠ B block type) → whole-block change. This is the
91
+ paragraph→heading, list→table, wrap-in-panel/quote case.
92
+ - **Textblock** (paragraph/heading on both sides) → `classifyTextblock` (§4.1).
93
+ - **Container** (layout, table, list, panel, expand, quote, …) → `classifyContainer` (§4.2).
94
+ 3. **Merge overlaps** (`mergeOverlappingByNewDocRange`) and clamp to doc bounds.
95
+
96
+ ### 4.1 Textblock — sentence vs paragraph vs inline
97
+
98
+ `classifyTextblock` segments both sides with `Intl.Segmenter` (graceful regex fallback — see §6),
99
+ treating inline non-text nodes as one opaque word that is **never** a sentence boundary.
100
+
101
+ - Compute `sentencesChanged / max(sentencesOld, sentencesNew)`.
102
+ - If `sentencesChanged >= paragraph.minChanged` **and** ratio `>= paragraph.ratio` →
103
+ **whole-paragraph** change (`smartLevel: 'paragraph'`).
104
+ - Otherwise, per changed sentence, compute `wordsChanged / max(wordsOld, wordsNew)`:
105
+ - `wordsChanged >= sentence.minChanged` **and** ratio `>= sentence.ratio` →
106
+ **whole-sentence** change (`smartLevel: 'sentence'`).
107
+ - else keep the raw **inline** change(s).
108
+
109
+ ### 4.2 Container — recurse with escalation
110
+
111
+ `classifyContainer` measures density over **direct children**:
112
+ `changedChildren / max(childrenOld, childrenNew)`.
113
+
114
+ - If `>= node.ratio` (default 0.6) → **whole-node** change.
115
+ - Else, an alternative text-bearing trigger: if the fraction of text-bearing children
116
+ (paragraph/heading) that were themselves promoted to paragraph-level is `>= node.textBearingRatio`
117
+ → **whole-node** change.
118
+ - Otherwise, recurse into each changed child (`classifyChild`), keeping the diff strictly inside.
119
+
120
+ Tables are special-cased (`classifyTable`): cell density is escalated to the **row**, and row
121
+ density to the **table** (see §5).
122
+
123
+ ## 5. Structural integrity — the escalation rule
124
+
125
+ Some children are **structurally rigid**: deleting and re-inserting one on its own would break
126
+ the parent's structure. These are tracked in `RIGID_CHILD_TYPES`:
127
+
128
+ ```
129
+ layoutColumn, tableCell, tableHeader
130
+ ```
131
+
132
+ Rules (`classifyChild` / `classifyContainer` / `classifyTable`):
133
+
134
+ - **`layoutColumn`** — never a whole-block result on its own. A dense column does **not** become
135
+ "column deleted + column added" (that would break the `layoutSection`). Instead we either
136
+ promote the **whole `layoutSection`** (when overall section density ≥ `node.ratio`) or keep the
137
+ diff **inside** the column (recurse into its children).
138
+ - **`tableCell` / `tableHeader`** — same: a single dense cell never becomes a deleted+added cell.
139
+ Cell density escalates to the **`tableRow`** (a row *can* be shown deleted + re-inserted without
140
+ breaking the table), and row density escalates to the **whole `table`**. So even if one cell is
141
+ 100% changed, if its row is < `node.ratio` changed overall, the diff stays inline inside the
142
+ cell.
143
+ - **`listItem`** — *not* rigid. A list item *can* be replaced wholesale (delete + re-insert at the
144
+ same position keeps the list valid), so it follows the normal container rule: promote the whole
145
+ item when dense, else recurse. And when ≥ `node.ratio` of items changed, the **whole list** is
146
+ promoted.
147
+
148
+ This is why the doc emphasises escalation over naive "promote the nearest container".
149
+
150
+ ## 6. Configurable thresholds
151
+
152
+ `SmartDiffThresholds` (in `smart/thresholds.ts`) is passed from the `showDiff` command via
153
+ `smartThresholds` → plugin state → `calculateDiffDecorations` → `classifySmartChanges`.
154
+ Overrides are shallow-merged per level (`resolveThresholds`).
155
+
156
+ ```ts
157
+ DEFAULT_SMART_THRESHOLDS = {
158
+ sentence: { ratio: 0.4, minChanged: 2 }, // unit = words (inline node = 1 word)
159
+ paragraph: { ratio: 0.4, minChanged: 2 }, // unit = sentences
160
+ node: { ratio: 0.6, textBearingRatio: 0.6 }, // unit = direct children
161
+ };
162
+ ```
163
+
164
+ - **sentence** — numerator = words changed; denominator = `max(wordsOld, wordsNew)`;
165
+ `minChanged` = 2.
166
+ - **paragraph** — numerator = sentences changed; denominator = `max(sentencesOld, sentencesNew)`;
167
+ `minChanged` = 2.
168
+ - **node** — numerator = direct children changed; denominator = `max(childrenOld, childrenNew)`;
169
+ ratio = 0.6. The `textBearingRatio` alternative fires node-level when ≥ 60% of the text-bearing
170
+ children were themselves promoted to paragraph-level.
171
+
172
+ ### Segmentation (`smart/segmentText.ts`)
173
+
174
+ - Primary: `Intl.Segmenter` for both sentence and word granularity (locale from `intl.locale`).
175
+ - **Graceful regex fallback** when `Intl.Segmenter` is unavailable: sentences split on terminator
176
+ punctuation, words on whitespace/word boundaries.
177
+ - Operates on a `(string | null)[]` char-view where `null` marks an **opaque inline token**
178
+ (date, mention, emoji, status, …). An inline token counts as exactly one word and is **never**
179
+ a sentence boundary.
180
+
181
+ ## 7. Type/wrapping changes & attribute annotation
182
+
183
+ - A pure **attribute** change (same node type, same text) is annotated by the existing
184
+ `AttrStep`/`SetAttrsStep` path — not by smart.
185
+ - A **node-type or wrapping** change (paragraph→heading, list→table, wrap in panel/quote/
186
+ codeBlock) cannot be an attribute annotation, because the node types differ. Smart detects this
187
+ at the block-group level (A type ≠ B type) and emits a **whole-block** (node-level) change: old
188
+ node shown deleted, new node shown inserted.
189
+
190
+ ## 8. Feature gating
191
+
192
+ `smart` requires **both** the extended diff pipeline behaviour **and** its own gate. The single
193
+ source of truth is `pm-plugins/isExtendedEnabled.ts`:
194
+
195
+ ```ts
196
+ export const isExtendedEnabled = (diffType?: DiffType): boolean =>
197
+ expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true) ||
198
+ (diffType === 'smart' && fg('platform_editor_ai_smart_diff'));
199
+ ```
200
+
201
+ Because the whole extended decoration pipeline was previously gated only by
202
+ `platform_editor_diff_plugin_extended`, `diffType` is **threaded through the entire decoration
203
+ layer** (inline/block/node/row decorations, `wrapBlockNodeView` and its helpers,
204
+ `decorationKeys`, `getScrollableDecorations`) so that every internal extended-gate check becomes
205
+ `isExtendedEnabled(diffType)`. This makes `smart` render the full extended shape even when
206
+ `platform_editor_diff_plugin_extended` is off — and only when `platform_editor_ai_smart_diff` is
207
+ on. Other diff types are unaffected (they pass `diffType` that is not `'smart'`, so the check
208
+ reduces to the extended gate alone).
209
+
210
+ ## 9. Rendering specifics
211
+
212
+ - **Node- and paragraph-level** smart changes render the **new (purple) content on top** and the
213
+ **deleted (gray, strikethrough) content below** it (`placeBelow`), so the new content always
214
+ reads first.
215
+ - For **node-level** smart changes the redundant inline highlight is skipped (`isSmartNodeLevel`),
216
+ since the block/widget decorations already highlight the container.
217
+
218
+ ## 10. Module layout
219
+
220
+ ```
221
+ src/pm-plugins/
222
+ isExtendedEnabled.ts ← shared gate helper (diffType-aware)
223
+ calculateDiff/smart/
224
+ classifySmartChanges.ts ← block-first orchestrator (§4, §5)
225
+ thresholds.ts ← SmartDiffThresholds + defaults (§6)
226
+ segmentText.ts ← Intl.Segmenter + regex fallback (§6)
227
+ helpers.ts ← makePromotedChange, merge/overlap, smartLevel tag
228
+ ```
229
+
230
+ ## 11. Testing
231
+
232
+ - `segmentText` unit tests live in
233
+ `editor-plugin-show-diff-tests/src/__tests__/jest/segmentText.test.ts` and cover the
234
+ Intl.Segmenter path and the regex fallback (inline token = one word, never a sentence
235
+ boundary).
236
+ - Classifier behaviour is exercised end-to-end via the `examples/3-smart.tsx` gallery (scenarios
237
+ 1a–C6) covering inline/sentence/paragraph promotion, list→table, table cells, layout columns,
238
+ panels, quotes, code blocks, and multi-step changes.
239
+
240
+ Run: `afm test unit packages/editor/editor-plugin-show-diff-tests/src/__tests__/jest/segmentText.test.ts --run-in-band`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-show-diff",
3
- "version": "10.1.22",
3
+ "version": "10.2.0",
4
4
  "description": "ShowDiff plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -28,7 +28,7 @@
28
28
  "@atlaskit/editor-prosemirror": "^8.0.0",
29
29
  "@atlaskit/editor-tables": "^3.0.0",
30
30
  "@atlaskit/platform-feature-flags": "^2.0.0",
31
- "@atlaskit/tmp-editor-statsig": "^123.0.0",
31
+ "@atlaskit/tmp-editor-statsig": "^124.0.0",
32
32
  "@atlaskit/tokens": "^15.5.0",
33
33
  "@babel/runtime": "^7.0.0",
34
34
  "@compiled/react": "^0.20.0",
@@ -40,6 +40,7 @@
40
40
  "@atlaskit/adf-utils": "^20.3.0",
41
41
  "@atlaskit/button": "^24.3.0",
42
42
  "@atlaskit/css": "^1.0.0",
43
+ "@atlaskit/dropdown-menu": "^17.1.0",
43
44
  "@atlaskit/editor-core": "^221.7.0",
44
45
  "@atlaskit/editor-json-transformer": "^9.0.0",
45
46
  "@atlaskit/form": "^16.1.0",
@@ -92,5 +93,9 @@
92
93
  ]
93
94
  }
94
95
  },
95
- "platform-feature-flags": {}
96
+ "platform-feature-flags": {
97
+ "platform_editor_ai_smart_diff": {
98
+ "type": "boolean"
99
+ }
100
+ }
96
101
  }