@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
|
@@ -17,7 +17,10 @@ import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
|
|
|
17
17
|
import { recordPerfSample } from "../../ui-tailwind/editor-surface/perf-probe.ts";
|
|
18
18
|
import type { EditorStoryTarget } from "../../api/public-types";
|
|
19
19
|
import type {
|
|
20
|
+
PublicBlockFragment,
|
|
20
21
|
PublicPageNode,
|
|
22
|
+
PublicPageRegion,
|
|
23
|
+
PublicRegionBlock,
|
|
21
24
|
WordReviewEditorLayoutFacet,
|
|
22
25
|
} from "../layout/public-facet.ts";
|
|
23
26
|
import type {
|
|
@@ -305,6 +308,7 @@ function buildPage(
|
|
|
305
308
|
zoom,
|
|
306
309
|
"header",
|
|
307
310
|
page.stories.header,
|
|
311
|
+
facet,
|
|
308
312
|
);
|
|
309
313
|
}
|
|
310
314
|
if (page.stories.footer) {
|
|
@@ -314,9 +318,18 @@ function buildPage(
|
|
|
314
318
|
zoom,
|
|
315
319
|
"footer",
|
|
316
320
|
page.stories.footer,
|
|
321
|
+
facet,
|
|
317
322
|
);
|
|
318
323
|
}
|
|
319
324
|
|
|
325
|
+
const footnoteRegions = buildFootnoteRegions(page, topPx, zoom, facet);
|
|
326
|
+
if (footnoteRegions.length > 0) {
|
|
327
|
+
regions.footnotes = footnoteRegions;
|
|
328
|
+
}
|
|
329
|
+
// Endnotes intentionally skipped — per-page endnote projection is not
|
|
330
|
+
// populated; endnotes use document-end placement via
|
|
331
|
+
// `facet.getDocumentEndnoteBlocks()`.
|
|
332
|
+
|
|
320
333
|
const chromeReservations: PageChromeReservations = {
|
|
321
334
|
...defaultChromeReservations(layout, zoom),
|
|
322
335
|
};
|
|
@@ -440,51 +453,181 @@ function buildHeaderFooterRegion(
|
|
|
440
453
|
zoom: RenderZoom,
|
|
441
454
|
kind: "header" | "footer",
|
|
442
455
|
storyTarget: EditorStoryTarget,
|
|
456
|
+
facet: WordReviewEditorLayoutFacet,
|
|
443
457
|
): RenderStoryRegion {
|
|
444
458
|
const layout = page.layout;
|
|
445
|
-
const
|
|
459
|
+
const fallbackWidthTwips =
|
|
446
460
|
layout.pageWidth - layout.marginLeft - layout.marginRight;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
461
|
+
const fallbackTopTwips =
|
|
462
|
+
kind === "header"
|
|
463
|
+
? (layout.headerMargin ?? 720)
|
|
464
|
+
: layout.pageHeight - layout.marginBottom;
|
|
465
|
+
const fallbackHeightTwips =
|
|
466
|
+
kind === "header"
|
|
467
|
+
? Math.max(0, layout.marginTop - (layout.headerMargin ?? 720))
|
|
468
|
+
: Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720));
|
|
469
|
+
|
|
470
|
+
const region: PublicPageRegion =
|
|
471
|
+
(kind === "header" ? page.regions.header : page.regions.footer) ?? {
|
|
472
|
+
kind,
|
|
473
|
+
originTwips: fallbackTopTwips,
|
|
474
|
+
widthTwips: fallbackWidthTwips,
|
|
475
|
+
heightTwips: fallbackHeightTwips,
|
|
476
|
+
fragmentCount: 0,
|
|
477
|
+
};
|
|
456
478
|
|
|
457
479
|
const frame: RenderFrameRect = {
|
|
458
480
|
leftPx: layout.marginLeft * zoom.pxPerTwip,
|
|
459
|
-
topPx: pageTopPx +
|
|
460
|
-
widthPx: widthTwips * zoom.pxPerTwip,
|
|
461
|
-
heightPx: heightTwips * zoom.pxPerTwip,
|
|
481
|
+
topPx: pageTopPx + region.originTwips * zoom.pxPerTwip,
|
|
482
|
+
widthPx: region.widthTwips * zoom.pxPerTwip,
|
|
483
|
+
heightPx: region.heightTwips * zoom.pxPerTwip,
|
|
462
484
|
};
|
|
463
485
|
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
fragmentCount: 0,
|
|
474
|
-
},
|
|
475
|
-
frame,
|
|
476
|
-
blocks: [],
|
|
477
|
-
};
|
|
478
|
-
}
|
|
486
|
+
const regionBlocks = facet.getStoryBlocksForRegion(page.pageIndex, kind);
|
|
487
|
+
const blocks = projectRegionBlocks(
|
|
488
|
+
regionBlocks,
|
|
489
|
+
frame,
|
|
490
|
+
zoom.pxPerTwip,
|
|
491
|
+
page.pageId,
|
|
492
|
+
page.pageIndex,
|
|
493
|
+
kind,
|
|
494
|
+
);
|
|
479
495
|
|
|
480
496
|
return {
|
|
481
497
|
storyTarget,
|
|
482
498
|
region,
|
|
483
499
|
frame,
|
|
484
|
-
blocks
|
|
500
|
+
blocks,
|
|
485
501
|
};
|
|
486
502
|
}
|
|
487
503
|
|
|
504
|
+
/**
|
|
505
|
+
* P8.3 — Build one `RenderStoryRegion` per `page.regions.footnotes` entry.
|
|
506
|
+
* The page graph reserves footnote regions at the bottom of the page
|
|
507
|
+
* (above the footer band) when `noteAllocations` produced fragments.
|
|
508
|
+
* Blocks come from `facet.getStoryBlocksForRegion(pageIndex, "footnote-area")`
|
|
509
|
+
* — one entry per allocated note body, stacked vertically.
|
|
510
|
+
*
|
|
511
|
+
* Currently the page graph emits a single footnote region per page
|
|
512
|
+
* covering every allocation, so the returned array has length 0 or 1. The
|
|
513
|
+
* shape allows for future allocation-splitting without changing the
|
|
514
|
+
* render-kernel contract.
|
|
515
|
+
*/
|
|
516
|
+
function buildFootnoteRegions(
|
|
517
|
+
page: PublicPageNode,
|
|
518
|
+
pageTopPx: number,
|
|
519
|
+
zoom: RenderZoom,
|
|
520
|
+
facet: WordReviewEditorLayoutFacet,
|
|
521
|
+
): RenderStoryRegion[] {
|
|
522
|
+
const footnoteRegions = page.regions.footnotes;
|
|
523
|
+
if (!footnoteRegions || footnoteRegions.length === 0) return [];
|
|
524
|
+
|
|
525
|
+
const regionBlocks = facet.getStoryBlocksForRegion(
|
|
526
|
+
page.pageIndex,
|
|
527
|
+
"footnote-area",
|
|
528
|
+
);
|
|
529
|
+
if (regionBlocks.length === 0) return [];
|
|
530
|
+
|
|
531
|
+
const results: RenderStoryRegion[] = [];
|
|
532
|
+
// Today the runtime emits a single footnote-area region per page; if that
|
|
533
|
+
// ever changes we will split `regionBlocks` across each region entry's
|
|
534
|
+
// `fragmentCount`. Preserve the shape so consumers can iterate safely.
|
|
535
|
+
let cursor = 0;
|
|
536
|
+
for (const region of footnoteRegions) {
|
|
537
|
+
const frame: RenderFrameRect = {
|
|
538
|
+
leftPx: page.layout.marginLeft * zoom.pxPerTwip,
|
|
539
|
+
topPx: pageTopPx + region.originTwips * zoom.pxPerTwip,
|
|
540
|
+
widthPx: region.widthTwips * zoom.pxPerTwip,
|
|
541
|
+
heightPx: region.heightTwips * zoom.pxPerTwip,
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const blocksForThisRegion =
|
|
545
|
+
footnoteRegions.length === 1
|
|
546
|
+
? regionBlocks
|
|
547
|
+
: regionBlocks.slice(cursor, cursor + region.fragmentCount);
|
|
548
|
+
cursor += region.fragmentCount;
|
|
549
|
+
|
|
550
|
+
const blocks = projectRegionBlocks(
|
|
551
|
+
blocksForThisRegion,
|
|
552
|
+
frame,
|
|
553
|
+
zoom.pxPerTwip,
|
|
554
|
+
page.pageId,
|
|
555
|
+
page.pageIndex,
|
|
556
|
+
"footnote-area",
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
// Footnote regions don't have a single canonical storyTarget (each
|
|
560
|
+
// block belongs to a different footnote body). Pick the first block's
|
|
561
|
+
// note as the region's primary story when available so chrome surfaces
|
|
562
|
+
// keyed on `storyTarget.kind === "footnote"` can dispatch on the band.
|
|
563
|
+
const firstNote = page.noteAllocations.find(
|
|
564
|
+
(alloc) => alloc.noteKind === "footnote",
|
|
565
|
+
);
|
|
566
|
+
const storyTarget: EditorStoryTarget = firstNote
|
|
567
|
+
? { kind: "footnote", noteId: firstNote.noteId }
|
|
568
|
+
: MAIN_STORY_TARGET;
|
|
569
|
+
|
|
570
|
+
results.push({
|
|
571
|
+
storyTarget,
|
|
572
|
+
region,
|
|
573
|
+
frame,
|
|
574
|
+
blocks,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return results;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* P8.3 — Stack a `PublicRegionBlock[]` into `RenderBlock[]` inside the
|
|
583
|
+
* given region frame. The cursor starts at `regionFrame.topPx` and
|
|
584
|
+
* advances by each block's `heightTwips × pxPerTwip`. Synthesizes a
|
|
585
|
+
* `PublicBlockFragment` shape for each block so chrome surfaces reading
|
|
586
|
+
* `RenderBlock.fragment` see a consistent shape across body / header /
|
|
587
|
+
* footer / footnote-area regions.
|
|
588
|
+
*/
|
|
589
|
+
function projectRegionBlocks(
|
|
590
|
+
regionBlocks: readonly PublicRegionBlock[],
|
|
591
|
+
regionFrame: RenderFrameRect,
|
|
592
|
+
pxPerTwip: number,
|
|
593
|
+
pageId: string,
|
|
594
|
+
pageIndex: number,
|
|
595
|
+
regionKind: PublicPageRegion["kind"],
|
|
596
|
+
): RenderBlock[] {
|
|
597
|
+
const blocks: RenderBlock[] = [];
|
|
598
|
+
let y = regionFrame.topPx;
|
|
599
|
+
for (let i = 0; i < regionBlocks.length; i += 1) {
|
|
600
|
+
const regionBlock = regionBlocks[i]!;
|
|
601
|
+
const blockHeightPx = Math.max(0, regionBlock.heightTwips) * pxPerTwip;
|
|
602
|
+
const blockFrame: RenderFrameRect = {
|
|
603
|
+
leftPx: regionFrame.leftPx,
|
|
604
|
+
topPx: y,
|
|
605
|
+
widthPx: regionFrame.widthPx,
|
|
606
|
+
heightPx: blockHeightPx,
|
|
607
|
+
};
|
|
608
|
+
const fragment: PublicBlockFragment = {
|
|
609
|
+
fragmentId: regionBlock.fragmentId,
|
|
610
|
+
blockId: regionBlock.blockId,
|
|
611
|
+
pageId,
|
|
612
|
+
pageIndex,
|
|
613
|
+
regionKind,
|
|
614
|
+
from: regionBlock.runtimeFromOffset,
|
|
615
|
+
to: regionBlock.runtimeToOffset,
|
|
616
|
+
heightTwips: regionBlock.heightTwips,
|
|
617
|
+
orderInRegion: i,
|
|
618
|
+
};
|
|
619
|
+
blocks.push({
|
|
620
|
+
fragment,
|
|
621
|
+
frame: blockFrame,
|
|
622
|
+
kind: classifyBlockKindFromId(regionBlock.blockId),
|
|
623
|
+
lines: [],
|
|
624
|
+
blockDecorations: [],
|
|
625
|
+
});
|
|
626
|
+
y += blockHeightPx;
|
|
627
|
+
}
|
|
628
|
+
return blocks;
|
|
629
|
+
}
|
|
630
|
+
|
|
488
631
|
// classifyBlockKind moved to `./block-fragment-projection.ts` (P4).
|
|
489
632
|
|
|
490
633
|
function buildAnchorIndex(
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
|
|
2
|
+
import type { SelectionSnapshot } from "../../core/state/editor-state.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Snap a selection to a valid position relative to the document.
|
|
6
|
+
*
|
|
7
|
+
* Pure function. O(1) on the identity (in-bounds) fast path — returns
|
|
8
|
+
* the SAME object reference when no change is needed. Callers should
|
|
9
|
+
* compare with `!==` to detect a snap (e.g. to decide whether to
|
|
10
|
+
* re-spread runtime state).
|
|
11
|
+
*
|
|
12
|
+
* Wired into the runtime snapshot-emit chokepoint
|
|
13
|
+
* (`applyTransactionToState` -> `cachedRenderSnapshot = refreshRenderSnapshot()`),
|
|
14
|
+
* so it runs once per transaction commit. Must NOT walk the document;
|
|
15
|
+
* the caller is responsible for passing a valid `maxOffset` (the
|
|
16
|
+
* POST-mutation `surface.storySize`, primed via
|
|
17
|
+
* `getCachedSurface(state.document, activeStory).storySize`).
|
|
18
|
+
*
|
|
19
|
+
* NodeAnchor invalidation is deferred until CanonicalDocumentEnvelope
|
|
20
|
+
* grows an O(1) node-by-id accessor. Until then, NodeAnchor selections
|
|
21
|
+
* are returned unchanged (identity).
|
|
22
|
+
*
|
|
23
|
+
* @param document The post-mutation canonical document. Currently
|
|
24
|
+
* unused except for the deferred NodeAnchor branch;
|
|
25
|
+
* the parameter is kept for API stability.
|
|
26
|
+
* @param selection The selection to validate.
|
|
27
|
+
* @param maxOffset The POST-mutation maximum story offset. Caller
|
|
28
|
+
* passes `getCachedSurface(state.document,
|
|
29
|
+
* activeStory).storySize` (which primes the cache
|
|
30
|
+
* that `refreshRenderSnapshot` reuses on its next
|
|
31
|
+
* call — no extra surface walk). The validator does
|
|
32
|
+
* NOT walk the document to compute this. Do NOT pass
|
|
33
|
+
* the pre-mutation snapshot's storySize: at end-of-doc
|
|
34
|
+
* inserts, the new selection legitimately exceeds the
|
|
35
|
+
* old bound and the validator would clamp the caret
|
|
36
|
+
* backward by one position per keystroke. Pass
|
|
37
|
+
* `Number.POSITIVE_INFINITY` to skip the upper-bound
|
|
38
|
+
* clamp.
|
|
39
|
+
*/
|
|
40
|
+
export function validateSelectionAgainstDocument(
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- reserved for deferred NodeAnchor lookup
|
|
42
|
+
document: CanonicalDocumentEnvelope,
|
|
43
|
+
selection: SelectionSnapshot,
|
|
44
|
+
maxOffset: number,
|
|
45
|
+
): SelectionSnapshot {
|
|
46
|
+
if (selection.activeRange.kind === "node") {
|
|
47
|
+
// Deferred: NodeAnchor invalidation requires an O(1) node-by-id
|
|
48
|
+
// accessor on CanonicalDocumentEnvelope. Until that lands, return
|
|
49
|
+
// identity so we never falsely invalidate a still-valid node anchor.
|
|
50
|
+
return selection;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const anchor = clamp(selection.anchor, 0, maxOffset);
|
|
54
|
+
const head = clamp(selection.head, 0, maxOffset);
|
|
55
|
+
|
|
56
|
+
if (anchor === selection.anchor && head === selection.head) {
|
|
57
|
+
// Identity fast path — no allocation, same reference returned.
|
|
58
|
+
return selection;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const range = { from: Math.min(anchor, head), to: Math.max(anchor, head) };
|
|
62
|
+
const assoc =
|
|
63
|
+
selection.activeRange.kind === "range"
|
|
64
|
+
? selection.activeRange.assoc
|
|
65
|
+
: { start: 1 as const, end: 1 as const };
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
anchor,
|
|
69
|
+
head,
|
|
70
|
+
isCollapsed: anchor === head,
|
|
71
|
+
activeRange: { kind: "range", range, assoc },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clamp(n: number, lo: number, hi: number): number {
|
|
76
|
+
return n < lo ? lo : n > hi ? hi : n;
|
|
77
|
+
}
|
|
@@ -68,11 +68,17 @@ interface ParagraphAccumulator {
|
|
|
68
68
|
segments: SurfaceInlineSegment[];
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
export interface SurfaceProjectionOptions {
|
|
72
|
+
viewportBlockRange?: { start: number; end: number } | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
71
75
|
export function createEditorSurfaceSnapshot(
|
|
72
76
|
document: CanonicalDocumentEnvelope,
|
|
73
77
|
_selection: SelectionSnapshot,
|
|
74
78
|
activeStory: EditorStoryTarget = { kind: "main" },
|
|
79
|
+
options: SurfaceProjectionOptions = {},
|
|
75
80
|
): EditorSurfaceSnapshot {
|
|
81
|
+
const viewportBlockRange = options.viewportBlockRange ?? null;
|
|
76
82
|
const root = normalizeDocumentRoot({
|
|
77
83
|
type: "doc",
|
|
78
84
|
children: [...getStoryBlocks(document, activeStory)],
|
|
@@ -99,15 +105,41 @@ export function createEditorSurfaceSnapshot(
|
|
|
99
105
|
numberingPrefixResolver,
|
|
100
106
|
activeStory.kind !== "main",
|
|
101
107
|
);
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
const isInViewport =
|
|
109
|
+
viewportBlockRange === null ||
|
|
110
|
+
(index >= viewportBlockRange.start && index < viewportBlockRange.end);
|
|
111
|
+
|
|
112
|
+
if (isInViewport) {
|
|
113
|
+
blocks.push(surfaceBlock.block);
|
|
114
|
+
lockedFragmentIds.push(...surfaceBlock.lockedFragmentIds);
|
|
115
|
+
} else {
|
|
116
|
+
// Replace with size-preserving placeholder. from/to track the SAME
|
|
117
|
+
// position range as the real block, so selection and anchor stability
|
|
118
|
+
// outside the viewport is preserved.
|
|
119
|
+
const placeholderSize = surfaceBlock.nextCursor - cursor;
|
|
120
|
+
const placeholderBlockId = `placeholder-culled-${index}`;
|
|
121
|
+
blocks.push({
|
|
122
|
+
blockId: placeholderBlockId,
|
|
123
|
+
kind: "opaque_block",
|
|
124
|
+
from: cursor,
|
|
125
|
+
to: surfaceBlock.nextCursor,
|
|
126
|
+
fragmentId: placeholderBlockId,
|
|
127
|
+
warningId: placeholderBlockId,
|
|
128
|
+
label: "",
|
|
129
|
+
detail: "",
|
|
130
|
+
placeholderSize,
|
|
131
|
+
state: "placeholder-culled",
|
|
132
|
+
} as SurfaceBlockSnapshot);
|
|
133
|
+
// Do NOT push lockedFragmentIds — placeholder has no real fragment.
|
|
134
|
+
}
|
|
135
|
+
|
|
104
136
|
cursor = surfaceBlock.nextCursor;
|
|
105
137
|
if (index < root.children.length - 1 && root.children[index + 1]?.type === "paragraph") {
|
|
106
138
|
cursor += 1;
|
|
107
139
|
}
|
|
108
140
|
}
|
|
109
141
|
|
|
110
|
-
const secondaryStories = createSecondaryStorySurfaces(document);
|
|
142
|
+
const secondaryStories = createSecondaryStorySurfaces(document, numberingPrefixResolver);
|
|
111
143
|
|
|
112
144
|
return {
|
|
113
145
|
storySize: cursor,
|
|
@@ -115,6 +147,7 @@ export function createEditorSurfaceSnapshot(
|
|
|
115
147
|
blocks,
|
|
116
148
|
lockedFragmentIds,
|
|
117
149
|
secondaryStories,
|
|
150
|
+
viewportBlockRange,
|
|
118
151
|
};
|
|
119
152
|
}
|
|
120
153
|
|
|
@@ -886,9 +919,13 @@ function appendInlineSegments(
|
|
|
886
919
|
return { nextCursor: start + 1, lockedFragmentIds: [node.fragmentId] };
|
|
887
920
|
}
|
|
888
921
|
case "chart_preview":
|
|
889
|
-
return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node)
|
|
922
|
+
return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node), {
|
|
923
|
+
previewMediaId: node.previewMediaId,
|
|
924
|
+
});
|
|
890
925
|
case "smartart_preview":
|
|
891
|
-
return appendComplexPreviewSegment(paragraph, node, start, "SmartArt diagram", createSmartArtDetail(node)
|
|
926
|
+
return appendComplexPreviewSegment(paragraph, node, start, "SmartArt diagram", createSmartArtDetail(node), {
|
|
927
|
+
previewMediaId: node.previewMediaId,
|
|
928
|
+
});
|
|
892
929
|
case "shape":
|
|
893
930
|
if (promoteSecondaryStoryTextBoxes && node.isTextBox && node.text) {
|
|
894
931
|
return appendTextBoxSegment(
|
|
@@ -1023,6 +1060,7 @@ function appendComplexPreviewSegment(
|
|
|
1023
1060
|
start: number,
|
|
1024
1061
|
label: string,
|
|
1025
1062
|
detail: string,
|
|
1063
|
+
extras: { previewMediaId?: string } = {},
|
|
1026
1064
|
): { nextCursor: number; lockedFragmentIds: string[] } {
|
|
1027
1065
|
paragraph.segments.push({
|
|
1028
1066
|
segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
|
|
@@ -1033,6 +1071,7 @@ function appendComplexPreviewSegment(
|
|
|
1033
1071
|
warningId: `warning:complex-preview:${start}`,
|
|
1034
1072
|
label,
|
|
1035
1073
|
detail,
|
|
1074
|
+
...(extras.previewMediaId ? { previewMediaId: extras.previewMediaId } : {}),
|
|
1036
1075
|
state: "locked-preserve-only",
|
|
1037
1076
|
});
|
|
1038
1077
|
return { nextCursor: start + 1, lockedFragmentIds: [] };
|
|
@@ -1174,6 +1213,7 @@ function createPlainText(
|
|
|
1174
1213
|
|
|
1175
1214
|
function createSecondaryStorySurfaces(
|
|
1176
1215
|
document: CanonicalDocumentEnvelope,
|
|
1216
|
+
numberingPrefixResolver: NumberingPrefixResolver,
|
|
1177
1217
|
): SecondaryStorySurface[] {
|
|
1178
1218
|
const surfaces: SecondaryStorySurface[] = [];
|
|
1179
1219
|
const subParts = document.subParts;
|
|
@@ -1181,8 +1221,6 @@ function createSecondaryStorySurfaces(
|
|
|
1181
1221
|
return surfaces;
|
|
1182
1222
|
}
|
|
1183
1223
|
|
|
1184
|
-
const numberingPrefixResolver = createNumberingPrefixResolver(document.numbering);
|
|
1185
|
-
|
|
1186
1224
|
for (const section of collectSectionContexts(document)) {
|
|
1187
1225
|
const headerVariants = resolveSectionVariants(
|
|
1188
1226
|
"header",
|
|
@@ -22,28 +22,32 @@ import type {
|
|
|
22
22
|
WorkflowCommentMarkup,
|
|
23
23
|
} from "../api/public-types";
|
|
24
24
|
import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
projectSurfaceText,
|
|
27
|
+
searchProjectedSurfaceText,
|
|
28
|
+
} from "../core/search/search-text.ts";
|
|
26
29
|
import { describeOpaqueFragment, isBlockedImportFeatureKey } from "../preservation/store.ts";
|
|
27
30
|
import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Surface-derived markup (highlights + block-level opaque fragments).
|
|
34
|
+
*
|
|
35
|
+
* Pure function of `(surface, preservation)` — extracted from
|
|
36
|
+
* `collectWorkflowMarkupSnapshot` so callers can cache the expensive walk
|
|
37
|
+
* separately from the cheap reference-equal inputs (metadata, comments,
|
|
38
|
+
* revisions, protected ranges).
|
|
39
|
+
*/
|
|
40
|
+
export function collectWorkflowSurfaceMarkup(
|
|
41
|
+
surface: RuntimeRenderSnapshot["surface"],
|
|
42
|
+
preservation: CanonicalDocumentEnvelope["preservation"],
|
|
43
|
+
): { highlights: WorkflowHighlightMarkup[]; opaqueFragments: WorkflowOpaqueFragmentMarkup[] } {
|
|
36
44
|
const highlights: WorkflowHighlightMarkup[] = [];
|
|
37
|
-
const metadata = collectWorkflowMetadataMarkup(input.workflowMetadataSnapshot);
|
|
38
|
-
const fields: WorkflowFieldMarkup[] = [];
|
|
39
45
|
const opaqueFragments: WorkflowOpaqueFragmentMarkup[] = [];
|
|
40
|
-
const surface = input.renderSnapshot.surface;
|
|
41
|
-
|
|
42
46
|
if (surface) {
|
|
43
47
|
collectSurfaceMarkup(
|
|
44
48
|
surface.blocks,
|
|
45
49
|
MAIN_STORY_TARGET,
|
|
46
|
-
|
|
50
|
+
preservation,
|
|
47
51
|
highlights,
|
|
48
52
|
opaqueFragments,
|
|
49
53
|
);
|
|
@@ -51,16 +55,55 @@ export function collectWorkflowMarkupSnapshot(input: {
|
|
|
51
55
|
collectSurfaceMarkup(
|
|
52
56
|
story.blocks,
|
|
53
57
|
story.target,
|
|
54
|
-
|
|
58
|
+
preservation,
|
|
55
59
|
highlights,
|
|
56
60
|
opaqueFragments,
|
|
57
61
|
);
|
|
58
62
|
}
|
|
63
|
+
}
|
|
64
|
+
return { highlights, opaqueFragments };
|
|
65
|
+
}
|
|
59
66
|
|
|
67
|
+
export function collectWorkflowMarkupSnapshot(input: {
|
|
68
|
+
renderSnapshot: RuntimeRenderSnapshot;
|
|
69
|
+
fieldSnapshot: FieldSnapshot;
|
|
70
|
+
protectionSnapshot: ProtectionSnapshot;
|
|
71
|
+
preservation: CanonicalDocumentEnvelope["preservation"];
|
|
72
|
+
workflowMetadataSnapshot?: WorkflowMetadataSnapshot;
|
|
73
|
+
surfaceMarkupCache?: {
|
|
74
|
+
highlights: WorkflowHighlightMarkup[];
|
|
75
|
+
opaqueFragments: WorkflowOpaqueFragmentMarkup[];
|
|
76
|
+
};
|
|
77
|
+
perfStage?: (name: string, durationMs: number) => void;
|
|
78
|
+
}): WorkflowMarkupSnapshot {
|
|
79
|
+
const perf = input.perfStage;
|
|
80
|
+
const stageStart = perf ? () => performance.now() : () => 0;
|
|
81
|
+
const stageEnd = perf ? (name: string, t0: number) => perf(name, performance.now() - t0) : () => {};
|
|
82
|
+
|
|
83
|
+
const tMeta = stageStart();
|
|
84
|
+
const metadata = collectWorkflowMetadataMarkup(input.workflowMetadataSnapshot);
|
|
85
|
+
stageEnd("metadata", tMeta);
|
|
86
|
+
|
|
87
|
+
const fields: WorkflowFieldMarkup[] = [];
|
|
88
|
+
const surface = input.renderSnapshot.surface;
|
|
89
|
+
|
|
90
|
+
const tSurface = stageStart();
|
|
91
|
+
const surfaceMarkup = input.surfaceMarkupCache ??
|
|
92
|
+
collectWorkflowSurfaceMarkup(surface, input.preservation);
|
|
93
|
+
const highlights = surfaceMarkup.highlights.slice();
|
|
94
|
+
const opaqueFragments = surfaceMarkup.opaqueFragments.slice();
|
|
95
|
+
stageEnd("surface", tSurface);
|
|
96
|
+
|
|
97
|
+
if (surface) {
|
|
98
|
+
const tFields = stageStart();
|
|
60
99
|
fields.push(...collectFieldMarkup(surface, input.fieldSnapshot));
|
|
100
|
+
stageEnd("fields", tFields);
|
|
61
101
|
}
|
|
102
|
+
const tOpaqueRest = stageStart();
|
|
62
103
|
opaqueFragments.push(...collectOpaqueFragmentMarkup(input.preservation, opaqueFragments));
|
|
104
|
+
stageEnd("opaqueRest", tOpaqueRest);
|
|
63
105
|
|
|
106
|
+
const tCommentsEtc = stageStart();
|
|
64
107
|
const comments = input.renderSnapshot.comments.threads.map((thread): WorkflowCommentMarkup => ({
|
|
65
108
|
markupId: `comment:${thread.commentId}`,
|
|
66
109
|
kind: "comment",
|
|
@@ -113,6 +156,8 @@ export function collectWorkflowMarkupSnapshot(input: {
|
|
|
113
156
|
}),
|
|
114
157
|
);
|
|
115
158
|
|
|
159
|
+
stageEnd("commentsEtc", tCommentsEtc);
|
|
160
|
+
|
|
116
161
|
const items: WorkflowMarkupItem[] = [
|
|
117
162
|
...highlights,
|
|
118
163
|
...metadata,
|
|
@@ -303,11 +348,21 @@ function collectFieldMarkup(
|
|
|
303
348
|
return [];
|
|
304
349
|
}
|
|
305
350
|
|
|
351
|
+
// L7 Phase 1.5: project each story's text once up-front. The prior code
|
|
352
|
+
// called searchSurfaceBlocks per field, which re-projected the entire
|
|
353
|
+
// surface on every invocation. For the CCEP large-tables fixture this
|
|
354
|
+
// was ~220 ms per commit. Hoisting the projection out of the per-field
|
|
355
|
+
// loop collapses that to a single projection per story.
|
|
306
356
|
const stories = [
|
|
307
|
-
{
|
|
357
|
+
{
|
|
358
|
+
blocks: surface.blocks,
|
|
359
|
+
storyTarget: MAIN_STORY_TARGET,
|
|
360
|
+
projection: projectSurfaceText(surface.blocks),
|
|
361
|
+
},
|
|
308
362
|
...surface.secondaryStories.map((story) => ({
|
|
309
363
|
blocks: story.blocks,
|
|
310
364
|
storyTarget: story.target,
|
|
365
|
+
projection: projectSurfaceText(story.blocks),
|
|
311
366
|
})),
|
|
312
367
|
];
|
|
313
368
|
|
|
@@ -318,7 +373,7 @@ function collectFieldMarkup(
|
|
|
318
373
|
}
|
|
319
374
|
|
|
320
375
|
for (const story of stories) {
|
|
321
|
-
const matches =
|
|
376
|
+
const matches = searchProjectedSurfaceText(story.projection, displayText, { limit: 2 });
|
|
322
377
|
if (matches.length === 1) {
|
|
323
378
|
const match = matches[0]!;
|
|
324
379
|
return [
|