Skip to content

Commit e06e98b

Browse files
authored
test(selection): behavior tests for drag selection across marks and tables (SD-2057) (#2318)
* test(selection): add behavior tests for drag selection across marks and tables (SD-2057) Add 6 Playwright behavior tests covering the selection fixes from PR #2205: - Selection overlay remains visible when selecting across mark boundaries (bold → italic, different formatting runs) - Drag selection maintains continuous overlay without flicker mid-drag - Drag from paragraph into table clamps at the table boundary - Wide selection spanning past a table is allowed with visible overlay All tests pass on Chromium, Firefox, and WebKit. * chore: fix behavior tests
1 parent 1e0e017 commit e06e98b

1 file changed

Lines changed: 287 additions & 0 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js';
2+
3+
test.use({ config: { toolbar: 'full', showSelection: true } });
4+
5+
/**
6+
* Helper: type text with mixed formatting so adjacent runs have different marks.
7+
* Produces "NormalBoldItalic" where each word has distinct formatting.
8+
*/
9+
async function setupMixedFormattingText(superdoc: SuperDocFixture) {
10+
await superdoc.type('Normal');
11+
await superdoc.waitForStable();
12+
13+
await superdoc.bold();
14+
await superdoc.type('Bold');
15+
await superdoc.bold(); // toggle off
16+
await superdoc.waitForStable();
17+
18+
await superdoc.italic();
19+
await superdoc.type('Italic');
20+
await superdoc.italic(); // toggle off
21+
await superdoc.waitForStable();
22+
}
23+
24+
/**
25+
* Helper: count visible selection overlay rects.
26+
*/
27+
async function getSelectionOverlayRectCount(superdoc: SuperDocFixture): Promise<number> {
28+
return superdoc.page.evaluate(() => {
29+
const overlay = document.querySelector('.presentation-editor__selection-layer--local');
30+
if (!overlay) return 0;
31+
// Count children with non-zero dimensions (actual selection rects)
32+
let count = 0;
33+
for (const child of overlay.children) {
34+
const rect = child.getBoundingClientRect();
35+
if (rect.width > 0 && rect.height > 0) count++;
36+
}
37+
return count;
38+
});
39+
}
40+
41+
// ---------------------------------------------------------------------------
42+
// Selection across mark boundaries
43+
// ---------------------------------------------------------------------------
44+
45+
test.describe('selection across mark boundaries (SD-2024)', () => {
46+
test('selecting text that spans bold and italic runs shows a continuous highlight', async ({ superdoc }) => {
47+
await setupMixedFormattingText(superdoc);
48+
49+
// Select from "Normal" through "Bold" to "Italic" — crosses two mark boundaries
50+
const startPos = await superdoc.findTextPos('Normal');
51+
const endText = 'Italic';
52+
const endPos = await superdoc.findTextPos(endText);
53+
await superdoc.setTextSelection(startPos, endPos + endText.length);
54+
await superdoc.waitForStable();
55+
56+
// The selection overlay must have visible rects covering the selected text
57+
const rectCount = await getSelectionOverlayRectCount(superdoc);
58+
expect(rectCount).toBeGreaterThan(0);
59+
60+
// PM selection should span the full range
61+
const sel = await superdoc.getSelection();
62+
expect(sel.to - sel.from).toBeGreaterThan(0);
63+
});
64+
65+
test('selecting exactly at a mark boundary produces a visible highlight', async ({ superdoc }) => {
66+
await setupMixedFormattingText(superdoc);
67+
68+
// Select exactly across the Bold→Italic mark boundary.
69+
// boldPos = start of the bold run (Normal→Bold boundary),
70+
// italicPos = start of the italic run (Bold→Italic boundary).
71+
// Both endpoints land on a mark boundary so this exercises the SD-2024 edge case.
72+
const boldPos = await superdoc.findTextPos('Bold');
73+
const italicPos = await superdoc.findTextPos('Italic');
74+
await superdoc.setTextSelection(boldPos, italicPos);
75+
await superdoc.waitForStable();
76+
77+
const rectCount = await getSelectionOverlayRectCount(superdoc);
78+
expect(rectCount).toBeGreaterThan(0);
79+
80+
const sel = await superdoc.getSelection();
81+
expect(sel.to - sel.from).toBeGreaterThan(0);
82+
});
83+
84+
test('drag-selecting across bold and normal text maintains selection overlay', async ({ superdoc }) => {
85+
await setupMixedFormattingText(superdoc);
86+
await superdoc.waitForStable();
87+
88+
// Find the line element to compute drag coordinates
89+
const line = superdoc.page.locator('.superdoc-line').first();
90+
const box = await line.boundingBox();
91+
if (!box) throw new Error('Line not visible');
92+
93+
// Drag from left side (Normal text) to right side (Italic text)
94+
const startX = box.x + 10;
95+
const endX = box.x + box.width - 10;
96+
const y = box.y + box.height / 2;
97+
98+
await superdoc.page.mouse.move(startX, y);
99+
await superdoc.page.mouse.down();
100+
// Move in steps to simulate a real drag
101+
const steps = 5;
102+
for (let i = 1; i <= steps; i++) {
103+
const x = startX + ((endX - startX) * i) / steps;
104+
await superdoc.page.mouse.move(x, y);
105+
}
106+
await superdoc.page.mouse.up();
107+
await superdoc.waitForStable();
108+
109+
// After drag, we should have a non-collapsed selection with visible overlay
110+
const sel = await superdoc.getSelection();
111+
expect(sel.to - sel.from).toBeGreaterThan(0);
112+
113+
const rectCount = await getSelectionOverlayRectCount(superdoc);
114+
expect(rectCount).toBeGreaterThan(0);
115+
});
116+
117+
test('drag across marks never drops selection overlay mid-drag', async ({ superdoc }) => {
118+
await setupMixedFormattingText(superdoc);
119+
await superdoc.waitForStable();
120+
121+
const line = superdoc.page.locator('.superdoc-line').first();
122+
const box = await line.boundingBox();
123+
if (!box) throw new Error('Line not visible');
124+
125+
const startX = box.x + 10;
126+
const endX = box.x + box.width - 10;
127+
const y = box.y + box.height / 2;
128+
129+
await superdoc.page.mouse.move(startX, y);
130+
await superdoc.page.mouse.down();
131+
132+
// Drag across the line in small increments, sampling overlay at each step
133+
let minRects = Infinity;
134+
let sampledSteps = 0;
135+
const steps = 8;
136+
for (let i = 1; i <= steps; i++) {
137+
const x = startX + ((endX - startX) * i) / steps;
138+
await superdoc.page.mouse.move(x, y);
139+
// Small wait to let the rendering pipeline catch up
140+
await superdoc.page.waitForTimeout(50);
141+
142+
const sel = await superdoc.getSelection();
143+
if (sel.to - sel.from > 0) {
144+
sampledSteps++;
145+
const rects = await getSelectionOverlayRectCount(superdoc);
146+
minRects = Math.min(minRects, rects);
147+
}
148+
}
149+
150+
await superdoc.page.mouse.up();
151+
await superdoc.waitForStable();
152+
153+
// Guard: the drag must have produced at least one non-collapsed selection sample,
154+
// otherwise minRects stays Infinity and the next assertion passes vacuously.
155+
expect(sampledSteps).toBeGreaterThan(0);
156+
// At no point during the drag should the overlay have dropped to zero rects
157+
// when there was a non-collapsed selection
158+
expect(minRects).toBeGreaterThan(0);
159+
});
160+
});
161+
162+
// ---------------------------------------------------------------------------
163+
// Drag selection near tables (isolating node clamping)
164+
// ---------------------------------------------------------------------------
165+
166+
test.describe('drag selection near tables (SD-2024)', () => {
167+
async function setupParagraphAndTable(superdoc: SuperDocFixture) {
168+
await superdoc.type('Text before table');
169+
await superdoc.newLine();
170+
await superdoc.waitForStable();
171+
172+
await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false });
173+
await superdoc.waitForStable();
174+
}
175+
176+
test('drag from paragraph into table clamps selection at table boundary', async ({ superdoc }) => {
177+
await setupParagraphAndTable(superdoc);
178+
179+
// Click into the first paragraph to establish anchor
180+
const textPos = await superdoc.findTextPos('Text before table');
181+
await superdoc.setTextSelection(textPos + 5); // cursor in "before"
182+
await superdoc.waitForStable();
183+
184+
// Get coordinates for the paragraph and the table area
185+
const firstLine = superdoc.page.locator('.superdoc-line').first();
186+
const firstLineBox = await firstLine.boundingBox();
187+
if (!firstLineBox) throw new Error('First line not visible');
188+
189+
// Find the table fragment in the rendered DOM
190+
const tableFragment = superdoc.page.locator('.superdoc-table-fragment').first();
191+
const tableBox = await tableFragment.boundingBox();
192+
if (!tableBox) throw new Error('Table not visible');
193+
194+
// Drag from the paragraph down into the table
195+
const startX = firstLineBox.x + 50;
196+
const startY = firstLineBox.y + firstLineBox.height / 2;
197+
const endX = tableBox.x + tableBox.width / 2;
198+
const endY = tableBox.y + tableBox.height / 2;
199+
200+
await superdoc.page.mouse.move(startX, startY);
201+
await superdoc.page.mouse.down();
202+
await superdoc.page.mouse.move(endX, endY, { steps: 5 });
203+
await superdoc.page.mouse.up();
204+
await superdoc.waitForStable();
205+
206+
// The selection should NOT be a CellSelection (which would mean it jumped inside).
207+
// It should be a TextSelection with the head clamped at the table boundary.
208+
const selType = await superdoc.page.evaluate(() => {
209+
const { state } = (window as any).editor;
210+
return state.selection.constructor.name ?? state.selection.toJSON().type;
211+
});
212+
expect(selType).not.toBe('CellSelection');
213+
214+
// The selection should be non-collapsed (we dragged across text)
215+
const sel = await superdoc.getSelection();
216+
expect(sel.to - sel.from).toBeGreaterThan(0);
217+
});
218+
219+
test('selection starting in paragraph and ending past table is allowed', async ({ superdoc }) => {
220+
// Setup: paragraph, table, then another paragraph after the table
221+
await superdoc.type('Text before table');
222+
await superdoc.newLine();
223+
await superdoc.waitForStable();
224+
225+
await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false });
226+
await superdoc.waitForStable();
227+
228+
// Insert a real paragraph after the table via PM transaction.
229+
// Tab in the last cell calls addRowAfter().goToNextCell() instead of
230+
// exiting the table, so we cannot use Tab to leave.
231+
const afterTablePos = await superdoc.page.evaluate(() => {
232+
const { state, view } = (window as any).editor;
233+
let tableEndPos = -1;
234+
state.doc.descendants((node: any, pos: number) => {
235+
if (node.type.name === 'table' && tableEndPos === -1) {
236+
tableEndPos = pos + node.nodeSize;
237+
return false; // skip children
238+
}
239+
});
240+
if (tableEndPos === -1) throw new Error('Table not found');
241+
const { tr, schema } = state;
242+
tr.insert(tableEndPos, schema.nodes.paragraph.create());
243+
view.dispatch(tr);
244+
return tableEndPos + 1; // content position inside the new paragraph
245+
});
246+
await superdoc.waitForStable();
247+
248+
await superdoc.setTextSelection(afterTablePos);
249+
await superdoc.waitForStable();
250+
await superdoc.type('Text after table');
251+
await superdoc.waitForStable();
252+
253+
// Verify "Text after table" is actually outside the table
254+
const textIsOutsideTable = await superdoc.page.evaluate(() => {
255+
const { state } = (window as any).editor;
256+
let tableEnd = -1;
257+
state.doc.descendants((node: any, pos: number) => {
258+
if (node.type.name === 'table' && tableEnd === -1) {
259+
tableEnd = pos + node.nodeSize;
260+
return false;
261+
}
262+
});
263+
let textPos = -1;
264+
state.doc.descendants((node: any, pos: number) => {
265+
if (node.isText && node.text?.includes('Text after table')) {
266+
textPos = pos;
267+
return false;
268+
}
269+
});
270+
return textPos > tableEnd;
271+
});
272+
expect(textIsOutsideTable).toBe(true);
273+
274+
// Select from before the table to after it using PM positions
275+
const beforePos = await superdoc.findTextPos('Text before table');
276+
const afterPos = await superdoc.findTextPos('Text after table');
277+
await superdoc.setTextSelection(beforePos, afterPos + 'Text after table'.length);
278+
await superdoc.waitForStable();
279+
280+
// This wide selection spanning the table should be valid
281+
const sel = await superdoc.getSelection();
282+
expect(sel.to - sel.from).toBeGreaterThan(0);
283+
284+
const rectCount = await getSelectionOverlayRectCount(superdoc);
285+
expect(rectCount).toBeGreaterThan(0);
286+
});
287+
});

0 commit comments

Comments
 (0)