Skip to content
Open
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
7 changes: 1 addition & 6 deletions packages/layout-engine/layout-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,12 +1015,7 @@ export function selectionToRects(
pushEmptyLineSelectionBand(rects, {
x: fragment.x + contentOffsetX + cellX + padding.left,
yBase:
fragment.y +
contentOffsetY +
rowOffset +
blockTopCursor +
effectiveSpacingBeforePx +
pageTopY,
fragment.y + contentOffsetY + rowOffset + blockTopCursor + effectiveSpacingBeforePx + pageTopY,
width: cellMeasure.width - padding.left - padding.right,
lineHeight: line.lineHeight,
pageIndex,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,15 @@ describe('clickToPosition', () => {
rows: [
{
cells: [
{ blocks: [emptyCellMeasure], paragraph: emptyCellMeasure, width: 320, height: 28, gridColumnStart: 0, colSpan: 1, rowSpan: 1 },
{
blocks: [emptyCellMeasure],
paragraph: emptyCellMeasure,
width: 320,
height: 28,
gridColumnStart: 0,
colSpan: 1,
rowSpan: 1,
},
],
height: 28,
},
Expand Down
110 changes: 96 additions & 14 deletions packages/layout-engine/layout-bridge/test/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,36 @@ export const tableLayout: Layout = {
// PM layout: p1 "Table text" [2,12), empty para inside pos 14, p3 "More text" [16,26).
// A selection from 2..26 passes through all three lines; the empty line is a
// zero-width slice (pmStart === pmEnd === 14) that the rect builder used to skip.
const tableEmptyParaLineP1 = { fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 80, ascent: 10, descent: 4, lineHeight: TABLE_CELL_LINE_HEIGHT } as const;
const tableEmptyParaLineEmpty = { fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 0, ascent: 10, descent: 4, lineHeight: TABLE_CELL_LINE_HEIGHT } as const;
const tableEmptyParaLineP3 = { fromRun: 0, fromChar: 0, toRun: 0, toChar: 9, width: 70, ascent: 10, descent: 4, lineHeight: TABLE_CELL_LINE_HEIGHT } as const;
const tableEmptyParaLineP1 = {
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 10,
width: 80,
ascent: 10,
descent: 4,
lineHeight: TABLE_CELL_LINE_HEIGHT,
} as const;
const tableEmptyParaLineEmpty = {
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 0,
width: 0,
ascent: 10,
descent: 4,
lineHeight: TABLE_CELL_LINE_HEIGHT,
} as const;
const tableEmptyParaLineP3 = {
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 9,
width: 70,
ascent: 10,
descent: 4,
lineHeight: TABLE_CELL_LINE_HEIGHT,
} as const;

export const tableEmptyParaBlock: FlowBlock = {
kind: 'table',
Expand All @@ -294,9 +321,21 @@ export const tableEmptyParaBlock: FlowBlock = {
id: 'cell-0',
attrs: { padding: { top: 2, bottom: 2, left: 4, right: 4 } },
blocks: [
{ kind: 'paragraph', id: 'p1', runs: [{ text: 'Table text', fontFamily: 'Arial', fontSize: 14, pmStart: 2, pmEnd: 12 }] },
{ kind: 'paragraph', id: 'p-empty', runs: [{ text: '', fontFamily: 'Arial', fontSize: 14, pmStart: 14, pmEnd: 14 }] },
{ kind: 'paragraph', id: 'p3', runs: [{ text: 'More text', fontFamily: 'Arial', fontSize: 14, pmStart: 16, pmEnd: 26 }] },
{
kind: 'paragraph',
id: 'p1',
runs: [{ text: 'Table text', fontFamily: 'Arial', fontSize: 14, pmStart: 2, pmEnd: 12 }],
},
{
kind: 'paragraph',
id: 'p-empty',
runs: [{ text: '', fontFamily: 'Arial', fontSize: 14, pmStart: 14, pmEnd: 14 }],
},
{
kind: 'paragraph',
id: 'p3',
runs: [{ text: 'More text', fontFamily: 'Arial', fontSize: 14, pmStart: 16, pmEnd: 26 }],
},
],
},
],
Expand Down Expand Up @@ -334,7 +373,16 @@ export const tableEmptyParaLayout: Layout = {
{
number: 1,
fragments: [
{ kind: 'table' as const, blockId: 'table-empty-para', fromRow: 0, toRow: 1, x: 30, y: 60, width: 120, height: TABLE_CELL_LINE_HEIGHT * 3 + 4 },
{
kind: 'table' as const,
blockId: 'table-empty-para',
fromRow: 0,
toRow: 1,
x: 30,
y: 60,
width: 120,
height: TABLE_CELL_LINE_HEIGHT * 3 + 4,
},
],
},
],
Expand All @@ -345,15 +393,39 @@ export const tableEmptyParaLayout: Layout = {
// A selection 1..25 passes through all three; the empty line is a zero-width slice
// that the body rect builder used to skip, leaving a gap in the highlight band.
export const bodyEmptyParaBlocks: FlowBlock[] = [
{ kind: 'paragraph', id: 'body-p1', runs: [{ text: 'First line', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 11 }] },
{ kind: 'paragraph', id: 'body-empty', runs: [{ text: '', fontFamily: 'Arial', fontSize: 16, pmStart: 13, pmEnd: 13 }] },
{ kind: 'paragraph', id: 'body-p3', runs: [{ text: 'Third line', fontFamily: 'Arial', fontSize: 16, pmStart: 15, pmEnd: 25 }] },
{
kind: 'paragraph',
id: 'body-p1',
runs: [{ text: 'First line', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 11 }],
},
{
kind: 'paragraph',
id: 'body-empty',
runs: [{ text: '', fontFamily: 'Arial', fontSize: 16, pmStart: 13, pmEnd: 13 }],
},
{
kind: 'paragraph',
id: 'body-p3',
runs: [{ text: 'Third line', fontFamily: 'Arial', fontSize: 16, pmStart: 15, pmEnd: 25 }],
},
];

export const bodyEmptyParaMeasures: Measure[] = [
{ kind: 'paragraph', lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 80, ascent: 12, descent: 4, lineHeight: 20 }], totalHeight: 20 },
{ kind: 'paragraph', lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 0, ascent: 12, descent: 4, lineHeight: 20 }], totalHeight: 20 },
{ kind: 'paragraph', lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 80, ascent: 12, descent: 4, lineHeight: 20 }], totalHeight: 20 },
{
kind: 'paragraph',
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 80, ascent: 12, descent: 4, lineHeight: 20 }],
totalHeight: 20,
},
{
kind: 'paragraph',
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 0, ascent: 12, descent: 4, lineHeight: 20 }],
totalHeight: 20,
},
{
kind: 'paragraph',
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 80, ascent: 12, descent: 4, lineHeight: 20 }],
totalHeight: 20,
},
];

export const bodyEmptyParaLayout: Layout = {
Expand All @@ -363,7 +435,17 @@ export const bodyEmptyParaLayout: Layout = {
number: 1,
fragments: [
{ kind: 'para', blockId: 'body-p1', fromLine: 0, toLine: 1, x: 30, y: 40, width: 300, pmStart: 1, pmEnd: 11 },
{ kind: 'para', blockId: 'body-empty', fromLine: 0, toLine: 1, x: 30, y: 60, width: 300, pmStart: 13, pmEnd: 13 },
{
kind: 'para',
blockId: 'body-empty',
fromLine: 0,
toLine: 1,
x: 30,
y: 60,
width: 300,
pmStart: 13,
pmEnd: 13,
},
{ kind: 'para', blockId: 'body-p3', fromLine: 0, toLine: 1, x: 30, y: 80, width: 300, pmStart: 15, pmEnd: 25 },
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,7 @@ type ResolvedCellContext = {
tablePos: number;
};

function resolveCellContextAtResolvedPos(
$pos: ReturnType<ProseMirrorNode['resolve']>,
): ResolvedCellContext | null {
function resolveCellContextAtResolvedPos($pos: ReturnType<ProseMirrorNode['resolve']>): ResolvedCellContext | null {
const $cell = cellAround($pos);
if (!$cell) return null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { beforeEach, afterEach, describe, expect, it } from 'vitest';
import { TextSelection } from 'prosemirror-state';
import { TrackInsertMarkName, TrackDeleteMarkName } from './constants.js';
import { initTestEditor } from '@tests/helpers/helpers.js';
import { handleBackspace, handleDelete } from '@core/extensions/keymap.js';
import { buildReviewGraph, CanonicalChangeType } from './review-model/review-graph.js';

const ALICE = { name: 'Alice', email: 'alice@example.com' };
Expand Down Expand Up @@ -215,3 +216,149 @@ describe('overlap wired: insertTrackedChange delegates to compiler', () => {
expect(ok).toBe(false);
});
});

describe('overlap wired: deletion spanning a paragraph boundary (SD-3386)', () => {
let editor;
afterEach(() => {
editor?.destroy();
editor = null;
});

const TWO_PARAGRAPHS = '<p>First line of text</p><p>Second line of text</p>';

// Selection from the start of paragraph 1 (pos 1) through the middle of
// paragraph 2 ("Second|" → 21 + 6 = 27). deleteSelection routes through
// deleteRange, which emits a ReplaceStep whose slice is a structural
// paragraph re-join shell with no inline content.
const deleteAcrossBoundary = () => {
editor.commands.command(({ tr, dispatch }) => {
tr.setSelection(TextSelection.create(tr.doc, 1, 27));
tr.deleteSelection();
if (dispatch) dispatch(tr);
return true;
});
};

const paragraphTexts = (doc) => {
const texts = [];
doc.forEach((node) => texts.push(node.textContent));
return texts;
};

it('marks the range deleted without splitting the trailing paragraph', () => {
editor = setup(ALICE, TWO_PARAGRAPHS);
deleteAcrossBoundary();

// No spurious paragraph split: still exactly two paragraphs with the
// original text (the deletion is only marked, not applied).
expect(editor.state.doc.childCount).toBe(2);
expect(paragraphTexts(editor.state.doc)).toEqual(['First line of text', 'Second line of text']);

// One logical deletion — not a replacement wrapping an empty block shell.
const graph = graphFor(editor);
expect(graph.changes.size).toBe(1);
const change = Array.from(graph.changes.values())[0];
expect(change.type).toBe(CanonicalChangeType.Deletion);
});

it('accept removes the marked content and all tracked marks', () => {
editor = setup(ALICE, TWO_PARAGRAPHS);
deleteAcrossBoundary();

const ok = editor.commands.acceptTrackedChangesBetween(0, editor.state.doc.content.size);
expect(ok).toBe(true);
expect(editor.storage.trackChanges?.lastDecisionFailure ?? null).toBeNull();

// Deleted content is gone, remainder of paragraph 2 survives.
expect(paragraphTexts(editor.state.doc)).toEqual(['', 'ond line of text']);

// No tracked-change state (marks/decorations) survives the decision.
const graph = graphFor(editor);
expect(graph.changes.size).toBe(0);
});

it('reject restores the content and removes all tracked marks', () => {
editor = setup(ALICE, TWO_PARAGRAPHS);
deleteAcrossBoundary();

const graph = graphFor(editor);
const changeId = Array.from(graph.changes.keys())[0];
const ok = editor.commands.rejectTrackedChangeById(changeId);
expect(ok).toBe(true);
expect(editor.storage.trackChanges?.lastDecisionFailure ?? null).toBeNull();

// Original two-paragraph content is intact and unmarked.
expect(editor.state.doc.childCount).toBe(2);
expect(paragraphTexts(editor.state.doc)).toEqual(['First line of text', 'Second line of text']);
expect(graphFor(editor).changes.size).toBe(0);

let hasTrackedMarks = false;
editor.state.doc.descendants((node) => {
if (node.marks.some((m) => m.type.name === TrackInsertMarkName || m.type.name === TrackDeleteMarkName)) {
hasTrackedMarks = true;
}
});
expect(hasTrackedMarks).toBe(false);
});
});

describe('overlap wired: cross-paragraph deletion through the real keymap path (SD-3386)', () => {
let editor;
afterEach(() => {
editor?.destroy();
editor = null;
});

// The keymap path differs from a raw command dispatch: handleBackspace tags
// the transaction with inputType 'deleteContentBackward' and deleteSelection
// sets the post-step selection, which can land inside the structural shell
// slice that the tracked compile never inserts. Mapping that position back
// through a falsely-mirrored invert map produced `Position NaN` and the
// dispatch fallback then applied the deletion untracked.
const setupTwoLines = () => {
({ editor } = initTestEditor({
mode: 'text',
content: '<p>Line 1</p><p>Line 2</p>',
user: ALICE,
trackedChanges: {},
}));
editor.commands.enableTrackChanges();

let p2TextStart = null;
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text === 'Line 2') p2TextStart = pos;
});
// User selection from the start of "Line 1" through "Lin" of line 2,
// dispatched on its own like a mouse selection.
editor.commands.command(({ tr, dispatch }) => {
tr.setSelection(TextSelection.create(tr.doc, 1, p2TextStart + 3));
if (dispatch) dispatch(tr);
return true;
});
};

const paragraphTexts = (doc) => {
const texts = [];
doc.forEach((node) => texts.push(node.textContent));
return texts;
};

it.each([
['Backspace', handleBackspace],
['Delete', handleDelete],
])('%s tracks the deletion instead of hard-deleting', (_label, handler) => {
setupTwoLines();
const handled = handler(editor);
expect(handled).toBe(true);

// Content survives as a tracked deletion — never hard-deleted.
expect(paragraphTexts(editor.state.doc)).toEqual(['Line 1', 'Line 2']);
const graph = graphFor(editor);
expect(graph.changes.size).toBe(1);
expect(Array.from(graph.changes.values())[0].type).toBe(CanonicalChangeType.Deletion);

// Caret lands at the left edge of the tracked deletion, inside paragraph 1.
expect(editor.state.selection.empty).toBe(true);
expect(editor.state.selection.from).toBeLessThan(editor.state.doc.firstChild.nodeSize);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ const dispatchReviewDecision = ({ editor, state, dispatch, decision, target }) =
originalId: event.changeId,
}).some(({ mark }) => mark.attrs?.splitFromId === event.changeId);
if (successorsPresent) continue;
editor.emit('commentsUpdate', event);
// Carry the decision so resolved bubbles can report accepted vs
// rejected instead of assuming every resolution was an accept.
editor.emit('commentsUpdate', { ...event, decision });
}

const touched =
Expand Down
Loading
Loading