@beyondwork/docx-react-component 1.0.41 → 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 (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -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,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
+ }
@@ -1,12 +1,33 @@
1
- import React, { useCallback, useEffect, useRef, useState } from "react";
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { Check, CornerDownRight, RotateCcw } from "lucide-react";
3
3
 
4
4
  import type { CommentSidebarSnapshot, CommentSidebarThreadSnapshot } from "../../api/public-types";
5
+ import type {
6
+ CommentPresentation,
7
+ CommentPresentationReply,
8
+ CommentPresentationSnapshot,
9
+ } from "../../api/comment-presentation-types";
10
+ import { CommentMarkdownRenderer } from "./comment-markdown-renderer";
5
11
 
6
12
  export interface TwCommentSidebarProps {
7
13
  comments: CommentSidebarSnapshot;
8
14
  activeCommentId?: string;
9
15
  currentUserId?: string;
16
+ /**
17
+ * Optional per-comment rich presentation (markdown body + mentions +
18
+ * attachments + replies). When a thread's commentId matches an entry here
19
+ * we render its body through {@link CommentMarkdownRenderer} instead of
20
+ * the flat plaintext fallback. Threads without a presentation entry keep
21
+ * the legacy plaintext render — required for back-compat with docx
22
+ * authored outside BW.
23
+ */
24
+ commentPresentations?: CommentPresentationSnapshot;
25
+ /**
26
+ * Forwarded to the markdown renderer to resolve attachment
27
+ * `relationshipId` refs against `word/_rels/` image blobs. Hosts that do
28
+ * not render inline images can omit this.
29
+ */
30
+ resolveAttachmentHref?: (relationshipId: string) => string | undefined;
10
31
  onOpenComment?: (thread: CommentSidebarThreadSnapshot) => void;
11
32
  onResolveComment?: (commentId: string) => void;
12
33
  onReopenComment?: (commentId: string) => void;
@@ -18,7 +39,15 @@ const focusRingClass =
18
39
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
19
40
 
20
41
  export function TwCommentSidebar(props: TwCommentSidebarProps) {
21
- const { comments, activeCommentId, currentUserId } = props;
42
+ const { comments, activeCommentId, currentUserId, commentPresentations, resolveAttachmentHref } = props;
43
+
44
+ const presentationByCommentId = useMemo(() => {
45
+ const map = new Map<string, CommentPresentation>();
46
+ for (const entry of commentPresentations?.entries ?? []) {
47
+ map.set(entry.commentId, entry);
48
+ }
49
+ return map;
50
+ }, [commentPresentations]);
22
51
 
23
52
  return (
24
53
  <div className="outline-none">
@@ -41,6 +70,8 @@ export function TwCommentSidebar(props: TwCommentSidebarProps) {
41
70
  thread={thread}
42
71
  isActive={activeCommentId === thread.commentId}
43
72
  currentUserId={currentUserId}
73
+ presentation={presentationByCommentId.get(thread.commentId)}
74
+ resolveAttachmentHref={resolveAttachmentHref}
44
75
  onOpenComment={props.onOpenComment}
45
76
  onResolveComment={props.onResolveComment}
46
77
  onReopenComment={props.onReopenComment}
@@ -62,13 +93,22 @@ function CommentThreadCard(props: {
62
93
  thread: CommentSidebarThreadSnapshot;
63
94
  isActive: boolean;
64
95
  currentUserId?: string;
96
+ presentation?: CommentPresentation;
97
+ resolveAttachmentHref?: (relationshipId: string) => string | undefined;
65
98
  onOpenComment?: (thread: CommentSidebarThreadSnapshot) => void;
66
99
  onResolveComment?: (commentId: string) => void;
67
100
  onReopenComment?: (commentId: string) => void;
68
101
  onAddReply?: (commentId: string, body: string) => void;
69
102
  onEditBody?: (commentId: string, body: string) => void;
70
103
  }) {
71
- const { thread, isActive } = props;
104
+ const { thread, isActive, presentation, resolveAttachmentHref } = props;
105
+ const replyPresentationByEntryId = useMemo(() => {
106
+ const map = new Map<string, CommentPresentationReply>();
107
+ for (const reply of presentation?.replies ?? []) {
108
+ map.set(reply.entryId, reply);
109
+ }
110
+ return map;
111
+ }, [presentation]);
72
112
  const leadEntry = thread.entries[0];
73
113
  const isDraftThread = thread.status === "open" && thread.entryCount === 1 && isEmptyCommentBody(leadEntry?.body);
74
114
  const isOwnComment = props.currentUserId != null && leadEntry?.authorId === props.currentUserId;
@@ -138,6 +178,14 @@ function CommentThreadCard(props: {
138
178
  onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
139
179
  label={isDraftThread ? "New comment" : undefined}
140
180
  />
181
+ ) : presentation ? (
182
+ <CommentMarkdownRenderer
183
+ body={presentation.body}
184
+ mentions={presentation.mentions}
185
+ attachments={presentation.attachments}
186
+ resolveAttachmentHref={resolveAttachmentHref}
187
+ className="text-[10px] leading-[1.1rem] text-secondary break-words"
188
+ />
141
189
  ) : leadEntry?.body ? (
142
190
  <p
143
191
  className="text-[10px] leading-[1.1rem] text-secondary whitespace-pre-wrap break-words line-clamp-4"
@@ -158,20 +206,33 @@ function CommentThreadCard(props: {
158
206
  ) : null}
159
207
 
160
208
  {/* Reply entries (compact) */}
161
- {thread.entries.slice(1).map((entry) => (
162
- <div key={entry.entryId} className="mt-2 ml-4 border-l border-border/50 pl-2.5">
163
- <div className="mb-0.5 flex items-center gap-1">
164
- <span className="text-[9px] font-medium text-secondary">{entry.authorId}</span>
165
- <span className="text-[9px] text-tertiary">{formatCommentDate(entry.createdAt)}</span>
209
+ {thread.entries.slice(1).map((entry) => {
210
+ const replyPresentation = replyPresentationByEntryId.get(entry.entryId);
211
+ return (
212
+ <div key={entry.entryId} className="mt-2 ml-4 border-l border-border/50 pl-2.5">
213
+ <div className="mb-0.5 flex items-center gap-1">
214
+ <span className="text-[9px] font-medium text-secondary">{entry.authorId}</span>
215
+ <span className="text-[9px] text-tertiary">{formatCommentDate(entry.createdAt)}</span>
216
+ </div>
217
+ {replyPresentation ? (
218
+ <CommentMarkdownRenderer
219
+ body={replyPresentation.body}
220
+ mentions={presentation?.mentions}
221
+ attachments={presentation?.attachments}
222
+ resolveAttachmentHref={resolveAttachmentHref}
223
+ className="text-[10px] leading-4 text-secondary break-words"
224
+ />
225
+ ) : (
226
+ <p
227
+ className="text-[10px] leading-4 text-secondary whitespace-pre-wrap break-words line-clamp-3"
228
+ data-comment-reply-body="true"
229
+ >
230
+ {entry.body}
231
+ </p>
232
+ )}
166
233
  </div>
167
- <p
168
- className="text-[10px] leading-4 text-secondary whitespace-pre-wrap break-words line-clamp-3"
169
- data-comment-reply-body="true"
170
- >
171
- {entry.body}
172
- </p>
173
- </div>
174
- ))}
234
+ );
235
+ })}
175
236
 
176
237
  {thread.entryCount > thread.entries.length ? (
177
238
  <p className="mt-1 text-[9px] text-tertiary">