Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,72 @@ describe('TrackChanges extension commands', () => {
expect(markPresent(restoredState.doc, TrackDeleteMarkName)).toBe(false);
});

it('rejectTrackedChangesBetween emits tracked-change resolve events for rejected IDs', () => {
const insertMark = schema.marks[TrackInsertMarkName].create({ id: 'ins-resolve-1' });
const doc = createDoc('Pending', [insertMark]);
const state = createState(doc);
const emit = vi.fn();

commands.rejectTrackedChangesBetween(
1,
doc.content.size,
)({
state,
dispatch: (tr) => state.apply(tr),
editor: {
emit,
options: { user: { email: 'reviewer@example.com', name: 'Reviewer' } },
},
});

expect(emit).toHaveBeenCalledWith(
'commentsUpdate',
expect.objectContaining({
type: 'trackedChange',
event: 'resolve',
changeId: 'ins-resolve-1',
resolvedByEmail: 'reviewer@example.com',
resolvedByName: 'Reviewer',
}),
);
});

it('rejectTrackedChangesBetween expands partial selection to reject the full tracked-change ID', () => {
const changeId = 'ins-partial-still-present';
const insertMark = schema.marks[TrackInsertMarkName].create({ id: changeId });
const doc = createDoc('Pending', [insertMark]);
const state = createState(doc);
const emit = vi.fn();

let nextState;
commands.rejectTrackedChangesBetween(
1,
3,
)({
state,
dispatch: (tr) => {
nextState = state.apply(tr);
},
editor: {
emit,
options: { user: { email: 'reviewer@example.com', name: 'Reviewer' } },
},
});

expect(nextState).toBeDefined();
expect(nextState.doc.textContent).toBe('');

const resolveEventsForId = emit.mock.calls.filter(([eventName, payload]) => {
return (
eventName === 'commentsUpdate' &&
payload?.type === 'trackedChange' &&
payload?.event === 'resolve' &&
payload?.changeId === changeId
);
});
expect(resolveEventsForId).toHaveLength(1);
});

it('blocks rejecting tracked changes when permissionResolver denies access', () => {
const deleteMark = schema.marks[TrackDeleteMarkName].create({ id: 'del-guard', authorEmail: 'author@example.com' });
const doc = createDoc('Legacy', [deleteMark]);
Expand Down Expand Up @@ -211,6 +277,59 @@ describe('TrackChanges extension commands', () => {
expect(markPresent(afterReject.doc, 'italic')).toBe(false);
});

it('acceptTrackedChangesBetween bulk-accepts all format/style changes in range', () => {
const bold = schema.marks.bold.create();
const italic = schema.marks.italic.create();
const fmt1 = schema.marks[TrackFormatMarkName].create({
id: 'fmt-bulk-1',
before: [],
after: [{ type: 'bold', attrs: {} }],
});
const fmt2 = schema.marks[TrackFormatMarkName].create({
id: 'fmt-bulk-2',
before: [{ type: 'bold', attrs: {} }],
after: [{ type: 'italic', attrs: {} }],
});
const paragraph = schema.nodes.paragraph.create(null, [
schema.text('One', [bold, fmt1]),
schema.text(' two ', []),
schema.text('three', [italic, fmt2]),
]);
const doc = schema.nodes.doc.create(null, paragraph);
const state = createState(doc);

let afterAccept;
commands.acceptTrackedChangesBetween(
0,
doc.content.size,
)({
state,
dispatch: (tr) => {
afterAccept = state.apply(tr);
},
});

expect(afterAccept).toBeDefined();
expect(afterAccept.doc.textContent).toBe('One two three');

let formatMarkCount = 0;
afterAccept.doc.descendants((node) => {
if (node.marks.some((m) => m.type.name === TrackFormatMarkName)) formatMarkCount += 1;
});
expect(formatMarkCount).toBe(0);

const firstRange = getFirstTextRange(afterAccept.doc);
const firstMarks = afterAccept.doc.nodeAt(firstRange.from)?.marks ?? [];
expect(firstMarks.some((m) => m.type.name === 'bold')).toBe(true);

afterAccept.doc.descendants((node, pos) => {
if (!node.isText || node.textContent !== 'three') return;
const marks = afterAccept.doc.nodeAt(pos)?.marks ?? [];
expect(marks.some((m) => m.type.name === 'italic')).toBe(true);
return false;
});
});

it('rejectTrackedChangesBetween restores imported textStyle attrs for color suggestions', () => {
const oldTextStyle = schema.marks.textStyle.create({
styleId: 'Emphasis',
Expand Down Expand Up @@ -461,6 +580,54 @@ describe('TrackChanges extension commands', () => {
}
});

it('interaction: rejectTrackedChangeOnSelection reverts mixed marks + textStyle in suggesting mode', () => {
const { editor: interactionEditor } = initTestEditor({
mode: 'text',
content: '<p>Agreement signed by both parties</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.setColor('#112233');
interactionEditor.setDocumentMode('suggesting');

const selectionRange = getFirstTextRange(interactionEditor.state.doc);
interactionEditor.view.dispatch(
interactionEditor.state.tr.setSelection(
TextSelection.create(interactionEditor.state.doc, selectionRange.from, selectionRange.to),
),
);
interactionEditor.commands.toggleBold();
interactionEditor.commands.toggleUnderline();
interactionEditor.commands.setColor('#FF00AA');
interactionEditor.commands.setFontFamily('Arial, sans-serif');

interactionEditor.commands.rejectTrackedChangeOnSelection();

const textPos = getFirstTextRange(interactionEditor.state.doc);
const textNode = interactionEditor.state.doc.nodeAt(textPos.from);
const marks = textNode?.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).toBe('#112233');
expect(textStyle?.attrs?.fontFamily).toBe('Times New Roman, serif');
} 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
164 changes: 113 additions & 51 deletions packages/super-editor/src/extensions/track-changes/track-changes.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,78 +76,108 @@ export const TrackChanges = Extension.create({
rejectTrackedChangesBetween:
(from, to) =>
({ state, dispatch, editor }) => {
const trackedChanges = collectTrackedChanges({ state, from, to });
const trackedChangesInSelection = collectTrackedChanges({ state, from, to });
const trackedChangesById = getTrackedChangesByTouchedIds(state, trackedChangesInSelection);
const trackedChangesWithoutId = trackedChangesInSelection.filter(({ mark }) => !mark?.attrs?.id);
const trackedChanges = dedupeTrackedChangeRanges([...trackedChangesById, ...trackedChangesWithoutId]);
if (!isTrackedChangeActionAllowed({ editor, action: 'reject', trackedChanges })) return false;

const { tr, doc } = state;
// Keep the IDs rejected in this transaction so the comments layer can
// resolve/hide the corresponding tracked-change bubbles in one path.
const rejectedChangeIds = new Set();

// tr.setMeta('acceptReject', true);
tr.setMeta('inputType', 'acceptReject');

const map = new Mapping();

doc.nodesBetween(from, to, (node, pos) => {
if (node.marks && node.marks.find((mark) => mark.type.name === TrackDeleteMarkName)) {
const deletionMark = node.marks.find((mark) => mark.type.name === TrackDeleteMarkName);
trackedChanges.forEach(({ from: rangeFrom, to: rangeTo }) => {
doc.nodesBetween(rangeFrom, rangeTo, (node, pos) => {
if (node.marks && node.marks.find((mark) => mark.type.name === TrackDeleteMarkName)) {
const deletionMark = node.marks.find((mark) => mark.type.name === TrackDeleteMarkName);
if (deletionMark?.attrs?.id) rejectedChangeIds.add(deletionMark.attrs.id);

tr.step(
new RemoveMarkStep(
map.map(Math.max(pos, from)),
map.map(Math.min(pos + node.nodeSize, to)),
deletionMark,
),
);
} else if (node.marks && node.marks.find((mark) => mark.type.name === TrackInsertMarkName)) {
const deletionStep = new ReplaceStep(
map.map(Math.max(pos, from)),
map.map(Math.min(pos + node.nodeSize, to)),
Slice.empty,
);

tr.step(deletionStep);
map.appendMap(deletionStep.getMap());
} else if (node.marks && node.marks.find((mark) => mark.type.name === TrackFormatMarkName)) {
const formatChangeMark = node.marks.find((mark) => mark.type.name === TrackFormatMarkName);

formatChangeMark.attrs.before.forEach((oldMark) => {
tr.step(
new AddMarkStep(
map.map(Math.max(pos, from)),
map.map(Math.min(pos + node.nodeSize, to)),
state.schema.marks[oldMark.type].create(oldMark.attrs),
new RemoveMarkStep(
map.map(Math.max(pos, rangeFrom)),
map.map(Math.min(pos + node.nodeSize, rangeTo)),
deletionMark,
),
);
});
} else if (node.marks && node.marks.find((mark) => mark.type.name === TrackInsertMarkName)) {
const insertionMark = node.marks.find((mark) => mark.type.name === TrackInsertMarkName);
if (insertionMark?.attrs?.id) rejectedChangeIds.add(insertionMark.attrs.id);

const deletionStep = new ReplaceStep(
map.map(Math.max(pos, rangeFrom)),
map.map(Math.min(pos + node.nodeSize, rangeTo)),
Slice.empty,
);

formatChangeMark.attrs.after.forEach((newMark) => {
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,
tr.step(deletionStep);
map.appendMap(deletionStep.getMap());
} else if (node.marks && node.marks.find((mark) => mark.type.name === TrackFormatMarkName)) {
const formatChangeMark = node.marks.find((mark) => mark.type.name === TrackFormatMarkName);
if (formatChangeMark?.attrs?.id) rejectedChangeIds.add(formatChangeMark.attrs.id);

formatChangeMark.attrs.before.forEach((oldMark) => {
tr.step(
new AddMarkStep(
map.map(Math.max(pos, rangeFrom)),
map.map(Math.min(pos + node.nodeSize, rangeTo)),
state.schema.marks[oldMark.type].create(oldMark.attrs),
),
);
});

if (!liveMark) {
return;
}

tr.step(new RemoveMarkStep(mappedFrom, mappedTo, liveMark));
});
formatChangeMark.attrs.after.forEach((newMark) => {
const mappedFrom = map.map(Math.max(pos, rangeFrom));
const mappedTo = map.map(Math.min(pos + node.nodeSize, rangeTo));
const liveMark = findMarkInRangeBySnapshot({
doc: tr.doc,
from: mappedFrom,
to: mappedTo,
snapshot: newMark,
});

if (!liveMark) {
return;
}

tr.step(new RemoveMarkStep(mappedFrom, mappedTo, liveMark));
});

tr.step(
new RemoveMarkStep(
map.map(Math.max(pos, from)),
map.map(Math.min(pos + node.nodeSize, to)),
formatChangeMark,
),
);
}
tr.step(
new RemoveMarkStep(
map.map(Math.max(pos, rangeFrom)),
map.map(Math.min(pos + node.nodeSize, rangeTo)),
formatChangeMark,
),
);
}
});
});

if (tr.steps.length) {
dispatch(tr);

if (editor?.emit && rejectedChangeIds.size) {
const resolvedByEmail = editor.options?.user?.email;
const resolvedByName = editor.options?.user?.name;

// Bubble reject-by-selection/reject-all through the same
// tracked-change comment resolution channel used by bubble actions.
rejectedChangeIds.forEach((changeId) => {
editor.emit('commentsUpdate', {
type: 'trackedChange',
event: 'resolve',
changeId,
resolvedByEmail,
resolvedByName,
});
});
}
}

return true;
Expand Down Expand Up @@ -488,6 +518,38 @@ export const TrackChanges = Extension.create({
// }
// };

const dedupeTrackedChangeRanges = (changes = []) => {
const byKey = new Map();
changes.forEach((change) => {
if (!change || typeof change.from !== 'number' || typeof change.to !== 'number') {
return;
}

const type = change.mark?.type?.name || '';
const id = change.mark?.attrs?.id || '';
const key = `${change.from}:${change.to}:${type}:${id}`;
if (!byKey.has(key)) {
byKey.set(key, change);
}
});

return Array.from(byKey.values()).sort((left, right) => {
if (left.from !== right.from) {
return left.from - right.from;
}
return left.to - right.to;
});
};

const getTrackedChangesByTouchedIds = (state, trackedChanges = []) => {
const touchedIds = new Set(trackedChanges.map(({ mark }) => mark?.attrs?.id).filter(Boolean));
if (!touchedIds.size) {
return trackedChanges;
}

return Array.from(touchedIds).flatMap((id) => getChangesByIdToResolve(state, id) || []);
};

const getChangesByIdToResolve = (state, id) => {
const trackedChanges = getTrackChanges(state);
const changeIndex = trackedChanges.findIndex(({ mark }) => mark.attrs.id === id);
Expand Down
Loading
Loading