@atlaskit/editor-plugin-show-diff 15.0.1 → 15.1.2
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.
- package/CHANGELOG.md +41 -0
- package/dist/cjs/pm-plugins/calculateDiff/calculateDiffDecorations.js +131 -79
- package/dist/cjs/pm-plugins/decorations/colorSchemes/factory.js +32 -0
- package/dist/cjs/pm-plugins/decorations/createBlockChangedDecoration.js +87 -25
- package/dist/cjs/pm-plugins/decorations/createContributorTagWidget.js +27 -4
- package/dist/cjs/pm-plugins/decorations/createInlineChangedDecoration.js +17 -3
- package/dist/cjs/pm-plugins/decorations/createNodeChangedDecorationWidget.js +5 -3
- package/dist/cjs/pm-plugins/decorations/extractContributorTags.js +43 -14
- package/dist/cjs/pm-plugins/decorations/revealStyles.js +156 -0
- package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeView.js +25 -5
- package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.js +4 -2
- package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.legacy.js +2 -1
- package/dist/cjs/pm-plugins/main.js +28 -10
- package/dist/cjs/pm-plugins/resolveDiffContributors.js +16 -9
- package/dist/cjs/pm-plugins/revealAnimation.js +524 -0
- package/dist/cjs/showDiffPlugin.js +42 -7
- package/dist/es2019/pm-plugins/calculateDiff/calculateDiffDecorations.js +59 -9
- package/dist/es2019/pm-plugins/decorations/colorSchemes/factory.js +30 -0
- package/dist/es2019/pm-plugins/decorations/createBlockChangedDecoration.js +62 -4
- package/dist/es2019/pm-plugins/decorations/createContributorTagWidget.js +25 -3
- package/dist/es2019/pm-plugins/decorations/createInlineChangedDecoration.js +18 -1
- package/dist/es2019/pm-plugins/decorations/createNodeChangedDecorationWidget.js +5 -3
- package/dist/es2019/pm-plugins/decorations/extractContributorTags.js +36 -9
- package/dist/es2019/pm-plugins/decorations/revealStyles.js +139 -0
- package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeView.js +25 -6
- package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.js +6 -3
- package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.legacy.js +5 -2
- package/dist/es2019/pm-plugins/main.js +28 -9
- package/dist/es2019/pm-plugins/resolveDiffContributors.js +16 -8
- package/dist/es2019/pm-plugins/revealAnimation.js +495 -0
- package/dist/es2019/showDiffPlugin.js +37 -7
- package/dist/esm/pm-plugins/calculateDiff/calculateDiffDecorations.js +131 -79
- package/dist/esm/pm-plugins/decorations/colorSchemes/factory.js +30 -0
- package/dist/esm/pm-plugins/decorations/createBlockChangedDecoration.js +87 -25
- package/dist/esm/pm-plugins/decorations/createContributorTagWidget.js +27 -4
- package/dist/esm/pm-plugins/decorations/createInlineChangedDecoration.js +17 -3
- package/dist/esm/pm-plugins/decorations/createNodeChangedDecorationWidget.js +5 -3
- package/dist/esm/pm-plugins/decorations/extractContributorTags.js +43 -14
- package/dist/esm/pm-plugins/decorations/revealStyles.js +149 -0
- package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeView.js +25 -5
- package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.js +4 -2
- package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.legacy.js +2 -1
- package/dist/esm/pm-plugins/main.js +28 -10
- package/dist/esm/pm-plugins/resolveDiffContributors.js +16 -9
- package/dist/esm/pm-plugins/revealAnimation.js +518 -0
- package/dist/esm/showDiffPlugin.js +42 -7
- package/dist/types/entry-points/show-diff-plugin-type.d.ts +1 -1
- package/dist/types/pm-plugins/calculateDiff/calculateDiffDecorations.d.ts +2 -1
- package/dist/types/pm-plugins/decorations/colorSchemes/factory.d.ts +24 -0
- package/dist/types/pm-plugins/decorations/createBlockChangedDecoration.d.ts +5 -1
- package/dist/types/pm-plugins/decorations/createContributorTagWidget.d.ts +9 -3
- package/dist/types/pm-plugins/decorations/createInlineChangedDecoration.d.ts +3 -2
- package/dist/types/pm-plugins/decorations/createNodeChangedDecorationWidget.d.ts +3 -2
- package/dist/types/pm-plugins/decorations/revealStyles.d.ts +49 -0
- package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeView.d.ts +4 -3
- package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.d.ts +1 -1
- package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeViewStyles.legacy.d.ts +1 -1
- package/dist/types/pm-plugins/main.d.ts +6 -1
- package/dist/types/pm-plugins/resolveDiffContributors.d.ts +3 -2
- package/dist/types/pm-plugins/revealAnimation.d.ts +33 -0
- package/dist/types/showDiffPluginType.d.ts +29 -1
- package/package.json +9 -3
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
}));
|