@beyondwork/docx-react-component 1.0.38 → 1.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +41 -31
- package/src/api/public-types.ts +183 -6
- package/src/core/commands/table-structure-commands.ts +31 -2
- package/src/core/commands/text-commands.ts +122 -2
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-numbering.ts +42 -8
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +83 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-runtime.ts +134 -18
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +69 -2
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/page-graph.ts +36 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +342 -28
- package/src/runtime/layout/project-block-fragments.ts +154 -20
- package/src/runtime/layout/public-facet.ts +40 -1
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +21 -1
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -0
- package/src/runtime/render/render-kernel.ts +5 -1
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -0
- package/src/ui/WordReviewEditor.tsx +285 -5
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +4 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +34 -5
- package/src/ui/headless/scoped-chrome-policy.ts +29 -0
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
- package/src/ui-tailwind/chrome-overlay/index.ts +0 -6
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +27 -17
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +1 -5
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
- package/src/ui-tailwind/tw-review-workspace.tsx +132 -54
- package/src/runtime/collab-review-sync.ts +0 -254
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
SurfaceBlockSnapshot,
|
|
5
|
+
SurfaceInlineSegment,
|
|
6
|
+
SurfaceTextMark,
|
|
7
|
+
} from "../../api/public-types.ts";
|
|
8
|
+
import type { CanonicalRunFormatting } from "../../model/canonical-document.ts";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Constants
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const HEX_COLOR_RE = /^[0-9A-Fa-f]{3,8}$/;
|
|
15
|
+
const SAFE_FONT_RE = /^[A-Za-z0-9 ,\-'"]+$/;
|
|
16
|
+
const SAFE_ALIGNMENT = new Set(["left", "center", "right", "justify", "start", "end"]);
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helper utilities (mirror pm-schema.ts helpers)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
function safeHexColor(raw: string | null | undefined): string | null {
|
|
23
|
+
if (!raw || raw === "auto") return null;
|
|
24
|
+
return HEX_COLOR_RE.test(raw) ? `#${raw}` : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Resolve heading level from styleId or outlineLevel (outlineLevel 0 = Heading 1). */
|
|
28
|
+
function resolveHeadingLevel(styleId?: string, outlineLevel?: number): number | null {
|
|
29
|
+
if (styleId) {
|
|
30
|
+
const normalized = styleId.toLowerCase();
|
|
31
|
+
const compact = normalized.replace(/[\s_-]+/g, "");
|
|
32
|
+
const headingMatch = /^heading([1-6])$/.exec(compact);
|
|
33
|
+
if (headingMatch) {
|
|
34
|
+
return Number.parseInt(headingMatch[1], 10);
|
|
35
|
+
}
|
|
36
|
+
if (compact === "title") return 1;
|
|
37
|
+
if (compact === "subtitle") return 2;
|
|
38
|
+
if (compact === "tocheading") return 1;
|
|
39
|
+
if (/^(appendix|schedule|annex|exhibit|attachment)(heading|title)$/.test(compact)) return 2;
|
|
40
|
+
}
|
|
41
|
+
if (
|
|
42
|
+
typeof outlineLevel === "number" &&
|
|
43
|
+
Number.isInteger(outlineLevel) &&
|
|
44
|
+
outlineLevel >= 0 &&
|
|
45
|
+
outlineLevel <= 5
|
|
46
|
+
) {
|
|
47
|
+
return outlineLevel + 1;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveMarkerJustificationCss(raw: string | undefined): string {
|
|
53
|
+
switch (raw) {
|
|
54
|
+
case "left":
|
|
55
|
+
return "flex-start";
|
|
56
|
+
case "center":
|
|
57
|
+
return "center";
|
|
58
|
+
case "right":
|
|
59
|
+
case "both":
|
|
60
|
+
case "distribute":
|
|
61
|
+
default:
|
|
62
|
+
return "flex-end";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Style builders
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/** Build CSSProperties for a paragraph block from spacing/indent/alignment. */
|
|
71
|
+
function buildParagraphStyle(
|
|
72
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
73
|
+
): React.CSSProperties {
|
|
74
|
+
const style: React.CSSProperties = {};
|
|
75
|
+
|
|
76
|
+
// Alignment — direct takes precedence over resolvedParagraphFormatting
|
|
77
|
+
const rawAlignment = block.alignment ?? block.resolvedParagraphFormatting?.alignment;
|
|
78
|
+
const safeAlign = rawAlignment === "both" ? "justify" : rawAlignment;
|
|
79
|
+
if (safeAlign && SAFE_ALIGNMENT.has(safeAlign)) {
|
|
80
|
+
style.textAlign = safeAlign as React.CSSProperties["textAlign"];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Spacing
|
|
84
|
+
const spacingBefore =
|
|
85
|
+
block.spacing?.before ?? block.resolvedParagraphFormatting?.spacing?.before;
|
|
86
|
+
const spacingAfter =
|
|
87
|
+
block.spacing?.after ?? block.resolvedParagraphFormatting?.spacing?.after;
|
|
88
|
+
const lineSpacing =
|
|
89
|
+
block.spacing?.line ?? block.resolvedParagraphFormatting?.spacing?.line;
|
|
90
|
+
const lineRule =
|
|
91
|
+
block.spacing?.lineRule ?? block.resolvedParagraphFormatting?.spacing?.lineRule;
|
|
92
|
+
|
|
93
|
+
if (spacingBefore != null) {
|
|
94
|
+
style.marginTop = `${spacingBefore / 20}px`;
|
|
95
|
+
}
|
|
96
|
+
if (spacingAfter != null) {
|
|
97
|
+
style.marginBottom = `${spacingAfter / 20}px`;
|
|
98
|
+
}
|
|
99
|
+
if (lineSpacing && lineRule === "auto") {
|
|
100
|
+
style.lineHeight = String(lineSpacing / 240);
|
|
101
|
+
} else if (lineSpacing && lineRule === "exact") {
|
|
102
|
+
style.lineHeight = `${lineSpacing / 20}px`;
|
|
103
|
+
} else if (lineSpacing && lineRule === "atLeast") {
|
|
104
|
+
style.minHeight = `${lineSpacing / 20}px`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Indentation
|
|
108
|
+
const indentLeft =
|
|
109
|
+
block.indentation?.left ?? block.resolvedParagraphFormatting?.indentation?.left;
|
|
110
|
+
const indentRight =
|
|
111
|
+
block.indentation?.right ?? block.resolvedParagraphFormatting?.indentation?.right;
|
|
112
|
+
const indentFirstLine =
|
|
113
|
+
block.indentation?.firstLine ?? block.resolvedParagraphFormatting?.indentation?.firstLine;
|
|
114
|
+
const indentHanging =
|
|
115
|
+
block.indentation?.hanging ?? block.resolvedParagraphFormatting?.indentation?.hanging;
|
|
116
|
+
|
|
117
|
+
if (indentLeft) style.paddingLeft = `${indentLeft / 20}px`;
|
|
118
|
+
if (indentRight) style.paddingRight = `${indentRight / 20}px`;
|
|
119
|
+
if (indentHanging) style.textIndent = `-${indentHanging / 20}px`;
|
|
120
|
+
else if (indentFirstLine) style.textIndent = `${indentFirstLine / 20}px`;
|
|
121
|
+
|
|
122
|
+
// Shading
|
|
123
|
+
const shadingFill = block.shading?.fill;
|
|
124
|
+
const shadingColor = safeHexColor(shadingFill);
|
|
125
|
+
if (shadingColor) style.backgroundColor = shadingColor;
|
|
126
|
+
|
|
127
|
+
// Page break visual indicator
|
|
128
|
+
if (block.pageBreakBefore) {
|
|
129
|
+
style.borderTop = "2px dashed rgba(0,0,0,0.1)";
|
|
130
|
+
style.paddingTop = "8px";
|
|
131
|
+
style.marginTop = "16px";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return style;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Build CSSProperties for the numbering marker span. */
|
|
138
|
+
function buildMarkerStyle(
|
|
139
|
+
prefix: string,
|
|
140
|
+
suffix: "tab" | "space" | "nothing" | undefined,
|
|
141
|
+
markerRunProperties: CanonicalRunFormatting | undefined,
|
|
142
|
+
markerWidth: number | undefined,
|
|
143
|
+
markerJustification: string | undefined,
|
|
144
|
+
): React.CSSProperties {
|
|
145
|
+
const style: React.CSSProperties = {
|
|
146
|
+
fontVariantNumeric: "tabular-nums",
|
|
147
|
+
justifyContent: resolveMarkerJustificationCss(markerJustification),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (markerRunProperties) {
|
|
151
|
+
if (markerRunProperties.bold) style.fontWeight = "bold";
|
|
152
|
+
if (markerRunProperties.italic) style.fontStyle = "italic";
|
|
153
|
+
if (markerRunProperties.underline && markerRunProperties.underline !== "none") {
|
|
154
|
+
style.textDecoration = "underline";
|
|
155
|
+
}
|
|
156
|
+
if (typeof markerRunProperties.fontSizeHalfPoints === "number") {
|
|
157
|
+
style.fontSize = `${markerRunProperties.fontSizeHalfPoints / 2}pt`;
|
|
158
|
+
}
|
|
159
|
+
const colorHex = markerRunProperties.colorHex;
|
|
160
|
+
if (colorHex && colorHex !== "auto") {
|
|
161
|
+
style.color = `#${colorHex.toLowerCase()}`;
|
|
162
|
+
}
|
|
163
|
+
const family = markerRunProperties.fontFamilyAscii ?? markerRunProperties.fontFamily;
|
|
164
|
+
if (family && SAFE_FONT_RE.test(family)) {
|
|
165
|
+
style.fontFamily = family;
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
style.color = "var(--color-text-tertiary)";
|
|
169
|
+
style.fontFamily = "var(--font-legal-sans)";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const hasResolvedMarkerWidth = typeof markerWidth === "number" && markerWidth > 0;
|
|
173
|
+
if (hasResolvedMarkerWidth) {
|
|
174
|
+
const markerWidthPx = Math.max(1, Math.round(markerWidth! / 20));
|
|
175
|
+
style.width = `${markerWidthPx}px`;
|
|
176
|
+
style.minWidth = `${markerWidthPx}px`;
|
|
177
|
+
style.flexBasis = `${markerWidthPx}px`;
|
|
178
|
+
style.marginRight = 0;
|
|
179
|
+
style.overflow = "visible";
|
|
180
|
+
} else {
|
|
181
|
+
const fallbackMinWidth = Math.min(Math.max(prefix.length + 1, 4), 14);
|
|
182
|
+
const fallbackMarginRight =
|
|
183
|
+
suffix === "nothing" ? "0.25rem" : suffix === "space" ? "0.5rem" : "0.75rem";
|
|
184
|
+
style.minWidth = `${fallbackMinWidth}ch`;
|
|
185
|
+
style.marginRight = fallbackMarginRight;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return style;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Build CSSProperties for a text segment from marks and markAttrs. */
|
|
192
|
+
function buildSegmentStyle(
|
|
193
|
+
marks: SurfaceTextMark[] | undefined,
|
|
194
|
+
markAttrs?: {
|
|
195
|
+
fontSize?: number;
|
|
196
|
+
textColor?: string;
|
|
197
|
+
fontFamily?: string;
|
|
198
|
+
backgroundColor?: string;
|
|
199
|
+
charSpacing?: number;
|
|
200
|
+
},
|
|
201
|
+
): React.CSSProperties {
|
|
202
|
+
const style: React.CSSProperties = {};
|
|
203
|
+
|
|
204
|
+
if (marks) {
|
|
205
|
+
if (marks.includes("bold")) style.fontWeight = "bold";
|
|
206
|
+
if (marks.includes("italic")) style.fontStyle = "italic";
|
|
207
|
+
if (marks.includes("underline")) style.textDecoration = "underline";
|
|
208
|
+
if (marks.includes("strikethrough") || marks.includes("doubleStrikethrough")) {
|
|
209
|
+
style.textDecoration = marks.includes("underline")
|
|
210
|
+
? "underline line-through"
|
|
211
|
+
: "line-through";
|
|
212
|
+
}
|
|
213
|
+
if (marks.includes("superscript")) {
|
|
214
|
+
style.verticalAlign = "super";
|
|
215
|
+
style.fontSize = "smaller";
|
|
216
|
+
}
|
|
217
|
+
if (marks.includes("subscript")) {
|
|
218
|
+
style.verticalAlign = "sub";
|
|
219
|
+
style.fontSize = "smaller";
|
|
220
|
+
}
|
|
221
|
+
if (marks.includes("allCaps")) style.textTransform = "uppercase";
|
|
222
|
+
if (marks.includes("smallCaps")) style.fontVariant = "small-caps";
|
|
223
|
+
if (marks.includes("vanish")) style.display = "none";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (markAttrs) {
|
|
227
|
+
if (markAttrs.fontSize) style.fontSize = `${markAttrs.fontSize}pt`;
|
|
228
|
+
if (markAttrs.textColor) style.color = markAttrs.textColor;
|
|
229
|
+
if (markAttrs.fontFamily && SAFE_FONT_RE.test(markAttrs.fontFamily)) {
|
|
230
|
+
style.fontFamily = markAttrs.fontFamily;
|
|
231
|
+
}
|
|
232
|
+
if (markAttrs.backgroundColor) style.backgroundColor = markAttrs.backgroundColor;
|
|
233
|
+
if (markAttrs.charSpacing) style.letterSpacing = `${markAttrs.charSpacing}px`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return style;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function hasStyleEntries(style: React.CSSProperties): boolean {
|
|
240
|
+
return Object.keys(style).length > 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Segment renderer
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/** Render a single inline segment. */
|
|
248
|
+
function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
|
|
249
|
+
switch (seg.kind) {
|
|
250
|
+
case "text": {
|
|
251
|
+
const style = buildSegmentStyle(seg.marks, seg.markAttrs);
|
|
252
|
+
const content = seg.text;
|
|
253
|
+
if (!hasStyleEntries(style)) {
|
|
254
|
+
return <React.Fragment key={seg.segmentId}>{content}</React.Fragment>;
|
|
255
|
+
}
|
|
256
|
+
return (
|
|
257
|
+
<span key={seg.segmentId} style={style}>
|
|
258
|
+
{content}
|
|
259
|
+
</span>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
case "tab":
|
|
263
|
+
return (
|
|
264
|
+
<span
|
|
265
|
+
key={seg.segmentId}
|
|
266
|
+
data-node-type="tab"
|
|
267
|
+
style={{ display: "inline-block", width: "32px", minWidth: "8px" }}
|
|
268
|
+
>
|
|
269
|
+
{"\u00A0"}
|
|
270
|
+
</span>
|
|
271
|
+
);
|
|
272
|
+
case "hard_break":
|
|
273
|
+
return <br key={seg.segmentId} />;
|
|
274
|
+
case "image":
|
|
275
|
+
return (
|
|
276
|
+
<span
|
|
277
|
+
key={seg.segmentId}
|
|
278
|
+
data-node-type="image"
|
|
279
|
+
style={{
|
|
280
|
+
display: "inline-block",
|
|
281
|
+
width: "48px",
|
|
282
|
+
height: "32px",
|
|
283
|
+
backgroundColor: "#e0e0e0",
|
|
284
|
+
verticalAlign: "middle",
|
|
285
|
+
margin: "0 4px",
|
|
286
|
+
borderRadius: "2px",
|
|
287
|
+
}}
|
|
288
|
+
title={seg.altText ?? "Image"}
|
|
289
|
+
/>
|
|
290
|
+
);
|
|
291
|
+
case "field_ref":
|
|
292
|
+
return (
|
|
293
|
+
<span
|
|
294
|
+
key={seg.segmentId}
|
|
295
|
+
data-node-type="field_ref"
|
|
296
|
+
style={{ opacity: 0.6, fontSize: "0.85em" }}
|
|
297
|
+
>
|
|
298
|
+
[field]
|
|
299
|
+
</span>
|
|
300
|
+
);
|
|
301
|
+
case "note_ref":
|
|
302
|
+
return (
|
|
303
|
+
<span
|
|
304
|
+
key={seg.segmentId}
|
|
305
|
+
data-node-type="note_ref"
|
|
306
|
+
style={{ verticalAlign: "super", fontSize: "0.75em" }}
|
|
307
|
+
>
|
|
308
|
+
{seg.label}
|
|
309
|
+
</span>
|
|
310
|
+
);
|
|
311
|
+
case "opaque_inline":
|
|
312
|
+
return (
|
|
313
|
+
<span
|
|
314
|
+
key={seg.segmentId}
|
|
315
|
+
data-node-type="opaque_inline"
|
|
316
|
+
style={{ opacity: 0.6, fontSize: "0.85em" }}
|
|
317
|
+
>
|
|
318
|
+
{seg.displayText ?? seg.label}
|
|
319
|
+
</span>
|
|
320
|
+
);
|
|
321
|
+
default:
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Block renderers
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
/** Render a paragraph block as <p>. */
|
|
331
|
+
function ParagraphBlock({
|
|
332
|
+
block,
|
|
333
|
+
}: {
|
|
334
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
|
|
335
|
+
}): React.ReactElement {
|
|
336
|
+
const headingLevel = resolveHeadingLevel(block.styleId, block.outlineLevel);
|
|
337
|
+
|
|
338
|
+
const classes: string[] = ["leading-relaxed"];
|
|
339
|
+
if (headingLevel) {
|
|
340
|
+
classes.push(...headingClassList(headingLevel));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const pStyle = buildParagraphStyle(block);
|
|
344
|
+
const attrs: React.HTMLAttributes<HTMLParagraphElement> & {
|
|
345
|
+
"data-heading-level"?: string;
|
|
346
|
+
"data-numbered"?: string;
|
|
347
|
+
"data-contextual-spacing"?: string;
|
|
348
|
+
} = {
|
|
349
|
+
className: classes.join(" "),
|
|
350
|
+
style: Object.keys(pStyle).length > 0 ? pStyle : undefined,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
if (headingLevel) {
|
|
354
|
+
attrs["data-heading-level"] = String(headingLevel);
|
|
355
|
+
}
|
|
356
|
+
if (block.numbering) {
|
|
357
|
+
attrs["data-numbered"] = "true";
|
|
358
|
+
}
|
|
359
|
+
if (block.contextualSpacing) {
|
|
360
|
+
attrs["data-contextual-spacing"] = "true";
|
|
361
|
+
}
|
|
362
|
+
if (block.bidi) {
|
|
363
|
+
attrs.dir = "rtl";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Numbering prefix span
|
|
367
|
+
const numberingPrefix = block.numberingPrefix;
|
|
368
|
+
const numberingSuffix = block.numberingSuffix;
|
|
369
|
+
const resolvedNumbering = block.resolvedNumbering;
|
|
370
|
+
const markerRunProperties = resolvedNumbering?.markerRunProperties;
|
|
371
|
+
const markerWidth = resolvedNumbering?.geometry?.markerLane?.width;
|
|
372
|
+
const markerJustification = resolvedNumbering?.geometry?.markerJustification;
|
|
373
|
+
|
|
374
|
+
const prefixSpan =
|
|
375
|
+
numberingPrefix != null ? (
|
|
376
|
+
<span
|
|
377
|
+
className={[
|
|
378
|
+
"inline-flex",
|
|
379
|
+
"select-none",
|
|
380
|
+
"items-center",
|
|
381
|
+
...(!markerRunProperties ? ["text-tertiary", "font-[family-name:var(--font-legal-sans)]"] : []),
|
|
382
|
+
].join(" ")}
|
|
383
|
+
contentEditable={false}
|
|
384
|
+
data-numbering-prefix={numberingPrefix}
|
|
385
|
+
{...(typeof resolvedNumbering?.level === "number"
|
|
386
|
+
? { "data-numbering-level": String(resolvedNumbering.level) }
|
|
387
|
+
: {})}
|
|
388
|
+
{...(numberingSuffix ? { "data-numbering-suffix": numberingSuffix } : {})}
|
|
389
|
+
style={buildMarkerStyle(
|
|
390
|
+
numberingPrefix,
|
|
391
|
+
numberingSuffix,
|
|
392
|
+
markerRunProperties,
|
|
393
|
+
markerWidth,
|
|
394
|
+
markerJustification,
|
|
395
|
+
)}
|
|
396
|
+
>
|
|
397
|
+
{numberingPrefix}
|
|
398
|
+
</span>
|
|
399
|
+
) : null;
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<p {...attrs}>
|
|
403
|
+
{prefixSpan}
|
|
404
|
+
<span className="pm-paragraph-content">
|
|
405
|
+
{block.segments.map((seg) => renderSegment(seg))}
|
|
406
|
+
</span>
|
|
407
|
+
</p>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function headingClassList(level: number): string[] {
|
|
412
|
+
switch (level) {
|
|
413
|
+
case 1:
|
|
414
|
+
return ["text-3xl", "font-semibold", "tracking-tight", "leading-tight"];
|
|
415
|
+
case 2:
|
|
416
|
+
return ["text-2xl", "font-semibold", "tracking-tight"];
|
|
417
|
+
case 3:
|
|
418
|
+
return ["text-xl", "font-medium"];
|
|
419
|
+
case 4:
|
|
420
|
+
return ["text-lg", "font-medium"];
|
|
421
|
+
case 5:
|
|
422
|
+
return ["text-base", "font-semibold", "uppercase", "tracking-[0.12em]"];
|
|
423
|
+
case 6:
|
|
424
|
+
return ["text-sm", "font-semibold", "uppercase", "tracking-[0.16em]"];
|
|
425
|
+
default:
|
|
426
|
+
return [];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Render a table block as <table>. */
|
|
431
|
+
function TableBlock({
|
|
432
|
+
block,
|
|
433
|
+
}: {
|
|
434
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
|
|
435
|
+
}): React.ReactElement {
|
|
436
|
+
const tableStyle: React.CSSProperties = {
|
|
437
|
+
borderCollapse: "collapse",
|
|
438
|
+
width: "100%",
|
|
439
|
+
};
|
|
440
|
+
if (block.alignment === "center") {
|
|
441
|
+
tableStyle.marginLeft = "auto";
|
|
442
|
+
tableStyle.marginRight = "auto";
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return (
|
|
446
|
+
<table style={tableStyle} data-node-type="table">
|
|
447
|
+
<tbody>
|
|
448
|
+
{block.rows.map((row, rowIdx) => (
|
|
449
|
+
<tr
|
|
450
|
+
key={rowIdx}
|
|
451
|
+
style={
|
|
452
|
+
row.height != null && row.heightRule === "exact"
|
|
453
|
+
? { height: `${row.height / 20}px` }
|
|
454
|
+
: row.height != null && row.heightRule === "atLeast"
|
|
455
|
+
? { minHeight: `${row.height / 20}px` }
|
|
456
|
+
: undefined
|
|
457
|
+
}
|
|
458
|
+
>
|
|
459
|
+
{row.cells.map((cell, cellIdx) => {
|
|
460
|
+
if (cell.verticalMerge === "continue") {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
const cellStyle: React.CSSProperties = {};
|
|
464
|
+
if (cell.backgroundColor) cellStyle.backgroundColor = `#${cell.backgroundColor}`;
|
|
465
|
+
if (cell.verticalAlign) cellStyle.verticalAlign = cell.verticalAlign;
|
|
466
|
+
if (cell.borderTop) cellStyle.borderTop = cell.borderTop;
|
|
467
|
+
if (cell.borderRight) cellStyle.borderRight = cell.borderRight;
|
|
468
|
+
if (cell.borderBottom) cellStyle.borderBottom = cell.borderBottom;
|
|
469
|
+
if (cell.borderLeft) cellStyle.borderLeft = cell.borderLeft;
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<td
|
|
473
|
+
key={cellIdx}
|
|
474
|
+
colSpan={cell.colspan > 1 ? cell.colspan : undefined}
|
|
475
|
+
rowSpan={cell.rowspan > 1 ? cell.rowspan : undefined}
|
|
476
|
+
style={Object.keys(cellStyle).length > 0 ? cellStyle : undefined}
|
|
477
|
+
>
|
|
478
|
+
{cell.content.map((childBlock) => (
|
|
479
|
+
<BlockItem key={childBlock.blockId} block={childBlock} />
|
|
480
|
+
))}
|
|
481
|
+
</td>
|
|
482
|
+
);
|
|
483
|
+
})}
|
|
484
|
+
</tr>
|
|
485
|
+
))}
|
|
486
|
+
</tbody>
|
|
487
|
+
</table>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Render any block, dispatching to the appropriate component. */
|
|
492
|
+
function BlockItem({ block }: { block: SurfaceBlockSnapshot }): React.ReactElement | null {
|
|
493
|
+
switch (block.kind) {
|
|
494
|
+
case "paragraph":
|
|
495
|
+
return <ParagraphBlock block={block} />;
|
|
496
|
+
case "table":
|
|
497
|
+
return <TableBlock block={block} />;
|
|
498
|
+
case "sdt_block":
|
|
499
|
+
return (
|
|
500
|
+
<section data-node-type="sdt_block" style={{ margin: "8px 0" }}>
|
|
501
|
+
{block.children.map((child) => (
|
|
502
|
+
<BlockItem key={child.blockId} block={child} />
|
|
503
|
+
))}
|
|
504
|
+
</section>
|
|
505
|
+
);
|
|
506
|
+
case "opaque_block":
|
|
507
|
+
// Render as an uneditable placeholder
|
|
508
|
+
return (
|
|
509
|
+
<div
|
|
510
|
+
data-node-type="opaque_block"
|
|
511
|
+
style={{
|
|
512
|
+
opacity: 0.5,
|
|
513
|
+
borderLeft: "3px solid #aaa",
|
|
514
|
+
paddingLeft: "8px",
|
|
515
|
+
margin: "4px 0",
|
|
516
|
+
fontSize: "0.85em",
|
|
517
|
+
color: "#666",
|
|
518
|
+
}}
|
|
519
|
+
>
|
|
520
|
+
{block.label || "[Locked content]"}
|
|
521
|
+
</div>
|
|
522
|
+
);
|
|
523
|
+
default:
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// Public export
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* TwPageBlockView — read-only React renderer for a slice of SurfaceBlockSnapshot[].
|
|
534
|
+
*
|
|
535
|
+
* Used for pages 2+ in the page workspace. Page 1 uses the live PM editor;
|
|
536
|
+
* pages 2+ use this static view.
|
|
537
|
+
*
|
|
538
|
+
* Wraps output in a `.ProseMirror` container so it inherits pm-schema.css styles.
|
|
539
|
+
* The wrapper is `aria-hidden` because it is a visual-only duplicate of the
|
|
540
|
+
* live editor surface.
|
|
541
|
+
*/
|
|
542
|
+
export function TwPageBlockView({
|
|
543
|
+
blocks,
|
|
544
|
+
className,
|
|
545
|
+
}: {
|
|
546
|
+
blocks: SurfaceBlockSnapshot[];
|
|
547
|
+
className?: string;
|
|
548
|
+
}): React.ReactElement {
|
|
549
|
+
return (
|
|
550
|
+
<div
|
|
551
|
+
className={["ProseMirror", className].filter(Boolean).join(" ")}
|
|
552
|
+
aria-hidden="true"
|
|
553
|
+
>
|
|
554
|
+
{blocks.map((block) => (
|
|
555
|
+
<BlockItem key={block.blockId} block={block} />
|
|
556
|
+
))}
|
|
557
|
+
</div>
|
|
558
|
+
);
|
|
559
|
+
}
|