@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.
Files changed (76) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +183 -6
  3. package/src/core/commands/table-structure-commands.ts +31 -2
  4. package/src/core/commands/text-commands.ts +122 -2
  5. package/src/io/docx-session.ts +1 -0
  6. package/src/io/export/serialize-numbering.ts +42 -8
  7. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  8. package/src/io/export/serialize-run-formatting.ts +90 -0
  9. package/src/io/export/serialize-styles.ts +212 -0
  10. package/src/io/ooxml/parse-fields.ts +10 -3
  11. package/src/io/ooxml/parse-numbering.ts +41 -1
  12. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  13. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  14. package/src/io/ooxml/parse-styles.ts +31 -0
  15. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  16. package/src/io/ooxml/xml-element.ts +19 -0
  17. package/src/model/canonical-document.ts +83 -3
  18. package/src/runtime/collab/event-types.ts +165 -0
  19. package/src/runtime/collab/index.ts +22 -0
  20. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  21. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  22. package/src/runtime/document-runtime.ts +134 -18
  23. package/src/runtime/layout/index.ts +2 -0
  24. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  25. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  26. package/src/runtime/layout/layout-invalidation.ts +14 -5
  27. package/src/runtime/layout/page-graph.ts +36 -0
  28. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  29. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  30. package/src/runtime/layout/project-block-fragments.ts +154 -20
  31. package/src/runtime/layout/public-facet.ts +40 -1
  32. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  33. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  34. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  35. package/src/runtime/layout/table-render-plan.ts +21 -1
  36. package/src/runtime/numbering-prefix.ts +5 -0
  37. package/src/runtime/paragraph-style-resolver.ts +194 -0
  38. package/src/runtime/render/render-kernel.ts +5 -1
  39. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  40. package/src/runtime/surface-projection.ts +129 -9
  41. package/src/runtime/table-schema.ts +11 -0
  42. package/src/ui/WordReviewEditor.tsx +285 -5
  43. package/src/ui/editor-command-bag.ts +4 -0
  44. package/src/ui/editor-runtime-boundary.ts +16 -0
  45. package/src/ui/editor-shell-view.tsx +4 -0
  46. package/src/ui/editor-surface-controller.tsx +9 -1
  47. package/src/ui/headless/chrome-registry.ts +34 -5
  48. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  49. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  50. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  51. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  52. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  53. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  54. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  55. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  56. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  57. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  58. package/src/ui-tailwind/chrome-overlay/index.ts +0 -6
  59. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +27 -17
  60. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  61. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  62. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  63. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  64. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  65. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  66. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  67. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  68. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  69. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  70. package/src/ui-tailwind/index.ts +1 -5
  71. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  72. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  73. package/src/ui-tailwind/tw-review-workspace.tsx +132 -54
  74. package/src/runtime/collab-review-sync.ts +0 -254
  75. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  76. 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
+ }