diff --git a/packages/layout-engine/contracts/src/index.test.ts b/packages/layout-engine/contracts/src/index.test.ts index 51286c7172..9496677ac9 100644 --- a/packages/layout-engine/contracts/src/index.test.ts +++ b/packages/layout-engine/contracts/src/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { cloneColumnLayout, extractHeaderFooterSpace, normalizeColumnLayout, widthsEqual } from './index.js'; -import type { FlowBlock, Layout } from './index.js'; +import type { FlowBlock, Layout, TableRow, TrackedChangeMeta } from './index.js'; describe('contracts', () => { it('accepts a basic FlowBlock structure', () => { @@ -85,4 +85,18 @@ describe('contracts', () => { }); expect(normalizeColumnLayout({ count: 2, gap: 24 }, 624).widths).toEqual([300, 300]); }); + + it('TrackedChangeMeta accepts an optional operationId', () => { + const meta: TrackedChangeMeta = { kind: 'delete', id: 'r1', operationId: 'op-table-1' }; + expect(meta.operationId).toBe('op-table-1'); + }); + + it('TableRow accepts an optional trackedChange field carrying row-level metadata', () => { + const row: TableRow = { + id: 'row-1', + cells: [], + trackedChange: { kind: 'insert', id: 'r1', operationId: 'op-table-1' }, + }; + expect(row.trackedChange?.kind).toBe('insert'); + }); }); diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index d5ece58c9d..46e8275132 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -366,6 +366,12 @@ export type TrackedChangeMeta = { id: string; overlapParentId?: string; relationship?: 'parent' | 'child' | 'standalone'; + /** + * Optional grouping key. Tracked changes sharing a non-empty `operationId` + * are resolved together — e.g. every row of a deleted table shares one + * `operationId` and the review surface collapses them into one entry. + */ + operationId?: string; /** * Internal story key identifying which content story owns this tracked * change (`'body'`, `'hf:part:…'`, `'fn:…'`, `'en:…'`). @@ -926,6 +932,12 @@ export type TableRow = { cells: TableCell[]; attrs?: TableRowAttrs; sourceAnchor?: SourceAnchor; + /** + * Row-level tracked-change metadata. Populated by pm-adapter from the + * ProseMirror `trackChange` node attribute. The painter reads this and + * stamps `data-track-change*` on the cell DOM elements. + */ + trackedChange?: TrackedChangeMeta; }; export type TableBlock = { diff --git a/packages/layout-engine/layout-bridge/src/cache.ts b/packages/layout-engine/layout-bridge/src/cache.ts index fa3d1a515d..8b990ded4a 100644 --- a/packages/layout-engine/layout-bridge/src/cache.ts +++ b/packages/layout-engine/layout-bridge/src/cache.ts @@ -306,6 +306,21 @@ const hashRuns = (block: FlowBlock): string => { continue; } + // Row-level tracked change (block-level diff replay sets `trackedChange` + // on a tableRow's attrs). Without folding it into the measure-cache + // fingerprint, applyHunks-style transactions that only mutate the row's + // `trackChange` attr don't invalidate the cache, so the page is never + // re-measured and the visible cells never receive their decoration + // classes. Read from `row.attrs.trackedChange` — the canonical location + // written by the v1 layout-adapter from the OOXML-aligned + // `attrs.trackChange.type` shape. Mirror of the painter page-fingerprint + // fix in renderer.ts and the canonical-version fix in + // layout-resolved/versionSignature.ts. + const rowTC = row.attrs?.trackedChange; + if (rowTC) { + cellHashes.push(`rtc:${rowTC.kind ?? ''}:${rowTC.id ?? ''}:${rowTC.operationId ?? ''}`); + } + for (const cell of row.cells) { // Include cell-level attributes that affect rendering (borders, padding, etc.) // This ensures cache invalidation when cell formatting changes (e.g., remove borders). diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 672a866dc2..0b76046c96 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -533,6 +533,20 @@ export const deriveBlockVersion = (block: FlowBlock): string => { for (const row of rows) { if (!row || !Array.isArray(row.cells)) continue; hash = hashNumber(hash, row.cells.length); + // Row-level tracked change (block-level diff replay sets `trackedChange` + // on a tableRow's attrs). Without folding it into the canonical block + // version, applyHunks-style transactions that only mutate the row's + // `trackChange` attr don't bump the version, so the resolved-layout + // pipeline reuses cached entries and the painter never sees the update. + // Read from `row.attrs.trackedChange` — the canonical location written + // by the v1 layout-adapter from the OOXML-aligned `attrs.trackChange.type` + // shape. Mirror of the fixes in renderer.ts and layout-bridge/cache.ts. + const rowTC = row.attrs?.trackedChange; + if (rowTC) { + hash = hashString(hash, rowTC.kind ?? ''); + hash = hashString(hash, rowTC.id ?? ''); + hash = hashString(hash, rowTC.operationId ?? ''); + } for (const cell of row.cells) { if (!cell) continue; const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts index 2a4b99b983..bfc2792169 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts @@ -5,6 +5,7 @@ import type { ParagraphMeasure, ResolvedFragmentItem, SdtMetadata, + TrackedChangeMeta, } from '@superdoc/contracts'; import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; @@ -13,6 +14,7 @@ import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js'; import { allowFontSynthesis } from '../runs/font-synthesis.js'; +import { applyBlockTrackedChangeToParagraph, resolveTrackedChangesConfig } from '../runs/tracked-changes.js'; import type { BetweenBorderInfo } from './borders/index.js'; import { renderParagraphContent, type ParagraphRenderLineInput } from './renderParagraphContent.js'; @@ -152,6 +154,16 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams): contentControlsChrome, }); + // Whole-paragraph structural tracked change (insert/delete). The adapter + // stamped paint-ready meta onto block.attrs.trackedChange; decorate the + // fragment so a deleted paragraph strikes through (and collapses in 'final' + // mode) instead of leaving an empty bullet — the paragraph analogue of the + // row-cell decoration. + const blockTrackedChange = (block.attrs as { trackedChange?: TrackedChangeMeta } | undefined)?.trackedChange; + if (blockTrackedChange) { + applyBlockTrackedChangeToParagraph(fragmentEl, blockTrackedChange, resolveTrackedChangesConfig(block)); + } + return fragmentEl; } catch (error) { console.error('[DomPainter] Fragment rendering failed:', { fragment, error }); diff --git a/packages/layout-engine/painters/dom/src/runs/tracked-changes.ts b/packages/layout-engine/painters/dom/src/runs/tracked-changes.ts index 45fb421098..2aa2555e10 100644 --- a/packages/layout-engine/painters/dom/src/runs/tracked-changes.ts +++ b/packages/layout-engine/painters/dom/src/runs/tracked-changes.ts @@ -203,6 +203,62 @@ export const applyRowTrackedChangeToCell = ( } }; +/** + * Block-context marker for a whole-paragraph structural tracked change. Pairs + * with the same base/modifier classes the inline + row paths use; the block + * CSS targets it without colliding with the inline `.track-*-dec` span rules. + */ +const TRACK_CHANGE_BLOCK_CLASS = 'track-block-dec'; + +/** + * Applies a structural block-level tracked change (inserted/deleted whole + * paragraph) to its paragraph fragment element. The paragraph analogue of + * {@link applyRowTrackedChangeToCell}: same `TrackedChangeMeta`, base classes + * (`track-insert-dec` / `track-delete-dec`), mode modifier map, and per-author + * color variables. `hidden` mode collapses the paragraph (insert in 'original', + * delete in 'final') via the shared `.track-*-dec.hidden { display: none }` + * rule, matching inline + row behavior. + */ +export const applyBlockTrackedChangeToParagraph = ( + elem: HTMLElement, + meta: TrackedChangeMeta, + config: TrackedChangesRenderConfig, +): void => { + if (!config.enabled || config.mode === 'off') { + return; + } + if (meta.kind !== 'insert' && meta.kind !== 'delete') { + return; + } + + const baseClass = TRACK_CHANGE_BASE_CLASS[meta.kind]; + if (baseClass) { + elem.classList.add(baseClass); + } + elem.classList.add(TRACK_CHANGE_BLOCK_CLASS); + + const modifier = TRACK_CHANGE_MODIFIER_CLASS[meta.kind]?.[config.mode]; + if (modifier) { + elem.classList.add(modifier); + } + + applyAuthorColorVariables(elem, meta); + + elem.dataset.trackChangeId = meta.id; + elem.dataset.trackChangeKind = meta.kind; + elem.dataset.trackChangeStructural = 'block'; + elem.dataset.storyKey = meta.storyKey ?? 'body'; + if (meta.author) { + elem.dataset.trackChangeAuthor = meta.author; + } + if (meta.authorEmail) { + elem.dataset.trackChangeAuthorEmail = meta.authorEmail; + } + if (meta.date) { + elem.dataset.trackChangeDate = meta.date; + } +}; + export const applyTrackedChangeDecorations = ( elem: HTMLElement, run: Run, diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 7a7c8c71c0..c89378a653 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -484,4 +484,23 @@ describe('ensureTrackChangeStyles', () => { ); expect(cssText).not.toMatch(/track-format-dec\.highlighted\.track-change-focused\s*\{[\s\S]*border-bottom-width:/); }); + + it('block-level (row-cell) tracked-change rules are scoped to .superdoc-layout, not bare selectors', () => { + ensureTrackChangeStyles(document); + + const styleEl = document.querySelector('[data-superdoc-track-change-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + + // Scoped row-cell selectors present (upstream replaced the previous + // `[data-track-change='...']` attribute selectors with class-based + // `.track-row-cell-dec.track-insert-dec.highlighted` etc. that + // `applyRowTrackedChangeToCell` adds). + expect(cssText).toContain('.superdoc-layout .track-row-cell-dec.track-insert-dec.highlighted'); + expect(cssText).toContain('.superdoc-layout .track-row-cell-dec.track-delete-dec.highlighted'); + + // No bare `.track-row-cell-dec { ... }` selector — would leak the + // row-cell decoration outside the painted layout (e.g. into the PM + // contenteditable mirror or any host page using the same class name). + expect(cssText).not.toMatch(/^\s*\.track-row-cell-dec[.\s{]/m); + }); }); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 88adb80858..e1a0ec8bd3 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -440,6 +440,31 @@ const TRACK_CHANGE_STYLES = ` var(--sd-tracked-changes-delete-text, #cb0e47) var(--sd-tracked-changes-delete-decoration-thickness, 2px); } + +/* + * Block-level (whole-paragraph) structural tracked changes. The paragraph + * analogue of the row-cell rules above: the paragraph fragment carries the same + * base class (track-insert-dec / track-delete-dec) + modifier (highlighted / + * hidden) plus the block-context marker class track-block-dec. 'hidden' mode + * collapses the paragraph via the shared .track-*-dec.hidden { display: none } + * rule, so an inserted paragraph in 'original' mode and a deleted paragraph in + * 'final' mode disappear — matching inline + row behavior. + */ +.superdoc-layout .track-block-dec.track-insert-dec.highlighted { + background-color: var(--sd-tracked-changes-insert-background, #399c7222); +} + +.superdoc-layout .track-block-dec.track-delete-dec.highlighted { + background-color: var(--sd-tracked-changes-delete-background, #cb0e4722); +} + +.superdoc-layout .track-block-dec.track-delete-dec.highlighted .superdoc-line { + text-decoration: + line-through + solid + var(--sd-tracked-changes-delete-text, #cb0e47) + var(--sd-tracked-changes-delete-decoration-thickness, 2px); +} `; const FORMATTING_MARKS_STYLES = ` diff --git a/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css b/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css index 7fd224b92c..cfa01e613c 100644 --- a/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css @@ -306,6 +306,34 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } /* Track changes - end */ +/* + * Block-level tracked changes (row granularity for v1) — contenteditable path. + * + * The data-track-change attribute is stamped on PM's via the schema's + * renderDOM. The painter-side stamping (per-cell
in layout mode) is + * styled by BLOCK_TRACK_CHANGE_STYLES in + * packages/layout-engine/painters/dom/src/styles.ts to keep document visuals + * inside the painter's stylesheet boundary. + */ +.sd-editor-scoped .ProseMirror tr[data-track-change='delete'] { + background-color: var(--sd-block-tracked-deleted-bg, rgba(203, 14, 71, 0.18)); + outline: var(--sd-block-tracked-deleted-outline-width, 2px) dashed var(--sd-block-tracked-deleted-outline, #cb0e47); + outline-offset: var(--sd-block-tracked-deleted-outline-offset, -1px); +} + +.sd-editor-scoped .ProseMirror tr[data-track-change='delete'] td, +.sd-editor-scoped .ProseMirror tr[data-track-change='delete'] th { + text-decoration: line-through; + text-decoration-color: var(--sd-block-tracked-deleted-outline, #cb0e47); +} + +.sd-editor-scoped .ProseMirror tr[data-track-change='insert'] { + background-color: var(--sd-block-tracked-inserted-bg, rgba(57, 156, 114, 0.18)); + outline: var(--sd-block-tracked-inserted-outline-width, 2px) dashed var(--sd-block-tracked-inserted-outline, #00853d); + outline-offset: var(--sd-block-tracked-inserted-outline-offset, -1px); +} +/* Block-level tracked changes - end */ + /* Collaboration cursors */ .sd-editor-scoped .ProseMirror > .ProseMirror-yjs-cursor:first-child { margin-top: 16px; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.test.ts index ec2a2feae8..f6d6424ea9 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.test.ts @@ -89,6 +89,7 @@ vi.mock('../tracked-changes.js', () => ({ shouldHideTrackedNode: vi.fn(), annotateBlockWithTrackedChange: vi.fn(), applyTrackedChangesModeToRuns: vi.fn(), + buildBlockTrackedChangeMetaFromAttr: vi.fn(() => undefined), })); vi.mock('../attributes/paragraph-styles.js', () => ({ diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts index 78a6a9450a..e640d9a93f 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts @@ -33,7 +33,11 @@ import type { ConverterContext } from '../converter-context.js'; import { computeParagraphAttrs, deepClone } from '../attributes/index.js'; import { shouldRequirePageBoundary, hasIntrinsicBoundarySignals, createSectionBreakBlock } from '../sections/index.js'; import { trackedChangesCompatible, applyMarksToRun, collectTrackedChangeFromMarks } from '../marks/index.js'; -import { applyTrackedChangesModeToRuns } from '../tracked-changes.js'; +import { + applyTrackedChangesModeToRuns, + annotateBlockWithTrackedChange, + buildBlockTrackedChangeMetaFromAttr, +} from '../tracked-changes.js'; import { textNodeToRun } from './inline-converters/text-run.js'; import { DEFAULT_HYPERLINK_CONFIG, TOKEN_INLINE_TYPES } from '../constants.js'; import { computeRunAttrs, hasExplicitParagraphRunProperties } from '../attributes/paragraph.js'; @@ -606,6 +610,14 @@ export function paragraphToFlowBlocks({ const defaultSize = usePreviousFont && previousParagraphFont.fontSize ? previousParagraphFont.fontSize : extracted.defaultSize; + // Whole-paragraph structural tracked change (insert/delete) stamped on the + // PM node's `trackChange` attr by applyHunks. Surfaced only when tracked + // changes are enabled and not 'off', matching the row + inline-mark paths. + const blockTrackedChange = + trackedChangesConfig?.enabled && trackedChangesConfig.mode !== 'off' + ? buildBlockTrackedChangeMetaFromAttr(para.attrs as Record | undefined, storyKey) + : undefined; + const finalizeParagraphBlocks = (outputBlocks: FlowBlock[]): FlowBlock[] => { outputBlocks.forEach((block) => { if (block.kind === 'paragraph') { @@ -614,6 +626,7 @@ export function paragraphToFlowBlocks({ converterContext, para, }); + annotateBlockWithTrackedChange(block, blockTrackedChange, trackedChangesConfig); } }); return outputBlocks; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts index 6820ef2e18..f9ab11a77a 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts @@ -799,12 +799,21 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => { // The PM table-row extension has both cantSplit as a top-level attr AND within tableRowProperties // For layout engine, we only need to read from tableRowProperties.cantSplit - return { + const row: TableRow = { id: context.nextBlockId(`row-${rowIndex}`), cells, attrs, sourceAnchor: sourceAnchorFromNode(rowNode), }; + + // Row-level tracked changes flow through `row.attrs.trackedChange`, populated + // above via `buildRowTrackedChangeMeta` from the OOXML-aligned + // `attrs.trackChange.type` shape. The legacy top-level `row.trackedChange` + // copy this block used to populate from the old `{ kind }` shape is gone now + // that applyHunks writes the OOXML format; the painter and cache layers all + // read `row.attrs.trackedChange`. + + return row; }; /** diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/tracked-changes.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/tracked-changes.ts index 6de09ca50c..a59d6b54c2 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/tracked-changes.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/tracked-changes.ts @@ -338,6 +338,37 @@ export const shouldHideTrackedNode = (meta: TrackedChangeMeta | undefined, confi return false; }; +/** + * Build {@link TrackedChangeMeta} from a block node's `trackChange` attribute + * (the block-level structural tracked change written by `applyHunks`). Accepts + * the canonical `{ kind: 'insert' | 'delete' }` shape used by paragraphs and + * the OOXML `{ type: 'rowInsert' | 'rowDelete' }` shape used by table rows. + * Mirrors `buildTrackedChangeMetaFromMark` (inline) and the row builder in + * `converters/table.ts`. Returns undefined when the node is untracked. + */ +export const buildBlockTrackedChangeMetaFromAttr = ( + attrs: Record | undefined, + storyKey?: string, +): TrackedChangeMeta | undefined => { + const tc = attrs?.trackChange as Record | null | undefined; + if (!tc || typeof tc !== 'object') return undefined; + const kind: TrackedChangeKind | undefined = + tc.kind === 'insert' || tc.kind === 'delete' + ? tc.kind + : tc.type === 'rowInsert' + ? 'insert' + : tc.type === 'rowDelete' + ? 'delete' + : undefined; + if (!kind) return undefined; + const meta: TrackedChangeMeta = { kind, id: deriveTrackedChangeId(kind, tc) }; + if (typeof tc.author === 'string' && tc.author) meta.author = tc.author; + if (typeof tc.authorEmail === 'string' && tc.authorEmail) meta.authorEmail = tc.authorEmail; + if (typeof tc.date === 'string' && tc.date) meta.date = tc.date; + if (typeof storyKey === 'string' && storyKey.length > 0) meta.storyKey = storyKey; + return meta; +}; + /** * Annotates a block with tracked change metadata if applicable * diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts index 41f54261d0..a4ba624f36 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/input/PresentationInputBridge.ts @@ -305,6 +305,17 @@ export class PresentationInputBridge { return null; } + // The candidate must be a SuperDoc editor: every SuperDoc editor's + // ProseMirror DOM sits inside a `.sd-editor-scoped` ancestor. Without + // this check the bridge redirects keystrokes from any unrelated + // ProseMirror editor on the page (Tiptap, Remirror, raw PM) because + // they share the same .ProseMirror[contenteditable="true"] signature. + // Class-based (not WeakSet) so story editors that the bridge has never + // explicitly owned still benefit from stale-target recovery. + if (!staleEditorTarget.closest('.sd-editor-scoped')) { + return null; + } + return { activeTarget, staleEditorTarget, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts index 4e9abb5450..a397d18ebf 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationInputBridge.test.ts @@ -264,11 +264,22 @@ describe('PresentationInputBridge - Context Menu Handling', () => { expect(forwardedEvents).toEqual(['beforeinput']); }); + // Wrap a stale ProseMirror editor in a `.sd-editor-scoped` ancestor so + // the bridge recognizes it as a SuperDoc editor (matches the production + // DOM where every SuperDoc PM editor sits inside .sd-editor-scoped). + const makeSuperDocStaleEditor = (): HTMLElement => { + const wrapper = document.createElement('div'); + wrapper.className = 'sd-editor-scoped'; + const editor = document.createElement('div'); + editor.className = 'ProseMirror'; + editor.setAttribute('contenteditable', 'true'); + wrapper.appendChild(editor); + document.body.appendChild(wrapper); + return editor; + }; + it('reroutes beforeinput from a stale hidden editor to the active target when window fallback is enabled', () => { - const staleBodyEditor = document.createElement('div'); - staleBodyEditor.className = 'ProseMirror'; - staleBodyEditor.setAttribute('contenteditable', 'true'); - document.body.appendChild(staleBodyEditor); + const staleBodyEditor = makeSuperDocStaleEditor(); const staleEvent = new InputEvent('beforeinput', { data: 'a', @@ -300,10 +311,7 @@ describe('PresentationInputBridge - Context Menu Handling', () => { }); it('reroutes non-text keyboard commands from a stale hidden editor to the active target', () => { - const staleBodyEditor = document.createElement('div'); - staleBodyEditor.className = 'ProseMirror'; - staleBodyEditor.setAttribute('contenteditable', 'true'); - document.body.appendChild(staleBodyEditor); + const staleBodyEditor = makeSuperDocStaleEditor(); const staleEvent = new KeyboardEvent('keydown', { key: 'Backspace', @@ -332,6 +340,34 @@ describe('PresentationInputBridge - Context Menu Handling', () => { expect(staleEvent.defaultPrevented).toBe(true); }); + it('does NOT reroute keystrokes from a foreign ProseMirror editor (Tiptap, Remirror, etc.)', () => { + // Reproduces the al-pmo regression: a sibling editor on the page using + // the same .ProseMirror[contenteditable="true"] signature must not have + // its keystrokes hijacked by SuperDoc's stale-editor recovery. + const foreignEditor = document.createElement('div'); + foreignEditor.className = 'tiptap ProseMirror'; + foreignEditor.setAttribute('contenteditable', 'true'); + document.body.appendChild(foreignEditor); + + const targetFocusSpy = vi.spyOn(targetDom, 'focus').mockImplementation(() => {}); + const targetDispatchSpy = vi.spyOn(targetDom, 'dispatchEvent'); + + bridge.destroy(); + bridge = new PresentationInputBridge(windowRoot, layoutSurface, getTargetDom, isEditable, undefined, { + useWindowFallback: true, + }); + bridge.bind(); + // Note: foreignEditor is NOT registered with the bridge. + + foreignEditor.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', bubbles: true, cancelable: true })); + foreignEditor.dispatchEvent( + new InputEvent('beforeinput', { data: 'h', inputType: 'insertText', bubbles: true, cancelable: true }), + ); + + expect(targetFocusSpy).not.toHaveBeenCalled(); + expect(targetDispatchSpy).not.toHaveBeenCalled(); + }); + it('does not reroute keyboard input from a registered UI surface editor', () => { const commentEditor = document.createElement('div'); commentEditor.className = 'ProseMirror'; diff --git a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js index 74df3e32a0..32f1dcdaae 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js @@ -18,6 +18,7 @@ import { v4 as uuidv4 } from 'uuid'; import { TrackDeleteMarkName, TrackFormatMarkName, TrackInsertMarkName } from '../track-changes/constants.js'; import { TrackChangesBasePluginKey } from '../track-changes/plugins/index.js'; import { getTrackChanges } from '../track-changes/trackChangesHelpers/getTrackChanges.js'; +import { enumerateStructuralRowChanges } from '../track-changes/trackChangesHelpers/structuralRowChanges.js'; import { normalizeCommentEventPayload, updatePosition } from './helpers/index.js'; const TRACK_CHANGE_MARKS = [TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName]; @@ -480,8 +481,25 @@ export const CommentsPlugin = Extension.create({ key: CommentsPluginKey, state: { - init() { + init(_config, editorState) { const highlightColors = editor.options.comments?.highlightColors || {}; + // Pick up any block-level tracked changes already present in the + // initial doc (e.g. when a docx is loaded with pre-marked rows or + // when EditorState is reconstructed via setState during tests). + const trackedChanges = {}; + let hasBlockChanges = false; + if (editorState?.doc) { + // `enumerateStructuralRowChanges` returns one entry per + // whole-table tracked change (already grouped per table + side). + for (const entry of enumerateStructuralRowChanges(editorState)) { + trackedChanges[entry.id] = { + type: entry.side === 'insertion' ? 'trackedInsert' : 'trackedDelete', + range: { from: entry.tableFrom, to: entry.tableTo }, + isBlockLevel: true, + }; + hasBlockChanges = true; + } + } return { activeThreadId: null, externalColor: highlightColors.external ?? '#B1124B', @@ -489,7 +507,8 @@ export const CommentsPlugin = Extension.create({ decorations: DecorationSet.empty, allCommentPositions: {}, allCommentIds: [], - trackedChanges: {}, + trackedChanges, + hasBlockChanges, }; }, @@ -529,19 +548,63 @@ export const CommentsPlugin = Extension.create({ }; } - // If this is a tracked change transaction, handle separately + // If this is a tracked change transaction, handle separately. + // Compute the next trackedChanges value without mutating prev state; + // ProseMirror plugin state must be treated as immutable across apply(). const trackedChangeMeta = tr.getMeta(TrackChangesBasePluginKey); - const currentTrackedChanges = pluginState.trackedChanges; + let nextTrackedChanges = pluginState.trackedChanges; + let nextHasBlockChanges = pluginState.hasBlockChanges; if (trackedChangeMeta) { - pluginState.trackedChanges = handleTrackedChangeTransaction( + nextTrackedChanges = handleTrackedChangeTransaction( trackedChangeMeta, - currentTrackedChanges, + pluginState.trackedChanges, newEditorState, editor, ); } + // Block-level tracked changes don't fire TrackChangesBasePluginKey + // meta (they're applied as PM node attrs via setNodeMarkup). When the + // doc changes we may need to refresh the block-level entries. But we + // only walk the doc when: + // - the previous state already had block changes (so they may have + // been resolved or shifted), or + // - this transaction is a structural change stamping new rows + // (applyHunks / accept-reject row commands set + // inputType='acceptReject'). + // Skipping the walk on every typing transaction in a doc with no + // block-level changes avoids re-creating trackedChanges references + // and triggering downstream view rebuilds that can re-sync DOM + // selection. + const inputTypeMeta = tr.getMeta('inputType'); + const isBlockStampingTr = inputTypeMeta === 'acceptReject'; + const shouldWalkBlock = + (tr.docChanged || trackedChangeMeta) && (pluginState.hasBlockChanges || isBlockStampingTr); + if (shouldWalkBlock) { + const blockTracked = {}; + for (const entry of enumerateStructuralRowChanges(newEditorState)) { + blockTracked[entry.id] = { + type: entry.side === 'insertion' ? 'trackedInsert' : 'trackedDelete', + range: { from: entry.tableFrom, to: entry.tableTo }, + isBlockLevel: true, + }; + } + // Drop stale block-level entries from the previous state first. + // `enumerateStructuralRowChanges` is the source of truth for + // block-level tracking, so anything no longer in the doc + // (accepted/rejected) must not leak back via the merge. Inline + // entries are owned by handleTrackedChangeTransaction and are + // kept as-is. + const previousInline = {}; + for (const [k, v] of Object.entries(nextTrackedChanges || {})) { + if (!v?.isBlockLevel) previousInline[k] = v; + } + nextTrackedChanges = { ...previousInline, ...blockTracked }; + nextHasBlockChanges = Object.keys(blockTracked).length > 0; + } + // Check for changes in the actively selected comment + let nextActiveThreadId = pluginState.activeThreadId; if (!tr.docChanged && tr.selectionSet) { const { selection } = tr; @@ -569,8 +632,7 @@ export const CommentsPlugin = Extension.create({ const isNonCollapsedClear = currentActiveThread == null && selection && selection.$from.pos !== selection.$to.pos; if (previousSelectionId !== currentActiveThread && !isNonCollapsedClear) { - // Update both the plugin state and the local variable - pluginState.activeThreadId = currentActiveThread; + nextActiveThreadId = currentActiveThread; const update = { type: comments_module_events.SELECTED, activeCommentId: currentActiveThread ? currentActiveThread : null, @@ -581,7 +643,12 @@ export const CommentsPlugin = Extension.create({ } } - return { ...pluginState }; + return { + ...pluginState, + trackedChanges: nextTrackedChanges, + hasBlockChanges: nextHasBlockChanges, + activeThreadId: nextActiveThreadId, + }; }, }, }; @@ -723,6 +790,39 @@ export const CommentsPlugin = Extension.create({ decorations.push(trackedChangeDeco); } } + + // Block-level tracked changes (tableRow with the native + // `trackChange` attr written by `stampTableRows`). Surface the + // same bubble UX inline tracked changes have. Rows that share a + // `revisionGroupId` collapse to one entry (one bubble per + // whole-table change, not per row). + const blockTrackChange = node?.attrs?.trackChange; + const blockKind = + blockTrackChange?.type === 'rowInsert' + ? 'insert' + : blockTrackChange?.type === 'rowDelete' + ? 'delete' + : null; + if (blockTrackChange && blockKind && blockTrackChange.id) { + const threadId = blockTrackChange.revisionGroupId || blockTrackChange.id; + if (!onlyActiveThreadChanged) { + let currentBounds; + try { + currentBounds = view.coordsAtPos(pos); + } catch { + currentBounds = null; + } + if (currentBounds && !allCommentPositions[threadId]) { + updatePosition({ + allCommentPositions, + threadId, + pos, + currentBounds, + node, + }); + } + } + } }); const decorationSet = DecorationSet.create(doc, decorations); diff --git a/packages/super-editor/src/editors/v1/extensions/index.js b/packages/super-editor/src/editors/v1/extensions/index.js index dcbc408aaa..7755253560 100644 --- a/packages/super-editor/src/editors/v1/extensions/index.js +++ b/packages/super-editor/src/editors/v1/extensions/index.js @@ -94,6 +94,7 @@ import { PermEnd, PermEndBlock } from './perm-end/index.js'; // Helpers import { Diffing } from './diffing/index.js'; +import { StructuralTrackChanges } from './structural-track-changes/index.js'; const getRichTextExtensions = () => { return [ @@ -243,6 +244,7 @@ const getStarterExtensions = () => { PassthroughInline, PassthroughBlock, Diffing, + StructuralTrackChanges, ]; }; @@ -307,6 +309,7 @@ export { getStarterExtensions, getRichTextExtensions, Diffing, + StructuralTrackChanges, AiMark, AiAnimationMark, AiLoaderNode, diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/index.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/index.js new file mode 100644 index 0000000000..1896f0432c --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/index.js @@ -0,0 +1,2 @@ +export { StructuralTrackChanges, computeStructuralDiff } from './structural-track-changes.js'; +export { applyHunks } from './structuralTrackChangesHelpers/applyHunks.js'; diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structural-track-changes-e2e.test.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structural-track-changes-e2e.test.js new file mode 100644 index 0000000000..219777865c --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structural-track-changes-e2e.test.js @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { Editor } from '@core/Editor.js'; +import { getStarterExtensions } from '@extensions/index.js'; +import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; +import { StructuralTrackChanges, computeStructuralDiff } from './structural-track-changes.js'; +import { enumerateStructuralRowChanges } from '../track-changes/trackChangesHelpers/structuralRowChanges.js'; + +const editorFromFixture = async (name, user) => { + const buffer = await getTestDataAsBuffer(`diffing/${name}`); + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + return new Editor({ + isHeadless: true, + extensions: [...getStarterExtensions(), StructuralTrackChanges], + documentId: `test-${name}`, + content: docx, + mode: 'docx', + media, + mediaFiles, + fonts, + annotations: true, + user, + }); +}; + +describe('StructuralTrackChanges — end-to-end with real docx fixtures', () => { + it('compute → set → accept-all on a table-removal pair removes the table from the doc', async () => { + const testUser = { name: 'Tester', email: 'test@example.com' }; + const baseEditor = await editorFromFixture('diff_before_table_remove.docx', testUser); + const afterEditor = await editorFromFixture('diff_after_table_remove.docx'); + try { + const hunks = computeStructuralDiff(baseEditor.state.doc, afterEditor.state.doc); + expect(hunks.length).toBeGreaterThan(0); + expect(baseEditor.commands.setStructuralDiff(hunks)).toBe(true); + expect(enumerateStructuralRowChanges(baseEditor.state).length).toBeGreaterThan(0); + expect(baseEditor.commands.acceptAllTrackedChanges()).toBe(true); + expect(enumerateStructuralRowChanges(baseEditor.state).length).toBe(0); + let hasTable = false; + baseEditor.state.doc.descendants((n) => { + if (n.type.name === 'table') hasTable = true; + }); + expect(hasTable).toBe(false); + } finally { + baseEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + + it('compute → set → reject-all on a table-removal pair restores the base shape', async () => { + const testUser = { name: 'Tester', email: 'test@example.com' }; + const baseEditor = await editorFromFixture('diff_before_table_remove.docx', testUser); + const afterEditor = await editorFromFixture('diff_after_table_remove.docx'); + try { + const beforeText = baseEditor.state.doc.textContent; + const hunks = computeStructuralDiff(baseEditor.state.doc, afterEditor.state.doc); + baseEditor.commands.setStructuralDiff(hunks); + baseEditor.commands.rejectAllTrackedChanges(); + expect(enumerateStructuralRowChanges(baseEditor.state).length).toBe(0); + let hasTable = false; + baseEditor.state.doc.descendants((n) => { + if (n.type.name === 'table') hasTable = true; + }); + expect(hasTable).toBe(true); + expect(baseEditor.state.doc.textContent).toBe(beforeText); + } finally { + baseEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structural-track-changes.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structural-track-changes.js new file mode 100644 index 0000000000..c7a3e6bdf0 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structural-track-changes.js @@ -0,0 +1,48 @@ +import { Extension } from '@core/Extension.js'; +import { applyHunks } from './structuralTrackChangesHelpers/applyHunks.js'; +import { computeStructuralDiff } from './structuralTrackChangesHelpers/computeStructuralDiff.js'; + +/** + * StructuralTrackChanges — table-level diff replay built on the existing + * structural-row tracked-changes pipeline. + * + * Pattern: consumer (e.g. al-pmo's AI review flow) computes + * `StructuralHunk[]` between a base doc and a proposal doc via + * `computeStructuralDiff`, then dispatches the result with + * `editor.commands.setStructuralDiff(hunks)`. The extension threads the user + * + a timestamp into `applyHunks`, which delegates to upstream's + * `stampTableRows` so each tracked row carries the same + * `tableRow.attrs.trackChange` shape the OOXML importer/exporter, the + * row-change enumerator, the review graph, and the decision engine already + * use. Accept/reject is then handled by the existing inline pipeline — + * `acceptTrackedChangeById` / `acceptAllTrackedChanges` (and their reject + * twins) route structural changes through `dispatchReviewDecision` → + * review-graph → decision-engine. + * + * Registered in `getStarterExtensions()` so every editor instance carries the + * `setStructuralDiff` command; consumers that don't compute hunks just won't + * call it. + */ +export const StructuralTrackChanges = Extension.create({ + name: 'structuralTrackChanges', + + addCommands() { + return { + setStructuralDiff: + (hunks) => + ({ state, dispatch, editor }) => { + if (!Array.isArray(hunks) || hunks.length === 0) return true; + const tr = state.tr; + tr.setMeta('addToHistory', false); + const user = editor?.options?.user ?? {}; + const date = new Date().toISOString(); + const { applied } = applyHunks({ tr, state, user, date, hunks }); + if (applied === 0) return false; + if (dispatch) dispatch(tr); + return true; + }, + }; + }, +}); + +export { computeStructuralDiff }; diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structural-track-changes.test.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structural-track-changes.test.js new file mode 100644 index 0000000000..0c43107c12 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structural-track-changes.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { getStarterExtensions } from '@extensions/index.js'; +import { StructuralTrackChanges } from './structural-track-changes.js'; +import { enumerateStructuralRowChanges } from '../track-changes/trackChangesHelpers/structuralRowChanges.js'; + +describe('StructuralTrackChanges extension', () => { + let editor; + beforeEach(() => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

hi

after

', + extensions: [...getStarterExtensions(), StructuralTrackChanges], + })); + }); + afterEach(() => editor?.destroy()); + + it('setStructuralDiff stamps every row of the targeted table as rowDelete via stampTableRows', () => { + const table = editor.state.doc.firstChild; + const ok = editor.commands.setStructuralDiff([ + { kind: 'remove', changeId: 't1', basePos: 0, baseNodeSize: table.nodeSize }, + ]); + expect(ok).toBe(true); + const changes = enumerateStructuralRowChanges(editor.state); + expect(changes).toHaveLength(1); + expect(changes[0].subtype).toBe('table-delete'); + expect(changes[0].wholeTable).toBe(true); + }); + + it('is registered in default starter extensions', () => { + const names = getStarterExtensions().map((e) => e.name); + expect(names).toContain('structuralTrackChanges'); + }); + + it('acceptAllTrackedChanges resolves a staged table deletion (table + shell gone)', () => { + // Consumers like al-pmo only call acceptAllTrackedChanges; routing through + // the review-model decision engine (via the structural-row review-graph + // projection) handles whole-table accept without any block-level fallback. + const table = editor.state.doc.firstChild; + editor.commands.setStructuralDiff([{ kind: 'remove', changeId: 't1', basePos: 0, baseNodeSize: table.nodeSize }]); + editor.commands.acceptAllTrackedChanges(); + let hasTable = false; + editor.state.doc.descendants((n) => { + if (n.type.name === 'table') hasTable = true; + }); + expect(hasTable).toBe(false); + expect(enumerateStructuralRowChanges(editor.state)).toHaveLength(0); + }); + + it('rejectAllTrackedChanges clears the structural trackChange attrs and keeps the table', () => { + const table = editor.state.doc.firstChild; + editor.commands.setStructuralDiff([{ kind: 'remove', changeId: 't1', basePos: 0, baseNodeSize: table.nodeSize }]); + editor.commands.rejectAllTrackedChanges(); + let hasTable = false; + editor.state.doc.descendants((n) => { + if (n.type.name === 'table') hasTable = true; + }); + expect(hasTable).toBe(true); + expect(enumerateStructuralRowChanges(editor.state)).toHaveLength(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.js new file mode 100644 index 0000000000..7ae5fd0d02 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.js @@ -0,0 +1,94 @@ +// @ts-check +import { stampTableRows } from '../../track-changes/trackChangesHelpers/stampTableRows.js'; + +/** + * @typedef {{ + * kind: 'remove' | 'insert', + * changeId: string, + * basePos?: number, + * anchorBasePos?: number, + * proposalNode?: import('prosemirror-model').Node, + * }} StructuralHunk + */ + +/** + * Apply structural diff hunks to a transaction as upstream-native row-level + * tracked changes. + * + * For each hunk: + * remove → stamp the existing table at `basePos` with `rowDelete` (the + * content stays visible, struck-through, until accept/reject). + * insert → insert the proposal table at `anchorBasePos`, then stamp it with + * `rowInsert`. + * + * Delegates to `stampTableRows()` so every row of a tracked table carries the + * same attribute shape that the OOXML importer/exporter, the row-change + * enumerator, the review graph, and the decision engine already understand + * (`{ type: 'rowInsert' | 'rowDelete', id, author, authorId, authorEmail, + * authorImage, date, revisionGroupId }`). One `revisionGroupId` per table. + * + * Tables only. Paragraph-level structural revisions use a different OOXML + * primitive (`w:rPr/w:ins`) and would belong in a separate change. + * + * @param {{ + * tr: import('prosemirror-state').Transaction, + * state: import('prosemirror-state').EditorState, + * user: import('../../../core/types/EditorConfig.js').User, + * date: string, + * hunks: StructuralHunk[], + * }} args + * @returns {{ applied: number, warnings: string[] }} + */ + +export const applyHunks = ({ tr, state, user, date, hunks }) => { + /** @type {string[]} */ + const warnings = []; + let applied = 0; + + for (const hunk of hunks) { + if (!hunk || !hunk.changeId) continue; + + if (hunk.kind === 'remove') { + if (typeof hunk.basePos !== 'number') { + warnings.push(`Missing basePos for remove hunk ${hunk.changeId}`); + continue; + } + const livePos = tr.mapping.map(hunk.basePos); + const node = tr.doc.nodeAt(livePos); + if (!node || node.type.name !== 'table') { + warnings.push(`Expected a table at pos ${hunk.basePos} for remove hunk ${hunk.changeId}`); + continue; + } + if (stampTableRows({ type: 'rowDelete', tr, from: livePos, to: livePos + node.nodeSize, user, date })) { + applied += 1; + } + continue; + } + + if (hunk.kind === 'insert') { + if (!hunk.proposalNode) { + warnings.push(`Missing proposalNode for insert hunk ${hunk.changeId}`); + continue; + } + if (typeof hunk.anchorBasePos !== 'number') { + warnings.push(`Missing anchorBasePos for insert hunk ${hunk.changeId}`); + continue; + } + if (hunk.proposalNode.type.name !== 'table') { + warnings.push(`Only table inserts are supported (hunk ${hunk.changeId})`); + continue; + } + const livePos = tr.mapping.map(hunk.anchorBasePos); + const insertedSize = hunk.proposalNode.nodeSize; + tr.insert(livePos, hunk.proposalNode); + if (stampTableRows({ type: 'rowInsert', tr, from: livePos, to: livePos + insertedSize, user, date })) { + applied += 1; + } + continue; + } + + warnings.push(`Unsupported hunk kind: ${hunk.kind}`); + } + + return { applied, warnings }; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.test.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.test.js new file mode 100644 index 0000000000..efcdd2ee90 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { EditorState } from 'prosemirror-state'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { applyHunks } from './applyHunks.js'; + +describe('applyHunks', () => { + let editor, schema; + const user = { name: 'Tester', email: 'test@example.com' }; + const date = '2026-06-16T00:00:00.000Z'; + beforeEach(() => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + schema = editor.schema; + }); + afterEach(() => editor?.destroy()); + + const buildTable = (id, rowCount = 2) => { + const rows = []; + for (let i = 0; i < rowCount; i += 1) { + const cell = schema.nodes.tableCell.create(null, schema.nodes.paragraph.create(null, schema.text(`r${i}`))); + rows.push(schema.nodes.tableRow.create(null, [cell])); + } + return schema.nodes.table.create({ sdBlockId: id }, rows); + }; + + it('stamps rowDelete on every row of an existing table for a remove hunk', () => { + const table = buildTable('tbl-del', 3); + const doc = schema.nodes.doc.create(null, [table]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + const result = applyHunks({ + tr, + state, + user, + date, + hunks: [{ kind: 'remove', changeId: 'tbl-del', basePos: 0 }], + }); + expect(result.applied).toBe(1); + const nextDoc = state.apply(tr).doc; + const next = nextDoc.firstChild; + expect(next.type.name).toBe('table'); + expect(next.childCount).toBe(3); + const groupIds = new Set(); + next.forEach((row) => { + expect(row.attrs.trackChange?.type).toBe('rowDelete'); + groupIds.add(row.attrs.trackChange?.revisionGroupId); + }); + expect(groupIds.size).toBe(1); + }); + + it('inserts a proposal table with rowInsert on every row at the anchor position', () => { + const proposalTable = buildTable('tbl-add', 2); + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text('before'))]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + const insertPos = doc.content.size; + const result = applyHunks({ + tr, + state, + user, + date, + hunks: [{ kind: 'insert', changeId: 'tbl-add', proposalNode: proposalTable, anchorBasePos: insertPos }], + }); + expect(result.applied).toBe(1); + const nextDoc = state.apply(tr).doc; + const insertedTable = nextDoc.child(nextDoc.childCount - 1); + expect(insertedTable.type.name).toBe('table'); + insertedTable.forEach((row) => { + expect(row.attrs.trackChange?.type).toBe('rowInsert'); + expect(row.attrs.trackChange?.author).toBe(user.name); + expect(row.attrs.trackChange?.date).toBe(date); + }); + }); + + it('rows of one tracked table share a revisionGroupId but have unique row ids', () => { + const table = buildTable('tbl-ids', 4); + const doc = schema.nodes.doc.create(null, [table]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + applyHunks({ + tr, + state, + user, + date, + hunks: [{ kind: 'remove', changeId: 'tbl-ids', basePos: 0 }], + }); + const ids = new Set(); + let revisionGroupId; + state.apply(tr).doc.firstChild.forEach((row) => { + ids.add(row.attrs.trackChange.id); + revisionGroupId = row.attrs.trackChange.revisionGroupId; + }); + expect(ids.size).toBe(4); + expect(typeof revisionGroupId).toBe('string'); + expect(revisionGroupId.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.js new file mode 100644 index 0000000000..c6b9b7ca5f --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.js @@ -0,0 +1,121 @@ +// @ts-check +/** + * Compute structural hunks describing whole-block add/removes between two docs. + * + * Walks both docs at depth 1, matches blocks by `identityKey` (default: a + * content fingerprint built from the block's normalized `textContent` and + * node type). For each base block absent from proposal: emit + * `{ kind: 'remove' }`. For each proposal block (of allowed type) absent from + * base: emit `{ kind: 'insert' }`. + * + * Why content fingerprint instead of sdBlockId: the docx importer assigns a + * fresh sdBlockId on every load — there's no standard OOXML attribute for + * tables to persist a stable id. So two independently-loaded copies of the + * same document have different ids; matching by id would flag every block + * as changed. Content fingerprinting matches identical blocks across + * imports. + * + * Limitations of the default fingerprint: + * - Two truly-identical blocks (e.g. empty placeholder tables) share a + * fingerprint; the algorithm treats them as one. Pass a custom + * `identityKey` if your domain has identical blocks that must be + * distinguished. + * - A block whose content changed (same structure, different text) is + * treated as a remove of the old + insert of the new — visually rendered + * as "old red + new green." Consumers wanting "table modified in-place" + * semantics should pass a structural fingerprint (e.g., row count). + * + * Consumers whose proposal is a true in-place edit (sdBlockIds preserved + * across base/proposal) can opt back into id-based matching: + * computeStructuralDiff(base, proposal, { + * identityKey: (n) => n.attrs?.sdBlockId ?? null, + * }) + * + * @param {import('prosemirror-model').Node} baseDoc + * @param {import('prosemirror-model').Node} proposalDoc + * @param {{ blockTypes?: readonly string[], identityKey?: (n: any) => string | null }} [opts] + * @returns {Array<{ + * kind: 'remove' | 'insert', + * changeId: string, + * basePos?: number, + * baseNodeSize?: number, + * proposalNode?: import('prosemirror-model').Node, + * anchorBasePos?: number, + * }>} + */ +const DEFAULT_BLOCK_TYPES = Object.freeze(['table']); +const defaultIdentityKey = (node) => { + if (!node) return null; + const typeName = node.type?.name ?? 'node'; + const text = typeof node.textContent === 'string' ? node.textContent : ''; + return `${typeName}:${text.replace(/\s+/g, ' ').trim()}`; +}; + +export const computeStructuralDiff = (baseDoc, proposalDoc, opts = {}) => { + const blockTypes = opts.blockTypes ?? DEFAULT_BLOCK_TYPES; + const identityKey = opts.identityKey ?? defaultIdentityKey; + const blockTypeSet = new Set(blockTypes); + + const collectTopLevel = (doc) => { + const entries = []; + doc.forEach((node, offset) => { + const id = identityKey(node); + if (id != null) entries.push({ id, node, pos: offset, end: offset + node.nodeSize }); + }); + return entries; + }; + + const baseEntries = collectTopLevel(baseDoc); + const proposalEntries = collectTopLevel(proposalDoc); + const baseIds = new Set(baseEntries.map((e) => e.id)); + const proposalIds = new Set(proposalEntries.map((e) => e.id)); + + const hunks = []; + const emitRemove = (entry) => { + if (blockTypeSet.has(entry.node.type.name)) { + hunks.push({ + kind: 'remove', + changeId: entry.id, + basePos: entry.pos, + baseNodeSize: entry.node.nodeSize, + }); + } + }; + + // Lockstep walk. For each proposal entry: if it matches something downstream + // in base, drain unmatched base entries between (emitting removes) and consume + // the match. Otherwise it's an insert; anchor at the position of the next + // unmatched base entry (the one this insert is logically replacing), falling + // back to the end of the last matched base block. Anchoring at the unmatched + // base position is what keeps "remove table X" + "insert table X'" visually + // adjacent — without it, an edit to the first table inserts the new copy at + // position 0 instead of next to the original. + let bi = 0; + let lastMatchedBaseEnd = 0; + for (const pe of proposalEntries) { + if (baseIds.has(pe.id)) { + while (bi < baseEntries.length && baseEntries[bi].id !== pe.id) { + if (!proposalIds.has(baseEntries[bi].id)) emitRemove(baseEntries[bi]); + bi += 1; + } + if (bi < baseEntries.length) { + lastMatchedBaseEnd = baseEntries[bi].end; + bi += 1; + } + continue; + } + if (!blockTypeSet.has(pe.node.type.name)) continue; + let anchor = lastMatchedBaseEnd; + if (bi < baseEntries.length && !proposalIds.has(baseEntries[bi].id)) { + anchor = baseEntries[bi].pos; + } + hunks.push({ kind: 'insert', changeId: pe.id, proposalNode: pe.node, anchorBasePos: anchor }); + } + + while (bi < baseEntries.length) { + if (!proposalIds.has(baseEntries[bi].id)) emitRemove(baseEntries[bi]); + bi += 1; + } + + return hunks; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.test.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.test.js new file mode 100644 index 0000000000..e27a751e1c --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.test.js @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { computeStructuralDiff } from './computeStructuralDiff.js'; + +const idAttr = (id) => ({ sdBlockId: id }); + +describe('computeStructuralDiff', () => { + let editor, schema; + beforeEach(() => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + schema = editor.schema; + }); + afterEach(() => editor?.destroy()); + + const makeDoc = (blocks) => + schema.nodes.doc.create( + null, + blocks.map((b) => b(schema)), + ); + const p = (id, text) => (schema) => schema.nodes.paragraph.create(idAttr(id), schema.text(text)); + // Default cell text falls back to the id, so each table built here has a + // unique content fingerprint matching the matcher's default behavior. + const table = + (id, text = id) => + (schema) => { + const cell = schema.nodes.tableCell.create(null, schema.nodes.paragraph.create(null, schema.text(text))); + const row = schema.nodes.tableRow.create(null, [cell]); + return schema.nodes.table.create(idAttr(id), [row]); + }; + const tableFingerprint = (text) => `table:${text}`; + + it('emits a remove hunk for a base table absent from proposal', () => { + const base = makeDoc([p('p1', 'a'), table('t1'), p('p2', 'b')]); + const proposal = makeDoc([p('p1', 'a'), p('p2', 'b')]); + const hunks = computeStructuralDiff(base, proposal); + expect(hunks).toHaveLength(1); + expect(hunks[0]).toMatchObject({ kind: 'remove', changeId: tableFingerprint('t1') }); + }); + + it('emits an insert hunk for a proposal table absent from base', () => { + const base = makeDoc([p('p1', 'a')]); + const proposal = makeDoc([p('p1', 'a'), table('t1')]); + const hunks = computeStructuralDiff(base, proposal); + expect(hunks).toHaveLength(1); + expect(hunks[0]).toMatchObject({ kind: 'insert', changeId: tableFingerprint('t1') }); + }); + + it('emits no hunks when both sides have matching tables', () => { + const base = makeDoc([table('t1')]); + const proposal = makeDoc([table('t1')]); + expect(computeStructuralDiff(base, proposal)).toHaveLength(0); + }); + + it('matches tables across independent imports even when sdBlockIds differ', () => { + // Reproduces the bug: two docx files loaded independently get fresh + // sdBlockIds per import. The diff must still recognize identical tables. + const base = makeDoc([table('base-a', 'first'), table('base-b', 'second')]); + const proposal = makeDoc([table('prop-x', 'first'), table('prop-y', 'second')]); + expect(computeStructuralDiff(base, proposal)).toHaveLength(0); + }); + + it('with multiple unchanged tables and one modified, marks only the modified one', () => { + const base = makeDoc([ + table('base-a', 'unchanged-1'), + table('base-b', 'will-edit'), + table('base-c', 'unchanged-2'), + ]); + const proposal = makeDoc([ + table('prop-a', 'unchanged-1'), + table('prop-b', 'edited-content'), + table('prop-c', 'unchanged-2'), + ]); + const hunks = computeStructuralDiff(base, proposal); + expect(hunks).toHaveLength(2); + expect(hunks.some((h) => h.kind === 'remove' && h.changeId === tableFingerprint('will-edit'))).toBe(true); + expect(hunks.some((h) => h.kind === 'insert' && h.changeId === tableFingerprint('edited-content'))).toBe(true); + }); + + it('anchor for insert is end of last shared base block', () => { + const base = makeDoc([p('p1', 'first'), p('p2', 'second')]); + const proposal = makeDoc([p('p1', 'first'), table('t-new'), p('p2', 'second')]); + const hunks = computeStructuralDiff(base, proposal); + const insert = hunks.find((h) => h.kind === 'insert'); + expect(insert).toBeDefined(); + expect(insert.anchorBasePos).toBe(base.child(0).nodeSize); + }); + + it('insert with no shared base block anchors at the unmatched base entry it replaces', () => { + const base = makeDoc([p('p1', 'only')]); + const proposal = makeDoc([table('t-new')]); + const hunks = computeStructuralDiff(base, proposal); + const insert = hunks.find((h) => h.kind === 'insert'); + expect(insert?.anchorBasePos).toBe(0); + }); + + it('insert anchors next to the table it replaces, even when it is the first block in the doc', () => { + // Regression: when the AI edits the first table (no preceding shared + // paragraphs) the proposal table used to anchor at position 0 instead of + // next to the original. With multiple tables in the doc, the inserted + // copy ended up "near some random table" rather than alongside the + // edited one. + const base = makeDoc([table('a', 'orig-a'), table('b', 'unchanged-b'), table('c', 'unchanged-c')]); + const proposal = makeDoc([table("a'", 'edited-a'), table('b', 'unchanged-b'), table('c', 'unchanged-c')]); + const hunks = computeStructuralDiff(base, proposal); + const remove = hunks.find((h) => h.kind === 'remove' && h.changeId === tableFingerprint('orig-a')); + const insert = hunks.find((h) => h.kind === 'insert' && h.changeId === tableFingerprint('edited-a')); + expect(remove).toBeDefined(); + expect(insert).toBeDefined(); + expect(insert.anchorBasePos).toBe(remove.basePos); + }); + + it('insert anchors at the matching unmatched base entry, not the end of the last shared block', () => { + // base has [P1, TableA, TableB]; proposal edits TableB. The natural anchor + // for the inserted TableB' is TableB's position in base (so the new copy + // lands adjacent to the original), not the end of TableA. + const base = makeDoc([p('p1', 'intro'), table('a', 'unchanged-a'), table('b', 'orig-b')]); + const proposal = makeDoc([p('p1', 'intro'), table('a', 'unchanged-a'), table("b'", 'edited-b')]); + const hunks = computeStructuralDiff(base, proposal); + const insert = hunks.find((h) => h.kind === 'insert'); + const baseTableB = base.child(2); + let baseTableBPos = 0; + base.forEach((node, offset) => { + if (node === baseTableB) baseTableBPos = offset; + }); + expect(insert?.anchorBasePos).toBe(baseTableBPos); + }); + + it('blockTypes filter (default ["table"]) restricts insert events', () => { + const base = makeDoc([]); + const proposal = makeDoc([table('t1'), p('p1', 'extra')]); + const hunks = computeStructuralDiff(base, proposal); + expect(hunks.filter((h) => h.kind === 'insert')).toHaveLength(1); + expect(hunks.find((h) => h.kind === 'insert')?.changeId).toBe(tableFingerprint('t1')); + }); + + it('consumer can opt back into sdBlockId-based matching via identityKey override', () => { + // For consumers whose proposals are in-place edits (sdBlockIds preserved + // across base and proposal), id-based matching is sometimes preferable. + const sdBlockIdKey = (node) => node.attrs?.sdBlockId ?? null; + const base = makeDoc([table('t1', 'same')]); + const proposal = makeDoc([table('t2', 'same')]); // same content, different id + const hunks = computeStructuralDiff(base, proposal, { identityKey: sdBlockIdKey }); + // With id-based matching, these differ even though content is identical. + expect(hunks.filter((h) => h.kind === 'remove')).toHaveLength(1); + expect(hunks.filter((h) => h.kind === 'insert')).toHaveLength(1); + }); + + it('custom identityKey override', () => { + const fingerprint = (node) => `${node.type.name}:${node.textContent}`; + const noId = (text) => (schema) => schema.nodes.paragraph.create(null, schema.text(text)); + const base = makeDoc([noId('A'), noId('B')]); + const proposal = makeDoc([noId('A')]); + const hunks = computeStructuralDiff(base, proposal, { + identityKey: fingerprint, + blockTypes: ['paragraph'], + }); + expect(hunks).toHaveLength(1); + expect(hunks[0]).toMatchObject({ kind: 'remove', changeId: 'paragraph:B' }); + }); + + it('does not descend into matched tables (nested table inside matched outer is not double-counted)', () => { + const outerWithCellPara = (id) => (schema) => { + const cell = schema.nodes.tableCell.create(null, schema.nodes.paragraph.create(null, schema.text('inner'))); + const row = schema.nodes.tableRow.create(null, [cell]); + return schema.nodes.table.create(idAttr(id), [row]); + }; + const base = makeDoc([outerWithCellPara('t-outer')]); + const proposal = makeDoc([outerWithCellPara('t-outer')]); + expect(computeStructuralDiff(base, proposal)).toHaveLength(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js index 6787be6813..b2df8a6361 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js @@ -2353,5 +2353,91 @@ describe('TrackChanges extension commands', () => { authorImage: undefined, }); }); + + // Regression test for ALPMO-245: when text is inserted at a position + // where bare text can't live (e.g. at doc end past the last block), PM + // auto-wraps the text in the schema's required parents. The old + // implementation passed `insertedNode.nodeSize` as the mark `to`, which + // covered the wrapper open tokens instead of the trailing characters of + // the actual text — so the last char(s) escaped the trackInsert mark and + // survived reject as orphans. + it('marks the full inserted text when PM auto-wraps at end-of-doc insertion', () => { + const doc = createDoc('Hello'); + const state = createState(doc); + const insertAt = doc.content.size; // past the last block — triggers auto-wrap + + let nextState; + const result = commands.insertTrackedChange({ + from: insertAt, + to: insertAt, + text: 'World.', + })({ + state, + dispatch: (tr) => { + nextState = state.apply(tr); + }, + editor: { + options: { user: { name: 'Test', email: 'test@example.com' } }, + commands: { addCommentReply: vi.fn() }, + }, + }); + + expect(result).toBe(true); + expect(nextState).toBeDefined(); + // The full inserted text — including the trailing '.' — must carry + // trackInsert; no character may sit in an unmarked text node. + expect(getMarkedText(nextState.doc, TrackInsertMarkName)).toBe('World.'); + let unmarkedAppendedText = ''; + nextState.doc.descendants((node) => { + if (!node.isText) return; + if (node.text === 'Hello') return; // pre-existing base content + const hasTrackMark = node.marks.some((m) => m.type.name === TrackInsertMarkName); + if (!hasTrackMark) unmarkedAppendedText += node.text ?? ''; + }); + expect(unmarkedAppendedText).toBe(''); + }); + + it('rejectTrackedChangesBetween leaves no orphan chars after end-of-doc insertion', () => { + const doc = createDoc('Hello'); + const state = createState(doc); + const insertAt = doc.content.size; + + let nextState; + commands.insertTrackedChange({ + from: insertAt, + to: insertAt, + text: 'World.', + })({ + state, + dispatch: (tr) => { + nextState = state.apply(tr); + }, + editor: { + options: { user: { name: 'Test', email: 'test@example.com' } }, + commands: { addCommentReply: vi.fn() }, + }, + }); + + const afterInsert = nextState; + let afterReject; + commands.rejectTrackedChangesBetween( + 0, + afterInsert.doc.content.size, + )({ + state: afterInsert, + dispatch: (tr) => { + afterReject = afterInsert.apply(tr); + }, + editor: { + options: { user: { name: 'Test', email: 'test@example.com' } }, + commands: { addCommentReply: vi.fn() }, + }, + }); + + expect(afterReject).toBeDefined(); + // Doc must be restored to just 'Hello' — no orphan '.' or 'd' or 'l' + // surviving in a new wrapper paragraph the AI-style insertion created. + expect(afterReject.doc.textContent).toBe('Hello'); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/blockTrackedChangePassthrough.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/blockTrackedChangePassthrough.test.js new file mode 100644 index 0000000000..2bd034c75b --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/blockTrackedChangePassthrough.test.js @@ -0,0 +1,73 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { EditorState } from 'prosemirror-state'; +import { Slice, Fragment } from 'prosemirror-model'; +import { ReplaceStep } from 'prosemirror-transform'; +import { trackedTransaction } from './trackedTransaction.js'; +import { TrackInsertMarkName, TrackDeleteMarkName } from '../constants.js'; +import { initTestEditor } from '@tests/helpers/helpers.js'; + +describe('trackedTransaction — pass-through for pre-marked block content', () => { + let editor, schema, basePlugins; + const user = { name: 'Block Tester', email: 'block@example.com' }; + + beforeEach(() => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + schema = editor.schema; + basePlugins = editor.state.plugins; + }); + afterEach(() => { + vi.restoreAllMocks(); + editor?.destroy(); + editor = null; + }); + + const createState = (doc) => EditorState.create({ schema, doc, plugins: basePlugins }); + + const buildPreMarkedTable = (revisionGroupId, rowCount = 2) => { + const cell = schema.nodes.tableCell.create(null, schema.nodes.paragraph.create(null, schema.text('hello'))); + const rows = []; + for (let i = 0; i < rowCount; i += 1) { + rows.push( + schema.nodes.tableRow.create({ trackChange: { type: 'rowInsert', id: `row-${i}`, revisionGroupId } }, [cell]), + ); + } + return schema.nodes.table.create({ sdBlockId: 'tbl-1' }, rows); + }; + + const inlineTrackedMarksOnText = (doc) => { + let count = 0; + doc.descendants((node) => { + if (!node.isText) return; + node.marks.forEach((m) => { + if (m.type.name === TrackInsertMarkName || m.type.name === TrackDeleteMarkName) count += 1; + }); + }); + return count; + }; + + it('ReplaceStep inserting a pre-marked table passes through without inline-mark wrapping', () => { + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text('before'))]); + const state = createState(doc); + const tr = state.tr; + const preMarkedTable = buildPreMarkedTable('group-1', 2); + const insertPos = state.doc.content.size; + tr.step(new ReplaceStep(insertPos, insertPos, new Slice(Fragment.from(preMarkedTable), 0, 0))); + const result = trackedTransaction({ tr, state, user }); + const nextDoc = state.apply(result).doc; + const insertedTable = nextDoc.child(nextDoc.childCount - 1); + expect(insertedTable.type.name).toBe('table'); + insertedTable.forEach((row) => { + expect(row.attrs.trackChange?.type).toBe('rowInsert'); + }); + expect(inlineTrackedMarksOnText(nextDoc)).toBe(0); + }); + + it('normal text insertion still gets wrapped with inline trackInsert marks (regression guard)', () => { + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text('hello world'))]); + const state = createState(doc); + const tr = state.tr.insertText('X', 1); + const result = trackedTransaction({ tr, state, user }); + const nextDoc = state.apply(result).doc; + expect(inlineTrackedMarksOnText(nextDoc)).toBeGreaterThan(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js index 16d823955f..7778565d11 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js @@ -361,6 +361,31 @@ const getPendingDeadKeyPlaceholder = ({ tr, newTr, user }) => { }; }; +/** + * Detects whether a ReplaceStep's slice already carries block-level + * `trackChange` metadata via PM node attributes (e.g. a table whose rows + * have `trackChange.insert` stamped by `applyHunks`). When true, the slice + * should be applied as-is — wrapping its inline content with `trackInsert` + * marks would double-track. + * + * @param {import('prosemirror-model').Slice} slice + * @returns {boolean} + */ +const sliceContainsPreMarkedBlockTrackedChange = (slice) => { + if (!slice || !slice.content || slice.content.size === 0) return false; + let found = false; + slice.content.descendants((node) => { + if (found) return false; + const tc = node?.attrs?.trackChange; + if (tc && (tc.type === 'rowInsert' || tc.type === 'rowDelete')) { + found = true; + return false; + } + return undefined; + }); + return found; +}; + /** * Process a transaction through the track-changes pipeline and return * a modified transaction with tracked-change marks applied (or the @@ -433,6 +458,18 @@ export const trackedTransaction = ({ tr, state, user, replacements = 'paired' }) step = normalizeCompositionInsertStep({ step, doc, tr, user, pendingDeadKeyPlaceholder }); + // Block-level tracked-change replay path: the inserted slice already + // carries row-level tracked metadata via PM node attrs (see applyHunks). + // Wrapping the inner cell content with inline trackInsert marks would + // double-track. Apply such steps as-is — but still append to `map` so + // subsequent steps in the same transaction map through this step's + // changes (matches the pass-through pattern in replaceStep.js). + if (step instanceof ReplaceStep && sliceContainsPreMarkedBlockTrackedChange(step.slice)) { + newTr.step(step); + map.appendMap(step.getMap()); + return; + } + if (step instanceof ReplaceStep) { replaceStep({ state, diff --git a/packages/super-editor/src/editors/v1/index.js b/packages/super-editor/src/editors/v1/index.js index fcf98a5d4b..4645d3d4f6 100644 --- a/packages/super-editor/src/editors/v1/index.js +++ b/packages/super-editor/src/editors/v1/index.js @@ -41,6 +41,8 @@ import AIWriter from './components/toolbar/AIWriter.vue'; import * as fieldAnnotationHelpers from './extensions/field-annotation/fieldAnnotationHelpers/index.js'; import * as trackChangesHelpers from './extensions/track-changes/trackChangesHelpers/index.js'; import { TrackChangesBasePluginKey } from './extensions/track-changes/plugins/index.js'; +import { StructuralTrackChanges, computeStructuralDiff } from './extensions/structural-track-changes/index.js'; +import { enumerateStructuralRowChanges } from './extensions/track-changes/trackChangesHelpers/structuralRowChanges.js'; import { CommentsPluginKey, createOrUpdateTrackedChangeComment } from './extensions/comment/comments-plugin.js'; import { AnnotatorHelpers } from '@helpers/annotator.js'; import { SectionHelpers } from '@extensions/structured-content/document-section/index.js'; @@ -109,6 +111,10 @@ export { helpers, fieldAnnotationHelpers, trackChangesHelpers, + // Structural (block-level) tracked changes + StructuralTrackChanges, + computeStructuralDiff, + enumerateStructuralRowChanges, /** @internal */ AnnotatorHelpers, SectionHelpers, diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/diff_after_table_remove.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/diff_after_table_remove.docx new file mode 100644 index 0000000000..1904c3fd0f Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/diff_after_table_remove.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/diff_before_table_remove.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/diff_before_table_remove.docx new file mode 100644 index 0000000000..6d38931b1c Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/diff_before_table_remove.docx differ diff --git a/packages/superdoc/src/index.js b/packages/superdoc/src/index.js index dfa2324cff..cbbcdfba79 100644 --- a/packages/superdoc/src/index.js +++ b/packages/superdoc/src/index.js @@ -34,6 +34,10 @@ import { AIWriter, ContextMenu, SlashMenu, + // Structural (block-level) tracked changes + StructuralTrackChanges, + computeStructuralDiff, + enumerateStructuralRowChanges, } from '@superdoc/super-editor'; import { DOCX, PDF, HTML, getFileObject, compareVersions } from '@superdoc/common'; // @ts-expect-error Vite resolves DOCX asset URL imports; plain tsc does not. @@ -299,4 +303,9 @@ export { AIWriter, ContextMenu, SlashMenu, + + // Structural (block-level) tracked changes + StructuralTrackChanges, + computeStructuralDiff, + enumerateStructuralRowChanges, }; diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json index ab2169fc06..70556f0758 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-08T11:16:20.709Z", + "generatedAt": "2026-06-23T17:41:41.711Z", "ticket": "SD-3212 PR A0", "package": "superdoc", "rootExport": { @@ -514,6 +514,7 @@ "PresentationEditor", "SectionHelpers", "SlashMenu", + "StructuralTrackChanges", "SuperConverter", "SuperDoc", "SuperEditor", @@ -524,10 +525,12 @@ "assertNodeType", "buildTheme", "compareVersions", + "computeStructuralDiff", "createTheme", "createZip", "defineMark", "defineNode", + "enumerateStructuralRowChanges", "fieldAnnotationHelpers", "getActiveFormatting", "getAllowedImageDimensions", @@ -561,6 +564,7 @@ "PresentationEditor", "SectionHelpers", "SlashMenu", + "StructuralTrackChanges", "SuperConverter", "SuperDoc", "SuperEditor", @@ -571,10 +575,12 @@ "assertNodeType", "buildTheme", "compareVersions", + "computeStructuralDiff", "createTheme", "createZip", "defineMark", "defineNode", + "enumerateStructuralRowChanges", "fieldAnnotationHelpers", "getActiveFormatting", "getAllowedImageDimensions", @@ -595,9 +601,9 @@ "counts": { "types.import": 237, "types.require": 237, - "import": 41, - "require": 41, - "union": 237 + "import": 44, + "require": 44, + "union": 240 }, "divergences": { "typesImportVsRequire": { @@ -807,7 +813,7 @@ "ViewingVisibilityConfig", "VirtualizationOptions" ], - "runtimeOnly": [] + "runtimeOnly": ["StructuralTrackChanges", "computeStructuralDiff", "enumerateStructuralRowChanges"] } } } diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md index dee78477c2..4055b912c4 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md @@ -1,6 +1,6 @@ # superdoc root export inventory (SD-3212 PR A0) -Generated: 2026-06-08T11:16:20.709Z +Generated: 2026-06-23T17:41:41.711Z Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` ## Counts @@ -9,9 +9,9 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` |---|---|---| | types.import | `./dist/superdoc/src/public/index.d.ts` | 237 | | types.require | `./dist/superdoc/src/public/index.d.cts` | 237 | -| import | `./dist/superdoc.es.js` | 41 | -| require | `./dist/superdoc.cjs` | 41 | -| **union** | | **237** | +| import | `./dist/superdoc.es.js` | 44 | +| require | `./dist/superdoc.cjs` | 44 | +| **union** | | **240** | ## Divergences @@ -20,7 +20,13 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - ESM only (not in CJS): 0 - CJS only (not in ESM): 0 - typed but no runtime export (phantom risk): 196 -- runtime export but not typed (silent shadow on root): 0 +- runtime export but not typed (silent shadow on root): 3 + +### Runtime-only names (no type) + +- `StructuralTrackChanges` +- `computeStructuralDiff` +- `enumerateStructuralRowChanges` ### Type-only names (no runtime) @@ -244,7 +250,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `CollaborationProvider` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `Command` | ✓ | ✓ | | | 3 | ✓ | 78 | 0 | 8 | ✓ | | `CommandProps` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | ✓ | -| `Comment` | ✓ | ✓ | | | 5 | ✓ | 29 | 3 | 45 | | +| `Comment` | ✓ | ✓ | | | 5 | ✓ | 28 | 3 | 45 | | | `CommentAddress` | ✓ | ✓ | | | 1 | ✓ | 4 | 0 | 3 | | | `CommentConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `CommentElement` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -261,12 +267,12 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `ContextMenuItem` | ✓ | ✓ | | | 2 | ✓ | 4 | 0 | 5 | | | `ContextMenuSection` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `CoreCommandMap` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | ✓ | -| `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 160 | 25 | 55 | ✓ | +| `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 160 | 25 | 57 | ✓ | | `DirectSurfaceRequest` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `DocRange` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `Document` | ✓ | ✓ | | | 2 | | 291 | 56 | 110 | ✓ | +| `Document` | ✓ | ✓ | | | 2 | | 294 | 56 | 111 | ✓ | | `DocumentApi` | ✓ | ✓ | | | 3 | ✓ | 0 | 11 | 4 | ✓ | -| `DocumentFontOption` | ✓ | ✓ | | | 0 | | 0 | 0 | 0 | | +| `DocumentFontOption` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `DocumentMode` | ✓ | ✓ | | | 3 | ✓ | 2 | 16 | 3 | | | `DocumentProtectionState` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 1 | | | `DocxFileEntry` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -282,7 +288,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `EditorTransactionEvent` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `EditorUpdateEvent` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `EditorView` | ✓ | ✓ | | | 4 | ✓ | 2 | 0 | 0 | ✓ | -| `EntityAddress` | ✓ | ✓ | | | 2 | ✓ | 276 | 0 | 8 | | +| `EntityAddress` | ✓ | ✓ | | | 2 | ✓ | 297 | 0 | 8 | | | `ExportDocxParams` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `ExportFormat` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `ExportOptions` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -309,7 +315,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `FontsChangedPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `FontsConfig` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `FontsResolvedPayload` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | -| `HTML` | ✓ | ✓ | ✓ | ✓ | 2 | | 87 | 12 | 202 | | +| `HTML` | ✓ | ✓ | ✓ | ✓ | 2 | | 87 | 12 | 205 | | | `ImageDeselectedEvent` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `ImageSelectedEvent` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `IntentSurfaceRequest` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | @@ -330,7 +336,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `Modules` | ✓ | ✓ | | | 2 | ✓ | 4 | 0 | 0 | | | `NavigableAddress` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `OpenOptions` | ✓ | ✓ | | | 3 | ✓ | 1 | 0 | 0 | | -| `PDF` | ✓ | ✓ | ✓ | ✓ | 2 | | 37 | 0 | 1 | ✓ | +| `PDF` | ✓ | ✓ | ✓ | ✓ | 2 | | 38 | 0 | 1 | ✓ | | `PageMargins` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `PageSize` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `PageStyles` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -383,13 +389,14 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `SelectionCommandContext` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `SelectionCurrentInput` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `SelectionHandle` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `SelectionInfo` | ✓ | ✓ | | | 2 | ✓ | 6 | 0 | 1 | | +| `SelectionInfo` | ✓ | ✓ | | | 2 | ✓ | 11 | 0 | 1 | | | `SlashMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | -| `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 123 | 0 | 3 | | +| `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 1037 | 0 | 3 | | +| `StructuralTrackChanges` | | | ✓ | ✓ | 0 | | 0 | 0 | 0 | | | `SuperConverter` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 3 | ✓ | -| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 22 | | 1046 | 190 | 250 | ✓ | +| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 23 | | 1088 | 194 | 250 | ✓ | | `SuperDocAwarenessUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | -| `SuperDocCommentsUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocCommentsUpdatePayload` | ✓ | ✓ | | | 2 | | 2 | 0 | 0 | | | `SuperDocEditorPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocExceptionEditorPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocExceptionPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | @@ -423,15 +430,15 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `SurfaceResolver` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `SurfacesModuleConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `TelemetryEvent` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `TextAddress` | ✓ | ✓ | | | 3 | ✓ | 404 | 0 | 7 | | -| `TextSegment` | ✓ | ✓ | | | 3 | ✓ | 8 | 0 | 4 | | -| `TextTarget` | ✓ | ✓ | | | 3 | ✓ | 45 | 0 | 10 | | -| `Toolbar` | ✓ | ✓ | ✓ | ✓ | 1 | | 35 | 7 | 15 | | +| `TextAddress` | ✓ | ✓ | | | 3 | ✓ | 416 | 0 | 7 | | +| `TextSegment` | ✓ | ✓ | | | 3 | ✓ | 9 | 0 | 4 | | +| `TextTarget` | ✓ | ✓ | | | 3 | ✓ | 52 | 0 | 10 | | +| `Toolbar` | ✓ | ✓ | ✓ | ✓ | 1 | | 38 | 7 | 17 | | | `TrackChangeAuthor` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `TrackChangesAuthorColorsConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `TrackChangesBasePluginKey` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | | `TrackChangesModuleConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `TrackedChangeAddress` | ✓ | ✓ | | | 1 | ✓ | 13 | 0 | 3 | | +| `TrackedChangeAddress` | ✓ | ✓ | | | 1 | ✓ | 16 | 0 | 3 | | | `TrackedChangesMode` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `TrackedChangesOverrides` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `Transaction` | ✓ | ✓ | | | 3 | ✓ | 5 | 0 | 0 | ✓ | @@ -445,10 +452,12 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `assertNodeType` | ✓ | ✓ | ✓ | ✓ | 1 | | 2 | 0 | 1 | ✓ | | `buildTheme` | ✓ | ✓ | ✓ | ✓ | 1 | | 4 | 0 | 1 | | | `compareVersions` | ✓ | ✓ | ✓ | ✓ | 0 | | 0 | 0 | 1 | | +| `computeStructuralDiff` | | | ✓ | ✓ | 0 | | 0 | 0 | 0 | | | `createTheme` | ✓ | ✓ | ✓ | ✓ | 1 | | 21 | 8 | 1 | | | `createZip` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | | `defineMark` | ✓ | ✓ | ✓ | ✓ | 2 | | 3 | 0 | 1 | ✓ | | `defineNode` | ✓ | ✓ | ✓ | ✓ | 2 | | 4 | 0 | 1 | ✓ | +| `enumerateStructuralRowChanges` | | | ✓ | ✓ | 0 | | 0 | 0 | 0 | | | `fieldAnnotationHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 2 | 0 | 3 | | | `getActiveFormatting` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 2 | | | `getAllowedImageDimensions` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | | diff --git a/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt b/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt index 43aa841de4..ccd45c505e 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt +++ b/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt @@ -135,6 +135,7 @@ SelectionSlice SelectorFn SlashMenu StoryLocator +StructuralTrackChanges Subscribable SuperConverter SuperDocEditorLike @@ -178,12 +179,14 @@ ViewportRect ViewportRectResult VirtualizationOptions assertNodeType +computeStructuralDiff createHeadlessToolbar createOrUpdateTrackedChangeComment createSuperDocUI createZip defineMark defineNode +enumerateStructuralRowChanges fieldAnnotationHelpers getActiveFormatting getAllowedImageDimensions