From 5685db8baeca9f871233be610781ef0e2a383579 Mon Sep 17 00:00:00 2001 From: Roge Goncalves Date: Sun, 18 Oct 2026 17:15:03 -0300 Subject: [PATCH] Extend selection across paragraphs with Shift+ArrowUp/Down Each paragraph is its own editing host, so the native Shift+Arrow extension stopped at the paragraph edge. From a wrapped paragraph the textarea took the first step, but the next key reached the viewer root, where the native extension moved the DOM selection out of the paragraphs and the active range was cleared. onViewerKeyDown now moves the focus boundary one line: inside the paragraph, or into the next/previous paragraph (from the model) on the edge line, and applies it with setSelectionFromDocxBoundaries. Moves inside a single paragraph stay native, tables keep their cell selection, and once the selection spans paragraphs the key is always handled so the range is never dropped. Refs #20 Co-Authored-By: Claude Opus 5.5 --- .changeset/shift-arrow-cross-paragraph.md | 5 + packages/react-viewer/src/editor.tsx | 153 ++++++++++++++++++++++ tests/visual/fidelity-smoke.spec.ts | 118 +++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 .changeset/shift-arrow-cross-paragraph.md diff --git a/.changeset/shift-arrow-cross-paragraph.md b/.changeset/shift-arrow-cross-paragraph.md new file mode 100644 index 0000000..9b7efdd --- /dev/null +++ b/.changeset/shift-arrow-cross-paragraph.md @@ -0,0 +1,5 @@ +--- +"@extend-ai/react-docx": patch +--- + +Extend the text selection across paragraphs with Shift+ArrowUp/Down. diff --git a/packages/react-viewer/src/editor.tsx b/packages/react-viewer/src/editor.tsx index 05d5857..c8fb597 100644 --- a/packages/react-viewer/src/editor.tsx +++ b/packages/react-viewer/src/editor.tsx @@ -47595,6 +47595,140 @@ export function DocxEditorViewer({ ] ); + // Shift+ArrowUp/Down across paragraphs. Each paragraph is its own editing + // host, so the native extension stops at the host edge, and once focus is + // on the viewer root (cross-paragraph selections) it moves the DOM + // selection out of the paragraphs and the range is lost. This moves the + // focus boundary one line, into the next paragraph when it is on the edge + // line, and applies it the way Shift+click does. + const extendSelectionVertically = React.useCallback( + (direction: 1 | -1): boolean => { + const selection = window.getSelection(); + if (!selection?.anchorNode || !selection.focusNode) { + return false; + } + const anchor = resolveParagraphBoundaryFromSelectionPoint( + selection.anchorNode, + selection.anchorOffset + ); + const focus = resolveParagraphBoundaryFromSelectionPoint( + selection.focusNode, + selection.focusOffset + ); + // Tables keep their own cell selection. + if ( + !anchor || + !focus || + anchor.location.kind !== "paragraph" || + focus.location.kind !== "paragraph" + ) { + return false; + } + // Once the selection spans paragraphs the key is always handled here, + // even when it can't move, so the native extension never drops it. + const spansParagraphs = !sameParagraphLocation( + anchor.location, + focus.location + ); + + const lineRectsOf = (element: Element): DOMRect[] => { + const range = document.createRange(); + range.selectNodeContents(element); + return Array.from(range.getClientRects()).filter( + (rect) => rect.width > 0 && rect.height > 0 + ); + }; + const caretRange = document.createRange(); + try { + caretRange.setStart(selection.focusNode, selection.focusOffset); + } catch { + return spansParagraphs; + } + caretRange.collapse(true); + const caretRect = + caretRange.getClientRects()[0] ?? caretRange.getBoundingClientRect(); + const focusHost = resolveParagraphHostElement(focus.location); + if (!caretRect || caretRect.height <= 0 || !focusHost) { + return spansParagraphs; + } + const focusLines = lineRectsOf(focusHost); + const edgeLine = + direction > 0 ? focusLines[focusLines.length - 1] : focusLines[0]; + const caretMiddle = caretRect.top + caretRect.height / 2; + const onEdgeLine = + !edgeLine || + (caretMiddle >= edgeLine.top - 1 && caretMiddle <= edgeLine.bottom + 1); + + let target: DocxTextRangeBoundary | undefined; + if (!onEdgeLine) { + // Another line in the same paragraph. + if (!spansParagraphs) { + return false; + } + target = resolveBoundaryFromPoint({ + x: caretRect.left, + y: caretMiddle + direction * caretRect.height, + }); + if ( + !target || + !sameParagraphLocation(target.location, focus.location) + ) { + return true; + } + } else { + // First (or last) line of the next (or previous) paragraph. + let next: ParagraphLocation | undefined; + if (direction > 0) { + next = nextParagraphLocation(editor.model, focus.location); + } else { + for ( + let nodeIndex = focus.location.nodeIndex - 1; + nodeIndex >= 0 && !next; + nodeIndex -= 1 + ) { + next = lastParagraphLocationInNode(editor.model, nodeIndex); + } + } + const nextParagraph = + next?.kind === "paragraph" + ? getParagraphAtLocation(editor.model, next).paragraph + : undefined; + const nextHost = next ? resolveParagraphHostElement(next) : undefined; + if (!next || !nextParagraph || !nextHost) { + return spansParagraphs; + } + nextHost.scrollIntoView({ block: "nearest" }); + const nextLines = lineRectsOf(nextHost); + const line = + direction > 0 ? nextLines[0] : nextLines[nextLines.length - 1]; + const candidate = line + ? resolveBoundaryFromPoint({ + x: clampNumber(caretRect.left, line.left, line.right), + y: line.top + line.height / 2, + }) + : undefined; + target = + candidate && sameParagraphLocation(candidate.location, next) + ? candidate + : { + location: next, + offset: direction > 0 ? 0 : paragraphText(nextParagraph).length, + }; + } + + viewerRootRef.current?.focus({ preventScroll: true }); + setSelectionFromDocxBoundaries(anchor, target); + return true; + }, + [ + editor, + resolveBoundaryFromPoint, + resolveParagraphBoundaryFromSelectionPoint, + resolveParagraphHostElement, + setSelectionFromDocxBoundaries, + ] + ); + const onViewerKeyDown = React.useCallback( (event: React.KeyboardEvent): void => { commandSelectionGuardRef.current = undefined; @@ -47676,6 +47810,24 @@ export function DocxEditorViewer({ } } + if ( + !isReadOnly && + (event.key === "ArrowDown" || event.key === "ArrowUp") && + event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.defaultPrevented && + // The wrapped-paragraph textarea handles its own lines and the first + // step into the next paragraph. + document.activeElement !== wrappedParagraphTextareaRef.current && + extendSelectionVertically(event.key === "ArrowDown" ? 1 : -1) + ) { + event.preventDefault(); + event.stopPropagation(); + return; + } + const isSelectAll = (event.key === "a" || event.key === "A") && (event.ctrlKey || event.metaKey); @@ -47702,6 +47854,7 @@ export function DocxEditorViewer({ }, [ editor, + extendSelectionVertically, handleDeleteAcrossTableCellSelection, isReadOnly, resolveParagraphBoundaryFromSelectionPoint, diff --git a/tests/visual/fidelity-smoke.spec.ts b/tests/visual/fidelity-smoke.spec.ts index 354d952..42d4d61 100644 --- a/tests/visual/fidelity-smoke.spec.ts +++ b/tests/visual/fidelity-smoke.spec.ts @@ -550,3 +550,121 @@ test("applies and toggles toolbar styles on each new selection", async ({ ) .toBe(true); }); + +const VERTICAL_SELECTION_CONTENT_TYPES_XML = ` + + + + + +`; + +const VERTICAL_SELECTION_DOCUMENT_RELATIONSHIPS_XML = ` + + +`; + +const VERTICAL_SELECTION_STYLES_XML = ` + + + + + + + + + +`; + +function verticalSelectionDocx(): Buffer { + const paragraph = (text: string, style?: string) => + `${ + style ? `` : "" + }${text}`; + const documentXml = ` + + + ${paragraph("Section heading", "Heading2")} + ${paragraph("Alpha paragraph")} + ${paragraph("Bravo paragraph")} + ${paragraph("Charlie paragraph")} + ${paragraph("Delta paragraph")} + + + + + +`; + return Buffer.from( + createZip([ + { + name: "[Content_Types].xml", + content: VERTICAL_SELECTION_CONTENT_TYPES_XML, + }, + { name: "_rels/.rels", content: ROOT_RELATIONSHIPS_XML }, + { + name: "word/_rels/document.xml.rels", + content: VERTICAL_SELECTION_DOCUMENT_RELATIONSHIPS_XML, + }, + { name: "word/styles.xml", content: VERTICAL_SELECTION_STYLES_XML }, + { name: "word/document.xml", content: documentXml }, + ]) + ); +} + +test("extends the selection across paragraphs with Shift+ArrowUp/Down", async ({ + page, +}) => { + await page.goto("/"); + await page.locator('input[type="file"][accept*=".doc"]').setInputFiles({ + name: "vertical-selection.docx", + mimeType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + buffer: verticalSelectionDocx(), + }); + + const paragraph = (nodeIndex: number) => + page + .locator( + `[data-docx-paragraph-kind="paragraph"][data-docx-paragraph-node-index="${nodeIndex}"]` + ) + .first(); + await expect(paragraph(4)).toContainText("Delta paragraph", { + timeout: 20_000, + }); + const activeRange = () => + page.evaluate(() => { + const range = (window as any).__DOCX_TEST_HOOKS__.getActionState() + .activeTextRange; + return range + ? [ + range.start.location.nodeIndex, + range.start.offset, + range.end.location.nodeIndex, + range.end.offset, + ] + : null; + }); + + // Regular paragraphs: each press moves one paragraph, in both directions. + await selectTextOffsets(paragraph(1), 0, 0); + await page.keyboard.press("Shift+ArrowDown"); + await expect.poll(activeRange).toEqual([1, 0, 2, 0]); + await page.keyboard.press("Shift+ArrowDown"); + await expect.poll(activeRange).toEqual([1, 0, 3, 0]); + await page.keyboard.press("Shift+ArrowUp"); + await expect.poll(activeRange).toEqual([1, 0, 2, 0]); + await page.keyboard.press("Backspace"); + await expect(paragraph(1)).toHaveText("Bravo paragraph"); + + // From a heading on the wrapped-paragraph route: the textarea takes the + // first step into the next paragraph, and the next press keeps extending. + await expect( + paragraph(0).locator('[data-docx-wrapped-paragraph-root="true"]') + ).toHaveCount(1); + await selectTextOffsets(paragraph(0), 0, 0); + await page.keyboard.press("Shift+ArrowDown"); + await expect.poll(activeRange).toEqual([0, 0, 1, 0]); + await page.keyboard.press("Shift+ArrowDown"); + await expect.poll(activeRange).toEqual([0, 0, 2, 0]); +});