UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

524 lines (497 loc) • 20 kB
"use strict"; 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); };