@beyondwork/docx-react-component 1.0.96 → 1.0.98

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +47 -19
  3. package/src/api/v3/ui/_types.ts +11 -21
  4. package/src/api/v3/ui/chrome.ts +8 -9
  5. package/src/api/v3/ui/debug.ts +15 -77
  6. package/src/api/v3/ui/overlays-visibility.ts +9 -10
  7. package/src/api/v3/ui/overlays.ts +8 -75
  8. package/src/io/ooxml/parse-main-document.ts +30 -0
  9. package/src/io/ooxml/parse-picture.ts +14 -0
  10. package/src/io/ooxml/parse-shapes.ts +41 -1
  11. package/src/io/ooxml/payload-signature.ts +101 -0
  12. package/src/model/canonical-document.ts +17 -0
  13. package/src/runtime/layout/layout-engine-version.ts +14 -1
  14. package/src/runtime/layout/page-story-resolver.ts +1 -0
  15. package/src/runtime/layout/paginated-layout-engine.ts +26 -10
  16. package/src/runtime/surface-projection.ts +114 -12
  17. package/src/session/export/stateful-export-pipeline.ts +18 -2
  18. package/src/session/export/stateful-export.ts +21 -1
  19. package/src/ui/WordReviewEditor.tsx +6 -10
  20. package/src/ui/editor-command-bag.ts +2 -0
  21. package/src/ui/ui-controller-factory.ts +2 -2
  22. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +11 -25
  23. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +2 -2
  24. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +1 -1
  25. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +22 -220
  26. package/src/ui-tailwind/debug/README.md +12 -50
  27. package/src/ui-tailwind/debug/tw-debug-overlay.tsx +6 -6
  28. package/src/ui-tailwind/debug/tw-debug-presentation.tsx +9 -20
  29. package/src/ui-tailwind/debug/tw-debug-top-bar.tsx +5 -6
  30. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +1 -4
  31. package/src/ui-tailwind/editor-surface/picture-effects.ts +96 -0
  32. package/src/ui-tailwind/editor-surface/pm-schema.ts +89 -62
  33. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +205 -0
  34. package/src/ui-tailwind/editor-surface/preserve-position.ts +12 -2
  35. package/src/ui-tailwind/editor-surface/runtime-decoration-plugin.ts +190 -0
  36. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +67 -56
  37. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +83 -20
  38. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +114 -4
  39. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +5 -0
  40. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +3 -0
  41. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +3 -0
  42. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  43. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +26 -0
  44. package/src/ui-tailwind/theme/editor-theme.css +18 -11
  45. package/src/ui-tailwind/tw-review-workspace.tsx +15 -0
@@ -199,6 +199,14 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
199
199
  * Tests override it to capture the release callback.
200
200
  */
201
201
  scheduleMicrotask?: (callback: () => void) => void;
202
+ /**
203
+ * Synchronous work that must happen after `view.updateState(newState)`
204
+ * but before the optional scroll restore. The ProseMirror surface uses
205
+ * this to dispatch runtime decoration inputs into the PM plugin while
206
+ * the same captured scroll anchor still protects the whole replacement
207
+ * cycle.
208
+ */
209
+ afterUpdateState?: () => void;
202
210
  }
203
211
 
204
212
  /**
@@ -212,8 +220,9 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
212
220
  * `preserveScrollAnchor: true` and a live `geometryFacet`
213
221
  * 2. suppressionRef.current = true
214
222
  * 3. view.updateState(newState) ← PM may fire selection events here
215
- * 4. (optional) restore scroll anchor
216
- * 5. queueMicrotask(() => suppressionRef.current = false)
223
+ * 4. options.afterUpdateState?.() optional decoration/plugin sync
224
+ * 5. (optional) restore scroll anchor
225
+ * 6. queueMicrotask(() => suppressionRef.current = false)
217
226
  *
218
227
  * The microtask release guarantees the flag is still `true` for any
219
228
  * synchronous selection-change handler that fires during (3), and
@@ -242,6 +251,7 @@ export function replaceStatePreservingPosition(
242
251
  : null;
243
252
  options.suppressionRef.current = true;
244
253
  options.view.updateState(newState);
254
+ options.afterUpdateState?.();
245
255
  if (preserved) {
246
256
  const restored = restorePosition(preserved, options);
247
257
  if (!restored) {
@@ -0,0 +1,190 @@
1
+ // Refactor/11b Slice B — runtime decoration plugin (Option B fast-path).
2
+ //
3
+ // Before this slice, `tw-prosemirror-surface.tsx` rebuilt the entire
4
+ // `DecorationSet` from scratch on every commit via a React effect:
5
+ //
6
+ // view.setProps({ decorations: () => buildDecorations(...) });
7
+ //
8
+ // `buildDecorations` walks the whole PM doc + every comment thread + every
9
+ // revision + every workflow scope + every candidate + every locked zone.
10
+ // On a 500-page doc toggling "Show tracked changes" this was ~200–300 ms
11
+ // (documented residual in `docs/wiki/performance.md §Context-Analytics Hot
12
+ // Path`). On every keystroke it rebuilt the full set even when only the
13
+ // caret + a few characters moved.
14
+ //
15
+ // This plugin shifts the strategy to the idiomatic PM pattern:
16
+ //
17
+ // 1. Plugin state holds `{ inputs, set }` — the currently-authoritative
18
+ // decoration inputs and the computed DecorationSet.
19
+ // 2. On a transaction with `tr.setMeta(pluginKey, { inputs })`: inputs
20
+ // changed, rebuild the set once.
21
+ // 3. On a transaction without that meta: inputs are stable. Map the
22
+ // existing set through `tr.mapping` (O(decorations), not O(doc)).
23
+ //
24
+ // Runtime-origin doc replacements — where PM state is swapped wholesale
25
+ // via `view.updateState(state)` because the runtime produced a new
26
+ // snapshot — can't be handled by `tr.mapping`, so the consumer dispatches
27
+ // a meta to force a rebuild on those paths. Pure typing transactions
28
+ // (the `onInsertText` fast-text-edit-lane commit that `view.dispatch`es a
29
+ // local step) go through the no-meta branch and pay O(decorations) only.
30
+ //
31
+ // Scope of this slice: wraps the existing `buildDecorations` +
32
+ // `buildPageBreakDecorationsFromProps` unchanged. No restructuring of
33
+ // the 14 decoration sources inside `buildDecorations` — that's a
34
+ // follow-up that would split per-source subsets to let single-source
35
+ // updates skip untouched subsets.
36
+
37
+ import { type EditorState, type Transaction, Plugin, PluginKey } from "prosemirror-state";
38
+ import { type Decoration, type EditorView, DecorationSet } from "prosemirror-view";
39
+ import type { Node as PMNode } from "prosemirror-model";
40
+
41
+ import type {
42
+ EditorStoryTarget,
43
+ WorkflowBlockedCommandReason,
44
+ WorkflowCandidateRange,
45
+ WorkflowLockedZone,
46
+ WorkflowMetadataMarkup,
47
+ WorkflowScope,
48
+ } from "../../api/public-types";
49
+ import type { CommentDecorationModel, MarkupDisplay } from "../../ui/headless/comment-decoration-model";
50
+ import type {
51
+ RevisionDecorationModel,
52
+ RevisionDisplayFlags,
53
+ } from "../../ui/headless/revision-decoration-model";
54
+ import { buildDecorations } from "./pm-decorations";
55
+ import type { PositionMap } from "./pm-position-map";
56
+
57
+ /** Inputs that drive every call to `buildDecorations`, plus an opaque
58
+ * callback that supplies any additional decorations layered on top
59
+ * (today: page-break chrome widgets). Keeping page-break inputs inside
60
+ * the callback keeps this module decoupled from its current dependency
61
+ * graph — the caller captures whatever it needs in the closure. */
62
+ export interface RuntimeDecorationInputs {
63
+ positionMap: PositionMap;
64
+ commentModel: CommentDecorationModel | undefined;
65
+ revisionModel: RevisionDecorationModel | undefined;
66
+ markupDisplay: MarkupDisplay;
67
+ showTrackedChanges: boolean;
68
+ suggestionsEnabled: boolean;
69
+ workflowScopes: readonly WorkflowScope[] | undefined;
70
+ activeStory: EditorStoryTarget;
71
+ workflowCandidates: readonly WorkflowCandidateRange[] | undefined;
72
+ workflowBlockedReasons: readonly WorkflowBlockedCommandReason[] | undefined;
73
+ workflowLockedZones: readonly WorkflowLockedZone[] | undefined;
74
+ activeWorkflowWorkItemId: string | null | undefined;
75
+ activeWorkflowScopeIds: readonly string[] | undefined;
76
+ workflowMetadata: readonly WorkflowMetadataMarkup[] | undefined;
77
+ revisionDisplayByOffset: ReadonlyMap<number, RevisionDisplayFlags> | undefined;
78
+ /** Extra decorations layered on top of the base `buildDecorations`
79
+ * result. Today the page-break chrome widgets flow through here —
80
+ * their inputs + helper live in `tw-prosemirror-surface.tsx` so the
81
+ * consumer captures them in the closure and returns built
82
+ * decorations. `null`/empty returns are fine. */
83
+ extraDecorations?: (doc: PMNode) => readonly Decoration[];
84
+ }
85
+
86
+ interface DecorationState {
87
+ inputs: RuntimeDecorationInputs | null;
88
+ set: DecorationSet;
89
+ }
90
+
91
+ /** Key for the plugin. Consumers dispatch via
92
+ * `view.state.tr.setMeta(runtimeDecorationPluginKey, { inputs })`. */
93
+ export const runtimeDecorationPluginKey = new PluginKey<DecorationState>(
94
+ "wre-runtime-decorations",
95
+ );
96
+
97
+ /** Module-level counters — mirror the style used by the L1 identity cache.
98
+ * Read via `getRuntimeDecorationCounters()`. Useful for perf benches +
99
+ * tests that assert the fast-path actually runs. */
100
+ let fullRebuilds = 0;
101
+ let mappedTransactions = 0;
102
+ let noOpTransactions = 0;
103
+
104
+ export function getRuntimeDecorationCounters(): {
105
+ fullRebuilds: number;
106
+ mappedTransactions: number;
107
+ noOpTransactions: number;
108
+ } {
109
+ return { fullRebuilds, mappedTransactions, noOpTransactions };
110
+ }
111
+
112
+ export function __resetRuntimeDecorationCountersForTests(): void {
113
+ fullRebuilds = 0;
114
+ mappedTransactions = 0;
115
+ noOpTransactions = 0;
116
+ }
117
+
118
+ function rebuildSet(doc: PMNode, inputs: RuntimeDecorationInputs): DecorationSet {
119
+ const base = buildDecorations(
120
+ doc,
121
+ inputs.positionMap,
122
+ inputs.commentModel,
123
+ inputs.revisionModel,
124
+ inputs.markupDisplay,
125
+ inputs.showTrackedChanges,
126
+ inputs.suggestionsEnabled,
127
+ inputs.workflowScopes,
128
+ inputs.activeStory,
129
+ inputs.workflowCandidates,
130
+ inputs.workflowBlockedReasons,
131
+ inputs.workflowLockedZones,
132
+ inputs.activeWorkflowWorkItemId,
133
+ inputs.activeWorkflowScopeIds,
134
+ inputs.workflowMetadata,
135
+ inputs.revisionDisplayByOffset,
136
+ );
137
+ const extras = inputs.extraDecorations ? inputs.extraDecorations(doc) : [];
138
+ if (extras.length === 0) return base;
139
+ // Layer extras on top of the base set. `DecorationSet.add` is the
140
+ // supported join that keeps the base set's structure and adds new
141
+ // decorations to it.
142
+ return base.add(doc, [...extras]);
143
+ }
144
+
145
+ export function createRuntimeDecorationPlugin(): Plugin<DecorationState> {
146
+ return new Plugin<DecorationState>({
147
+ key: runtimeDecorationPluginKey,
148
+ state: {
149
+ init: () => ({ inputs: null, set: DecorationSet.empty }),
150
+ apply(tr, prev, _oldState, newState): DecorationState {
151
+ const meta = tr.getMeta(runtimeDecorationPluginKey) as
152
+ | { inputs: RuntimeDecorationInputs }
153
+ | null
154
+ | undefined;
155
+ if (meta) {
156
+ fullRebuilds += 1;
157
+ return { inputs: meta.inputs, set: rebuildSet(newState.doc, meta.inputs) };
158
+ }
159
+ if (tr.docChanged) {
160
+ mappedTransactions += 1;
161
+ return {
162
+ inputs: prev.inputs,
163
+ set: prev.set.map(tr.mapping, newState.doc),
164
+ };
165
+ }
166
+ noOpTransactions += 1;
167
+ return prev;
168
+ },
169
+ },
170
+ props: {
171
+ decorations(state: EditorState): DecorationSet {
172
+ return runtimeDecorationPluginKey.getState(state)?.set ?? DecorationSet.empty;
173
+ },
174
+ },
175
+ });
176
+ }
177
+
178
+ /** Convenience for consumers — dispatches a meta transaction that
179
+ * triggers a full rebuild. Call whenever any `RuntimeDecorationInputs`
180
+ * field changes identity or when the underlying doc was replaced
181
+ * wholesale (runtime-origin `view.updateState(state)` path). */
182
+ export function applyRuntimeDecorationInputs(
183
+ view: EditorView,
184
+ inputs: RuntimeDecorationInputs,
185
+ ): void {
186
+ const tr: Transaction = view.state.tr.setMeta(runtimeDecorationPluginKey, {
187
+ inputs,
188
+ });
189
+ view.dispatch(tr);
190
+ }
@@ -53,6 +53,11 @@ import { buildDecorations } from "./pm-decorations";
53
53
  import { buildPageBreakDecorations } from "./pm-page-break-decorations";
54
54
  import { findBlockIndexRangeForPage } from "./page-slice-util.ts";
55
55
  import { DecorationSet } from "prosemirror-view";
56
+ import {
57
+ applyRuntimeDecorationInputs,
58
+ createRuntimeDecorationPlugin,
59
+ type RuntimeDecorationInputs,
60
+ } from "./runtime-decoration-plugin.ts";
56
61
  import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
57
62
  import { buildPagePreviewMaps } from "../../api/public-types";
58
63
  import { createContextualInteractionPlugin } from "./pm-contextual-ui";
@@ -670,52 +675,61 @@ export const TwProseMirrorSurface = forwardRef<
670
675
  onRevisionHovered: (revisionId) => props.onRevisionHovered?.(revisionId),
671
676
  }),
672
677
  createSearchPlugin(),
678
+ // Refactor/11b Slice B — runtime decorations as PM plugin state.
679
+ // Non-meta transactions map the existing set through `tr.mapping`
680
+ // in O(decorations); meta-tagged transactions rebuild from inputs.
681
+ // Registered LAST so its `props.decorations(state)` is the
682
+ // runtime-authoritative set the view renders. Search-plugin
683
+ // decorations compose at the view level — PM merges `decorations`
684
+ // returned by each plugin's `props.decorations`.
685
+ createRuntimeDecorationPlugin(),
673
686
  ];
674
687
  }, [props.awareness, props.onCommentActivated, props.onRevisionActivated, props.onRevisionHovered]);
675
688
 
676
689
  const applyDecorationProps = useCallback(
677
690
  (view: EditorView, positionMap: PositionMap): void => {
678
- const baseDecorations = buildDecorations(
679
- view.state.doc,
691
+ // Refactor/11b Slice B — dispatch inputs into the runtime decoration
692
+ // plugin instead of imperatively calling `view.setProps({decorations})`
693
+ // on every effect fire. The plugin holds the computed DecorationSet
694
+ // in its state; non-meta transactions (typing, selection) map the
695
+ // existing set through `tr.mapping` in O(decorations) rather than
696
+ // re-walking the doc + every comment thread / revision / workflow
697
+ // scope / candidate / locked zone from scratch.
698
+ const inputs: RuntimeDecorationInputs = {
680
699
  positionMap,
681
700
  commentModel,
682
701
  revisionModel,
683
702
  markupDisplay,
684
703
  showTrackedChanges,
685
704
  suggestionsEnabled,
686
- props.workflowScopes,
687
- snapshot.activeStory,
688
- props.workflowCandidates,
689
- props.workflowBlockedReasons,
690
- props.workflowLockedZones,
691
- props.activeWorkflowWorkItemId,
692
- props.activeWorkflowScopeIds,
693
- props.workflowMetadata,
705
+ workflowScopes: props.workflowScopes,
706
+ activeStory: snapshot.activeStory,
707
+ workflowCandidates: props.workflowCandidates,
708
+ workflowBlockedReasons: props.workflowBlockedReasons,
709
+ workflowLockedZones: props.workflowLockedZones,
710
+ activeWorkflowWorkItemId: props.activeWorkflowWorkItemId ?? null,
711
+ activeWorkflowScopeIds: props.activeWorkflowScopeIds,
712
+ workflowMetadata: props.workflowMetadata,
694
713
  revisionDisplayByOffset,
695
- );
696
- const pageBreakDecos = buildPageBreakDecorationsFromProps(
697
- props.geometryFacet,
698
- snapshot.activeStory.kind === "main",
699
- positionMap,
700
- props.isPageWorkspace ? "page" : "canvas",
701
- props.canonicalDocument,
702
- {
703
- headerBandPx: props.pageChromeHeaderBandPx,
704
- footerBandPx: props.pageChromeFooterBandPx,
705
- interGapPx: props.pageChromeInterGapPx,
706
- },
707
- snapshot.surface?.blocks,
708
- );
709
- const decorations = pageBreakDecos.length > 0
710
- ? DecorationSet.create(view.state.doc, [
711
- ...pageBreakDecos,
712
- ...extractDecorations(baseDecorations, view.state.doc),
713
- ])
714
- : baseDecorations;
715
- view.setProps({
716
- editable: () => canEdit,
717
- decorations: () => decorations,
718
- });
714
+ extraDecorations: (_doc) =>
715
+ buildPageBreakDecorationsFromProps(
716
+ props.geometryFacet,
717
+ snapshot.activeStory.kind === "main",
718
+ positionMap,
719
+ props.isPageWorkspace ? "page" : "canvas",
720
+ props.canonicalDocument,
721
+ {
722
+ headerBandPx: props.pageChromeHeaderBandPx,
723
+ footerBandPx: props.pageChromeFooterBandPx,
724
+ interGapPx: props.pageChromeInterGapPx,
725
+ },
726
+ snapshot.surface?.blocks,
727
+ ),
728
+ };
729
+ applyRuntimeDecorationInputs(view, inputs);
730
+ // `editable` is not decoration-shaped; keep the existing setProps
731
+ // path for it so the plugin-state swap stays scoped to decorations.
732
+ view.setProps({ editable: () => canEdit });
719
733
  decorationBuildKeyRef.current = decorationBuildKey;
720
734
  recordPerfSample("pm.decorations");
721
735
  incrementInvalidationCounter("pm.laneB.decorationUpdates");
@@ -914,33 +928,20 @@ export const TwProseMirrorSurface = forwardRef<
914
928
  props.isPageWorkspace,
915
929
  );
916
930
  positionMapRef.current = positionMap;
917
- const decorations = buildDecorations(
918
- state.doc,
919
- positionMap,
920
- commentModel,
921
- revisionModel,
922
- markupDisplay,
923
- showTrackedChanges,
924
- suggestionsEnabled,
925
- props.workflowScopes,
926
- snapshot.activeStory,
927
- props.workflowCandidates,
928
- props.workflowBlockedReasons,
929
- props.workflowLockedZones,
930
- props.activeWorkflowWorkItemId,
931
- props.activeWorkflowScopeIds,
932
- props.workflowMetadata,
933
- revisionDisplayByOffset,
934
- );
931
+ // Slice B — the runtime decoration plugin (registered in the plugin
932
+ // list) now owns the decoration set. The fresh-mount path dispatches
933
+ // inputs into the plugin via `applyDecorationProps` below; PM reads
934
+ // the set via the plugin's `props.decorations(state)` hook. No
935
+ // pre-mount `buildDecorations` call is needed.
935
936
  recordPerfSample("pm.rebuild");
936
937
  incrementInvalidationCounter("pm.laneA.rebuilds");
937
938
 
939
+ let appliedDecorationProps = false;
938
940
  if (!viewRef.current) {
939
941
  const view = new EditorView(mountRef.current, {
940
942
  state,
941
943
  nodeViews: { ...tableNodeViews, ...chartNodeViews },
942
944
  editable: () => canEdit,
943
- decorations: () => decorations,
944
945
  });
945
946
  viewRef.current = view;
946
947
  recordPerfSample("pm.mount");
@@ -960,32 +961,42 @@ export const TwProseMirrorSurface = forwardRef<
960
961
  // would move by more than the small local-edit budget, the helper
961
962
  // refuses that exact target but restores the captured scrollTop so
962
963
  // a PM/browser-origin top jump is not accepted as the final state.
964
+ // The runtime-decoration plugin is also updated inside the funnel:
965
+ // its meta transaction can rebuild page-break/widget decorations
966
+ // synchronously, so it must be included before the final restore.
963
967
  //
964
968
  // Ordering invariant is regression-guarded by
965
969
  // `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
970
+ const view = viewRef.current;
966
971
  const scrollAnchorPolicy = pendingRebuildScrollAnchorRef.current;
967
972
  pendingRebuildScrollAnchorRef.current = null;
968
973
  const preserveScrollAnchor = shouldPreserveScrollAnchorForRebuild({
969
974
  policy: scrollAnchorPolicy,
970
- view: viewRef.current,
975
+ view,
971
976
  geometryFacet: props.geometryFacet,
972
977
  previousStory: lastBuiltStoryRef.current,
973
978
  nextStory: snapshot.activeStory,
974
979
  });
975
980
  replaceStatePreservingPosition(
976
981
  {
977
- view: viewRef.current,
982
+ view,
978
983
  geometryFacet: props.geometryFacet,
979
984
  suppressionRef: suppressSelectionEchoRef,
980
985
  preserveScrollAnchor,
981
986
  maxScrollDeltaPx: BOUNDED_TYPING_SCROLL_RESTORE_MAX_DELTA_PX,
987
+ afterUpdateState: () => {
988
+ applyDecorationProps(view, positionMap);
989
+ appliedDecorationProps = true;
990
+ },
982
991
  },
983
992
  state,
984
993
  );
985
994
  }
986
995
  documentBuildKeyRef.current = documentBuildKey;
987
996
  lastBuiltStoryRef.current = snapshot.activeStory;
988
- applyDecorationProps(viewRef.current, positionMap);
997
+ if (!appliedDecorationProps) {
998
+ applyDecorationProps(viewRef.current, positionMap);
999
+ }
989
1000
 
990
1001
  if (activeSearchRef.current) {
991
1002
  applySearch(
@@ -5,6 +5,7 @@ import type {
5
5
  SurfaceBlockSnapshot,
6
6
  SurfaceDrawingAnchor,
7
7
  SurfaceInlineSegment,
8
+ SurfacePictureEffects,
8
9
  } from "../../api/public-types.ts";
9
10
  import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
10
11
  import {
@@ -44,22 +45,31 @@ export interface FloatingImageOverlayItem {
44
45
  leftPx: number;
45
46
  widthPx: number;
46
47
  heightPx: number;
48
+ zIndex?: number;
47
49
  behindDoc: boolean;
48
50
  src: string | null;
49
51
  altText: string | null;
50
52
  detail: string | null;
53
+ pictureEffects?: SurfacePictureEffects;
54
+ shape?: {
55
+ label: string;
56
+ text: string;
57
+ fill?: Extract<Extract<SurfaceInlineSegment, { kind: "shape" }>["fill"], object>;
58
+ line?: Extract<SurfaceInlineSegment, { kind: "shape" }>["line"];
59
+ body?: Extract<SurfaceInlineSegment, { kind: "shape" }>["textBoxBody"];
60
+ marks?: Extract<SurfaceInlineSegment, { kind: "shape" }>["txbxMarks"];
61
+ markAttrs?: Extract<SurfaceInlineSegment, { kind: "shape" }>["txbxMarkAttrs"];
62
+ };
51
63
  }
52
64
 
53
65
  const SUPPORTED_HORIZONTAL_RELATIVE_FROM = new Set(["page", "margin"]);
54
- const SUPPORTED_VERTICAL_RELATIVE_FROM = new Set(["page", "margin"]);
66
+ const SUPPORTED_VERTICAL_RELATIVE_FROM = new Set(["page", "margin", "paragraph"]);
67
+ const SUPPORTED_FLOAT_WRAP_MODES = new Set(["none", "square", "topAndBottom"]);
55
68
 
56
69
  export function shouldRenderAbsoluteFloatingImageInPageOverlay(
57
70
  anchor: SurfaceDrawingAnchor | undefined,
58
71
  ): boolean {
59
- if (!anchor || anchor.display !== "floating" || anchor.wrapMode !== "none") {
60
- return false;
61
- }
62
- if (anchor.layoutInCell) {
72
+ if (!anchor || anchor.display !== "floating" || !SUPPORTED_FLOAT_WRAP_MODES.has(anchor.wrapMode)) {
63
73
  return false;
64
74
  }
65
75
  if (!anchor.positionH || !anchor.positionV) {
@@ -106,10 +116,13 @@ export function collectFloatingImageOverlayItems(input: {
106
116
  storyTarget: EditorStoryTarget,
107
117
  ) => {
108
118
  walkSurfaceBlocks(blocks, (segment) => {
109
- if (
110
- segment.kind !== "image" ||
111
- !shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor)
112
- ) {
119
+ if (!isOverlaySegment(segment)) {
120
+ return;
121
+ }
122
+ if (!shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor)) {
123
+ return;
124
+ }
125
+ if (isMicrosoftSensitivityLabelOverlay(segment)) {
113
126
  return;
114
127
  }
115
128
 
@@ -119,14 +132,14 @@ export function collectFloatingImageOverlayItems(input: {
119
132
  if (!pageRect) {
120
133
  continue;
121
134
  }
122
- const localRect = resolveFloatingImageLocalRect(page, storyTarget, segment, pxPerTwip);
135
+ const localRect = resolveFloatingDrawingLocalRect(page, storyTarget, segment.anchor, pxPerTwip);
123
136
  if (!localRect) {
124
137
  continue;
125
138
  }
126
- const preview = input.mediaPreviews?.[segment.mediaId];
139
+ const preview = segment.kind === "image" ? input.mediaPreviews?.[segment.mediaId] : undefined;
127
140
  items.push({
128
141
  key: `${segment.segmentId}:${page.pageId}`,
129
- mediaId: segment.mediaId,
142
+ mediaId: segment.kind === "image" ? segment.mediaId : `shape:${segment.segmentId}`,
130
143
  from: segment.from,
131
144
  to: segment.to,
132
145
  pageId: page.pageId,
@@ -135,10 +148,29 @@ export function collectFloatingImageOverlayItems(input: {
135
148
  leftPx: localRect.leftPx,
136
149
  widthPx: localRect.widthPx,
137
150
  heightPx: localRect.heightPx,
151
+ ...(segment.anchor?.relativeHeight !== undefined
152
+ ? { zIndex: segment.anchor.relativeHeight }
153
+ : {}),
138
154
  behindDoc: Boolean(segment.anchor?.behindDoc),
139
155
  src: preview?.src ?? null,
140
- altText: segment.altText ?? null,
156
+ altText: segment.kind === "image" ? segment.altText ?? null : null,
141
157
  detail: segment.detail ?? null,
158
+ ...(segment.kind === "image" && segment.pictureEffects
159
+ ? { pictureEffects: segment.pictureEffects }
160
+ : {}),
161
+ ...(segment.kind === "shape"
162
+ ? {
163
+ shape: {
164
+ label: segment.label,
165
+ text: segment.txbxText ?? segment.label,
166
+ ...(segment.fill ? { fill: segment.fill } : {}),
167
+ ...(segment.line ? { line: segment.line } : {}),
168
+ ...(segment.textBoxBody ? { body: segment.textBoxBody } : {}),
169
+ ...(segment.txbxMarks ? { marks: segment.txbxMarks } : {}),
170
+ ...(segment.txbxMarkAttrs ? { markAttrs: segment.txbxMarkAttrs } : {}),
171
+ },
172
+ }
173
+ : {}),
142
174
  });
143
175
  }
144
176
  });
@@ -157,7 +189,36 @@ export function collectFloatingImageOverlayItems(input: {
157
189
  collectFromStory(secondary.blocks, secondary.target);
158
190
  }
159
191
 
160
- return items;
192
+ return items.sort(compareOverlayItems);
193
+ }
194
+
195
+ function isMicrosoftSensitivityLabelOverlay(
196
+ segment: Extract<SurfaceInlineSegment, { kind: "image" | "shape" }>,
197
+ ): boolean {
198
+ if (segment.kind !== "shape") return false;
199
+ const name = segment.anchor?.docPr?.name ?? "";
200
+ const descr = segment.anchor?.docPr?.descr ?? "";
201
+ if (/^MSIPCM/i.test(name)) {
202
+ return true;
203
+ }
204
+ return /"Placement"\s*:\s*"Footer"/.test(descr) &&
205
+ /"Index"\s*:\s*"FirstPage"/.test(descr) &&
206
+ /classification/i.test(segment.txbxText ?? "");
207
+ }
208
+
209
+ function compareOverlayItems(a: FloatingImageOverlayItem, b: FloatingImageOverlayItem): number {
210
+ if (a.pageIndex !== b.pageIndex) return a.pageIndex - b.pageIndex;
211
+ const aZ = a.zIndex ?? 0;
212
+ const bZ = b.zIndex ?? 0;
213
+ if (aZ !== bZ) return aZ - bZ;
214
+ return a.key.localeCompare(b.key);
215
+ }
216
+
217
+ function isOverlaySegment(
218
+ segment: SurfaceInlineSegment,
219
+ ): segment is Extract<SurfaceInlineSegment, { kind: "image" | "shape" }> {
220
+ if (segment.kind === "image") return true;
221
+ return segment.kind === "shape" && Boolean(segment.isTextBox && segment.anchor);
161
222
  }
162
223
 
163
224
  function walkSurfaceBlocks(
@@ -205,10 +266,10 @@ function resolveTargetPages(
205
266
  });
206
267
  }
207
268
 
208
- function resolveFloatingImageLocalRect(
269
+ function resolveFloatingDrawingLocalRect(
209
270
  page: PublicPageNode,
210
271
  activeStory: EditorStoryTarget,
211
- segment: Extract<SurfaceInlineSegment, { kind: "image" }>,
272
+ anchor: SurfaceDrawingAnchor | undefined,
212
273
  pxPerTwip: number,
213
274
  ): {
214
275
  topPx: number;
@@ -216,7 +277,6 @@ function resolveFloatingImageLocalRect(
216
277
  widthPx: number;
217
278
  heightPx: number;
218
279
  } | null {
219
- const anchor = segment.anchor;
220
280
  if (!anchor) {
221
281
  return null;
222
282
  }
@@ -245,12 +305,13 @@ function resolveHorizontalSpace(
245
305
  pxPerTwip: number,
246
306
  ): { startPx: number; sizePx: number } | null {
247
307
  const pageWidthPx = twipsToPx(page.layout.pageWidth, pxPerTwip);
308
+ const pageMarginLeftPx = twipsToPx(page.layout.marginLeft, pxPerTwip);
248
309
  const storyHost = resolveStoryHostSpace(page, activeStory, pxPerTwip);
249
310
  switch (anchor.positionH?.relativeFrom) {
250
311
  case "page":
251
- return { startPx: 0, sizePx: pageWidthPx };
312
+ return { startPx: -pageMarginLeftPx, sizePx: pageWidthPx };
252
313
  case "margin":
253
- return storyHost ? { startPx: storyHost.leftPx, sizePx: storyHost.widthPx } : null;
314
+ return storyHost ? { startPx: 0, sizePx: storyHost.widthPx } : null;
254
315
  default:
255
316
  return null;
256
317
  }
@@ -269,6 +330,8 @@ function resolveVerticalSpace(
269
330
  return { startPx: 0, sizePx: pageHeightPx };
270
331
  case "margin":
271
332
  return storyHost ? { startPx: storyHost.topPx, sizePx: storyHost.heightPx } : null;
333
+ case "paragraph":
334
+ return storyHost ? { startPx: storyHost.topPx, sizePx: storyHost.heightPx } : null;
272
335
  default:
273
336
  return null;
274
337
  }
@@ -339,7 +402,7 @@ function alignAxisPosition(
339
402
  page: PublicPageNode,
340
403
  orientation: "horizontal" | "vertical",
341
404
  ): number {
342
- const remainder = Math.max(0, space.sizePx - objectSizePx);
405
+ const remainder = space.sizePx - objectSizePx;
343
406
  switch (align) {
344
407
  case "left":
345
408
  case "top":