@beyondwork/docx-react-component 1.0.106 → 1.0.109

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 (190) hide show
  1. package/package.json +19 -5
  2. package/src/api/geometry-overlay-rects.ts +5 -0
  3. package/src/api/package-version.ts +1 -1
  4. package/src/api/page-anchor-id.ts +5 -0
  5. package/src/api/public-types.ts +16 -9
  6. package/src/api/table-node-specs.ts +6 -0
  7. package/src/api/v3/_create.ts +2 -1
  8. package/src/api/v3/_page-anchor-id.ts +52 -0
  9. package/src/api/v3/_runtime-handle.ts +92 -1
  10. package/src/api/v3/ai/_audit-time.ts +5 -0
  11. package/src/api/v3/ai/_pe2-evidence.ts +38 -0
  12. package/src/api/v3/ai/attach.ts +7 -2
  13. package/src/api/v3/ai/replacement.ts +101 -18
  14. package/src/api/v3/ai/resolve.ts +2 -2
  15. package/src/api/v3/ai/review.ts +177 -3
  16. package/src/api/v3/index.ts +1 -0
  17. package/src/api/v3/runtime/collab.ts +462 -0
  18. package/src/api/v3/runtime/document.ts +503 -20
  19. package/src/api/v3/runtime/geometry.ts +97 -0
  20. package/src/api/v3/runtime/layout.ts +744 -0
  21. package/src/api/v3/runtime/perf-probe.ts +14 -0
  22. package/src/api/v3/runtime/viewport.ts +9 -8
  23. package/src/api/v3/ui/_types.ts +149 -55
  24. package/src/api/v3/ui/chrome-preset-model.ts +5 -5
  25. package/src/api/v3/ui/debug.ts +115 -2
  26. package/src/api/v3/ui/index.ts +13 -0
  27. package/src/api/v3/ui/overlays.ts +0 -8
  28. package/src/api/v3/ui/surface.ts +56 -0
  29. package/src/api/v3/ui/viewport.ts +22 -9
  30. package/src/core/commands/image-commands.ts +1 -0
  31. package/src/core/commands/index.ts +6 -0
  32. package/src/core/schema/text-schema.ts +43 -5
  33. package/src/core/selection/mapping.ts +8 -1
  34. package/src/core/selection/review-anchors.ts +5 -1
  35. package/src/core/state/text-transaction.ts +8 -2
  36. package/src/io/export/serialize-revisions.ts +149 -1
  37. package/src/io/normalize/normalize-text.ts +6 -0
  38. package/src/io/ooxml/parse-bookmark-references.ts +55 -0
  39. package/src/io/ooxml/parse-fields.ts +24 -2
  40. package/src/io/ooxml/parse-headers-footers.ts +38 -5
  41. package/src/io/ooxml/parse-main-document.ts +153 -9
  42. package/src/io/ooxml/parse-numbering.ts +20 -0
  43. package/src/io/ooxml/parse-revisions.ts +19 -8
  44. package/src/io/opc/package-reader.ts +98 -8
  45. package/src/model/anchor.ts +4 -3
  46. package/src/model/canonical-document.ts +220 -2
  47. package/src/model/canonical-hash.ts +221 -0
  48. package/src/model/canonical-layout-inputs.ts +245 -6
  49. package/src/model/layout/index.ts +1 -0
  50. package/src/model/layout/page-graph-types.ts +118 -1
  51. package/src/model/review/revision-types.ts +14 -3
  52. package/src/preservation/store.ts +20 -4
  53. package/src/review/README.md +1 -1
  54. package/src/review/store/revision-actions.ts +14 -2
  55. package/src/runtime/collab/event-types.ts +67 -1
  56. package/src/runtime/collab/runtime-collab-sync.ts +177 -5
  57. package/src/runtime/diagnostics/layout-guard-warning.ts +18 -0
  58. package/src/runtime/document-heading-outline.ts +147 -0
  59. package/src/runtime/document-navigation.ts +8 -243
  60. package/src/runtime/document-runtime.ts +240 -97
  61. package/src/runtime/edit-dispatch/dispatch-text-command.ts +11 -0
  62. package/src/runtime/formatting/layout-inputs.ts +38 -5
  63. package/src/runtime/formatting/numbering/geometry.ts +28 -2
  64. package/src/runtime/geometry/adjacent-geometry-intake.ts +835 -0
  65. package/src/runtime/geometry/caret-geometry.ts +5 -6
  66. package/src/runtime/geometry/geometry-facet.ts +60 -10
  67. package/src/runtime/geometry/geometry-index.ts +591 -20
  68. package/src/runtime/geometry/geometry-types.ts +59 -0
  69. package/src/runtime/geometry/hit-test.ts +11 -1
  70. package/src/runtime/geometry/overlay-rects.ts +5 -3
  71. package/src/runtime/geometry/project-anchors.ts +1 -1
  72. package/src/runtime/geometry/word-layout-v2-line-intake.ts +323 -0
  73. package/src/runtime/layout/index.ts +6 -0
  74. package/src/runtime/layout/layout-engine-instance.ts +6 -1
  75. package/src/runtime/layout/layout-engine-version.ts +181 -16
  76. package/src/runtime/layout/layout-facet-types.ts +6 -0
  77. package/src/runtime/layout/page-graph.ts +21 -4
  78. package/src/runtime/layout/paginated-layout-engine.ts +139 -15
  79. package/src/runtime/layout/project-block-fragments.ts +265 -7
  80. package/src/runtime/layout/public-facet.ts +78 -24
  81. package/src/runtime/layout/table-row-continuation-contract.ts +107 -0
  82. package/src/runtime/layout/table-row-split.ts +92 -35
  83. package/src/runtime/prerender/cache-envelope.ts +2 -2
  84. package/src/runtime/prerender/cache-key.ts +5 -4
  85. package/src/runtime/prerender/customxml-cache.ts +0 -1
  86. package/src/runtime/render/render-kernel.ts +1 -1
  87. package/src/runtime/revision-runtime.ts +112 -10
  88. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  89. package/src/runtime/scopes/action-validation.ts +22 -2
  90. package/src/runtime/scopes/capabilities.ts +316 -0
  91. package/src/runtime/scopes/compile-scope-bundle.ts +14 -0
  92. package/src/runtime/scopes/compiler-service.ts +108 -4
  93. package/src/runtime/scopes/content-control-evidence.ts +79 -0
  94. package/src/runtime/scopes/create-issue.ts +5 -5
  95. package/src/runtime/scopes/evidence.ts +91 -0
  96. package/src/runtime/scopes/formatting/apply.ts +2 -0
  97. package/src/runtime/scopes/geometry-evidence.ts +130 -0
  98. package/src/runtime/scopes/index.ts +54 -0
  99. package/src/runtime/scopes/issue-lifecycle.ts +224 -0
  100. package/src/runtime/scopes/layout-evidence.ts +374 -0
  101. package/src/runtime/scopes/multi-paragraph-refusal.ts +37 -0
  102. package/src/runtime/scopes/preservation-boundary.ts +7 -1
  103. package/src/runtime/scopes/replacement/apply.ts +97 -34
  104. package/src/runtime/scopes/scope-kinds/paragraph.ts +108 -12
  105. package/src/runtime/scopes/semantic-scope-types.ts +242 -3
  106. package/src/runtime/scopes/visualization.ts +28 -0
  107. package/src/runtime/surface-projection.ts +44 -5
  108. package/src/runtime/telemetry/perf-probe.ts +216 -0
  109. package/src/runtime/virtualized-rendering.ts +36 -1
  110. package/src/runtime/workflow/ai-issue-lifecycle.ts +253 -0
  111. package/src/runtime/workflow/coordinator.ts +39 -11
  112. package/src/runtime/workflow/derived-scope-resolver.ts +63 -9
  113. package/src/runtime/workflow/index.ts +3 -0
  114. package/src/runtime/workflow/overlay-lane-types.ts +58 -0
  115. package/src/runtime/workflow/overlay-lanes.ts +168 -10
  116. package/src/runtime/workflow/overlay-store.ts +2 -2
  117. package/src/runtime/workflow/redline-posture-calibration.ts +257 -0
  118. package/src/runtime/workflow/word-field-matrix-calibration.ts +231 -0
  119. package/src/session/_sync-legacy.ts +17 -27
  120. package/src/session/import/loader.ts +6 -4
  121. package/src/session/import/source-package-evidence.ts +186 -2
  122. package/src/session/index.ts +5 -6
  123. package/src/session/session.ts +30 -56
  124. package/src/session/types.ts +8 -13
  125. package/src/shell/session-bootstrap.ts +155 -81
  126. package/src/ui/WordReviewEditor.tsx +520 -12
  127. package/src/ui/editor-shell-view.tsx +14 -4
  128. package/src/ui/editor-surface-controller.tsx +5 -3
  129. package/src/ui/headless/selection-tool-resolver.ts +1 -2
  130. package/src/ui/presence-overlay-lane.ts +0 -1
  131. package/src/ui/ui-controller-factory.ts +7 -0
  132. package/src/ui-tailwind/chrome/build-context-menu-entries.ts +5 -1
  133. package/src/ui-tailwind/chrome/editor-action-registry.ts +105 -5
  134. package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +7 -0
  135. package/src/ui-tailwind/chrome/layer-debug-contracts.ts +208 -0
  136. package/src/ui-tailwind/chrome/resolve-target-kind.ts +13 -0
  137. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +11 -3
  138. package/src/ui-tailwind/chrome/tw-command-palette.tsx +36 -6
  139. package/src/ui-tailwind/chrome/tw-context-menu.tsx +6 -1
  140. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +42 -109
  141. package/src/ui-tailwind/chrome/tw-inline-find-bar.tsx +26 -6
  142. package/src/ui-tailwind/chrome/tw-navigation-command-bar.tsx +328 -0
  143. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +8 -4
  144. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +129 -1
  145. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +19 -5
  146. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +5 -1
  147. package/src/ui-tailwind/chrome/tw-workspace-chrome-host.tsx +28 -12
  148. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +30 -3
  149. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +116 -10
  150. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +223 -94
  151. package/src/ui-tailwind/chrome-overlay/tw-presence-overlay-lane.tsx +157 -0
  152. package/src/ui-tailwind/chrome-overlay/tw-review-overlay-lane-markers.tsx +259 -0
  153. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +5 -2
  154. package/src/ui-tailwind/chrome-overlay/tw-substrate-overlay-lanes.tsx +314 -0
  155. package/src/ui-tailwind/debug/README.md +4 -1
  156. package/src/ui-tailwind/debug/layer11-consumer-readiness.ts +272 -0
  157. package/src/ui-tailwind/debug/layer11-word-field-matrix-evidence.ts +160 -0
  158. package/src/ui-tailwind/editor-surface/perf-probe.ts +14 -215
  159. package/src/ui-tailwind/editor-surface/pm-decorations.ts +42 -0
  160. package/src/ui-tailwind/editor-surface/pm-position-map.ts +38 -2
  161. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -4
  162. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +34 -5
  163. package/src/ui-tailwind/editor-surface/runtime-decoration-plugin.ts +9 -19
  164. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -2
  165. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +145 -0
  166. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +16 -11
  167. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +8 -10
  168. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +3 -0
  169. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +4 -2
  170. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +60 -20
  171. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +16 -11
  172. package/src/ui-tailwind/review/tw-health-panel.tsx +36 -17
  173. package/src/ui-tailwind/review/tw-review-rail.tsx +7 -4
  174. package/src/ui-tailwind/review-workspace/diagnostics-visibility.ts +44 -0
  175. package/src/ui-tailwind/review-workspace/page-shell-metrics.ts +11 -0
  176. package/src/ui-tailwind/review-workspace/tw-review-workspace-rail.tsx +16 -1
  177. package/src/ui-tailwind/review-workspace/types.ts +26 -12
  178. package/src/ui-tailwind/review-workspace/use-diagnostics-signal.ts +40 -11
  179. package/src/ui-tailwind/review-workspace/use-layout-facet-render-signal.ts +2 -1
  180. package/src/ui-tailwind/review-workspace/use-page-markers.ts +15 -26
  181. package/src/ui-tailwind/review-workspace/use-scope-card-state.ts +35 -18
  182. package/src/ui-tailwind/review-workspace/use-selection-toolbar-placement.ts +41 -32
  183. package/src/ui-tailwind/review-workspace/use-status-bar-page-facts.ts +2 -1
  184. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +2 -1
  185. package/src/ui-tailwind/status/tw-status-bar.tsx +6 -5
  186. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +52 -80
  187. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +12 -48
  188. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +9 -4
  189. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +328 -361
  190. package/src/ui-tailwind/tw-review-workspace.tsx +152 -286
@@ -34,6 +34,7 @@ import type {
34
34
  import type { UiApiContext } from "./_context.ts";
35
35
  import { readComposedViewport } from "./_context.ts";
36
36
  import { emitUxResponse } from "../_ux-response.ts";
37
+ import { buildPageAnchorElementId } from "../_page-anchor-id.ts";
37
38
 
38
39
  export const getMetadata: ApiV3FnMetadata = {
39
40
  name: "ui.viewport.get",
@@ -159,7 +160,7 @@ export const scrollToPageMetadata: ApiV3FnMetadata = {
159
160
  stateClass: "C-local",
160
161
  persistsTo: "none",
161
162
  rwdReference:
162
- "§UI API § ui.viewport.scrollToPage. Resolves pageNumber → scrollY via handle.geometry.getPage(pageIndex); dispatches through controller.dispatchScroll({ kind:'page', value, behavior }); returns the settled {actualPage, scrollY}. 1-based page numbers; clamps to [1, pageCount]. First-class API for visual-fidelity harness + 'Go to page N' UX — replaces DOM-scrape fallback (coord-10 §γ). Parity note: reads the same `handle.geometry.getPage(i).frame.topPx` source as `runtime.viewport.getPageAnchor` (L07 coord-07 §2.9, shipped 2026-04-24 in `src/api/v3/runtime/viewport.ts`), so `actualPage + scrollY` here and `{scrollY, pageRect}` on the runtime side stay consistent by construction. No direct delegation today because `scripts/ci-check-ui-api-layer-purity.mjs` restricts `src/api/v3/ui/**` from importing `src/api/v3/runtime/**`; both surfaces are thin wrappers over the shared geometry facet.",
163
+ "§UI API § ui.viewport.scrollToPage. Resolves pageNumber → scrollY + elementId via handle.geometry.getPage(pageIndex); dispatches through controller.dispatchScroll({ kind:'page', value, behavior }); returns the settled {actualPage, scrollY, elementId}. 1-based page numbers; clamps to [1, pageCount]. First-class API for visual-fidelity harness + 'Go to page N' UX — replaces DOM-scrape fallback (coord-10 §γ). Parity note: reads the same `handle.geometry.getPage(i).frame.topPx` source as `runtime.viewport.getPageAnchor` (L07 coord-07 §2.9, shipped 2026-04-24 in `src/api/v3/runtime/viewport.ts`), so `actualPage + scrollY + elementId` here and `{scrollY, pageRect, elementId}` on the runtime side stay consistent by construction. No direct delegation today because `scripts/ci-check-ui-api-layer-purity.mjs` restricts `src/api/v3/ui/**` from importing `src/api/v3/runtime/**`; both surfaces are thin wrappers over the shared geometry facet.",
163
164
  };
164
165
 
165
166
  // ----- X5 markup-mode metadata (state-classes cross-cutting Slice X5) -----
@@ -392,10 +393,10 @@ export function createViewportFamily(ctx: UiApiContext) {
392
393
  * the document's page count returns the last valid page's scrollY;
393
394
  * `actualPage` reflects the clamp so callers can detect it.
394
395
  *
395
- * Returns `null` when (a) no controller is bound, (b) the controller
396
- * has no `dispatchScroll` hook, or (c) geometry cannot resolve any
397
- * page (pre-paint / empty doc). Callers that need explicit failure
398
- * handling check `result !== null` before trusting the scroll.
396
+ * Throws when controller wiring is missing. Returns `null` only when
397
+ * geometry cannot resolve any page (pre-paint / empty doc). Callers
398
+ * that need explicit failure handling check `result !== null` before
399
+ * trusting the scroll.
399
400
  */
400
401
  async scrollToPage(
401
402
  pageNumber: number,
@@ -425,11 +426,15 @@ export function createViewportFamily(ctx: UiApiContext) {
425
426
  // Try the requested page; if null, scan downward through lower
426
427
  // indices to land on the largest resolvable page (the doc's last
427
428
  // populated page). If nothing resolves, return null.
428
- let resolved: { pageIndex: number; scrollY: number } | null = null;
429
+ let resolved: { pageIndex: number; scrollY: number; elementId: string } | null = null;
429
430
  for (let i = requestedClampedLow - 1; i >= 0; i--) {
430
431
  const page = getPage.call(ctx.handle.geometry, i);
431
432
  if (page) {
432
- resolved = { pageIndex: i, scrollY: page.frame.topPx };
433
+ resolved = {
434
+ pageIndex: i,
435
+ scrollY: page.frame.topPx,
436
+ elementId: buildPageAnchorElementId(page.pageId, i),
437
+ };
433
438
  break;
434
439
  }
435
440
  }
@@ -451,11 +456,19 @@ export function createViewportFamily(ctx: UiApiContext) {
451
456
  expectedDelta: scrollToPageMetadata.uxIntent.expectedDelta,
452
457
  actualDelta: {
453
458
  kind: "surface-refresh",
454
- payload: { page: actualPage, scrollY: resolved.scrollY },
459
+ payload: {
460
+ page: actualPage,
461
+ scrollY: resolved.scrollY,
462
+ elementId: resolved.elementId,
463
+ },
455
464
  },
456
465
  });
457
466
 
458
- return { actualPage, scrollY: resolved.scrollY };
467
+ return {
468
+ actualPage,
469
+ scrollY: resolved.scrollY,
470
+ elementId: resolved.elementId,
471
+ };
459
472
  },
460
473
 
461
474
  // ----- X5 markup-mode (state-classes cross-cutting Slice X5) -----
@@ -139,6 +139,7 @@ function insertImageIntoParagraph(
139
139
  (unit) =>
140
140
  unit.kind === "opaque_inline" ||
141
141
  unit.kind === "opaque_block" ||
142
+ unit.kind === "structural_block" ||
142
143
  unit.kind === "image",
143
144
  );
144
145
  if (protectedUnit) {
@@ -15,7 +15,9 @@ import {
15
15
  createNodeAnchor,
16
16
  createRangeAnchor,
17
17
  DEFAULT_BOUNDARY_ASSOC,
18
+ MAIN_STORY_TARGET,
18
19
  mapAnchor,
20
+ storyTargetsEqual,
19
21
  type BoundaryAssoc,
20
22
  type EditorAnchorProjection,
21
23
  type MappingStep,
@@ -1925,6 +1927,10 @@ function applyReviewCommand(
1925
1927
  continue;
1926
1928
  }
1927
1929
 
1930
+ if (!storyTargetsEqual(entry.storyTarget, MAIN_STORY_TARGET)) {
1931
+ continue;
1932
+ }
1933
+
1928
1934
  selection = remapSelection(selection, entry.mapping);
1929
1935
  mappingSteps.push(...entry.mapping.steps);
1930
1936
 
@@ -8,6 +8,11 @@ import type {
8
8
  TextMark,
9
9
  } from "../../model/canonical-document.ts";
10
10
 
11
+ type PreservedStructuralBlockNode = Exclude<
12
+ DocumentRootNode["children"][number],
13
+ ParagraphNode | OpaqueBlockNode
14
+ >;
15
+
11
16
  export interface ParagraphProperties {
12
17
  styleId?: string;
13
18
  numbering?: ParagraphNode["numbering"];
@@ -33,6 +38,7 @@ export type StoryUnit =
33
38
  | ImageUnit
34
39
  | OpaqueInlineUnit
35
40
  | OpaqueBlockUnit
41
+ | PreservedStructuralBlockUnit
36
42
  | ScopeMarkerUnit
37
43
  | ParagraphBreakUnit;
38
44
 
@@ -72,6 +78,12 @@ export interface OpaqueBlockUnit {
72
78
  nextParagraph?: ParagraphProperties;
73
79
  }
74
80
 
81
+ export interface PreservedStructuralBlockUnit {
82
+ kind: "structural_block";
83
+ block: PreservedStructuralBlockNode;
84
+ nextParagraph?: ParagraphProperties;
85
+ }
86
+
75
87
  export interface ParagraphBreakUnit {
76
88
  kind: "paragraph_break";
77
89
  nextParagraph: ParagraphProperties;
@@ -114,14 +126,21 @@ export function parseTextStory(content: unknown): TextStory {
114
126
  continue;
115
127
  }
116
128
 
117
- if (block.type !== "opaque_block") {
129
+ if (block.type === "opaque_block") {
130
+ units.push({
131
+ kind: "opaque_block",
132
+ fragmentId: block.fragmentId,
133
+ warningId: block.warningId,
134
+ ...(isParagraphNode(nextBlock)
135
+ ? { nextParagraph: extractParagraphProperties(nextBlock) }
136
+ : {}),
137
+ });
118
138
  continue;
119
139
  }
120
140
 
121
141
  units.push({
122
- kind: "opaque_block",
123
- fragmentId: block.fragmentId,
124
- warningId: block.warningId,
142
+ kind: "structural_block",
143
+ block,
125
144
  ...(isParagraphNode(nextBlock)
126
145
  ? { nextParagraph: extractParagraphProperties(nextBlock) }
127
146
  : {}),
@@ -186,7 +205,7 @@ export function logicalPositionToUnitIndex(
186
205
  }
187
206
 
188
207
  export function serializeTextStory(story: TextStory): DocumentRootNode {
189
- const blocks: Array<ParagraphNode | OpaqueBlockNode> = [];
208
+ const blocks: DocumentRootNode["children"] = [];
190
209
  let currentParagraph: ParagraphNode | undefined = createParagraph(story.firstParagraph);
191
210
  let currentHyperlink: HyperlinkNode | undefined;
192
211
  let activeTextBuffer:
@@ -301,6 +320,15 @@ export function serializeTextStory(story: TextStory): DocumentRootNode {
301
320
  continue;
302
321
  }
303
322
 
323
+ if (unit.kind === "structural_block") {
324
+ flushParagraph();
325
+ blocks.push(unit.block);
326
+ currentParagraph = unit.nextParagraph
327
+ ? createParagraph(unit.nextParagraph)
328
+ : undefined;
329
+ continue;
330
+ }
331
+
304
332
  if (unit.kind === "text") {
305
333
  const shouldExtendBuffer =
306
334
  activeTextBuffer &&
@@ -384,6 +412,8 @@ export function createPlainText(story: TextStory): string {
384
412
  return "\uFFF9";
385
413
  case "opaque_block":
386
414
  return "\uFFFA";
415
+ case "structural_block":
416
+ return "\uFFFA";
387
417
  case "scope_marker":
388
418
  return "";
389
419
  }
@@ -431,6 +461,14 @@ export function cloneStoryUnit(unit: StoryUnit): StoryUnit {
431
461
  ? { nextParagraph: cloneParagraphProperties(unit.nextParagraph) }
432
462
  : {}),
433
463
  };
464
+ case "structural_block":
465
+ return {
466
+ kind: "structural_block",
467
+ block: structuredClone(unit.block) as PreservedStructuralBlockNode,
468
+ ...(unit.nextParagraph
469
+ ? { nextParagraph: cloneParagraphProperties(unit.nextParagraph) }
470
+ : {}),
471
+ };
434
472
  case "paragraph_break":
435
473
  return {
436
474
  kind: "paragraph_break",
@@ -67,12 +67,19 @@ export function createRangeAnchor(
67
67
  assoc: BoundaryAssoc = DEFAULT_BOUNDARY_ASSOC,
68
68
  ): RangeAnchor {
69
69
  const normalized = normalizeRange({ from, to });
70
- return {
70
+ const anchor = {
71
71
  kind: "range",
72
72
  from: normalized.from,
73
73
  to: normalized.to,
74
74
  assoc,
75
75
  };
76
+ Object.defineProperty(anchor, "range", {
77
+ value: normalized,
78
+ enumerable: false,
79
+ configurable: false,
80
+ writable: false,
81
+ });
82
+ return anchor as RangeAnchor;
76
83
  }
77
84
 
78
85
  export function createNodeAnchor(at: Position, assoc: Assoc = 1): NodeAnchor {
@@ -95,7 +95,11 @@ export function rangeStaysWithinSingleParagraph(
95
95
  continue;
96
96
  }
97
97
 
98
- if (unit.kind === "paragraph_break" || unit.kind === "opaque_block") {
98
+ if (
99
+ unit.kind === "paragraph_break" ||
100
+ unit.kind === "opaque_block" ||
101
+ unit.kind === "structural_block"
102
+ ) {
99
103
  return false;
100
104
  }
101
105
  }
@@ -914,6 +914,8 @@ function resolveParagraphPropertiesAtPosition(
914
914
  current = cloneParagraphProperties(unit.nextParagraph);
915
915
  } else if (unit.kind === "opaque_block" && unit.nextParagraph) {
916
916
  current = cloneParagraphProperties(unit.nextParagraph);
917
+ } else if (unit.kind === "structural_block" && unit.nextParagraph) {
918
+ current = cloneParagraphProperties(unit.nextParagraph);
917
919
  }
918
920
  }
919
921
 
@@ -947,7 +949,11 @@ function normalizeStoryUnits(units: StoryUnit[]): StoryUnit[] {
947
949
 
948
950
  function ensureEditableRange(units: StoryUnit[]): void {
949
951
  const protectedUnit = units.find(
950
- (unit) => unit.kind === "opaque_inline" || unit.kind === "opaque_block" || unit.kind === "image",
952
+ (unit) =>
953
+ unit.kind === "opaque_inline" ||
954
+ unit.kind === "opaque_block" ||
955
+ unit.kind === "structural_block" ||
956
+ unit.kind === "image",
951
957
  );
952
958
 
953
959
  if (!protectedUnit) {
@@ -971,5 +977,5 @@ function containsParagraphBoundaryChange(
971
977
 
972
978
  return story.units
973
979
  .slice(range.from, range.to)
974
- .some((unit) => unit.kind === "paragraph_break");
980
+ .some((unit) => unit.kind === "paragraph_break" || unit.kind === "structural_block");
975
981
  }
@@ -40,12 +40,22 @@ export function serializeRevisionsIntoDocumentXml(
40
40
  const boundaries = options.boundaries ?? mapRevisionBoundaries(documentXml);
41
41
  const replacements: XmlReplacement[] = [];
42
42
  const consumedRevisionIds = new Set<string>();
43
+ const consumedMarkupRevisionIds = new Set<string>();
43
44
  const paragraphDecisions = collectParagraphMarkupDecisions(
44
45
  preservedMarkup,
45
46
  revisionById,
46
47
  boundaries,
47
48
  );
48
49
 
50
+ replacements.push(
51
+ ...createMoveRangeMarkerReplacements(
52
+ preservedMarkup,
53
+ revisionById,
54
+ consumedRevisionIds,
55
+ consumedMarkupRevisionIds,
56
+ ),
57
+ );
58
+
49
59
  replacements.push(
50
60
  ...createParagraphStructuralReplacements(
51
61
  documentXml,
@@ -56,7 +66,10 @@ export function serializeRevisionsIntoDocumentXml(
56
66
  );
57
67
 
58
68
  for (const markup of preservedMarkup) {
59
- if (consumedRevisionIds.has(markup.revisionId)) {
69
+ if (
70
+ consumedRevisionIds.has(markup.revisionId) ||
71
+ consumedMarkupRevisionIds.has(markup.revisionId)
72
+ ) {
60
73
  continue;
61
74
  }
62
75
 
@@ -87,6 +100,141 @@ export function serializeRevisionsIntoDocumentXml(
87
100
  return applyReplacements(documentXml, replacements);
88
101
  }
89
102
 
103
+ function createMoveRangeMarkerReplacements(
104
+ preservedMarkup: readonly PreservedRevisionMarkup[],
105
+ revisionById: ReadonlyMap<string, RevisionRecord>,
106
+ consumedRevisionIds: Set<string>,
107
+ consumedMarkupRevisionIds: Set<string>,
108
+ ): XmlReplacement[] {
109
+ const replacements: XmlReplacement[] = [];
110
+ const rangeMarkers = collectMoveRangeMarkers(preservedMarkup);
111
+
112
+ for (const marker of rangeMarkers) {
113
+ const revision = revisionById.get(marker.start.revisionId);
114
+ if (
115
+ !revision ||
116
+ revision.status === "active" ||
117
+ revision.status === "detached" ||
118
+ revision.kind !== "move"
119
+ ) {
120
+ continue;
121
+ }
122
+
123
+ const direction = revision.metadata.moveData?.direction;
124
+ if (direction !== marker.direction) {
125
+ continue;
126
+ }
127
+
128
+ const shouldDropSpan =
129
+ (revision.status === "accepted" && marker.direction === "from") ||
130
+ (revision.status === "rejected" && marker.direction === "to");
131
+
132
+ if (shouldDropSpan) {
133
+ replacements.push({
134
+ start: marker.start.xmlStart,
135
+ end: marker.end.xmlEnd,
136
+ replacement: "",
137
+ });
138
+ } else {
139
+ replacements.push(
140
+ {
141
+ start: marker.start.xmlStart,
142
+ end: marker.start.xmlEnd,
143
+ replacement: "",
144
+ },
145
+ {
146
+ start: marker.end.xmlStart,
147
+ end: marker.end.xmlEnd,
148
+ replacement: "",
149
+ },
150
+ );
151
+ }
152
+
153
+ consumedRevisionIds.add(marker.start.revisionId);
154
+ consumedMarkupRevisionIds.add(marker.end.revisionId);
155
+ }
156
+
157
+ return replacements;
158
+ }
159
+
160
+ function collectMoveRangeMarkers(
161
+ preservedMarkup: readonly PreservedRevisionMarkup[],
162
+ ): Array<{
163
+ direction: "from" | "to";
164
+ moveId: string;
165
+ start: PreservedRevisionMarkup;
166
+ end: PreservedRevisionMarkup;
167
+ }> {
168
+ const starts = new Map<string, PreservedRevisionMarkup>();
169
+ const ends = new Map<string, PreservedRevisionMarkup>();
170
+
171
+ for (const markup of preservedMarkup) {
172
+ const direction = getMoveRangeMarkerDirection(markup.originalRevisionType);
173
+ if (!direction) {
174
+ continue;
175
+ }
176
+ const moveId = readMoveRangeMarkerId(markup);
177
+ if (!moveId) {
178
+ continue;
179
+ }
180
+ const key = `${direction}:${moveId}`;
181
+ if (isMoveRangeStart(markup.originalRevisionType)) {
182
+ starts.set(key, markup);
183
+ } else if (isMoveRangeEnd(markup.originalRevisionType)) {
184
+ ends.set(key, markup);
185
+ }
186
+ }
187
+
188
+ const markers: Array<{
189
+ direction: "from" | "to";
190
+ moveId: string;
191
+ start: PreservedRevisionMarkup;
192
+ end: PreservedRevisionMarkup;
193
+ }> = [];
194
+
195
+ for (const [key, start] of starts.entries()) {
196
+ const end = ends.get(key);
197
+ if (!end) {
198
+ continue;
199
+ }
200
+ const [direction, moveId] = key.split(":");
201
+ if ((direction === "from" || direction === "to") && moveId) {
202
+ markers.push({ direction, moveId, start, end });
203
+ }
204
+ }
205
+
206
+ return markers;
207
+ }
208
+
209
+ function isMoveRangeStart(originalRevisionType: string): boolean {
210
+ return originalRevisionType === "moveFromRangeStart" ||
211
+ originalRevisionType === "moveToRangeStart";
212
+ }
213
+
214
+ function isMoveRangeEnd(originalRevisionType: string): boolean {
215
+ return originalRevisionType === "moveFromRangeEnd" ||
216
+ originalRevisionType === "moveToRangeEnd";
217
+ }
218
+
219
+ function getMoveRangeMarkerDirection(
220
+ originalRevisionType: string,
221
+ ): "from" | "to" | undefined {
222
+ if (originalRevisionType === "moveFromRangeStart" ||
223
+ originalRevisionType === "moveFromRangeEnd") {
224
+ return "from";
225
+ }
226
+ if (originalRevisionType === "moveToRangeStart" ||
227
+ originalRevisionType === "moveToRangeEnd") {
228
+ return "to";
229
+ }
230
+ return undefined;
231
+ }
232
+
233
+ function readMoveRangeMarkerId(markup: PreservedRevisionMarkup): string | undefined {
234
+ const match = /\b(?:w:)?id=(["'])([^"']+)\1/.exec(markup.rawXml);
235
+ return match?.[2];
236
+ }
237
+
90
238
  function serializeMarkup(
91
239
  markup: PreservedRevisionMarkup,
92
240
  revision: RevisionRecord | undefined,
@@ -245,6 +245,7 @@ function normalizeParagraph(
245
245
  const children = normalizeInlineChildren(paragraph.children, state, packagePartName);
246
246
  return {
247
247
  type: "paragraph",
248
+ ...(paragraph.sourceRef ? { sourceRef: paragraph.sourceRef } : {}),
248
249
  ...(paragraph.styleId ? { styleId: paragraph.styleId } : {}),
249
250
  ...(paragraph.numbering ? { numbering: paragraph.numbering } : {}),
250
251
  ...(paragraph.alignment ? { alignment: paragraph.alignment } : {}),
@@ -603,6 +604,7 @@ function normalizeInlineChildren(
603
604
  ...(classification.target ? { fieldTarget: classification.target } : {}),
604
605
  ...(classification.switches ? { switches: classification.switches } : {}),
605
606
  refreshStatus: classification.supported ? "stale" : "preserve-only",
607
+ ...(node.sourceRef ? { sourceRef: node.sourceRef } : {}),
606
608
  ...(node.legacyFormField ? { legacyFormField: node.legacyFormField } : {}),
607
609
  });
608
610
  state.cursor += renderedLength > 0 ? renderedLength : 1;
@@ -720,6 +722,8 @@ function registerComplexPreviewMedia(
720
722
  function normalizeHyperlink(node: ParsedHyperlinkNode): {
721
723
  type: "hyperlink";
722
724
  href: string;
725
+ sourceRef?: ParsedHyperlinkNode["sourceRef"];
726
+ fieldCarrier?: ParsedHyperlinkNode["fieldCarrier"];
723
727
  children: Array<
724
728
  | TextNode
725
729
  | { type: "hard_break" }
@@ -789,6 +793,8 @@ function normalizeHyperlink(node: ParsedHyperlinkNode): {
789
793
  return {
790
794
  type: "hyperlink",
791
795
  href: node.href,
796
+ ...(node.sourceRef ? { sourceRef: node.sourceRef } : {}),
797
+ ...(node.fieldCarrier ? { fieldCarrier: node.fieldCarrier } : {}),
792
798
  children,
793
799
  };
794
800
  }
@@ -13,6 +13,10 @@
13
13
  * 3. A `TOC` field anywhere in the doc — TOC fields produce
14
14
  * hyperlinks to generated `_Toc####` anchors at render time, so
15
15
  * we blanket-retain `_Toc*` whenever a TOC field is present.
16
+ * 4. A bookmark range that encloses active revision markup. Those
17
+ * bookmarks are part of the review round-trip surface; stripping them
18
+ * after runtime revision export turns structured re-import into plain
19
+ * text.
16
20
  *
17
21
  * Produces a typed `BookmarkReferenceScan` that the parser consults
18
22
  * at every `<w:bookmarkStart>` / `<w:bookmarkEnd>` emission site to
@@ -64,6 +68,11 @@ const TOC_FIELD_RE = /\bTOC\b/;
64
68
  const REFLIKE_FIELD_RE =
65
69
  /\b(?:HYPERLINK|REF|PAGEREF|NOTEREF)\s+([A-Za-z0-9_:.\-]+)/g;
66
70
  const DATA_BINDING_RE = /<(?:\w+:)?dataBinding\b/i;
71
+ const BOOKMARK_START_RE =
72
+ /<(?:\w+:)?bookmarkStart\b(?=[^>]*\bw:id\s*=\s*"([^"]*)")(?=[^>]*\bw:name\s*=\s*"([^"]*)")[^>]*\/>/gi;
73
+ const BOOKMARK_END_RE =
74
+ /<(?:\w+:)?bookmarkEnd\b[^>]*\bw:id\s*=\s*"([^"]*)"[^>]*\/>/gi;
75
+ const ACTIVE_REVISION_RE = /<(?:\w+:)?(?:ins|del)\b/i;
67
76
 
68
77
  /**
69
78
  * Always-retain prefix check — bookmarks whose name starts with
@@ -102,6 +111,8 @@ export function scanBookmarkReferences(
102
111
  }
103
112
  }
104
113
 
114
+ retainRevisionBoundedBookmarks(documentXml, retained);
115
+
105
116
  return {
106
117
  retainedNames: retained,
107
118
  retainAllTocPattern: retainAllToc,
@@ -109,6 +120,50 @@ export function scanBookmarkReferences(
109
120
  };
110
121
  }
111
122
 
123
+ function retainRevisionBoundedBookmarks(
124
+ documentXml: string,
125
+ retained: Set<string>,
126
+ ): void {
127
+ const starts = new Map<
128
+ string,
129
+ {
130
+ name: string;
131
+ endOffset: number;
132
+ }
133
+ >();
134
+
135
+ BOOKMARK_START_RE.lastIndex = 0;
136
+ let startMatch: RegExpExecArray | null;
137
+ while ((startMatch = BOOKMARK_START_RE.exec(documentXml)) !== null) {
138
+ const id = startMatch[1];
139
+ const name = startMatch[2];
140
+ if (!id || !name) {
141
+ continue;
142
+ }
143
+ starts.set(id, {
144
+ name,
145
+ endOffset: startMatch.index + startMatch[0].length,
146
+ });
147
+ }
148
+
149
+ BOOKMARK_END_RE.lastIndex = 0;
150
+ let endMatch: RegExpExecArray | null;
151
+ while ((endMatch = BOOKMARK_END_RE.exec(documentXml)) !== null) {
152
+ const id = endMatch[1];
153
+ if (!id) {
154
+ continue;
155
+ }
156
+ const start = starts.get(id);
157
+ if (!start || start.endOffset > endMatch.index) {
158
+ continue;
159
+ }
160
+ const enclosedXml = documentXml.slice(start.endOffset, endMatch.index);
161
+ if (ACTIVE_REVISION_RE.test(enclosedXml)) {
162
+ retained.add(start.name);
163
+ }
164
+ }
165
+ }
166
+
112
167
  export function isRetainedBookmarkName(
113
168
  name: string,
114
169
  scan: BookmarkReferenceScan,
@@ -332,13 +332,14 @@ import type {
332
332
  } from "../../model/canonical-document.ts";
333
333
  import {
334
334
  MAIN_STORY_KEY,
335
+ createCanonicalFieldId,
335
336
  createHeaderFooterStoryKey,
336
337
  createNoteStoryKey,
337
338
  } from "../../model/canonical-layout-inputs.ts";
338
339
  import { parseFieldSwitches } from "./parse-field-switches.ts";
339
340
 
340
341
  const FIELD_FAMILY_PATTERN =
341
- /^\s*(REF|PAGEREF|NOTEREF|TOC|PAGE|NUMPAGES|DATE|TIME|AUTHOR|FILENAME|MERGEFIELD|IF|SEQ|INDEX|TC|FORMTEXT|FORMCHECKBOX|FORMDROPDOWN|STYLEREF|SECTIONPAGES)\b/i;
342
+ /^\s*(REF|PAGEREF|NOTEREF|TOC|PAGE|NUMPAGES|DATE|TIME|AUTHOR|FILENAME|HYPERLINK|MERGEFIELD|IF|SEQ|INDEX|TC|FORMTEXT|FORMCHECKBOX|FORMDROPDOWN|STYLEREF|SECTIONPAGES)\b/i;
342
343
 
343
344
  const SUPPORTED_FAMILIES = new Set<string>([
344
345
  "REF",
@@ -432,20 +433,31 @@ export function buildFieldRegistry(
432
433
  walkFieldDocument(root, (node, pIdx) => {
433
434
  paragraphIndex = pIdx;
434
435
  if (node.type === "field") {
436
+ const storyKey = MAIN_STORY_KEY;
435
437
  const classification = node.fieldFamily
436
438
  ? { family: node.fieldFamily, supported: isSupportedFieldFamily(node.fieldFamily), target: node.fieldTarget, switches: node.switches }
437
439
  : classifyFieldInstruction(node.instruction);
438
440
  const displayText = flattenFieldText(node.children);
439
441
  const entry: Mutable<FieldRegistryEntry> = {
440
442
  fieldIndex,
443
+ canonicalFieldId:
444
+ node.canonicalFieldId ??
445
+ createCanonicalFieldId({
446
+ fieldIndex,
447
+ storyKey,
448
+ sourceRef: node.sourceRef,
449
+ }),
441
450
  fieldFamily: classification.family,
442
451
  supported: classification.supported,
443
452
  instruction: node.instruction,
444
453
  ...(classification.target ? { fieldTarget: classification.target } : {}),
445
454
  displayText,
446
455
  paragraphIndex,
447
- storyKey: MAIN_STORY_KEY,
456
+ storyKey,
457
+ ...(node.sourceRef !== undefined ? { sourceRef: node.sourceRef } : {}),
448
458
  refreshStatus: node.refreshStatus ?? (classification.supported ? "stale" : "preserve-only"),
459
+ ...(node.locked !== undefined ? { locked: node.locked } : {}),
460
+ ...(node.dirty !== undefined ? { dirty: node.dirty } : {}),
449
461
  ...(classification.switches ? { switches: classification.switches } : {}),
450
462
  };
451
463
  if (classification.supported) {
@@ -469,6 +481,13 @@ export function buildFieldRegistry(
469
481
  const displayText = flattenFieldText(node.children);
470
482
  const entry: Mutable<FieldRegistryEntry> = {
471
483
  fieldIndex,
484
+ canonicalFieldId:
485
+ node.canonicalFieldId ??
486
+ createCanonicalFieldId({
487
+ fieldIndex,
488
+ storyKey,
489
+ sourceRef: node.sourceRef,
490
+ }),
472
491
  fieldFamily: classification.family,
473
492
  supported: classification.supported,
474
493
  instruction: node.instruction,
@@ -476,7 +495,10 @@ export function buildFieldRegistry(
476
495
  displayText,
477
496
  paragraphIndex,
478
497
  storyKey,
498
+ ...(node.sourceRef !== undefined ? { sourceRef: node.sourceRef } : {}),
479
499
  refreshStatus: node.refreshStatus ?? (classification.supported ? "stale" : "preserve-only"),
500
+ ...(node.locked !== undefined ? { locked: node.locked } : {}),
501
+ ...(node.dirty !== undefined ? { dirty: node.dirty } : {}),
480
502
  ...(classification.switches ? { switches: classification.switches } : {}),
481
503
  };
482
504
  if (classification.supported) {