Skip to content
Closed
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 @@ -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') {
Expand All @@ -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()));
Comment thread
gpardhivvarma marked this conversation as resolved.
newTr.addMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), step.mark);

const allowedMarks = ['bold', 'italic', 'strike', 'underline', 'textStyle', 'highlight'];
Expand Down Expand Up @@ -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);
Comment thread
gpardhivvarma marked this conversation as resolved.

meta.formatMark = newFormatMark;
meta.step = step;
Expand Down
Original file line number Diff line number Diff line change
@@ -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: '<p></p>' }));
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);
Comment thread
gpardhivvarma marked this conversation as resolved.
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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];
Expand All @@ -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,
Expand Down
Loading