UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

314 lines (308 loc) • 18.1 kB
import { toggleExpandRange } from '@atlaskit/editor-common/expand'; import { processRawValue } from '@atlaskit/editor-common/process-raw-value'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { PluginKey } from '@atlaskit/editor-prosemirror/state'; import { Step as ProseMirrorStep } from '@atlaskit/editor-prosemirror/transform-override'; import { DecorationSet } from '@atlaskit/editor-prosemirror/view'; import { fg } from '@atlaskit/platform-feature-flags/fg'; import { getActiveDiffAnnouncement } from '../ui/DiffNavigation/announceActiveDiff'; import { calculateDiffDecorations } from './calculateDiff/calculateDiffDecorations'; import { isDiffDecoration, isDiffDecorationSpec } from './decorations/decorationKeys'; import { enforceCustomStepRegisters } from './enforceCustomStepRegisters'; import { getDefaultDiffType } from './getDefaultDiffType'; import { getScrollableDecorations } from './getScrollableDecorations'; import { NodeViewSerializer } from './NodeViewSerializer'; import { rebindReveal } from './revealAnimation'; import { scrollToDecoration } from './scrollToDiff'; export const showDiffPluginKey = new PluginKey('showDiffPlugin'); /** * Tags whose diff decoration survived the transaction. A document change can map a decoration away * without any `showDiff`/`hideDiff` following it, and a tag left behind has no host to render into. * Returns the given array when every tag is still live. */ const dropOrphanedContributorTags = (contributorTags, decorations) => { if (!(contributorTags !== null && contributorTags !== void 0 && contributorTags.length)) { return contributorTags; } const liveDiffIds = new Set(decorations.find(undefined, undefined, isDiffDecorationSpec).filter(isDiffDecoration).map(({ spec }) => spec.diffId)); const liveTags = contributorTags.filter(({ diffId }) => liveDiffIds.has(diffId)); return liveTags.length === contributorTags.length ? contributorTags : liveTags; }; export const createPlugin = (config, getIntl, api, onEditorView) => { enforceCustomStepRegisters(); const nodeViewSerializer = new NodeViewSerializer({}); const setNodeViewSerializer = editorView => { nodeViewSerializer.init({ editorView }); }; /** * This editor's own content root. The contributor tags scope every DOM lookup to it, so they take * it from the view the plugin was mounted on rather than resolving `.ProseMirror` by class name * from a decoration — a class this package does not own, and one whose nearest match is the wrong * editor whenever editors are nested. A getter because the view arrives with the ProseMirror view * lifecycle, which decorations are calculated independently of. */ let currentEditorView; const tagMountContext = { api, getEditorRoot: () => { var _currentEditorView$do, _currentEditorView; return (_currentEditorView$do = (_currentEditorView = currentEditorView) === null || _currentEditorView === void 0 ? void 0 : _currentEditorView.dom) !== null && _currentEditorView$do !== void 0 ? _currentEditorView$do : null; }, getIntl }; return new SafePlugin({ key: showDiffPluginKey, state: { init(_, _state) { // We do initial setup after we setup the editor view return { steps: [], stepAttributions: [], originalDoc: undefined, decorations: DecorationSet.empty, isDisplayingChanges: false, isInverted: false, diffType: getDefaultDiffType(), hideDeletedDiffs: false, hideAddedDiffsUnderline: false, showIndicators: false, diffDescriptors: [] }; }, apply: (tr, currentPluginState, oldState, newState) => { const meta = tr.getMeta(showDiffPluginKey); let newPluginState = currentPluginState; if (meta) { if ((meta === null || meta === void 0 ? void 0 : meta.action) === 'SHOW_DIFF' || (meta === null || meta === void 0 ? void 0 : meta.action) === 'REVEAL_COMPLETE') { var _newPluginState$color, _newPluginState, _newPluginState2, _newPluginState3, _newPluginState4, _newPluginState5, _newPluginState6, _newPluginState7, _newPluginState8, _newPluginState9, _newPluginState0; // REVEAL_COMPLETE repaints with the reveal dropped, so the decorations render their // ordinary resting style. Without it the reveal stays in state indefinitely and the // next unrelated repaint re-emits the hidden, zero-width highlight with no animation // left to bring it in — the highlight simply vanishes. newPluginState = meta.action === 'REVEAL_COMPLETE' ? { ...currentPluginState, reveal: undefined } : { ...currentPluginState, ...meta, isDisplayingChanges: true, activeIndex: undefined, // Explicit rather than left to the spread: an absent key in `meta` would let // the previous paint's choreography leak into this one, so closing the diff // would animate too. reveal: meta.reveal }; // Calculate and store decorations in state const { contributorTags, decorations, diffDescriptors } = calculateDiffDecorations({ state: newState, pluginState: newPluginState, nodeViewSerializer, colorScheme: (_newPluginState$color = (_newPluginState = newPluginState) === null || _newPluginState === void 0 ? void 0 : _newPluginState.colorScheme) !== null && _newPluginState$color !== void 0 ? _newPluginState$color : config === null || config === void 0 ? void 0 : config.colorScheme, intl: getIntl(), activeIndexPos: newPluginState.activeIndexPos, api, tagMountContext, isInverted: (_newPluginState2 = newPluginState) === null || _newPluginState2 === void 0 ? void 0 : _newPluginState2.isInverted, diffType: (_newPluginState3 = newPluginState) === null || _newPluginState3 === void 0 ? void 0 : _newPluginState3.diffType, hideDeletedDiffs: (_newPluginState4 = newPluginState) === null || _newPluginState4 === void 0 ? void 0 : _newPluginState4.hideDeletedDiffs, hideAddedDiffsUnderline: (_newPluginState5 = newPluginState) === null || _newPluginState5 === void 0 ? void 0 : _newPluginState5.hideAddedDiffsUnderline, showIndicators: (_newPluginState6 = newPluginState) === null || _newPluginState6 === void 0 ? void 0 : _newPluginState6.showIndicators, smartThresholds: (_newPluginState7 = newPluginState) === null || _newPluginState7 === void 0 ? void 0 : _newPluginState7.smartThresholds, deletedDiffPlacement: (_newPluginState8 = newPluginState) === null || _newPluginState8 === void 0 ? void 0 : _newPluginState8.deletedDiffPlacement, inlineDeletedDiffPlacement: (_newPluginState9 = newPluginState) === null || _newPluginState9 === void 0 ? void 0 : _newPluginState9.inlineDeletedDiffPlacement, // SHOW_DIFF only. The scroll-to-next recalculation further down deliberately // omits this so stepping through changes cannot replay the choreography. reveal: (_newPluginState0 = newPluginState) === null || _newPluginState0 === void 0 ? void 0 : _newPluginState0.reveal }); // Update the decorations and their ids newPluginState.decorations = decorations; newPluginState.contributorTags = contributorTags; newPluginState.diffDescriptors = diffDescriptors; } else if ((meta === null || meta === void 0 ? void 0 : meta.action) === 'HIDE_DIFF') { newPluginState = { ...currentPluginState, ...meta, decorations: DecorationSet.empty, isDisplayingChanges: false, activeIndex: undefined, contributorTags: [], reveal: undefined, // Per-call override — do not let it leak into the next, unrelated `showDiff` call. colorScheme: undefined, /** * Reset isInverted & diffType state when hiding diffs * Otherwise this should persist for the diff-showing session */ isInverted: false, diffType: getDefaultDiffType(), hideDeletedDiffs: false, hideAddedDiffsUnderline: false, diffDescriptors: [] }; } else if ((meta === null || meta === void 0 ? void 0 : meta.action) === 'SCROLL_TO_NEXT' || (meta === null || meta === void 0 ? void 0 : meta.action) === 'SCROLL_TO_PREVIOUS') { // Update the active index in plugin state and recalculate decorations const decorations = getScrollableDecorations(currentPluginState.decorations, newState.doc); if (decorations.length > 0) { var _currentPluginState$a, _newPluginState$color2, _newPluginState1; // Initialize to -1 if undefined so that the first "next" scroll takes us to index 0 (first change). // This allows the UI to start with no selection and only highlight on first user interaction. let nextIndex = (_currentPluginState$a = currentPluginState.activeIndex) !== null && _currentPluginState$a !== void 0 ? _currentPluginState$a : -1; if (meta.action === 'SCROLL_TO_NEXT') { nextIndex = (nextIndex + 1) % decorations.length; } else { // Handle scrolling backwards from the uninitialized state. // If at -1 (no selection), wrap to the last decoration. if (nextIndex === -1) { nextIndex = decorations.length - 1; } else { nextIndex = (nextIndex - 1 + decorations.length) % decorations.length; } } const activeDecoration = decorations[nextIndex]; newPluginState = { ...currentPluginState, activeIndex: nextIndex, activeIndexPos: activeDecoration ? { from: activeDecoration.from, to: activeDecoration.to, ...(fg('confluence_ncs_step_diffing_version_history') ? { attributionKey: isDiffDecoration(activeDecoration) ? activeDecoration.spec.attributionKey : undefined } : {}) } : undefined }; // Recalculate decorations with the new active index const { contributorTags: updatedContributorTags, decorations: updatedDecorations, diffDescriptors: updatedDiffDescriptors } = calculateDiffDecorations({ state: newState, pluginState: newPluginState, nodeViewSerializer, colorScheme: (_newPluginState$color2 = (_newPluginState1 = newPluginState) === null || _newPluginState1 === void 0 ? void 0 : _newPluginState1.colorScheme) !== null && _newPluginState$color2 !== void 0 ? _newPluginState$color2 : config === null || config === void 0 ? void 0 : config.colorScheme, intl: getIntl(), activeIndexPos: newPluginState.activeIndexPos, api, tagMountContext, isInverted: newPluginState.isInverted, diffType: newPluginState.diffType, hideDeletedDiffs: newPluginState.hideDeletedDiffs, hideAddedDiffsUnderline: newPluginState.hideAddedDiffsUnderline, showIndicators: newPluginState.showIndicators, smartThresholds: newPluginState.smartThresholds, deletedDiffPlacement: newPluginState.deletedDiffPlacement, inlineDeletedDiffPlacement: newPluginState.inlineDeletedDiffPlacement }); newPluginState.decorations = updatedDecorations; newPluginState.contributorTags = updatedContributorTags; newPluginState.diffDescriptors = updatedDiffDescriptors; } } else { newPluginState = { ...currentPluginState, ...meta }; } } const mappedDecorations = newPluginState.decorations.map(tr.mapping, tr.doc); const nextState = { ...newPluginState, decorations: mappedDecorations }; if (tr.docChanged) { nextState.contributorTags = dropOrphanedContributorTags(newPluginState.contributorTags, mappedDecorations); } return nextState; } }, view(editorView) { setNodeViewSerializer(editorView); currentEditorView = editorView; onEditorView === null || onEditorView === void 0 ? void 0 : onEditorView(editorView); let isFirst = true; let previousActiveIndex; let cancelPendingScrollToDecoration = null; return { update(view) { // If we're using configuration to show diffs we initialise here once we setup the editor view if (config !== null && config !== void 0 && config.originalDoc && config !== null && config !== void 0 && config.steps && config.steps.length > 0 && isFirst) { isFirst = false; view.dispatch(view.state.tr.setMeta(showDiffPluginKey, { action: 'SHOW_DIFF', steps: config.steps.map(step => ProseMirrorStep.fromJSON(view.state.schema, step)), originalDoc: processRawValue(view.state.schema, config.originalDoc) })); } const pluginState = showDiffPluginKey.getState(view.state); // A repaint rebuilds inline decorations, destroying any animation bound to them, so // an in-flight reveal has to be re-attached to the new nodes. No-op when idle. rebindReveal(view, view.dom); // Scroll to the first decoration when scrollIntoView was requested. // Use the same filtered/position-sorted list as the active-index path // so "first" reliably means the topmost scrollable diff in the document. if (pluginState !== null && pluginState !== void 0 && pluginState.scrollIntoView) { var _cancelPendingScrollT; (_cancelPendingScrollT = cancelPendingScrollToDecoration) === null || _cancelPendingScrollT === void 0 ? void 0 : _cancelPendingScrollT(); cancelPendingScrollToDecoration = scrollToDecoration(view, getScrollableDecorations(pluginState.decorations, view.state.doc), undefined, api); // Reset the flag so we don't scroll again on subsequent updates view.dispatch(view.state.tr.setMeta(showDiffPluginKey, { scrollIntoView: false })); } // Check for any potential scroll side-effects const activeIndexChanged = (pluginState === null || pluginState === void 0 ? void 0 : pluginState.activeIndex) !== undefined && pluginState.activeIndex !== previousActiveIndex; previousActiveIndex = pluginState === null || pluginState === void 0 ? void 0 : pluginState.activeIndex; if ((pluginState === null || pluginState === void 0 ? void 0 : pluginState.activeIndex) !== undefined && activeIndexChanged) { var _cancelPendingScrollT2; (_cancelPendingScrollT2 = cancelPendingScrollToDecoration) === null || _cancelPendingScrollT2 === void 0 ? void 0 : _cancelPendingScrollT2(); const scrollableDecorations = getScrollableDecorations(pluginState.decorations, view.state.doc); const activeDecoration = scrollableDecorations[pluginState.activeIndex]; if (activeDecoration) { // EDITOR-7926: use editor-common's command instead of the expand plugin API to // avoid a circular dependency; expand picks up the meta if loaded (no-op if not). api === null || api === void 0 ? void 0 : api.core.actions.execute(toggleExpandRange(activeDecoration.from, activeDecoration.to, true)); } cancelPendingScrollToDecoration = scrollToDecoration(view, scrollableDecorations, pluginState.activeIndex, api); // Stepping only scrolls — no focus moves and no content changes — so without this a // screen-reader user gets silence. Announced through the live region rather than by // focusing the change, since the "next change" control has to stay focused to be // pressed again. After the scroll above, so the announcement never precedes it. const announcement = getActiveDiffAnnouncement({ activeIndex: pluginState.activeIndex, contributorTags: pluginState.contributorTags, decorations: scrollableDecorations, intl: getIntl() }); if (announcement) { var _api$accessibilityUti; api === null || api === void 0 ? void 0 : (_api$accessibilityUti = api.accessibilityUtils) === null || _api$accessibilityUti === void 0 ? void 0 : _api$accessibilityUti.actions.ariaNotify(announcement); } } }, destroy() { var _cancelPendingScrollT3; (_cancelPendingScrollT3 = cancelPendingScrollToDecoration) === null || _cancelPendingScrollT3 === void 0 ? void 0 : _cancelPendingScrollT3(); cancelPendingScrollToDecoration = null; currentEditorView = undefined; onEditorView === null || onEditorView === void 0 ? void 0 : onEditorView(undefined); } }; }, props: { decorations: state => { const pluginState = showDiffPluginKey.getState(state); return pluginState === null || pluginState === void 0 ? void 0 : pluginState.decorations; } } }); };