@beyondwork/docx-react-component 1.0.42 → 1.0.45
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/README.md +17 -0
- package/package.json +5 -4
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +333 -4
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +60 -10
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/search/search-text.ts +15 -2
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +29 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +692 -2
- package/src/io/export/build-app-properties-xml.ts +1 -1
- package/src/io/export/serialize-comments.ts +38 -9
- package/src/io/export/twip.ts +1 -1
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +116 -0
- package/src/io/ooxml/parse-comments.ts +0 -33
- package/src/io/ooxml/parse-complex-content.ts +14 -0
- package/src/io/ooxml/parse-main-document.ts +4 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +661 -42
- package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
- package/src/runtime/edit-dispatch/index.ts +2 -0
- package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/editor-surface/capabilities.ts +411 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +63 -2
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +430 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/prerender/cache-envelope.ts +29 -0
- package/src/runtime/prerender/cache-key.ts +66 -0
- package/src/runtime/prerender/font-fingerprint.ts +17 -0
- package/src/runtime/prerender/graph-canonicalize.ts +121 -0
- package/src/runtime/prerender/indexeddb-cache.ts +184 -0
- package/src/runtime/prerender/prerender-document.ts +145 -0
- package/src/runtime/render/block-fragment-projection.ts +2 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +45 -7
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +142 -237
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +115 -12
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
- package/src/ui-tailwind/index.ts +5 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
|
@@ -268,7 +268,7 @@ function buildPMBlocks(
|
|
|
268
268
|
} else if (block.kind === "sdt_block") {
|
|
269
269
|
nodes.push(buildSdtBlock(block, mediaPreviews, showUnsupportedObjectPreviews));
|
|
270
270
|
} else {
|
|
271
|
-
nodes.push(buildOpaqueBlock(block));
|
|
271
|
+
nodes.push(buildOpaqueBlock(block, showUnsupportedObjectPreviews));
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
@@ -366,7 +366,13 @@ function buildParagraph(
|
|
|
366
366
|
indentRight: paragraphLayout.indentation?.right ?? null,
|
|
367
367
|
indentFirstLine: paragraphLayout.indentation?.firstLine ?? null,
|
|
368
368
|
indentHanging: paragraphLayout.indentation?.hanging ?? null,
|
|
369
|
-
numberingMarkerWidth:
|
|
369
|
+
numberingMarkerWidth:
|
|
370
|
+
paragraphLayout.markerLane?.width ??
|
|
371
|
+
paragraphLayout.indentation?.hanging ??
|
|
372
|
+
(paragraphLayout.indentation?.firstLine !== undefined &&
|
|
373
|
+
paragraphLayout.indentation.firstLine < 0
|
|
374
|
+
? Math.abs(paragraphLayout.indentation.firstLine)
|
|
375
|
+
: null),
|
|
370
376
|
numberingMarkerJustification: paragraphLayout.markerJustification ?? null,
|
|
371
377
|
numberingMarkerRunProperties: block.resolvedNumbering?.markerRunProperties ?? null,
|
|
372
378
|
shadingFill: block.shading?.fill ?? cascade?.shading?.fill ?? null,
|
|
@@ -453,7 +459,7 @@ function buildInlineContent(
|
|
|
453
459
|
}
|
|
454
460
|
|
|
455
461
|
case "opaque_inline":
|
|
456
|
-
return [buildOpaqueInlineOrComplexAtom(segment, showUnsupportedObjectPreviews)];
|
|
462
|
+
return [buildOpaqueInlineOrComplexAtom(segment, mediaPreviews, showUnsupportedObjectPreviews)];
|
|
457
463
|
|
|
458
464
|
case "note_ref": {
|
|
459
465
|
const text = editorSchema.text(
|
|
@@ -602,6 +608,7 @@ const UNSUPPORTED_COMPLEX_PREVIEW_LABELS = new Set<string>([
|
|
|
602
608
|
*/
|
|
603
609
|
function buildOpaqueInlineOrComplexAtom(
|
|
604
610
|
segment: Extract<import("../../api/public-types").SurfaceInlineSegment, { kind: "opaque_inline" }>,
|
|
611
|
+
mediaPreviews: Record<string, MediaPreviewDescriptor>,
|
|
605
612
|
showUnsupportedObjectPreviews: boolean,
|
|
606
613
|
): PMNode {
|
|
607
614
|
const label = segment.label;
|
|
@@ -618,11 +625,30 @@ function buildOpaqueInlineOrComplexAtom(
|
|
|
618
625
|
});
|
|
619
626
|
}
|
|
620
627
|
|
|
621
|
-
|
|
622
|
-
|
|
628
|
+
// Bitmap-backed complex objects always upgrade to the typed atom so the
|
|
629
|
+
// reviewer sees Word's own cached rendering regardless of the debug-preview
|
|
630
|
+
// flag. The flag still gates the badge-only path for shape/wordart/vml
|
|
631
|
+
// families (below) which are decoration-weight. Chart and SmartArt are
|
|
632
|
+
// *always* rendered as typed atoms regardless of flag — a silent quiet
|
|
633
|
+
// marker over a chart leaves the reviewer with no signal that data is
|
|
634
|
+
// missing, which is worse than the small cost of an always-visible chip.
|
|
635
|
+
const previewSrc = segment.previewMediaId
|
|
636
|
+
? mediaPreviews[segment.previewMediaId]?.src ?? null
|
|
637
|
+
: null;
|
|
638
|
+
|
|
639
|
+
if (label === "Embedded chart") {
|
|
640
|
+
return editorSchema.nodes.chart_atom.create({
|
|
641
|
+
previewMediaId: segment.previewMediaId ?? null,
|
|
642
|
+
previewSrc,
|
|
643
|
+
detail,
|
|
644
|
+
});
|
|
623
645
|
}
|
|
624
|
-
if (
|
|
625
|
-
return editorSchema.nodes.smartart_atom.create({
|
|
646
|
+
if (label === "SmartArt diagram") {
|
|
647
|
+
return editorSchema.nodes.smartart_atom.create({
|
|
648
|
+
previewMediaId: segment.previewMediaId ?? null,
|
|
649
|
+
previewSrc,
|
|
650
|
+
detail,
|
|
651
|
+
});
|
|
626
652
|
}
|
|
627
653
|
if (showUnsupportedObjectPreviews && (label === "Drawing shape" || label === "Text box")) {
|
|
628
654
|
const textMatch = /(?:Text content|Content): "([^"]+)"/.exec(detail);
|
|
@@ -672,12 +698,37 @@ function buildOpaqueInlineOrComplexAtom(
|
|
|
672
698
|
|
|
673
699
|
function buildOpaqueBlock(
|
|
674
700
|
block: Extract<SurfaceBlockSnapshot, { kind: "opaque_block" }>,
|
|
701
|
+
showUnsupportedObjectPreviews: boolean,
|
|
675
702
|
): PMNode {
|
|
703
|
+
// Viewport-culled placeholder: emit a single paragraph with ZWSP text so
|
|
704
|
+
// PM position math matches the original block span.
|
|
705
|
+
// See docs/plans/lane-2-render-performance.md Task 2.2.3.
|
|
706
|
+
const placeholderSize = block.placeholderSize ?? null;
|
|
707
|
+
if (placeholderSize !== null) {
|
|
708
|
+
const targetSize = placeholderSize as number;
|
|
709
|
+
if (targetSize <= 2) {
|
|
710
|
+
// Edge case: bare empty paragraph claims exactly 2 positions.
|
|
711
|
+
return editorSchema.nodes.paragraph.create(
|
|
712
|
+
{ blockId: block.blockId, placeholderCulled: true },
|
|
713
|
+
Fragment.empty,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
// General case: one paragraph with (targetSize - 2) ZWSP chars so the
|
|
717
|
+
// total PM positions = 1 (open) + (targetSize - 2) (text) + 1 (close) = targetSize.
|
|
718
|
+
const filler = "\u200b".repeat(targetSize - 2);
|
|
719
|
+
return editorSchema.nodes.paragraph.create(
|
|
720
|
+
{ blockId: block.blockId, placeholderCulled: true },
|
|
721
|
+
editorSchema.text(filler),
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Real (non-placeholder) opaque_block: existing behaviour unchanged.
|
|
676
726
|
return editorSchema.nodes.opaque_block.create({
|
|
677
727
|
fragmentId: block.fragmentId,
|
|
678
728
|
warningId: block.warningId,
|
|
679
729
|
label: block.label,
|
|
680
730
|
detail: block.detail,
|
|
731
|
+
presentation: showUnsupportedObjectPreviews ? "callout" : "quiet-marker",
|
|
681
732
|
});
|
|
682
733
|
}
|
|
683
734
|
|
|
@@ -23,6 +23,7 @@ export function createSurfaceDocumentBuildKey(input: {
|
|
|
23
23
|
mediaPreviewKey: string;
|
|
24
24
|
showUnsupportedObjectPreviews?: boolean;
|
|
25
25
|
}): string {
|
|
26
|
+
const vp = input.surface?.viewportBlockRange ?? null;
|
|
26
27
|
return JSON.stringify({
|
|
27
28
|
surfaceIdentity:
|
|
28
29
|
input.surface === undefined || input.surface === null
|
|
@@ -31,6 +32,7 @@ export function createSurfaceDocumentBuildKey(input: {
|
|
|
31
32
|
activeStory: input.activeStory,
|
|
32
33
|
mediaPreviewKey: input.mediaPreviewKey,
|
|
33
34
|
showUnsupportedObjectPreviews: input.showUnsupportedObjectPreviews ?? false,
|
|
35
|
+
viewport: vp ? `${vp.start}:${vp.end}` : "full",
|
|
34
36
|
});
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
SurfaceBlockSnapshot,
|
|
5
|
+
SurfaceTextMark,
|
|
6
|
+
} from "../../api/public-types.ts";
|
|
7
|
+
import type { CanonicalRunFormatting } from "../../model/canonical-document.ts";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Pure helpers shared by `tw-page-block-view` (body) and `tw-region-block-
|
|
11
|
+
// renderer` (header / footer / footnote / endnote bands). Extracted in P8.4
|
|
12
|
+
// so per-page regions reuse body typography verbatim — indent/margin/line-
|
|
13
|
+
// height/marker geometry stay identical across all regions.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const HEX_COLOR_RE = /^[0-9A-Fa-f]{3,8}$/;
|
|
17
|
+
export const SAFE_FONT_RE = /^[A-Za-z0-9 ,\-'"]+$/;
|
|
18
|
+
export const SAFE_ALIGNMENT = new Set([
|
|
19
|
+
"left",
|
|
20
|
+
"center",
|
|
21
|
+
"right",
|
|
22
|
+
"justify",
|
|
23
|
+
"start",
|
|
24
|
+
"end",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export function safeHexColor(raw: string | null | undefined): string | null {
|
|
28
|
+
if (!raw || raw === "auto") return null;
|
|
29
|
+
return HEX_COLOR_RE.test(raw) ? `#${raw}` : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolve heading level from styleId or outlineLevel (outlineLevel 0 = Heading 1). */
|
|
33
|
+
export function resolveHeadingLevel(
|
|
34
|
+
styleId?: string,
|
|
35
|
+
outlineLevel?: number,
|
|
36
|
+
): number | null {
|
|
37
|
+
if (styleId) {
|
|
38
|
+
const normalized = styleId.toLowerCase();
|
|
39
|
+
const compact = normalized.replace(/[\s_-]+/g, "");
|
|
40
|
+
const headingMatch = /^heading([1-6])$/.exec(compact);
|
|
41
|
+
if (headingMatch) {
|
|
42
|
+
return Number.parseInt(headingMatch[1], 10);
|
|
43
|
+
}
|
|
44
|
+
if (compact === "title") return 1;
|
|
45
|
+
if (compact === "subtitle") return 2;
|
|
46
|
+
if (compact === "tocheading") return 1;
|
|
47
|
+
if (/^(appendix|schedule|annex|exhibit|attachment)(heading|title)$/.test(compact)) return 2;
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
typeof outlineLevel === "number" &&
|
|
51
|
+
Number.isInteger(outlineLevel) &&
|
|
52
|
+
outlineLevel >= 0 &&
|
|
53
|
+
outlineLevel <= 5
|
|
54
|
+
) {
|
|
55
|
+
return outlineLevel + 1;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveMarkerJustificationCss(raw: string | undefined): string {
|
|
61
|
+
switch (raw) {
|
|
62
|
+
case "left":
|
|
63
|
+
return "flex-start";
|
|
64
|
+
case "center":
|
|
65
|
+
return "center";
|
|
66
|
+
case "right":
|
|
67
|
+
case "both":
|
|
68
|
+
case "distribute":
|
|
69
|
+
default:
|
|
70
|
+
return "flex-end";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build CSSProperties for a paragraph block from spacing/indent/alignment. */
|
|
75
|
+
export function buildParagraphStyle(
|
|
76
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
77
|
+
): React.CSSProperties {
|
|
78
|
+
const style: React.CSSProperties = {};
|
|
79
|
+
|
|
80
|
+
// Alignment — direct takes precedence over resolvedParagraphFormatting
|
|
81
|
+
const rawAlignment = block.alignment ?? block.resolvedParagraphFormatting?.alignment;
|
|
82
|
+
const safeAlign = rawAlignment === "both" ? "justify" : rawAlignment;
|
|
83
|
+
if (safeAlign && SAFE_ALIGNMENT.has(safeAlign)) {
|
|
84
|
+
style.textAlign = safeAlign as React.CSSProperties["textAlign"];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Spacing
|
|
88
|
+
const spacingBefore =
|
|
89
|
+
block.spacing?.before ?? block.resolvedParagraphFormatting?.spacing?.before;
|
|
90
|
+
const spacingAfter =
|
|
91
|
+
block.spacing?.after ?? block.resolvedParagraphFormatting?.spacing?.after;
|
|
92
|
+
const lineSpacing =
|
|
93
|
+
block.spacing?.line ?? block.resolvedParagraphFormatting?.spacing?.line;
|
|
94
|
+
const lineRule =
|
|
95
|
+
block.spacing?.lineRule ?? block.resolvedParagraphFormatting?.spacing?.lineRule;
|
|
96
|
+
|
|
97
|
+
if (spacingBefore != null) {
|
|
98
|
+
style.marginTop = `${spacingBefore / 20}pt`;
|
|
99
|
+
}
|
|
100
|
+
if (spacingAfter != null) {
|
|
101
|
+
style.marginBottom = `${spacingAfter / 20}pt`;
|
|
102
|
+
}
|
|
103
|
+
if (lineSpacing && lineRule === "auto") {
|
|
104
|
+
style.lineHeight = String(lineSpacing / 240);
|
|
105
|
+
} else if (lineSpacing && lineRule === "exact") {
|
|
106
|
+
style.lineHeight = `${lineSpacing / 20}pt`;
|
|
107
|
+
} else if (lineSpacing && lineRule === "atLeast") {
|
|
108
|
+
style.minHeight = `${lineSpacing / 20}pt`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Indentation
|
|
112
|
+
const indentLeft =
|
|
113
|
+
block.indentation?.left ?? block.resolvedParagraphFormatting?.indentation?.left;
|
|
114
|
+
const indentRight =
|
|
115
|
+
block.indentation?.right ?? block.resolvedParagraphFormatting?.indentation?.right;
|
|
116
|
+
const indentFirstLine =
|
|
117
|
+
block.indentation?.firstLine ?? block.resolvedParagraphFormatting?.indentation?.firstLine;
|
|
118
|
+
const indentHanging =
|
|
119
|
+
block.indentation?.hanging ?? block.resolvedParagraphFormatting?.indentation?.hanging;
|
|
120
|
+
|
|
121
|
+
if (indentLeft) style.paddingLeft = `${indentLeft / 20}pt`;
|
|
122
|
+
if (indentRight) style.paddingRight = `${indentRight / 20}pt`;
|
|
123
|
+
if (indentHanging) style.textIndent = `-${indentHanging / 20}pt`;
|
|
124
|
+
else if (indentFirstLine) style.textIndent = `${indentFirstLine / 20}pt`;
|
|
125
|
+
|
|
126
|
+
// Shading
|
|
127
|
+
const shadingFill = block.shading?.fill;
|
|
128
|
+
const shadingColor = safeHexColor(shadingFill);
|
|
129
|
+
if (shadingColor) style.backgroundColor = shadingColor;
|
|
130
|
+
|
|
131
|
+
// Page break visual indicator
|
|
132
|
+
if (block.pageBreakBefore) {
|
|
133
|
+
style.borderTop = "2px dashed rgba(0,0,0,0.1)";
|
|
134
|
+
style.paddingTop = "8px";
|
|
135
|
+
style.marginTop = "16px";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return style;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Build CSSProperties for the numbering marker span. */
|
|
142
|
+
export function buildMarkerStyle(
|
|
143
|
+
prefix: string,
|
|
144
|
+
suffix: "tab" | "space" | "nothing" | undefined,
|
|
145
|
+
markerRunProperties: CanonicalRunFormatting | undefined,
|
|
146
|
+
markerWidth: number | undefined,
|
|
147
|
+
markerJustification: string | undefined,
|
|
148
|
+
): React.CSSProperties {
|
|
149
|
+
const style: React.CSSProperties = {
|
|
150
|
+
fontVariantNumeric: "tabular-nums",
|
|
151
|
+
justifyContent: resolveMarkerJustificationCss(markerJustification),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (markerRunProperties) {
|
|
155
|
+
if (markerRunProperties.bold) style.fontWeight = "bold";
|
|
156
|
+
if (markerRunProperties.italic) style.fontStyle = "italic";
|
|
157
|
+
if (markerRunProperties.underline && markerRunProperties.underline !== "none") {
|
|
158
|
+
style.textDecoration = "underline";
|
|
159
|
+
}
|
|
160
|
+
if (typeof markerRunProperties.fontSizeHalfPoints === "number") {
|
|
161
|
+
style.fontSize = `${markerRunProperties.fontSizeHalfPoints / 2}pt`;
|
|
162
|
+
}
|
|
163
|
+
const colorHex = markerRunProperties.colorHex;
|
|
164
|
+
if (colorHex && colorHex !== "auto") {
|
|
165
|
+
style.color = `#${colorHex.toLowerCase()}`;
|
|
166
|
+
}
|
|
167
|
+
const family = markerRunProperties.fontFamilyAscii ?? markerRunProperties.fontFamily;
|
|
168
|
+
if (family && SAFE_FONT_RE.test(family)) {
|
|
169
|
+
style.fontFamily = family;
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
style.color = "var(--color-text-tertiary)";
|
|
173
|
+
style.fontFamily = "var(--font-legal-sans)";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const hasResolvedMarkerWidth = typeof markerWidth === "number" && markerWidth > 0;
|
|
177
|
+
if (hasResolvedMarkerWidth) {
|
|
178
|
+
// P13.a: emit marker geometry in pt so it self-scales under CSS `zoom`.
|
|
179
|
+
const markerWidthPt = Math.max(1, markerWidth! / 20);
|
|
180
|
+
style.width = `${markerWidthPt}pt`;
|
|
181
|
+
style.minWidth = `${markerWidthPt}pt`;
|
|
182
|
+
style.flexBasis = `${markerWidthPt}pt`;
|
|
183
|
+
style.marginRight = 0;
|
|
184
|
+
style.overflow = "visible";
|
|
185
|
+
} else {
|
|
186
|
+
const fallbackMinWidth = Math.min(Math.max(prefix.length + 1, 4), 14);
|
|
187
|
+
const fallbackMarginRight =
|
|
188
|
+
suffix === "nothing" ? "0.25rem" : suffix === "space" ? "0.5rem" : "0.75rem";
|
|
189
|
+
style.minWidth = `${fallbackMinWidth}ch`;
|
|
190
|
+
style.marginRight = fallbackMarginRight;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return style;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Build CSSProperties for a text segment from marks and markAttrs. */
|
|
197
|
+
export function buildSegmentStyle(
|
|
198
|
+
marks: SurfaceTextMark[] | undefined,
|
|
199
|
+
markAttrs?: {
|
|
200
|
+
fontSize?: number;
|
|
201
|
+
textColor?: string;
|
|
202
|
+
fontFamily?: string;
|
|
203
|
+
backgroundColor?: string;
|
|
204
|
+
charSpacing?: number;
|
|
205
|
+
},
|
|
206
|
+
): React.CSSProperties {
|
|
207
|
+
const style: React.CSSProperties = {};
|
|
208
|
+
|
|
209
|
+
if (marks) {
|
|
210
|
+
if (marks.includes("bold")) style.fontWeight = "bold";
|
|
211
|
+
if (marks.includes("italic")) style.fontStyle = "italic";
|
|
212
|
+
if (marks.includes("underline")) style.textDecoration = "underline";
|
|
213
|
+
if (marks.includes("strikethrough") || marks.includes("doubleStrikethrough")) {
|
|
214
|
+
style.textDecoration = marks.includes("underline")
|
|
215
|
+
? "underline line-through"
|
|
216
|
+
: "line-through";
|
|
217
|
+
}
|
|
218
|
+
if (marks.includes("superscript")) {
|
|
219
|
+
style.verticalAlign = "super";
|
|
220
|
+
style.fontSize = "smaller";
|
|
221
|
+
}
|
|
222
|
+
if (marks.includes("subscript")) {
|
|
223
|
+
style.verticalAlign = "sub";
|
|
224
|
+
style.fontSize = "smaller";
|
|
225
|
+
}
|
|
226
|
+
if (marks.includes("allCaps")) style.textTransform = "uppercase";
|
|
227
|
+
if (marks.includes("smallCaps")) style.fontVariant = "small-caps";
|
|
228
|
+
if (marks.includes("vanish")) style.display = "none";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (markAttrs) {
|
|
232
|
+
if (markAttrs.fontSize) style.fontSize = `${markAttrs.fontSize}pt`;
|
|
233
|
+
if (markAttrs.textColor) style.color = markAttrs.textColor;
|
|
234
|
+
if (markAttrs.fontFamily && SAFE_FONT_RE.test(markAttrs.fontFamily)) {
|
|
235
|
+
style.fontFamily = markAttrs.fontFamily;
|
|
236
|
+
}
|
|
237
|
+
if (markAttrs.backgroundColor) style.backgroundColor = markAttrs.backgroundColor;
|
|
238
|
+
if (markAttrs.charSpacing) style.letterSpacing = `${markAttrs.charSpacing}px`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return style;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function hasStyleEntries(style: React.CSSProperties): boolean {
|
|
245
|
+
return Object.keys(style).length > 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function headingClassList(level: number): string[] {
|
|
249
|
+
switch (level) {
|
|
250
|
+
case 1:
|
|
251
|
+
return ["text-3xl", "font-semibold", "tracking-tight", "leading-tight"];
|
|
252
|
+
case 2:
|
|
253
|
+
return ["text-2xl", "font-semibold", "tracking-tight"];
|
|
254
|
+
case 3:
|
|
255
|
+
return ["text-xl", "font-medium"];
|
|
256
|
+
case 4:
|
|
257
|
+
return ["text-lg", "font-medium"];
|
|
258
|
+
case 5:
|
|
259
|
+
return ["text-base", "font-semibold", "uppercase", "tracking-[0.12em]"];
|
|
260
|
+
case 6:
|
|
261
|
+
return ["text-sm", "font-semibold", "uppercase", "tracking-[0.16em]"];
|
|
262
|
+
default:
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -3,243 +3,15 @@ import React from "react";
|
|
|
3
3
|
import type {
|
|
4
4
|
SurfaceBlockSnapshot,
|
|
5
5
|
SurfaceInlineSegment,
|
|
6
|
-
SurfaceTextMark,
|
|
7
6
|
} from "../../api/public-types.ts";
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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}pt`;
|
|
95
|
-
}
|
|
96
|
-
if (spacingAfter != null) {
|
|
97
|
-
style.marginBottom = `${spacingAfter / 20}pt`;
|
|
98
|
-
}
|
|
99
|
-
if (lineSpacing && lineRule === "auto") {
|
|
100
|
-
style.lineHeight = String(lineSpacing / 240);
|
|
101
|
-
} else if (lineSpacing && lineRule === "exact") {
|
|
102
|
-
style.lineHeight = `${lineSpacing / 20}pt`;
|
|
103
|
-
} else if (lineSpacing && lineRule === "atLeast") {
|
|
104
|
-
style.minHeight = `${lineSpacing / 20}pt`;
|
|
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}pt`;
|
|
118
|
-
if (indentRight) style.paddingRight = `${indentRight / 20}pt`;
|
|
119
|
-
if (indentHanging) style.textIndent = `-${indentHanging / 20}pt`;
|
|
120
|
-
else if (indentFirstLine) style.textIndent = `${indentFirstLine / 20}pt`;
|
|
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
|
-
// P13.a: emit marker geometry in pt so it self-scales under CSS `zoom`.
|
|
175
|
-
const markerWidthPt = Math.max(1, markerWidth! / 20);
|
|
176
|
-
style.width = `${markerWidthPt}pt`;
|
|
177
|
-
style.minWidth = `${markerWidthPt}pt`;
|
|
178
|
-
style.flexBasis = `${markerWidthPt}pt`;
|
|
179
|
-
style.marginRight = 0;
|
|
180
|
-
style.overflow = "visible";
|
|
181
|
-
} else {
|
|
182
|
-
const fallbackMinWidth = Math.min(Math.max(prefix.length + 1, 4), 14);
|
|
183
|
-
const fallbackMarginRight =
|
|
184
|
-
suffix === "nothing" ? "0.25rem" : suffix === "space" ? "0.5rem" : "0.75rem";
|
|
185
|
-
style.minWidth = `${fallbackMinWidth}ch`;
|
|
186
|
-
style.marginRight = fallbackMarginRight;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return style;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Build CSSProperties for a text segment from marks and markAttrs. */
|
|
193
|
-
function buildSegmentStyle(
|
|
194
|
-
marks: SurfaceTextMark[] | undefined,
|
|
195
|
-
markAttrs?: {
|
|
196
|
-
fontSize?: number;
|
|
197
|
-
textColor?: string;
|
|
198
|
-
fontFamily?: string;
|
|
199
|
-
backgroundColor?: string;
|
|
200
|
-
charSpacing?: number;
|
|
201
|
-
},
|
|
202
|
-
): React.CSSProperties {
|
|
203
|
-
const style: React.CSSProperties = {};
|
|
204
|
-
|
|
205
|
-
if (marks) {
|
|
206
|
-
if (marks.includes("bold")) style.fontWeight = "bold";
|
|
207
|
-
if (marks.includes("italic")) style.fontStyle = "italic";
|
|
208
|
-
if (marks.includes("underline")) style.textDecoration = "underline";
|
|
209
|
-
if (marks.includes("strikethrough") || marks.includes("doubleStrikethrough")) {
|
|
210
|
-
style.textDecoration = marks.includes("underline")
|
|
211
|
-
? "underline line-through"
|
|
212
|
-
: "line-through";
|
|
213
|
-
}
|
|
214
|
-
if (marks.includes("superscript")) {
|
|
215
|
-
style.verticalAlign = "super";
|
|
216
|
-
style.fontSize = "smaller";
|
|
217
|
-
}
|
|
218
|
-
if (marks.includes("subscript")) {
|
|
219
|
-
style.verticalAlign = "sub";
|
|
220
|
-
style.fontSize = "smaller";
|
|
221
|
-
}
|
|
222
|
-
if (marks.includes("allCaps")) style.textTransform = "uppercase";
|
|
223
|
-
if (marks.includes("smallCaps")) style.fontVariant = "small-caps";
|
|
224
|
-
if (marks.includes("vanish")) style.display = "none";
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (markAttrs) {
|
|
228
|
-
if (markAttrs.fontSize) style.fontSize = `${markAttrs.fontSize}pt`;
|
|
229
|
-
if (markAttrs.textColor) style.color = markAttrs.textColor;
|
|
230
|
-
if (markAttrs.fontFamily && SAFE_FONT_RE.test(markAttrs.fontFamily)) {
|
|
231
|
-
style.fontFamily = markAttrs.fontFamily;
|
|
232
|
-
}
|
|
233
|
-
if (markAttrs.backgroundColor) style.backgroundColor = markAttrs.backgroundColor;
|
|
234
|
-
if (markAttrs.charSpacing) style.letterSpacing = `${markAttrs.charSpacing}px`;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return style;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function hasStyleEntries(style: React.CSSProperties): boolean {
|
|
241
|
-
return Object.keys(style).length > 0;
|
|
242
|
-
}
|
|
7
|
+
import {
|
|
8
|
+
buildMarkerStyle,
|
|
9
|
+
buildParagraphStyle,
|
|
10
|
+
buildSegmentStyle,
|
|
11
|
+
hasStyleEntries,
|
|
12
|
+
headingClassList,
|
|
13
|
+
resolveHeadingLevel,
|
|
14
|
+
} from "./tw-page-block-view.helpers.ts";
|
|
243
15
|
|
|
244
16
|
// ---------------------------------------------------------------------------
|
|
245
17
|
// Segment renderer
|
|
@@ -409,25 +181,6 @@ function ParagraphBlock({
|
|
|
409
181
|
);
|
|
410
182
|
}
|
|
411
183
|
|
|
412
|
-
function headingClassList(level: number): string[] {
|
|
413
|
-
switch (level) {
|
|
414
|
-
case 1:
|
|
415
|
-
return ["text-3xl", "font-semibold", "tracking-tight", "leading-tight"];
|
|
416
|
-
case 2:
|
|
417
|
-
return ["text-2xl", "font-semibold", "tracking-tight"];
|
|
418
|
-
case 3:
|
|
419
|
-
return ["text-xl", "font-medium"];
|
|
420
|
-
case 4:
|
|
421
|
-
return ["text-lg", "font-medium"];
|
|
422
|
-
case 5:
|
|
423
|
-
return ["text-base", "font-semibold", "uppercase", "tracking-[0.12em]"];
|
|
424
|
-
case 6:
|
|
425
|
-
return ["text-sm", "font-semibold", "uppercase", "tracking-[0.16em]"];
|
|
426
|
-
default:
|
|
427
|
-
return [];
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
184
|
/** Render a table block as <table>. */
|
|
432
185
|
function TableBlock({
|
|
433
186
|
block,
|