@beyondwork/docx-react-component 1.0.70 → 1.0.72
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 +964 -75
- package/package.json +1 -1
- package/src/api/public-types.ts +243 -1
- package/src/api/v3/_create.ts +16 -1
- package/src/api/v3/_runtime-handle.ts +2 -0
- package/src/api/v3/ai/evaluate.ts +113 -0
- package/src/api/v3/ai/outline.ts +140 -0
- package/src/api/v3/ai/replacement.ts +8 -0
- package/src/api/v3/ai/review.ts +342 -0
- package/src/api/v3/ai/stats.ts +62 -0
- package/src/api/v3/runtime/viewport.ts +181 -0
- package/src/api/v3/runtime/workflow.ts +114 -1
- package/src/api/v3/ui/_types.ts +35 -0
- package/src/api/v3/ui/index.ts +1 -0
- package/src/api/v3/ui/viewport.ts +112 -0
- package/src/compare/diff-engine.ts +2 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/table-structure-commands.ts +1 -0
- package/src/io/export/serialize-headers-footers.ts +1 -0
- package/src/io/export/serialize-main-document.ts +13 -0
- package/src/io/export/serialize-paragraph-formatting.ts +34 -0
- package/src/io/export/split-review-boundaries.ts +1 -0
- package/src/io/normalize/normalize-text.ts +11 -0
- package/src/io/ooxml/parse-main-document.ts +21 -5
- package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
- package/src/model/canonical-document.ts +401 -1
- package/src/runtime/formatting/formatting-context.ts +2 -1
- package/src/runtime/geometry/overlay-rects.ts +7 -10
- package/src/runtime/layout/layout-engine-version.ts +257 -1
- package/src/runtime/layout/paginated-layout-engine.ts +134 -8
- package/src/runtime/layout/resolved-formatting-state.ts +108 -13
- package/src/runtime/markdown-sanitizer.ts +21 -4
- package/src/runtime/render/render-kernel.ts +21 -1
- package/src/runtime/scopes/audit-bundle.ts +8 -0
- package/src/runtime/scopes/compiler-service.ts +1 -0
- package/src/runtime/scopes/enumerate-scopes.ts +61 -3
- package/src/runtime/scopes/replacement/apply.ts +49 -3
- package/src/runtime/scopes/semantic-scope-types.ts +8 -0
- package/src/runtime/surface-projection.ts +22 -0
- package/src/runtime/workflow/coordinator.ts +3 -0
- package/src/runtime/workflow/scope-writer.ts +34 -0
- package/src/session/export/embedded-reconstitute.ts +37 -3
- package/src/session/import/embedded-offload.ts +26 -1
- package/src/shell/media-previews.ts +8 -6
- package/src/ui/WordReviewEditor.tsx +1 -0
- package/src/ui/editor-surface-controller.tsx +11 -0
- package/src/ui/headless/selection-helpers.ts +2 -2
- package/src/ui/runtime-shortcut-dispatch.ts +4 -4
- package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
- package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
- package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -4
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
- package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +37 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
- package/src/ui-tailwind/index.ts +4 -2
- package/src/ui-tailwind/page-chrome-model.ts +5 -7
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +5 -2
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +4 -1
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +73 -8
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
- package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
- package/src/ui-tailwind/tw-review-workspace.tsx +1 -0
|
@@ -68,6 +68,34 @@ export async function reconstituteEmbeddedDocuments(
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* SEC-IO-01 (2026-04-23): tamper-verified resolution.
|
|
73
|
+
*
|
|
74
|
+
* Two sources are consulted in order:
|
|
75
|
+
* 1. `hostAdapter.loadEmbeddedDocument(storageReference)` — verified against
|
|
76
|
+
* `entry.sha256`. Mismatch falls through to inline bytes, treated as a
|
|
77
|
+
* storage failure.
|
|
78
|
+
* 2. `entry.inlineBytes` base64 fallback — also verified against
|
|
79
|
+
* `entry.sha256`. If the inline bytes have been tampered with (attacker-
|
|
80
|
+
* modified customXml payload carrying altered bytes with the original
|
|
81
|
+
* declared sha), this check fail-closes: export throws a specific error
|
|
82
|
+
* rather than shipping attacker-chosen bytes into the OPC package.
|
|
83
|
+
*
|
|
84
|
+
* Before the fix, the inline-bytes path returned the decoded payload without
|
|
85
|
+
* verification — a crafted DOCX could inject attacker-controlled bytes into
|
|
86
|
+
* every exported package part whose host-adapter lookup happened to fail.
|
|
87
|
+
*/
|
|
88
|
+
class EmbeddedOffloadTamperError extends Error {
|
|
89
|
+
constructor(public readonly entry: { sourcePartPath: string; sha256: string }) {
|
|
90
|
+
super(
|
|
91
|
+
`Embedded offload entry '${entry.sourcePartPath}' failed sha256 integrity ` +
|
|
92
|
+
`check against inline-bytes fallback (expected sha256=${entry.sha256.slice(0, 16)}…). ` +
|
|
93
|
+
`Source customXml payload is tampered.`,
|
|
94
|
+
);
|
|
95
|
+
this.name = "EmbeddedOffloadTamperError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
async function resolveEntryBytes(
|
|
72
100
|
hostAdapter: EditorHostAdapter | undefined,
|
|
73
101
|
entry: EmbeddingOffloadEntry,
|
|
@@ -89,11 +117,17 @@ async function resolveEntryBytes(
|
|
|
89
117
|
}
|
|
90
118
|
} catch {
|
|
91
119
|
// Treat identically to `null` — fall through to the inline-
|
|
92
|
-
// bytes fallback.
|
|
93
|
-
// fails on storage unavailability.
|
|
120
|
+
// bytes fallback.
|
|
94
121
|
}
|
|
95
122
|
}
|
|
96
|
-
|
|
123
|
+
// SEC-IO-01 verification on the inline fallback. Unlike storage
|
|
124
|
+
// unavailability (which is benign), sha mismatch here means the
|
|
125
|
+
// source customXml payload was tampered with. Fail closed.
|
|
126
|
+
const decoded = decodeInlineBytes(entry.inlineBytes);
|
|
127
|
+
if (sha256Hex(decoded) !== entry.sha256) {
|
|
128
|
+
throw new EmbeddedOffloadTamperError(entry);
|
|
129
|
+
}
|
|
130
|
+
return decoded;
|
|
97
131
|
}
|
|
98
132
|
|
|
99
133
|
/**
|
|
@@ -263,6 +263,31 @@ const EMPTY_RESULT: EmbeddedOffloadResult = Object.freeze({
|
|
|
263
263
|
// (Buffer) and browser (btoa/atob) contexts. Direct call; the earlier
|
|
264
264
|
// local `encodeBase64` alias was unnecessary indirection.
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* SEC-IO-02 (2026-04-23): allowlist check for offload sourcePartPath.
|
|
268
|
+
*
|
|
269
|
+
* Offload entries come from customXml — untrusted content. The export-time
|
|
270
|
+
* `reconstituteEmbeddedDocuments` writes to `entry.sourcePartPath` via
|
|
271
|
+
* `exportSession.replaceOwnedPart`, which accepts arbitrary OPC paths. A
|
|
272
|
+
* malicious `sourcePartPath` could overwrite `/[Content_Types].xml`,
|
|
273
|
+
* `/_rels/.rels`, `/word/document.xml`, or any other sensitive part of the
|
|
274
|
+
* exported package.
|
|
275
|
+
*
|
|
276
|
+
* The offload feature covers EMBEDDED documents only — sub-documents Word
|
|
277
|
+
* stores under `/word/embeddings/embedded{N}.bin` (OLE) or similar. Narrow
|
|
278
|
+
* the allowlist to that canonical prefix. Entries with any other path,
|
|
279
|
+
* any traversal segment, or any control char silently drop.
|
|
280
|
+
*/
|
|
281
|
+
const SAFE_EMBEDDING_PATH = /^\/word\/embeddings\/[A-Za-z0-9_.-]+$/;
|
|
282
|
+
|
|
283
|
+
function isSafeSourcePartPath(path: unknown): path is string {
|
|
284
|
+
if (typeof path !== "string") return false;
|
|
285
|
+
if (path.length === 0 || path.length > 256) return false;
|
|
286
|
+
if (path.includes("..")) return false;
|
|
287
|
+
if (path.includes("\0") || path.includes("\n") || path.includes("\r")) return false;
|
|
288
|
+
return SAFE_EMBEDDING_PATH.test(path);
|
|
289
|
+
}
|
|
290
|
+
|
|
266
291
|
function parseEmbeddingsInline(
|
|
267
292
|
inline: unknown,
|
|
268
293
|
): EmbeddingOffloadEntry[] | undefined {
|
|
@@ -293,7 +318,7 @@ function parseEmbeddingsInline(
|
|
|
293
318
|
if (
|
|
294
319
|
typeof r.embeddedDocId !== "string" ||
|
|
295
320
|
typeof r.relationshipId !== "string" ||
|
|
296
|
-
|
|
321
|
+
!isSafeSourcePartPath(r.sourcePartPath) ||
|
|
297
322
|
typeof r.contentType !== "string" ||
|
|
298
323
|
typeof r.sha256 !== "string" ||
|
|
299
324
|
typeof r.inlineBytes !== "string" ||
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* digest, walks the OPC parts table, and projects each canonical media
|
|
7
7
|
* item into a `MediaPreviewDescriptor`.
|
|
8
8
|
*
|
|
9
|
-
* SVG is
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* SVG is EXCLUDED from the content-type allowlist (SEC-UI-03, 2026-04-23).
|
|
10
|
+
* While Chromium's `<img src="data:image/svg+xml;base64,…">` path does block
|
|
11
|
+
* script execution on modern browsers, user-authored SVGs in DOCX `word/media/`
|
|
12
|
+
* can still leak user presence via `<image xlink:href="http://...">` trackers
|
|
13
|
+
* and have historically had CSP-bypass gaps. Chart-preview SVGs (Stage 0B)
|
|
14
|
+
* come from our own renderer via `chart-preview-resolver.ts`, not from
|
|
15
|
+
* `word/media/*`, so dropping SVG here does not regress charts.
|
|
14
16
|
*
|
|
15
17
|
* Lives in `src/shell/` so the three `io/**` substrate imports stay
|
|
16
18
|
* outside the L11 boundary register — package decoding is a shell
|
|
@@ -33,7 +35,7 @@ const BROWSER_SAFE_PREVIEW_TYPES = new Set([
|
|
|
33
35
|
"image/gif",
|
|
34
36
|
"image/webp",
|
|
35
37
|
"image/bmp",
|
|
36
|
-
|
|
38
|
+
// SVG intentionally EXCLUDED — see SEC-UI-03 header comment.
|
|
37
39
|
]);
|
|
38
40
|
|
|
39
41
|
export function buildMediaPreviews(
|
|
@@ -3321,6 +3321,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
3321
3321
|
pasteFragmentParser={SHELL_PASTE_FRAGMENT_PARSER}
|
|
3322
3322
|
runtimeSearchDocument={api.runtime.search.searchDocument}
|
|
3323
3323
|
runtimeGetTableSelectionDescriptor={api.runtime.table.getSelectionDescriptor}
|
|
3324
|
+
scopeTagRegistryFactory={api.runtime.workflow.createScopeTagRegistry}
|
|
3324
3325
|
snapshot={snapshot}
|
|
3325
3326
|
canonicalDocument={canonicalDocument}
|
|
3326
3327
|
documentNavigation={documentNavigation}
|
|
@@ -84,6 +84,17 @@ export interface EditorSurfaceControllerProps {
|
|
|
84
84
|
runtimeGetTableSelectionDescriptor?: (
|
|
85
85
|
state: import("prosemirror-state").EditorState,
|
|
86
86
|
) => TableSelectionDescriptor | null;
|
|
87
|
+
/**
|
|
88
|
+
* Coord-10 L11-4 — forwarded to `TwProseMirrorSurface`. Threaded
|
|
89
|
+
* from `api.runtime.workflow.createScopeTagRegistry` (shipped by
|
|
90
|
+
* L06 in react-refactor `534f2b97`). Replaces the pre-L06-catalog
|
|
91
|
+
* pragmatic public-types re-export (refactor/11 §4.17, 2026-04-24
|
|
92
|
+
* `7a2d2fc0`). When absent, `TwProseMirrorSurface` falls back to
|
|
93
|
+
* the public-types re-export for test / headless mount back-compat.
|
|
94
|
+
*/
|
|
95
|
+
scopeTagRegistryFactory?: () => import(
|
|
96
|
+
"../api/public-types.ts"
|
|
97
|
+
).ScopeTagRegistry;
|
|
87
98
|
onPasteApplied?: (meta: {
|
|
88
99
|
segmentCount: number;
|
|
89
100
|
charCount: number;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { SelectionSnapshot } from "../../api/public-types";
|
|
2
1
|
import {
|
|
2
|
+
type SelectionSnapshot,
|
|
3
3
|
createPublicNodeAnchor,
|
|
4
4
|
createPublicRangeAnchor,
|
|
5
|
-
} from "../../
|
|
5
|
+
} from "../../api/public-types";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Headless-UI-side `createSelectionSnapshot` that produces the **public**
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
CAPABILITY_BY_ID,
|
|
3
|
+
type StyleCatalogSnapshot,
|
|
4
|
+
type WorkflowBlockedCommandReason,
|
|
4
5
|
} from "../api/public-types.ts";
|
|
5
|
-
import { CAPABILITY_BY_ID } from "../runtime/editor-surface/capabilities.ts";
|
|
6
6
|
|
|
7
7
|
export interface ShortcutKeyInput {
|
|
8
8
|
key: string;
|
|
@@ -6,10 +6,28 @@ import React, {
|
|
|
6
6
|
} from "react";
|
|
7
7
|
|
|
8
8
|
import type { WordReviewEditorRef } from "../../api/public-types";
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Opaque runtime handle for the REPL. The REPL hands the value to
|
|
12
|
+
* `new Function("runtime", ...)` for user-supplied JS evaluation and
|
|
13
|
+
* does not introspect its shape — so the dialog's prop type is
|
|
14
|
+
* deliberately structurally loose (`object | null`) rather than the
|
|
15
|
+
* non-public `DocumentRuntime`.
|
|
16
|
+
*
|
|
17
|
+
* Callers inside the component tree that actually hold a
|
|
18
|
+
* `DocumentRuntime` / `RuntimeApiHandle` / `ApiV3` can pass it here
|
|
19
|
+
* unchanged; the widening only affects compile-time autocomplete for
|
|
20
|
+
* the REPL's internal use, not the live eval target.
|
|
21
|
+
*
|
|
22
|
+
* Retired handoff §4.8 residual: the previous `DocumentRuntime`
|
|
23
|
+
* prop type was the last direct import of the non-public runtime
|
|
24
|
+
* class name in `src/ui-tailwind/**` per refactor/11 handoff §4.8
|
|
25
|
+
* remainder row.
|
|
26
|
+
*/
|
|
27
|
+
export type TwRuntimeReplTarget = object;
|
|
10
28
|
|
|
11
29
|
export interface TwRuntimeReplDialogProps {
|
|
12
|
-
runtime:
|
|
30
|
+
runtime: TwRuntimeReplTarget | null;
|
|
13
31
|
/**
|
|
14
32
|
* Optional editor ref. When provided, the REPL exposes it to evaluated
|
|
15
33
|
* expressions as `ref` — e.g. `ref.getRenderSnapshot()`. The REPL reads
|
|
@@ -370,11 +388,11 @@ export function isReplToggleShortcut(event: KeyboardEvent): boolean {
|
|
|
370
388
|
|
|
371
389
|
export async function evaluateReplExpression(
|
|
372
390
|
code: string,
|
|
373
|
-
runtime:
|
|
391
|
+
runtime: TwRuntimeReplTarget,
|
|
374
392
|
ref: WordReviewEditorRef | null = null,
|
|
375
393
|
): Promise<unknown> {
|
|
376
394
|
type ReplFn = (
|
|
377
|
-
runtime:
|
|
395
|
+
runtime: TwRuntimeReplTarget,
|
|
378
396
|
ref: WordReviewEditorRef | null,
|
|
379
397
|
) => Promise<unknown>;
|
|
380
398
|
let fn: ReplFn | null = null;
|
|
@@ -214,17 +214,17 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
|
|
|
214
214
|
capability={tableContext?.operations.setTableAlignment}
|
|
215
215
|
disabled={props.disabled}
|
|
216
216
|
onClick={() => props.onSetTableAlignment?.(align)}
|
|
217
|
-
//
|
|
218
|
-
// `
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
active={
|
|
217
|
+
// Refactor/11 handoff §4.13 — L07 shipped
|
|
218
|
+
// `TableStructureContextSnapshot.alignment` in
|
|
219
|
+
// `4cfe52a3` (2026-04-24), landing alignment on the
|
|
220
|
+
// structure context alongside the existing
|
|
221
|
+
// `PublicTableSummary.alignment`. Toggle the active
|
|
222
|
+
// state against the live value. `null` means no
|
|
223
|
+
// explicit alignment declared (parent default, typically
|
|
224
|
+
// left) — all three buttons remain inactive in that
|
|
225
|
+
// case; setting any alignment via the callback produces
|
|
226
|
+
// a subsequent snapshot with a non-null value.
|
|
227
|
+
active={tableContext?.alignment === align}
|
|
228
228
|
>
|
|
229
229
|
{align[0]!.toUpperCase()}
|
|
230
230
|
</ToolbarButton>
|
|
@@ -28,9 +28,9 @@ import type {
|
|
|
28
28
|
WordReviewEditorLayoutFacet,
|
|
29
29
|
} from "../../api/public-types";
|
|
30
30
|
import type { GeometryFacet } from "../../api/public-types";
|
|
31
|
+
import { DEFAULT_PX_PER_TWIP } from "../../api/public-types";
|
|
31
32
|
import type { OverlayCoordinateSpace } from "../chrome-overlay/chrome-overlay-projector";
|
|
32
33
|
import { projectRectToOverlay } from "../chrome-overlay/chrome-overlay-projector";
|
|
33
|
-
import { DEFAULT_PX_PER_TWIP } from "../../runtime/render/render-frame-types";
|
|
34
34
|
import { forwardNonDragClick } from "./forward-non-drag-click";
|
|
35
35
|
|
|
36
36
|
const GRIP_PX = 2;
|
|
@@ -183,6 +183,9 @@ export interface TwChromeOverlayProps {
|
|
|
183
183
|
* See `useVisiblePageIndexRange` in `src/ui-tailwind/page-stack/use-visible-block-range.ts`.
|
|
184
184
|
*/
|
|
185
185
|
visiblePageIndexRange?: { start: number; end: number } | null;
|
|
186
|
+
/** Preview catalog threaded into the page-stack chrome so header /
|
|
187
|
+
* footer / footnote / endnote regions render real <img>s. */
|
|
188
|
+
mediaPreviews?: Record<string, import("../editor-surface/pm-state-from-snapshot").MediaPreviewDescriptor>;
|
|
186
189
|
}
|
|
187
190
|
|
|
188
191
|
/**
|
|
@@ -226,6 +229,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
226
229
|
pmSurfaceElement,
|
|
227
230
|
pmView,
|
|
228
231
|
visiblePageIndexRange,
|
|
232
|
+
mediaPreviews,
|
|
229
233
|
}) => {
|
|
230
234
|
return (
|
|
231
235
|
<div
|
|
@@ -243,6 +247,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
243
247
|
pmSurfaceElement={pmSurfaceElement}
|
|
244
248
|
pmView={pmView}
|
|
245
249
|
visiblePageIndexRange={visiblePageIndexRange ?? null}
|
|
250
|
+
mediaPreviews={mediaPreviews}
|
|
246
251
|
/>
|
|
247
252
|
) : null}
|
|
248
253
|
<TwScopeRailLayer
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import type { RenderBlockDecoration } from "../../api/public-types.ts";
|
|
3
3
|
import { TwCommentPreview } from "../chrome/tw-comment-preview";
|
|
4
|
+
import {
|
|
5
|
+
projectRectToOverlay,
|
|
6
|
+
type OverlayCoordinateSpace,
|
|
7
|
+
} from "./chrome-overlay-projector.ts";
|
|
4
8
|
|
|
5
9
|
const WIDE_BREAKPOINT_PX = 1024;
|
|
6
10
|
const CONNECTOR_GAP_PX = 16;
|
|
@@ -24,14 +28,26 @@ export interface TwCommentBalloonLayerProps {
|
|
|
24
28
|
viewportWidthPx: number;
|
|
25
29
|
/** Right edge of the page frame in overlay coordinates (px). */
|
|
26
30
|
pageRightEdgePx: number;
|
|
31
|
+
/**
|
|
32
|
+
* Overlay coordinate space — subtracted from each decoration rect so
|
|
33
|
+
* balloon `top` is relative to the overlay's own top-left, not the
|
|
34
|
+
* document column. Matches `TwScopeCardLayer` / `TwTableGripLayer`
|
|
35
|
+
* precedent. Defaults to the zero-origin space so existing callers
|
|
36
|
+
* that mount the layer at the document column's origin continue to
|
|
37
|
+
* work unchanged.
|
|
38
|
+
*/
|
|
39
|
+
space?: OverlayCoordinateSpace;
|
|
27
40
|
onOpenThread?: (commentId: string) => void;
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
const ZERO_SPACE: OverlayCoordinateSpace = { originLeftPx: 0, originTopPx: 0 };
|
|
44
|
+
|
|
30
45
|
export const TwCommentBalloonLayer = React.memo(function TwCommentBalloonLayer({
|
|
31
46
|
commentDecorations,
|
|
32
47
|
commentDataById,
|
|
33
48
|
viewportWidthPx,
|
|
34
49
|
pageRightEdgePx,
|
|
50
|
+
space = ZERO_SPACE,
|
|
35
51
|
onOpenThread,
|
|
36
52
|
}: TwCommentBalloonLayerProps) {
|
|
37
53
|
if (viewportWidthPx < WIDE_BREAKPOINT_PX) return null;
|
|
@@ -42,12 +58,13 @@ export const TwCommentBalloonLayer = React.memo(function TwCommentBalloonLayer({
|
|
|
42
58
|
{commentDecorations.map((dec) => {
|
|
43
59
|
const data = commentDataById.get(dec.refId);
|
|
44
60
|
if (!data) return null;
|
|
61
|
+
const projected = projectRectToOverlay(dec.frame, space);
|
|
45
62
|
return (
|
|
46
63
|
<div
|
|
47
64
|
key={dec.refId}
|
|
48
65
|
style={{
|
|
49
66
|
position: "absolute",
|
|
50
|
-
top:
|
|
67
|
+
top: projected.top,
|
|
51
68
|
left: pageRightEdgePx + CONNECTOR_GAP_PX,
|
|
52
69
|
width: BALLOON_MAX_WIDTH_PX,
|
|
53
70
|
pointerEvents: "auto",
|
|
@@ -176,6 +176,7 @@ export function resolvePageOverlayRects(
|
|
|
176
176
|
if (!scrollRoot || count <= 0) return [];
|
|
177
177
|
widgets = measureWidgetsViaOffsetChain(scrollRoot);
|
|
178
178
|
pageCount = count;
|
|
179
|
+
// geometry:allow-dom-fallback
|
|
179
180
|
scrollHeight = scrollRoot.clientHeight;
|
|
180
181
|
} else if (
|
|
181
182
|
input !== null &&
|
|
@@ -195,6 +196,7 @@ export function resolvePageOverlayRects(
|
|
|
195
196
|
if (legacyPageCount <= 0) return [];
|
|
196
197
|
widgets = measureWidgetsViaOffsetChain(scrollRoot);
|
|
197
198
|
pageCount = legacyPageCount;
|
|
199
|
+
// geometry:allow-dom-fallback
|
|
198
200
|
scrollHeight = scrollRoot.clientHeight;
|
|
199
201
|
} else {
|
|
200
202
|
return [];
|
|
@@ -275,6 +277,10 @@ export function measureWidgetsViaBoundingRect(
|
|
|
275
277
|
},
|
|
276
278
|
): PageBoundaryMeasurement[] {
|
|
277
279
|
if (!queryRoot || !originElement) return [];
|
|
280
|
+
// Cold-open / pre-paint DOM fallback — warm path flows through
|
|
281
|
+
// `geometryFacet.getPage(i)` at the caller; this branch fires only
|
|
282
|
+
// before the first render frame.
|
|
283
|
+
// geometry:allow-dom-fallback
|
|
278
284
|
const originRect = originElement.getBoundingClientRect();
|
|
279
285
|
const normalizedVisiblePageIndexRange = normalizeVisiblePageIndexRange(
|
|
280
286
|
options?.visiblePageIndexRange,
|
|
@@ -301,6 +307,7 @@ export function measureWidgetsViaBoundingRect(
|
|
|
301
307
|
const prevPageId = widget.getAttribute("data-page-frame-end");
|
|
302
308
|
const nextPageId = widget.getAttribute("data-page-frame-start");
|
|
303
309
|
if (!prevPageId || !nextPageId) continue;
|
|
310
|
+
// geometry:allow-dom-fallback
|
|
304
311
|
const rect = widget.getBoundingClientRect();
|
|
305
312
|
out.push({
|
|
306
313
|
prevPageId,
|
|
@@ -380,10 +387,14 @@ function resolveOffsetTop(
|
|
|
380
387
|
// still defined and defaults to 0. Browsers set it relative to the
|
|
381
388
|
// offsetParent. We walk up the offset chain until we reach the scroll
|
|
382
389
|
// root (or exit the document) so the result is scroll-root-relative.
|
|
390
|
+
// Cold-open / pre-paint DOM fallback — warm path flows through
|
|
391
|
+
// `geometryFacet.getPage(i)` at the caller.
|
|
383
392
|
let node: HTMLElement | null = widget;
|
|
384
393
|
let top = 0;
|
|
385
394
|
while (node) {
|
|
395
|
+
// geometry:allow-dom-fallback
|
|
386
396
|
top += node.offsetTop ?? 0;
|
|
397
|
+
// geometry:allow-dom-fallback
|
|
387
398
|
const parent = node.offsetParent as HTMLElement | null;
|
|
388
399
|
if (parent === scrollRoot || parent === null) break;
|
|
389
400
|
node = parent;
|
|
@@ -392,6 +403,7 @@ function resolveOffsetTop(
|
|
|
392
403
|
}
|
|
393
404
|
|
|
394
405
|
function resolveOffsetHeight(widget: HTMLElement): number {
|
|
406
|
+
// geometry:allow-dom-fallback
|
|
395
407
|
return widget.offsetHeight ?? 0;
|
|
396
408
|
}
|
|
397
409
|
|
|
@@ -437,12 +449,10 @@ export interface TwPageStackOverlayLayerProps {
|
|
|
437
449
|
* owns the projection math; the overlay component re-exports from this
|
|
438
450
|
* module for back-compat with existing imports.
|
|
439
451
|
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
* a `geometryFacet` to this overlay — the DOM-measurement path remains
|
|
445
|
-
* the default.
|
|
452
|
+
* **Slice 3c reconciliation shipped 2026-04-23** — kernel `PAGE_GAP_PX`
|
|
453
|
+
* (16 → 48) now matches the DOM chrome's `interGapPx`, so `geometryFacet`
|
|
454
|
+
* is the production warm path. The DOM-measurement branch below stays as
|
|
455
|
+
* the cold-open fallback only.
|
|
446
456
|
*/
|
|
447
457
|
export const resolvePageOverlayRectsFromGeometry =
|
|
448
458
|
resolvePageOverlayRectsFromGeometryImpl;
|
|
@@ -628,17 +638,22 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
|
|
|
628
638
|
}
|
|
629
639
|
}
|
|
630
640
|
|
|
641
|
+
// Cold-open / pre-paint DOM fallback — warm path early-returned
|
|
642
|
+
// above via `geometryFacet` or the UI-API resolver. Lines below
|
|
643
|
+
// fire only before the first render frame.
|
|
631
644
|
if (origin) {
|
|
632
645
|
const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
|
|
633
646
|
pageCount,
|
|
634
647
|
visiblePageIndexRange,
|
|
635
648
|
});
|
|
649
|
+
// geometry:allow-dom-fallback
|
|
636
650
|
const originRect = origin.getBoundingClientRect();
|
|
637
651
|
setRects(
|
|
638
652
|
resolvePageOverlayRects({
|
|
639
653
|
widgets,
|
|
640
654
|
pageCount,
|
|
641
655
|
scrollHeight:
|
|
656
|
+
// geometry:allow-dom-fallback
|
|
642
657
|
origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
|
|
643
658
|
visiblePageIndexRange,
|
|
644
659
|
}),
|
|
@@ -652,6 +667,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
|
|
|
652
667
|
resolvePageOverlayRects({
|
|
653
668
|
widgets,
|
|
654
669
|
pageCount,
|
|
670
|
+
// geometry:allow-dom-fallback
|
|
655
671
|
scrollHeight: scrollRoot.clientHeight,
|
|
656
672
|
visiblePageIndexRange,
|
|
657
673
|
}),
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import type { RenderBlockDecoration } from "../../api/public-types.ts";
|
|
3
3
|
import { AUTHOR_PALETTE } from "../../ui/headless/revision-decoration-model";
|
|
4
|
+
import {
|
|
5
|
+
projectRectToOverlay,
|
|
6
|
+
type OverlayCoordinateSpace,
|
|
7
|
+
} from "./chrome-overlay-projector.ts";
|
|
4
8
|
|
|
5
9
|
const BAR_WIDTH_PX = 3;
|
|
6
10
|
const BAR_LEFT_OFFSET_PX = 8;
|
|
@@ -15,12 +19,24 @@ export interface TwRevisionMarginBarLayerProps {
|
|
|
15
19
|
authorPaletteIndexById: ReadonlyMap<string, number>;
|
|
16
20
|
/** Left edge of the page body in overlay coordinates (px). */
|
|
17
21
|
pageBodyLeftPx: number;
|
|
22
|
+
/**
|
|
23
|
+
* Overlay coordinate space — subtracted from each decoration rect so
|
|
24
|
+
* bar `top` is relative to the overlay's own top-left, not the
|
|
25
|
+
* document column. Matches `TwScopeCardLayer` / `TwTableGripLayer`
|
|
26
|
+
* precedent. Defaults to the zero-origin space so existing callers
|
|
27
|
+
* that mount the layer at the document column's origin continue to
|
|
28
|
+
* work unchanged.
|
|
29
|
+
*/
|
|
30
|
+
space?: OverlayCoordinateSpace;
|
|
18
31
|
}
|
|
19
32
|
|
|
33
|
+
const ZERO_SPACE: OverlayCoordinateSpace = { originLeftPx: 0, originTopPx: 0 };
|
|
34
|
+
|
|
20
35
|
export const TwRevisionMarginBarLayer = React.memo(function TwRevisionMarginBarLayer({
|
|
21
36
|
revisionDecorations,
|
|
22
37
|
authorPaletteIndexById,
|
|
23
38
|
pageBodyLeftPx,
|
|
39
|
+
space = ZERO_SPACE,
|
|
24
40
|
}: TwRevisionMarginBarLayerProps) {
|
|
25
41
|
if (revisionDecorations.length === 0) return null;
|
|
26
42
|
|
|
@@ -29,13 +45,14 @@ export const TwRevisionMarginBarLayer = React.memo(function TwRevisionMarginBarL
|
|
|
29
45
|
{revisionDecorations.map((dec, idx) => {
|
|
30
46
|
const paletteIdx = authorPaletteIndexById.get(dec.refId) ?? 0;
|
|
31
47
|
const color = AUTHOR_PALETTE[paletteIdx % AUTHOR_PALETTE.length];
|
|
48
|
+
const projected = projectRectToOverlay(dec.frame, space);
|
|
32
49
|
return (
|
|
33
50
|
<div
|
|
34
51
|
key={`rev-bar-${dec.refId}-${idx}`}
|
|
35
52
|
aria-hidden
|
|
36
53
|
style={{
|
|
37
54
|
position: "absolute",
|
|
38
|
-
top:
|
|
55
|
+
top: projected.top,
|
|
39
56
|
left: pageBodyLeftPx - BAR_LEFT_OFFSET_PX - BAR_WIDTH_PX,
|
|
40
57
|
width: BAR_WIDTH_PX,
|
|
41
58
|
height: dec.frame.heightPx,
|
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { Decoration } from "prosemirror-view";
|
|
27
|
-
import
|
|
28
|
-
|
|
27
|
+
import {
|
|
28
|
+
type RuntimePageGraph,
|
|
29
|
+
resolvePageFieldDisplayText,
|
|
30
|
+
} from "../../api/public-types.ts";
|
|
29
31
|
|
|
30
32
|
export const PAGE_CHROME_DEFAULTS = {
|
|
31
33
|
headerBandPx: 32,
|
|
@@ -93,7 +95,7 @@ export function buildPageBreakDecorations(
|
|
|
93
95
|
input: PageBreakDecorationInput,
|
|
94
96
|
): Decoration[] {
|
|
95
97
|
const { graph, posture, runtimeToPmOffset } = input;
|
|
96
|
-
if (!graph
|
|
98
|
+
if (!graph) return [];
|
|
97
99
|
|
|
98
100
|
const headerBandPx =
|
|
99
101
|
input.headerBandPx ?? PAGE_CHROME_DEFAULTS.headerBandPx;
|
|
@@ -103,6 +105,15 @@ export function buildPageBreakDecorations(
|
|
|
103
105
|
|
|
104
106
|
const decorations: Decoration[] = [];
|
|
105
107
|
|
|
108
|
+
// Inter-page boundary widgets (existing) — emitted first so
|
|
109
|
+
// `decorations[0..N-2]` remain boundary widgets for any legacy
|
|
110
|
+
// callers that index by position. Per-page content-anchor widgets
|
|
111
|
+
// (coord-11 §19) are emitted in a second pass at the end of this
|
|
112
|
+
// function.
|
|
113
|
+
if (graph.pages.length < 2) {
|
|
114
|
+
return buildPageAnchorDecorationsInto(decorations, graph, posture, runtimeToPmOffset);
|
|
115
|
+
}
|
|
116
|
+
|
|
106
117
|
for (let i = 1; i < graph.pages.length; i += 1) {
|
|
107
118
|
const prev = graph.pages[i - 1]!;
|
|
108
119
|
const next = graph.pages[i]!;
|
|
@@ -160,6 +171,58 @@ export function buildPageBreakDecorations(
|
|
|
160
171
|
),
|
|
161
172
|
);
|
|
162
173
|
}
|
|
174
|
+
return buildPageAnchorDecorationsInto(decorations, graph, posture, runtimeToPmOffset);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Append per-page content-anchor widgets to `decorations` for coord-11
|
|
179
|
+
* §19. One zero-height anchor per non-filler page at the page's content-
|
|
180
|
+
* start offset. Chrome-preset-independent (PM widget = content layer) +
|
|
181
|
+
* markup-mode stable (document-offset-anchored, not chrome-dependent).
|
|
182
|
+
*
|
|
183
|
+
* Each anchor carries `data-page-content-wrapper` + `data-page-number`
|
|
184
|
+
* (1-based, skipping blank fillers) + `data-page-id` (from
|
|
185
|
+
* `RuntimePageNode.pageId` — stable across chrome/markup transitions).
|
|
186
|
+
* Consumed by `runtime.viewport.getPageAnchor(n)` (coord-07 §2.9) and
|
|
187
|
+
* `ui.viewport.scrollToPage(n)` (coord-10 §γ); also by the visual-
|
|
188
|
+
* fidelity harness at `test/visual-fidelity/` per coord-11 §20.
|
|
189
|
+
*
|
|
190
|
+
* Anchors use `side: 1` so they sit AFTER the document position
|
|
191
|
+
* (inside page N's content). Boundary widgets use `side: -1` so they
|
|
192
|
+
* sit BEFORE the same offset (at the end of page N-1's content + gap).
|
|
193
|
+
* Ordering places each anchor at the top of its page's content area.
|
|
194
|
+
*/
|
|
195
|
+
function buildPageAnchorDecorationsInto(
|
|
196
|
+
decorations: Decoration[],
|
|
197
|
+
graph: RuntimePageGraph,
|
|
198
|
+
posture: "page" | "canvas",
|
|
199
|
+
runtimeToPmOffset: ((runtimeOffset: number) => number | null) | undefined,
|
|
200
|
+
): Decoration[] {
|
|
201
|
+
let contentPageOrdinal = 0;
|
|
202
|
+
for (const page of graph.pages) {
|
|
203
|
+
if (page.isBlankFiller) continue;
|
|
204
|
+
contentPageOrdinal += 1;
|
|
205
|
+
const pagePmOffset = runtimeToPmOffset
|
|
206
|
+
? runtimeToPmOffset(page.startOffset)
|
|
207
|
+
: page.startOffset;
|
|
208
|
+
if (pagePmOffset === null || pagePmOffset === undefined) continue;
|
|
209
|
+
const anchorPageNumber = contentPageOrdinal;
|
|
210
|
+
const anchorPageId = page.pageId;
|
|
211
|
+
decorations.push(
|
|
212
|
+
Decoration.widget(
|
|
213
|
+
pagePmOffset,
|
|
214
|
+
() => buildPageAnchorWidgetDom({
|
|
215
|
+
pageNumber: anchorPageNumber,
|
|
216
|
+
pageId: anchorPageId,
|
|
217
|
+
}),
|
|
218
|
+
{
|
|
219
|
+
side: 1,
|
|
220
|
+
key: `pa-${page.pageId}-${posture}`,
|
|
221
|
+
ignoreSelection: true,
|
|
222
|
+
},
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
163
226
|
return decorations;
|
|
164
227
|
}
|
|
165
228
|
|
|
@@ -262,6 +325,38 @@ export function __resetPageBreakWidgetCache(): void {
|
|
|
262
325
|
widgetDomCache.clear();
|
|
263
326
|
}
|
|
264
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Build the per-page content-anchor widget DOM for coord-11 §19.
|
|
330
|
+
*
|
|
331
|
+
* Zero-height, non-visual marker carrying `data-page-content-wrapper`,
|
|
332
|
+
* `data-page-number` (1-based), and `data-page-id` (stable across
|
|
333
|
+
* chrome preset + markup mode transitions — sourced from
|
|
334
|
+
* `RuntimePageNode.pageId`). Consumed by `runtime.viewport.getPageAnchor(n)`
|
|
335
|
+
* (coord-07 §2.9) + `ui.viewport.scrollToPage(n)` (coord-10 §γ) and by
|
|
336
|
+
* the visual-fidelity harness.
|
|
337
|
+
*
|
|
338
|
+
* Emitted as a PM widget via `buildPageBreakDecorations` so it lives
|
|
339
|
+
* on the content layer (present under chrome=none) rather than on an
|
|
340
|
+
* absolute-positioned chrome overlay.
|
|
341
|
+
*/
|
|
342
|
+
function buildPageAnchorWidgetDom(input: {
|
|
343
|
+
pageNumber: number;
|
|
344
|
+
pageId: string;
|
|
345
|
+
}): HTMLElement {
|
|
346
|
+
const root = document.createElement("span");
|
|
347
|
+
root.setAttribute("data-kind", "page-content-anchor");
|
|
348
|
+
root.setAttribute("data-page-content-wrapper", "");
|
|
349
|
+
root.setAttribute("data-page-number", String(input.pageNumber));
|
|
350
|
+
root.setAttribute("data-page-id", input.pageId);
|
|
351
|
+
root.setAttribute("aria-hidden", "true");
|
|
352
|
+
root.contentEditable = "false";
|
|
353
|
+
root.style.display = "block";
|
|
354
|
+
root.style.height = "0";
|
|
355
|
+
root.style.width = "100%";
|
|
356
|
+
root.style.userSelect = "none";
|
|
357
|
+
return root;
|
|
358
|
+
}
|
|
359
|
+
|
|
265
360
|
function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
|
|
266
361
|
const root = document.createElement("div");
|
|
267
362
|
root.className = "wre-page-chrome-widget";
|