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 @@ -51,38 +51,54 @@ export const markDeletion = ({ tr, from, to, user, date, id: providedId }) => {
});

const deletionMap = new Mapping();
const shouldReassignExistingDeletions = Boolean(providedId);

// Add deletion mark to block nodes (figures, text blocks) and find already deleted inline nodes (and leave them alone).
// Add deletion mark to inline nodes in range.
// Behavior when replacing over existing tracked changes:
// - Own insertions are removed (collapsed).
// - Existing deletions are reassigned to the new deletion mark ID.
// - Non-deleted inline nodes are marked as deleted.
let nodes = [];
tr.doc.nodesBetween(from, to, (node, pos) => {
if (node.type.name.includes('table')) {
return;
}

// Skip inline containers (e.g. run), operate on leaf inline nodes only.
if (!node.isInline || !node.isLeaf) {
return;
}

const mappedFrom = deletionMap.map(Math.max(from, pos));
const mappedTo = deletionMap.map(Math.min(to, pos + node.nodeSize));
if (mappedFrom >= mappedTo) {
return;
}

const insertMark = node.marks.find((mark) => mark.type.name === TrackInsertMarkName);
if (node.isInline && insertMark && isOwnInsertion(insertMark)) {
const removeStep = new ReplaceStep(
deletionMap.map(Math.max(from, pos)),
deletionMap.map(Math.min(to, pos + node.nodeSize)),
Slice.empty,
);
const existingDeleteMarks = node.marks.filter((mark) => mark.type.name === TrackDeleteMarkName);

if (insertMark && isOwnInsertion(insertMark)) {
const removeStep = new ReplaceStep(mappedFrom, mappedTo, Slice.empty);
if (!tr.maybeStep(removeStep).failed) {
deletionMap.appendMap(removeStep.getMap());
}
} else if (node.isInline && !node.marks.find((mark) => mark.type.name === TrackDeleteMarkName)) {
nodes.push(node);
tr.addMark(
deletionMap.map(Math.max(from, pos)),
deletionMap.map(Math.min(to, pos + node.nodeSize)),
deletionMark,
);
} else if (
node.attrs.track &&
!node.attrs.track.find((trackAttr) => trackAttr.type === TrackDeleteMarkName) &&
!['bulletList', 'orderedList'].includes(node.type.name)
) {
// Skip for now.
return;
}

if (existingDeleteMarks.length > 0) {
if (shouldReassignExistingDeletions) {
nodes.push(node);
existingDeleteMarks.forEach((existingDeleteMark) => {
tr.removeMark(mappedFrom, mappedTo, existingDeleteMark);
});
tr.addMark(mappedFrom, mappedTo, deletionMark);
}
return;
}

nodes.push(node);
tr.addMark(mappedFrom, mappedTo, deletionMark);
});

return { deletionMark, deletionMap, nodes };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,13 @@ export const replaceStep = ({ state, tr, step, newTr, map, user, date, originalS
// NOTE: Only adjust position for single-step transactions. Multi-step transactions (like input rules)
// have subsequent steps that depend on original positions, and adjusting breaks their mapping.
let positionTo = step.to;
let positionAdjusted = false;
const isSingleStep = tr.steps.length === 1;

if (isSingleStep) {
const probePos = Math.max(step.from, step.to - 1);
const deletionSpan = findMarkPosition(trTemp.doc, probePos, TrackDeleteMarkName);
if (deletionSpan && deletionSpan.to > positionTo) {
positionTo = deletionSpan.to;
positionAdjusted = true;
}
}

Expand Down Expand Up @@ -138,11 +136,12 @@ export const replaceStep = ({ state, tr, step, newTr, map, user, date, originalS
if (insertedFrom !== insertedTo) {
meta.insertedMark = insertedMark;
meta.step = condensedStep;
// Store the actual insertion end position for cursor placement (SD-1624).
// Only needed when position was adjusted to insert after a deletion span.
// For single-step transactions, positionTo is in newTr.doc coordinates after our condensedStep,
// so we just add the insertion length to get the cursor position.
if (positionAdjusted) {
// Store insertion end position when (1) we adjusted the insertion position (e.g. past a
// deletion span), or (2) single-step replace of a range — selection mapping is wrong then
// so we need an explicit caret position. Skip for multi-step (e.g. input rules) so their
// intended selection is preserved.
const needInsertedTo = positionTo !== step.to || (isSingleStep && step.from !== step.to);
if (needInsertedTo) {
const insertionLength = insertedTo - insertedFrom;
meta.insertedTo = positionTo + insertionLength;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { trackedTransaction, documentHelpers } from './index.js';
import { TrackInsertMarkName, TrackDeleteMarkName } from '../constants.js';
import { TrackChangesBasePluginKey } from '../plugins/trackChangesBasePlugin.js';
import { initTestEditor } from '@tests/helpers/helpers.js';
import { findTextPos } from './testUtils.js';

describe('trackChangesHelpers replaceStep', () => {
let editor;
Expand Down Expand Up @@ -32,15 +33,25 @@ describe('trackChangesHelpers replaceStep', () => {
plugins: basePlugins,
});

const findTextPos = (docNode, exactText) => {
let found = null;
docNode.descendants((node, pos) => {
if (found) return false;
const getParagraphRange = (docNode, index) => {
let range = null;
docNode.forEach((node, offset, childIndex) => {
if (childIndex !== index) return;
range = { from: offset + 1, to: offset + node.nodeSize - 1 };
});
return range;
};

const getTrackedTextById = (docNode, id, markName) => {
let text = '';
docNode.descendants((node) => {
if (!node.isText) return;
if (node.text !== exactText) return;
found = pos;
const hasMark = node.marks.some((mark) => mark.type.name === markName && mark.attrs.id === id);
if (hasMark) {
text += node.text;
}
});
return found;
return text;
};

it('types characters in correct order after fully deleting content (SD-1624)', () => {
Expand Down Expand Up @@ -493,4 +504,129 @@ describe('trackChangesHelpers replaceStep', () => {
true,
);
});

it('supersedes tracked changes across multiple paragraphs with one replacement ID', () => {
const line1 = 'Line one base';
const line2 = 'Line two base';
const tail = 'Tail line';

const doc = schema.nodes.doc.create({}, [
schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text(line1)])),
schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text(line2)])),
schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text(tail)])),
]);
let state = createState(doc);

const applyTrackedReplace = ({ from, to, text }) => {
let tr = state.tr.replaceWith(from, to, schema.text(text));
tr.setSelection(TextSelection.create(tr.doc, from + text.length));
tr.setMeta('inputType', 'insertText');
const tracked = trackedTransaction({ tr, state, user });
state = state.apply(tracked);
};

const line1Pos = findTextPos(state.doc, line1);
expect(line1Pos).toBeTypeOf('number');
applyTrackedReplace({ from: line1Pos, to: line1Pos + line1.length, text: 'Line one change' });

const line2Pos = findTextPos(state.doc, line2);
expect(line2Pos).toBeTypeOf('number');
applyTrackedReplace({ from: line2Pos, to: line2Pos + line2.length, text: 'Line two change' });

const para1 = getParagraphRange(state.doc, 0);
const para2 = getParagraphRange(state.doc, 1);
expect(para1).toBeTruthy();
expect(para2).toBeTruthy();

state = state.apply(state.tr.setSelection(TextSelection.create(state.doc, para1.from, para2.to)));
let tr = state.tr.replaceWith(para1.from, para2.to, schema.text('Merged suggestion'));
tr.setSelection(TextSelection.create(tr.doc, tr.selection.from));
tr.setMeta('inputType', 'insertText');

const tracked = trackedTransaction({ tr, state, user });
const meta = tracked.getMeta(TrackChangesBasePluginKey);
const finalState = state.apply(tracked);

expect(meta?.insertedMark).toBeDefined();
expect(meta?.deletionMark).toBeDefined();
expect(meta.insertedMark.attrs.id).toBe(meta.deletionMark.attrs.id);

const replacementId = meta.insertedMark.attrs.id;
const insertedText = getTrackedTextById(finalState.doc, replacementId, TrackInsertMarkName);
const deletedText = getTrackedTextById(finalState.doc, replacementId, TrackDeleteMarkName);
expect(insertedText).toContain('Merged suggestion');
expect(deletedText).toContain(line1);
expect(deletedText).toContain(line2);
expect(deletedText).not.toContain('Line one change');
expect(deletedText).not.toContain('Line two change');

const insertIds = new Set();
finalState.doc.descendants((node) => {
if (!node.isText) return;
node.marks.forEach((mark) => {
if (mark.type.name === TrackInsertMarkName) {
insertIds.add(mark.attrs.id);
}
});
});
expect(insertIds.size).toBe(1);
});

it('keeps caret stable after superseding multi-paragraph tracked changes', () => {
const line1 = 'Alpha base';
const line2 = 'Beta base';
const tail = 'Tail text';

const doc = schema.nodes.doc.create({}, [
schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text(line1)])),
schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text(line2)])),
schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text(tail)])),
]);
let state = createState(doc);

const applyTrackedReplace = ({ from, to, text }) => {
let tr = state.tr.replaceWith(from, to, schema.text(text));
tr.setSelection(TextSelection.create(tr.doc, from + text.length));
tr.setMeta('inputType', 'insertText');
const tracked = trackedTransaction({ tr, state, user });
state = state.apply(tracked);
};

const line1Pos = findTextPos(state.doc, line1);
applyTrackedReplace({ from: line1Pos, to: line1Pos + line1.length, text: 'Alpha change' });

const line2Pos = findTextPos(state.doc, line2);
applyTrackedReplace({ from: line2Pos, to: line2Pos + line2.length, text: 'Beta change' });

const para1 = getParagraphRange(state.doc, 0);
const para2 = getParagraphRange(state.doc, 1);
state = state.apply(state.tr.setSelection(TextSelection.create(state.doc, para1.from, para2.to)));
let tr = state.tr.replaceWith(para1.from, para2.to, schema.text('Merged'));
tr.setSelection(TextSelection.create(tr.doc, tr.selection.from));
tr.setMeta('inputType', 'insertText');
state = state.apply(trackedTransaction({ tr, state, user }));

['X', 'Y', 'Z'].forEach((char) => {
const prevSelection = state.selection.from;
let typingTr = state.tr.replaceWith(state.selection.from, state.selection.from, schema.text(char));
typingTr.setSelection(TextSelection.create(typingTr.doc, typingTr.selection.from));
typingTr.setMeta('inputType', 'insertText');
state = state.apply(trackedTransaction({ tr: typingTr, state, user }));

expect(state.selection.from).toBe(prevSelection + 1);
const tailPos = findTextPos(state.doc, tail);
expect(tailPos).toBeTypeOf('number');
expect(state.selection.from).toBeLessThanOrEqual(tailPos);
});

const insertedText = [];
state.doc.descendants((node) => {
if (!node.isText) return;
if (node.marks.some((mark) => mark.type.name === TrackInsertMarkName)) {
insertedText.push(node.text);
}
});

expect(insertedText.join('')).toContain('MergedXYZ');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Shared test utilities for trackChangesHelpers tests.
*/

/**
* Find the document position of a text node whose text equals exactText.
* @param {import('prosemirror-model').Node} docNode - Document or node to search
* @param {string} exactText - Exact text to find
* @returns {number | null} Start position of the text node, or null if not found
*/
export function findTextPos(docNode, exactText) {
let found = null;
docNode.descendants((node, pos) => {
if (found) return false;
if (!node.isText || node.text !== exactText) return;
found = pos;
});
return found;
}
Loading
Loading