@beyondwork/docx-react-component 1.0.43 → 1.0.46
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 +35 -1
- package/package.json +44 -32
- package/src/api/public-types.ts +156 -3
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +27 -2
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +16 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +21 -1
- 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/normalize/normalize-text.ts +33 -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/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/document-runtime.ts +351 -25
- 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-surface/capabilities.ts +411 -0
- package/src/runtime/event-refresh-hints.ts +1 -0
- package/src/runtime/layout/docx-font-loader.ts +30 -11
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +46 -0
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/public-facet.ts +30 -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/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +35 -2
- package/src/ui/WordReviewEditor.tsx +75 -192
- package/src/ui/editor-runtime-boundary.ts +5 -1
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +23 -0
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +38 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +131 -29
|
@@ -30,7 +30,12 @@ import { resolvePageFieldDisplayText } from "../../runtime/layout/resolve-page-f
|
|
|
30
30
|
export const PAGE_CHROME_DEFAULTS = {
|
|
31
31
|
headerBandPx: 32,
|
|
32
32
|
footerBandPx: 32,
|
|
33
|
-
|
|
33
|
+
// L8 polish (2026-04-19): bumped from 24 → 48 so the workspace canvas
|
|
34
|
+
// between papers reads as a real break between discrete pages, not a
|
|
35
|
+
// seam inside one long paper. Phase D's discrete paper cards will
|
|
36
|
+
// re-tune once overlay-owned gaps ship; until then the widget spacer
|
|
37
|
+
// carries the full gap height.
|
|
38
|
+
interGapPx: 48,
|
|
34
39
|
} as const;
|
|
35
40
|
|
|
36
41
|
export function totalPageBreakGapPx(
|
|
@@ -73,6 +78,15 @@ export interface PageBreakDecorationInput {
|
|
|
73
78
|
headerPreviewByPageId?: ReadonlyMap<string, string>;
|
|
74
79
|
/** Same shape for footers. */
|
|
75
80
|
footerPreviewByPageId?: ReadonlyMap<string, string>;
|
|
81
|
+
/**
|
|
82
|
+
* L7 Phase 2 Task 2.2.4a — optional per-page block-index range so the
|
|
83
|
+
* viewport-tracking hook can translate page visibility into block indices
|
|
84
|
+
* without re-walking the surface blocks. Keyed by pageIndex (0-based).
|
|
85
|
+
* When omitted, the chrome widgets are emitted without block-index
|
|
86
|
+
* attributes and the hook falls back to its ESTIMATED_BLOCKS_PER_PAGE
|
|
87
|
+
* heuristic.
|
|
88
|
+
*/
|
|
89
|
+
blockIndexRangeByPageIndex?: ReadonlyMap<number, { first: number; last: number }>;
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
export function buildPageBreakDecorations(
|
|
@@ -88,6 +102,7 @@ export function buildPageBreakDecorations(
|
|
|
88
102
|
const interGapPx = input.interGapPx ?? PAGE_CHROME_DEFAULTS.interGapPx;
|
|
89
103
|
|
|
90
104
|
const decorations: Decoration[] = [];
|
|
105
|
+
|
|
91
106
|
for (let i = 1; i < graph.pages.length; i += 1) {
|
|
92
107
|
const prev = graph.pages[i - 1]!;
|
|
93
108
|
const next = graph.pages[i]!;
|
|
@@ -106,6 +121,8 @@ export function buildPageBreakDecorations(
|
|
|
106
121
|
const nextHeaderPreview =
|
|
107
122
|
input.headerPreviewByPageId?.get(next.pageId) ?? "";
|
|
108
123
|
|
|
124
|
+
const nextBlockRange = input.blockIndexRangeByPageIndex?.get(next.pageIndex);
|
|
125
|
+
|
|
109
126
|
decorations.push(
|
|
110
127
|
Decoration.widget(
|
|
111
128
|
pmOffset,
|
|
@@ -125,6 +142,8 @@ export function buildPageBreakDecorations(
|
|
|
125
142
|
hasNextHeaderStory: Boolean(next.stories.header),
|
|
126
143
|
prevFooterPreview,
|
|
127
144
|
nextHeaderPreview,
|
|
145
|
+
nextPageFirstBlockIndex: nextBlockRange?.first ?? -1,
|
|
146
|
+
nextPageLastBlockIndex: nextBlockRange?.last ?? -1,
|
|
128
147
|
}),
|
|
129
148
|
{
|
|
130
149
|
side: -1,
|
|
@@ -176,6 +195,16 @@ interface ChromeWidgetInput {
|
|
|
176
195
|
hasNextHeaderStory: boolean;
|
|
177
196
|
prevFooterPreview: string;
|
|
178
197
|
nextHeaderPreview: string;
|
|
198
|
+
/**
|
|
199
|
+
* L7 Phase 2 Task 2.2.4a — inclusive first block index of the next page.
|
|
200
|
+
* -1 when block-index info was not supplied to the decoration builder.
|
|
201
|
+
*/
|
|
202
|
+
nextPageFirstBlockIndex: number;
|
|
203
|
+
/**
|
|
204
|
+
* L7 Phase 2 Task 2.2.4a — inclusive last block index of the next page.
|
|
205
|
+
* -1 when block-index info was not supplied to the decoration builder.
|
|
206
|
+
*/
|
|
207
|
+
nextPageLastBlockIndex: number;
|
|
179
208
|
}
|
|
180
209
|
|
|
181
210
|
// P14.c — cache the widget DOM by input identity. PM rebuilds the
|
|
@@ -205,6 +234,8 @@ function widgetCacheKey(input: ChromeWidgetInput): string {
|
|
|
205
234
|
input.hasNextHeaderStory ? "1" : "0",
|
|
206
235
|
input.prevFooterPreview,
|
|
207
236
|
input.nextHeaderPreview,
|
|
237
|
+
input.nextPageFirstBlockIndex,
|
|
238
|
+
input.nextPageLastBlockIndex,
|
|
208
239
|
].join("\x1f");
|
|
209
240
|
}
|
|
210
241
|
|
|
@@ -238,14 +269,27 @@ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
|
|
|
238
269
|
root.setAttribute("data-posture", input.posture);
|
|
239
270
|
root.setAttribute("data-prev-page-id", input.prevPageId);
|
|
240
271
|
root.setAttribute("data-next-page-id", input.nextPageId);
|
|
241
|
-
|
|
242
|
-
|
|
272
|
+
// NOTE: data-prev-page-index and data-next-page-index were removed — they had
|
|
273
|
+
// no reader after data-page-frame was added (Task 2.2.4a). The page-id attrs
|
|
274
|
+
// above are still consumed by the chrome overlay (see line ~265 in this file).
|
|
243
275
|
// P3.a: expose page-frame boundary markers so the page stack and tests
|
|
244
276
|
// can enumerate pages without re-walking the render graph. Each widget
|
|
245
277
|
// ends page N (`prev`) and starts page N+1 (`next`); the outer workspace
|
|
246
278
|
// frame still accounts for the boundaries at both ends of the document.
|
|
247
279
|
root.setAttribute("data-page-frame-end", input.prevPageId);
|
|
248
280
|
root.setAttribute("data-page-frame-start", input.nextPageId);
|
|
281
|
+
// L7 Phase 2 Task 2.2.4a — per-page markers for the viewport-tracking hook
|
|
282
|
+
// (`useVisibleBlockRange`). Each boundary widget identifies the page that
|
|
283
|
+
// STARTS at this boundary (i.e. `nextPageIndex`). The hook's
|
|
284
|
+
// IntersectionObserver picks these up via `[data-page-frame]` to compute the
|
|
285
|
+
// visible block range. Block-index attributes are set only when the caller
|
|
286
|
+
// supplied a `blockIndexRangeByPageIndex` map (value ≥ 0); otherwise they
|
|
287
|
+
// are omitted so the hook's fallback estimate is used cleanly.
|
|
288
|
+
root.setAttribute("data-page-frame", String(input.nextPageIndex));
|
|
289
|
+
if (input.nextPageFirstBlockIndex >= 0) {
|
|
290
|
+
root.setAttribute("data-page-first-block-index", String(input.nextPageFirstBlockIndex));
|
|
291
|
+
root.setAttribute("data-page-last-block-index", String(input.nextPageLastBlockIndex));
|
|
292
|
+
}
|
|
249
293
|
root.contentEditable = "false";
|
|
250
294
|
root.setAttribute("aria-hidden", "false");
|
|
251
295
|
root.style.display = "block";
|
|
@@ -286,175 +330,42 @@ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
|
|
|
286
330
|
return root;
|
|
287
331
|
}
|
|
288
332
|
|
|
289
|
-
//
|
|
333
|
+
// L8 Phase B (2026-04-19): the page-posture widget is a **transparent
|
|
334
|
+
// spacer**. Inline band / gap / band chrome is retired — the real H/F
|
|
335
|
+
// bands are painted by `TwPageStackChromeLayer` as per-page overlays
|
|
336
|
+
// (P8.11). The spacer keeps the same total height as the retired stack
|
|
337
|
+
// so `TwPageStackOverlayLayer.measureWidgetsViaBoundingRect` continues to
|
|
338
|
+
// produce the same page-overlay rect math; L8 Phase D will take over
|
|
339
|
+
// painting the gap and reduce this to 0.
|
|
340
|
+
//
|
|
341
|
+
// L8 polish (2026-04-19): the spacer carries a subtle, borderless
|
|
342
|
+
// page-count label centered in the inter-page gap so the workspace
|
|
343
|
+
// canvas reads as "two papers with a page number between them" — no
|
|
344
|
+
// pill, no border, just small muted text.
|
|
290
345
|
root.style.height = `${
|
|
291
346
|
input.footerBandPx + input.interGapPx + input.headerBandPx
|
|
292
347
|
}px`;
|
|
293
348
|
root.style.position = "relative";
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
kind: "footer",
|
|
297
|
-
pageId: input.prevPageId,
|
|
298
|
-
pageIndex: input.prevPageIndex,
|
|
299
|
-
pageLabel: input.prevPageLabel,
|
|
300
|
-
bandPx: input.footerBandPx,
|
|
301
|
-
position: "top",
|
|
302
|
-
hasStory: input.hasPrevFooterStory,
|
|
303
|
-
previewText: input.prevFooterPreview,
|
|
304
|
-
});
|
|
305
|
-
root.appendChild(footer);
|
|
306
|
-
|
|
307
|
-
// P3.a: the inter-page gap is now a visible canvas strip that reads as
|
|
308
|
-
// "the space between two papers", not a subtle gradient inside one white
|
|
309
|
-
// page. The strip paints in the workspace canvas color (the same color
|
|
310
|
-
// the page frames float on in page mode), with subtle drop/rise shadows
|
|
311
|
-
// on either edge so the preceding footer reads as "bottom of page N's
|
|
312
|
-
// paper" and the following header reads as "top of page N+1's paper".
|
|
313
|
-
//
|
|
314
|
-
// The visual goal: bringing the user closer to a Word-native perception
|
|
315
|
-
// of distinct pages without requiring the PM editable tree to be split
|
|
316
|
-
// into per-page subtrees (that lands in P3.b).
|
|
317
|
-
const separator = document.createElement("div");
|
|
318
|
-
separator.className = "wre-page-chrome-separator";
|
|
319
|
-
separator.setAttribute("data-kind", "page-chrome-separator");
|
|
320
|
-
separator.style.position = "absolute";
|
|
321
|
-
separator.style.left = "0";
|
|
322
|
-
separator.style.right = "0";
|
|
323
|
-
separator.style.top = `${input.footerBandPx}px`;
|
|
324
|
-
separator.style.height = `${input.interGapPx}px`;
|
|
325
|
-
// Canvas color (same as the scroll root's bg-surface) so the strip reads
|
|
326
|
-
// as "gap between two papers". Page mode and canvas mode both use the
|
|
327
|
-
// same token so the UX remains consistent at any chrome preset.
|
|
328
|
-
separator.style.backgroundColor = "var(--color-surface, #f1f5f9)";
|
|
329
|
-
// Inner shadows on top/bottom: the previous footer's bottom edge gains
|
|
330
|
-
// a subtle paper-edge shadow, and the next header's top edge likewise.
|
|
331
|
-
// Inset shadows let us avoid touching the footer / header DOM while
|
|
332
|
-
// keeping the shadows flush with the band borders.
|
|
333
|
-
separator.style.boxShadow = [
|
|
334
|
-
// Top edge — simulates the bottom shadow of page N's paper.
|
|
335
|
-
"inset 0 1px 0 rgba(15, 23, 42, 0.06)",
|
|
336
|
-
"inset 0 2px 3px -2px rgba(15, 23, 42, 0.12)",
|
|
337
|
-
// Bottom edge — simulates the top shadow of page N+1's paper.
|
|
338
|
-
"inset 0 -1px 0 rgba(15, 23, 42, 0.06)",
|
|
339
|
-
"inset 0 -2px 3px -2px rgba(15, 23, 42, 0.12)",
|
|
340
|
-
].join(", ");
|
|
341
|
-
root.appendChild(separator);
|
|
342
|
-
|
|
343
|
-
const header = buildBand({
|
|
344
|
-
kind: "header",
|
|
345
|
-
pageId: input.nextPageId,
|
|
346
|
-
pageIndex: input.nextPageIndex,
|
|
347
|
-
pageLabel: input.nextPageLabel,
|
|
348
|
-
bandPx: input.headerBandPx,
|
|
349
|
-
position: "bottom",
|
|
350
|
-
topOffsetPx: input.footerBandPx + input.interGapPx,
|
|
351
|
-
hasStory: input.hasNextHeaderStory,
|
|
352
|
-
previewText: input.nextHeaderPreview,
|
|
353
|
-
});
|
|
354
|
-
root.appendChild(header);
|
|
355
|
-
|
|
356
|
-
return root;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function buildBand(input: {
|
|
360
|
-
kind: "header" | "footer";
|
|
361
|
-
pageId: string;
|
|
362
|
-
pageIndex: number;
|
|
363
|
-
pageLabel: string;
|
|
364
|
-
bandPx: number;
|
|
365
|
-
position: "top" | "bottom";
|
|
366
|
-
topOffsetPx?: number;
|
|
367
|
-
hasStory: boolean;
|
|
368
|
-
previewText: string;
|
|
369
|
-
}): HTMLElement {
|
|
370
|
-
const band = document.createElement("div");
|
|
371
|
-
band.className = `wre-page-chrome-band wre-page-chrome-band-${input.kind}`;
|
|
372
|
-
band.setAttribute("data-band-kind", input.kind);
|
|
373
|
-
band.setAttribute("data-page-id", input.pageId);
|
|
374
|
-
band.setAttribute("data-page-index", String(input.pageIndex));
|
|
375
|
-
band.style.position = "absolute";
|
|
376
|
-
band.style.left = "0";
|
|
377
|
-
band.style.right = "0";
|
|
378
|
-
band.style.top = `${input.topOffsetPx ?? 0}px`;
|
|
379
|
-
band.style.height = `${input.bandPx}px`;
|
|
380
|
-
band.style.display = "flex";
|
|
381
|
-
band.style.alignItems = "center";
|
|
382
|
-
band.style.justifyContent = "space-between";
|
|
383
|
-
band.style.padding = "0 16px";
|
|
384
|
-
band.style.fontSize = "10px";
|
|
385
|
-
band.style.letterSpacing = "0.12em";
|
|
386
|
-
band.style.textTransform = "uppercase";
|
|
387
|
-
band.style.color = "var(--color-text-tertiary, #6b7280)";
|
|
388
|
-
band.style.backgroundColor =
|
|
389
|
-
"var(--color-surface-subtle, rgba(0,0,0,0.02))";
|
|
390
|
-
band.style.borderTop =
|
|
391
|
-
input.kind === "header"
|
|
392
|
-
? "1px solid var(--color-border, rgba(0,0,0,0.08))"
|
|
393
|
-
: "none";
|
|
394
|
-
band.style.borderBottom =
|
|
395
|
-
input.kind === "footer"
|
|
396
|
-
? "1px solid var(--color-border, rgba(0,0,0,0.08))"
|
|
397
|
-
: "none";
|
|
398
|
-
// Bands are interactive: double-click fires a custom event the shell
|
|
399
|
-
// forwards to `runtime.openStory()`.
|
|
400
|
-
band.style.pointerEvents = "auto";
|
|
401
|
-
band.style.cursor = input.hasStory ? "pointer" : "default";
|
|
402
|
-
band.title = input.hasStory
|
|
403
|
-
? `Double-click to edit ${input.kind}`
|
|
404
|
-
: `No ${input.kind} defined for this page`;
|
|
349
|
+
root.style.pointerEvents = "none";
|
|
350
|
+
root.setAttribute("aria-hidden", "true");
|
|
405
351
|
|
|
406
352
|
const label = document.createElement("span");
|
|
407
|
-
label.className = "wre-page-chrome-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
} else {
|
|
422
|
-
label.textContent = input.kind === "header" ? "Header" : "Footer";
|
|
423
|
-
}
|
|
424
|
-
band.appendChild(label);
|
|
425
|
-
|
|
426
|
-
const pageLabel = document.createElement("span");
|
|
427
|
-
pageLabel.className = "wre-page-chrome-band-page";
|
|
428
|
-
pageLabel.textContent = input.pageLabel;
|
|
429
|
-
band.appendChild(pageLabel);
|
|
353
|
+
label.className = "wre-page-chrome-gap-label";
|
|
354
|
+
label.setAttribute("data-kind", "page-gap-label");
|
|
355
|
+
label.textContent = input.nextPageLabel;
|
|
356
|
+
label.style.position = "absolute";
|
|
357
|
+
label.style.left = "50%";
|
|
358
|
+
label.style.top = "50%";
|
|
359
|
+
label.style.transform = "translate(-50%, -50%)";
|
|
360
|
+
label.style.fontSize = "10px";
|
|
361
|
+
label.style.letterSpacing = "0.12em";
|
|
362
|
+
label.style.textTransform = "uppercase";
|
|
363
|
+
label.style.color = "var(--color-tertiary, #6b7280)";
|
|
364
|
+
// Intentionally no background, no border, no padding — the label floats
|
|
365
|
+
// transparently in the workspace-canvas gap.
|
|
366
|
+
root.appendChild(label);
|
|
430
367
|
|
|
431
|
-
|
|
432
|
-
band.addEventListener("dblclick", (event) => {
|
|
433
|
-
event.stopPropagation();
|
|
434
|
-
event.preventDefault();
|
|
435
|
-
const eventName =
|
|
436
|
-
input.kind === "header"
|
|
437
|
-
? "wre-open-header-story-for-page"
|
|
438
|
-
: "wre-open-footer-story-for-page";
|
|
439
|
-
// Use the band's owning document's `CustomEvent` constructor so the
|
|
440
|
-
// event passes through jsdom's instance-of check. In a real browser
|
|
441
|
-
// `band.ownerDocument.defaultView.CustomEvent` is the same as the
|
|
442
|
-
// global `CustomEvent`; in jsdom the two differ and the global one
|
|
443
|
-
// fails `dispatchEvent`'s internal Event-type convert step.
|
|
444
|
-
const view = band.ownerDocument?.defaultView as
|
|
445
|
-
| (Window & typeof globalThis)
|
|
446
|
-
| null;
|
|
447
|
-
const Ctor = view?.CustomEvent ?? CustomEvent;
|
|
448
|
-
band.dispatchEvent(
|
|
449
|
-
new Ctor(eventName, {
|
|
450
|
-
bubbles: true,
|
|
451
|
-
detail: { pageIndex: input.pageIndex, pageId: input.pageId },
|
|
452
|
-
}),
|
|
453
|
-
);
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
return band;
|
|
368
|
+
return root;
|
|
458
369
|
}
|
|
459
370
|
|
|
460
371
|
/**
|
|
@@ -160,9 +160,26 @@ export const editorSchema = new Schema({
|
|
|
160
160
|
bidi: { default: null },
|
|
161
161
|
pageBreakBefore: { default: null },
|
|
162
162
|
hiddenTextOnly: { default: null },
|
|
163
|
+
placeholderCulled: { default: null },
|
|
164
|
+
blockId: { default: null },
|
|
163
165
|
},
|
|
164
166
|
parseDOM: [{ tag: "p" }],
|
|
165
167
|
toDOM(node) {
|
|
168
|
+
// Viewport-culled placeholder paragraph — cheap size-preserving leaf.
|
|
169
|
+
if (node.attrs.placeholderCulled) {
|
|
170
|
+
return [
|
|
171
|
+
"div",
|
|
172
|
+
{
|
|
173
|
+
"data-node-type": "paragraph-placeholder",
|
|
174
|
+
"data-placeholder-culled": "true",
|
|
175
|
+
"data-placeholder-size": String(node.nodeSize),
|
|
176
|
+
"data-placeholder-block-id": node.attrs.blockId ?? "",
|
|
177
|
+
style: "min-height: 20px; contain: strict;",
|
|
178
|
+
"aria-hidden": "true",
|
|
179
|
+
},
|
|
180
|
+
0,
|
|
181
|
+
];
|
|
182
|
+
}
|
|
166
183
|
const classes: string[] = ["leading-relaxed"];
|
|
167
184
|
const styleId = node.attrs.styleId as string | null;
|
|
168
185
|
const outlineLevel = node.attrs.outlineLevel as number | null;
|
|
@@ -893,6 +910,7 @@ export const editorSchema = new Schema({
|
|
|
893
910
|
label: { default: "Locked" },
|
|
894
911
|
detail: { default: "" },
|
|
895
912
|
presentation: { default: "callout" },
|
|
913
|
+
placeholderSize: { default: null },
|
|
896
914
|
},
|
|
897
915
|
toDOM(node) {
|
|
898
916
|
const fragmentId = node.attrs.fragmentId as string;
|
|
@@ -700,6 +700,29 @@ function buildOpaqueBlock(
|
|
|
700
700
|
block: Extract<SurfaceBlockSnapshot, { kind: "opaque_block" }>,
|
|
701
701
|
showUnsupportedObjectPreviews: boolean,
|
|
702
702
|
): PMNode {
|
|
703
|
+
// Viewport-culled placeholder: emit a single paragraph with ZWSP text so
|
|
704
|
+
// PM position math matches the original block span.
|
|
705
|
+
// See docs/plans/lane-2-render-performance.md Task 2.2.3.
|
|
706
|
+
const placeholderSize = block.placeholderSize ?? null;
|
|
707
|
+
if (placeholderSize !== null) {
|
|
708
|
+
const targetSize = placeholderSize as number;
|
|
709
|
+
if (targetSize <= 2) {
|
|
710
|
+
// Edge case: bare empty paragraph claims exactly 2 positions.
|
|
711
|
+
return editorSchema.nodes.paragraph.create(
|
|
712
|
+
{ blockId: block.blockId, placeholderCulled: true },
|
|
713
|
+
Fragment.empty,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
// General case: one paragraph with (targetSize - 2) ZWSP chars so the
|
|
717
|
+
// total PM positions = 1 (open) + (targetSize - 2) (text) + 1 (close) = targetSize.
|
|
718
|
+
const filler = "\u200b".repeat(targetSize - 2);
|
|
719
|
+
return editorSchema.nodes.paragraph.create(
|
|
720
|
+
{ blockId: block.blockId, placeholderCulled: true },
|
|
721
|
+
editorSchema.text(filler),
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Real (non-placeholder) opaque_block: existing behaviour unchanged.
|
|
703
726
|
return editorSchema.nodes.opaque_block.create({
|
|
704
727
|
fragmentId: block.fragmentId,
|
|
705
728
|
warningId: block.warningId,
|
|
@@ -23,6 +23,7 @@ export function createSurfaceDocumentBuildKey(input: {
|
|
|
23
23
|
mediaPreviewKey: string;
|
|
24
24
|
showUnsupportedObjectPreviews?: boolean;
|
|
25
25
|
}): string {
|
|
26
|
+
const vp = input.surface?.viewportBlockRange ?? null;
|
|
26
27
|
return JSON.stringify({
|
|
27
28
|
surfaceIdentity:
|
|
28
29
|
input.surface === undefined || input.surface === null
|
|
@@ -31,6 +32,7 @@ export function createSurfaceDocumentBuildKey(input: {
|
|
|
31
32
|
activeStory: input.activeStory,
|
|
32
33
|
mediaPreviewKey: input.mediaPreviewKey,
|
|
33
34
|
showUnsupportedObjectPreviews: input.showUnsupportedObjectPreviews ?? false,
|
|
35
|
+
viewport: vp ? `${vp.start}:${vp.end}` : "full",
|
|
34
36
|
});
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -98,6 +98,7 @@ function buildPageBreakDecorationsFromProps(
|
|
|
98
98
|
footerBandPx?: number;
|
|
99
99
|
interGapPx?: number;
|
|
100
100
|
} = {},
|
|
101
|
+
surfaceBlocks?: readonly import("../../api/public-types.ts").SurfaceBlockSnapshot[],
|
|
101
102
|
): ReturnType<typeof buildPageBreakDecorations> {
|
|
102
103
|
if (!facet || !isMainStory) return [];
|
|
103
104
|
if (typeof facet.getRenderFrame !== "function") return [];
|
|
@@ -133,6 +134,41 @@ function buildPageBreakDecorationsFromProps(
|
|
|
133
134
|
})
|
|
134
135
|
: undefined;
|
|
135
136
|
|
|
137
|
+
// L7 Phase 2 Task 2.2.4a — compute per-page block-index ranges from the
|
|
138
|
+
// render frame's page offsets + the surface blocks list. Each block has a
|
|
139
|
+
// `from`/`to` offset; we find the first and last block whose offset range
|
|
140
|
+
// falls within each page's [startOffset, nextPage.startOffset) window.
|
|
141
|
+
// This map is passed into `buildPageBreakDecorations` so the chrome widgets
|
|
142
|
+
// carry `data-page-first-block-index` / `data-page-last-block-index`
|
|
143
|
+
// attributes needed by `useVisibleBlockRange`.
|
|
144
|
+
let blockIndexRangeByPageIndex: Map<number, { first: number; last: number }> | undefined;
|
|
145
|
+
if (surfaceBlocks && surfaceBlocks.length > 0 && frame.pages.length > 0) {
|
|
146
|
+
blockIndexRangeByPageIndex = new Map();
|
|
147
|
+
for (let pi = 0; pi < frame.pages.length; pi++) {
|
|
148
|
+
const page = frame.pages[pi]!;
|
|
149
|
+
if (page.page.isBlankFiller) continue;
|
|
150
|
+
const pageStart = page.page.startOffset;
|
|
151
|
+
const pageEnd =
|
|
152
|
+
pi + 1 < frame.pages.length
|
|
153
|
+
? frame.pages[pi + 1]!.page.startOffset
|
|
154
|
+
: Infinity;
|
|
155
|
+
let first = -1;
|
|
156
|
+
let last = -1;
|
|
157
|
+
for (let bi = 0; bi < surfaceBlocks.length; bi++) {
|
|
158
|
+
const block = surfaceBlocks[bi]!;
|
|
159
|
+
const blockFrom = block.from; // from is required on all SurfaceBlockSnapshot variants
|
|
160
|
+
// Block belongs to this page if its start falls within the page's offset window.
|
|
161
|
+
if (blockFrom >= pageStart && blockFrom < pageEnd) {
|
|
162
|
+
if (first === -1) first = bi;
|
|
163
|
+
last = bi;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (first !== -1) {
|
|
167
|
+
blockIndexRangeByPageIndex.set(page.page.pageIndex, { first, last });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
136
172
|
return buildPageBreakDecorations({
|
|
137
173
|
graph: fakeGraph as never,
|
|
138
174
|
posture,
|
|
@@ -142,6 +178,7 @@ function buildPageBreakDecorationsFromProps(
|
|
|
142
178
|
runtimeToPmOffset: (offset) => positionMap.runtimeToPm(offset),
|
|
143
179
|
headerPreviewByPageId: previews?.headerPreviewByPageId,
|
|
144
180
|
footerPreviewByPageId: previews?.footerPreviewByPageId,
|
|
181
|
+
blockIndexRangeByPageIndex,
|
|
145
182
|
});
|
|
146
183
|
}
|
|
147
184
|
|
|
@@ -512,6 +549,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
512
549
|
footerBandPx: props.pageChromeFooterBandPx,
|
|
513
550
|
interGapPx: props.pageChromeInterGapPx,
|
|
514
551
|
},
|
|
552
|
+
snapshot.surface?.blocks,
|
|
515
553
|
);
|
|
516
554
|
const decorations = pageBreakDecos.length > 0
|
|
517
555
|
? DecorationSet.create(view.state.doc, [
|
|
@@ -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
|
+
}
|