UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

203 lines (191 loc) • 9.54 kB
import { Decoration } from '@atlaskit/editor-prosemirror/view'; import { fg } from '@atlaskit/platform-feature-flags/fg'; import { isDiffDecoration, isDiffDecorationSpec } from './decorations/decorationKeys'; /** * 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; } } function isRangeFullyInside(range1Start, range1End, range2Start, range2End) { return range2Start <= range1Start && range1End <= range2End; } /** * Where a decoration paints relative to others at the same position: negative before the node at * that position, positive after it. Only deleted-content widgets carry one — everything else is * ranged, and so paints where its range starts. */ function decorationSide(decoration) { return isDiffDecoration(decoration) && decoration.spec.side || 0; } /** * Whose change a decoration is part of, or `''` on an unattributed diff — where every decoration * reads as one contributor and the grouping below is unchanged. * * Contributors are kept apart so one stop never covers two of them, since only the tag on the * change stepped to reveals (EDITOR-8932). */ function contributorOf(decoration) { return isDiffDecoration(decoration) && decoration.spec.attributionKey || ''; } /** * Collapses decorations that represent one continuous customer-facing edit. * * Decorations are kept separate in the DecorationSet so each one can retain its own visual * styling. This list is only used for change counting and navigation, where an insertion and a * deletion at the same location should be treated as one replacement. Zero-width decorations * (normally deleted-content widgets) can join a group, but cannot extend its range and therefore * cannot bridge two otherwise separate edits. * * One group per contributor within a run of touching ranges — see `contributorOf`. */ function groupTouchingDecorations(decorations, isInlineDecoration) { if (decorations.length < 2) { return decorations; } const groups = []; // The groups a following decoration can still join — one per contributor, dropped at every gap. let openGroups = []; let clusterTo = 0; const sortedDecorations = [...decorations].sort((a, b) => a.from === b.from ? a.to - b.to : a.from - b.from); sortedDecorations.forEach(decoration => { // A gap ends the run: nothing before it can be joined. if (decoration.from > clusterTo) { openGroups = []; } const contributor = contributorOf(decoration); const openGroup = openGroups.find(group => group.contributor === contributor); if (openGroup === undefined) { const group = { contributor, decorations: [decoration], from: decoration.from, to: decoration.to }; groups.push(group); openGroups = [...openGroups, group]; } else { openGroup.decorations.push(decoration); openGroup.to = Math.max(openGroup.to, decoration.to); } // A zero-width decoration must not extend the run and bridge a gap. clusterTo = Math.max(clusterTo, decoration.to); }); return groups.map(({ decorations: group, from, to }) => { var _ref, _group$find; const representative = (_ref = (_group$find = group.find(isInlineDecoration)) !== null && _group$find !== void 0 ? _group$find : group.find(decoration => decoration.from !== decoration.to)) !== null && _ref !== void 0 ? _ref : group[0]; if (!representative) { return representative; } // Deleted content is a widget at the start of the added content that replaced it, on a more // negative side so it paints above. That makes it the visual start of the edit, reachable // only through its own DOM — resolving the group's start position lands on the added content // painted after it. The group keeps its range, which navigation and the active-range // calculation both need, and reports the widget as what to scroll to. const scrollTarget = fg('platform_editor_ai_show_diff_patch_1') ? group.find(decoration => decoration.from === from && decorationSide(decoration) < decorationSide(representative)) : undefined; if (!scrollTarget && representative.from === from && representative.to === to) { return representative; } // This decoration is only used for navigation and active-range calculation. The actual // visual decorations remain in the DecorationSet with their original ranges and styles — // including their spec, hence a copy to add `scrollTarget` for `scrollToDiff` to read. const spec = { ...representative.spec, scrollTarget }; return isInlineDecoration(representative) ? Decoration.inline(from, to, {}, spec) : Decoration.node(from, to, {}, spec); }); } /** * 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. When `confluence_ncs_step_diffing_version_history` is enabled, groups overlapping or * directly touching ranges across decoration types into one result per contributor, using the * union of all grouped ranges * (zero-width widgets can join a group without extending it). Under * `platform_editor_ai_show_diff_patch_1`, a group that starts with content painting above * the content that replaced it — reports that widget as its `scrollTarget` spec, * so scrolling reaches the visual start of the edit * 6. 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) => { if (!set) { return []; } const isBlockDecoration = decoration => isDiffDecoration(decoration) && decoration.spec.decorationType === 'block'; const isInlineDecoration = decoration => isDiffDecoration(decoration) && decoration.spec.decorationType === 'inline'; const isWidgetDecoration = decoration => isDiffDecoration(decoration) && decoration.spec.decorationType === 'widget'; const seenBlockKeys = new Set(); const allDecorations = set.find(undefined, undefined, isDiffDecorationSpec); // 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); let result; if (fg('confluence_ncs_step_diffing_version_history')) { // Group overlapping or directly touching ranges into one customer-facing edit. result = groupTouchingDecorations([...blocks, ...inlines, ...widgets], isInlineDecoration); } else { // Legacy behavior: exclude blocks that contain or are contained by an inline decoration. const nonOverlappingBlocks = blocks.filter(block => !inlines.some(inline => isRangeFullyInside(block.from, block.to, inline.from, inline.to) || isRangeFullyInside(inline.from, inline.to, block.from, block.to))); 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; };