Skip to content
Closed
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
173 changes: 136 additions & 37 deletions packages/react-viewer/src/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10597,8 +10597,13 @@ function estimateWrappedLineCountForParagraph(
!useTabLeaderLayout && anchoredTabLayout === "center";
const useRightTabLayout =
!useTabLeaderLayout && anchoredTabLayout === "right";
const useLeftRightTabLayout =
!useTabLeaderLayout && anchoredTabLayout === "left-right";
const useAnchoredTabLayout =
useCenterRightTabLayout || useCenterTabLayout || useRightTabLayout;
useCenterRightTabLayout ||
useCenterTabLayout ||
useRightTabLayout ||
useLeftRightTabLayout;
const paragraphEligibleForPlainPretextLineCount =
!paragraph.style?.indent &&
firstLineMaxWidthPx === maxLineWidthPx &&
Expand Down Expand Up @@ -17037,7 +17042,12 @@ function paragraphTabCharacterCount(paragraph: ParagraphNode): number {
}, 0);
}

type ParagraphAnchoredTabLayout = "none" | "center-right" | "center" | "right";
type ParagraphAnchoredTabLayout =
| "none"
| "center-right"
| "center"
| "right"
| "left-right";

function paragraphAnchoredTabLayout(
paragraph: ParagraphNode,
Expand All @@ -17052,6 +17062,14 @@ function paragraphAnchoredTabLayout(
const tabCount = paragraphTabCharacterCount(paragraph);
const withinHeaderFooter = options?.withinHeaderFooter === true;

// A left tab combined with a right tab (no center) is the classic two-column
// signature-block layout: a left column at the left tab and a right-aligned
// trailer at the right tab. Anchor it instead of falling back to the plain
// left-only tab path (which would treat the right tab as left and mis-wrap).
if (hasLeft && hasRight && !hasCenter && tabCount >= 2) {
return "left-right";
}

if (hasLeft && (hasCenter || hasRight)) {
return "none";
}
Expand All @@ -17077,7 +17095,7 @@ function paragraphAnchoredTabLayout(

function paragraphFirstTabStopPx(
paragraph: ParagraphNode,
alignment: "center" | "right"
alignment: "left" | "center" | "right"
): number | undefined {
return (paragraph.style?.tabStops ?? [])
.filter((tabStop) => tabStop.alignment === alignment)
Expand Down Expand Up @@ -19219,8 +19237,13 @@ function renderParagraphRuns(
!useTabLeaderLayout && anchoredTabLayout === "center";
const useRightTabLayout =
!useTabLeaderLayout && anchoredTabLayout === "right";
const useLeftRightTabLayout =
!useTabLeaderLayout && anchoredTabLayout === "left-right";
const useAnchoredTabLayout =
useCenterRightTabLayout || useCenterTabLayout || useRightTabLayout;
useCenterRightTabLayout ||
useCenterTabLayout ||
useRightTabLayout ||
useLeftRightTabLayout;
const pageFieldSequence = paragraphPageFieldSequence(paragraph);
const pageFieldValueSequence = paragraphPageFieldValueSequence(paragraph);
const styleRefFieldValueSequence =
Expand Down Expand Up @@ -20223,12 +20246,21 @@ function renderParagraphRuns(

renderNumberingIntoTarget(zones[0], `${keyPrefix}-numbering-anchored`);

// A line break (`\n`, e.g. a `<w:br/>`) is a shared row boundary: it starts
// a new visual line in EVERY column and returns the cursor to the first
// column. Emit a <br> into all zones and reset the active column.
const emitRowBreakToAllZones = (breakKey: string): void => {
zones.forEach((zone, zoneIdx) => {
zone.push(React.createElement("br", { key: `${breakKey}-z${zoneIdx}` }));
});
activeZone = 0;
};

paragraph.children.forEach((child, childIndex) => {
const zoneIndex = Math.max(0, Math.min(zoneCount - 1, activeZone));
const zoneTarget = zones[zoneIndex];
const key = `${keyPrefix}-anchored-run-${childIndex}`;
appendTrackedDeletionSegments(
zoneTarget,
zones[zoneIndex],
`${key}-before`,
child.type === "text" || child.type === "form-field"
? child.style
Expand All @@ -20242,47 +20274,46 @@ function renderParagraphRuns(
: child.type === "form-field"
? formFieldDisplayValue(child)
: undefined;
if (typeof rawText !== "string" || !rawText.includes("\t")) {
if (child.type === "text") {
const resolvedText = resolveFieldText(rawText ?? "", zoneIndex);
renderRun(
zoneTarget,
child,
key,
attachTextToPreviousCheckbox(paragraph, childIndex, resolvedText),
trackedInlineChange,
childIndex
);
} else {
renderRun(
zoneTarget,
child,
key,
undefined,
trackedInlineChange,
childIndex
);
}

if (typeof rawText !== "string") {
// Non-text child (image, etc.) — render into the current column.
renderRun(
zones[Math.max(0, Math.min(zoneCount - 1, activeZone))],
child,
key,
undefined,
trackedInlineChange,
childIndex
);
consumeTrackedVisibleChild(child);
return;
}

const parts = rawText.split("\t");
parts.forEach((part, partIndex) => {
const currentZone = Math.max(0, Math.min(zoneCount - 1, activeZone));
if (part.length > 0) {
// Tokenize on tabs and line breaks, keeping the delimiters: a tab advances
// to the next column; a newline is a shared row break.
const tokens = rawText.split(/(\t|\n)/);
tokens.forEach((token, tokenIndex) => {
if (token === "\t") {
if (activeZone < zoneCount - 1) {
activeZone += 1;
}
return;
}
if (token === "\n") {
emitRowBreakToAllZones(`${key}-br-${tokenIndex}`);
return;
}
if (token.length > 0) {
const currentZone = Math.max(0, Math.min(zoneCount - 1, activeZone));
renderRun(
zones[currentZone],
child,
`${key}-part-${partIndex}`,
resolveFieldText(part, currentZone),
`${key}-part-${tokenIndex}`,
resolveFieldText(token, currentZone),
trackedInlineChange,
childIndex
);
}
if (partIndex < parts.length - 1 && activeZone < zoneCount - 1) {
activeZone += 1;
}
});
consumeTrackedVisibleChild(child);
});
Expand Down Expand Up @@ -20436,6 +20467,73 @@ function renderParagraphRuns(
appendTrackedDeletionSegments(trailingTarget, `${keyPrefix}-tail`);
}

if (useLeftRightTabLayout) {
// Two-column signature-block layout: zone 0 at the left margin, and
// everything after the first tab as a left-aligned right column anchored at
// the left tab. Word collapses the trailing right tab once the column's
// content runs past it (the usual case for party names), so the content
// simply left-flows and wraps within the right column — matching Word and
// avoiding the overlap a fixed right-aligned zone would cause.
const zones = buildAnchoredTabZones(2);
const leftStopPx = Math.max(
0,
Math.round(paragraphFirstTabStopPx(paragraph, "left") ?? 0)
);

return (
<div
key={`${keyPrefix}-left-right-tabs`}
data-docx-tab-layout="left-right"
style={{
display: "grid",
gridTemplateColumns: `${leftStopPx}px 0px minmax(0, 1fr)`,
alignItems: "start",
width: "100%",
}}
>
{zones[0].length > 0 ? (
<span
data-docx-tab-zone="0"
style={{
// Block (not the shared inline-flex zone style) so shared row
// breaks (<br>) split the left column into lines too.
display: "block",
minWidth: 0,
whiteSpace: "pre-wrap",
wordBreak: "normal",
overflowWrap: "normal",
gridColumn: "1 / 2",
gridRow: "1 / 2",
justifySelf: "start",
textAlign: "left",
}}
>
{zones[0]}
</span>
) : null}
<span
data-docx-tab-zone="1"
style={{
// Normal flowing block (not the shared inline-flex zone style) so
// the right column's run segments wrap as ordinary text at the
// column width instead of shrinking into side-by-side sub-columns.
display: "block",
minWidth: 0,
whiteSpace: "pre-wrap",
wordBreak: "normal",
overflowWrap: "normal",
gridColumn: "3 / -1",
gridRow: "1 / 2",
justifySelf: "stretch",
textAlign: "left",
}}
>
{zones[1]}
</span>
</div>
);
}

if (useCenterRightTabLayout) {
const zones = buildAnchoredTabZones(3);
const centerStopPx = Math.max(
Expand Down Expand Up @@ -49104,7 +49202,8 @@ export function DocxEditorViewer({
paragraphUsesTabLeaders(paragraph) ||
anchoredTabLayout === "center-right" ||
anchoredTabLayout === "center" ||
anchoredTabLayout === "right";
anchoredTabLayout === "right" ||
anchoredTabLayout === "left-right";
const hasFixedPositionWrappedImage = paragraph.children.some(
(child) =>
child.type === "image" && isFixedPositionWrappedFloatingImage(child)
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/left-right-tab-columns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeAll, describe, expect, it } from "vitest";
import type { DocModel } from "@extend-ai/react-docx-doc-model";
import { DocxEditorViewer, useDocxEditor } from "../../packages/react-viewer/src/editor";

// Layout measures via OffscreenCanvas; provide a deterministic stub for node.
beforeAll(() => {
const makeContext = () => ({ font: "", measureText: (t: string) => ({ width: t.length * 7 }) });
(globalThis as unknown as { OffscreenCanvas: unknown }).OffscreenCanvas = class {
getContext() {
return makeContext();
}
};
});

// Regression for extend-hq/react-docx#13: a paragraph mixing a LEFT tab stop and
// a RIGHT-aligned tab stop (the classic two-column signature block) must use the
// anchored tab layout — not fall back to the plain left-only tab path that
// treats the right tab as left and mis-wraps.
function buildModel(): DocModel {
return {
nodes: [
{
type: "paragraph",
style: {
tabStops: [
{ alignment: "left", positionTwips: 5760 },
{ alignment: "right", positionTwips: 9240 },
],
},
// Two rows (split by the line break) x two columns (split by the tab):
// Client A | Company B Legal Name LLC
// On behalf of itself and ... | On behalf of itself and ...
children: [
{
type: "text",
text:
"Client A\tCompany B Legal Name LLC\nOn behalf of itself and its affiliates\tOn behalf of itself and its affiliates",
},
],
},
],
metadata: {
sourceParts: 1,
warnings: [],
headerSections: [],
footerSections: [],
paragraphStyles: [],
},
};
}

function Viewer({ model }: { model: DocModel }): React.JSX.Element {
const editor = useDocxEditor({ starterModel: model });
return React.createElement(DocxEditorViewer, { editor, mode: "read-only" });
}

describe("left+right tab-stop columns (signature block)", () => {
it("renders via the anchored left-right tab layout (left margin + right column)", () => {
const html = renderToStaticMarkup(React.createElement(Viewer, { model: buildModel() }));
expect(html).toContain('data-docx-tab-layout="left-right"');
expect(html).toMatch(/data-docx-tab-zone="0"/);
expect(html).toMatch(/data-docx-tab-zone="1"/);
});
});
Loading