@beyondwork/docx-react-component 1.0.42 → 1.0.43

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 (51) hide show
  1. package/package.json +30 -41
  2. package/src/api/editor-state-types.ts +110 -0
  3. package/src/api/public-types.ts +194 -1
  4. package/src/core/commands/index.ts +33 -8
  5. package/src/core/search/search-text.ts +15 -2
  6. package/src/index.ts +13 -0
  7. package/src/io/docx-session.ts +672 -2
  8. package/src/io/load-scheduler.ts +230 -0
  9. package/src/io/normalize/normalize-text.ts +83 -0
  10. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  11. package/src/io/ooxml/workflow-payload.ts +172 -1
  12. package/src/runtime/collab-session.ts +1 -1
  13. package/src/runtime/document-runtime.ts +364 -36
  14. package/src/runtime/editor-state-channel.ts +544 -0
  15. package/src/runtime/editor-state-integration.ts +217 -0
  16. package/src/runtime/layout/index.ts +2 -0
  17. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  18. package/src/runtime/layout/layout-engine-instance.ts +17 -2
  19. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  20. package/src/runtime/layout/public-facet.ts +400 -1
  21. package/src/runtime/perf-counters.ts +28 -0
  22. package/src/runtime/render/render-frame-types.ts +17 -0
  23. package/src/runtime/render/render-kernel.ts +172 -29
  24. package/src/runtime/surface-projection.ts +10 -5
  25. package/src/runtime/workflow-markup.ts +71 -16
  26. package/src/ui/WordReviewEditor.tsx +67 -45
  27. package/src/ui/editor-command-bag.ts +14 -0
  28. package/src/ui/editor-runtime-boundary.ts +110 -11
  29. package/src/ui/editor-shell-view.tsx +10 -0
  30. package/src/ui/editor-surface-controller.tsx +5 -0
  31. package/src/ui/headless/selection-helpers.ts +10 -0
  32. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  33. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  34. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  35. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  36. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  37. package/src/ui-tailwind/editor-surface/pm-schema.ts +152 -4
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  39. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  40. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  42. package/src/ui-tailwind/index.ts +5 -1
  43. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  44. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  45. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  46. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  47. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  48. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  49. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  50. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  51. package/src/ui-tailwind/tw-review-workspace.tsx +172 -94
@@ -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 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}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,
@@ -188,6 +188,11 @@ export interface TwProseMirrorSurfaceProps {
188
188
  onUndo?: () => void;
189
189
  onRedo?: () => void;
190
190
  onBlockedInput?: (command: "paste" | "drop", message: string) => void;
191
+ onPasteApplied?: (meta: {
192
+ segmentCount: number;
193
+ charCount: number;
194
+ source: "paste" | "drop";
195
+ }) => void;
191
196
  onCommentActivated?: (commentId: string) => void;
192
197
  onRevisionActivated?: (revisionId: string) => void;
193
198
  onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
@@ -321,6 +326,9 @@ export const TwProseMirrorSurface = forwardRef<
321
326
  onBlockedInput: (command, message) => {
322
327
  props.onBlockedInput?.(command, message);
323
328
  },
329
+ onPasteApplied: (meta) => {
330
+ props.onPasteApplied?.(meta);
331
+ },
324
332
  onSelectionChange: (sel) => {
325
333
  pendingSelectionProbeRef.current = startPerfProbe("selection");
326
334
  props.onSelectionChange?.(
@@ -448,6 +456,7 @@ export const TwProseMirrorSurface = forwardRef<
448
456
  onUndo: () => callbacksRef.current?.onUndo(),
449
457
  onRedo: () => callbacksRef.current?.onRedo(),
450
458
  onBlockedInput: (command, message) => callbacksRef.current?.onBlockedInput?.(command, message),
459
+ onPasteApplied: (meta) => callbacksRef.current?.onPasteApplied?.(meta),
451
460
  onCompositionChange: (composing) => {
452
461
  sessionRef.current?.setComposing(composing);
453
462
  },
@@ -11,7 +11,11 @@ export { renderTwCaret } from "./editor-surface/tw-caret";
11
11
 
12
12
  // Review rail
13
13
  export { TwReviewRail, type TwReviewRailProps, type ReviewRailTab } from "./review/tw-review-rail";
14
- export { TwCommentSidebar } from "./review/tw-comment-sidebar";
14
+ export { TwCommentSidebar, type TwCommentSidebarProps } from "./review/tw-comment-sidebar";
15
+ export {
16
+ CommentMarkdownRenderer,
17
+ type CommentMarkdownRendererProps,
18
+ } from "./review/comment-markdown-renderer";
15
19
  export { TwRevisionSidebar } from "./review/tw-revision-sidebar";
16
20
  export { TwHealthPanel } from "./review/tw-health-panel";
17
21
  export { TwWorkflowTab, type TwWorkflowTabProps } from "./review/tw-workflow-tab";
@@ -0,0 +1,57 @@
1
+ import React from "react";
2
+
3
+ import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // TwEndnoteArea (P8.7)
8
+ //
9
+ // Read-only area for document-end endnote placement. Unlike footnotes, which
10
+ // the chrome layer positions per-page via an absolute rectangle, endnotes in
11
+ // the default mode sit at the end of the document (Word's `w:pos="docEnd"`
12
+ // behavior). This component is mounted as a sibling of the chrome layer
13
+ // after the last page rect — not as an absolutely positioned chrome overlay.
14
+ //
15
+ // It renders:
16
+ // 1. A default 1px separator (1/3 of the parent width) along the top edge,
17
+ // and
18
+ // 2. The ordered endnote bodies via `TwRegionBlockRenderer` (P8.4).
19
+ //
20
+ // When `blocks` is empty the component returns `null` so the document-end
21
+ // slot stays visually absent.
22
+ //
23
+ // Per-section endnote placement (`w:endnotePr/w:pos` other than `docEnd`)
24
+ // is a follow-up — tracked with the P8.b polish pass.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface TwEndnoteAreaProps {
28
+ blocks: readonly SurfaceBlockSnapshot[];
29
+ "data-testid"?: string;
30
+ }
31
+
32
+ export const TwEndnoteArea: React.FC<TwEndnoteAreaProps> = ({
33
+ blocks,
34
+ "data-testid": testId,
35
+ }) => {
36
+ if (blocks.length === 0) return null;
37
+ return (
38
+ <div
39
+ data-endnote-area
40
+ data-testid={testId}
41
+ style={{ marginTop: "24pt" }}
42
+ >
43
+ <div
44
+ data-endnote-separator
45
+ style={{
46
+ width: "33%",
47
+ height: "1px",
48
+ backgroundColor: "currentColor",
49
+ marginBottom: "8pt",
50
+ }}
51
+ />
52
+ <TwRegionBlockRenderer blocks={blocks} />
53
+ </div>
54
+ );
55
+ };
56
+
57
+ export default TwEndnoteArea;