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
5 changes: 5 additions & 0 deletions .changeset/shift-arrow-cross-paragraph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@extend-ai/react-docx": patch
---

Extend the text selection across paragraphs with Shift+ArrowUp/Down.
153 changes: 153 additions & 0 deletions packages/react-viewer/src/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>): void => {
commandSelectionGuardRef.current = undefined;
Expand Down Expand Up @@ -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);
Expand All @@ -47702,6 +47854,7 @@ export function DocxEditorViewer({
},
[
editor,
extendSelectionVertically,
handleDeleteAcrossTableCellSelection,
isReadOnly,
resolveParagraphBoundaryFromSelectionPoint,
Expand Down
118 changes: 118 additions & 0 deletions tests/visual/fidelity-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,3 +550,121 @@ test("applies and toggles toolbar styles on each new selection", async ({
)
.toBe(true);
});

const VERTICAL_SELECTION_CONTENT_TYPES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>`;

const VERTICAL_SELECTION_DOCUMENT_RELATIONSHIPS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`;

const VERTICAL_SELECTION_STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/>
<w:basedOn w:val="Normal"/>
<w:next w:val="Normal"/>
<w:pPr><w:spacing w:before="240" w:after="120"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
</w:style>
</w:styles>`;

function verticalSelectionDocx(): Buffer {
const paragraph = (text: string, style?: string) =>
`<w:p>${
style ? `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>` : ""
}<w:r><w:t>${text}</w:t></w:r></w:p>`;
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
${paragraph("Section heading", "Heading2")}
${paragraph("Alpha paragraph")}
${paragraph("Bravo paragraph")}
${paragraph("Charlie paragraph")}
${paragraph("Delta paragraph")}
<w:sectPr>
<w:pgSz w:w="12240" w:h="15840"/>
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/>
</w:sectPr>
</w:body>
</w:document>`;
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]);
});