@beyondwork/docx-react-component 1.0.42 → 1.0.43

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 (51) hide show
  1. package/package.json +30 -41
  2. package/src/api/editor-state-types.ts +110 -0
  3. package/src/api/public-types.ts +194 -1
  4. package/src/core/commands/index.ts +33 -8
  5. package/src/core/search/search-text.ts +15 -2
  6. package/src/index.ts +13 -0
  7. package/src/io/docx-session.ts +672 -2
  8. package/src/io/load-scheduler.ts +230 -0
  9. package/src/io/normalize/normalize-text.ts +83 -0
  10. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  11. package/src/io/ooxml/workflow-payload.ts +172 -1
  12. package/src/runtime/collab-session.ts +1 -1
  13. package/src/runtime/document-runtime.ts +364 -36
  14. package/src/runtime/editor-state-channel.ts +544 -0
  15. package/src/runtime/editor-state-integration.ts +217 -0
  16. package/src/runtime/layout/index.ts +2 -0
  17. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  18. package/src/runtime/layout/layout-engine-instance.ts +17 -2
  19. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  20. package/src/runtime/layout/public-facet.ts +400 -1
  21. package/src/runtime/perf-counters.ts +28 -0
  22. package/src/runtime/render/render-frame-types.ts +17 -0
  23. package/src/runtime/render/render-kernel.ts +172 -29
  24. package/src/runtime/surface-projection.ts +10 -5
  25. package/src/runtime/workflow-markup.ts +71 -16
  26. package/src/ui/WordReviewEditor.tsx +67 -45
  27. package/src/ui/editor-command-bag.ts +14 -0
  28. package/src/ui/editor-runtime-boundary.ts +110 -11
  29. package/src/ui/editor-shell-view.tsx +10 -0
  30. package/src/ui/editor-surface-controller.tsx +5 -0
  31. package/src/ui/headless/selection-helpers.ts +10 -0
  32. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  33. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  34. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  35. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  36. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  37. package/src/ui-tailwind/editor-surface/pm-schema.ts +152 -4
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  39. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  40. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  42. package/src/ui-tailwind/index.ts +5 -1
  43. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  44. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  45. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  46. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  47. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  48. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  49. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  50. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  51. package/src/ui-tailwind/tw-review-workspace.tsx +172 -94
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Extract a sequence of callback operations from a plain-text clipboard
3
+ * payload. Used by the `pm-command-bridge` handlePaste / handleDrop
4
+ * hooks to turn a pasted string into calls to `onInsertText` /
5
+ * `onSplitParagraph` / `onInsertHardBreak` — the same runtime-owned
6
+ * callbacks typing already uses.
7
+ *
8
+ * Separator convention:
9
+ * - LF (`\n`), CRLF (`\r\n`), CR (`\r`) → paragraph split
10
+ * ({ kind: "split" }).
11
+ * - U+000B (vertical tab) → hard break ({ kind: "hard_break" }).
12
+ * Word's plain-text clipboard represents shift-enter line breaks
13
+ * as vertical-tab; preserving them keeps paragraph-internal line
14
+ * structure.
15
+ * - Tab (U+0009) stays inside the current text segment. The
16
+ * runtime's `onInsertText` decides whether it is rendered as a
17
+ * tab-stop or swallowed.
18
+ *
19
+ * Consecutive separators produce multiple splits in sequence (empty
20
+ * paragraphs). Leading / trailing separators are emitted verbatim so
21
+ * the caller can decide whether to coalesce into a trailing blank.
22
+ *
23
+ * Pure function: no DOM, no React, no ProseMirror state.
24
+ *
25
+ * Source plan: `docs/plans/editor-paste-drop.md` §Phase 1.
26
+ */
27
+
28
+ export type PastePlainSegment =
29
+ | { kind: "text"; value: string }
30
+ | { kind: "split" }
31
+ | { kind: "hard_break" };
32
+
33
+ export function extractPlainTextSegments(input: string): PastePlainSegment[] {
34
+ if (input.length === 0) return [];
35
+
36
+ const segments: PastePlainSegment[] = [];
37
+ let buffer = "";
38
+
39
+ const flush = (): void => {
40
+ if (buffer.length > 0) {
41
+ segments.push({ kind: "text", value: buffer });
42
+ buffer = "";
43
+ }
44
+ };
45
+
46
+ for (let i = 0; i < input.length; i += 1) {
47
+ const ch = input[i];
48
+
49
+ if (ch === "\r") {
50
+ flush();
51
+ segments.push({ kind: "split" });
52
+ // Collapse CRLF into one split — advance past the LF.
53
+ if (input[i + 1] === "\n") i += 1;
54
+ continue;
55
+ }
56
+ if (ch === "\n") {
57
+ flush();
58
+ segments.push({ kind: "split" });
59
+ continue;
60
+ }
61
+ if (ch === "\u000B") {
62
+ flush();
63
+ segments.push({ kind: "hard_break" });
64
+ continue;
65
+ }
66
+
67
+ buffer += ch;
68
+ }
69
+
70
+ flush();
71
+ return segments;
72
+ }
@@ -7,8 +7,62 @@ import {
7
7
  createSelectionSnapshot,
8
8
  } from "../../ui/headless/selection-helpers";
9
9
  import { resolveSurfaceShortcut } from "../../ui/runtime-shortcut-dispatch";
10
+ import {
11
+ extractPlainTextSegments,
12
+ type PastePlainSegment,
13
+ } from "./paste-plain-text";
10
14
  import type { PositionMap } from "./pm-position-map";
11
15
 
16
+ /**
17
+ * Callback subset used by paste / drop dispatch. Exported so tests can
18
+ * record dispatch order without constructing the full
19
+ * `CommandBridgeCallbacks` surface.
20
+ */
21
+ export interface PasteDispatchCallbacks {
22
+ onInsertText: (text: string) => void;
23
+ onSplitParagraph: () => void;
24
+ onInsertHardBreak: () => void;
25
+ }
26
+
27
+ /**
28
+ * Dispatch an ordered list of plain-text segments to the runtime-owned
29
+ * callbacks. Empty text segments are skipped. Pure with respect to the
30
+ * callbacks — no global state, no PM mutation.
31
+ */
32
+ export function applyPasteSegmentsToCallbacks(
33
+ segments: readonly PastePlainSegment[],
34
+ callbacks: PasteDispatchCallbacks,
35
+ ): void {
36
+ for (const seg of segments) {
37
+ switch (seg.kind) {
38
+ case "text":
39
+ if (seg.value.length > 0) callbacks.onInsertText(seg.value);
40
+ break;
41
+ case "split":
42
+ callbacks.onSplitParagraph();
43
+ break;
44
+ case "hard_break":
45
+ callbacks.onInsertHardBreak();
46
+ break;
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Sum the character length across every `text` segment. Used by the
53
+ * paste / drop handlers to populate the `charCount` field of the
54
+ * public `paste_applied` event.
55
+ */
56
+ export function totalTextCharCount(
57
+ segments: readonly PastePlainSegment[],
58
+ ): number {
59
+ let total = 0;
60
+ for (const seg of segments) {
61
+ if (seg.kind === "text") total += seg.value.length;
62
+ }
63
+ return total;
64
+ }
65
+
12
66
  export interface SelectionSyncCallbacks {
13
67
  onSelectionChange: (selection: SelectionSnapshot) => void;
14
68
  getPositionMap: () => PositionMap | null;
@@ -28,6 +82,17 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
28
82
  onUndo: () => void;
29
83
  onRedo: () => void;
30
84
  onBlockedInput?: (command: "paste" | "drop", message: string) => void;
85
+ /**
86
+ * Optional. Fires after a plain-text paste or drop successfully
87
+ * dispatches through the runtime callbacks. `source` distinguishes
88
+ * paste from drop. Rich paste (HTML, Office clipboard) still fires
89
+ * `onBlockedInput`, not this callback.
90
+ */
91
+ onPasteApplied?: (meta: {
92
+ segmentCount: number;
93
+ charCount: number;
94
+ source: "paste" | "drop";
95
+ }) => void;
31
96
  /**
32
97
  * Optional. Fires on `compositionstart` (true) and `compositionend`
33
98
  * (false). The surface forwards this to the predicted lane's session
@@ -130,16 +195,61 @@ export function createCommandBridgePlugins(
130
195
  return true; // Block PM from processing
131
196
  },
132
197
 
133
- // Block paste (rich paste is not safe, plain paste via text.insert is TODO)
134
- handlePaste() {
135
- callbacks.onBlockedInput?.("paste", "Paste is not supported in the mounted editor yet.");
136
- return true; // Block
198
+ // Plain-text paste: extract text/plain from the clipboard and
199
+ // dispatch through the runtime-owned callbacks that typing uses.
200
+ // Rich paste (HTML, Office clipboard) stays blocked hosts that
201
+ // listen for onBlockedInput still get notified when a non-plain-
202
+ // text payload arrives. See docs/plans/editor-paste-drop.md.
203
+ handlePaste(_view, event) {
204
+ if (isComposing) return true;
205
+ const clipboard = event.clipboardData;
206
+ if (!clipboard) {
207
+ callbacks.onBlockedInput?.("paste", "Clipboard data was not available.");
208
+ return true;
209
+ }
210
+ const plain = clipboard.getData("text/plain");
211
+ if (!plain) {
212
+ callbacks.onBlockedInput?.(
213
+ "paste",
214
+ "Non-plain-text paste is not supported yet.",
215
+ );
216
+ return true;
217
+ }
218
+ const segments = extractPlainTextSegments(plain);
219
+ applyPasteSegmentsToCallbacks(segments, callbacks);
220
+ callbacks.onPasteApplied?.({
221
+ segmentCount: segments.length,
222
+ charCount: totalTextCharCount(segments),
223
+ source: "paste",
224
+ });
225
+ return true;
137
226
  },
138
227
 
139
- // Block drop
140
- handleDrop() {
141
- callbacks.onBlockedInput?.("drop", "Drag and drop is not supported in the mounted editor.");
142
- return true; // Block
228
+ // Plain-text drop: symmetric path — extract text/plain from the
229
+ // DataTransfer and dispatch through the same callbacks paste uses.
230
+ handleDrop(_view, event) {
231
+ if (isComposing) return true;
232
+ const dt = (event as DragEvent).dataTransfer;
233
+ if (!dt) {
234
+ callbacks.onBlockedInput?.("drop", "Drop data was not available.");
235
+ return true;
236
+ }
237
+ const plain = dt.getData("text/plain");
238
+ if (!plain) {
239
+ callbacks.onBlockedInput?.(
240
+ "drop",
241
+ "Non-plain-text drop is not supported yet.",
242
+ );
243
+ return true;
244
+ }
245
+ const segments = extractPlainTextSegments(plain);
246
+ applyPasteSegmentsToCallbacks(segments, callbacks);
247
+ callbacks.onPasteApplied?.({
248
+ segmentCount: segments.length,
249
+ charCount: totalTextCharCount(segments),
250
+ source: "drop",
251
+ });
252
+ return true;
143
253
  },
144
254
  },
145
255
  });
@@ -634,16 +634,51 @@ export const editorSchema = new Schema({
634
634
  selectable: false,
635
635
  attrs: {
636
636
  previewMediaId: { default: null },
637
+ previewSrc: { default: null },
637
638
  detail: { default: null },
638
639
  },
639
640
  toDOM(node) {
641
+ const previewSrc = node.attrs.previewSrc as string | null;
642
+ const detail = (node.attrs.detail as string) ?? "Chart";
643
+ if (previewSrc) {
644
+ // Bitmap-backed: render the fallback image Word cached in mc:Fallback.
645
+ // The corner chip preserves the typed identity so agents and humans
646
+ // still see "this is a chart" at a glance.
647
+ return [
648
+ "span",
649
+ {
650
+ class: "relative inline-block align-baseline mx-0.5 max-w-full",
651
+ "data-node-type": "chart_atom",
652
+ "data-preview-media-id": (node.attrs.previewMediaId as string) ?? "",
653
+ contenteditable: "false",
654
+ title: detail,
655
+ },
656
+ [
657
+ "img",
658
+ {
659
+ src: previewSrc,
660
+ alt: detail,
661
+ class: "block max-w-full h-auto",
662
+ draggable: "false",
663
+ },
664
+ ],
665
+ [
666
+ "span",
667
+ {
668
+ class: "absolute top-1 right-1 inline-flex items-center gap-0.5 rounded border border-blue-200 bg-blue-50/90 px-1 py-0.5 text-[10px] text-blue-700",
669
+ "aria-hidden": "true",
670
+ },
671
+ "\uD83D\uDCC8 Chart",
672
+ ],
673
+ ];
674
+ }
640
675
  return [
641
676
  "span",
642
677
  {
643
678
  class: "inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded text-xs text-blue-700 bg-blue-50 border border-blue-200",
644
679
  "data-node-type": "chart_atom",
645
680
  contenteditable: "false",
646
- title: (node.attrs.detail as string) ?? "Chart",
681
+ title: detail,
647
682
  },
648
683
  "\uD83D\uDCC8 Chart",
649
684
  ];
@@ -657,16 +692,48 @@ export const editorSchema = new Schema({
657
692
  selectable: false,
658
693
  attrs: {
659
694
  previewMediaId: { default: null },
695
+ previewSrc: { default: null },
660
696
  detail: { default: null },
661
697
  },
662
698
  toDOM(node) {
699
+ const previewSrc = node.attrs.previewSrc as string | null;
700
+ const detail = (node.attrs.detail as string) ?? "SmartArt";
701
+ if (previewSrc) {
702
+ return [
703
+ "span",
704
+ {
705
+ class: "relative inline-block align-baseline mx-0.5 max-w-full",
706
+ "data-node-type": "smartart_atom",
707
+ "data-preview-media-id": (node.attrs.previewMediaId as string) ?? "",
708
+ contenteditable: "false",
709
+ title: detail,
710
+ },
711
+ [
712
+ "img",
713
+ {
714
+ src: previewSrc,
715
+ alt: detail,
716
+ class: "block max-w-full h-auto",
717
+ draggable: "false",
718
+ },
719
+ ],
720
+ [
721
+ "span",
722
+ {
723
+ class: "absolute top-1 right-1 inline-flex items-center gap-0.5 rounded border border-purple-200 bg-purple-50/90 px-1 py-0.5 text-[10px] text-purple-700",
724
+ "aria-hidden": "true",
725
+ },
726
+ "\uD83D\uDDFA SmartArt",
727
+ ],
728
+ ];
729
+ }
663
730
  return [
664
731
  "span",
665
732
  {
666
733
  class: "inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded text-xs text-purple-700 bg-purple-50 border border-purple-200",
667
734
  "data-node-type": "smartart_atom",
668
735
  contenteditable: "false",
669
- title: (node.attrs.detail as string) ?? "SmartArt",
736
+ title: detail,
670
737
  },
671
738
  "\uD83D\uDDFA SmartArt",
672
739
  ];
@@ -707,17 +774,50 @@ export const editorSchema = new Schema({
707
774
  attrs: {
708
775
  text: { default: "" },
709
776
  geometry: { default: null },
777
+ previewMediaId: { default: null },
778
+ previewSrc: { default: null },
710
779
  detail: { default: null },
711
780
  },
712
781
  toDOM(node) {
713
782
  const text = node.attrs.text as string;
783
+ const previewSrc = node.attrs.previewSrc as string | null;
784
+ const detail = (node.attrs.detail as string) ?? (text ? `WordArt: ${text}` : "WordArt");
785
+ if (previewSrc) {
786
+ return [
787
+ "span",
788
+ {
789
+ class: "relative inline-block align-baseline mx-0.5 max-w-full",
790
+ "data-node-type": "wordart_atom",
791
+ "data-preview-media-id": (node.attrs.previewMediaId as string) ?? "",
792
+ contenteditable: "false",
793
+ title: detail,
794
+ },
795
+ [
796
+ "img",
797
+ {
798
+ src: previewSrc,
799
+ alt: detail,
800
+ class: "block max-w-full h-auto",
801
+ draggable: "false",
802
+ },
803
+ ],
804
+ [
805
+ "span",
806
+ {
807
+ class: "absolute top-1 right-1 inline-flex items-center gap-0.5 rounded border border-orange-200 bg-orange-50/90 px-1 py-0.5 text-[10px] text-orange-700",
808
+ "aria-hidden": "true",
809
+ },
810
+ "\u2728 WordArt",
811
+ ],
812
+ ];
813
+ }
714
814
  return [
715
815
  "span",
716
816
  {
717
817
  class: "inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded text-xs text-orange-700 bg-orange-50 border border-orange-200 font-medium italic",
718
818
  "data-node-type": "wordart_atom",
719
819
  contenteditable: "false",
720
- title: (node.attrs.detail as string) ?? `WordArt: ${text}`,
820
+ title: detail,
721
821
  },
722
822
  "\u2728 " + (text || "WordArt"),
723
823
  ];
@@ -732,18 +832,51 @@ export const editorSchema = new Schema({
732
832
  attrs: {
733
833
  text: { default: null },
734
834
  shapeType: { default: null },
835
+ previewMediaId: { default: null },
836
+ previewSrc: { default: null },
735
837
  detail: { default: null },
736
838
  },
737
839
  toDOM(node) {
738
840
  const text = node.attrs.text as string | null;
739
841
  const label = text ? `VML: ${text}` : "VML shape";
842
+ const previewSrc = node.attrs.previewSrc as string | null;
843
+ const detail = (node.attrs.detail as string) ?? label;
844
+ if (previewSrc) {
845
+ return [
846
+ "span",
847
+ {
848
+ class: "relative inline-block align-baseline mx-0.5 max-w-full",
849
+ "data-node-type": "vml_atom",
850
+ "data-preview-media-id": (node.attrs.previewMediaId as string) ?? "",
851
+ contenteditable: "false",
852
+ title: detail,
853
+ },
854
+ [
855
+ "img",
856
+ {
857
+ src: previewSrc,
858
+ alt: detail,
859
+ class: "block max-w-full h-auto",
860
+ draggable: "false",
861
+ },
862
+ ],
863
+ [
864
+ "span",
865
+ {
866
+ class: "absolute top-1 right-1 inline-flex items-center gap-0.5 rounded border border-gray-300 bg-gray-100/90 px-1 py-0.5 text-[10px] text-gray-600",
867
+ "aria-hidden": "true",
868
+ },
869
+ "\u25A6 VML",
870
+ ],
871
+ ];
872
+ }
740
873
  return [
741
874
  "span",
742
875
  {
743
876
  class: "inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded text-xs text-gray-600 bg-gray-100 border border-gray-300",
744
877
  "data-node-type": "vml_atom",
745
878
  contenteditable: "false",
746
- title: (node.attrs.detail as string) ?? label,
879
+ title: detail,
747
880
  },
748
881
  "\u25A6 " + label,
749
882
  ];
@@ -759,10 +892,25 @@ export const editorSchema = new Schema({
759
892
  warningId: { default: "" },
760
893
  label: { default: "Locked" },
761
894
  detail: { default: "" },
895
+ presentation: { default: "callout" },
762
896
  },
763
897
  toDOM(node) {
764
898
  const fragmentId = node.attrs.fragmentId as string;
765
899
  const isPreview = fragmentId.startsWith("preview:");
900
+ const presentation = node.attrs.presentation as string;
901
+ if (presentation === "quiet-marker") {
902
+ return [
903
+ "div",
904
+ {
905
+ class: "block h-0 w-0 overflow-hidden",
906
+ contenteditable: "false",
907
+ "data-node-type": "opaque_block",
908
+ "data-block-presentation": "quiet-marker",
909
+ title: node.attrs.detail as string,
910
+ "aria-label": node.attrs.label as string,
911
+ },
912
+ ];
913
+ }
766
914
  return [
767
915
  "div",
768
916
  {
@@ -268,7 +268,7 @@ function buildPMBlocks(
268
268
  } else if (block.kind === "sdt_block") {
269
269
  nodes.push(buildSdtBlock(block, mediaPreviews, showUnsupportedObjectPreviews));
270
270
  } else {
271
- nodes.push(buildOpaqueBlock(block));
271
+ nodes.push(buildOpaqueBlock(block, showUnsupportedObjectPreviews));
272
272
  }
273
273
  }
274
274
 
@@ -366,7 +366,13 @@ function buildParagraph(
366
366
  indentRight: paragraphLayout.indentation?.right ?? null,
367
367
  indentFirstLine: paragraphLayout.indentation?.firstLine ?? null,
368
368
  indentHanging: paragraphLayout.indentation?.hanging ?? null,
369
- numberingMarkerWidth: paragraphLayout.markerLane?.width ?? null,
369
+ numberingMarkerWidth:
370
+ paragraphLayout.markerLane?.width ??
371
+ paragraphLayout.indentation?.hanging ??
372
+ (paragraphLayout.indentation?.firstLine !== undefined &&
373
+ paragraphLayout.indentation.firstLine < 0
374
+ ? Math.abs(paragraphLayout.indentation.firstLine)
375
+ : null),
370
376
  numberingMarkerJustification: paragraphLayout.markerJustification ?? null,
371
377
  numberingMarkerRunProperties: block.resolvedNumbering?.markerRunProperties ?? null,
372
378
  shadingFill: block.shading?.fill ?? cascade?.shading?.fill ?? null,
@@ -453,7 +459,7 @@ function buildInlineContent(
453
459
  }
454
460
 
455
461
  case "opaque_inline":
456
- return [buildOpaqueInlineOrComplexAtom(segment, showUnsupportedObjectPreviews)];
462
+ return [buildOpaqueInlineOrComplexAtom(segment, mediaPreviews, showUnsupportedObjectPreviews)];
457
463
 
458
464
  case "note_ref": {
459
465
  const text = editorSchema.text(
@@ -602,6 +608,7 @@ const UNSUPPORTED_COMPLEX_PREVIEW_LABELS = new Set<string>([
602
608
  */
603
609
  function buildOpaqueInlineOrComplexAtom(
604
610
  segment: Extract<import("../../api/public-types").SurfaceInlineSegment, { kind: "opaque_inline" }>,
611
+ mediaPreviews: Record<string, MediaPreviewDescriptor>,
605
612
  showUnsupportedObjectPreviews: boolean,
606
613
  ): PMNode {
607
614
  const label = segment.label;
@@ -618,11 +625,30 @@ function buildOpaqueInlineOrComplexAtom(
618
625
  });
619
626
  }
620
627
 
621
- if (showUnsupportedObjectPreviews && label === "Embedded chart") {
622
- return editorSchema.nodes.chart_atom.create({ detail });
628
+ // Bitmap-backed complex objects always upgrade to the typed atom so the
629
+ // reviewer sees Word's own cached rendering regardless of the debug-preview
630
+ // flag. The flag still gates the badge-only path for shape/wordart/vml
631
+ // families (below) which are decoration-weight. Chart and SmartArt are
632
+ // *always* rendered as typed atoms regardless of flag — a silent quiet
633
+ // marker over a chart leaves the reviewer with no signal that data is
634
+ // missing, which is worse than the small cost of an always-visible chip.
635
+ const previewSrc = segment.previewMediaId
636
+ ? mediaPreviews[segment.previewMediaId]?.src ?? null
637
+ : null;
638
+
639
+ if (label === "Embedded chart") {
640
+ return editorSchema.nodes.chart_atom.create({
641
+ previewMediaId: segment.previewMediaId ?? null,
642
+ previewSrc,
643
+ detail,
644
+ });
623
645
  }
624
- if (showUnsupportedObjectPreviews && label === "SmartArt diagram") {
625
- return editorSchema.nodes.smartart_atom.create({ detail });
646
+ if (label === "SmartArt diagram") {
647
+ return editorSchema.nodes.smartart_atom.create({
648
+ previewMediaId: segment.previewMediaId ?? null,
649
+ previewSrc,
650
+ detail,
651
+ });
626
652
  }
627
653
  if (showUnsupportedObjectPreviews && (label === "Drawing shape" || label === "Text box")) {
628
654
  const textMatch = /(?:Text content|Content): "([^"]+)"/.exec(detail);
@@ -672,12 +698,14 @@ function buildOpaqueInlineOrComplexAtom(
672
698
 
673
699
  function buildOpaqueBlock(
674
700
  block: Extract<SurfaceBlockSnapshot, { kind: "opaque_block" }>,
701
+ showUnsupportedObjectPreviews: boolean,
675
702
  ): PMNode {
676
703
  return editorSchema.nodes.opaque_block.create({
677
704
  fragmentId: block.fragmentId,
678
705
  warningId: block.warningId,
679
706
  label: block.label,
680
707
  detail: block.detail,
708
+ presentation: showUnsupportedObjectPreviews ? "callout" : "quiet-marker",
681
709
  });
682
710
  }
683
711