Skip to content
Original file line number Diff line number Diff line change
@@ -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<void> {
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.');
});
},
});
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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: '<p></p>' }));
Expand Down Expand Up @@ -203,6 +211,136 @@ 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('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',
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' });
Expand Down Expand Up @@ -238,6 +376,88 @@ 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: '<p>Plain text</p>',
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('interaction: rejecting multi-format suggestions reverts all tracked formatting', () => {
const { editor: interactionEditor } = initTestEditor({
mode: 'text',
content: '<p>Plain text</p>',
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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading