@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
524 lines (497 loc) • 20 kB
JavaScript
;
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.rebindReveal = exports.cancelReveal = exports.beginReveal = exports.REVEAL_DEFAULT_DURATION_MS = void 0;
var _toConsumableArray2 = _interopRequireDefault(require("@babel/runtime/helpers/toConsumableArray"));
var _revealStyles = require("./decorations/revealStyles");
/**
* Reveal animation timing. Phase A (0–0.6D): outgoing state fades in, agent highlight wipes out.
* Phase B (0.6D–D): all highlights wipe in. Uses Web Animations API for element reusability and
* per-reveal cancellation.
*/
/** Total choreography length when the caller does not specify one. */
var REVEAL_DEFAULT_DURATION_MS = exports.REVEAL_DEFAULT_DURATION_MS = 950;
/**
* The reflow settles slightly ahead of the highlights. Running it for the full duration makes the
* movement read as sluggish next to the cross-fade, which is finished well before it.
*/
var REFLOW_DURATION_FRACTION = 0.8;
/** Share of the total spent cross-fading before the highlights come back. */
var PHASE_A_FRACTION = 0.6;
var EASING = 'ease-in-out';
/** Selector for agent highlights in the outgoing snapshot. */
var CHANGED_DECORATION_SELECTOR = '[data-testid="show-diff-changed-decoration"]';
/**
* Every marker that identifies a block as changed, in either the outgoing or incoming render.
* Deleted content is a widget in the incoming state only; reveal markers exist only while revealing.
*/
var CHANGED_BLOCK_SELECTOR = ['[data-testid="show-diff-changed-decoration"]', '[data-testid="show-diff-deleted-decoration"]', "[".concat(_revealStyles.REVEAL_ATTR, "]")].join(', ');
/** Elements that re-mount on clone; replace with placeholders to preserve layout. */
var REMOUNT_UNSAFE_SELECTOR = 'iframe, object, embed, video, audio, canvas';
/**
* Marks animations this module owns, so a re-bind can tell them from anything else on the node.
* Suffixed per purpose because one element can legitimately carry several — a changed block that
* also grows takes both the cross-fade and the clip.
*/
var REVEAL_ID = {
clock: 'show-diff-reveal-clock',
fade: 'show-diff-reveal-fade',
reflow: 'show-diff-reveal-reflow',
wipe: 'show-diff-reveal-wipe'
};
/** `property` distinguishes the clip from the carry, since one block can need both. */
var running = new WeakMap();
/**
* Stop any running reveal and drop it from plugin state.
*
* Clearing the state is what restores the resting appearance: revealing decorations paint their
* highlight at zero width, so the repaint that follows is what renders them normally again. Doing
* it in state rather than by writing styles onto the current elements means decorations rendered
* later are correct too — an imperative fix only reaches the elements that exist at that instant.
*/
var cancelReveal = exports.cancelReveal = function cancelReveal(editorView) {
var _current$overlay, _current$restoreReflo;
var current = running.get(editorView);
if (!current) {
return;
}
running.delete(editorView);
[].concat((0, _toConsumableArray2.default)(current.outgoing), (0, _toConsumableArray2.default)(current.incoming)).forEach(function (animation) {
return animation.cancel();
});
(_current$overlay = current.overlay) === null || _current$overlay === void 0 || _current$overlay.remove();
(_current$restoreReflo = current.restoreReflow) === null || _current$restoreReflo === void 0 || _current$restoreReflo.call(current);
current.complete();
};
/**
* Sanitise cloned subtree: remove IDs, contenteditable, re-mount-unsafe elements.
* Measures embeds from LIVE element (clone is detached, would measure 0x0).
*/
var sanitiseClone = function sanitiseClone(live, clone) {
clone.removeAttribute('id');
clone.querySelectorAll('[id]').forEach(function (element) {
return element.removeAttribute('id');
});
clone.removeAttribute('contenteditable');
clone.querySelectorAll('[contenteditable]').forEach(function (element) {
element.removeAttribute('contenteditable');
});
// Revealing decorations paint at zero width; snapshot must show full width so clear markers here.
clone.querySelectorAll("[".concat(_revealStyles.REVEAL_ATTR, "]")).forEach(function (element) {
element.removeAttribute(_revealStyles.REVEAL_ATTR);
element.style.removeProperty('background-size');
});
var liveEmbeds = live.querySelectorAll(REMOUNT_UNSAFE_SELECTOR);
clone.querySelectorAll(REMOUNT_UNSAFE_SELECTOR).forEach(function (element, index) {
var placeholder = document.createElement('div');
var source = liveEmbeds[index];
if (source) {
var _source$getBoundingCl = source.getBoundingClientRect(),
width = _source$getBoundingCl.width,
height = _source$getBoundingCl.height;
placeholder.style.width = "".concat(width, "px");
placeholder.style.height = "".concat(height, "px");
}
element.replaceWith(placeholder);
});
};
/**
* Convert flat highlights (from non-revealing render) to wipeable gradients.
* Reads colour from LIVE element (clone is detached, no computed style).
*/
var makeOutgoingHighlightsWipeable = function makeOutgoingHighlightsWipeable(liveBlock, clonedBlock) {
var live = liveBlock.querySelectorAll(CHANGED_DECORATION_SELECTOR);
var cloned = clonedBlock.querySelectorAll(CHANGED_DECORATION_SELECTOR);
var wipeable = [];
cloned.forEach(function (clone, index) {
var _clone$getAttribute;
var source = live[index];
if (!source) {
return;
}
// Read colour from custom property (revealing render) or computed style (flat render).
var color = source.style.getPropertyValue(_revealStyles.REVEAL_BG_VAR).trim() || window.getComputedStyle(source).backgroundColor;
if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') {
return;
}
clone.setAttribute('style', "".concat((_clone$getAttribute = clone.getAttribute('style')) !== null && _clone$getAttribute !== void 0 ? _clone$getAttribute : '').concat((0, _revealStyles.buildWipeableBackground)(color)));
// Anchor right: shrinking retracts the highlight through the right edge.
clone.style.backgroundSize = '100% 100%';
clone.style.backgroundPosition = '100% 0';
wipeable.push(clone);
});
return wipeable;
};
/**
* The contiguous run of top-level blocks containing a change.
*
* Only these cross-fade. Fading the whole content area would also fade blocks that did not change
* and have merely been pushed up or down by the reflow, which reads as the entire document
* flickering.
*
* Exact rather than contiguous: an agent can touch two paragraphs either side of untouched ones,
* and a first-to-last range would sweep up everything between them. The index travels with each
* block so its outgoing height can be paired with its incoming height across the swap.
*/
var findChangedBlocks = function findChangedBlocks(content) {
return Array.from(content.children).filter(function (child) {
return child instanceof HTMLElement;
}).map(function (block, index) {
return {
block: block,
index: index
};
}).filter(function (_ref) {
var block = _ref.block;
return block.matches(CHANGED_BLOCK_SELECTOR) || block.querySelector(CHANGED_BLOCK_SELECTOR);
});
};
/** Outgoing height of every changed block, keyed by its position among the content's children. */
var measureBlocks = function measureBlocks(changed) {
return new Map(changed.map(function (_ref2) {
var block = _ref2.block,
index = _ref2.index;
return [index, block.getBoundingClientRect().height];
}));
};
/**
* Build and position a snapshot of the outgoing state over the changed blocks.
*
* The wrapper is a shallow clone of the ProseMirror element so the snapshot keeps the class-based
* typography the real content has; a plain div would render the text differently. Inserted as a
* sibling rather than a child so ProseMirror does not reconcile it away.
*/
var buildOverlay = function buildOverlay(content, changed) {
var host = content.parentElement;
if (!host || !content.offsetParent || changed.length === 0) {
return undefined;
}
var wrapper = content.cloneNode(false);
if (!(wrapper instanceof HTMLElement)) {
return undefined;
}
var contentRect = content.getBoundingClientRect();
changed.forEach(function (_ref3) {
var block = _ref3.block;
var clone = block.cloneNode(true);
if (!(clone instanceof HTMLElement)) {
return;
}
sanitiseClone(block, clone);
makeOutgoingHighlightsWipeable(block, clone);
// Each clone is placed at its own offset. Stacking them in flow would close the gaps left by
// the unchanged blocks that were not copied, so a later block would sit too high.
var rect = block.getBoundingClientRect();
clone.style.position = 'absolute';
clone.style.top = "".concat(rect.top - contentRect.top, "px");
clone.style.left = "".concat(rect.left - contentRect.left, "px");
clone.style.width = "".concat(rect.width, "px");
clone.style.margin = '0';
wrapper.appendChild(clone);
});
wrapper.removeAttribute('id');
wrapper.removeAttribute('contenteditable');
// aria-hidden + inert removes overlay from focus, hit-testing and AT (aria-hidden-focus safe).
wrapper.setAttribute('aria-hidden', 'true');
wrapper.setAttribute('inert', '');
wrapper.style.position = 'absolute';
// Sits on the content box, with the clones positioned relative to it.
wrapper.style.top = "".concat(content.offsetTop, "px");
wrapper.style.left = "".concat(content.offsetLeft, "px");
wrapper.style.width = "".concat(content.offsetWidth, "px");
// The wrapper inherits the editor's own padding and margin, which would offset the copied blocks
// a second time on top of the position already measured from them.
wrapper.style.margin = '0';
wrapper.style.padding = '0';
wrapper.style.boxSizing = 'border-box';
wrapper.style.pointerEvents = 'none';
wrapper.style.zIndex = '1';
host.insertBefore(wrapper, content);
return wrapper;
};
/**
* Slide the content below the change from where it used to sit to where it now sits.
*
* The incoming blocks are already at their final height, so without this everything below them
* jumps the instant the diff is applied. `margin-bottom` carries the following content; when the
* change has grown, the last block is also clipped back to its old height and released, so the
* content below is not overlapped while it catches up.
*/
var animateReflow = function animateReflow(changed, outgoingHeights, duration) {
var _changed$;
var specs = [];
var restores = [];
var deltas = new Map();
changed.forEach(function (_ref4) {
var block = _ref4.block,
index = _ref4.index;
var before = outgoingHeights.get(index);
if (before === undefined) {
return;
}
var delta = before - block.getBoundingClientRect().height;
if (Math.abs(delta) < 1) {
return;
}
deltas.set(index, delta);
// Growth only: clip the block back to its old height and open it up. Without this the taller
// new content would overlap the content below, which has not caught up yet.
var growth = Math.max(0, -delta);
if (growth === 0) {
return;
}
var previousOverflow = block.style.overflow;
block.style.overflow = 'hidden';
restores.push(function () {
block.style.overflow = previousOverflow;
});
specs.push({
index: index,
keyframes: [{
clipPath: "inset(0 0 ".concat(growth, "px 0)")
}, {
clipPath: 'inset(0 0 0 0)'
}],
property: 'clip'
});
});
var parent = (_changed$ = changed[0]) === null || _changed$ === void 0 ? void 0 : _changed$.block.parentElement;
if (deltas.size === 0 || !parent) {
return {
restore: function restore() {
return restores.forEach(function (restore) {
return restore();
});
},
specs: specs
};
}
// Everything below a change is carried with `transform`, never `margin`. Margin is a layout
// property, so each frame would re-lay-out the content below at a fractional offset and
// re-rasterise its text — that reads as shimmer even at a locked 60fps. Transforms run on the
// compositor: the glyphs are rasterised once and moved.
var carried = 0;
Array.from(parent.children).forEach(function (child, index) {
var _deltas$get;
if (child instanceof HTMLElement && carried !== 0) {
specs.push({
index: index,
keyframes: [{
transform: "translateY(".concat(carried, "px)")
}, {
transform: 'translateY(0px)'
}],
property: 'carry'
});
}
// Applied after the block itself: a change moves the content below it, not its own top edge.
carried += (_deltas$get = deltas.get(index)) !== null && _deltas$get !== void 0 ? _deltas$get : 0;
});
return {
restore: function restore() {
return restores.forEach(function (restore) {
return restore();
});
},
specs: specs
};
};
/** Animate incoming highlights; re-run if diff repaints mid-reveal. */
/**
* Creates an animation unless the element already carries one of ours, and resumes it at `elapsed`.
*
* ProseMirror rebuilds inline decorations on any repaint, which destroys animations bound to them.
* Widget DOM is reused and so survives, which is why deleted highlights used to animate while added
* ones snapped in. Re-binding at the elapsed time keeps a re-rendered element in step rather than
* restarting it from the beginning.
*/
var bind = function bind(entry, element, id, keyframes, options, elapsed) {
if (element.getAnimations().some(function (animation) {
return animation.id === id;
})) {
return;
}
var animation = element.animate(keyframes, options);
animation.id = id;
if (elapsed > 0) {
animation.currentTime = Math.min(elapsed, options.duration);
}
entry.incoming.push(animation);
};
var highlightKeyframes = function highlightKeyframes(element) {
var border = element.style.getPropertyValue(_revealStyles.REVEAL_BORDER_VAR).trim();
return [{
backgroundSize: '0% 100%',
borderBottomColor: 'transparent',
offset: 0
}, {
backgroundSize: '0% 100%',
borderBottomColor: 'transparent',
easing: EASING,
offset: PHASE_A_FRACTION
}, {
backgroundSize: '100% 100%',
borderBottomColor: border,
offset: 1
}];
};
/** (Re)binds every animation that runs on live, ProseMirror-managed DOM. */
var applyIncoming = function applyIncoming(entry, content, total, phaseA) {
var elapsed = Math.max(0, performance.now() - entry.startedAt);
var changedBlocks = findChangedBlocks(content);
// Only the changed blocks cross-fade. Everything else is unchanged content that has merely
// moved, and fading it would read as the whole document flickering.
if (entry.overlay) {
var fading = changedBlocks.length > 0 ? changedBlocks.map(function (_ref5) {
var block = _ref5.block;
return block;
}) : [content];
fading.forEach(function (block) {
bind(entry, block, REVEAL_ID.fade, [{
opacity: 0
}, {
opacity: 1
}], {
duration: phaseA,
easing: EASING,
fill: 'forwards'
}, elapsed);
});
}
content.querySelectorAll("[".concat(_revealStyles.REVEAL_ATTR, "]")).forEach(function (element) {
bind(entry, element, REVEAL_ID.wipe, highlightKeyframes(element), {
duration: total,
fill: 'forwards'
}, elapsed);
});
entry.reflowSpecs.forEach(function (_ref6) {
var index = _ref6.index,
keyframes = _ref6.keyframes,
property = _ref6.property;
var child = content.children[index];
if (child instanceof HTMLElement) {
bind(entry, child, "".concat(REVEAL_ID.reflow, "-").concat(property), keyframes, {
duration: entry.reflowDuration,
easing: EASING,
fill: 'backwards'
}, elapsed);
}
});
};
/**
* Re-binds the reveal to the current decorations. Called on every repaint while a reveal is in
* flight, because a repaint silently destroys any animation attached to re-rendered DOM.
*/
var rebindReveal = exports.rebindReveal = function rebindReveal(editorView, content) {
var entry = running.get(editorView);
if (entry) {
applyIncoming(entry, content, entry.total, entry.phaseA);
}
};
var scheduleIncoming = function scheduleIncoming(editorView, entry, content, total, phaseA) {
requestAnimationFrame(function () {
if (running.get(editorView) !== entry) {
return;
}
entry.startedAt = performance.now();
var reflow = animateReflow(findChangedBlocks(content), entry.outgoingHeights, entry.reflowDuration);
entry.reflowSpecs = reflow.specs;
entry.restoreReflow = reflow.restore;
applyIncoming(entry, content, total, phaseA);
// The clock runs on the content root, which ProseMirror never replaces. Hanging completion
// off a decoration animation would strand the reveal whenever that decoration was rebuilt.
var clock = content.animate([{
opacity: 1
}, {
opacity: 1
}], {
duration: total
});
clock.id = REVEAL_ID.clock;
clock.onfinish = function () {
if (running.get(editorView) === entry) {
cancelReveal(editorView);
}
};
entry.incoming.push(clock);
});
};
/**
* Start the reveal. Must be called BEFORE transaction dispatch (last moment outgoing DOM exists
* for capture). Incoming half animates a frame later, post-decoration-render.
*/
var beginReveal = exports.beginReveal = function beginReveal(_ref7) {
var _reveal$durationMs;
var editorView = _ref7.editorView,
onComplete = _ref7.onComplete,
reveal = _ref7.reveal;
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
var content = editorView.dom;
if (!(content instanceof HTMLElement)) {
return;
}
var total = (_reveal$durationMs = reveal.durationMs) !== null && _reveal$durationMs !== void 0 ? _reveal$durationMs : REVEAL_DEFAULT_DURATION_MS;
var phaseA = Math.round(total * PHASE_A_FRACTION);
// Repaint mid-reveal: reuse snapshot (not retake), rebind incoming animations to fresh decorations.
var existing = running.get(editorView);
if (existing) {
existing.incoming.forEach(function (animation) {
return animation.cancel();
});
existing.incoming = [];
scheduleIncoming(editorView, existing, content, total, phaseA);
return;
}
// Captured before dispatch: this is the last moment the outgoing layout can be measured.
var outgoingBlocks = findChangedBlocks(content);
var overlay = buildOverlay(content, outgoingBlocks);
var outgoing = [];
if (overlay) {
// fill: 'forwards' is load-bearing: default fill: 'none' snaps snapshot to opaque (reads as swap).
var fadeOut = overlay.animate([{
opacity: 1
}, {
opacity: 0
}], {
duration: phaseA,
easing: EASING,
fill: 'forwards'
});
fadeOut.onfinish = function () {
overlay.remove();
};
outgoing.push(fadeOut);
overlay.querySelectorAll(CHANGED_DECORATION_SELECTOR).forEach(function (element) {
if (!element.style.backgroundImage) {
return;
}
outgoing.push(element.animate([{
backgroundSize: '100% 100%'
}, {
backgroundSize: '0% 100%'
}], {
duration: phaseA,
easing: EASING,
fill: 'forwards'
}));
});
}
var entry = {
complete: onComplete,
incoming: [],
outgoing: outgoing,
outgoingHeights: measureBlocks(outgoingBlocks),
phaseA: phaseA,
reflowDuration: Math.round(total * REFLOW_DURATION_FRACTION),
reflowSpecs: [],
startedAt: performance.now(),
total: total,
overlay: overlay
};
running.set(editorView, entry);
scheduleIncoming(editorView, entry, content, total, phaseA);
};