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]); +});