@atlaskit/editor-plugin-show-diff 15.0.0 → 15.1.1

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 (63) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/afm-cc/tsconfig.json +0 -12
  3. package/afm-products/tsconfig.json +0 -12
  4. package/dist/cjs/pm-plugins/calculateDiff/calculateDiffDecorations.js +131 -79
  5. package/dist/cjs/pm-plugins/decorations/colorSchemes/factory.js +32 -0
  6. package/dist/cjs/pm-plugins/decorations/createBlockChangedDecoration.js +87 -25
  7. package/dist/cjs/pm-plugins/decorations/createContributorTagWidget.js +27 -4
  8. package/dist/cjs/pm-plugins/decorations/createInlineChangedDecoration.js +17 -3
  9. package/dist/cjs/pm-plugins/decorations/createNodeChangedDecorationWidget.js +5 -3
  10. package/dist/cjs/pm-plugins/decorations/extractContributorTags.js +43 -14
  11. package/dist/cjs/pm-plugins/decorations/revealStyles.js +156 -0
  12. package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeView.js +25 -5
  13. package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.js +4 -2
  14. package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.legacy.js +2 -1
  15. package/dist/cjs/pm-plugins/main.js +28 -10
  16. package/dist/cjs/pm-plugins/resolveDiffContributors.js +4 -0
  17. package/dist/cjs/pm-plugins/revealAnimation.js +524 -0
  18. package/dist/cjs/showDiffPlugin.js +42 -7
  19. package/dist/es2019/pm-plugins/calculateDiff/calculateDiffDecorations.js +59 -9
  20. package/dist/es2019/pm-plugins/decorations/colorSchemes/factory.js +30 -0
  21. package/dist/es2019/pm-plugins/decorations/createBlockChangedDecoration.js +62 -4
  22. package/dist/es2019/pm-plugins/decorations/createContributorTagWidget.js +25 -3
  23. package/dist/es2019/pm-plugins/decorations/createInlineChangedDecoration.js +18 -1
  24. package/dist/es2019/pm-plugins/decorations/createNodeChangedDecorationWidget.js +5 -3
  25. package/dist/es2019/pm-plugins/decorations/extractContributorTags.js +36 -9
  26. package/dist/es2019/pm-plugins/decorations/revealStyles.js +139 -0
  27. package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeView.js +25 -6
  28. package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.js +6 -3
  29. package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.legacy.js +5 -2
  30. package/dist/es2019/pm-plugins/main.js +28 -9
  31. package/dist/es2019/pm-plugins/resolveDiffContributors.js +4 -0
  32. package/dist/es2019/pm-plugins/revealAnimation.js +495 -0
  33. package/dist/es2019/showDiffPlugin.js +37 -7
  34. package/dist/esm/pm-plugins/calculateDiff/calculateDiffDecorations.js +131 -79
  35. package/dist/esm/pm-plugins/decorations/colorSchemes/factory.js +30 -0
  36. package/dist/esm/pm-plugins/decorations/createBlockChangedDecoration.js +87 -25
  37. package/dist/esm/pm-plugins/decorations/createContributorTagWidget.js +27 -4
  38. package/dist/esm/pm-plugins/decorations/createInlineChangedDecoration.js +17 -3
  39. package/dist/esm/pm-plugins/decorations/createNodeChangedDecorationWidget.js +5 -3
  40. package/dist/esm/pm-plugins/decorations/extractContributorTags.js +43 -14
  41. package/dist/esm/pm-plugins/decorations/revealStyles.js +149 -0
  42. package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeView.js +25 -5
  43. package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.js +4 -2
  44. package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.legacy.js +2 -1
  45. package/dist/esm/pm-plugins/main.js +28 -10
  46. package/dist/esm/pm-plugins/resolveDiffContributors.js +4 -0
  47. package/dist/esm/pm-plugins/revealAnimation.js +518 -0
  48. package/dist/esm/showDiffPlugin.js +42 -7
  49. package/dist/types/entry-points/show-diff-plugin-type.d.ts +1 -1
  50. package/dist/types/pm-plugins/calculateDiff/calculateDiffDecorations.d.ts +2 -1
  51. package/dist/types/pm-plugins/decorations/colorSchemes/factory.d.ts +24 -0
  52. package/dist/types/pm-plugins/decorations/createBlockChangedDecoration.d.ts +5 -1
  53. package/dist/types/pm-plugins/decorations/createContributorTagWidget.d.ts +9 -3
  54. package/dist/types/pm-plugins/decorations/createInlineChangedDecoration.d.ts +3 -2
  55. package/dist/types/pm-plugins/decorations/createNodeChangedDecorationWidget.d.ts +3 -2
  56. package/dist/types/pm-plugins/decorations/revealStyles.d.ts +49 -0
  57. package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeView.d.ts +4 -3
  58. package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.d.ts +1 -1
  59. package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.legacy.d.ts +1 -1
  60. package/dist/types/pm-plugins/main.d.ts +6 -1
  61. package/dist/types/pm-plugins/revealAnimation.d.ts +33 -0
  62. package/dist/types/showDiffPluginType.d.ts +29 -1
  63. package/package.json +10 -4
@@ -0,0 +1,495 @@
1
+ import { buildWipeableBackground, REVEAL_ATTR, REVEAL_BG_VAR, REVEAL_BORDER_VAR } from './decorations/revealStyles';
2
+
3
+ /**
4
+ * Reveal animation timing. Phase A (0–0.6D): outgoing state fades in, agent highlight wipes out.
5
+ * Phase B (0.6D–D): all highlights wipe in. Uses Web Animations API for element reusability and
6
+ * per-reveal cancellation.
7
+ */
8
+
9
+ /** Total choreography length when the caller does not specify one. */
10
+ export const REVEAL_DEFAULT_DURATION_MS = 950;
11
+
12
+ /**
13
+ * The reflow settles slightly ahead of the highlights. Running it for the full duration makes the
14
+ * movement read as sluggish next to the cross-fade, which is finished well before it.
15
+ */
16
+ const REFLOW_DURATION_FRACTION = 0.8;
17
+
18
+ /** Share of the total spent cross-fading before the highlights come back. */
19
+ const PHASE_A_FRACTION = 0.6;
20
+ const EASING = 'ease-in-out';
21
+
22
+ /** Selector for agent highlights in the outgoing snapshot. */
23
+ const CHANGED_DECORATION_SELECTOR = '[data-testid="show-diff-changed-decoration"]';
24
+
25
+ /**
26
+ * Every marker that identifies a block as changed, in either the outgoing or incoming render.
27
+ * Deleted content is a widget in the incoming state only; reveal markers exist only while revealing.
28
+ */
29
+ const CHANGED_BLOCK_SELECTOR = ['[data-testid="show-diff-changed-decoration"]', '[data-testid="show-diff-deleted-decoration"]', `[${REVEAL_ATTR}]`].join(', ');
30
+
31
+ /** Elements that re-mount on clone; replace with placeholders to preserve layout. */
32
+ const REMOUNT_UNSAFE_SELECTOR = 'iframe, object, embed, video, audio, canvas';
33
+
34
+ /**
35
+ * Marks animations this module owns, so a re-bind can tell them from anything else on the node.
36
+ * Suffixed per purpose because one element can legitimately carry several — a changed block that
37
+ * also grows takes both the cross-fade and the clip.
38
+ */
39
+ const REVEAL_ID = {
40
+ clock: 'show-diff-reveal-clock',
41
+ fade: 'show-diff-reveal-fade',
42
+ reflow: 'show-diff-reveal-reflow',
43
+ wipe: 'show-diff-reveal-wipe'
44
+ };
45
+ /** `property` distinguishes the clip from the carry, since one block can need both. */
46
+
47
+ const running = new WeakMap();
48
+
49
+ /**
50
+ * Stop any running reveal and drop it from plugin state.
51
+ *
52
+ * Clearing the state is what restores the resting appearance: revealing decorations paint their
53
+ * highlight at zero width, so the repaint that follows is what renders them normally again. Doing
54
+ * it in state rather than by writing styles onto the current elements means decorations rendered
55
+ * later are correct too — an imperative fix only reaches the elements that exist at that instant.
56
+ */
57
+ export const cancelReveal = editorView => {
58
+ var _current$overlay, _current$restoreReflo;
59
+ const current = running.get(editorView);
60
+ if (!current) {
61
+ return;
62
+ }
63
+ running.delete(editorView);
64
+ [...current.outgoing, ...current.incoming].forEach(animation => animation.cancel());
65
+ (_current$overlay = current.overlay) === null || _current$overlay === void 0 ? void 0 : _current$overlay.remove();
66
+ (_current$restoreReflo = current.restoreReflow) === null || _current$restoreReflo === void 0 ? void 0 : _current$restoreReflo.call(current);
67
+ current.complete();
68
+ };
69
+
70
+ /**
71
+ * Sanitise cloned subtree: remove IDs, contenteditable, re-mount-unsafe elements.
72
+ * Measures embeds from LIVE element (clone is detached, would measure 0x0).
73
+ */
74
+ const sanitiseClone = (live, clone) => {
75
+ clone.removeAttribute('id');
76
+ clone.querySelectorAll('[id]').forEach(element => element.removeAttribute('id'));
77
+ clone.removeAttribute('contenteditable');
78
+ clone.querySelectorAll('[contenteditable]').forEach(element => {
79
+ element.removeAttribute('contenteditable');
80
+ });
81
+ // Revealing decorations paint at zero width; snapshot must show full width so clear markers here.
82
+ clone.querySelectorAll(`[${REVEAL_ATTR}]`).forEach(element => {
83
+ element.removeAttribute(REVEAL_ATTR);
84
+ element.style.removeProperty('background-size');
85
+ });
86
+ const liveEmbeds = live.querySelectorAll(REMOUNT_UNSAFE_SELECTOR);
87
+ clone.querySelectorAll(REMOUNT_UNSAFE_SELECTOR).forEach((element, index) => {
88
+ const placeholder = document.createElement('div');
89
+ const source = liveEmbeds[index];
90
+ if (source) {
91
+ const {
92
+ width,
93
+ height
94
+ } = source.getBoundingClientRect();
95
+ placeholder.style.width = `${width}px`;
96
+ placeholder.style.height = `${height}px`;
97
+ }
98
+ element.replaceWith(placeholder);
99
+ });
100
+ };
101
+
102
+ /**
103
+ * Convert flat highlights (from non-revealing render) to wipeable gradients.
104
+ * Reads colour from LIVE element (clone is detached, no computed style).
105
+ */
106
+ const makeOutgoingHighlightsWipeable = (liveBlock, clonedBlock) => {
107
+ const live = liveBlock.querySelectorAll(CHANGED_DECORATION_SELECTOR);
108
+ const cloned = clonedBlock.querySelectorAll(CHANGED_DECORATION_SELECTOR);
109
+ const wipeable = [];
110
+ cloned.forEach((clone, index) => {
111
+ var _clone$getAttribute;
112
+ const source = live[index];
113
+ if (!source) {
114
+ return;
115
+ }
116
+ // Read colour from custom property (revealing render) or computed style (flat render).
117
+ const color = source.style.getPropertyValue(REVEAL_BG_VAR).trim() || window.getComputedStyle(source).backgroundColor;
118
+ if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') {
119
+ return;
120
+ }
121
+ clone.setAttribute('style', `${(_clone$getAttribute = clone.getAttribute('style')) !== null && _clone$getAttribute !== void 0 ? _clone$getAttribute : ''}${buildWipeableBackground(color)}`);
122
+ // Anchor right: shrinking retracts the highlight through the right edge.
123
+ clone.style.backgroundSize = '100% 100%';
124
+ clone.style.backgroundPosition = '100% 0';
125
+ wipeable.push(clone);
126
+ });
127
+ return wipeable;
128
+ };
129
+
130
+ /**
131
+ * The contiguous run of top-level blocks containing a change.
132
+ *
133
+ * Only these cross-fade. Fading the whole content area would also fade blocks that did not change
134
+ * and have merely been pushed up or down by the reflow, which reads as the entire document
135
+ * flickering.
136
+ *
137
+ * Exact rather than contiguous: an agent can touch two paragraphs either side of untouched ones,
138
+ * and a first-to-last range would sweep up everything between them. The index travels with each
139
+ * block so its outgoing height can be paired with its incoming height across the swap.
140
+ */
141
+ const findChangedBlocks = content => Array.from(content.children).filter(child => child instanceof HTMLElement).map((block, index) => ({
142
+ block,
143
+ index
144
+ })).filter(({
145
+ block
146
+ }) => block.matches(CHANGED_BLOCK_SELECTOR) || block.querySelector(CHANGED_BLOCK_SELECTOR));
147
+
148
+ /** Outgoing height of every changed block, keyed by its position among the content's children. */
149
+ const measureBlocks = changed => new Map(changed.map(({
150
+ block,
151
+ index
152
+ }) => [index, block.getBoundingClientRect().height]));
153
+
154
+ /**
155
+ * Build and position a snapshot of the outgoing state over the changed blocks.
156
+ *
157
+ * The wrapper is a shallow clone of the ProseMirror element so the snapshot keeps the class-based
158
+ * typography the real content has; a plain div would render the text differently. Inserted as a
159
+ * sibling rather than a child so ProseMirror does not reconcile it away.
160
+ */
161
+ const buildOverlay = (content, changed) => {
162
+ const host = content.parentElement;
163
+ if (!host || !content.offsetParent || changed.length === 0) {
164
+ return undefined;
165
+ }
166
+ const wrapper = content.cloneNode(false);
167
+ if (!(wrapper instanceof HTMLElement)) {
168
+ return undefined;
169
+ }
170
+ const contentRect = content.getBoundingClientRect();
171
+ changed.forEach(({
172
+ block
173
+ }) => {
174
+ const clone = block.cloneNode(true);
175
+ if (!(clone instanceof HTMLElement)) {
176
+ return;
177
+ }
178
+ sanitiseClone(block, clone);
179
+ makeOutgoingHighlightsWipeable(block, clone);
180
+
181
+ // Each clone is placed at its own offset. Stacking them in flow would close the gaps left by
182
+ // the unchanged blocks that were not copied, so a later block would sit too high.
183
+ const rect = block.getBoundingClientRect();
184
+ clone.style.position = 'absolute';
185
+ clone.style.top = `${rect.top - contentRect.top}px`;
186
+ clone.style.left = `${rect.left - contentRect.left}px`;
187
+ clone.style.width = `${rect.width}px`;
188
+ clone.style.margin = '0';
189
+ wrapper.appendChild(clone);
190
+ });
191
+ wrapper.removeAttribute('id');
192
+ wrapper.removeAttribute('contenteditable');
193
+ // aria-hidden + inert removes overlay from focus, hit-testing and AT (aria-hidden-focus safe).
194
+ wrapper.setAttribute('aria-hidden', 'true');
195
+ wrapper.setAttribute('inert', '');
196
+ wrapper.style.position = 'absolute';
197
+ // Sits on the content box, with the clones positioned relative to it.
198
+ wrapper.style.top = `${content.offsetTop}px`;
199
+ wrapper.style.left = `${content.offsetLeft}px`;
200
+ wrapper.style.width = `${content.offsetWidth}px`;
201
+ // The wrapper inherits the editor's own padding and margin, which would offset the copied blocks
202
+ // a second time on top of the position already measured from them.
203
+ wrapper.style.margin = '0';
204
+ wrapper.style.padding = '0';
205
+ wrapper.style.boxSizing = 'border-box';
206
+ wrapper.style.pointerEvents = 'none';
207
+ wrapper.style.zIndex = '1';
208
+ host.insertBefore(wrapper, content);
209
+ return wrapper;
210
+ };
211
+
212
+ /**
213
+ * Slide the content below the change from where it used to sit to where it now sits.
214
+ *
215
+ * The incoming blocks are already at their final height, so without this everything below them
216
+ * jumps the instant the diff is applied. `margin-bottom` carries the following content; when the
217
+ * change has grown, the last block is also clipped back to its old height and released, so the
218
+ * content below is not overlapped while it catches up.
219
+ */
220
+ const animateReflow = (changed, outgoingHeights, duration) => {
221
+ var _changed$;
222
+ const specs = [];
223
+ const restores = [];
224
+ const deltas = new Map();
225
+ changed.forEach(({
226
+ block,
227
+ index
228
+ }) => {
229
+ const before = outgoingHeights.get(index);
230
+ if (before === undefined) {
231
+ return;
232
+ }
233
+ const delta = before - block.getBoundingClientRect().height;
234
+ if (Math.abs(delta) < 1) {
235
+ return;
236
+ }
237
+ deltas.set(index, delta);
238
+
239
+ // Growth only: clip the block back to its old height and open it up. Without this the taller
240
+ // new content would overlap the content below, which has not caught up yet.
241
+ const growth = Math.max(0, -delta);
242
+ if (growth === 0) {
243
+ return;
244
+ }
245
+ const previousOverflow = block.style.overflow;
246
+ block.style.overflow = 'hidden';
247
+ restores.push(() => {
248
+ block.style.overflow = previousOverflow;
249
+ });
250
+ specs.push({
251
+ index,
252
+ keyframes: [{
253
+ clipPath: `inset(0 0 ${growth}px 0)`
254
+ }, {
255
+ clipPath: 'inset(0 0 0 0)'
256
+ }],
257
+ property: 'clip'
258
+ });
259
+ });
260
+ const parent = (_changed$ = changed[0]) === null || _changed$ === void 0 ? void 0 : _changed$.block.parentElement;
261
+ if (deltas.size === 0 || !parent) {
262
+ return {
263
+ restore: () => restores.forEach(restore => restore()),
264
+ specs
265
+ };
266
+ }
267
+
268
+ // Everything below a change is carried with `transform`, never `margin`. Margin is a layout
269
+ // property, so each frame would re-lay-out the content below at a fractional offset and
270
+ // re-rasterise its text — that reads as shimmer even at a locked 60fps. Transforms run on the
271
+ // compositor: the glyphs are rasterised once and moved.
272
+ let carried = 0;
273
+ Array.from(parent.children).forEach((child, index) => {
274
+ var _deltas$get;
275
+ if (child instanceof HTMLElement && carried !== 0) {
276
+ specs.push({
277
+ index,
278
+ keyframes: [{
279
+ transform: `translateY(${carried}px)`
280
+ }, {
281
+ transform: 'translateY(0px)'
282
+ }],
283
+ property: 'carry'
284
+ });
285
+ }
286
+ // Applied after the block itself: a change moves the content below it, not its own top edge.
287
+ carried += (_deltas$get = deltas.get(index)) !== null && _deltas$get !== void 0 ? _deltas$get : 0;
288
+ });
289
+ return {
290
+ restore: () => restores.forEach(restore => restore()),
291
+ specs
292
+ };
293
+ };
294
+
295
+ /** Animate incoming highlights; re-run if diff repaints mid-reveal. */
296
+ /**
297
+ * Creates an animation unless the element already carries one of ours, and resumes it at `elapsed`.
298
+ *
299
+ * ProseMirror rebuilds inline decorations on any repaint, which destroys animations bound to them.
300
+ * Widget DOM is reused and so survives, which is why deleted highlights used to animate while added
301
+ * ones snapped in. Re-binding at the elapsed time keeps a re-rendered element in step rather than
302
+ * restarting it from the beginning.
303
+ */
304
+ const bind = (entry, element, id, keyframes, options, elapsed) => {
305
+ if (element.getAnimations().some(animation => animation.id === id)) {
306
+ return;
307
+ }
308
+ const animation = element.animate(keyframes, options);
309
+ animation.id = id;
310
+ if (elapsed > 0) {
311
+ animation.currentTime = Math.min(elapsed, options.duration);
312
+ }
313
+ entry.incoming.push(animation);
314
+ };
315
+ const highlightKeyframes = element => {
316
+ const border = element.style.getPropertyValue(REVEAL_BORDER_VAR).trim();
317
+ return [{
318
+ backgroundSize: '0% 100%',
319
+ borderBottomColor: 'transparent',
320
+ offset: 0
321
+ }, {
322
+ backgroundSize: '0% 100%',
323
+ borderBottomColor: 'transparent',
324
+ easing: EASING,
325
+ offset: PHASE_A_FRACTION
326
+ }, {
327
+ backgroundSize: '100% 100%',
328
+ borderBottomColor: border,
329
+ offset: 1
330
+ }];
331
+ };
332
+
333
+ /** (Re)binds every animation that runs on live, ProseMirror-managed DOM. */
334
+ const applyIncoming = (entry, content, total, phaseA) => {
335
+ const elapsed = Math.max(0, performance.now() - entry.startedAt);
336
+ const changedBlocks = findChangedBlocks(content);
337
+
338
+ // Only the changed blocks cross-fade. Everything else is unchanged content that has merely
339
+ // moved, and fading it would read as the whole document flickering.
340
+ if (entry.overlay) {
341
+ const fading = changedBlocks.length > 0 ? changedBlocks.map(({
342
+ block
343
+ }) => block) : [content];
344
+ fading.forEach(block => {
345
+ bind(entry, block, REVEAL_ID.fade, [{
346
+ opacity: 0
347
+ }, {
348
+ opacity: 1
349
+ }], {
350
+ duration: phaseA,
351
+ easing: EASING,
352
+ fill: 'forwards'
353
+ }, elapsed);
354
+ });
355
+ }
356
+ content.querySelectorAll(`[${REVEAL_ATTR}]`).forEach(element => {
357
+ bind(entry, element, REVEAL_ID.wipe, highlightKeyframes(element), {
358
+ duration: total,
359
+ fill: 'forwards'
360
+ }, elapsed);
361
+ });
362
+ entry.reflowSpecs.forEach(({
363
+ index,
364
+ keyframes,
365
+ property
366
+ }) => {
367
+ const child = content.children[index];
368
+ if (child instanceof HTMLElement) {
369
+ bind(entry, child, `${REVEAL_ID.reflow}-${property}`, keyframes, {
370
+ duration: entry.reflowDuration,
371
+ easing: EASING,
372
+ fill: 'backwards'
373
+ }, elapsed);
374
+ }
375
+ });
376
+ };
377
+
378
+ /**
379
+ * Re-binds the reveal to the current decorations. Called on every repaint while a reveal is in
380
+ * flight, because a repaint silently destroys any animation attached to re-rendered DOM.
381
+ */
382
+ export const rebindReveal = (editorView, content) => {
383
+ const entry = running.get(editorView);
384
+ if (entry) {
385
+ applyIncoming(entry, content, entry.total, entry.phaseA);
386
+ }
387
+ };
388
+ const scheduleIncoming = (editorView, entry, content, total, phaseA) => {
389
+ requestAnimationFrame(() => {
390
+ if (running.get(editorView) !== entry) {
391
+ return;
392
+ }
393
+ entry.startedAt = performance.now();
394
+ const reflow = animateReflow(findChangedBlocks(content), entry.outgoingHeights, entry.reflowDuration);
395
+ entry.reflowSpecs = reflow.specs;
396
+ entry.restoreReflow = reflow.restore;
397
+ applyIncoming(entry, content, total, phaseA);
398
+
399
+ // The clock runs on the content root, which ProseMirror never replaces. Hanging completion
400
+ // off a decoration animation would strand the reveal whenever that decoration was rebuilt.
401
+ const clock = content.animate([{
402
+ opacity: 1
403
+ }, {
404
+ opacity: 1
405
+ }], {
406
+ duration: total
407
+ });
408
+ clock.id = REVEAL_ID.clock;
409
+ clock.onfinish = () => {
410
+ if (running.get(editorView) === entry) {
411
+ cancelReveal(editorView);
412
+ }
413
+ };
414
+ entry.incoming.push(clock);
415
+ });
416
+ };
417
+
418
+ /**
419
+ * Start the reveal. Must be called BEFORE transaction dispatch (last moment outgoing DOM exists
420
+ * for capture). Incoming half animates a frame later, post-decoration-render.
421
+ */
422
+ export const beginReveal = ({
423
+ editorView,
424
+ onComplete,
425
+ reveal
426
+ }) => {
427
+ var _reveal$durationMs;
428
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
429
+ return;
430
+ }
431
+ const content = editorView.dom;
432
+ if (!(content instanceof HTMLElement)) {
433
+ return;
434
+ }
435
+ const total = (_reveal$durationMs = reveal.durationMs) !== null && _reveal$durationMs !== void 0 ? _reveal$durationMs : REVEAL_DEFAULT_DURATION_MS;
436
+ const phaseA = Math.round(total * PHASE_A_FRACTION);
437
+
438
+ // Repaint mid-reveal: reuse snapshot (not retake), rebind incoming animations to fresh decorations.
439
+ const existing = running.get(editorView);
440
+ if (existing) {
441
+ existing.incoming.forEach(animation => animation.cancel());
442
+ existing.incoming = [];
443
+ scheduleIncoming(editorView, existing, content, total, phaseA);
444
+ return;
445
+ }
446
+
447
+ // Captured before dispatch: this is the last moment the outgoing layout can be measured.
448
+ const outgoingBlocks = findChangedBlocks(content);
449
+ const overlay = buildOverlay(content, outgoingBlocks);
450
+ const outgoing = [];
451
+ if (overlay) {
452
+ // fill: 'forwards' is load-bearing: default fill: 'none' snaps snapshot to opaque (reads as swap).
453
+ const fadeOut = overlay.animate([{
454
+ opacity: 1
455
+ }, {
456
+ opacity: 0
457
+ }], {
458
+ duration: phaseA,
459
+ easing: EASING,
460
+ fill: 'forwards'
461
+ });
462
+ fadeOut.onfinish = () => {
463
+ overlay.remove();
464
+ };
465
+ outgoing.push(fadeOut);
466
+ overlay.querySelectorAll(CHANGED_DECORATION_SELECTOR).forEach(element => {
467
+ if (!element.style.backgroundImage) {
468
+ return;
469
+ }
470
+ outgoing.push(element.animate([{
471
+ backgroundSize: '100% 100%'
472
+ }, {
473
+ backgroundSize: '0% 100%'
474
+ }], {
475
+ duration: phaseA,
476
+ easing: EASING,
477
+ fill: 'forwards'
478
+ }));
479
+ });
480
+ }
481
+ const entry = {
482
+ complete: onComplete,
483
+ incoming: [],
484
+ outgoing,
485
+ outgoingHeights: measureBlocks(outgoingBlocks),
486
+ phaseA,
487
+ reflowDuration: Math.round(total * REFLOW_DURATION_FRACTION),
488
+ reflowSpecs: [],
489
+ startedAt: performance.now(),
490
+ total,
491
+ overlay
492
+ };
493
+ running.set(editorView, entry);
494
+ scheduleIncoming(editorView, entry, content, total, phaseA);
495
+ };
@@ -1,14 +1,16 @@
1
1
  import React from 'react';
2
+ import { fg } from '@atlaskit/platform-feature-flags/fg';
2
3
  import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
3
4
  import { areAttributionColorGatesEnabled, resolveContributors } from './pm-plugins/decorations/colorSchemes/attributions';
4
5
  import { getDeletedWidgets } from './pm-plugins/getDeletedWidgets';
5
6
  import { getScrollableDecorations } from './pm-plugins/getScrollableDecorations';
6
7
  import { createPlugin, showDiffPluginKey } from './pm-plugins/main';
7
8
  import { resolveDiffContributors } from './pm-plugins/resolveDiffContributors';
9
+ import { beginReveal, cancelReveal } from './pm-plugins/revealAnimation';
8
10
  import { IndicatorBarContentComponent } from './ui/IndicatorBar/IndicatorBarContentComponent';
9
11
  const normalizeShowDiffParams = params => {
10
12
  if (!params || !('stepsWithAttribution' in params)) {
11
- // Cleared so a previous attributed diff's contributors cannot leak onto an unattributed one.
13
+ // Clear contributors to prevent leaking from attributed to unattributed diffs.
12
14
  return params ? {
13
15
  ...params,
14
16
  stepAttributions: [],
@@ -22,8 +24,7 @@ const normalizeShowDiffParams = params => {
22
24
  } = params;
23
25
  return {
24
26
  ...rest,
25
- // Resolved and keyed at the public boundary so both the contributor model and the attribution-key
26
- // format stay internal.
27
+ // Resolve at boundary to keep contributor model and attribution format internal.
27
28
  contributors: areAttributionColorGatesEnabled() ? resolveContributors(resolveDiffContributors(stepsWithAttribution, contributorProfiles)) : undefined,
28
29
  steps: stepsWithAttribution.map(({
29
30
  step
@@ -33,12 +34,21 @@ const normalizeShowDiffParams = params => {
33
34
  }) => stepAttribution)
34
35
  };
35
36
  };
37
+ const prefersReducedMotion = () => typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
38
+
39
+ /**
40
+ * Resolve reveal preconditions once here so decorations and animation share one answer. If
41
+ * decorations think a reveal is happening but animation doesn't, highlight paints hidden forever.
42
+ * All preconditions resolved here (not just gate) ensures "unable to animate" means "do not reveal".
43
+ */
44
+ const resolveReveal = (reveal, editorView) =>
45
+ // Gate first, so it is the primary switch and is always observable once a reveal is requested.
46
+ reveal && fg('platform_editor_diff_reveal_animation') && editorView && typeof window !== 'undefined' && !prefersReducedMotion() ? reveal : undefined;
36
47
  export const showDiffPlugin = ({
37
48
  api,
38
49
  config
39
50
  }) => {
40
- // Captured from the plugin's view lifecycle (see `createPlugin`) so read-only actions can access
41
- // the current state without exposing the plugin key to consumers.
51
+ // Captured from view lifecycle so read-only actions access state without exposing plugin key.
42
52
  let editorView;
43
53
  const setEditorView = view => {
44
54
  editorView = view;
@@ -58,14 +68,35 @@ export const showDiffPlugin = ({
58
68
  tr
59
69
  });
60
70
  }
71
+ const reveal = resolveReveal(params === null || params === void 0 ? void 0 : params.reveal, editorView);
72
+
73
+ // Capture snapshot BEFORE transaction dispatch (last moment outgoing DOM exists).
74
+ if (reveal && editorView) {
75
+ const view = editorView;
76
+ beginReveal({
77
+ editorView: view,
78
+ reveal,
79
+ onComplete: () => {
80
+ // Repaint without the reveal so the decorations settle into their resting
81
+ // style. Kept out of history: this is presentation, not an edit.
82
+ view.dispatch(view.state.tr.setMeta(showDiffPluginKey, {
83
+ action: 'REVEAL_COMPLETE'
84
+ }).setMeta('addToHistory', false));
85
+ }
86
+ });
87
+ }
61
88
  return tr.setMeta(showDiffPluginKey, {
62
89
  ...normalizeShowDiffParams(params),
90
+ reveal,
63
91
  action: 'SHOW_DIFF'
64
92
  });
65
93
  },
66
94
  hideDiff: ({
67
95
  tr
68
96
  }) => {
97
+ if (editorView) {
98
+ cancelReveal(editorView);
99
+ }
69
100
  if (expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true)) {
70
101
  var _api$userIntent2;
71
102
  api === null || api === void 0 ? void 0 : (_api$userIntent2 = api.userIntent) === null || _api$userIntent2 === void 0 ? void 0 : _api$userIntent2.commands.setCurrentUserIntent('default')({
@@ -108,8 +139,7 @@ export const showDiffPlugin = ({
108
139
  if (!expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true)) {
109
140
  return null;
110
141
  }
111
- // Rendered here so it shares the DOM context of the decoration anchor spans. Contributor
112
- // tags are not: each one mounts into the host element its own decoration draws.
142
+ // Rendered here sharing decoration anchor span DOM context (tags mount separately).
113
143
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(IndicatorBarContentComponent, {
114
144
  api: api
115
145
  }));