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 6fd16bebb1..1511520702 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.js @@ -18,6 +18,7 @@ import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; */ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { const meta = {}; + let sharedWid = null; doc.nodesBetween(step.from, step.to, (node, pos) => { if (!node.isInline || node.type.name === 'run') { @@ -38,7 +39,7 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { const existingChangeMark = liveMarks.find((mark) => [TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name), ); - const wid = existingChangeMark ? existingChangeMark.attrs.id : uuidv4(); + 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']; @@ -93,11 +94,7 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => { before, after, }); - newTr.addMark( - step.from, // Math.max(step.from, pos) - step.to, // Math.min(step.to, pos + node.nodeSize), - newFormatMark, - ); + newTr.addMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), newFormatMark); meta.formatMark = newFormatMark; meta.step = step; diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.test.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.test.js new file mode 100644 index 0000000000..0405b6ec7b --- /dev/null +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.test.js @@ -0,0 +1,172 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { AddMarkStep } from 'prosemirror-transform'; +import { trackedTransaction } from './index.js'; +import { TrackFormatMarkName } from '../constants.js'; +import { initTestEditor } from '@tests/helpers/helpers.js'; + +describe('trackChangesHelpers addMarkStep / removeMarkStep (track format)', () => { + let editor; + let schema; + let basePlugins; + + const user = { name: 'Track Tester', email: 'track@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, + }); + + /** + * Collect all TrackFormat marks from a document. + * Returns an array of { id, before, after, from, to } objects. + */ + const getTrackFormatMarks = (docNode) => { + const results = []; + docNode.descendants((node, pos) => { + if (!node.isInline) return; + const tfMark = node.marks.find((m) => m.type.name === TrackFormatMarkName); + if (tfMark) { + results.push({ + id: tfMark.attrs.id, + before: tfMark.attrs.before, + after: tfMark.attrs.after, + from: pos, + to: pos + node.nodeSize, + }); + } + }); + return results; + }; + + it('shares one TrackFormat ID across two text nodes when a single AddMarkStep spans both', () => { + // Create a paragraph with two adjacent text nodes inside a run: + // "Hello" (plain) + "World" (italic). A single AddMarkStep across both + // should produce TrackFormat marks that share one ID. + const italicMark = schema.marks.italic.create(); + const run = schema.nodes.run.create({}, [schema.text('Hello'), schema.text('World', [italicMark])]); + const doc = schema.nodes.doc.create({}, schema.nodes.paragraph.create({}, run)); + let state = createState(doc); + + // Find positions of both text nodes + let helloPos = null; + let worldEnd = null; + state.doc.descendants((node, pos) => { + if (node.isText && node.text === 'Hello') helloPos = pos; + if (node.isText && node.text === 'World') worldEnd = pos + node.nodeSize; + }); + expect(helloPos).toBeTypeOf('number'); + expect(worldEnd).toBeTypeOf('number'); + + // Use a single AddMarkStep to ensure both nodes are processed in one call + const boldMark = schema.marks.bold.create(); + let tr = state.tr; + tr.step(new AddMarkStep(helloPos, worldEnd, boldMark)); + tr.setMeta('inputType', 'programmatic'); + const tracked = trackedTransaction({ tr, state, user }); + state = state.apply(tracked); + + // Both text nodes should have TrackFormat marks with the same ID + const tfMarks = getTrackFormatMarks(state.doc); + expect(tfMarks.length).toBeGreaterThanOrEqual(2); + + const ids = new Set(tfMarks.map((m) => m.id)); + expect(ids.size).toBe(1); + + // Ranges should be clamped to per-node boundaries (no overlap between marks) + expect(tfMarks[0].to).toBeLessThanOrEqual(tfMarks[1].from); + + // The "after" array should include bold + for (const tf of tfMarks) { + expect(tf.after.some((s) => s.type === 'bold')).toBe(true); + } + }); + + it('removes TrackFormat mark when toggling bold off immediately after adding it', () => { + // Create a paragraph with plain text "Hello" + const run = schema.nodes.run.create({}, [schema.text('Hello')]); + const doc = schema.nodes.doc.create({}, schema.nodes.paragraph.create({}, run)); + let state = createState(doc); + + let helloPos = null; + let helloEnd = null; + state.doc.descendants((node, pos) => { + if (node.isText && node.text === 'Hello') { + helloPos = pos; + helloEnd = pos + node.nodeSize; + } + }); + expect(helloPos).toBeTypeOf('number'); + + state = state.apply(state.tr.setSelection(TextSelection.create(state.doc, helloPos, helloEnd))); + + // Step 1: Add bold (tracked) + const boldMark = schema.marks.bold.create(); + let tr = state.tr.addMark(helloPos, helloEnd, boldMark); + tr.setMeta('inputType', 'programmatic'); + let tracked = trackedTransaction({ tr, state, user }); + state = state.apply(tracked); + + // Verify TrackFormat exists after adding bold + let tfMarks = getTrackFormatMarks(state.doc); + expect(tfMarks.length).toBeGreaterThanOrEqual(1); + expect(tfMarks[0].after.some((s) => s.type === 'bold')).toBe(true); + + // Step 2: Remove bold (tracked) — this reverses the tracked addition + tr = state.tr.removeMark(helloPos, helloEnd, boldMark); + tr.setMeta('inputType', 'programmatic'); + tracked = trackedTransaction({ tr, state, user }); + state = state.apply(tracked); + + // TrackFormat should be completely removed (both before and after are empty) + tfMarks = getTrackFormatMarks(state.doc); + expect(tfMarks.length).toBe(0); + }); + + it('keeps TrackFormat when removing bold reveals a tracked removal (before is non-empty)', () => { + // Create text that already has bold — removing bold in track mode creates + // a TrackFormat with before=[bold], after=[]. This should persist because + // it represents a real tracked removal. + const boldMark = schema.marks.bold.create(); + const run = schema.nodes.run.create({}, [schema.text('Hello', [boldMark])]); + const doc = schema.nodes.doc.create({}, schema.nodes.paragraph.create({}, run)); + let state = createState(doc); + + let helloPos = null; + let helloEnd = null; + state.doc.descendants((node, pos) => { + if (node.isText && node.text === 'Hello') { + helloPos = pos; + helloEnd = pos + node.nodeSize; + } + }); + expect(helloPos).toBeTypeOf('number'); + + state = state.apply(state.tr.setSelection(TextSelection.create(state.doc, helloPos, helloEnd))); + + // Remove bold (tracked) + let tr = state.tr.removeMark(helloPos, helloEnd, boldMark); + tr.setMeta('inputType', 'programmatic'); + const tracked = trackedTransaction({ tr, state, user }); + state = state.apply(tracked); + + // TrackFormat should persist with before=[bold] + const tfMarks = getTrackFormatMarks(state.doc); + expect(tfMarks.length).toBeGreaterThanOrEqual(1); + expect(tfMarks[0].before.some((s) => s.type === 'bold')).toBe(true); + }); +}); 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 0011e05edb..69d89128e2 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/removeMarkStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/removeMarkStep.js @@ -16,6 +16,7 @@ import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js'; */ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { const meta = {}; + let sharedWid = null; doc.nodesBetween(step.from, step.to, (node, pos) => { if (!node.isInline || node.type.name === 'run') { @@ -52,6 +53,10 @@ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { after = [ ...formatChangeMark.attrs.after.filter((mark) => !markSnapshotMatchesStepMark(mark, step.mark, true)), ]; + if (after.length === 0 && formatChangeMark.attrs.before.length === 0) { + newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), formatChangeMark); + return; + } before = [...formatChangeMark.attrs.before]; } else { after = [...formatChangeMark.attrs.after]; @@ -77,7 +82,7 @@ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => { if (after.length || before.length) { const newFormatMark = state.schema.marks[TrackFormatMarkName].create({ - id: uuidv4(), + id: formatChangeMark ? formatChangeMark.attrs.id : (sharedWid ?? (sharedWid = uuidv4())), author: user.name, authorEmail: user.email, authorImage: user.image,