From 9d402911edd1d612359e5a17478bee1037f3c89d Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Sun, 8 Feb 2026 00:17:26 -0800 Subject: [PATCH 1/7] fix(tracked-changes): colors should be restored when format rejected --- .../track-changes-extension.test.js | 104 ++++++++++++- .../extensions/track-changes/track-changes.js | 22 ++- .../trackChangesHelpers/addMarkStep.js | 28 ++-- .../trackChangesHelpers/index.js | 1 + .../markSnapshotHelpers.js | 67 +++++++++ .../markSnapshotHelpers.test.js | 140 ++++++++++++++++++ .../trackChangesHelpers/removeMarkStep.js | 22 +-- .../trackChangesHelpers.test.js | 33 +++++ 8 files changed, 383 insertions(+), 34 deletions(-) create mode 100644 packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js create mode 100644 packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.test.js 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 a92f231a3d..8136488d0d 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 @@ -1,5 +1,5 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { EditorState } from 'prosemirror-state'; +import { EditorState, TextSelection } from 'prosemirror-state'; import { TrackChanges } from './track-changes.js'; import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from './constants.js'; import { TrackChangesBasePlugin, TrackChangesBasePluginKey } from './plugins/trackChangesBasePlugin.js'; @@ -24,6 +24,14 @@ describe('TrackChanges extension commands', () => { }); const markPresent = (doc, markName) => doc.nodeAt(1)?.marks.some((mark) => mark.type.name === markName); + const getFirstTextRange = (doc) => { + let range = null; + doc.descendants((node, pos) => { + if (!node.isText || range) return; + range = { from: pos, to: pos + node.nodeSize }; + }); + return range; + }; beforeEach(() => { ({ editor } = initTestEditor({ mode: 'text', content: '

' })); @@ -203,6 +211,57 @@ describe('TrackChanges extension commands', () => { expect(markPresent(afterReject.doc, 'italic')).toBe(false); }); + it('rejectTrackedChangesBetween restores imported textStyle attrs for color suggestions', () => { + const oldTextStyle = schema.marks.textStyle.create({ + styleId: 'Emphasis', + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + color: '#112233', + }); + const newTextStyle = schema.marks.textStyle.create({ + styleId: 'Emphasis', + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + color: '#FF0000', + }); + const formatMark = schema.marks[TrackFormatMarkName].create({ + id: 'fmt-color-1', + before: [{ type: 'textStyle', attrs: oldTextStyle.attrs }], + after: [{ type: 'textStyle', attrs: newTextStyle.attrs }], + }); + const doc = createDoc('Styled', [newTextStyle, 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); + + let restoredTextStyle; + afterReject.doc.descendants((node) => { + if (!node.isText) { + return; + } + + restoredTextStyle = node.marks.find((mark) => mark.type.name === 'textStyle'); + if (restoredTextStyle) { + return false; + } + }); + + expect(restoredTextStyle).toBeDefined(); + expect(restoredTextStyle.attrs).toEqual(oldTextStyle.attrs); + }); + it('acceptTrackedChangeById and rejectTrackedChangeById should NOT link two insertions', () => { const prevMark = schema.marks[TrackInsertMarkName].create({ id: 'prev' }); const targetMark = schema.marks[TrackInsertMarkName].create({ id: 'ins-id' }); @@ -238,6 +297,49 @@ describe('TrackChanges extension commands', () => { expect(rejectSpy).toHaveBeenCalledWith(2, 3); }); + it('interaction: color suggestion reject removes inline color styling from DOM', () => { + const { editor: interactionEditor } = initTestEditor({ + mode: 'text', + content: '

Plain text

', + user: { name: 'Track Tester', email: 'track@example.com' }, + }); + + try { + interactionEditor.commands.enableTrackChanges(); + + 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.setColor('#FF0000'); + + const coloredInline = interactionEditor.view.dom.querySelector('span[style*="color"]'); + expect(coloredInline).toBeTruthy(); + let hasTrackFormat = false; + interactionEditor.state.doc.descendants((node) => { + if (!node.isText) { + return; + } + if (node.marks.some((mark) => mark.type.name === TrackFormatMarkName)) { + hasTrackFormat = true; + return false; + } + }); + expect(hasTrackFormat).toBe(true); + + interactionEditor.commands.rejectTrackedChangesBetween(0, interactionEditor.state.doc.content.size); + + const coloredInlineAfterReject = interactionEditor.view.dom.querySelector('span[style*="color"]'); + expect(coloredInlineAfterReject).toBeNull(); + } finally { + interactionEditor.destroy(); + } + }); + it('acceptTrackedChangeById links contiguous insertion segments sharing an id across formatting', () => { const italicMark = schema.marks.italic.create(); const insertionId = 'ins-multi'; 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 5078cd5eea..c5729e74a1 100644 --- a/packages/super-editor/src/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/extensions/track-changes/track-changes.js @@ -9,6 +9,7 @@ import { markDeletion } from './trackChangesHelpers/markDeletion.js'; import { markInsertion } from './trackChangesHelpers/markInsertion.js'; import { collectTrackedChanges, isTrackedChangeActionAllowed } from './permission-helpers.js'; import { CommentsPluginKey } from '../comment/comments-plugin.js'; +import { findMarkInRangeBySnapshot } from './trackChangesHelpers/markSnapshotHelpers.js'; export const TrackChanges = Extension.create({ name: 'trackChanges', @@ -119,13 +120,20 @@ export const TrackChanges = Extension.create({ }); formatChangeMark.attrs.after.forEach((newMark) => { - tr.step( - new RemoveMarkStep( - map.map(Math.max(pos, from)), - map.map(Math.min(pos + node.nodeSize, to)), - node.marks.find((mark) => mark.type.name === newMark.type), - ), - ); + const mappedFrom = map.map(Math.max(pos, from)); + const mappedTo = map.map(Math.min(pos + node.nodeSize, to)); + const liveMark = findMarkInRangeBySnapshot({ + doc: tr.doc, + from: mappedFrom, + to: mappedTo, + snapshot: newMark, + }); + + if (!liveMark) { + return; + } + + tr.step(new RemoveMarkStep(mappedFrom, mappedTo, liveMark)); }); tr.step( 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 37e774feda..3bb863caf3 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.js @@ -1,8 +1,8 @@ import { TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; import { v4 as uuidv4 } from 'uuid'; -import { objectIncludes } from '@core/utilities/objectIncludes.js'; import { TrackChangesBasePluginKey } from '../plugins/trackChangesBasePlugin.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; +import { hasMatchingMark, markSnapshotMatchesStepMark, upsertMarkSnapshotByType } from './markSnapshotHelpers.js'; /** * Add mark step. @@ -36,32 +36,28 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { const allowedMarks = ['bold', 'italic', 'strike', 'underline', 'textStyle']; // ![TrackDeleteMarkName].includes(step.mark.type.name) - if (allowedMarks.includes(step.mark.type.name) && !node.marks.find((mark) => mark.type === step.mark.type)) { + if (allowedMarks.includes(step.mark.type.name) && !hasMatchingMark(node.marks, step.mark)) { const formatChangeMark = node.marks.find((mark) => mark.type.name === TrackFormatMarkName); let after = []; let before = []; if (formatChangeMark) { - let foundBefore = formatChangeMark.attrs.before.find((mark) => { - if (mark.type === 'textStyle') { - return mark.type === step.mark.type.name && objectIncludes(mark.attrs, step.mark.attrs); - } - return mark.type === step.mark.type.name; - }); + let foundBefore = formatChangeMark.attrs.before.find((mark) => + markSnapshotMatchesStepMark(mark, step.mark, true), + ); if (foundBefore) { - before = [...formatChangeMark.attrs.before.filter((mark) => mark.type !== step.mark.type.name)]; + before = [ + ...formatChangeMark.attrs.before.filter((mark) => !markSnapshotMatchesStepMark(mark, step.mark, true)), + ]; after = [...formatChangeMark.attrs.after]; } else { before = [...formatChangeMark.attrs.before]; - after = [ - ...formatChangeMark.attrs.after, - { - type: step.mark.type.name, - attrs: { ...step.mark.attrs }, - }, - ]; + after = upsertMarkSnapshotByType(formatChangeMark.attrs.after, { + type: step.mark.type.name, + attrs: { ...step.mark.attrs }, + }); } } else { // before = []; diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/index.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/index.js index 0d36b740a4..ee243c86d4 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/index.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/index.js @@ -9,4 +9,5 @@ export * from './removeMarkStep.js'; export * from './getTrackChanges.js'; export * from './parseFormatList.js'; export * from './findTrackedMarkBetween.js'; +export * from './markSnapshotHelpers.js'; export * as documentHelpers from './documentHelpers.js'; diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js new file mode 100644 index 0000000000..0369729dbb --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js @@ -0,0 +1,67 @@ +import { objectIncludes } from '@core/utilities/objectIncludes.js'; + +export const attrsExactlyMatch = (left = {}, right = {}) => { + return objectIncludes(left, right) && objectIncludes(right, left); +}; + +export const markSnapshotMatchesStepMark = (snapshot, stepMark, exact = true) => { + if (!snapshot || !stepMark || snapshot.type !== stepMark.type.name) { + return false; + } + + if (!exact) { + return true; + } + + return attrsExactlyMatch(snapshot.attrs || {}, stepMark.attrs || {}); +}; + +export const hasMatchingMark = (marks, stepMark) => { + return marks.some((mark) => { + return mark.type === stepMark.type && attrsExactlyMatch(mark.attrs || {}, stepMark.attrs || {}); + }); +}; + +export const upsertMarkSnapshotByType = (snapshots, incoming) => { + const withoutSameType = snapshots.filter((mark) => mark.type !== incoming.type); + return [...withoutSameType, incoming]; +}; + +const markMatchesSnapshot = (mark, snapshot, exact = true) => { + if (!mark || !snapshot || mark.type.name !== snapshot.type) { + return false; + } + + if (!exact) { + return true; + } + + return attrsExactlyMatch(mark.attrs || {}, snapshot.attrs || {}); +}; + +export const findMarkInRangeBySnapshot = ({ doc, from, to, snapshot }) => { + let exactMatch = null; + let typeOnlyMatch = null; + const shouldFallbackToTypeOnly = !snapshot?.attrs || Object.keys(snapshot.attrs).length === 0; + + doc.nodesBetween(from, to, (node) => { + if (!node.isInline) { + return; + } + + const exact = node.marks.find((mark) => markMatchesSnapshot(mark, snapshot, true)); + if (exact && !exactMatch) { + exactMatch = exact; + return false; + } + + if (!typeOnlyMatch) { + const fallback = node.marks.find((mark) => markMatchesSnapshot(mark, snapshot, false)); + if (fallback) { + typeOnlyMatch = fallback; + } + } + }); + + return exactMatch || (shouldFallbackToTypeOnly ? typeOnlyMatch : null); +}; 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 new file mode 100644 index 0000000000..b7db7365cc --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.test.js @@ -0,0 +1,140 @@ +import { beforeEach, afterEach, describe, expect, it } from 'vitest'; +import { EditorState } from 'prosemirror-state'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { + attrsExactlyMatch, + markSnapshotMatchesStepMark, + hasMatchingMark, + upsertMarkSnapshotByType, + findMarkInRangeBySnapshot, +} from './markSnapshotHelpers.js'; + +describe('markSnapshotHelpers', () => { + let editor; + let schema; + let basePlugins; + + beforeEach(() => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + schema = editor.schema; + basePlugins = editor.state.plugins; + }); + + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + const createDocWithRuns = (runs) => { + const runNodes = runs.map(({ text, marks = [] }) => schema.nodes.run.create({}, schema.text(text, marks))); + return schema.nodes.doc.create({}, schema.nodes.paragraph.create({}, runNodes)); + }; + + const createState = (doc) => + EditorState.create({ + schema, + doc, + plugins: basePlugins, + }); + + it('attrsExactlyMatch checks both directions', () => { + expect(attrsExactlyMatch({ color: '#112233', size: '11pt' }, { size: '11pt', color: '#112233' })).toBe(true); + expect(attrsExactlyMatch({ color: '#112233' }, { color: '#112233', size: '11pt' })).toBe(false); + expect(attrsExactlyMatch({}, {})).toBe(true); + }); + + it('markSnapshotMatchesStepMark supports exact and type-only modes', () => { + const textStyleMark = schema.marks.textStyle.create({ color: '#112233', fontSize: '11pt' }); + + expect( + markSnapshotMatchesStepMark({ type: 'textStyle', attrs: { ...textStyleMark.attrs } }, textStyleMark, true), + ).toBe(true); + + expect(markSnapshotMatchesStepMark({ type: 'textStyle', attrs: { color: '#FFFFFF' } }, textStyleMark, true)).toBe( + false, + ); + + expect(markSnapshotMatchesStepMark({ type: 'textStyle', attrs: { color: '#FFFFFF' } }, textStyleMark, false)).toBe( + true, + ); + + expect(markSnapshotMatchesStepMark({ type: 'bold', attrs: {} }, textStyleMark, false)).toBe(false); + }); + + it('hasMatchingMark requires same mark type and attrs', () => { + const existing = [schema.marks.bold.create(), schema.marks.textStyle.create({ color: '#AA0000' })]; + + expect(hasMatchingMark(existing, schema.marks.textStyle.create({ color: '#AA0000' }))).toBe(true); + expect(hasMatchingMark(existing, schema.marks.textStyle.create({ color: '#00AA00' }))).toBe(false); + expect(hasMatchingMark(existing, schema.marks.italic.create())).toBe(false); + }); + + it('upsertMarkSnapshotByType replaces same-type snapshot and preserves others', () => { + const snapshots = [ + { type: 'bold', attrs: {} }, + { type: 'textStyle', attrs: { color: '#112233' } }, + { type: 'italic', attrs: {} }, + ]; + + const updated = upsertMarkSnapshotByType(snapshots, { type: 'textStyle', attrs: { color: '#FF0000' } }); + + expect(updated).toEqual([ + { type: 'bold', attrs: {} }, + { type: 'italic', attrs: {} }, + { type: 'textStyle', attrs: { color: '#FF0000' } }, + ]); + }); + + it('findMarkInRangeBySnapshot returns exact attr match when present', () => { + const red = schema.marks.textStyle.create({ color: '#FF0000' }); + const blue = schema.marks.textStyle.create({ color: '#0000FF' }); + const doc = createDocWithRuns([ + { text: 'A', marks: [red] }, + { text: 'B', marks: [blue] }, + ]); + const state = createState(doc); + + const match = findMarkInRangeBySnapshot({ + doc: state.doc, + from: 1, + to: state.doc.content.size, + snapshot: { type: 'textStyle', attrs: { ...blue.attrs } }, + }); + + expect(match).toBeTruthy(); + expect(match.type.name).toBe('textStyle'); + expect(match.attrs.color).toBe('#0000FF'); + }); + + it('findMarkInRangeBySnapshot falls back to type-only when snapshot attrs are empty', () => { + const red = schema.marks.textStyle.create({ color: '#FF0000' }); + const doc = createDocWithRuns([{ text: 'A', marks: [red] }]); + const state = createState(doc); + + const match = findMarkInRangeBySnapshot({ + doc: state.doc, + from: 2, + to: 3, + snapshot: { type: 'textStyle', attrs: {} }, + }); + + expect(match).toBeTruthy(); + expect(match.type.name).toBe('textStyle'); + expect(match.attrs.color).toBe('#FF0000'); + }); + + it('findMarkInRangeBySnapshot does not fallback when snapshot attrs are present', () => { + const red = schema.marks.textStyle.create({ color: '#FF0000' }); + const doc = createDocWithRuns([{ text: 'A', marks: [red] }]); + const state = createState(doc); + + const match = findMarkInRangeBySnapshot({ + doc: state.doc, + from: 2, + to: 3, + snapshot: { type: 'textStyle', attrs: { color: '#00FF00' } }, + }); + + 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 c00dd6a99f..c3b9a5111b 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/removeMarkStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/removeMarkStep.js @@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid'; import { TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/trackChangesBasePlugin.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; +import { hasMatchingMark, markSnapshotMatchesStepMark, upsertMarkSnapshotByType } from './markSnapshotHelpers.js'; /** * Remove mark step. @@ -28,27 +29,28 @@ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { const allowedMarks = ['bold', 'italic', 'strike', 'underline', 'textStyle']; - if (allowedMarks.includes(step.mark.type.name) && node.marks.find((mark) => mark.type === step.mark.type)) { + if (allowedMarks.includes(step.mark.type.name) && hasMatchingMark(node.marks, step.mark)) { const formatChangeMark = node.marks.find((mark) => mark.type.name === TrackFormatMarkName); let after = []; let before = []; if (formatChangeMark) { - let foundAfter = formatChangeMark.attrs.after.find((mark) => mark.type === step.mark.type.name); + let foundAfter = formatChangeMark.attrs.after.find((mark) => + markSnapshotMatchesStepMark(mark, step.mark, true), + ); if (foundAfter) { - after = [...formatChangeMark.attrs.after.filter((mark) => mark.type !== step.mark.type.name)]; + after = [ + ...formatChangeMark.attrs.after.filter((mark) => !markSnapshotMatchesStepMark(mark, step.mark, true)), + ]; before = [...formatChangeMark.attrs.before]; } else { after = [...formatChangeMark.attrs.after]; - before = [ - ...formatChangeMark.attrs.before, - { - type: step.mark.type.name, - attrs: { ...step.mark.attrs }, - }, - ]; + before = upsertMarkSnapshotByType(formatChangeMark.attrs.before, { + type: step.mark.type.name, + attrs: { ...step.mark.attrs }, + }); } } else { after = []; 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 c208c57654..cb365356df 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 @@ -223,6 +223,39 @@ describe('trackChangesHelpers', () => { expect(newTr.getMeta(CommentsPluginKey)).toEqual({ type: 'force' }); }); + it('addMarkStep tracks textStyle attr changes on imported-like marks', () => { + const importedTextStyle = schema.marks.textStyle.create({ + styleId: 'Emphasis', + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + color: '#112233', + }); + const doc = createDocWithText('Format me', [importedTextStyle]); + const state = createState(doc); + const changedTextStyle = schema.marks.textStyle.create({ + styleId: 'Emphasis', + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + color: '#FF0000', + }); + const step = new AddMarkStep(1, 9, changedTextStyle); + const newTr = state.tr; + + addMarkStep({ + state, + step, + newTr, + doc: state.doc, + user, + date, + }); + + const meta = newTr.getMeta(TrackChangesBasePluginKey); + expect(meta?.formatMark?.type.name).toBe(TrackFormatMarkName); + expect(meta?.formatMark?.attrs?.before).toEqual([{ type: 'textStyle', attrs: importedTextStyle.attrs }]); + expect(meta?.formatMark?.attrs?.after).toEqual([{ type: 'textStyle', attrs: changedTextStyle.attrs }]); + }); + it('removeMarkStep records previous formatting when mark removed', () => { const bold = schema.marks.bold.create(); const doc = createDocWithText('Styled', [bold]); From 1fbcd747de2574875eb9a2516b67f5d18e8ac003 Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Mon, 9 Feb 2026 20:30:13 -0800 Subject: [PATCH 2/7] refactor: a more robust tracking of formatting changes before action --- .../track-changes-extension.test.js | 87 +++++++++++++++++++ .../trackChangesHelpers/addMarkStep.js | 25 ++++-- .../getLiveInlineMarksInRange.js | 21 +++++ .../trackChangesHelpers/index.js | 1 + .../markSnapshotHelpers.js | 8 +- .../markSnapshotHelpers.test.js | 2 + .../trackChangesHelpers/removeMarkStep.js | 12 ++- 7 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 packages/super-editor/src/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js 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 8136488d0d..90e69cae57 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 @@ -262,6 +262,54 @@ describe('TrackChanges extension commands', () => { expect(restoredTextStyle.attrs).toEqual(oldTextStyle.attrs); }); + it('rejectTrackedChangesBetween restores full before snapshot across tracked mark types', () => { + const beforeTextStyle = schema.marks.textStyle.create({ + styleId: 'Emphasis', + fontFamily: 'Times New Roman, serif', + fontSize: '11pt', + color: '#111111', + }); + const afterTextStyle = schema.marks.textStyle.create({ + styleId: 'Emphasis', + fontFamily: 'Arial, sans-serif', + fontSize: '12pt', + color: '#FF0000', + }); + const afterItalic = schema.marks.italic.create(); + const formatMark = schema.marks[TrackFormatMarkName].create({ + id: 'fmt-snapshot-reject', + before: [ + { type: 'bold', attrs: {} }, + { type: 'textStyle', attrs: beforeTextStyle.attrs }, + ], + after: [ + { type: 'italic', attrs: {} }, + { type: 'textStyle', attrs: afterTextStyle.attrs }, + ], + }); + const doc = createDoc('Styled', [afterItalic, 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); + expect(markPresent(afterReject.doc, 'bold')).toBe(true); + expect(markPresent(afterReject.doc, 'italic')).toBe(false); + + const textStyle = afterReject.doc.nodeAt(1)?.marks.find((mark) => mark.type.name === 'textStyle'); + expect(textStyle?.attrs).toEqual(beforeTextStyle.attrs); + }); + it('acceptTrackedChangeById and rejectTrackedChangeById should NOT link two insertions', () => { const prevMark = schema.marks[TrackInsertMarkName].create({ id: 'prev' }); const targetMark = schema.marks[TrackInsertMarkName].create({ id: 'ins-id' }); @@ -340,6 +388,45 @@ describe('TrackChanges extension commands', () => { } }); + it('interaction: rejecting multi-format suggestions reverts all tracked formatting', () => { + const { editor: interactionEditor } = initTestEditor({ + mode: 'text', + content: '

Plain text

', + user: { name: 'Track Tester', email: 'track@example.com' }, + }); + + try { + 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.setFontFamily('Times New Roman, serif'); + interactionEditor.commands.enableTrackChanges(); + + interactionEditor.commands.toggleBold(); + interactionEditor.commands.setColor('#FF00AA'); + interactionEditor.commands.toggleUnderline(); + interactionEditor.commands.setFontFamily('Arial, sans-serif'); + + interactionEditor.commands.rejectTrackedChangesBetween(0, interactionEditor.state.doc.content.size); + + const marks = interactionEditor.state.doc.nodeAt(1)?.marks || []; + const textStyle = marks.find((mark) => mark.type.name === 'textStyle'); + + expect(marks.some((mark) => mark.type.name === TrackFormatMarkName)).toBe(false); + expect(marks.some((mark) => mark.type.name === 'bold')).toBe(false); + expect(marks.some((mark) => mark.type.name === 'underline')).toBe(false); + expect(textStyle?.attrs?.color).not.toBe('#FF00AA'); + } finally { + interactionEditor.destroy(); + } + }); + it('acceptTrackedChangeById links contiguous insertion segments sharing an id across formatting', () => { const italicMark = schema.marks.italic.create(); const insertionId = 'ins-multi'; 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 3bb863caf3..2449f1141e 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.js @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { TrackChangesBasePluginKey } from '../plugins/trackChangesBasePlugin.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; import { hasMatchingMark, markSnapshotMatchesStepMark, upsertMarkSnapshotByType } from './markSnapshotHelpers.js'; +import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; /** * Add mark step. @@ -27,7 +28,14 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { return false; } - const existingChangeMark = node.marks.find((mark) => + const rangeFrom = Math.max(step.from, pos); + const rangeTo = Math.min(step.to, pos + node.nodeSize); + const liveMarks = getLiveInlineMarksInRange({ + doc: newTr.doc, + from: rangeFrom, + to: rangeTo, + }); + const existingChangeMark = liveMarks.find((mark) => [TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name), ); const wid = existingChangeMark ? existingChangeMark.attrs.id : uuidv4(); @@ -36,8 +44,8 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { const allowedMarks = ['bold', 'italic', 'strike', 'underline', 'textStyle']; // ![TrackDeleteMarkName].includes(step.mark.type.name) - if (allowedMarks.includes(step.mark.type.name) && !hasMatchingMark(node.marks, step.mark)) { - const formatChangeMark = node.marks.find((mark) => mark.type.name === TrackFormatMarkName); + if (allowedMarks.includes(step.mark.type.name) && !hasMatchingMark(liveMarks, step.mark)) { + const formatChangeMark = liveMarks.find((mark) => mark.type.name === TrackFormatMarkName); let after = []; let before = []; @@ -60,11 +68,12 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { }); } } else { - // before = []; - before = node.marks.map((mark) => ({ - type: mark.type.name, - attrs: { ...mark.attrs }, - })); + before = liveMarks + .filter((mark) => ![TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name)) + .map((mark) => ({ + type: mark.type.name, + attrs: { ...mark.attrs }, + })); after = [ { diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js new file mode 100644 index 0000000000..6d273a4c0c --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/getLiveInlineMarksInRange.js @@ -0,0 +1,21 @@ +export const getLiveInlineMarksInRange = ({ doc, from, to }) => { + const marks = []; + const seen = new Set(); + + doc.nodesBetween(from, to, (node) => { + if (!node.isInline) { + return; + } + + node.marks.forEach((mark) => { + const key = `${mark.type.name}:${JSON.stringify(mark.attrs || {})}`; + if (seen.has(key)) { + return; + } + seen.add(key); + marks.push(mark); + }); + }); + + return marks; +}; diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/index.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/index.js index ee243c86d4..7212773bc0 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/index.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/index.js @@ -6,6 +6,7 @@ export * from './markInsertion.js'; export * from './markDeletion.js'; export * from './addMarkStep.js'; export * from './removeMarkStep.js'; +export * from './getLiveInlineMarksInRange.js'; export * from './getTrackChanges.js'; export * from './parseFormatList.js'; export * from './findTrackedMarkBetween.js'; 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 0369729dbb..c5f88f97e9 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js @@ -1,7 +1,13 @@ import { objectIncludes } from '@core/utilities/objectIncludes.js'; +const normalizeAttrs = (attrs = {}) => { + return Object.fromEntries(Object.entries(attrs).filter(([, value]) => value !== null && value !== undefined)); +}; + export const attrsExactlyMatch = (left = {}, right = {}) => { - return objectIncludes(left, right) && objectIncludes(right, left); + const normalizedLeft = normalizeAttrs(left); + const normalizedRight = normalizeAttrs(right); + return objectIncludes(normalizedLeft, normalizedRight) && objectIncludes(normalizedRight, normalizedLeft); }; export const markSnapshotMatchesStepMark = (snapshot, stepMark, exact = true) => { 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 b7db7365cc..1e5bef7e70 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 @@ -41,6 +41,8 @@ describe('markSnapshotHelpers', () => { expect(attrsExactlyMatch({ color: '#112233', size: '11pt' }, { size: '11pt', color: '#112233' })).toBe(true); expect(attrsExactlyMatch({ color: '#112233' }, { color: '#112233', size: '11pt' })).toBe(false); expect(attrsExactlyMatch({}, {})).toBe(true); + expect(attrsExactlyMatch({ underline: null }, {})).toBe(true); + expect(attrsExactlyMatch({ underline: null, color: '#111111' }, { color: '#111111' })).toBe(true); }); it('markSnapshotMatchesStepMark supports exact and type-only modes', () => { 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 c3b9a5111b..6b07960279 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/removeMarkStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/removeMarkStep.js @@ -3,6 +3,7 @@ import { TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; import { TrackChangesBasePluginKey } from '../plugins/trackChangesBasePlugin.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; import { hasMatchingMark, markSnapshotMatchesStepMark, upsertMarkSnapshotByType } from './markSnapshotHelpers.js'; +import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; /** * Remove mark step. @@ -25,12 +26,19 @@ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { return false; } + const rangeFrom = Math.max(step.from, pos); + const rangeTo = Math.min(step.to, pos + node.nodeSize); + const liveMarksBeforeRemove = getLiveInlineMarksInRange({ + doc: newTr.doc, + from: rangeFrom, + to: rangeTo, + }); newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), step.mark); const allowedMarks = ['bold', 'italic', 'strike', 'underline', 'textStyle']; - if (allowedMarks.includes(step.mark.type.name) && hasMatchingMark(node.marks, step.mark)) { - const formatChangeMark = node.marks.find((mark) => mark.type.name === TrackFormatMarkName); + if (allowedMarks.includes(step.mark.type.name) && hasMatchingMark(liveMarksBeforeRemove, step.mark)) { + const formatChangeMark = liveMarksBeforeRemove.find((mark) => mark.type.name === TrackFormatMarkName); let after = []; let before = []; From e5d3f689e4a945b4e9c5abf68e74a3776d5d63c3 Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Mon, 9 Feb 2026 20:31:52 -0800 Subject: [PATCH 3/7] fix: add warning for missing live mark in findMarkInRangeBySnapshot --- .../track-changes/trackChangesHelpers/markSnapshotHelpers.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 c5f88f97e9..7c70c15846 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js @@ -69,5 +69,7 @@ export const findMarkInRangeBySnapshot = ({ doc, from, to, snapshot }) => { } }); - return exactMatch || (shouldFallbackToTypeOnly ? typeOnlyMatch : null); + const liveMark = exactMatch || (shouldFallbackToTypeOnly ? typeOnlyMatch : null); + if (!liveMark) console.warn('[track-changes] could not find live mark for snapshot', snapshot); + return liveMark; }; From 952c3f7c02d205e87e6f6e5cd345e63d603104ed Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 11 Feb 2026 08:24:27 -0300 Subject: [PATCH 4/7] test(interaction): tracked change reject --- .../comments-tcs/reject-format-suggestion.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 devtools/visual-testing/tests/interactions/stories/comments-tcs/reject-format-suggestion.ts diff --git a/devtools/visual-testing/tests/interactions/stories/comments-tcs/reject-format-suggestion.ts b/devtools/visual-testing/tests/interactions/stories/comments-tcs/reject-format-suggestion.ts new file mode 100644 index 0000000000..32d9c43c4e --- /dev/null +++ b/devtools/visual-testing/tests/interactions/stories/comments-tcs/reject-format-suggestion.ts @@ -0,0 +1,71 @@ +import { defineStory } from '@superdoc-testing/helpers'; + +const WAIT_MS = 500; + +export default defineStory({ + name: 'reject-format-suggestion', + description: 'Rejecting a color/format suggestion in suggestion mode restores original styling.', + tickets: ['SD-1770', 'IT-411'], + startDocument: null, + layout: true, + comments: 'panel', + hideCaret: true, + waitForFonts: true, + + async run(page, helpers): Promise { + const { step, type, selectAll, focus, executeCommand, setDocumentMode, waitForStable, milestone } = helpers; + + // ========================================= + // SETUP: Type text with specific styling + // ========================================= + await step('Type and style initial text', async () => { + await focus(); + await type('Agreement signed by both parties'); + await waitForStable(WAIT_MS); + + await selectAll(); + await executeCommand('setFontFamily', 'Times New Roman, serif'); + await executeCommand('setColor', '#112233'); + await waitForStable(WAIT_MS); + await milestone('initial', 'Text styled with Times New Roman and #112233 color.'); + }); + + // ========================================= + // SCENARIO 1: Color suggestion then reject + // ========================================= + await step('Enter suggesting mode and change color', async () => { + await setDocumentMode('suggesting'); + await waitForStable(300); + + await selectAll(); + await executeCommand('setColor', '#FF0000'); + await waitForStable(WAIT_MS); + await milestone('color-suggested', 'Color changed to red in suggesting mode.'); + }); + + await step('Reject color suggestion', async () => { + await executeCommand('rejectAllTrackedChanges'); + await waitForStable(WAIT_MS); + await milestone('color-rejected', 'Color reverted to #112233, Times New Roman preserved.'); + }); + + // ========================================= + // SCENARIO 2: Multi-format suggestion then reject + // ========================================= + await step('Apply multiple format changes in suggesting mode', async () => { + await selectAll(); + await executeCommand('toggleBold'); + await executeCommand('toggleUnderline'); + await executeCommand('setColor', '#FF00AA'); + await executeCommand('setFontFamily', 'Arial, sans-serif'); + await waitForStable(WAIT_MS); + await milestone('multi-format-suggested', 'Bold, underline, color, and font changed in suggesting mode.'); + }); + + await step('Reject all multi-format suggestions', async () => { + await executeCommand('rejectAllTrackedChanges'); + await waitForStable(WAIT_MS); + await milestone('multi-format-rejected', 'All formatting reverted to original Times New Roman #112233.'); + }); + }, +}); From 2555f816f30d2b08aaa4bad1b2ad5c6e4d0c71a4 Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Wed, 11 Feb 2026 22:20:04 -0800 Subject: [PATCH 5/7] fix: reduce strictness on match --- .../track-changes-extension.test.js | 31 +++++++++++++++++++ .../markSnapshotHelpers.js | 29 +++++++++++++++-- .../markSnapshotHelpers.test.js | 22 +++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) 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 90e69cae57..4436a14eef 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 @@ -262,6 +262,37 @@ describe('TrackChanges extension commands', () => { expect(restoredTextStyle.attrs).toEqual(oldTextStyle.attrs); }); + it('rejectTrackedChangesBetween removes sparse after textStyle snapshots against richer live marks', () => { + const suggestedTextStyle = schema.marks.textStyle.create({ + styleId: 'Emphasis', + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + color: '#FF0000', + }); + const formatMark = schema.marks[TrackFormatMarkName].create({ + id: 'fmt-sparse-after', + before: [], + after: [{ type: 'textStyle', attrs: { color: '#FF0000' } }], + }); + 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 restores full before snapshot across tracked mark types', () => { const beforeTextStyle = schema.marks.textStyle.create({ styleId: 'Emphasis', 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 7c70c15846..c019b03fca 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js @@ -45,10 +45,28 @@ const markMatchesSnapshot = (mark, snapshot, exact = true) => { return attrsExactlyMatch(mark.attrs || {}, snapshot.attrs || {}); }; +const markAttrsIncludeSnapshotAttrs = (mark, snapshot) => { + if (!mark || !snapshot || mark.type.name !== snapshot.type) { + return false; + } + + const normalizedMarkAttrs = normalizeAttrs(mark.attrs || {}); + const normalizedSnapshotAttrs = normalizeAttrs(snapshot.attrs || {}); + + if (Object.keys(normalizedSnapshotAttrs).length === 0) { + return false; + } + + return objectIncludes(normalizedMarkAttrs, normalizedSnapshotAttrs); +}; + export const findMarkInRangeBySnapshot = ({ doc, from, to, snapshot }) => { let exactMatch = null; + let subsetMatch = null; let typeOnlyMatch = null; - const shouldFallbackToTypeOnly = !snapshot?.attrs || Object.keys(snapshot.attrs).length === 0; + const normalizedSnapshotAttrs = normalizeAttrs(snapshot?.attrs || {}); + const hasSnapshotAttrs = Object.keys(normalizedSnapshotAttrs).length > 0; + const shouldFallbackToTypeOnly = !hasSnapshotAttrs; doc.nodesBetween(from, to, (node) => { if (!node.isInline) { @@ -61,6 +79,13 @@ export const findMarkInRangeBySnapshot = ({ doc, from, to, snapshot }) => { return false; } + if (!subsetMatch) { + const subset = node.marks.find((mark) => markAttrsIncludeSnapshotAttrs(mark, snapshot)); + if (subset) { + subsetMatch = subset; + } + } + if (!typeOnlyMatch) { const fallback = node.marks.find((mark) => markMatchesSnapshot(mark, snapshot, false)); if (fallback) { @@ -69,7 +94,7 @@ export const findMarkInRangeBySnapshot = ({ doc, from, to, snapshot }) => { } }); - const liveMark = exactMatch || (shouldFallbackToTypeOnly ? typeOnlyMatch : null); + const liveMark = exactMatch || subsetMatch || (shouldFallbackToTypeOnly ? typeOnlyMatch : null); if (!liveMark) console.warn('[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 1e5bef7e70..0a206c6c75 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 @@ -139,4 +139,26 @@ describe('markSnapshotHelpers', () => { expect(match).toBeNull(); }); + + it('findMarkInRangeBySnapshot falls back to subset attr match for sparse snapshots', () => { + const richTextStyle = schema.marks.textStyle.create({ + styleId: 'Emphasis', + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + color: '#FF0000', + }); + const doc = createDocWithRuns([{ text: 'A', marks: [richTextStyle] }]); + const state = createState(doc); + + const match = findMarkInRangeBySnapshot({ + doc: state.doc, + from: 2, + to: 3, + snapshot: { type: 'textStyle', attrs: { color: '#FF0000' } }, + }); + + expect(match).toBeTruthy(); + expect(match.type.name).toBe('textStyle'); + expect(match.attrs).toEqual(richTextStyle.attrs); + }); }); From 8b17283da555d380589875989e797923948d8c5a Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Wed, 11 Feb 2026 22:24:22 -0800 Subject: [PATCH 6/7] refactor: dedupe function --- .../markSnapshotHelpers.js | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) 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 c019b03fca..ea6f218dea 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js @@ -10,8 +10,12 @@ export const attrsExactlyMatch = (left = {}, right = {}) => { return objectIncludes(normalizedLeft, normalizedRight) && objectIncludes(normalizedRight, normalizedLeft); }; -export const markSnapshotMatchesStepMark = (snapshot, stepMark, exact = true) => { - if (!snapshot || !stepMark || snapshot.type !== stepMark.type.name) { +const getTypeName = (markLike) => { + return markLike?.type?.name ?? markLike?.type; +}; + +const marksMatch = (left, right, exact = true) => { + if (!left || !right || getTypeName(left) !== getTypeName(right)) { return false; } @@ -19,12 +23,16 @@ export const markSnapshotMatchesStepMark = (snapshot, stepMark, exact = true) => return true; } - return attrsExactlyMatch(snapshot.attrs || {}, stepMark.attrs || {}); + return attrsExactlyMatch(left.attrs || {}, right.attrs || {}); +}; + +export const markSnapshotMatchesStepMark = (snapshot, stepMark, exact = true) => { + return marksMatch(snapshot, stepMark, exact); }; export const hasMatchingMark = (marks, stepMark) => { return marks.some((mark) => { - return mark.type === stepMark.type && attrsExactlyMatch(mark.attrs || {}, stepMark.attrs || {}); + return marksMatch(mark, stepMark, true); }); }; @@ -34,15 +42,7 @@ export const upsertMarkSnapshotByType = (snapshots, incoming) => { }; const markMatchesSnapshot = (mark, snapshot, exact = true) => { - if (!mark || !snapshot || mark.type.name !== snapshot.type) { - return false; - } - - if (!exact) { - return true; - } - - return attrsExactlyMatch(mark.attrs || {}, snapshot.attrs || {}); + return marksMatch(mark, snapshot, exact); }; const markAttrsIncludeSnapshotAttrs = (mark, snapshot) => { From 5b75741d9318e60487eb5d6eddd931c96a033229 Mon Sep 17 00:00:00 2001 From: Clarence Palmer Date: Wed, 11 Feb 2026 22:30:02 -0800 Subject: [PATCH 7/7] fix: short circuit for perf --- .../track-changes/trackChangesHelpers/markSnapshotHelpers.js | 5 +++++ 1 file changed, 5 insertions(+) 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 ea6f218dea..ec24c12e2b 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/markSnapshotHelpers.js @@ -69,6 +69,11 @@ export const findMarkInRangeBySnapshot = ({ doc, from, to, snapshot }) => { const shouldFallbackToTypeOnly = !hasSnapshotAttrs; doc.nodesBetween(from, to, (node) => { + // nodesBetween cannot be fully broken; skip extra scans once exact match is found. + if (exactMatch) { + return false; + } + if (!node.isInline) { return; }