@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
125 lines (116 loc) • 6.89 kB
JavaScript
import { DiffDecorationKey, isDiffDecoration, isDiffDecorationSpec } from './decorations/decorationKeys';
import { isExtendedEnabled } from './isExtendedEnabled';
/**
* True if `fragment` contains at least one inline node (text, hardBreak, emoji, mention, etc.).
* Block-only subtrees (e.g. empty paragraphs, block cards with no inline children) return false.
*/
function fragmentContainsInlineContent(fragment) {
for (let i = 0; i < fragment.childCount; i++) {
const node = fragment.child(i);
if (node.isInline) {
return true;
}
if (node.content.size > 0 && fragmentContainsInlineContent(node.content)) {
return true;
}
}
return false;
}
/**
* Returns true when an inline decoration's [from, to) range can actually show in the document:
* positions are valid, and the slice contains at least one inline node ProseMirror would paint
* (not only empty block wrappers or block-only structure).
*/
export function isInlineDiffDecorationRenderableInDoc(doc, from, to) {
try {
const slice = doc.slice(from, to);
return fragmentContainsInlineContent(slice.content);
} catch {
return false;
}
}
/**
* Checks if range1 is fully contained within range2
*/
function isRangeFullyInside(range1Start, range1End, range2Start, range2End) {
return range2Start <= range1Start && range1End <= range2End;
}
function specHasDiffKeyPrefix(spec, keyPrefix) {
return Boolean(spec && typeof spec === 'object' && 'key' in spec && typeof spec.key === 'string' && spec.key.startsWith(keyPrefix));
}
/**
* Gets scrollable decorations from a DecorationSet, filtering out overlapping decorations
* and applying various rules for diff visualization.
*
* Rules:
* 1. Only includes diff-inline, diff-widget-* and diff-block decorations
* 2. Excludes listItem diff-block decorations (never scrollable)
* 3. Deduplicates diff-block decorations with same from, to and nodeName
* 4. When `doc` is passed: excludes diff-inline decorations whose range has no inline content
* (invalid positions, or block-only slices with no text/atoms — e.g. empty blocks)
* 5. Excludes diff-inline decorations that are fully contained within a diff-block
* 6. Excludes diff-block decorations that are fully contained within a diff-inline
* 7. Results are sorted by from position, then by to position
*
* @param set - The DecorationSet to extract scrollable decorations from
* @param doc - Current document; when set, diff-inline ranges are validated against this doc
* @returns Array of scrollable decorations, sorted and deduplicated
*/
export const getScrollableDecorations = (set, doc, diffType) => {
if (!set) {
return [];
}
const isBlockDecoration = decoration => {
var _decoration$spec$key$, _decoration$spec, _decoration$spec$key;
return isExtendedEnabled(diffType) ? isDiffDecoration(decoration) && decoration.spec.decorationType === 'block' : (_decoration$spec$key$ = (_decoration$spec = decoration.spec) === null || _decoration$spec === void 0 ? void 0 : (_decoration$spec$key = _decoration$spec.key) === null || _decoration$spec$key === void 0 ? void 0 : _decoration$spec$key.startsWith(DiffDecorationKey.block)) !== null && _decoration$spec$key$ !== void 0 ? _decoration$spec$key$ : false;
};
const isInlineDecoration = decoration => {
var _decoration$spec$key$2, _decoration$spec2, _decoration$spec2$key;
return isExtendedEnabled(diffType) ? isDiffDecoration(decoration) && decoration.spec.decorationType === 'inline' : (_decoration$spec$key$2 = (_decoration$spec2 = decoration.spec) === null || _decoration$spec2 === void 0 ? void 0 : (_decoration$spec2$key = _decoration$spec2.key) === null || _decoration$spec2$key === void 0 ? void 0 : _decoration$spec2$key.startsWith(DiffDecorationKey.inline)) !== null && _decoration$spec$key$2 !== void 0 ? _decoration$spec$key$2 : false;
};
const isWidgetDecoration = decoration => {
var _decoration$spec$key$3, _decoration$spec3, _decoration$spec3$key;
return isExtendedEnabled(diffType) ? isDiffDecoration(decoration) && decoration.spec.decorationType === 'widget' : (_decoration$spec$key$3 = (_decoration$spec3 = decoration.spec) === null || _decoration$spec3 === void 0 ? void 0 : (_decoration$spec3$key = _decoration$spec3.key) === null || _decoration$spec3$key === void 0 ? void 0 : _decoration$spec3$key.startsWith(DiffDecorationKey.widget)) !== null && _decoration$spec$key$3 !== void 0 ? _decoration$spec$key$3 : false;
};
const seenBlockKeys = new Set();
const allDecorations = isExtendedEnabled(diffType) ? set.find(undefined, undefined, isDiffDecorationSpec) : set.find(undefined, undefined, spec => specHasDiffKeyPrefix(spec, DiffDecorationKey.inline) || specHasDiffKeyPrefix(spec, DiffDecorationKey.widget) || specHasDiffKeyPrefix(spec, DiffDecorationKey.block));
// First pass: filter out listItem blocks and deduplicates blocks
const filtered = allDecorations.filter(dec => {
if (!isBlockDecoration(dec) && !isInlineDecoration(dec) && !isWidgetDecoration(dec)) {
return false;
}
if (isBlockDecoration(dec)) {
var _dec$spec, _dec$spec$nodeName;
// Skip listItem blocks as they are not scrollable
if (((_dec$spec = dec.spec) === null || _dec$spec === void 0 ? void 0 : _dec$spec.nodeName) === 'listItem') return false;
const key = `${dec.from}-${dec.to}-${(_dec$spec$nodeName = dec.spec.nodeName) !== null && _dec$spec$nodeName !== void 0 ? _dec$spec$nodeName : ''}`;
// Skip blocks that have already been seen
if (seenBlockKeys.has(key)) return false;
seenBlockKeys.add(key);
}
return true;
});
// Separate decorations by type for easier processing
const blocks = filtered.filter(isBlockDecoration);
const rawInlines = filtered.filter(isInlineDecoration);
const inlines = doc !== undefined ? rawInlines.filter(d => isInlineDiffDecorationRenderableInDoc(doc, d.from, d.to)) : rawInlines;
const widgets = filtered.filter(isWidgetDecoration);
// Second pass: exclude overlapping decorations
// Rules:
// - If an inline is fully inside a block, exclude the block (inline takes priority)
// - If a block is fully inside an inline, exclude the block (inline takes priority)
const nonOverlappingBlocks = blocks.filter(block => {
// Exclude block if:
// 1. It's fully contained within any inline, OR
// 2. It fully contains any inline
return !inlines.some(inline => isRangeFullyInside(block.from, block.to, inline.from, inline.to) ||
// block inside inline
isRangeFullyInside(inline.from, inline.to, block.from, block.to) // inline inside block
);
});
// Combine all non-overlapping decorations
const result = [...nonOverlappingBlocks, ...inlines, ...widgets];
// Sort by from position, then by to position
result.sort((a, b) => a.from === b.from ? a.to - b.to : a.from - b.from);
return result;
};