diff --git a/packages/layout-engine/layout-bridge/src/cache.ts b/packages/layout-engine/layout-bridge/src/cache.ts index 2b5f27f9d1..12f045fc2d 100644 --- a/packages/layout-engine/layout-bridge/src/cache.ts +++ b/packages/layout-engine/layout-bridge/src/cache.ts @@ -152,22 +152,28 @@ const hashRuns = (block: FlowBlock): string => { // (Fix for PR #1551: previously /\s+/g normalization caused cache collisions) const text = 'text' in run && typeof run.text === 'string' ? run.text : ''; - // Include formatting marks that affect measurement (mirroring paragraph approach) + // Include formatting marks that affect visual output (mirroring paragraph approach). const bold = 'bold' in run ? run.bold : false; const italic = 'italic' in run ? run.italic : false; + const underline = 'underline' in run ? run.underline : undefined; + const strike = 'strike' in run ? run.strike : false; const color = 'color' in run ? run.color : undefined; const fontSize = 'fontSize' in run ? run.fontSize : undefined; const fontFamily = 'fontFamily' in run ? run.fontFamily : undefined; const highlight = 'highlight' in run ? run.highlight : undefined; + const link = 'link' in run ? run.link : undefined; // Build marks string including all formatting properties const marks = [ bold ? 'b' : '', italic ? 'i' : '', + underline ? `u:${JSON.stringify(underline)}` : '', + strike ? 's' : '', color ?? '', fontSize !== undefined ? `fs:${fontSize}` : '', fontFamily ? `ff:${fontFamily}` : '', highlight ? `hl:${highlight}` : '', + link ? `ln:${JSON.stringify(link)}` : '', ].join(''); // Use type guard to safely access comment metadata @@ -293,17 +299,23 @@ const hashRuns = (block: FlowBlock): string => { const text = 'src' in run || run.kind === 'lineBreak' || run.kind === 'break' ? '' : (run.text ?? ''); const bold = 'bold' in run ? run.bold : false; const italic = 'italic' in run ? run.italic : false; + const underline = 'underline' in run ? run.underline : undefined; + const strike = 'strike' in run ? run.strike : false; const color = 'color' in run ? run.color : undefined; const fontSize = 'fontSize' in run ? run.fontSize : undefined; const fontFamily = 'fontFamily' in run ? run.fontFamily : undefined; const highlight = 'highlight' in run ? run.highlight : undefined; + const link = 'link' in run ? run.link : undefined; const marks = [ bold ? 'b' : '', italic ? 'i' : '', + underline ? `u:${JSON.stringify(underline)}` : '', + strike ? 's' : '', color ?? '', fontSize !== undefined ? `fs:${fontSize}` : '', fontFamily ? `ff:${fontFamily}` : '', highlight ? `hl:${highlight}` : '', + link ? `ln:${JSON.stringify(link)}` : '', ].join(''); // Include tracked change metadata in hash diff --git a/packages/layout-engine/layout-bridge/src/diff.ts b/packages/layout-engine/layout-bridge/src/diff.ts index b454d401b3..5bcd22e255 100644 --- a/packages/layout-engine/layout-bridge/src/diff.ts +++ b/packages/layout-engine/layout-bridge/src/diff.ts @@ -404,25 +404,35 @@ const paragraphBlocksEqual = (a: FlowBlock & { kind: 'paragraph' }, b: FlowBlock for (let i = 0; i < a.runs.length; i += 1) { const runA = a.runs[i]; const runB = b.runs[i]; - if ( - ('src' in runA || runA.kind === 'lineBreak' || runA.kind === 'break' || runA.kind === 'fieldAnnotation' + const leftText = + 'src' in runA || runA.kind === 'lineBreak' || runA.kind === 'break' || runA.kind === 'fieldAnnotation' ? '' - : runA.text) !== - ('src' in runB || runB.kind === 'lineBreak' || runB.kind === 'break' || runB.kind === 'fieldAnnotation' - ? '' - : runB.text) || + : runA.text; + const rightText = + 'src' in runB || runB.kind === 'lineBreak' || runB.kind === 'break' || runB.kind === 'fieldAnnotation' + ? '' + : runB.text; + const leftUnderline = JSON.stringify('underline' in runA ? runA.underline : undefined); + const rightUnderline = JSON.stringify('underline' in runB ? runB.underline : undefined); + const leftLink = JSON.stringify('link' in runA ? runA.link : undefined); + const rightLink = JSON.stringify('link' in runB ? runB.link : undefined); + + const mismatch = + leftText !== rightText || fieldAnnotationKey(runA) !== fieldAnnotationKey(runB) || ('bold' in runA ? runA.bold : false) !== ('bold' in runB ? runB.bold : false) || ('italic' in runA ? runA.italic : false) !== ('italic' in runB ? runB.italic : false) || + leftUnderline !== rightUnderline || + ('strike' in runA ? runA.strike : false) !== ('strike' in runB ? runB.strike : false) || ('color' in runA ? runA.color : undefined) !== ('color' in runB ? runB.color : undefined) || ('fontSize' in runA ? runA.fontSize : undefined) !== ('fontSize' in runB ? runB.fontSize : undefined) || ('fontFamily' in runA ? runA.fontFamily : undefined) !== ('fontFamily' in runB ? runB.fontFamily : undefined) || ('highlight' in runA ? runA.highlight : undefined) !== ('highlight' in runB ? runB.highlight : undefined) || + leftLink !== rightLink || getTrackedChangeKey(runA) !== getTrackedChangeKey(runB) || - getCommentKey(runA) !== getCommentKey(runB) - ) { - return false; - } + getCommentKey(runA) !== getCommentKey(runB); + + if (mismatch) return false; } return true; }; diff --git a/packages/layout-engine/layout-bridge/test/cache.test.ts b/packages/layout-engine/layout-bridge/test/cache.test.ts index 63035805f0..a72dea5829 100644 --- a/packages/layout-engine/layout-bridge/test/cache.test.ts +++ b/packages/layout-engine/layout-bridge/test/cache.test.ts @@ -941,6 +941,103 @@ describe('MeasureCache', () => { expect(cache.get(table2, 800, 600)).toBeUndefined(); }); + it('invalidates cache when underline changes in table cells', () => { + const table1: TableBlock = { + kind: 'table', + id: 'table-underline', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0', + paragraph: { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'Hello', fontFamily: 'Arial', fontSize: 12, underline: { style: 'single' } }], + }, + }, + ], + }, + ], + }; + + const table2: TableBlock = { + kind: 'table', + id: 'table-underline', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0', + paragraph: { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'Hello', fontFamily: 'Arial', fontSize: 12 }], + }, + }, + ], + }, + ], + }; + + cache.set(table1, 800, 600, { totalHeight: 50 }); + expect(cache.get(table2, 800, 600)).toBeUndefined(); + }); + + it('invalidates cache when hyperlink mark changes in table cells', () => { + const table1: TableBlock = { + kind: 'table', + id: 'table-link', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0', + paragraph: { + kind: 'paragraph', + id: 'para-0', + runs: [ + { + text: 'Hello', + fontFamily: 'Arial', + fontSize: 12, + link: { href: 'https://example.com' } as any, + }, + ], + }, + }, + ], + }, + ], + }; + + const table2: TableBlock = { + kind: 'table', + id: 'table-link', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0', + paragraph: { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'Hello', fontFamily: 'Arial', fontSize: 12 }], + }, + }, + ], + }, + ], + }; + + cache.set(table1, 800, 600, { totalHeight: 50 }); + expect(cache.get(table2, 800, 600)).toBeUndefined(); + }); + it('creates cache hit when table formatting is identical', () => { const table1: TableBlock = { kind: 'table', diff --git a/packages/layout-engine/layout-bridge/test/diff.test.ts b/packages/layout-engine/layout-bridge/test/diff.test.ts index 04d42da5b7..7c762a635f 100644 --- a/packages/layout-engine/layout-bridge/test/diff.test.ts +++ b/packages/layout-engine/layout-bridge/test/diff.test.ts @@ -98,6 +98,51 @@ describe('computeDirtyRegions', () => { expect(result.firstDirtyIndex).toBe(0); }); + it('detects underline changes', () => { + const prev = [ + { + kind: 'paragraph' as const, + id: '0-paragraph', + runs: [{ text: 'Hello', fontFamily: 'Arial', fontSize: 12, underline: { style: 'single' as const } }], + }, + ]; + const next = [ + { + kind: 'paragraph' as const, + id: '0-paragraph', + runs: [{ text: 'Hello', fontFamily: 'Arial', fontSize: 12 }], + }, + ]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + }); + + it('detects hyperlink changes', () => { + const prev = [ + { + kind: 'paragraph' as const, + id: '0-paragraph', + runs: [ + { + text: 'Hello', + fontFamily: 'Arial', + fontSize: 12, + link: { href: 'https://example.com' } as any, + }, + ], + }, + ]; + const next = [ + { + kind: 'paragraph' as const, + id: '0-paragraph', + runs: [{ text: 'Hello', fontFamily: 'Arial', fontSize: 12 }], + }, + ]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + }); + it('treats identical fontSize and fontFamily as stable', () => { const prev = [ { diff --git a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js index f1db6063f9..68bc85cfa7 100644 --- a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js +++ b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js @@ -23,6 +23,8 @@ const RUN_PROPERTIES_DERIVED_FROM_MARKS = new Set([ 'position', ]); +const TRANSIENT_HYPERLINK_STYLE_IDS = new Set(['Hyperlink', 'FollowedHyperlink']); + const RUN_PROPERTY_PRESERVE_META_KEY = 'sdPreserveRunPropertiesKeys'; /** @@ -384,6 +386,14 @@ function getInlineRunProperties( if (existingRunProperties != null) { Object.keys(existingRunProperties).forEach((key) => { if (RUN_PROPERTIES_DERIVED_FROM_MARKS.has(key) && !preservedDerivedKeys.has(key)) return; + if ( + key === 'styleId' && + TRANSIENT_HYPERLINK_STYLE_IDS.has(existingRunProperties[key]) && + (runPropertiesFromMarks.styleId == null || runPropertiesFromMarks.styleId === '') + ) { + // Link-derived character styles must not survive after link/textStyle marks are removed. + return; + } if (key in inlineRunProperties) return; if (existingRunProperties[key] === undefined) return; inlineRunProperties[key] = existingRunProperties[key]; diff --git a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.test.js b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.test.js index 8ab7e39e7d..a296ea4f4a 100644 --- a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.test.js +++ b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.test.js @@ -464,6 +464,23 @@ describe('calculateInlineRunPropertiesPlugin', () => { expect(runNode?.attrs.runProperties).toEqual({ styleId: 'Style1' }); }); + it('drops stale Hyperlink styleId after hyperlink marks are removed', () => { + decodeRPrFromMarksMock.mockImplementation(() => ({})); + resolveRunPropertiesMock.mockImplementation(() => ({})); + + const schema = makeSchema(); + const boldMark = schema.marks.bold.create(); + const doc = paragraphDoc(schema, { runProperties: { bold: true, styleId: 'Hyperlink' } }, [boldMark]); + const state = createState(schema, doc); + const { from, to } = runTextRange(state.doc, 0, doc.textContent.length); + + const tr = state.tr.removeMark(from, to, schema.marks.bold); + const { state: nextState } = state.applyTransaction(tr); + + const runNode = nextState.doc.nodeAt(runPos(nextState.doc) ?? 0); + expect(runNode?.attrs.runProperties).toBeNull(); + }); + it('does not carry over mark-derived properties from existing runProperties', () => { decodeRPrFromMarksMock.mockImplementation(() => ({ fontFamily: { ascii: 'Arial' } })); resolveRunPropertiesMock.mockImplementation(() => ({ fontFamily: { ascii: 'Arial' } })); diff --git a/packages/super-editor/src/extensions/track-changes/track-changes-extension.test.js b/packages/super-editor/src/extensions/track-changes/track-changes-extension.test.js index 457569b28c..73687becac 100644 --- a/packages/super-editor/src/extensions/track-changes/track-changes-extension.test.js +++ b/packages/super-editor/src/extensions/track-changes/track-changes-extension.test.js @@ -4,6 +4,7 @@ import { TrackChanges } from './track-changes.js'; import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from './constants.js'; import { TrackChangesBasePlugin, TrackChangesBasePluginKey } from './plugins/trackChangesBasePlugin.js'; import { initTestEditor } from '@tests/helpers/helpers.js'; +import { hasAnyMark } from './trackChangesHelpers/documentHelpers.js'; const commands = TrackChanges.config.addCommands(); @@ -664,6 +665,83 @@ describe('TrackChanges extension commands', () => { expect(markPresent(afterReject.doc, 'textStyle')).toBe(false); }); + it('rejectTrackedChangesBetween removes richer after textStyle snapshots against sparse live marks', () => { + const suggestedTextStyle = schema.marks.textStyle.create({ + color: '#0563C1', + }); + const formatMark = schema.marks[TrackFormatMarkName].create({ + id: 'fmt-rich-after-textstyle', + before: [], + after: [ + { + type: 'textStyle', + attrs: { + color: '#0563C1', + styleId: 'Hyperlink', + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + }, + }, + ], + }); + const doc = createDoc('Styled', [suggestedTextStyle, formatMark]); + const rejectState = createState(doc); + + let afterReject; + commands.rejectTrackedChangesBetween( + 1, + doc.content.size, + )({ + state: rejectState, + dispatch: (tr) => { + afterReject = rejectState.apply(tr); + }, + }); + + expect(afterReject).toBeDefined(); + expect(markPresent(afterReject.doc, TrackFormatMarkName)).toBe(false); + expect(markPresent(afterReject.doc, 'textStyle')).toBe(false); + }); + + it('rejectTrackedChangesBetween preserves restored textStyle when before/after attrs overlap', () => { + const beforeTextStyle = schema.marks.textStyle.create({ + color: '#0563C1', + fontFamily: 'Times New Roman, serif', + fontSize: '12pt', + }); + const afterTextStyle = schema.marks.textStyle.create({ + color: '#0563C1', + styleId: 'Hyperlink', + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + }); + const formatMark = schema.marks[TrackFormatMarkName].create({ + id: 'fmt-overlap-reject', + before: [{ type: 'textStyle', attrs: beforeTextStyle.attrs }], + after: [{ type: 'textStyle', attrs: afterTextStyle.attrs }], + }); + const doc = createDoc('Styled', [afterTextStyle, formatMark]); + const rejectState = createState(doc); + + let afterReject; + commands.rejectTrackedChangesBetween( + 1, + doc.content.size, + )({ + state: rejectState, + dispatch: (tr) => { + afterReject = rejectState.apply(tr); + }, + }); + + expect(afterReject).toBeDefined(); + expect(markPresent(afterReject.doc, TrackFormatMarkName)).toBe(false); + + const restoredTextStyle = afterReject.doc.nodeAt(1)?.marks.find((mark) => mark.type.name === 'textStyle'); + expect(restoredTextStyle).toBeDefined(); + expect(restoredTextStyle?.attrs).toEqual(beforeTextStyle.attrs); + }); + it('rejectTrackedChangesBetween restores full before snapshot across tracked mark types', () => { const beforeTextStyle = schema.marks.textStyle.create({ styleId: 'Emphasis', @@ -832,6 +910,141 @@ describe('TrackChanges extension commands', () => { } }); + it('interaction: rejecting hyperlink suggestion removes link formatting', () => { + const { editor: interactionEditor } = initTestEditor({ + mode: 'text', + content: '

Plain text

', + user: { name: 'Track Tester', email: 'track@example.com' }, + }); + + try { + interactionEditor.setDocumentMode('suggesting'); + + const textRange = getFirstTextRange(interactionEditor.state.doc); + expect(textRange).toBeDefined(); + + interactionEditor.view.dispatch( + interactionEditor.state.tr.setSelection( + TextSelection.create(interactionEditor.state.doc, textRange.from, textRange.to), + ), + ); + + interactionEditor.commands.setLink({ href: 'https://example.com' }); + + expect(hasAnyMark(interactionEditor.state.doc, 'link')).toBe(true); + expect(hasAnyMark(interactionEditor.state.doc, TrackFormatMarkName)).toBe(true); + + const selectedRange = getFirstTextRange(interactionEditor.state.doc); + interactionEditor.view.dispatch( + interactionEditor.state.tr.setSelection( + TextSelection.create(interactionEditor.state.doc, selectedRange.from, selectedRange.to), + ), + ); + + interactionEditor.commands.rejectTrackedChangeOnSelection(); + + expect(hasAnyMark(interactionEditor.state.doc, TrackFormatMarkName)).toBe(false); + expect(hasAnyMark(interactionEditor.state.doc, 'link')).toBe(false); + expect(hasAnyMark(interactionEditor.state.doc, 'underline')).toBe(false); + expect(hasAnyMark(interactionEditor.state.doc, 'textStyle')).toBe(false); + } finally { + interactionEditor.destroy(); + } + }); + + it('interaction: rejecting hyperlink suggestion by tracked-change id removes link formatting', () => { + const { editor: interactionEditor } = initTestEditor({ + mode: 'text', + content: '

Plain text

', + user: { name: 'Track Tester', email: 'track@example.com' }, + }); + + try { + interactionEditor.setDocumentMode('suggesting'); + + const textRange = getFirstTextRange(interactionEditor.state.doc); + expect(textRange).toBeDefined(); + + interactionEditor.view.dispatch( + interactionEditor.state.tr.setSelection( + TextSelection.create(interactionEditor.state.doc, textRange.from, textRange.to), + ), + ); + + interactionEditor.commands.setLink({ href: 'https://example.com' }); + + const trackFormatIds = new Set(); + interactionEditor.state.doc.descendants((node) => { + if (!node.isText) return; + node.marks.forEach((mark) => { + if (mark.type.name === TrackFormatMarkName && mark.attrs?.id) { + trackFormatIds.add(mark.attrs.id); + } + }); + }); + + const [trackedChangeId] = [...trackFormatIds]; + expect(trackedChangeId).toBeDefined(); + expect(trackFormatIds.size).toBe(1); + + interactionEditor.commands.rejectTrackedChangeById(trackedChangeId); + + expect(hasAnyMark(interactionEditor.state.doc, TrackFormatMarkName)).toBe(false); + expect(hasAnyMark(interactionEditor.state.doc, 'link')).toBe(false); + expect(hasAnyMark(interactionEditor.state.doc, 'underline')).toBe(false); + expect(hasAnyMark(interactionEditor.state.doc, 'textStyle')).toBe(false); + } finally { + interactionEditor.destroy(); + } + }); + + it('interaction(docx): rejecting hyperlink suggestion by tracked-change id removes link formatting', () => { + const { editor: interactionEditor } = initTestEditor({ + mode: 'docx', + content: '

', + user: { name: 'Track Tester', email: 'track@example.com' }, + }); + + try { + interactionEditor.commands.insertContent('Plain text'); + interactionEditor.setDocumentMode('suggesting'); + + const textRange = getFirstTextRange(interactionEditor.state.doc); + expect(textRange).toBeDefined(); + + interactionEditor.view.dispatch( + interactionEditor.state.tr.setSelection( + TextSelection.create(interactionEditor.state.doc, textRange.from, textRange.to), + ), + ); + + interactionEditor.commands.setLink({ href: 'https://example.com' }); + + const trackFormatIds = new Set(); + interactionEditor.state.doc.descendants((node) => { + if (!node.isText) return; + node.marks.forEach((mark) => { + if (mark.type.name === TrackFormatMarkName && mark.attrs?.id) { + trackFormatIds.add(mark.attrs.id); + } + }); + }); + + const [trackedChangeId] = [...trackFormatIds]; + expect(trackedChangeId).toBeDefined(); + expect(trackFormatIds.size).toBe(1); + + interactionEditor.commands.rejectTrackedChangeById(trackedChangeId); + + expect(hasAnyMark(interactionEditor.state.doc, TrackFormatMarkName)).toBe(false); + expect(hasAnyMark(interactionEditor.state.doc, 'link')).toBe(false); + expect(hasAnyMark(interactionEditor.state.doc, 'underline')).toBe(false); + expect(hasAnyMark(interactionEditor.state.doc, 'textStyle')).toBe(false); + } finally { + interactionEditor.destroy(); + } + }); + it('interaction: rejectTrackedChangeOnSelection reverts mixed marks + textStyle in suggesting mode', () => { const { editor: interactionEditor } = initTestEditor({ mode: 'text', diff --git a/packages/super-editor/src/extensions/track-changes/track-changes.js b/packages/super-editor/src/extensions/track-changes/track-changes.js index c8b899e1d4..11d0b9636f 100644 --- a/packages/super-editor/src/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/extensions/track-changes/track-changes.js @@ -97,10 +97,6 @@ export const TrackChanges = Extension.create({ return; } - trackedMark.attrs.before.forEach((oldMark) => { - tr.step(new AddMarkStep(mappedFrom, mappedTo, state.schema.marks[oldMark.type].create(oldMark.attrs))); - }); - trackedMark.attrs.after.forEach((newMark) => { const liveMark = findMarkInRangeBySnapshot({ doc: tr.doc, @@ -116,6 +112,12 @@ export const TrackChanges = Extension.create({ tr.step(new RemoveMarkStep(mappedFrom, mappedTo, liveMark)); }); + // Remove suggested "after" marks first, then restore "before" marks. + // This avoids overlap matching removing a just-restored attribute-only mark (e.g. textStyle). + trackedMark.attrs.before.forEach((oldMark) => { + tr.step(new AddMarkStep(mappedFrom, mappedTo, state.schema.marks[oldMark.type].create(oldMark.attrs))); + }); + tr.step(new RemoveMarkStep(mappedFrom, mappedTo, trackedMark)); }); diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.js index b2f115f08f..c28ece4c1b 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.js @@ -49,7 +49,7 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { const wid = existingChangeMark ? existingChangeMark.attrs.id : (sharedWid ?? (sharedWid = uuidv4())); newTr.addMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), step.mark); - const allowedMarks = ['bold', 'italic', 'strike', 'underline', 'textStyle', 'highlight']; + const allowedMarks = ['bold', 'italic', 'strike', 'underline', 'textStyle', 'highlight', 'link']; // ![TrackDeleteMarkName].includes(step.mark.type.name) if (allowedMarks.includes(step.mark.type.name) && !hasMatchingMark(liveMarks, step.mark)) { diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/documentHelpers.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/documentHelpers.js index 6d8b04f838..dec0fbec17 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/documentHelpers.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/documentHelpers.js @@ -60,3 +60,18 @@ export const findChildren = (node, predicate, descend) => { export const findInlineNodes = (node, descend) => { return findChildren(node, (child) => child.isInline, descend); }; + +export const hasAnyMark = (doc, markName) => { + let found = false; + doc.descendants((node) => { + if (found) return false; + if (!node.isInline) return; + + const hasMark = node.marks.some((mark) => mark.type.name === markName); + if (!hasMark) return; + + found = true; + return false; // Stop traversing once we know there's at least one. + }); + return found; +}; diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js index f1a14be124..0de7aea5b7 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js @@ -115,9 +115,39 @@ const markAttrsIncludeSnapshotAttrs = (mark, snapshot) => { return isMatch(normalizedMarkAttrs, normalizedSnapshotAttrs); }; +// Attribute-only marks (like textStyle) can be serialized with different attr density +// between snapshot and live state. This overlap matcher lets reject find the live mark +// when exact/subset comparisons fail but shared attrs still clearly identify the mark. +const markAttrsMatchOnOverlap = (mark, snapshot) => { + if (!mark || !snapshot || mark.type.name !== snapshot.type) { + return false; + } + + if (!ATTRIBUTE_ONLY_MARKS.includes(snapshot.type)) { + return false; + } + + const normalizedMarkAttrs = normalizeAttrs(mark.attrs || {}); + const normalizedSnapshotAttrs = normalizeAttrs(snapshot.attrs || {}); + const markKeys = Object.keys(normalizedMarkAttrs); + const snapshotKeys = Object.keys(normalizedSnapshotAttrs); + + if (markKeys.length === 0 || snapshotKeys.length === 0) { + return false; + } + + const overlapKeys = markKeys.filter((key) => Object.prototype.hasOwnProperty.call(normalizedSnapshotAttrs, key)); + if (overlapKeys.length === 0) { + return false; + } + + return overlapKeys.every((key) => isEqual(normalizedMarkAttrs[key], normalizedSnapshotAttrs[key])); +}; + export const findMarkInRangeBySnapshot = ({ doc, from, to, snapshot }) => { let exactMatch = null; let subsetMatch = null; + let overlapMatch = null; let typeOnlyMatch = null; const normalizedSnapshotAttrs = normalizeAttrs(snapshot?.attrs || {}); const hasSnapshotAttrs = Object.keys(normalizedSnapshotAttrs).length > 0; @@ -146,6 +176,13 @@ export const findMarkInRangeBySnapshot = ({ doc, from, to, snapshot }) => { } } + if (!overlapMatch) { + const overlap = node.marks.find((mark) => markAttrsMatchOnOverlap(mark, snapshot)); + if (overlap) { + overlapMatch = overlap; + } + } + if (!typeOnlyMatch) { const fallback = node.marks.find((mark) => markMatchesSnapshot(mark, snapshot, false)); if (fallback) { @@ -154,7 +191,7 @@ export const findMarkInRangeBySnapshot = ({ doc, from, to, snapshot }) => { } }); - const liveMark = exactMatch || subsetMatch || (shouldFallbackToTypeOnly ? typeOnlyMatch : null); + const liveMark = exactMatch || subsetMatch || overlapMatch || (shouldFallbackToTypeOnly ? typeOnlyMatch : null); if (!liveMark) console.debug('[track-changes] could not find live mark for snapshot', snapshot); return liveMark; }; diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.test.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.test.js index 5eba583d3b..36bbd644d2 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.test.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.test.js @@ -225,4 +225,55 @@ describe('markSnapshotHelpers', () => { expect(match.type.name).toBe('textStyle'); expect(match.attrs).toEqual(richTextStyle.attrs); }); + + it('findMarkInRangeBySnapshot matches rich textStyle snapshots against sparse live marks', () => { + const sparseTextStyle = schema.marks.textStyle.create({ + color: '#FF0000', + }); + const doc = createDocWithRuns([{ text: 'A', marks: [sparseTextStyle] }]); + const state = createState(doc); + + const match = findMarkInRangeBySnapshot({ + doc: state.doc, + from: 2, + to: 3, + snapshot: { + type: 'textStyle', + attrs: { + color: '#FF0000', + styleId: 'Hyperlink', + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + }, + }, + }); + + expect(match).toBeTruthy(); + expect(match.type.name).toBe('textStyle'); + expect(match.attrs).toEqual(sparseTextStyle.attrs); + }); + + it('findMarkInRangeBySnapshot does not match rich textStyle snapshots without overlapping attrs', () => { + const sparseTextStyle = schema.marks.textStyle.create({ + color: '#FF0000', + }); + const doc = createDocWithRuns([{ text: 'A', marks: [sparseTextStyle] }]); + const state = createState(doc); + + const match = findMarkInRangeBySnapshot({ + doc: state.doc, + from: 2, + to: 3, + snapshot: { + type: 'textStyle', + attrs: { + styleId: 'Hyperlink', + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + }, + }, + }); + + expect(match).toBeNull(); + }); }); diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/removeMarkStep.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/removeMarkStep.js index 017c7070eb..38bcb49d0d 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/removeMarkStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/removeMarkStep.js @@ -36,7 +36,7 @@ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { }); newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), step.mark); - const allowedMarks = ['bold', 'italic', 'strike', 'underline', 'textStyle', 'highlight']; + const allowedMarks = ['bold', 'italic', 'strike', 'underline', 'textStyle', 'highlight', 'link']; if (allowedMarks.includes(step.mark.type.name) && hasMatchingMark(liveMarksBeforeRemove, step.mark)) { const formatChangeMark = liveMarksBeforeRemove.find((mark) => mark.type.name === TrackFormatMarkName); diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js index 8ea87ee294..22b1dcd551 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/trackChangesHelpers.test.js @@ -87,6 +87,17 @@ describe('trackChangesHelpers', () => { const inlineNodes = documentHelpers.findInlineNodes(doc, true); expect(inlineNodes.every(({ node }) => node.isInline)).toBe(true); + + const insertMark = schema.marks[TrackInsertMarkName].create({ + id: 'ins-any', + author: user.name, + authorEmail: user.email, + date, + }); + const markedDoc = createDocWithText('abc', [insertMark]); + + expect(documentHelpers.hasAnyMark(markedDoc, TrackInsertMarkName)).toBe(true); + expect(documentHelpers.hasAnyMark(markedDoc, TrackDeleteMarkName)).toBe(false); }); it('parseFormatList gracefully handles malformed input', () => { diff --git a/tests/behavior/tests/comments/sd-2251-reject-hyperlink-suggestion.spec.ts b/tests/behavior/tests/comments/sd-2251-reject-hyperlink-suggestion.spec.ts new file mode 100644 index 0000000000..f50b23f12d --- /dev/null +++ b/tests/behavior/tests/comments/sd-2251-reject-hyperlink-suggestion.spec.ts @@ -0,0 +1,68 @@ +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; +import { listTrackChanges, rejectTrackChange } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true, showSelection: true } }); + +const LINK_DROPDOWN = '.link-input-ctn'; +const TEXT = 'Hyperlink'; +const HREF = 'https://superdoc.dev'; + +async function applyLink(superdoc: SuperDocFixture, href: string): Promise { + const page = superdoc.page; + + await page.locator('[data-item="btn-link"]').click(); + await superdoc.waitForStable(); + + await page.locator(`${LINK_DROPDOWN} input[name="link"]`).fill(href); + await page.locator('[data-item="btn-link-apply"]').click(); + await page.locator(LINK_DROPDOWN).waitFor({ state: 'hidden', timeout: 5000 }); + await superdoc.waitForStable(); +} + +test('SD-2251 rejecting hyperlink suggestion removes hyperlink visuals and tracked bubble', async ({ superdoc }) => { + await superdoc.type(TEXT); + await superdoc.waitForStable(); + await superdoc.snapshot('sd-2251-before-link'); + + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + const textPos = await superdoc.findTextPos(TEXT); + await superdoc.setTextSelection(textPos, textPos + TEXT.length); + await superdoc.waitForStable(); + + await applyLink(superdoc, HREF); + + await superdoc.assertTextHasMarks(TEXT, ['link', 'underline']); + await superdoc.assertTextMarkAttrs(TEXT, 'link', { href: HREF }); + await superdoc.assertTrackedChangeExists('format'); + await superdoc.assertLinkExists(HREF); + + const trackedDialog = superdoc.page.locator('.comment-placeholder .comments-dialog', { + has: superdoc.page.locator('.tracked-change-text'), + }); + await expect(trackedDialog).toHaveCount(1); + await superdoc.snapshot('sd-2251-after-link-suggestion'); + + await expect.poll(async () => (await listTrackChanges(superdoc.page, { type: 'format' })).total).toBe(1); + const formatChanges = await listTrackChanges(superdoc.page, { type: 'format' }); + const changeId = formatChanges.changes[0]?.id; + expect(typeof changeId).toBe('string'); + expect(changeId).toBeTruthy(); + + await rejectTrackChange(superdoc.page, { id: String(changeId) }); + await superdoc.waitForStable(); + + await expect(superdoc.page.locator('.track-format-dec')).toHaveCount(0); + await expect(trackedDialog).toHaveCount(0); + await superdoc.assertTextLacksMarks(TEXT, ['link', 'underline']); + await expect + .poll(() => + superdoc.page.evaluate((href) => { + return Array.from(document.querySelectorAll('.superdoc-link')).some((el) => el.getAttribute('href') === href); + }, HREF), + ) + .toBe(false); + + await superdoc.snapshot('sd-2251-after-reject'); +});