@beyondwork/docx-react-component 1.0.96 → 1.0.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/public-types.ts +33 -19
- package/src/api/v3/ui/_types.ts +11 -21
- package/src/api/v3/ui/chrome.ts +8 -9
- package/src/api/v3/ui/debug.ts +15 -77
- package/src/api/v3/ui/overlays-visibility.ts +9 -10
- package/src/api/v3/ui/overlays.ts +8 -75
- package/src/io/ooxml/parse-main-document.ts +30 -0
- package/src/io/ooxml/parse-picture.ts +14 -0
- package/src/io/ooxml/parse-shapes.ts +41 -1
- package/src/model/canonical-document.ts +17 -0
- package/src/runtime/layout/layout-engine-version.ts +8 -1
- package/src/runtime/layout/page-story-resolver.ts +1 -0
- package/src/runtime/layout/paginated-layout-engine.ts +26 -10
- package/src/runtime/surface-projection.ts +114 -12
- package/src/ui/WordReviewEditor.tsx +6 -10
- package/src/ui/editor-command-bag.ts +2 -0
- package/src/ui/ui-controller-factory.ts +2 -2
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +11 -25
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +2 -2
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +1 -1
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +22 -220
- package/src/ui-tailwind/debug/README.md +12 -50
- package/src/ui-tailwind/debug/tw-debug-overlay.tsx +6 -6
- package/src/ui-tailwind/debug/tw-debug-presentation.tsx +9 -20
- package/src/ui-tailwind/debug/tw-debug-top-bar.tsx +5 -6
- package/src/ui-tailwind/editor-surface/chart-node-view.tsx +1 -4
- package/src/ui-tailwind/editor-surface/picture-effects.ts +96 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +89 -62
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +205 -0
- package/src/ui-tailwind/editor-surface/runtime-decoration-plugin.ts +190 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +53 -53
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +83 -20
- package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +114 -4
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +5 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +3 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +3 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +26 -0
- package/src/ui-tailwind/theme/editor-theme.css +18 -11
- package/src/ui-tailwind/tw-review-workspace.tsx +15 -0
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
ROTATION_UNITS_PER_DEGREE,
|
|
18
18
|
SRCRECT_UNITS_PER_PERCENT,
|
|
19
19
|
} from "../../api/public-types.ts";
|
|
20
|
+
import { buildPictureFilterCss } from "./picture-effects.ts";
|
|
20
21
|
|
|
21
22
|
const HEX_COLOR_RE = /^[0-9A-Fa-f]{3,8}$/;
|
|
22
23
|
const SAFE_FONT_RE = /^[A-Za-z0-9 ,\-'"]+$/;
|
|
@@ -632,6 +633,7 @@ export const editorSchema = new Schema({
|
|
|
632
633
|
flipH: { default: false },
|
|
633
634
|
flipV: { default: false },
|
|
634
635
|
srcRect: { default: null },
|
|
636
|
+
lum: { default: null },
|
|
635
637
|
// Lane 6d N9 — float-wrap fields surfaced from `SurfaceDrawingAnchor`.
|
|
636
638
|
wrapMode: { default: null },
|
|
637
639
|
distMargins: { default: null },
|
|
@@ -653,14 +655,34 @@ export const editorSchema = new Schema({
|
|
|
653
655
|
const widthEmu = node.attrs.widthEmu as number | null;
|
|
654
656
|
const heightEmu = node.attrs.heightEmu as number | null;
|
|
655
657
|
if (renderInPageOverlay && isFloating) {
|
|
658
|
+
const wrapMode = node.attrs.wrapMode as string | null;
|
|
659
|
+
const distMargins = node.attrs.distMargins as
|
|
660
|
+
| { top?: number; bottom?: number; left?: number; right?: number }
|
|
661
|
+
| null;
|
|
662
|
+
const overlayAnchorAttrs: Record<string, string> = {
|
|
663
|
+
class: "inline-block h-0 w-0 overflow-hidden align-middle",
|
|
664
|
+
"data-node-type": "image-floating-anchor",
|
|
665
|
+
title: (node.attrs.detail as string) ?? (node.attrs.altText as string) ?? "Floating image anchor",
|
|
666
|
+
"aria-hidden": "true",
|
|
667
|
+
};
|
|
668
|
+
if (wrapMode) overlayAnchorAttrs["data-wrap-mode"] = wrapMode;
|
|
669
|
+
if (wrapMode === "topAndBottom" && heightEmu) {
|
|
670
|
+
const heightPx = Math.max(1, Math.round(heightEmu / EMU_PER_PX));
|
|
671
|
+
const marginTopPx = distMargins?.top ? Math.round(distMargins.top / EMU_PER_PX) : 0;
|
|
672
|
+
const marginBottomPx = distMargins?.bottom ? Math.round(distMargins.bottom / EMU_PER_PX) : 0;
|
|
673
|
+
overlayAnchorAttrs.class = "block overflow-hidden";
|
|
674
|
+
overlayAnchorAttrs.style = [
|
|
675
|
+
"display:block",
|
|
676
|
+
"width:0",
|
|
677
|
+
`height:${heightPx}px`,
|
|
678
|
+
`margin:${marginTopPx}px 0 ${marginBottomPx}px 0`,
|
|
679
|
+
"padding:0",
|
|
680
|
+
].join(";");
|
|
681
|
+
overlayAnchorAttrs["data-overlay-reserves-flow"] = "true";
|
|
682
|
+
}
|
|
656
683
|
return [
|
|
657
684
|
"span",
|
|
658
|
-
|
|
659
|
-
class: "inline-block h-0 w-0 overflow-hidden align-middle",
|
|
660
|
-
"data-node-type": "image-floating-anchor",
|
|
661
|
-
title: (node.attrs.detail as string) ?? (node.attrs.altText as string) ?? "Floating image anchor",
|
|
662
|
-
"aria-hidden": "true",
|
|
663
|
-
},
|
|
685
|
+
overlayAnchorAttrs,
|
|
664
686
|
];
|
|
665
687
|
}
|
|
666
688
|
if (!isMissing && src) {
|
|
@@ -673,6 +695,7 @@ export const editorSchema = new Schema({
|
|
|
673
695
|
const srcRect = node.attrs.srcRect as
|
|
674
696
|
| { top: number; bottom: number; left: number; right: number }
|
|
675
697
|
| null;
|
|
698
|
+
const lum = node.attrs.lum as { bright?: number; contrast?: number } | null;
|
|
676
699
|
const transformParts: string[] = [];
|
|
677
700
|
if (rotation && rotation !== 0) {
|
|
678
701
|
transformParts.push(`rotate(${(rotation / ROTATION_UNITS_PER_DEGREE).toFixed(3)}deg)`);
|
|
@@ -687,7 +710,6 @@ export const editorSchema = new Schema({
|
|
|
687
710
|
`inset(${(srcRect.top / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.right / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.bottom / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.left / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}%)`,
|
|
688
711
|
);
|
|
689
712
|
}
|
|
690
|
-
// N11.b filter effects → CSS filter on the img element.
|
|
691
713
|
const softEdgeRadius = node.attrs.softEdgeRadius as number | null;
|
|
692
714
|
const outerShadow = node.attrs.outerShadow as {
|
|
693
715
|
blurRad: number; dist: number; dir: number; color: string;
|
|
@@ -697,41 +719,16 @@ export const editorSchema = new Schema({
|
|
|
697
719
|
radius: number; color: string;
|
|
698
720
|
colorType: "srgbClr" | "schemeClr";
|
|
699
721
|
} | null;
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
// empty string otherwise, so schemeClr tokens (e.g. "accent1")
|
|
711
|
-
// naturally skip this branch until a theme resolver runs.
|
|
712
|
-
const safeFilterHexColor = (raw: unknown): string => {
|
|
713
|
-
return typeof raw === "string" &&
|
|
714
|
-
/^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(raw)
|
|
715
|
-
? `#${raw.toUpperCase()}`
|
|
716
|
-
: "";
|
|
717
|
-
};
|
|
718
|
-
if (glow) {
|
|
719
|
-
const glowColor = safeFilterHexColor(glow.color);
|
|
720
|
-
if (glowColor) {
|
|
721
|
-
filterParts.push(`drop-shadow(0 0 ${(glow.radius / EMU_PER_PX).toFixed(2)}px ${glowColor})`);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
if (outerShadow) {
|
|
725
|
-
const shadowColor = safeFilterHexColor(outerShadow.color);
|
|
726
|
-
if (shadowColor) {
|
|
727
|
-
const blurPx = (outerShadow.blurRad / EMU_PER_PX).toFixed(2);
|
|
728
|
-
const distPx = outerShadow.dist / EMU_PER_PX;
|
|
729
|
-
const dirRad = (outerShadow.dir / ROTATION_UNITS_PER_DEGREE) * (Math.PI / 180);
|
|
730
|
-
const dx = (distPx * Math.cos(dirRad)).toFixed(2);
|
|
731
|
-
const dy = (distPx * Math.sin(dirRad)).toFixed(2);
|
|
732
|
-
filterParts.push(`drop-shadow(${dx}px ${dy}px ${blurPx}px ${shadowColor})`);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
722
|
+
// N11.b filter effects → CSS filter on the img element. The helper
|
|
723
|
+
// re-validates colors at the CSS sink and maps OOXML luminance to
|
|
724
|
+
// an affine CSS filter, so brightened cover photos do not keep
|
|
725
|
+
// black pixels pinned at black.
|
|
726
|
+
const filter = buildPictureFilterCss({
|
|
727
|
+
...(lum ? { lum } : {}),
|
|
728
|
+
...(softEdgeRadius ? { softEdgeRadius } : {}),
|
|
729
|
+
...(glow ? { glow } : {}),
|
|
730
|
+
...(outerShadow ? { outerShadow } : {}),
|
|
731
|
+
});
|
|
735
732
|
// N9 float-wrap → CSS float + shape-outside on the wrapper span.
|
|
736
733
|
const wrapMode = node.attrs.wrapMode as string | null;
|
|
737
734
|
const positionH = node.attrs.positionH as { align?: string } | null;
|
|
@@ -771,7 +768,7 @@ export const editorSchema = new Schema({
|
|
|
771
768
|
heightPx ? `height:${heightPx}px` : "",
|
|
772
769
|
transformParts.length > 0 ? `transform:${transformParts.join(" ")}` : "",
|
|
773
770
|
clipParts.length > 0 ? `clip-path:${clipParts[0]}` : "",
|
|
774
|
-
|
|
771
|
+
filter ? `filter:${filter}` : "",
|
|
775
772
|
].filter(Boolean).join(";");
|
|
776
773
|
const wrapperStyle = wrapperStyleParts.join(";");
|
|
777
774
|
const wrapperAttrs: Record<string, string> = {
|
|
@@ -822,32 +819,29 @@ export const editorSchema = new Schema({
|
|
|
822
819
|
dropdownItems: { default: null },
|
|
823
820
|
comboBoxItems: { default: null },
|
|
824
821
|
showingPlcHdr: { default: false },
|
|
822
|
+
containsPageBreak: { default: false },
|
|
825
823
|
},
|
|
826
824
|
toDOM(node) {
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
825
|
+
const containsPageBreak = Boolean(node.attrs.containsPageBreak);
|
|
826
|
+
if (containsPageBreak) {
|
|
827
|
+
return [
|
|
828
|
+
"section",
|
|
829
|
+
{
|
|
830
|
+
class: "my-0 border-0 bg-transparent p-0",
|
|
831
|
+
style: "min-height:var(--wre-page-frame-height-px,1056px);position:relative",
|
|
832
|
+
"data-node-type": "sdt_block",
|
|
833
|
+
"data-sdt-page-break": "true",
|
|
834
|
+
},
|
|
835
|
+
["div", 0],
|
|
836
|
+
];
|
|
837
|
+
}
|
|
836
838
|
return [
|
|
837
839
|
"section",
|
|
838
840
|
{
|
|
839
|
-
class: "my-
|
|
841
|
+
class: "my-0 border-0 bg-transparent p-0",
|
|
840
842
|
"data-node-type": "sdt_block",
|
|
841
|
-
...(sdtType ? { "data-sdt-type": sdtType } : {}),
|
|
843
|
+
...(node.attrs.sdtType ? { "data-sdt-type": String(node.attrs.sdtType) } : {}),
|
|
842
844
|
},
|
|
843
|
-
[
|
|
844
|
-
"div",
|
|
845
|
-
{
|
|
846
|
-
class: "mb-2 text-[11px] uppercase tracking-[0.18em] text-tertiary",
|
|
847
|
-
contenteditable: "false",
|
|
848
|
-
},
|
|
849
|
-
meta || "Content control",
|
|
850
|
-
],
|
|
851
845
|
["div", 0],
|
|
852
846
|
];
|
|
853
847
|
},
|
|
@@ -1103,8 +1097,10 @@ export const editorSchema = new Schema({
|
|
|
1103
1097
|
txbxText: { default: null },
|
|
1104
1098
|
wrapMode: { default: "none" },
|
|
1105
1099
|
display: { default: "inline" },
|
|
1100
|
+
renderInPageOverlay: { default: false },
|
|
1106
1101
|
widthEmu: { default: null },
|
|
1107
1102
|
heightEmu: { default: null },
|
|
1103
|
+
positionH: { default: null },
|
|
1108
1104
|
},
|
|
1109
1105
|
toDOM(node) {
|
|
1110
1106
|
// V2c.5: when the rich attrs are present (DrawingFrame source),
|
|
@@ -1114,6 +1110,37 @@ export const editorSchema = new Schema({
|
|
|
1114
1110
|
// previews stay visually identical.
|
|
1115
1111
|
const isV2c5 = node.attrs.label !== null || node.attrs.isTextBox === true;
|
|
1116
1112
|
if (isV2c5) {
|
|
1113
|
+
if (Boolean(node.attrs.renderInPageOverlay) && node.attrs.display === "floating") {
|
|
1114
|
+
const wrapMode = node.attrs.wrapMode as string | null;
|
|
1115
|
+
const heightEmu = node.attrs.heightEmu as number | null;
|
|
1116
|
+
const widthEmu = node.attrs.widthEmu as number | null;
|
|
1117
|
+
const positionH = node.attrs.positionH as { align?: string } | null;
|
|
1118
|
+
const attrs: Record<string, string> = {
|
|
1119
|
+
class: "inline-block h-0 w-0 overflow-hidden align-middle",
|
|
1120
|
+
"data-node-type": "shape-floating-anchor",
|
|
1121
|
+
title: (node.attrs.detail as string) || (node.attrs.label as string) || "Floating shape anchor",
|
|
1122
|
+
"aria-hidden": "true",
|
|
1123
|
+
};
|
|
1124
|
+
if (wrapMode) attrs["data-wrap-mode"] = wrapMode;
|
|
1125
|
+
if (wrapMode === "square" && widthEmu && heightEmu) {
|
|
1126
|
+
const floatSide = positionH?.align === "right" ? "right" : "left";
|
|
1127
|
+
attrs.class = "block overflow-hidden";
|
|
1128
|
+
attrs.style = [
|
|
1129
|
+
`float:${floatSide}`,
|
|
1130
|
+
`width:${Math.max(1, Math.round(widthEmu / EMU_PER_PX))}px`,
|
|
1131
|
+
`height:${Math.max(1, Math.round(heightEmu / EMU_PER_PX))}px`,
|
|
1132
|
+
"margin:0",
|
|
1133
|
+
"padding:0",
|
|
1134
|
+
"opacity:0",
|
|
1135
|
+
"pointer-events:none",
|
|
1136
|
+
].join(";");
|
|
1137
|
+
attrs["data-overlay-reserves-flow"] = "true";
|
|
1138
|
+
}
|
|
1139
|
+
return [
|
|
1140
|
+
"span",
|
|
1141
|
+
attrs,
|
|
1142
|
+
];
|
|
1143
|
+
}
|
|
1117
1144
|
const geometry = node.attrs.geometry as string | null;
|
|
1118
1145
|
const fill = node.attrs.fill as
|
|
1119
1146
|
| ShapeFill
|
|
@@ -2,6 +2,7 @@ import { Fragment, type Mark, type Node as PMNode, type Schema } from "prosemirr
|
|
|
2
2
|
import { EditorState, NodeSelection, type Plugin, Selection, TextSelection } from "prosemirror-state";
|
|
3
3
|
|
|
4
4
|
import type {
|
|
5
|
+
BlockNode,
|
|
5
6
|
EditorSurfaceSnapshot,
|
|
6
7
|
SelectionSnapshot,
|
|
7
8
|
SurfaceBlockSnapshot,
|
|
@@ -10,6 +11,27 @@ import type {
|
|
|
10
11
|
SurfaceTableCellSnapshot,
|
|
11
12
|
} from "../../api/public-types";
|
|
12
13
|
import { shouldRenderAbsoluteFloatingImageInPageOverlay } from "../page-stack/floating-image-overlay-model.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Refactor/11b Slice A — mirror of the Symbol registered by
|
|
17
|
+
* `src/runtime/surface-projection.ts`. `Symbol.for` resolves to the
|
|
18
|
+
* same symbol in any layer, so no cross-layer import is required to
|
|
19
|
+
* share the channel. Reads the parallel `blockRefs` array that
|
|
20
|
+
* surface-projection attaches to the snapshot, feeding the L1 PM Node
|
|
21
|
+
* identity cache below.
|
|
22
|
+
*/
|
|
23
|
+
const CANONICAL_BLOCK_REFS_SYMBOL = Symbol.for("wre.canonical-block-refs");
|
|
24
|
+
|
|
25
|
+
function getCanonicalBlockRefs(
|
|
26
|
+
snapshot: EditorSurfaceSnapshot,
|
|
27
|
+
): readonly (BlockNode | null)[] | null {
|
|
28
|
+
const refs = (
|
|
29
|
+
snapshot as unknown as {
|
|
30
|
+
[CANONICAL_BLOCK_REFS_SYMBOL]?: readonly (BlockNode | null)[];
|
|
31
|
+
}
|
|
32
|
+
)[CANONICAL_BLOCK_REFS_SYMBOL];
|
|
33
|
+
return refs ?? null;
|
|
34
|
+
}
|
|
13
35
|
import { editorSchema } from "./pm-schema";
|
|
14
36
|
import { buildPositionMap, type PositionMap } from "./pm-position-map";
|
|
15
37
|
import { getChartModel } from "../../api/v3/runtime/chart.ts";
|
|
@@ -19,6 +41,122 @@ export interface PMStateResult {
|
|
|
19
41
|
positionMap: PositionMap;
|
|
20
42
|
}
|
|
21
43
|
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Refactor/11b Slice A — L1 PM Node identity cache
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// PM's `ViewDesc.update()` uses `node.eq()` to skip rebuilding subtrees whose
|
|
48
|
+
// content + attrs + marks are unchanged. Our rebuilder historically produced
|
|
49
|
+
// structurally-equal but identity-different Node instances on every commit,
|
|
50
|
+
// defeating that short-circuit and making `view.updateState()` O(doc) instead
|
|
51
|
+
// of O(changed blocks).
|
|
52
|
+
//
|
|
53
|
+
// Fix: when a canonical `BlockNode` reference is unchanged across commits
|
|
54
|
+
// (the canonical document preserves identity for unmodified blocks via
|
|
55
|
+
// structural sharing), reuse the PM Node we built last time — keyed on the
|
|
56
|
+
// canonical ref plus a digest of external inputs that actually affect the
|
|
57
|
+
// emitted attrs (neighbor-driven flags, media preview src, the two
|
|
58
|
+
// unsupported-preview booleans).
|
|
59
|
+
//
|
|
60
|
+
// Scope: paragraphs only in this slice. Tables/SDTs/opaques skip the cache
|
|
61
|
+
// (typically rare or cheap). See slice commit body for deferrals.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
type BlockCacheBucket = Map<string, PMNode>;
|
|
65
|
+
/** Module-scoped cache. Keyed on canonical BlockNode reference — auto-evicts
|
|
66
|
+
* via WeakMap when the canonical doc rebuilds a block. Inner map is capped
|
|
67
|
+
* per bucket to bound memory when external keys churn (e.g. markup-mode
|
|
68
|
+
* toggles). */
|
|
69
|
+
const pmNodeCache = new WeakMap<BlockNode, BlockCacheBucket>();
|
|
70
|
+
const MAX_ENTRIES_PER_BLOCK = 4;
|
|
71
|
+
|
|
72
|
+
/** WeakMap<mediaPreviews, stableId> — turns mediaPreviews object identity
|
|
73
|
+
* into a stable number for external-key composition. Equal identity →
|
|
74
|
+
* equal id. Different identity (even same content) → different id.
|
|
75
|
+
*
|
|
76
|
+
* Empty objects (no enumerable keys) all map to id 0 — they're
|
|
77
|
+
* semantically equivalent from the rebuilder's view (no images to wire)
|
|
78
|
+
* and treating them as one canonical id avoids false-miss on callers
|
|
79
|
+
* that default-construct `{}` each render. */
|
|
80
|
+
const mediaPreviewsIdentityIds = new WeakMap<object, number>();
|
|
81
|
+
let nextMediaPreviewsIdentityId = 1;
|
|
82
|
+
function mediaPreviewsIdentity(obj: object): number {
|
|
83
|
+
// Fast path: empty object → canonical id 0.
|
|
84
|
+
let hasAnyKey = false;
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
86
|
+
for (const _k in obj) {
|
|
87
|
+
hasAnyKey = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (!hasAnyKey) return 0;
|
|
91
|
+
let id = mediaPreviewsIdentityIds.get(obj);
|
|
92
|
+
if (id === undefined) {
|
|
93
|
+
id = nextMediaPreviewsIdentityId++;
|
|
94
|
+
mediaPreviewsIdentityIds.set(obj, id);
|
|
95
|
+
}
|
|
96
|
+
return id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let identityCacheHits = 0;
|
|
100
|
+
let identityCacheMisses = 0;
|
|
101
|
+
|
|
102
|
+
/** Test-only + perf-probe hook. Reads the accumulated hit/miss counts. */
|
|
103
|
+
export function getPmNodeCacheCounters(): { hits: number; misses: number } {
|
|
104
|
+
return { hits: identityCacheHits, misses: identityCacheMisses };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Test-only: reset the cache + counters. Production never calls this. */
|
|
108
|
+
export function __resetPmNodeCacheForTests(): void {
|
|
109
|
+
identityCacheHits = 0;
|
|
110
|
+
identityCacheMisses = 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** External-key digest for a single paragraph's cache lookup. Encodes every
|
|
114
|
+
* input to `buildParagraph` that does NOT come from the `BlockNode` itself.
|
|
115
|
+
* Neighbor digests are minimal — they only need to reflect fields that
|
|
116
|
+
* influence the current paragraph's rendered PM attrs (listContinuation,
|
|
117
|
+
* contextualSpacingBefore/After). */
|
|
118
|
+
function paragraphNeighborDigest(
|
|
119
|
+
p: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null,
|
|
120
|
+
): string {
|
|
121
|
+
if (!p) return "_";
|
|
122
|
+
const numInst = p.numbering?.numberingInstanceId ?? "";
|
|
123
|
+
const numLvl = p.numbering?.level ?? "";
|
|
124
|
+
const style = p.styleId ?? "";
|
|
125
|
+
const ctx = p.contextualSpacing ? "1" : "0";
|
|
126
|
+
return `${numInst}:${numLvl}:${style}:${ctx}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function paragraphExternalKey(
|
|
130
|
+
prev: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null,
|
|
131
|
+
next: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null,
|
|
132
|
+
mediaPreviewsId: number,
|
|
133
|
+
showUnsupportedObjectPreviews: boolean,
|
|
134
|
+
renderAbsoluteFloatingObjectsInPageOverlay: boolean,
|
|
135
|
+
): string {
|
|
136
|
+
return `p|${paragraphNeighborDigest(prev)}|${paragraphNeighborDigest(next)}|m${mediaPreviewsId}|${showUnsupportedObjectPreviews ? 1 : 0}|${renderAbsoluteFloatingObjectsInPageOverlay ? 1 : 0}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function cacheLookup(blockRef: BlockNode, externalKey: string): PMNode | null {
|
|
140
|
+
const bucket = pmNodeCache.get(blockRef);
|
|
141
|
+
if (!bucket) return null;
|
|
142
|
+
const node = bucket.get(externalKey);
|
|
143
|
+
return node ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cacheStore(blockRef: BlockNode, externalKey: string, node: PMNode): void {
|
|
147
|
+
let bucket = pmNodeCache.get(blockRef);
|
|
148
|
+
if (!bucket) {
|
|
149
|
+
bucket = new Map();
|
|
150
|
+
pmNodeCache.set(blockRef, bucket);
|
|
151
|
+
} else if (bucket.size >= MAX_ENTRIES_PER_BLOCK) {
|
|
152
|
+
// Evict the oldest (insertion-order) entry. JS Maps iterate in insertion
|
|
153
|
+
// order, so `keys().next().value` is the oldest.
|
|
154
|
+
const oldestKey = bucket.keys().next().value;
|
|
155
|
+
if (oldestKey !== undefined) bucket.delete(oldestKey);
|
|
156
|
+
}
|
|
157
|
+
bucket.set(externalKey, node);
|
|
158
|
+
}
|
|
159
|
+
|
|
22
160
|
/**
|
|
23
161
|
* Test-friendly wrapper: build a PM EditorState from a minimal snapshot with no
|
|
24
162
|
* selection or plugins. The snapshot is cast to EditorSurfaceSnapshot so callers
|
|
@@ -243,6 +381,7 @@ function buildPMDoc(
|
|
|
243
381
|
mediaPreviews,
|
|
244
382
|
showUnsupportedObjectPreviews,
|
|
245
383
|
renderAbsoluteFloatingObjectsInPageOverlay,
|
|
384
|
+
getCanonicalBlockRefs(surface),
|
|
246
385
|
);
|
|
247
386
|
|
|
248
387
|
// Ensure at least one block (PM requires non-empty doc)
|
|
@@ -258,8 +397,10 @@ function buildPMBlocks(
|
|
|
258
397
|
mediaPreviews: Record<string, MediaPreviewDescriptor>,
|
|
259
398
|
showUnsupportedObjectPreviews: boolean,
|
|
260
399
|
renderAbsoluteFloatingObjectsInPageOverlay: boolean,
|
|
400
|
+
blockRefs: readonly (BlockNode | null)[] | null = null,
|
|
261
401
|
): PMNode[] {
|
|
262
402
|
const nodes: PMNode[] = [];
|
|
403
|
+
const mediaId = mediaPreviewsIdentity(mediaPreviews);
|
|
263
404
|
|
|
264
405
|
for (let index = 0; index < blocks.length; index += 1) {
|
|
265
406
|
const block = blocks[index];
|
|
@@ -269,6 +410,39 @@ function buildPMBlocks(
|
|
|
269
410
|
const nextParagraph = nextBlock?.kind === "paragraph" ? nextBlock : null;
|
|
270
411
|
|
|
271
412
|
if (block.kind === "paragraph") {
|
|
413
|
+
// L1 identity cache — paragraphs only in this slice. Canonical ref
|
|
414
|
+
// comes from the parallel `blockRefs` array attached by
|
|
415
|
+
// `surface-projection`. Missing ref (null or no array provided) is
|
|
416
|
+
// treated as a cache miss: the function is backward-compatible with
|
|
417
|
+
// callers that don't supply refs (tests, cell-internal recursion).
|
|
418
|
+
const ref = blockRefs?.[index] ?? null;
|
|
419
|
+
if (ref !== null) {
|
|
420
|
+
const extKey = paragraphExternalKey(
|
|
421
|
+
previousParagraph,
|
|
422
|
+
nextParagraph,
|
|
423
|
+
mediaId,
|
|
424
|
+
showUnsupportedObjectPreviews,
|
|
425
|
+
renderAbsoluteFloatingObjectsInPageOverlay,
|
|
426
|
+
);
|
|
427
|
+
const cached = cacheLookup(ref, extKey);
|
|
428
|
+
if (cached) {
|
|
429
|
+
identityCacheHits += 1;
|
|
430
|
+
nodes.push(cached);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
identityCacheMisses += 1;
|
|
434
|
+
const built = buildParagraph(
|
|
435
|
+
block,
|
|
436
|
+
previousParagraph,
|
|
437
|
+
nextParagraph,
|
|
438
|
+
mediaPreviews,
|
|
439
|
+
showUnsupportedObjectPreviews,
|
|
440
|
+
renderAbsoluteFloatingObjectsInPageOverlay,
|
|
441
|
+
);
|
|
442
|
+
cacheStore(ref, extKey, built);
|
|
443
|
+
nodes.push(built);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
272
446
|
nodes.push(
|
|
273
447
|
buildParagraph(
|
|
274
448
|
block,
|
|
@@ -501,6 +675,7 @@ function buildInlineContent(
|
|
|
501
675
|
widthEmu: preview?.widthEmu ?? preview?.widthEmu ?? segment.anchor?.extent.widthEmu ?? null,
|
|
502
676
|
heightEmu: preview?.heightEmu ?? segment.anchor?.extent.heightEmu ?? null,
|
|
503
677
|
// Lane 6d N11 — picture effects.
|
|
678
|
+
lum: segment.pictureEffects?.lum ?? null,
|
|
504
679
|
rotation: segment.pictureEffects?.rotation ?? null,
|
|
505
680
|
flipH: segment.pictureEffects?.flipH ?? false,
|
|
506
681
|
flipV: segment.pictureEffects?.flipV ?? false,
|
|
@@ -525,6 +700,10 @@ function buildInlineContent(
|
|
|
525
700
|
return [buildOpaqueInlineOrComplexAtom(segment, mediaPreviews, showUnsupportedObjectPreviews)];
|
|
526
701
|
|
|
527
702
|
case "shape":
|
|
703
|
+
{
|
|
704
|
+
const renderInPageOverlay =
|
|
705
|
+
renderAbsoluteFloatingObjectsInPageOverlay &&
|
|
706
|
+
shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor);
|
|
528
707
|
return [
|
|
529
708
|
editorSchema.nodes.shape_atom.create({
|
|
530
709
|
label: segment.label,
|
|
@@ -536,10 +715,13 @@ function buildInlineContent(
|
|
|
536
715
|
txbxText: segment.txbxText ?? null,
|
|
537
716
|
wrapMode: segment.anchor?.wrapMode ?? "none",
|
|
538
717
|
display: segment.anchor?.display ?? "inline",
|
|
718
|
+
renderInPageOverlay,
|
|
539
719
|
widthEmu: segment.anchor?.extent.widthEmu ?? null,
|
|
540
720
|
heightEmu: segment.anchor?.extent.heightEmu ?? null,
|
|
721
|
+
positionH: segment.anchor?.positionH ?? null,
|
|
541
722
|
}),
|
|
542
723
|
];
|
|
724
|
+
}
|
|
543
725
|
|
|
544
726
|
case "note_ref": {
|
|
545
727
|
const text = editorSchema.text(
|
|
@@ -722,11 +904,34 @@ function buildSdtBlock(
|
|
|
722
904
|
dropdownItems: block.dropdownItems ?? null,
|
|
723
905
|
comboBoxItems: block.comboBoxItems ?? null,
|
|
724
906
|
showingPlcHdr: block.showingPlcHdr ?? false,
|
|
907
|
+
containsPageBreak: surfaceBlocksContainPageBreak(block.children),
|
|
725
908
|
},
|
|
726
909
|
Fragment.from(children),
|
|
727
910
|
);
|
|
728
911
|
}
|
|
729
912
|
|
|
913
|
+
function surfaceBlocksContainPageBreak(blocks: readonly SurfaceBlockSnapshot[]): boolean {
|
|
914
|
+
for (const block of blocks) {
|
|
915
|
+
if (
|
|
916
|
+
block.kind === "paragraph" &&
|
|
917
|
+
block.segments.some((segment) => segment.kind === "opaque_inline" && segment.label === "Page break")
|
|
918
|
+
) {
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
if (block.kind === "sdt_block" && surfaceBlocksContainPageBreak(block.children)) {
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
if (block.kind === "table") {
|
|
925
|
+
for (const row of block.rows) {
|
|
926
|
+
for (const cell of row.cells) {
|
|
927
|
+
if (surfaceBlocksContainPageBreak(cell.content)) return true;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
|
|
730
935
|
/**
|
|
731
936
|
* Labels surface-projection emits for preserve-only complex fragments
|
|
732
937
|
* (charts, SmartArt, drawing shapes, WordArt, legacy VML). These have
|