@beyondwork/docx-react-component 1.0.55 → 1.0.57

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 (107) hide show
  1. package/package.json +43 -32
  2. package/src/api/public-types.ts +157 -0
  3. package/src/compare/diff-engine.ts +3 -0
  4. package/src/core/commands/formatting-commands.ts +1 -0
  5. package/src/core/commands/index.ts +17 -11
  6. package/src/core/selection/mapping.ts +18 -1
  7. package/src/core/selection/review-anchors.ts +29 -18
  8. package/src/io/chart-preview-resolver.ts +175 -41
  9. package/src/io/docx-session.ts +57 -2
  10. package/src/io/export/serialize-main-document.ts +82 -0
  11. package/src/io/export/serialize-styles.ts +61 -3
  12. package/src/io/export/table-properties-xml.ts +19 -4
  13. package/src/io/normalize/normalize-text.ts +33 -0
  14. package/src/io/ooxml/parse-anchor.ts +182 -0
  15. package/src/io/ooxml/parse-drawing.ts +319 -0
  16. package/src/io/ooxml/parse-fields.ts +115 -2
  17. package/src/io/ooxml/parse-fill.ts +215 -0
  18. package/src/io/ooxml/parse-font-table.ts +190 -0
  19. package/src/io/ooxml/parse-footnotes.ts +52 -1
  20. package/src/io/ooxml/parse-main-document.ts +241 -1
  21. package/src/io/ooxml/parse-numbering.ts +96 -0
  22. package/src/io/ooxml/parse-picture.ts +107 -0
  23. package/src/io/ooxml/parse-settings.ts +34 -0
  24. package/src/io/ooxml/parse-shapes.ts +87 -0
  25. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  26. package/src/io/ooxml/parse-styles.ts +74 -1
  27. package/src/io/ooxml/parse-theme.ts +60 -0
  28. package/src/io/paste/html-clipboard.ts +449 -0
  29. package/src/io/paste/word-clipboard.ts +5 -1
  30. package/src/legal/_document-root.ts +26 -0
  31. package/src/legal/bookmarks.ts +4 -3
  32. package/src/legal/cross-references.ts +3 -2
  33. package/src/legal/defined-terms.ts +2 -1
  34. package/src/legal/signature-blocks.ts +2 -1
  35. package/src/model/canonical-document.ts +415 -3
  36. package/src/runtime/chart/chart-model-store.ts +73 -10
  37. package/src/runtime/document-runtime.ts +693 -41
  38. package/src/runtime/edit-ops/index.ts +129 -0
  39. package/src/runtime/event-refresh-hints.ts +7 -0
  40. package/src/runtime/field-resolver.ts +341 -0
  41. package/src/runtime/footnote-resolver.ts +55 -0
  42. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  43. package/src/runtime/object-grab/index.ts +51 -0
  44. package/src/runtime/paragraph-style-resolver.ts +105 -0
  45. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  46. package/src/runtime/selection/cursor-ops.ts +186 -15
  47. package/src/runtime/selection/index.ts +17 -1
  48. package/src/runtime/structure-ops/index.ts +77 -0
  49. package/src/runtime/styles-cascade.ts +33 -0
  50. package/src/runtime/surface-projection.ts +186 -12
  51. package/src/runtime/theme-color-resolver.ts +189 -44
  52. package/src/runtime/units.ts +46 -0
  53. package/src/runtime/view-state.ts +13 -2
  54. package/src/ui/WordReviewEditor.tsx +168 -10
  55. package/src/ui/editor-runtime-boundary.ts +94 -1
  56. package/src/ui/editor-shell-view.tsx +1 -1
  57. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  58. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  59. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  60. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  61. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  62. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  63. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  64. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  65. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  66. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  67. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  68. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  69. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  70. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  72. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  73. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  76. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  77. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  78. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  79. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  80. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  81. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  83. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  85. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  86. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  87. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  88. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  90. package/src/ui-tailwind/editor-surface/pm-schema.ts +192 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -3
  92. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  93. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  94. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  95. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  96. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  97. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  98. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  99. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  100. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  101. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  102. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  103. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  104. package/src/ui-tailwind/theme/tokens.css +6 -0
  105. package/src/ui-tailwind/theme/tokens.ts +10 -0
  106. package/src/validation/compatibility-engine.ts +2 -0
  107. package/src/validation/docx-comment-proof.ts +12 -3
@@ -52,6 +52,7 @@ import type {
52
52
  SurfaceBlockSnapshot,
53
53
  SurfaceInlineSegment,
54
54
  TableOp,
55
+ CanonicalDocumentFragment,
55
56
  TableOpResult,
56
57
  PublicTableEvent,
57
58
  PublicTableRenderPlan,
@@ -480,6 +481,50 @@ function collectChartSnapshots(doc: CanonicalDocType): import("../api/public-typ
480
481
  return results;
481
482
  }
482
483
 
484
+ /**
485
+ * Walk the canonical document, compute each chart_preview's stableChartId,
486
+ * and project a snapshot for the first matching id. Short-circuits on
487
+ * match so the happy path is O(k) in blocks-until-match rather than the
488
+ * O(N) the `collect().find()` fallback incurred for every ref call. For
489
+ * hosts that call `getChartSnapshot` in a tight loop over many chartIds,
490
+ * this reduces the cost from O(N²) to O(N·k).
491
+ */
492
+ function lookupChartSnapshot(
493
+ doc: CanonicalDocType,
494
+ chartId: string,
495
+ ): import("../api/public-types").ChartSnapshot | null {
496
+ return lookupChartSnapshotInBlocks(doc.content.children, chartId);
497
+ }
498
+
499
+ function lookupChartSnapshotInBlocks(
500
+ blocks: CanonicalDocType["content"]["children"],
501
+ chartId: string,
502
+ ): import("../api/public-types").ChartSnapshot | null {
503
+ for (const block of blocks) {
504
+ if (block.type === "paragraph") {
505
+ for (const inline of block.children) {
506
+ if (inline.type === "chart_preview" && inline.parsedData) {
507
+ const id = stableChartId(inline.rawXml);
508
+ if (id === chartId) {
509
+ return projectChartSnapshot(id, inline.parsedData);
510
+ }
511
+ }
512
+ }
513
+ } else if (block.type === "table") {
514
+ for (const row of block.rows) {
515
+ for (const cell of row.cells) {
516
+ const found = lookupChartSnapshotInBlocks(cell.children, chartId);
517
+ if (found) return found;
518
+ }
519
+ }
520
+ } else if (block.type === "sdt" || block.type === "custom_xml") {
521
+ const found = lookupChartSnapshotInBlocks(block.children, chartId);
522
+ if (found) return found;
523
+ }
524
+ }
525
+ return null;
526
+ }
527
+
483
528
  function collectChartSnapshotsFromBlocks(
484
529
  blocks: CanonicalDocType["content"]["children"],
485
530
  results: import("../api/public-types").ChartSnapshot[],
@@ -528,6 +573,16 @@ export function __createWordReviewEditorRefBridge(
528
573
  redo: () => runtime.redo(),
529
574
  replaceText: (text, target, formatting) => runtime.replaceText(text, target, formatting),
530
575
  insertFragment: (fragment, target) => runtime.insertFragment(fragment, target),
576
+ copy: (target) => runtime.copy(target),
577
+ cut: (target) => runtime.cut(target),
578
+ getClipboardBuffer: () => runtime.getClipboardBuffer(),
579
+ getClipboardWireFormats: () => runtime.getClipboardWireFormats(),
580
+ selectObject: (objectId) => runtime.selectObject(objectId),
581
+ deselectObject: () => runtime.deselectObject(),
582
+ getGrabbedObject: () => runtime.getGrabbedObject(),
583
+ startAction: (name) => runtime.startAction(name),
584
+ endAction: () => runtime.endAction(),
585
+ isInAction: () => runtime.isInAction(),
531
586
  addComment: (params) => runtime.addComment(params),
532
587
  openComment: (commentId) => runtime.openComment(commentId),
533
588
  resolveComment: (commentId) => runtime.resolveComment(commentId),
@@ -554,11 +609,7 @@ export function __createWordReviewEditorRefBridge(
554
609
  getRenderSnapshot: () => clonePublicValue(runtime.getRenderSnapshot()),
555
610
  getCompatibilityReport: () => runtime.getCompatibilityReport(),
556
611
  getWarnings: () => runtime.getWarnings(),
557
- getChartSnapshot: (chartId) => {
558
- return collectChartSnapshots(runtime.getCanonicalDocument()).find(
559
- (s) => s.chartId === chartId,
560
- ) ?? null;
561
- },
612
+ getChartSnapshot: (chartId) => lookupChartSnapshot(runtime.getCanonicalDocument(), chartId),
562
613
  getChartSnapshots: () => collectChartSnapshots(runtime.getCanonicalDocument()),
563
614
  getCommentSidebarSnapshot: () =>
564
615
  clonePublicValue(runtime.getRenderSnapshot().comments),
@@ -1528,6 +1579,16 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1528
1579
  redo: () => activeRuntime.redo(),
1529
1580
  replaceText: (text, target, formatting) => activeRuntime.replaceText(text, target, formatting),
1530
1581
  insertFragment: (fragment, target) => activeRuntime.insertFragment(fragment, target),
1582
+ copy: (target) => activeRuntime.copy(target),
1583
+ cut: (target) => activeRuntime.cut(target),
1584
+ getClipboardBuffer: () => activeRuntime.getClipboardBuffer(),
1585
+ getClipboardWireFormats: () => activeRuntime.getClipboardWireFormats(),
1586
+ selectObject: (objectId) => activeRuntime.selectObject(objectId),
1587
+ deselectObject: () => activeRuntime.deselectObject(),
1588
+ getGrabbedObject: () => activeRuntime.getGrabbedObject(),
1589
+ startAction: (name) => activeRuntime.startAction(name),
1590
+ endAction: () => activeRuntime.endAction(),
1591
+ isInAction: () => activeRuntime.isInAction(),
1531
1592
  addComment: (params) =>
1532
1593
  activeRuntime.addComment({
1533
1594
  ...params,
@@ -1584,9 +1645,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1584
1645
  getCompatibilityReport: () => activeRuntime.getCompatibilityReport(),
1585
1646
  getWarnings: () => activeRuntime.getWarnings(),
1586
1647
  getChartSnapshot: (chartId) =>
1587
- collectChartSnapshots(activeRuntime.getCanonicalDocument()).find(
1588
- (s) => s.chartId === chartId,
1589
- ) ?? null,
1648
+ lookupChartSnapshot(activeRuntime.getCanonicalDocument(), chartId),
1590
1649
  getChartSnapshots: () => collectChartSnapshots(activeRuntime.getCanonicalDocument()),
1591
1650
  getCommentSidebarSnapshot: () =>
1592
1651
  clonePublicValue(activeRuntime.getRenderSnapshot().comments),
@@ -2777,10 +2836,84 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2777
2836
  onBlur: handleSurfaceBlur,
2778
2837
  onSelectionChange: dispatchSelection,
2779
2838
  onInsertText: (text: string) => dispatchTextCommand(activeRuntime, { type: "insert-text", text }, DISPATCH_CONTEXT),
2780
- onDeleteBackward: () => dispatchTextCommand(activeRuntime, { type: "delete-backward" }, DISPATCH_CONTEXT),
2781
- onDeleteForward: () => dispatchTextCommand(activeRuntime, { type: "delete-forward" }, DISPATCH_CONTEXT),
2839
+ // v5 follow-up Delete/Backspace when a grab is active (R.3
2840
+ // ObjectGrabLayer). LO reference: `SwFEShell::DeleteSelection()` is
2841
+ // grab-aware; without grab-awareness, Delete would run
2842
+ // paragraph-delete on whichever paragraph the text caret happened to
2843
+ // be in — a silent data-loss footgun.
2844
+ //
2845
+ // Minimum safe behavior until a proper `object.delete` runtime
2846
+ // command + Lane 6 P11 chrome land: swallow the key (don't run
2847
+ // paragraph-delete) and deselect the grab so the user can see that
2848
+ // the key was recognized. The actual object removal follows when
2849
+ // chrome wires resize/delete drag handles; this gate just prevents
2850
+ // the paragraph-delete footgun.
2851
+ onDeleteBackward: () => {
2852
+ const grabbed = activeRuntime.getGrabbedObject();
2853
+ if (grabbed) {
2854
+ activeRuntime.deselectObject();
2855
+ return;
2856
+ }
2857
+ dispatchTextCommand(activeRuntime, { type: "delete-backward" }, DISPATCH_CONTEXT);
2858
+ },
2859
+ onDeleteForward: () => {
2860
+ const grabbed = activeRuntime.getGrabbedObject();
2861
+ if (grabbed) {
2862
+ activeRuntime.deselectObject();
2863
+ return;
2864
+ }
2865
+ dispatchTextCommand(activeRuntime, { type: "delete-forward" }, DISPATCH_CONTEXT);
2866
+ },
2782
2867
  onInsertTab: () => dispatchTextCommand(activeRuntime, { type: "insert-tab" }, DISPATCH_CONTEXT),
2783
2868
  onOutdentTab: () => dispatchTextCommand(activeRuntime, { type: "outdent-tab" }, DISPATCH_CONTEXT),
2869
+ // I3 widening tail — Tab at last cell: dispatch `add-row-after` via the
2870
+ // same table-structure-operation path every other row-insert caller uses
2871
+ // (toolbar, context menu, ref method), so track-changes + collab replay
2872
+ // stay consistent. See src/ui-tailwind/editor-surface/pm-command-bridge.ts
2873
+ // `isAtLastCellOfTable` for the detection criterion.
2874
+ onTableInsertRowBelow: () =>
2875
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current, {
2876
+ type: "add-row-after",
2877
+ }),
2878
+ // I2 Tier B Slice 5 — drag-to-move / drag-to-copy.
2879
+ // v5 close-out: bridge now emits `dragstart`/`dragend` so same-editor
2880
+ // drag-to-move gets `sourceRange` populated. On move-effect drops we
2881
+ // insert the fragment AND delete the source range — the full Word
2882
+ // behavior. Both ops are wrapped in one action bracket via the
2883
+ // auto-bracketing in insertFragment + our explicit startAction around
2884
+ // the delete, so the undo history sees a single atomic action.
2885
+ onDropFragment: (meta: {
2886
+ fragment: CanonicalDocumentFragment;
2887
+ effect: "move" | "copy";
2888
+ sourceRange?: { from: number; to: number };
2889
+ }) => {
2890
+ if (meta.effect === "move" && meta.sourceRange) {
2891
+ // Delete source FIRST, then insert at the drop position. This order
2892
+ // is correctness-critical: if the drop was AFTER the source, the
2893
+ // insertion would shift the source offsets forward and a subsequent
2894
+ // delete would target the wrong (post-insertion) positions. By
2895
+ // deleting first and letting `insertFragment` land on the current
2896
+ // selection, both orderings behave correctly. The runtime's
2897
+ // selection validator (R.5.b) clamps the drop-site selection if
2898
+ // the source delete shifted it. Word degenerates move to copy
2899
+ // when drop is inside source; we pass through unchanged.
2900
+ activeRuntime.startAction("drag-move");
2901
+ try {
2902
+ activeRuntime.replaceText("", {
2903
+ kind: "range",
2904
+ from: meta.sourceRange.from,
2905
+ to: meta.sourceRange.to,
2906
+ assoc: { start: -1, end: 1 },
2907
+ });
2908
+ activeRuntime.insertFragment(meta.fragment);
2909
+ } finally {
2910
+ activeRuntime.endAction();
2911
+ }
2912
+ } else {
2913
+ // Copy-effect OR external drag (no sourceRange) — just insert.
2914
+ activeRuntime.insertFragment(meta.fragment);
2915
+ }
2916
+ },
2784
2917
  onInsertHardBreak: () => dispatchTextCommand(activeRuntime, { type: "insert-hard-break" }, DISPATCH_CONTEXT),
2785
2918
  onSplitParagraph: () => dispatchTextCommand(activeRuntime, { type: "split-paragraph" }, DISPATCH_CONTEXT),
2786
2919
  onUndo: () => activeRuntime.undo(),
@@ -2803,6 +2936,31 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2803
2936
  source: meta.source,
2804
2937
  });
2805
2938
  },
2939
+ // v5 close-out: wire rich-paste fragment routing to insertFragment so
2940
+ // Office / HTML clipboard payloads land in the mounted editor. The
2941
+ // insertFragment auto-bracket (R.5.a Phase 2) makes the paste a
2942
+ // single-undo action.
2943
+ onPasteFragment: (meta: {
2944
+ fragment: CanonicalDocumentFragment;
2945
+ source: "wordml" | "html";
2946
+ }) => {
2947
+ activeRuntime.insertFragment(meta.fragment);
2948
+ },
2949
+ // v5 close-out: image paste via existing insertImage ref method.
2950
+ // Width/height are omitted so the renderer picks sensible defaults
2951
+ // based on the decoded bitmap dimensions (existing insertImage
2952
+ // behavior); hosts that need specific dimensions can intercept this
2953
+ // callback on their own CommandBridgeCallbacks override.
2954
+ onPasteImage: (meta: {
2955
+ data: Uint8Array;
2956
+ mimeType: string;
2957
+ source: "paste" | "drop";
2958
+ }) => {
2959
+ applyRuntimeInsertImage(activeRuntime, {
2960
+ data: meta.data,
2961
+ mimeType: meta.mimeType,
2962
+ });
2963
+ },
2806
2964
  };
2807
2965
 
2808
2966
  const reviewCallbacks = {
@@ -122,6 +122,12 @@ export interface WordReviewEditorRuntime extends DocumentRuntime {
122
122
  ): void;
123
123
  }
124
124
 
125
+ type InternalWordReviewEditorRuntime = WordReviewEditorRuntime & {
126
+ hydrateChartPreviews?: (
127
+ resolvedDocument: EditorSessionState["canonicalDocument"],
128
+ ) => boolean;
129
+ };
130
+
125
131
  type PackageBackedDocxSession = ReturnType<typeof loadDocxEditorSession>;
126
132
 
127
133
  interface SnapshotExportBarrier {
@@ -313,6 +319,9 @@ export function useEditorRuntimeBoundary(
313
319
  progressiveSurfaceRef.current = progressiveSurface;
314
320
  const runtimeRef = useRef<WordReviewEditorRuntime | null>(null);
315
321
  const pendingReadySourceRef = useRef<"docx" | "session" | "snapshot" | null>(null);
322
+ const activeLoadTokenRef = useRef<symbol | null>(null);
323
+ const pendingChartPreviewDocRef =
324
+ useRef<EditorSessionState["canonicalDocument"] | null>(null);
316
325
  const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
317
326
  const lastSavedRevisionTokenRef = useRef<string | null>(null);
318
327
  // Fastload P6: scheduler used by the DOM-side async docx loader. Held
@@ -386,6 +395,9 @@ export function useEditorRuntimeBoundary(
386
395
  async function loadRuntime(): Promise<void> {
387
396
  setLoadError(null);
388
397
  pendingReadySourceRef.current = null;
398
+ pendingChartPreviewDocRef.current = null;
399
+ const loadToken = Symbol(documentId);
400
+ activeLoadTokenRef.current = loadToken;
389
401
  if (autosaveTimerRef.current) {
390
402
  clearTimeout(autosaveTimerRef.current);
391
403
  autosaveTimerRef.current = null;
@@ -448,12 +460,34 @@ export function useEditorRuntimeBoundary(
448
460
  return;
449
461
  }
450
462
  recordPerfSample("loadSession.laycacheProbe");
463
+ const handleChartPreviewsReady = (
464
+ resolvedDocument: EditorSessionState["canonicalDocument"],
465
+ ): void => {
466
+ if (cancelled || activeLoadTokenRef.current !== loadToken) {
467
+ return;
468
+ }
469
+ pendingChartPreviewDocRef.current = resolvedDocument;
470
+ if (source.preloadedDocxSession) {
471
+ source.preloadedDocxSession.initialSessionState = {
472
+ ...source.preloadedDocxSession.initialSessionState,
473
+ canonicalDocument: resolvedDocument,
474
+ };
475
+ source.preloadedDocxSession.initialSnapshot = {
476
+ ...source.preloadedDocxSession.initialSnapshot,
477
+ canonicalDocument: resolvedDocument,
478
+ };
479
+ }
480
+ (
481
+ runtimeRef.current as InternalWordReviewEditorRuntime | null
482
+ )?.hydrateChartPreviews?.(resolvedDocument);
483
+ };
451
484
  const preloaded = await loadDocxEditorSessionAsync({
452
485
  documentId,
453
486
  sourceLabel: source.sourceLabel,
454
487
  bytes: source.initialDocx,
455
488
  editorBuild: "dev",
456
489
  scheduler,
490
+ onChartPreviewsReady: handleChartPreviewsReady,
457
491
  ...(probeResult ? { laycacheEnvelope: probeResult.envelope } : {
458
492
  // C2c: on the cold path (no cached envelope), emit the first
459
493
  // viewport surface as soon as body-normalize completes so the
@@ -475,6 +509,16 @@ export function useEditorRuntimeBoundary(
475
509
  return;
476
510
  }
477
511
  source.preloadedDocxSession = preloaded;
512
+ if (pendingChartPreviewDocRef.current) {
513
+ source.preloadedDocxSession.initialSessionState = {
514
+ ...source.preloadedDocxSession.initialSessionState,
515
+ canonicalDocument: pendingChartPreviewDocRef.current,
516
+ };
517
+ source.preloadedDocxSession.initialSnapshot = {
518
+ ...source.preloadedDocxSession.initialSnapshot,
519
+ canonicalDocument: pendingChartPreviewDocRef.current,
520
+ };
521
+ }
478
522
  if (probeResult) {
479
523
  source.preloadedLaycacheGraph = probeResult.envelope.graph;
480
524
  }
@@ -499,6 +543,11 @@ export function useEditorRuntimeBoundary(
499
543
  );
500
544
  recordPerfSample("runtime.create");
501
545
  runtimeRef.current = nextRuntime;
546
+ if (pendingChartPreviewDocRef.current) {
547
+ (
548
+ nextRuntime as InternalWordReviewEditorRuntime
549
+ ).hydrateChartPreviews?.(pendingChartPreviewDocRef.current);
550
+ }
502
551
  pendingReadySourceRef.current = source.source;
503
552
  // C2c: clear the transient progressive surface — the full runtime
504
553
  // snapshot supersedes it. No need for startTransition here since
@@ -530,6 +579,8 @@ export function useEditorRuntimeBoundary(
530
579
 
531
580
  return () => {
532
581
  cancelled = true;
582
+ pendingChartPreviewDocRef.current = null;
583
+ activeLoadTokenRef.current = null;
533
584
  };
534
585
  }, [
535
586
  documentId,
@@ -753,6 +804,11 @@ function createRuntime(
753
804
  defaultAuthorId: args.currentUserId,
754
805
  onCommandApplied: args.commandAppliedBridge?.onCommandApplied,
755
806
  });
807
+ const internalBaseRuntime = baseRuntime as DocumentRuntime & {
808
+ hydrateCanonicalDocumentInternally?: (
809
+ document: EditorSessionState["canonicalDocument"],
810
+ ) => boolean;
811
+ };
756
812
 
757
813
  // Schema 1.2: drive load-path hydration from the parsed envelope.
758
814
  if (docxSession?.initialEditorStatePayload) {
@@ -784,7 +840,7 @@ function createRuntime(
784
840
  });
785
841
  }
786
842
 
787
- const runtime: WordReviewEditorRuntime = Object.assign(baseRuntime, {
843
+ const runtime: InternalWordReviewEditorRuntime = Object.assign(baseRuntime, {
788
844
  drainBootstrapEvents: () => bootstrapEvents.splice(0, bootstrapEvents.length),
789
845
  emitBlockedCommand: (
790
846
  command: string,
@@ -802,6 +858,25 @@ function createRuntime(
802
858
  },
803
859
  });
804
860
  },
861
+ hydrateChartPreviews: (
862
+ resolvedDocument: EditorSessionState["canonicalDocument"],
863
+ ): boolean => {
864
+ if (docxSession) {
865
+ docxSession.initialSessionState = {
866
+ ...docxSession.initialSessionState,
867
+ canonicalDocument: resolvedDocument,
868
+ };
869
+ docxSession.initialSnapshot = {
870
+ ...docxSession.initialSnapshot,
871
+ canonicalDocument: resolvedDocument,
872
+ };
873
+ }
874
+ return (
875
+ internalBaseRuntime.hydrateCanonicalDocumentInternally?.(
876
+ resolvedDocument,
877
+ ) ?? false
878
+ );
879
+ },
805
880
  });
806
881
 
807
882
  return runtime;
@@ -947,8 +1022,21 @@ function createLoadingRuntimeBridge(input: {
947
1022
  },
948
1023
  getCanonicalDocument: () => input.sessionState.canonicalDocument,
949
1024
  getSourcePackage: () => input.sessionState.sourcePackage,
1025
+ getFontTable: () => input.sessionState.canonicalDocument.fontTable,
1026
+ getFontEntry: (name: string) =>
1027
+ input.sessionState.canonicalDocument.fontTable?.fonts[name],
950
1028
  replaceText: () => undefined,
951
1029
  insertFragment: () => undefined,
1030
+ copy: () => undefined,
1031
+ cut: () => undefined,
1032
+ getClipboardBuffer: () => null,
1033
+ getClipboardWireFormats: () => null,
1034
+ selectObject: () => undefined,
1035
+ deselectObject: () => undefined,
1036
+ getGrabbedObject: () => null,
1037
+ startAction: () => undefined,
1038
+ endAction: () => undefined,
1039
+ isInAction: () => false,
952
1040
  applyActiveStoryTextCommand: () => ({
953
1041
  kind: "rejected",
954
1042
  newRevisionToken: "",
@@ -1016,6 +1104,11 @@ function createLoadingRuntimeBridge(input: {
1016
1104
  changeKinds: ["content"],
1017
1105
  }),
1018
1106
  getFieldSnapshot: () => emptyFieldSnapshot,
1107
+ getFieldResolver: () => ({
1108
+ resolve: () => undefined,
1109
+ cacheKey: "loading",
1110
+ }),
1111
+ getFootnoteResolver: () => undefined,
1019
1112
  updateFields: () => emptyFieldResult,
1020
1113
  updateTableOfContents: () => emptyTocResult,
1021
1114
  getSessionState: () => {
@@ -154,7 +154,7 @@ export function EditorShellView(props: EditorShellViewProps) {
154
154
  aria-describedby={`${accessibilityInstructionsId} ${accessibilityStatusId}${
155
155
  diagnosticsModeMessage ? ` ${accessibilityAlertId}` : ""
156
156
  }`}
157
- className="relative h-full"
157
+ className="wre-editor relative h-full"
158
158
  onKeyDownCapture={onShellKeyDownCapture}
159
159
  >
160
160
  <p id={accessibilityInstructionsId} style={visuallyHiddenStyles}>
@@ -19,6 +19,14 @@ export interface ShellShortcutContext {
19
19
 
20
20
  export interface SurfaceShortcutContext {
21
21
  inTable: boolean;
22
+ /**
23
+ * I3 widening tail — when the caret is in the last cell of the last row of
24
+ * a table, Tab (without shift) triggers implicit row-insert-below instead
25
+ * of cell navigation. Matches Word's Tab-at-table-tail behavior. Flag is
26
+ * computed by the PM bridge at dispatch time (bridge knows cell position);
27
+ * defaults to false for callers that don't supply it.
28
+ */
29
+ isAtTableTail?: boolean;
22
30
  }
23
31
 
24
32
  export type ShellShortcutResolution =
@@ -46,6 +54,7 @@ export type SurfaceShortcutResolution =
46
54
  | { kind: "insert-tab" }
47
55
  | { kind: "outdent-tab" }
48
56
  | { kind: "navigate-table-cell"; direction: 1 | -1 }
57
+ | { kind: "table-insert-row-below" }
49
58
  | { kind: "history"; history: "undo" | "redo" };
50
59
 
51
60
  export function resolveShellShortcut(
@@ -190,9 +199,14 @@ export function resolveSurfaceShortcut(
190
199
  }
191
200
 
192
201
  if (key === "tab" && !hasAnyModifiers(input)) {
193
- return context.inTable
194
- ? { kind: "navigate-table-cell", direction: 1 }
195
- : { kind: "insert-tab" };
202
+ if (context.inTable) {
203
+ // I3 widening tail — Tab at the last cell inserts a new row below
204
+ // (Word behavior). Shift+Tab continues to navigate regardless.
205
+ return context.isAtTableTail
206
+ ? { kind: "table-insert-row-below" }
207
+ : { kind: "navigate-table-cell", direction: 1 };
208
+ }
209
+ return { kind: "insert-tab" };
196
210
  }
197
211
 
198
212
  return { kind: "none" };
@@ -24,7 +24,7 @@
24
24
  import React from "react";
25
25
  import type { ChartModel } from "../../io/ooxml/chart/types.ts";
26
26
  import type { ResolvedTheme } from "../../model/canonical-document.ts";
27
- import { layoutPlotArea, type PlotAreaLayout } from "./layout/plot-area.ts";
27
+ import { layoutPlotArea, CHROME_PAINTED_DEFAULT, type PlotAreaLayout } from "./layout/plot-area.ts";
28
28
  import { AreaChart } from "./render/area.tsx";
29
29
  import { BarColumnChart } from "./render/bar-column.tsx";
30
30
  import { BubbleChart } from "./render/bubble.tsx";
@@ -63,7 +63,15 @@ function ChartSurfaceImpl({
63
63
  resolveMediaUrl,
64
64
  previewMediaId,
65
65
  }: ChartSurfaceProps): React.ReactElement {
66
- const resolvedLayout = layout ?? layoutPlotArea({ w: width, h: height }, model, theme);
66
+ // Graceful degradation: pass CHROME_PAINTED_DEFAULT so title / legend /
67
+ // axis bands are NOT reserved by the layout pass. No component paints
68
+ // them today (documented gap in docs/wiki/images-and-media.md §"Known
69
+ // gaps"), so reserving space would leave empty margins around the plot.
70
+ // When Stage-C chrome wire-up lands, flip the matching flag here.
71
+ const resolvedLayout =
72
+ layout ?? layoutPlotArea({ w: width, h: height }, model, theme, {
73
+ paintedChrome: CHROME_PAINTED_DEFAULT,
74
+ });
67
75
  const defs = new DefsRegistry();
68
76
 
69
77
  const body = dispatchBody({
@@ -187,20 +195,38 @@ function renderDefs(defs: DefsRegistry): React.ReactElement[] {
187
195
 
188
196
  /**
189
197
  * Convert a gradient angle (degrees, OOXML clockwise-from-horizontal)
190
- * to SVG `x1/y1/x2/y2` percentage values. = left-to-right,
191
- * 90° = top-to-bottom.
198
+ * to SVG `x1/y1/x2/y2` percentage values on the object bounding box.
199
+ * 0° = left-to-right, 90° = top-to-bottom. Returned percentages are
200
+ * continuous in the angle rather than snapped to the four corners —
201
+ * non-cardinal gradients (45°, 60°, 120°, …) render with the correct
202
+ * direction instead of collapsing to the nearest corner-pair.
192
203
  */
193
204
  function angleToCoords(angleDeg: number): {
194
205
  x1: string; y1: string; x2: string; y2: string;
195
206
  } {
196
- const rad = (angleDeg * Math.PI) / 180;
207
+ const normalized = ((angleDeg % 360) + 360) % 360;
208
+ const rad = (normalized * Math.PI) / 180;
197
209
  const dx = Math.cos(rad);
198
210
  const dy = Math.sin(rad);
199
- const x1 = dx < 0 ? "100%" : "0%";
200
- const x2 = dx < 0 ? "0%" : "100%";
201
- const y1 = dy < 0 ? "100%" : "0%";
202
- const y2 = dy < 0 ? "0%" : "100%";
203
- return { x1, y1, x2, y2 };
211
+
212
+ // Center + half-length projection onto the [0,1]² bounding box. Scale
213
+ // so the dominant component reaches 0.5 (half the box side), keeping
214
+ // the gradient within the object bbox and edge-to-edge along the axis.
215
+ const cx = 0.5;
216
+ const cy = 0.5;
217
+ const absDx = Math.abs(dx);
218
+ const absDy = Math.abs(dy);
219
+ const scale = absDx === 0 && absDy === 0 ? 0 : 0.5 / Math.max(absDx, absDy);
220
+ const hx = dx * scale;
221
+ const hy = dy * scale;
222
+
223
+ const fmtPct = (n: number): string => `${(n * 100).toFixed(2)}%`;
224
+ return {
225
+ x1: fmtPct(cx - hx),
226
+ y1: fmtPct(cy - hy),
227
+ x2: fmtPct(cx + hx),
228
+ y2: fmtPct(cy + hy),
229
+ };
204
230
  }
205
231
 
206
232
  // ---------------------------------------------------------------------------