@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.
Files changed (82) hide show
  1. package/README.md +17 -0
  2. package/package.json +5 -4
  3. package/src/api/editor-state-types.ts +110 -0
  4. package/src/api/public-types.ts +333 -4
  5. package/src/core/commands/formatting-commands.ts +7 -1
  6. package/src/core/commands/index.ts +60 -10
  7. package/src/core/commands/text-commands.ts +59 -0
  8. package/src/core/search/search-text.ts +15 -2
  9. package/src/core/selection/review-anchors.ts +131 -21
  10. package/src/index.ts +29 -1
  11. package/src/io/chart-preview-resolver.ts +281 -0
  12. package/src/io/docx-session.ts +692 -2
  13. package/src/io/export/build-app-properties-xml.ts +1 -1
  14. package/src/io/export/serialize-comments.ts +38 -9
  15. package/src/io/export/twip.ts +1 -1
  16. package/src/io/load-scheduler.ts +230 -0
  17. package/src/io/normalize/normalize-text.ts +116 -0
  18. package/src/io/ooxml/parse-comments.ts +0 -33
  19. package/src/io/ooxml/parse-complex-content.ts +14 -0
  20. package/src/io/ooxml/parse-main-document.ts +4 -0
  21. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  22. package/src/io/ooxml/workflow-payload.ts +172 -1
  23. package/src/preservation/opaque-region.ts +5 -0
  24. package/src/review/store/comment-remapping.ts +2 -2
  25. package/src/runtime/collab-session.ts +1 -1
  26. package/src/runtime/document-runtime.ts +661 -42
  27. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  28. package/src/runtime/edit-dispatch/index.ts +2 -0
  29. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  30. package/src/runtime/editor-state-channel.ts +544 -0
  31. package/src/runtime/editor-state-integration.ts +217 -0
  32. package/src/runtime/editor-surface/capabilities.ts +411 -0
  33. package/src/runtime/layout/index.ts +2 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +63 -2
  36. package/src/runtime/layout/layout-engine-version.ts +41 -0
  37. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  38. package/src/runtime/layout/public-facet.ts +430 -1
  39. package/src/runtime/perf-counters.ts +28 -0
  40. package/src/runtime/prerender/cache-envelope.ts +29 -0
  41. package/src/runtime/prerender/cache-key.ts +66 -0
  42. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  43. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  44. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  45. package/src/runtime/prerender/prerender-document.ts +145 -0
  46. package/src/runtime/render/block-fragment-projection.ts +2 -0
  47. package/src/runtime/render/render-frame-types.ts +17 -0
  48. package/src/runtime/render/render-kernel.ts +172 -29
  49. package/src/runtime/selection/post-edit-validator.ts +77 -0
  50. package/src/runtime/surface-projection.ts +45 -7
  51. package/src/runtime/workflow-markup.ts +71 -16
  52. package/src/ui/WordReviewEditor.tsx +142 -237
  53. package/src/ui/editor-command-bag.ts +14 -0
  54. package/src/ui/editor-runtime-boundary.ts +115 -12
  55. package/src/ui/editor-shell-view.tsx +10 -0
  56. package/src/ui/editor-surface-controller.tsx +5 -0
  57. package/src/ui/headless/selection-helpers.ts +10 -0
  58. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  59. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  60. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  62. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  63. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  64. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  65. package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
  66. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
  67. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  68. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  69. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  70. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
  71. package/src/ui-tailwind/index.ts +5 -1
  72. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  73. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  74. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  75. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  76. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  77. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  78. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  79. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  80. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  81. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  82. package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
@@ -0,0 +1,374 @@
1
+ import React from "react";
2
+
3
+ import type {
4
+ SurfaceBlockSnapshot,
5
+ SurfaceInlineSegment,
6
+ } from "../../api/public-types.ts";
7
+ import {
8
+ buildMarkerStyle,
9
+ buildParagraphStyle,
10
+ buildSegmentStyle,
11
+ hasStyleEntries,
12
+ headingClassList,
13
+ resolveHeadingLevel,
14
+ } from "../editor-surface/tw-page-block-view.helpers.ts";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // TwRegionBlockRenderer (P8.4)
18
+ //
19
+ // Read-only React DOM renderer for `SurfaceBlockSnapshot[]`. Mounted by the
20
+ // per-page region bands — header, footer, footnote area, endnote area — so
21
+ // typography matches the body's `tw-page-block-view` output exactly.
22
+ //
23
+ // Uses the same pure helpers as `tw-page-block-view` (extracted into
24
+ // `tw-page-block-view.helpers.ts`) so indents, margins, line heights, and
25
+ // numbering-marker geometry stay identical across all regions.
26
+ //
27
+ // Unlike the body renderer this component:
28
+ // - Never mounts a PM view; it is pure presentational React.
29
+ // - Sets `contentEditable={false}` on its root.
30
+ // - Emits `data-block-kind` + `data-block-id` on every block element so
31
+ // downstream region diagnostics can introspect what rendered.
32
+ // - Emits a `<colgroup>` on every table (P6.a) so column widths come from
33
+ // the canonical `gridColumns`, not intrinsic cell measurement.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Inline segment renderer — mirrors `tw-page-block-view`'s `renderSegment`.
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
41
+ switch (seg.kind) {
42
+ case "text": {
43
+ const style = buildSegmentStyle(seg.marks, seg.markAttrs);
44
+ const content = seg.text;
45
+ if (!hasStyleEntries(style)) {
46
+ return <React.Fragment key={seg.segmentId}>{content}</React.Fragment>;
47
+ }
48
+ return (
49
+ <span key={seg.segmentId} style={style}>
50
+ {content}
51
+ </span>
52
+ );
53
+ }
54
+ case "tab":
55
+ return (
56
+ <span
57
+ key={seg.segmentId}
58
+ data-node-type="tab"
59
+ style={{ display: "inline-block", width: "32px", minWidth: "8px" }}
60
+ >
61
+ {"\u00A0"}
62
+ </span>
63
+ );
64
+ case "hard_break":
65
+ return <br key={seg.segmentId} />;
66
+ case "image":
67
+ return (
68
+ <span
69
+ key={seg.segmentId}
70
+ data-node-type="image"
71
+ style={{
72
+ display: "inline-block",
73
+ width: "48px",
74
+ height: "32px",
75
+ backgroundColor: "#e0e0e0",
76
+ verticalAlign: "middle",
77
+ margin: "0 4px",
78
+ borderRadius: "2px",
79
+ }}
80
+ title={seg.altText ?? "Image"}
81
+ />
82
+ );
83
+ case "field_ref":
84
+ return (
85
+ <span
86
+ key={seg.segmentId}
87
+ data-node-type="field_ref"
88
+ style={{ opacity: 0.6, fontSize: "0.85em" }}
89
+ >
90
+ [field]
91
+ </span>
92
+ );
93
+ case "note_ref":
94
+ return (
95
+ <span
96
+ key={seg.segmentId}
97
+ data-node-type="note_ref"
98
+ style={{ verticalAlign: "super", fontSize: "0.75em" }}
99
+ >
100
+ {seg.label}
101
+ </span>
102
+ );
103
+ case "opaque_inline":
104
+ return (
105
+ <span
106
+ key={seg.segmentId}
107
+ data-node-type="opaque_inline"
108
+ style={{ opacity: 0.6, fontSize: "0.85em" }}
109
+ >
110
+ {seg.displayText ?? seg.label}
111
+ </span>
112
+ );
113
+ default:
114
+ return null;
115
+ }
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Block renderers
120
+ // ---------------------------------------------------------------------------
121
+
122
+ function RegionParagraph({
123
+ block,
124
+ }: {
125
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
126
+ }): React.ReactElement {
127
+ const headingLevel = resolveHeadingLevel(block.styleId, block.outlineLevel);
128
+ const classes: string[] = ["leading-relaxed"];
129
+ if (headingLevel) {
130
+ classes.push(...headingClassList(headingLevel));
131
+ }
132
+
133
+ const pStyle = buildParagraphStyle(block);
134
+
135
+ // Numbering prefix span — matches tw-page-block-view so region content that
136
+ // happens to carry numbering (e.g. footnote bodies authored as lists) shows
137
+ // the same marker geometry.
138
+ const numberingPrefix = block.numberingPrefix;
139
+ const numberingSuffix = block.numberingSuffix;
140
+ const resolvedNumbering = block.resolvedNumbering;
141
+ const markerRunProperties = resolvedNumbering?.markerRunProperties;
142
+ const markerWidth = resolvedNumbering?.geometry?.markerLane?.width;
143
+ const markerJustification = resolvedNumbering?.geometry?.markerJustification;
144
+
145
+ const prefixSpan =
146
+ numberingPrefix != null ? (
147
+ <span
148
+ className={[
149
+ "inline-flex",
150
+ "select-none",
151
+ "items-center",
152
+ ...(!markerRunProperties
153
+ ? ["text-tertiary", "font-[family-name:var(--font-legal-sans)]"]
154
+ : []),
155
+ ].join(" ")}
156
+ contentEditable={false}
157
+ data-numbering-prefix={numberingPrefix}
158
+ {...(typeof resolvedNumbering?.level === "number"
159
+ ? { "data-numbering-level": String(resolvedNumbering.level) }
160
+ : {})}
161
+ {...(numberingSuffix ? { "data-numbering-suffix": numberingSuffix } : {})}
162
+ style={buildMarkerStyle(
163
+ numberingPrefix,
164
+ numberingSuffix,
165
+ markerRunProperties,
166
+ markerWidth,
167
+ markerJustification,
168
+ )}
169
+ >
170
+ {numberingPrefix}
171
+ </span>
172
+ ) : null;
173
+
174
+ const attrs: React.HTMLAttributes<HTMLDivElement> & {
175
+ "data-block-kind": "paragraph";
176
+ "data-block-id": string;
177
+ "data-heading-level"?: string;
178
+ "data-numbered"?: string;
179
+ "data-contextual-spacing"?: string;
180
+ } = {
181
+ "data-block-kind": "paragraph",
182
+ "data-block-id": block.blockId,
183
+ className: classes.join(" "),
184
+ style: hasStyleEntries(pStyle) ? pStyle : undefined,
185
+ };
186
+
187
+ if (headingLevel) attrs["data-heading-level"] = String(headingLevel);
188
+ if (block.numbering) attrs["data-numbered"] = "true";
189
+ if (block.contextualSpacing) attrs["data-contextual-spacing"] = "true";
190
+ if (block.bidi) attrs.dir = "rtl";
191
+
192
+ return (
193
+ <div {...attrs}>
194
+ {prefixSpan}
195
+ <span className="pm-paragraph-content">
196
+ {block.segments.map((seg) => renderSegment(seg))}
197
+ </span>
198
+ </div>
199
+ );
200
+ }
201
+
202
+ function RegionTable({
203
+ block,
204
+ }: {
205
+ block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
206
+ }): React.ReactElement {
207
+ const tableStyle: React.CSSProperties = {
208
+ borderCollapse: "collapse",
209
+ width: "100%",
210
+ };
211
+ if (block.alignment === "center") {
212
+ tableStyle.marginLeft = "auto";
213
+ tableStyle.marginRight = "auto";
214
+ }
215
+
216
+ // P6.a: emit a `<colgroup>` so column widths come from canonical
217
+ // `gridColumns` (twips), not intrinsic measurement. Widths in pt per
218
+ // P13.a so they self-scale under CSS `zoom`.
219
+ const gridColumns = block.gridColumns ?? [];
220
+
221
+ return (
222
+ <table
223
+ data-block-kind="table"
224
+ data-block-id={block.blockId}
225
+ data-node-type="table"
226
+ style={tableStyle}
227
+ >
228
+ {gridColumns.length > 0 ? (
229
+ <colgroup>
230
+ {gridColumns.map((widthTwips, colIdx) => (
231
+ <col
232
+ key={colIdx}
233
+ style={{ width: `${widthTwips / 20}pt` }}
234
+ />
235
+ ))}
236
+ </colgroup>
237
+ ) : null}
238
+ <tbody>
239
+ {block.rows.map((row, rowIdx) => (
240
+ <tr
241
+ key={rowIdx}
242
+ style={
243
+ row.height != null && row.heightRule === "exact"
244
+ ? { height: `${row.height / 20}pt` }
245
+ : row.height != null && row.heightRule === "atLeast"
246
+ ? { minHeight: `${row.height / 20}pt` }
247
+ : undefined
248
+ }
249
+ >
250
+ {row.cells.map((cell, cellIdx) => {
251
+ if (cell.verticalMerge === "continue") {
252
+ return null;
253
+ }
254
+ const cellStyle: React.CSSProperties = {};
255
+ if (cell.backgroundColor) cellStyle.backgroundColor = `#${cell.backgroundColor}`;
256
+ if (cell.verticalAlign) cellStyle.verticalAlign = cell.verticalAlign;
257
+ if (cell.borderTop) cellStyle.borderTop = cell.borderTop;
258
+ if (cell.borderRight) cellStyle.borderRight = cell.borderRight;
259
+ if (cell.borderBottom) cellStyle.borderBottom = cell.borderBottom;
260
+ if (cell.borderLeft) cellStyle.borderLeft = cell.borderLeft;
261
+
262
+ return (
263
+ <td
264
+ key={cellIdx}
265
+ colSpan={cell.colspan > 1 ? cell.colspan : undefined}
266
+ rowSpan={cell.rowspan > 1 ? cell.rowspan : undefined}
267
+ style={Object.keys(cellStyle).length > 0 ? cellStyle : undefined}
268
+ >
269
+ {cell.content.map((childBlock) => (
270
+ <RegionBlockItem key={childBlock.blockId} block={childBlock} />
271
+ ))}
272
+ </td>
273
+ );
274
+ })}
275
+ </tr>
276
+ ))}
277
+ </tbody>
278
+ </table>
279
+ );
280
+ }
281
+
282
+ function RegionOpaque({
283
+ block,
284
+ }: {
285
+ block: Extract<SurfaceBlockSnapshot, { kind: "opaque_block" }>;
286
+ }): React.ReactElement {
287
+ return (
288
+ <div
289
+ data-block-kind="opaque"
290
+ data-block-id={block.blockId}
291
+ data-node-type="opaque_block"
292
+ style={{
293
+ opacity: 0.5,
294
+ borderLeft: "3px solid #aaa",
295
+ paddingLeft: "8px",
296
+ margin: "4px 0",
297
+ fontSize: "0.85em",
298
+ color: "#666",
299
+ }}
300
+ >
301
+ {block.label || "[Locked content]"}
302
+ </div>
303
+ );
304
+ }
305
+
306
+ function RegionBlockItem({
307
+ block,
308
+ }: {
309
+ block: SurfaceBlockSnapshot;
310
+ }): React.ReactElement | null {
311
+ switch (block.kind) {
312
+ case "paragraph":
313
+ return <RegionParagraph block={block} />;
314
+ case "table":
315
+ return <RegionTable block={block} />;
316
+ case "sdt_block":
317
+ return (
318
+ <section
319
+ data-block-kind="sdt"
320
+ data-block-id={block.blockId}
321
+ data-node-type="sdt_block"
322
+ style={{ margin: "8px 0" }}
323
+ >
324
+ {block.children.map((child) => (
325
+ <RegionBlockItem key={child.blockId} block={child} />
326
+ ))}
327
+ </section>
328
+ );
329
+ case "opaque_block":
330
+ return <RegionOpaque block={block} />;
331
+ default:
332
+ return null;
333
+ }
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Public export
338
+ // ---------------------------------------------------------------------------
339
+
340
+ export interface TwRegionBlockRendererProps {
341
+ /** Blocks to render in document order. */
342
+ blocks: readonly SurfaceBlockSnapshot[];
343
+ /** Optional class name applied to the root wrapper. */
344
+ className?: string;
345
+ }
346
+
347
+ /**
348
+ * TwRegionBlockRenderer — read-only React renderer for a region's
349
+ * `SurfaceBlockSnapshot[]`. Used by the header / footer / footnote /
350
+ * endnote bands that P8 mounts per page.
351
+ *
352
+ * The root wrapper is `contentEditable={false}` and carries
353
+ * `data-region-block-renderer` so region chrome overlays can target it.
354
+ * No PM decorations, no `contenteditable=true` — this renderer is pure
355
+ * presentational DOM.
356
+ */
357
+ export function TwRegionBlockRenderer({
358
+ blocks,
359
+ className,
360
+ }: TwRegionBlockRendererProps): React.ReactElement {
361
+ const rootClasses = ["ProseMirror"];
362
+ if (className) rootClasses.push(className);
363
+ return (
364
+ <div
365
+ className={rootClasses.join(" ")}
366
+ contentEditable={false}
367
+ data-region-block-renderer=""
368
+ >
369
+ {blocks.map((block) => (
370
+ <RegionBlockItem key={block.blockId} block={block} />
371
+ ))}
372
+ </div>
373
+ );
374
+ }
@@ -0,0 +1,157 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Fallback estimate of blocks per page. Used ONLY when no page markers
5
+ * are available (pre-observer window or a degenerate empty-pageMarkers
6
+ * input). Once the observer fires, actual per-page spans are read from
7
+ * `data-page-first-block-index` / `data-page-last-block-index` markers.
8
+ * Short (title-only) or very long (large-table) pages deviate from this,
9
+ * but the fallback is transient and only affects the initial render.
10
+ */
11
+ const ESTIMATED_BLOCKS_PER_PAGE_FALLBACK = 50;
12
+
13
+ /**
14
+ * Block-range hook — returns the range of surface block indices that should
15
+ * be rendered in PM as "real" (non-placeholder) blocks.
16
+ *
17
+ * Sources of truth:
18
+ * 1. IntersectionObserver on `[data-page-frame]` markers in the PM DOM.
19
+ * 2. Selection head block-index — always included (selection-guard).
20
+ * 3. Overscan — ±N pages around the visible set to avoid jank when scrolling.
21
+ *
22
+ * If the selection is far off-screen, the returned range spans both the
23
+ * visible window AND the selection's page (with the gap between filled in).
24
+ * Gap-filling is a deliberate correctness choice: position preservation does
25
+ * NOT require continuous viewport coverage, but continuous coverage simplifies
26
+ * the snapshot-projection step downstream.
27
+ */
28
+ export interface VisibleBlockRangeInput {
29
+ pageMarkers: readonly HTMLElement[];
30
+ overscanPages: number;
31
+ selectionBlockIndex: number | null;
32
+ totalBlockCount: number;
33
+ }
34
+
35
+ export interface BlockRange {
36
+ start: number; // inclusive
37
+ end: number; // exclusive
38
+ }
39
+
40
+ function readBlockIndex(el: HTMLElement, attr: string): number | null {
41
+ const v = el.getAttribute(attr);
42
+ if (v === null) return null;
43
+ const n = Number(v);
44
+ return Number.isFinite(n) ? n : null;
45
+ }
46
+
47
+ export function useVisibleBlockRange(input: VisibleBlockRangeInput): BlockRange {
48
+ const { pageMarkers, overscanPages, selectionBlockIndex, totalBlockCount } = input;
49
+ const [visiblePages, setVisiblePages] = React.useState<Set<number>>(() => new Set());
50
+
51
+ React.useEffect(() => {
52
+ // Reset: marker set changed (e.g. document reload). Stale page indices
53
+ // from the previous observer would look up against new markers and miss,
54
+ // falling through to the `Infinity` fallback and producing a transient
55
+ // over-wide range. Clear them now so the new observer's first callback
56
+ // is the single source of truth.
57
+ setVisiblePages(new Set());
58
+
59
+ if (pageMarkers.length === 0) return;
60
+ const view = pageMarkers[0].ownerDocument?.defaultView;
61
+ if (!view?.IntersectionObserver) return;
62
+
63
+ const observer = new view.IntersectionObserver(
64
+ (entries) => {
65
+ setVisiblePages((prev) => {
66
+ const next = new Set(prev);
67
+ let changed = false;
68
+ for (const entry of entries) {
69
+ const idx = readBlockIndex(entry.target as HTMLElement, "data-page-frame");
70
+ if (idx === null) continue;
71
+ const was = next.has(idx);
72
+ if (entry.isIntersecting && !was) {
73
+ next.add(idx);
74
+ changed = true;
75
+ } else if (!entry.isIntersecting && was) {
76
+ next.delete(idx);
77
+ changed = true;
78
+ }
79
+ }
80
+ return changed ? next : prev;
81
+ });
82
+ },
83
+ { root: null, rootMargin: "0px", threshold: 0 },
84
+ );
85
+
86
+ for (const marker of pageMarkers) observer.observe(marker);
87
+ return () => observer.disconnect();
88
+ }, [pageMarkers]);
89
+
90
+ return React.useMemo(() => {
91
+ if (totalBlockCount <= 0) return { start: 0, end: 0 };
92
+ if (visiblePages.size === 0 && selectionBlockIndex === null) {
93
+ // No visibility signal yet — return initial-load window (first 2 × overscanPages worth).
94
+ const initialEnd = Math.min(totalBlockCount, (overscanPages * 2 + 1) * ESTIMATED_BLOCKS_PER_PAGE_FALLBACK);
95
+ return { start: 0, end: initialEnd };
96
+ }
97
+
98
+ // Expand visiblePages by ±overscanPages.
99
+ const expanded = new Set<number>();
100
+ for (const p of visiblePages) {
101
+ for (let d = -overscanPages; d <= overscanPages; d++) expanded.add(p + d);
102
+ }
103
+
104
+ // Translate page indices → block indices using marker attrs.
105
+ let minBlock = Infinity;
106
+ let maxBlock = -Infinity;
107
+ for (const marker of pageMarkers) {
108
+ const idx = readBlockIndex(marker, "data-page-frame");
109
+ if (idx === null || !expanded.has(idx)) continue;
110
+ const first = readBlockIndex(marker, "data-page-first-block-index");
111
+ const last = readBlockIndex(marker, "data-page-last-block-index");
112
+ if (first !== null) minBlock = Math.min(minBlock, first);
113
+ if (last !== null) maxBlock = Math.max(maxBlock, last + 1);
114
+ }
115
+ if (minBlock === Infinity) {
116
+ minBlock = 0;
117
+ maxBlock = Math.min(totalBlockCount, (overscanPages * 2 + 1) * ESTIMATED_BLOCKS_PER_PAGE_FALLBACK);
118
+ }
119
+
120
+ // Selection-guard: if selection is outside [minBlock, maxBlock), extend to cover
121
+ // the entire page that contains the selection.
122
+ if (selectionBlockIndex !== null) {
123
+ if (selectionBlockIndex < minBlock) {
124
+ // Find the page that contains selectionBlockIndex and extend to its start.
125
+ for (const marker of pageMarkers) {
126
+ const first = readBlockIndex(marker, "data-page-first-block-index");
127
+ const last = readBlockIndex(marker, "data-page-last-block-index");
128
+ if (first !== null && last !== null && selectionBlockIndex >= first && selectionBlockIndex <= last) {
129
+ if (first < minBlock) minBlock = first;
130
+ break;
131
+ }
132
+ }
133
+ // Fallback: just include the block itself.
134
+ if (selectionBlockIndex < minBlock) minBlock = selectionBlockIndex;
135
+ }
136
+ if (selectionBlockIndex >= maxBlock) {
137
+ // Find the page that contains selectionBlockIndex and extend to its end.
138
+ for (const marker of pageMarkers) {
139
+ const first = readBlockIndex(marker, "data-page-first-block-index");
140
+ const last = readBlockIndex(marker, "data-page-last-block-index");
141
+ if (first !== null && last !== null && selectionBlockIndex >= first && selectionBlockIndex <= last) {
142
+ if (last + 1 > maxBlock) maxBlock = last + 1;
143
+ break;
144
+ }
145
+ }
146
+ // Fallback: just include the block itself.
147
+ if (selectionBlockIndex >= maxBlock) maxBlock = selectionBlockIndex + 1;
148
+ }
149
+ }
150
+
151
+ // Clamp to doc bounds.
152
+ return {
153
+ start: Math.max(0, minBlock),
154
+ end: Math.min(totalBlockCount, maxBlock),
155
+ };
156
+ }, [visiblePages, selectionBlockIndex, pageMarkers, overscanPages, totalBlockCount]);
157
+ }
@@ -0,0 +1,155 @@
1
+ import React from "react";
2
+
3
+ import type {
4
+ CommentAttachment,
5
+ CommentBody,
6
+ CommentMention,
7
+ } from "../../api/comment-presentation-types";
8
+ import { sanitizeMarkdown } from "../../runtime/markdown-sanitizer";
9
+
10
+ export interface CommentMarkdownRendererProps {
11
+ body: CommentBody;
12
+ mentions?: CommentMention[];
13
+ attachments?: CommentAttachment[];
14
+ /**
15
+ * Resolves an attachment's OOXML `relationshipId` (from `word/_rels/`) to
16
+ * an object-URL / href that can be fed to an `<img src>`. Hosts that do
17
+ * not carry image attachments can omit this; the renderer then falls back
18
+ * to the attachment's display name or alt text.
19
+ */
20
+ resolveAttachmentHref?: (relationshipId: string) => string | undefined;
21
+ className?: string;
22
+ }
23
+
24
+ export function CommentMarkdownRenderer(props: CommentMarkdownRendererProps) {
25
+ const { body, mentions = [], attachments = [], resolveAttachmentHref } = props;
26
+ const { text, sanitized: sanitizedOnRender } = sanitizeMarkdown(body.text);
27
+ const wasSanitized = Boolean(body.sanitized) || sanitizedOnRender;
28
+
29
+ const paragraphs = text
30
+ .split(/\n\s*\n/)
31
+ .map((p) => p.trimEnd())
32
+ .filter((p) => p.length > 0);
33
+
34
+ return (
35
+ <div data-testid="comment-body" className={props.className}>
36
+ {paragraphs.map((paragraph, idx) => (
37
+ <p key={idx}>
38
+ {renderInline(paragraph, mentions, attachments, resolveAttachmentHref)}
39
+ </p>
40
+ ))}
41
+ {wasSanitized ? (
42
+ <span
43
+ data-testid="comment-body-sanitized-badge"
44
+ title="This content was sanitized on render for safety."
45
+ className="inline-block rounded px-1 py-px text-[9px] font-medium uppercase tracking-wide text-comment bg-warning-soft"
46
+ >
47
+ sanitized
48
+ </span>
49
+ ) : null}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ const INLINE_PATTERN =
55
+ /(`[^`\n]+`)|(!\[([^\]]*)\]\(([^)]+)\))|(\[([^\]]+)\]\(([^)]+)\))|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)/g;
56
+
57
+ function renderInline(
58
+ text: string,
59
+ mentions: readonly CommentMention[],
60
+ attachments: readonly CommentAttachment[],
61
+ resolveAttachmentHref?: (relationshipId: string) => string | undefined,
62
+ ): React.ReactNode[] {
63
+ const nodes: React.ReactNode[] = [];
64
+ let cursor = 0;
65
+ let key = 0;
66
+ INLINE_PATTERN.lastIndex = 0;
67
+ let match: RegExpExecArray | null;
68
+ while ((match = INLINE_PATTERN.exec(text)) !== null) {
69
+ if (match.index > cursor) {
70
+ nodes.push(text.slice(cursor, match.index));
71
+ }
72
+ const [
73
+ ,
74
+ codeMatch,
75
+ imageMatch,
76
+ imageAlt,
77
+ imageTarget,
78
+ linkMatch,
79
+ linkLabel,
80
+ linkTarget,
81
+ boldMatch,
82
+ italicMatch,
83
+ ] = match;
84
+
85
+ if (codeMatch) {
86
+ nodes.push(<code key={key++}>{codeMatch.slice(1, -1)}</code>);
87
+ } else if (imageMatch) {
88
+ nodes.push(
89
+ renderAttachmentImage(
90
+ key++,
91
+ imageAlt ?? "",
92
+ imageTarget ?? "",
93
+ attachments,
94
+ resolveAttachmentHref,
95
+ ),
96
+ );
97
+ } else if (linkMatch) {
98
+ nodes.push(
99
+ renderLinkOrMention(key++, linkLabel ?? "", linkTarget ?? "", mentions),
100
+ );
101
+ } else if (boldMatch) {
102
+ nodes.push(<strong key={key++}>{boldMatch.slice(2, -2)}</strong>);
103
+ } else if (italicMatch) {
104
+ nodes.push(<em key={key++}>{italicMatch.slice(1, -1)}</em>);
105
+ }
106
+ cursor = match.index + match[0].length;
107
+ }
108
+ if (cursor < text.length) nodes.push(text.slice(cursor));
109
+ return nodes;
110
+ }
111
+
112
+ function renderAttachmentImage(
113
+ key: number,
114
+ alt: string,
115
+ target: string,
116
+ attachments: readonly CommentAttachment[],
117
+ resolveAttachmentHref?: (relationshipId: string) => string | undefined,
118
+ ): React.ReactNode {
119
+ // Sanitizer enforces `bw:attachment:` scheme on images; anything else is
120
+ // already downgraded to a bare `[alt]` by the time we see it.
121
+ if (!target.startsWith("bw:attachment:")) {
122
+ return <span key={key}>{alt}</span>;
123
+ }
124
+ const attachmentId = target.slice("bw:attachment:".length);
125
+ const attachment = attachments.find((a) => a.id === attachmentId);
126
+ const relationshipId = attachment?.relationshipId;
127
+ const href = relationshipId ? resolveAttachmentHref?.(relationshipId) : undefined;
128
+ if (href) {
129
+ return <img key={key} src={href} alt={alt || attachment?.displayName || ""} />;
130
+ }
131
+ return <span key={key}>{alt || attachment?.displayName || ""}</span>;
132
+ }
133
+
134
+ function renderLinkOrMention(
135
+ key: number,
136
+ label: string,
137
+ target: string,
138
+ mentions: readonly CommentMention[],
139
+ ): React.ReactNode {
140
+ if (target.startsWith("bw:user:")) {
141
+ const userId = target.slice("bw:user:".length);
142
+ const mention = mentions.find((m) => m.userId === userId);
143
+ const text = label || `@${mention?.displayName ?? userId}`;
144
+ return (
145
+ <span key={key} data-testid={`comment-mention-${userId}`} className="font-medium text-accent">
146
+ {text}
147
+ </span>
148
+ );
149
+ }
150
+ return (
151
+ <a key={key} href={target} target="_blank" rel="noopener noreferrer">
152
+ {label}
153
+ </a>
154
+ );
155
+ }