@beyondwork/docx-react-component 1.0.42 → 1.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/package.json +5 -4
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +333 -4
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +60 -10
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/search/search-text.ts +15 -2
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +29 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +692 -2
- package/src/io/export/build-app-properties-xml.ts +1 -1
- package/src/io/export/serialize-comments.ts +38 -9
- package/src/io/export/twip.ts +1 -1
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +116 -0
- package/src/io/ooxml/parse-comments.ts +0 -33
- package/src/io/ooxml/parse-complex-content.ts +14 -0
- package/src/io/ooxml/parse-main-document.ts +4 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +661 -42
- package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
- package/src/runtime/edit-dispatch/index.ts +2 -0
- package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/editor-surface/capabilities.ts +411 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +63 -2
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +430 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/prerender/cache-envelope.ts +29 -0
- package/src/runtime/prerender/cache-key.ts +66 -0
- package/src/runtime/prerender/font-fingerprint.ts +17 -0
- package/src/runtime/prerender/graph-canonicalize.ts +121 -0
- package/src/runtime/prerender/indexeddb-cache.ts +184 -0
- package/src/runtime/prerender/prerender-document.ts +145 -0
- package/src/runtime/render/block-fragment-projection.ts +2 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +45 -7
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +142 -237
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +115 -12
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
- package/src/ui-tailwind/index.ts +5 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
|
@@ -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
|
+
}
|