UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

190 lines (180 loc) • 8.56 kB
import isEqual from 'lodash/isEqual'; import omit from 'lodash/omit'; import { SetAttrsStep } from '@atlaskit/adf-schema/steps/set-attrs'; import { getBaseNodeTypeName } from '@atlaskit/editor-common/utils/node-type-utils'; import { AttrStep } from '@atlaskit/editor-prosemirror/transform'; import { fg } from '@atlaskit/platform-feature-flags/fg'; import { getRequiredIncludedDiffableAttrs, isDiffableAttr } from './diffableAttrs'; import { getComparableExcerptIncludeAttrs, getReferencedContentId, isExcerptInclude } from './excerptIncludeAttrs'; const filterUndefined = x => !!x; // Attr lists sourced from the shared `diffableAttrs` map (shared with the encoder). const mediaAttrs = getRequiredIncludedDiffableAttrs('media'); const dateAttrs = getRequiredIncludedDiffableAttrs('date'); const taskItemAttrs = getRequiredIncludedDiffableAttrs('taskItem'); const panelAttrs = getRequiredIncludedDiffableAttrs('panel'); const emojiAttrs = getRequiredIncludedDiffableAttrs('emoji'); const mentionAttrs = getRequiredIncludedDiffableAttrs('mention'); const statusAttrs = getRequiredIncludedDiffableAttrs('status'); // Map of node type name → the attrs that represent a meaningful content change for that node const inlineNodeAttrMap = { date: dateAttrs, emoji: emojiAttrs, mention: mentionAttrs, status: statusAttrs }; export const isInlineAttrChangeNodeName = nodeName => nodeName in inlineNodeAttrMap; // Extension node type names. Their "any attr except localId" exclude rule lives // in the shared `diffableAttrs` map and is applied via `isDiffableAttr` below. const extensionNodeNames = ['extension', 'inlineExtension', 'bodiedExtension']; const transientExtensionAttrPaths = ['localId', 'parameters.macroParams._parentId']; /** * Removes extension metadata that can change between document versions without changing the * extension's user-visible configuration. */ const getComparableExtensionAttrs = node => omit(node.attrs, transientExtensionAttrPaths); const haveSameRelevantExtensionAttrs = (beforeNode, afterNode) => { if (fg('platform_editor_normalize_excerpt_diff') && isExcerptInclude(beforeNode.attrs) && isExcerptInclude(afterNode.attrs)) { const beforeReference = getReferencedContentId(beforeNode.attrs); const afterReference = getReferencedContentId(afterNode.attrs); return isEqual(getComparableExcerptIncludeAttrs(beforeNode.attrs), getComparableExcerptIncludeAttrs(afterNode.attrs)) && (beforeReference === undefined || afterReference === undefined || beforeReference === afterReference); } return isEqual(getComparableExtensionAttrs(beforeNode), getComparableExtensionAttrs(afterNode)); }; const getStepAttrs = step => { if (step instanceof AttrStep) { return [step.attr]; } if (step.attrs) { return Object.keys(step.attrs); } return []; }; export const getAttrChangeRanges = (doc, attrStepContexts, originalDoc) => { return attrStepContexts.map(attrStepContext => { const { afterNode, attributionKey, beforeNode, finalPos, originalPos, step } = attrStepContext; if (!(step instanceof AttrStep) && !(step instanceof SetAttrsStep)) { return undefined; } const stepAttrs = getStepAttrs(step); // SetAttrsStep contains the complete replacement attrs, not only the attrs that changed, // so narrow it to the attrs whose values actually differ. Fall back to the full step // payload if either step-time node is unavailable. const attrsToCheck = beforeNode && afterNode ? stepAttrs.filter(attrName => !isEqual(beforeNode.attrs[attrName], afterNode.attrs[attrName])) : stepAttrs; const $pos = doc.resolve(finalPos); const nodeAtPos = doc.nodeAt(finalPos); const originalNodeAtPos = originalPos === undefined ? null : originalDoc.nodeAt(originalPos); const hasChangedAttr = watchedAttrs => attrsToCheck.some(attr => watchedAttrs.includes(attr) && (!fg('confluence_ncs_step_diffing_version_history') || !originalNodeAtPos || originalNodeAtPos.type !== (nodeAtPos === null || nodeAtPos === void 0 ? void 0 : nodeAtPos.type) || !isEqual(originalNodeAtPos.attrs[attr], nodeAtPos === null || nodeAtPos === void 0 ? void 0 : nodeAtPos.attrs[attr]))); // The changeset path (createDecorationsForChange) handles the deletion widget via // prosemirror-changeset; we only need to add the inline insertion highlight here. if (nodeAtPos) { const nodeName = nodeAtPos.type.name; if (isInlineAttrChangeNodeName(nodeName)) { const watchedAttrs = inlineNodeAttrMap[nodeName]; if (hasChangedAttr(watchedAttrs)) { return { ...(attributionKey ? { attributionKey } : {}), fromB: finalPos, toB: finalPos + nodeAtPos.nodeSize, isInline: true, inlineNodeName: nodeName, ...(originalPos !== undefined && originalNodeAtPos && { fromA: originalPos, toA: originalPos + originalNodeAtPos.nodeSize }) }; } } } // taskItem node: state attribute change — highlight the taskItem node if (attrsToCheck.some(v => taskItemAttrs.includes(v)) && (nodeAtPos === null || nodeAtPos === void 0 ? void 0 : nodeAtPos.type.name) === 'taskItem') { return { ...(attributionKey ? { attributionKey } : {}), fromB: finalPos, toB: finalPos + nodeAtPos.nodeSize }; } // panel node (incl. variants like panel_c1): type/colour/icon attribute change // (e.g. note -> warning) — highlight the new panel and, when the original node // is resolvable, expose its range (fromA/toA) so the caller can render the old // panel as a "deleted" widget for a before/after comparison. if (hasChangedAttr(panelAttrs) && nodeAtPos && getBaseNodeTypeName(nodeAtPos.type) === 'panel') { return { ...(attributionKey ? { attributionKey } : {}), fromB: finalPos, toB: finalPos + nodeAtPos.nodeSize, ...(originalPos !== undefined && originalNodeAtPos && { fromA: originalPos, toA: originalPos + originalNodeAtPos.nodeSize }) }; } // Extension nodes: highlight changes to user-visible configuration. Transient extension // metadata is ignored by the comparison below. if (nodeAtPos && extensionNodeNames.includes(nodeAtPos.type.name) && attrsToCheck.some(v => isDiffableAttr(nodeAtPos.type.name, v))) { if (beforeNode && afterNode && beforeNode.type === afterNode.type && haveSameRelevantExtensionAttrs(beforeNode, afterNode)) { return undefined; } const isInline = nodeAtPos.type.name === 'inlineExtension'; return { ...(attributionKey ? { attributionKey } : {}), fromB: finalPos, toB: finalPos + nodeAtPos.nodeSize, isInline }; } // media node: id/collection/url attribute change — highlight the mediaSingle parent if (attrsToCheck.some(v => mediaAttrs.includes(v)) && $pos.parent.type === doc.type.schema.nodes.mediaSingle) { const startPos = $pos.pos + $pos.parentOffset; return { ...(attributionKey ? { attributionKey } : {}), fromB: startPos, toB: startPos + $pos.parent.nodeSize - 1 }; } return undefined; }).filter(filterUndefined) // Deduplicate by node position: multiple AttrSteps on the same node // (e.g. setNodeAttribute(pos, 'text', ...) + setNodeAttribute(pos, 'color', ...)) // should produce only one decoration, not one per step. .filter((range, i, arr) => fg('confluence_ncs_step_diffing_version_history') ? !arr.some((r, nextIndex) => nextIndex > i && r.fromB === range.fromB) : arr.findIndex(r => r.fromB === range.fromB) === i); }; /** * Check if the step was a valid attr change and affected the doc * * @param step Attr step to test * @param beforeDoc Doc before the step * @param afterDoc Doc after the step * @returns Boolean if the change should show a decoration */ export const stepIsValidAttrChange = (step, beforeDoc, afterDoc) => { try { if (step instanceof AttrStep || step instanceof SetAttrsStep) { const attrStepAfter = afterDoc.nodeAt(step.pos); const attrStepBefore = beforeDoc.nodeAt(step.pos); // The change affected the document if (attrStepAfter && attrStepBefore && !attrStepAfter.eq(attrStepBefore)) { return true; } } return false; } catch { return false; } };