@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
190 lines (180 loc) • 8.56 kB
JavaScript
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;
}
};