-
Notifications
You must be signed in to change notification settings - Fork 132
test(selection): behavior tests for drag selection across marks and tables (SD-2057) #2318
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
harbournick
merged 4 commits into
main
from
tadeu/sd-2057-selection-drag-behavior-tests
Mar 18, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
1fe2186
test(selection): add behavior tests for drag selection across marks a…
tupizz 527de77
Merge branch 'main' into tadeu/sd-2057-selection-drag-behavior-tests
harbournick b68d39d
Merge branch 'main' into tadeu/sd-2057-selection-drag-behavior-tests
harbournick bbb89e1
chore: fix behavior tests
harbournick File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
287 changes: 287 additions & 0 deletions
287
tests/behavior/tests/selection/drag-selection-across-marks-and-tables.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,287 @@ | ||
| import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; | ||
|
|
||
| test.use({ config: { toolbar: 'full', showSelection: true } }); | ||
|
|
||
| /** | ||
| * Helper: type text with mixed formatting so adjacent runs have different marks. | ||
| * Produces "NormalBoldItalic" where each word has distinct formatting. | ||
| */ | ||
| async function setupMixedFormattingText(superdoc: SuperDocFixture) { | ||
| await superdoc.type('Normal'); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| await superdoc.bold(); | ||
| await superdoc.type('Bold'); | ||
| await superdoc.bold(); // toggle off | ||
| await superdoc.waitForStable(); | ||
|
|
||
| await superdoc.italic(); | ||
| await superdoc.type('Italic'); | ||
| await superdoc.italic(); // toggle off | ||
| await superdoc.waitForStable(); | ||
| } | ||
|
|
||
| /** | ||
| * Helper: count visible selection overlay rects. | ||
| */ | ||
| async function getSelectionOverlayRectCount(superdoc: SuperDocFixture): Promise<number> { | ||
| return superdoc.page.evaluate(() => { | ||
| const overlay = document.querySelector('.presentation-editor__selection-layer--local'); | ||
| if (!overlay) return 0; | ||
| // Count children with non-zero dimensions (actual selection rects) | ||
| let count = 0; | ||
| for (const child of overlay.children) { | ||
| const rect = child.getBoundingClientRect(); | ||
| if (rect.width > 0 && rect.height > 0) count++; | ||
| } | ||
| return count; | ||
| }); | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Selection across mark boundaries | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| test.describe('selection across mark boundaries (SD-2024)', () => { | ||
| test('selecting text that spans bold and italic runs shows a continuous highlight', async ({ superdoc }) => { | ||
| await setupMixedFormattingText(superdoc); | ||
|
|
||
| // Select from "Normal" through "Bold" to "Italic" — crosses two mark boundaries | ||
| const startPos = await superdoc.findTextPos('Normal'); | ||
| const endText = 'Italic'; | ||
| const endPos = await superdoc.findTextPos(endText); | ||
| await superdoc.setTextSelection(startPos, endPos + endText.length); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| // The selection overlay must have visible rects covering the selected text | ||
| const rectCount = await getSelectionOverlayRectCount(superdoc); | ||
| expect(rectCount).toBeGreaterThan(0); | ||
|
|
||
| // PM selection should span the full range | ||
| const sel = await superdoc.getSelection(); | ||
| expect(sel.to - sel.from).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| test('selecting exactly at a mark boundary produces a visible highlight', async ({ superdoc }) => { | ||
| await setupMixedFormattingText(superdoc); | ||
|
|
||
| // Select exactly across the Bold→Italic mark boundary. | ||
| // boldPos = start of the bold run (Normal→Bold boundary), | ||
| // italicPos = start of the italic run (Bold→Italic boundary). | ||
| // Both endpoints land on a mark boundary so this exercises the SD-2024 edge case. | ||
| const boldPos = await superdoc.findTextPos('Bold'); | ||
| const italicPos = await superdoc.findTextPos('Italic'); | ||
| await superdoc.setTextSelection(boldPos, italicPos); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| const rectCount = await getSelectionOverlayRectCount(superdoc); | ||
| expect(rectCount).toBeGreaterThan(0); | ||
|
|
||
| const sel = await superdoc.getSelection(); | ||
| expect(sel.to - sel.from).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| test('drag-selecting across bold and normal text maintains selection overlay', async ({ superdoc }) => { | ||
| await setupMixedFormattingText(superdoc); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| // Find the line element to compute drag coordinates | ||
| const line = superdoc.page.locator('.superdoc-line').first(); | ||
| const box = await line.boundingBox(); | ||
| if (!box) throw new Error('Line not visible'); | ||
|
|
||
| // Drag from left side (Normal text) to right side (Italic text) | ||
| const startX = box.x + 10; | ||
| const endX = box.x + box.width - 10; | ||
| const y = box.y + box.height / 2; | ||
|
|
||
| await superdoc.page.mouse.move(startX, y); | ||
| await superdoc.page.mouse.down(); | ||
| // Move in steps to simulate a real drag | ||
| const steps = 5; | ||
| for (let i = 1; i <= steps; i++) { | ||
| const x = startX + ((endX - startX) * i) / steps; | ||
| await superdoc.page.mouse.move(x, y); | ||
| } | ||
| await superdoc.page.mouse.up(); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| // After drag, we should have a non-collapsed selection with visible overlay | ||
| const sel = await superdoc.getSelection(); | ||
| expect(sel.to - sel.from).toBeGreaterThan(0); | ||
|
|
||
| const rectCount = await getSelectionOverlayRectCount(superdoc); | ||
| expect(rectCount).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| test('drag across marks never drops selection overlay mid-drag', async ({ superdoc }) => { | ||
| await setupMixedFormattingText(superdoc); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| const line = superdoc.page.locator('.superdoc-line').first(); | ||
| const box = await line.boundingBox(); | ||
| if (!box) throw new Error('Line not visible'); | ||
|
|
||
| const startX = box.x + 10; | ||
| const endX = box.x + box.width - 10; | ||
| const y = box.y + box.height / 2; | ||
|
|
||
| await superdoc.page.mouse.move(startX, y); | ||
| await superdoc.page.mouse.down(); | ||
|
|
||
| // Drag across the line in small increments, sampling overlay at each step | ||
| let minRects = Infinity; | ||
| let sampledSteps = 0; | ||
| const steps = 8; | ||
| for (let i = 1; i <= steps; i++) { | ||
| const x = startX + ((endX - startX) * i) / steps; | ||
| await superdoc.page.mouse.move(x, y); | ||
| // Small wait to let the rendering pipeline catch up | ||
| await superdoc.page.waitForTimeout(50); | ||
|
|
||
| const sel = await superdoc.getSelection(); | ||
| if (sel.to - sel.from > 0) { | ||
| sampledSteps++; | ||
| const rects = await getSelectionOverlayRectCount(superdoc); | ||
| minRects = Math.min(minRects, rects); | ||
| } | ||
| } | ||
|
|
||
| await superdoc.page.mouse.up(); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| // Guard: the drag must have produced at least one non-collapsed selection sample, | ||
| // otherwise minRects stays Infinity and the next assertion passes vacuously. | ||
| expect(sampledSteps).toBeGreaterThan(0); | ||
| // At no point during the drag should the overlay have dropped to zero rects | ||
| // when there was a non-collapsed selection | ||
| expect(minRects).toBeGreaterThan(0); | ||
| }); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Drag selection near tables (isolating node clamping) | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| test.describe('drag selection near tables (SD-2024)', () => { | ||
| async function setupParagraphAndTable(superdoc: SuperDocFixture) { | ||
| await superdoc.type('Text before table'); | ||
| await superdoc.newLine(); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); | ||
| await superdoc.waitForStable(); | ||
| } | ||
|
|
||
| test('drag from paragraph into table clamps selection at table boundary', async ({ superdoc }) => { | ||
| await setupParagraphAndTable(superdoc); | ||
|
|
||
| // Click into the first paragraph to establish anchor | ||
| const textPos = await superdoc.findTextPos('Text before table'); | ||
| await superdoc.setTextSelection(textPos + 5); // cursor in "before" | ||
| await superdoc.waitForStable(); | ||
|
|
||
| // Get coordinates for the paragraph and the table area | ||
| const firstLine = superdoc.page.locator('.superdoc-line').first(); | ||
| const firstLineBox = await firstLine.boundingBox(); | ||
| if (!firstLineBox) throw new Error('First line not visible'); | ||
|
|
||
| // Find the table fragment in the rendered DOM | ||
| const tableFragment = superdoc.page.locator('.superdoc-table-fragment').first(); | ||
| const tableBox = await tableFragment.boundingBox(); | ||
| if (!tableBox) throw new Error('Table not visible'); | ||
|
|
||
| // Drag from the paragraph down into the table | ||
| const startX = firstLineBox.x + 50; | ||
| const startY = firstLineBox.y + firstLineBox.height / 2; | ||
| const endX = tableBox.x + tableBox.width / 2; | ||
| const endY = tableBox.y + tableBox.height / 2; | ||
|
|
||
| await superdoc.page.mouse.move(startX, startY); | ||
| await superdoc.page.mouse.down(); | ||
| await superdoc.page.mouse.move(endX, endY, { steps: 5 }); | ||
| await superdoc.page.mouse.up(); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| // The selection should NOT be a CellSelection (which would mean it jumped inside). | ||
| // It should be a TextSelection with the head clamped at the table boundary. | ||
| const selType = await superdoc.page.evaluate(() => { | ||
| const { state } = (window as any).editor; | ||
| return state.selection.constructor.name ?? state.selection.toJSON().type; | ||
| }); | ||
| expect(selType).not.toBe('CellSelection'); | ||
|
|
||
| // The selection should be non-collapsed (we dragged across text) | ||
| const sel = await superdoc.getSelection(); | ||
| expect(sel.to - sel.from).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| test('selection starting in paragraph and ending past table is allowed', async ({ superdoc }) => { | ||
| // Setup: paragraph, table, then another paragraph after the table | ||
| await superdoc.type('Text before table'); | ||
| await superdoc.newLine(); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| // Insert a real paragraph after the table via PM transaction. | ||
| // Tab in the last cell calls addRowAfter().goToNextCell() instead of | ||
| // exiting the table, so we cannot use Tab to leave. | ||
| const afterTablePos = await superdoc.page.evaluate(() => { | ||
| const { state, view } = (window as any).editor; | ||
| let tableEndPos = -1; | ||
| state.doc.descendants((node: any, pos: number) => { | ||
| if (node.type.name === 'table' && tableEndPos === -1) { | ||
| tableEndPos = pos + node.nodeSize; | ||
| return false; // skip children | ||
| } | ||
| }); | ||
| if (tableEndPos === -1) throw new Error('Table not found'); | ||
| const { tr, schema } = state; | ||
| tr.insert(tableEndPos, schema.nodes.paragraph.create()); | ||
| view.dispatch(tr); | ||
| return tableEndPos + 1; // content position inside the new paragraph | ||
| }); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| await superdoc.setTextSelection(afterTablePos); | ||
| await superdoc.waitForStable(); | ||
| await superdoc.type('Text after table'); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| // Verify "Text after table" is actually outside the table | ||
| const textIsOutsideTable = await superdoc.page.evaluate(() => { | ||
| const { state } = (window as any).editor; | ||
| let tableEnd = -1; | ||
| state.doc.descendants((node: any, pos: number) => { | ||
| if (node.type.name === 'table' && tableEnd === -1) { | ||
| tableEnd = pos + node.nodeSize; | ||
| return false; | ||
| } | ||
| }); | ||
| let textPos = -1; | ||
| state.doc.descendants((node: any, pos: number) => { | ||
| if (node.isText && node.text?.includes('Text after table')) { | ||
| textPos = pos; | ||
| return false; | ||
| } | ||
| }); | ||
| return textPos > tableEnd; | ||
| }); | ||
| expect(textIsOutsideTable).toBe(true); | ||
|
|
||
| // Select from before the table to after it using PM positions | ||
| const beforePos = await superdoc.findTextPos('Text before table'); | ||
| const afterPos = await superdoc.findTextPos('Text after table'); | ||
| await superdoc.setTextSelection(beforePos, afterPos + 'Text after table'.length); | ||
| await superdoc.waitForStable(); | ||
|
|
||
| // This wide selection spanning the table should be valid | ||
| const sel = await superdoc.getSelection(); | ||
| expect(sel.to - sel.from).toBeGreaterThan(0); | ||
|
|
||
| const rectCount = await getSelectionOverlayRectCount(superdoc); | ||
| expect(rectCount).toBeGreaterThan(0); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.