@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.
- package/package.json +38 -37
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +541 -5
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +601 -9
- package/src/core/search/search-text.ts +15 -2
- package/src/index.ts +131 -1
- package/src/io/docx-session.ts +672 -2
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +83 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +367 -0
- package/src/io/ooxml/workflow-payload.ts +317 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +639 -124
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- 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 +139 -14
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +441 -48
- package/src/runtime/layout/public-facet.ts +585 -14
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/surface-projection.ts +10 -5
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +80 -16
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +654 -45
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +111 -11
- package/src/ui/editor-shell-view.tsx +21 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- 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-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- 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 +10 -256
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +37 -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/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- 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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
<
|
|
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
|
-
|
|
168
|
-
|
|
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">
|