@beyondwork/docx-react-component 1.0.59 → 1.0.61

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 (46) hide show
  1. package/package.json +33 -44
  2. package/src/api/public-types.ts +43 -0
  3. package/src/core/state/editor-state.ts +2 -0
  4. package/src/io/docx-session.ts +167 -8
  5. package/src/io/export/serialize-footnotes.ts +36 -5
  6. package/src/io/export/serialize-headers-footers.ts +7 -0
  7. package/src/io/export/serialize-main-document.ts +25 -18
  8. package/src/io/export/serialize-paragraph-formatting.ts +6 -0
  9. package/src/io/export/serialize-settings.ts +130 -3
  10. package/src/io/normalize/normalize-text.ts +8 -4
  11. package/src/io/ooxml/parse-footnotes.ts +11 -0
  12. package/src/io/ooxml/parse-headers-footers.ts +117 -42
  13. package/src/io/ooxml/parse-main-document.ts +20 -8
  14. package/src/io/ooxml/parse-paragraph-formatting.ts +25 -1
  15. package/src/io/ooxml/parse-settings.ts +91 -1
  16. package/src/io/ooxml/workflow-payload.ts +6 -1
  17. package/src/model/canonical-document.ts +36 -2
  18. package/src/model/snapshot.ts +2 -0
  19. package/src/runtime/diagnostics/build-diagnostic.ts +2 -0
  20. package/src/runtime/diagnostics/code-metadata-table.ts +9 -0
  21. package/src/runtime/document-runtime.ts +770 -21
  22. package/src/runtime/footnote-resolver.ts +32 -8
  23. package/src/runtime/layout/layout-engine-version.ts +7 -1
  24. package/src/runtime/layout/measurement-backend-canvas.ts +1 -1
  25. package/src/runtime/layout/measurement-backend-empirical.ts +1 -1
  26. package/src/runtime/layout/paginated-layout-engine.ts +41 -8
  27. package/src/runtime/layout/resolved-formatting-document.ts +11 -9
  28. package/src/runtime/layout/resolved-formatting-state.ts +4 -0
  29. package/src/runtime/numbering-prefix.ts +26 -2
  30. package/src/runtime/query-scopes.ts +103 -2
  31. package/src/runtime/surface-projection.ts +75 -14
  32. package/src/runtime/table-schema.ts +26 -0
  33. package/src/ui/WordReviewEditor.tsx +25 -0
  34. package/src/ui/editor-runtime-boundary.ts +1 -0
  35. package/src/ui/editor-shell-view.tsx +8 -0
  36. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +514 -0
  37. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -0
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +55 -6
  39. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  40. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +4 -0
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -1
  42. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +16 -0
  43. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +319 -0
  44. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +248 -0
  45. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +4 -0
  46. package/src/ui-tailwind/tw-review-workspace.tsx +54 -3
@@ -23,6 +23,7 @@ import type {
23
23
  AddCommentResult,
24
24
  AddScopeParams,
25
25
  AddScopeResult,
26
+ ClearHighlightOptions,
26
27
  CommentSidebarSnapshot,
27
28
  CommentSidebarThreadSnapshot,
28
29
  CompatibilityReport,
@@ -150,6 +151,7 @@ import {
150
151
  findScopesIntersecting,
151
152
  resolveScope,
152
153
  } from "./scope-resolver.ts";
154
+ import { buildDiagnosticFromLegacyWarningCode } from "./diagnostics/build-diagnostic.ts";
153
155
  import {
154
156
  projectScopeQueryResults,
155
157
  queryScopes as runQueryScopes,
@@ -158,6 +160,7 @@ import {
158
160
  insertScopeMarkers,
159
161
  removeScopeMarkers,
160
162
  } from "../core/commands/add-scope.ts";
163
+ import { applyFormattingOperationToDocument } from "../core/commands/formatting-commands.ts";
161
164
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
162
165
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
163
166
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
@@ -480,6 +483,22 @@ export interface DocumentRuntime {
480
483
  rejectChange(changeId: string): void;
481
484
  acceptAllChanges(): void;
482
485
  rejectAllChanges(): void;
486
+ /**
487
+ * Clears the highlight (background color) on a range in the currently
488
+ * active story. The caller's selection is NOT moved.
489
+ *
490
+ * When `options.range` is omitted, the current document selection is used.
491
+ *
492
+ * Tracked-changes / suggesting mode is honored: in suggesting mode the
493
+ * clear is recorded as an `rPrChange` property-change suggestion when the
494
+ * resolved range is a single bounded text segment, and is reported via
495
+ * `command_blocked` when the range spans multiple segments.
496
+ *
497
+ * When `options.expandToFullHighlight` is `true`, the resolved range is
498
+ * grown outward before clearing to cover the entire contiguous highlighted
499
+ * span it touches. Expansion stops at paragraph and table-cell boundaries.
500
+ */
501
+ clearHighlight(options?: ClearHighlightOptions): void;
483
502
  openStory(target: EditorStoryTarget): boolean;
484
503
  closeStory(): void;
485
504
  getActiveStory(): EditorStoryTarget;
@@ -872,6 +891,7 @@ export function createDocumentRuntime(
872
891
  options.initialSessionState?.workflowMetadata?.entries
873
892
  ?? options.initialSnapshot?.workflowMetadata?.entries
874
893
  ?? [];
894
+ let markerBackedScopeIds = new Set<string>();
875
895
  let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
876
896
  // §C7 — local view-state for scope chrome visibility; never collab-replicated.
877
897
  let scopeChromeVisibilityState: ScopeChromeVisibilityState = { mode: "all" };
@@ -909,6 +929,32 @@ export function createDocumentRuntime(
909
929
  storySelections.set(storyTargetKey(MAIN_STORY_TARGET), state.selection);
910
930
  lastHeadingFingerprint = computeHeadingFingerprint(state.document);
911
931
 
932
+ function syncMarkerBackedScopeIds(
933
+ document: CanonicalDocumentEnvelope,
934
+ overlay: WorkflowOverlay | null,
935
+ ): void {
936
+ const presentScopeIds = new Set(collectScopeLocations(document).keys());
937
+ if (!overlay) {
938
+ markerBackedScopeIds = presentScopeIds;
939
+ return;
940
+ }
941
+ const overlayScopeIds = new Set(overlay.scopes.map((scope) => scope.scopeId));
942
+ const next = new Set<string>();
943
+ for (const scopeId of markerBackedScopeIds) {
944
+ if (overlayScopeIds.has(scopeId)) {
945
+ next.add(scopeId);
946
+ }
947
+ }
948
+ for (const scopeId of presentScopeIds) {
949
+ if (overlayScopeIds.has(scopeId)) {
950
+ next.add(scopeId);
951
+ }
952
+ }
953
+ markerBackedScopeIds = next;
954
+ }
955
+
956
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
957
+
912
958
  // Runtime-owned paginated layout engine (Phase 1+ of the layout facet work).
913
959
  // The engine caches graph + resolved-formatting + fragment mapper keyed on
914
960
  // (content, styles, subParts). It is the single internal source of truth
@@ -1924,19 +1970,37 @@ export function createDocumentRuntime(
1924
1970
  return scope;
1925
1971
  }
1926
1972
  const location = locations.get(scope.scopeId);
1973
+ const isMarkerBacked = markerBackedScopeIds.has(scope.scopeId);
1974
+ let nextAnchor: EditorAnchorProjection | null = null;
1927
1975
  if (
1928
- !location ||
1929
- location.startPos === undefined ||
1930
- location.endPos === undefined
1976
+ location &&
1977
+ location.startPos !== undefined &&
1978
+ location.endPos !== undefined
1931
1979
  ) {
1980
+ nextAnchor = {
1981
+ kind: "range",
1982
+ from: Math.min(location.startPos, location.endPos),
1983
+ to: Math.max(location.startPos, location.endPos),
1984
+ assoc: { start: -1, end: 1 },
1985
+ };
1986
+ } else if (isMarkerBacked) {
1987
+ const lastKnownRange =
1988
+ scope.anchor.kind === "range"
1989
+ ? { from: scope.anchor.from, to: scope.anchor.to }
1990
+ : scope.anchor.kind === "node"
1991
+ ? { from: scope.anchor.at, to: scope.anchor.at }
1992
+ : scope.anchor.lastKnownRange;
1993
+ nextAnchor = {
1994
+ kind: "detached",
1995
+ reason:
1996
+ location && (location.startPos !== undefined || location.endPos !== undefined)
1997
+ ? "deleted"
1998
+ : "invalidatedByStructureChange",
1999
+ lastKnownRange,
2000
+ };
2001
+ } else {
1932
2002
  return scope;
1933
2003
  }
1934
- const nextAnchor: EditorAnchorProjection = {
1935
- kind: "range",
1936
- from: Math.min(location.startPos, location.endPos),
1937
- to: Math.max(location.startPos, location.endPos),
1938
- assoc: { start: -1, end: 1 },
1939
- };
1940
2004
  if (workflowAnchorsEqual(scope.anchor, nextAnchor)) {
1941
2005
  return scope;
1942
2006
  }
@@ -1966,6 +2030,161 @@ export function createDocumentRuntime(
1966
2030
  return normalizeWorkflowOverlayForDocument(state.document, workflowOverlay);
1967
2031
  }
1968
2032
 
2033
+ function buildWarningSignature(warning: InternalEditorWarning): string {
2034
+ return JSON.stringify({
2035
+ code: warning.code,
2036
+ severity: warning.severity,
2037
+ message: warning.message,
2038
+ source: warning.source,
2039
+ featureEntryId: warning.featureEntryId ?? null,
2040
+ details: warning.details ?? null,
2041
+ affectedAnchor: warning.affectedAnchor ?? null,
2042
+ });
2043
+ }
2044
+
2045
+ function mergeDetachedWorkflowScopeWarnings(
2046
+ overlay: WorkflowOverlay | null,
2047
+ existingWarnings: readonly InternalEditorWarning[],
2048
+ ): {
2049
+ nextWarnings: InternalEditorWarning[];
2050
+ added: InternalEditorWarning[];
2051
+ cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }>;
2052
+ } {
2053
+ const detachedScopesById = new Map<string, WorkflowScope>();
2054
+ for (const scope of overlay?.scopes ?? []) {
2055
+ if (scope.anchor.kind === "detached") {
2056
+ detachedScopesById.set(scope.scopeId, scope);
2057
+ }
2058
+ }
2059
+
2060
+ const retainedWarnings = existingWarnings.filter(
2061
+ (warning) => warning.code !== "workflow_scope_invalidated",
2062
+ );
2063
+ const existingDetachedWarnings = existingWarnings.filter(
2064
+ (warning) => warning.code === "workflow_scope_invalidated",
2065
+ );
2066
+ const existingById = new Map(
2067
+ existingDetachedWarnings.map((warning) => [warning.warningId, warning] as const),
2068
+ );
2069
+ const desiredById = new Map(
2070
+ [...detachedScopesById.values()].map((scope) => {
2071
+ const warning = createInvalidatedWorkflowScopeWarning(scope);
2072
+ return [warning.warningId, warning] as const;
2073
+ }),
2074
+ );
2075
+
2076
+ const added: InternalEditorWarning[] = [];
2077
+ const cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }> = [];
2078
+
2079
+ for (const [warningId, existingWarning] of existingById) {
2080
+ const desiredWarning = desiredById.get(warningId);
2081
+ if (!desiredWarning) {
2082
+ cleared.push({ warningId, code: existingWarning.code });
2083
+ continue;
2084
+ }
2085
+ if (buildWarningSignature(existingWarning) !== buildWarningSignature(desiredWarning)) {
2086
+ cleared.push({ warningId, code: existingWarning.code });
2087
+ added.push(desiredWarning);
2088
+ }
2089
+ }
2090
+
2091
+ for (const [warningId, desiredWarning] of desiredById) {
2092
+ if (!existingById.has(warningId)) {
2093
+ added.push(desiredWarning);
2094
+ }
2095
+ }
2096
+
2097
+ return {
2098
+ nextWarnings: [...retainedWarnings, ...desiredById.values()],
2099
+ added,
2100
+ cleared,
2101
+ };
2102
+ }
2103
+
2104
+ function createInvalidatedWorkflowScopeWarning(
2105
+ scope: WorkflowScope,
2106
+ ): InternalEditorWarning {
2107
+ const anchor = scope.anchor.kind === "detached" ? scope.anchor : null;
2108
+ const subject = scope.label
2109
+ ? `Workflow scope "${scope.label}" (${scope.scopeId})`
2110
+ : `Workflow scope ${scope.scopeId}`;
2111
+ const reasonPhrase =
2112
+ anchor?.reason === "deleted"
2113
+ ? "its anchored text was deleted"
2114
+ : anchor?.reason === "invalidatedByStructureChange"
2115
+ ? "document structure changed around it"
2116
+ : "its anchor could not be resolved unambiguously";
2117
+ const modePhrase =
2118
+ scope.mode === "view"
2119
+ ? "read-only enforcement"
2120
+ : `${scope.mode} enforcement`;
2121
+
2122
+ return {
2123
+ warningId: `warning:workflow-scope-invalidated:${scope.scopeId}`,
2124
+ code: "workflow_scope_invalidated",
2125
+ severity: "warning",
2126
+ message: `${subject} was invalidated because ${reasonPhrase}. Reapply the scope before relying on ${modePhrase}.`,
2127
+ source: "runtime",
2128
+ affectedAnchor: anchor ? toInternalAnchorProjection(anchor) : undefined,
2129
+ diagnostic: buildDiagnosticFromLegacyWarningCode("workflow_scope_invalidated", {
2130
+ diagnosticId: `warning-diag:workflow-scope-invalidated:${scope.scopeId}`,
2131
+ technical: {
2132
+ message: `${subject} lost its trusted anchor and is now detached.`,
2133
+ source: "runtime",
2134
+ },
2135
+ details: {
2136
+ scopeId: scope.scopeId,
2137
+ label: scope.label,
2138
+ mode: scope.mode,
2139
+ reason: anchor?.reason,
2140
+ lastKnownRange: anchor?.lastKnownRange,
2141
+ storyTarget: scope.storyTarget,
2142
+ reapplySuggested: true,
2143
+ },
2144
+ affectedAnchor: anchor ? scope.anchor : undefined,
2145
+ llmMetadata: {
2146
+ userSummary: `${subject} is no longer attached to trusted document content. Reapply the scope before relying on it.`,
2147
+ remediation: {
2148
+ kind: "fallback",
2149
+ suggestion:
2150
+ "Locate the intended text using warning.details.scopeId and warning.details.lastKnownRange, then call addScope again for the repaired range.",
2151
+ },
2152
+ recoveryClass: "requires-input",
2153
+ echoedInput: {
2154
+ scopeId: scope.scopeId,
2155
+ lastKnownRange: anchor?.lastKnownRange,
2156
+ },
2157
+ },
2158
+ }),
2159
+ details: {
2160
+ scopeId: scope.scopeId,
2161
+ label: scope.label,
2162
+ mode: scope.mode,
2163
+ reason: anchor?.reason,
2164
+ lastKnownRange: anchor?.lastKnownRange,
2165
+ storyTarget: scope.storyTarget,
2166
+ reapplySuggested: true,
2167
+ actionabilityNote:
2168
+ "Resolve the intended text again, then reapply the scope; the previous anchor is no longer trusted.",
2169
+ },
2170
+ };
2171
+ }
2172
+
2173
+ function syncDetachedWorkflowScopeWarningsInState(): {
2174
+ added: InternalEditorWarning[];
2175
+ cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }>;
2176
+ } {
2177
+ const { nextWarnings, added, cleared } = mergeDetachedWorkflowScopeWarnings(
2178
+ getNormalizedWorkflowOverlay(),
2179
+ state.warnings,
2180
+ );
2181
+ if (added.length === 0 && cleared.length === 0) {
2182
+ return { added, cleared };
2183
+ }
2184
+ state = { ...state, warnings: nextWarnings };
2185
+ return { added, cleared };
2186
+ }
2187
+
1969
2188
  function deriveWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
1970
2189
  const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
1971
2190
  if (!normalizedWorkflowOverlay) return null;
@@ -2529,6 +2748,7 @@ export function createDocumentRuntime(
2529
2748
  return snapshot;
2530
2749
  }
2531
2750
 
2751
+ syncDetachedWorkflowScopeWarningsInState();
2532
2752
  let cachedRenderSnapshot = refreshRenderSnapshot();
2533
2753
 
2534
2754
  emit({
@@ -3351,17 +3571,29 @@ export function createDocumentRuntime(
3351
3571
  });
3352
3572
 
3353
3573
  if (params.persistence && params.persistence !== "runtime-only") {
3574
+ const requestedMetadata = params.metadata ?? {};
3575
+ const entryPersistence =
3576
+ requestedMetadata.metadataPersistence ??
3577
+ (params.persistence === "session" ? "external" : "internal");
3354
3578
  const entry: WorkflowMetadataEntry = {
3355
- entryId: `scope-metadata-${scopeId}`,
3356
- metadataId: "workflow.scope",
3579
+ entryId: requestedMetadata.entryId ?? `scope-metadata-${scopeId}`,
3580
+ metadataId: requestedMetadata.metadataId ?? "workflow.scope",
3357
3581
  anchor: publicAnchor,
3582
+ ...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
3358
3583
  scopeId,
3359
- value:
3360
- params.persistence === "document-metadata"
3361
- ? { ...(params.metadata?.value ?? {}), label: params.label }
3362
- : params.metadata?.value,
3363
- metadataPersistence:
3364
- params.persistence === "session" ? "external" : "internal",
3584
+ ...(requestedMetadata.workItemId ? { workItemId: requestedMetadata.workItemId } : {}),
3585
+ ...(requestedMetadata.value !== undefined
3586
+ ? { value: requestedMetadata.value }
3587
+ : params.persistence === "document-metadata" && params.label
3588
+ ? { value: { label: params.label } }
3589
+ : {}),
3590
+ metadataPersistence: entryPersistence,
3591
+ ...(requestedMetadata.storageRef !== undefined
3592
+ ? { storageRef: requestedMetadata.storageRef }
3593
+ : {}),
3594
+ ...(requestedMetadata.metadataVersion !== undefined
3595
+ ? { metadataVersion: requestedMetadata.metadataVersion }
3596
+ : {}),
3365
3597
  };
3366
3598
  this.dispatch({
3367
3599
  type: "workflow.set-metadata-entries",
@@ -3491,6 +3723,143 @@ export function createDocumentRuntime(
3491
3723
  origin: createOrigin("api", clock()),
3492
3724
  });
3493
3725
  },
3726
+ clearHighlight(options) {
3727
+ const resolvedRange = resolveClearHighlightRange(
3728
+ options?.range,
3729
+ state.selection,
3730
+ );
3731
+ if (!resolvedRange) {
3732
+ return;
3733
+ }
3734
+ if (viewState.documentMode === "viewing") {
3735
+ this.emitBlockedCommand("clearHighlight", [{
3736
+ code: "document_viewing_mode",
3737
+ message: "Cannot clear highlight in viewing mode.",
3738
+ }]);
3739
+ return;
3740
+ }
3741
+
3742
+ const surfaceBlocks = getActiveStorySurfaceBlocks();
3743
+ if (!surfaceBlocks) {
3744
+ return;
3745
+ }
3746
+
3747
+ const inputFrom = Math.min(resolvedRange.from, resolvedRange.to);
3748
+ const inputTo = Math.max(resolvedRange.from, resolvedRange.to);
3749
+ let targetFrom = inputFrom;
3750
+ let targetTo = inputTo;
3751
+ if (options?.expandToFullHighlight === true) {
3752
+ const expanded = expandRangeToHighlightExtent(
3753
+ surfaceBlocks,
3754
+ inputFrom,
3755
+ inputTo,
3756
+ );
3757
+ targetFrom = expanded.from;
3758
+ targetTo = expanded.to;
3759
+ }
3760
+ if (targetFrom === targetTo) {
3761
+ return;
3762
+ }
3763
+
3764
+ const activeStoryDocument =
3765
+ activeStory.kind === "main"
3766
+ ? state.document
3767
+ : {
3768
+ ...state.document,
3769
+ content: {
3770
+ type: "doc" as const,
3771
+ children: [...getStoryBlocks(state.document, activeStory)],
3772
+ },
3773
+ };
3774
+ const syntheticSnapshot: RuntimeRenderSnapshot = {
3775
+ ...cachedRenderSnapshot,
3776
+ ...(activeStory.kind === "main" ? {} : { activeStory: MAIN_STORY_TARGET }),
3777
+ selection: {
3778
+ anchor: targetFrom,
3779
+ head: targetTo,
3780
+ isCollapsed: false,
3781
+ activeRange: {
3782
+ kind: "range",
3783
+ from: targetFrom,
3784
+ to: targetTo,
3785
+ assoc: { start: -1, end: 1 },
3786
+ },
3787
+ },
3788
+ };
3789
+
3790
+ const suggesting =
3791
+ getEffectiveDocumentMode(state.selection) === "suggesting";
3792
+ if (suggesting) {
3793
+ if (activeStory.kind !== "main") {
3794
+ this.emitBlockedCommand("clearHighlight", [{
3795
+ code: "suggesting_unsupported",
3796
+ message: `"clearHighlight" is not supported in suggesting mode for this story.`,
3797
+ }]);
3798
+ return;
3799
+ }
3800
+ const segment = findSingleSelectedTextSegment(syntheticSnapshot);
3801
+ if (!segment) {
3802
+ this.emitBlockedCommand("clearHighlight", [{
3803
+ code: "suggesting_unsupported",
3804
+ message: `"clearHighlight" requires one bounded text segment in suggesting mode.`,
3805
+ }]);
3806
+ return;
3807
+ }
3808
+ const beforeXml = buildRunPropertyBeforeXml(segment);
3809
+ const mutation = applyFormattingOperationToDocument(
3810
+ activeStoryDocument,
3811
+ syntheticSnapshot,
3812
+ { type: "set-highlight-color", color: null },
3813
+ );
3814
+ if (!mutation.changed) {
3815
+ return;
3816
+ }
3817
+ const timestamp = clock();
3818
+ const nextDocument = appendPropertyChangeSuggestion(
3819
+ mutation.document,
3820
+ { from: segment.from, to: segment.to },
3821
+ {
3822
+ originalRevisionType: "rPrChange",
3823
+ xmlTag: "rPrChange",
3824
+ beforeXml,
3825
+ semanticKind: "formatting-change",
3826
+ storyTarget: activeStory,
3827
+ authorId: defaultAuthorId ?? undefined,
3828
+ },
3829
+ timestamp,
3830
+ );
3831
+ this.dispatch({
3832
+ type: "document.replace",
3833
+ document: nextDocument,
3834
+ mapping: createEmptyMapping(),
3835
+ origin: createOrigin("api", timestamp),
3836
+ });
3837
+ return;
3838
+ }
3839
+
3840
+ const result = applyFormattingOperationToDocument(
3841
+ activeStoryDocument,
3842
+ syntheticSnapshot,
3843
+ { type: "set-highlight-color", color: null },
3844
+ );
3845
+ if (!result.changed) {
3846
+ return;
3847
+ }
3848
+ const nextDocument =
3849
+ activeStory.kind === "main"
3850
+ ? result.document
3851
+ : replaceStoryBlocks(
3852
+ state.document,
3853
+ activeStory,
3854
+ result.document.content.children,
3855
+ );
3856
+ this.dispatch({
3857
+ type: "document.replace",
3858
+ document: nextDocument,
3859
+ mapping: createEmptyMapping(),
3860
+ origin: createOrigin("api", clock()),
3861
+ });
3862
+ },
3494
3863
  openStory(target) {
3495
3864
  const normalizedTarget =
3496
3865
  target.kind === "header" || target.kind === "footer"
@@ -3601,6 +3970,7 @@ export function createDocumentRuntime(
3601
3970
  collection,
3602
3971
  collectSectionPropertiesInOrder(state.document),
3603
3972
  state.document,
3973
+ state.document.subParts?.settings,
3604
3974
  );
3605
3975
  },
3606
3976
  layout: layoutFacet,
@@ -3951,16 +4321,42 @@ export function createDocumentRuntime(
3951
4321
  overlay: workflowOverlay,
3952
4322
  entries: workflowMetadataEntries,
3953
4323
  document: state.document,
4324
+ markerBackedScopeIds,
3954
4325
  },
3955
4326
  filter,
3956
4327
  );
3957
4328
  },
3958
4329
  subscribeToScopeQuery(filter, callback) {
4330
+ const buildAnchorKey = (anchor: EditorAnchorProjection): string => {
4331
+ switch (anchor.kind) {
4332
+ case "range":
4333
+ return `range:${anchor.from}:${anchor.to}:${anchor.assoc.start}:${anchor.assoc.end}`;
4334
+ case "node":
4335
+ return `node:${anchor.at}`;
4336
+ case "detached":
4337
+ return `detached:${anchor.reason}:${anchor.lastKnownRange.from}:${anchor.lastKnownRange.to}`;
4338
+ default:
4339
+ return "unknown";
4340
+ }
4341
+ };
4342
+
3959
4343
  const buildKey = (results: ScopeQueryResult[]) =>
3960
4344
  results
3961
4345
  .map(
3962
4346
  (r) =>
3963
- `${r.scope.scopeId}:${r.scope.version ?? 0}:${r.scope.visibility ?? "visible"}`,
4347
+ [
4348
+ r.scope.scopeId,
4349
+ r.scope.version ?? 0,
4350
+ r.scope.visibility ?? "visible",
4351
+ buildAnchorKey(r.scope.anchor),
4352
+ r.workItem?.workItemId ?? "",
4353
+ r.entries
4354
+ .map(
4355
+ (entry) =>
4356
+ `${entry.entryId}:${entry.metadataVersion ?? 0}:${buildAnchorKey(entry.anchor)}`,
4357
+ )
4358
+ .join("|"),
4359
+ ].join(":"),
3964
4360
  )
3965
4361
  .join(",");
3966
4362
 
@@ -4027,7 +4423,12 @@ export function createDocumentRuntime(
4027
4423
  if (pos === null) return [];
4028
4424
  const hits = findAllScopesAt(state.document, pos);
4029
4425
  return projectScopeQueryResults(
4030
- { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
4426
+ {
4427
+ overlay: workflowOverlay,
4428
+ entries: workflowMetadataEntries,
4429
+ document: state.document,
4430
+ markerBackedScopeIds,
4431
+ },
4031
4432
  hits.map((h) => h.scopeId),
4032
4433
  options,
4033
4434
  );
@@ -4036,7 +4437,12 @@ export function createDocumentRuntime(
4036
4437
  if (range.kind !== "range") return [];
4037
4438
  const hits = findScopesIntersecting(state.document, range.from, range.to, options?.mode);
4038
4439
  return projectScopeQueryResults(
4039
- { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
4440
+ {
4441
+ overlay: workflowOverlay,
4442
+ entries: workflowMetadataEntries,
4443
+ document: state.document,
4444
+ markerBackedScopeIds,
4445
+ },
4040
4446
  hits.map((h) => h.scopeId),
4041
4447
  options,
4042
4448
  );
@@ -4249,6 +4655,8 @@ export function createDocumentRuntime(
4249
4655
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
4250
4656
  perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
4251
4657
  storySelections.set(storyTargetKey(activeStory), state.selection);
4658
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
4659
+ const detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
4252
4660
 
4253
4661
  const tInvalidate0 = performance.now();
4254
4662
  if (transaction.markDirty && transaction.mapping.steps.length > 0) {
@@ -4319,7 +4727,20 @@ export function createDocumentRuntime(
4319
4727
  perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
4320
4728
 
4321
4729
  const tNotify0 = performance.now();
4322
- notify(previous, state, transaction);
4730
+ notify(previous, state, {
4731
+ ...transaction,
4732
+ effects: {
4733
+ ...transaction.effects,
4734
+ warningsAdded: [
4735
+ ...transaction.effects.warningsAdded,
4736
+ ...detachedWorkflowScopeWarnings.added,
4737
+ ],
4738
+ warningsCleared: [
4739
+ ...transaction.effects.warningsCleared,
4740
+ ...detachedWorkflowScopeWarnings.cleared,
4741
+ ],
4742
+ },
4743
+ });
4323
4744
  perfCounters.increment("commit.notify.us", Math.round((performance.now() - tNotify0) * 1000));
4324
4745
  perfCounters.increment("commit.total.us", Math.round((performance.now() - tApply0) * 1000));
4325
4746
  }
@@ -4948,10 +5369,18 @@ export function createDocumentRuntime(
4948
5369
  function applyRuntimeStateOverlayCommand(
4949
5370
  command: RuntimeStateOverlayCommand,
4950
5371
  ): void {
5372
+ let detachedWorkflowScopeWarnings:
5373
+ | {
5374
+ added: InternalEditorWarning[];
5375
+ cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }>;
5376
+ }
5377
+ | null = null;
4951
5378
  switch (command.type) {
4952
5379
  case "workflow.set-overlay": {
4953
5380
  workflowOverlay = structuredClone(command.overlay);
5381
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
4954
5382
  cachedNormalizedWorkflowOverlay = undefined;
5383
+ detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
4955
5384
  cachedRenderSnapshot = refreshRenderSnapshot();
4956
5385
  const snapshot = deriveWorkflowScopeSnapshot()!;
4957
5386
  emit({
@@ -4970,7 +5399,9 @@ export function createDocumentRuntime(
4970
5399
  }
4971
5400
  case "workflow.clear-overlay": {
4972
5401
  workflowOverlay = null;
5402
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
4973
5403
  cachedNormalizedWorkflowOverlay = undefined;
5404
+ detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
4974
5405
  cachedRenderSnapshot = refreshRenderSnapshot();
4975
5406
  emit({
4976
5407
  type: "workflow_active_work_item_changed",
@@ -5045,6 +5476,25 @@ export function createDocumentRuntime(
5045
5476
  break;
5046
5477
  }
5047
5478
  }
5479
+ if (detachedWorkflowScopeWarnings) {
5480
+ for (const warning of detachedWorkflowScopeWarnings.added) {
5481
+ const publicWarning = toPublicWarning(warning);
5482
+ emit({
5483
+ type: "warning_added",
5484
+ documentId: state.documentId,
5485
+ warning: publicWarning,
5486
+ });
5487
+ options.onWarning?.(publicWarning);
5488
+ }
5489
+ for (const cleared of detachedWorkflowScopeWarnings.cleared) {
5490
+ emit({
5491
+ type: "warning_cleared",
5492
+ documentId: state.documentId,
5493
+ warningId: cleared.warningId,
5494
+ code: cleared.code,
5495
+ });
5496
+ }
5497
+ }
5048
5498
  for (const listener of listeners) {
5049
5499
  listener();
5050
5500
  }
@@ -5533,11 +5983,42 @@ function toPublicCompatibilityFeatureEntry(
5533
5983
  }
5534
5984
 
5535
5985
  function toPublicWarning(warning: InternalEditorWarning): EditorWarning {
5986
+ const diagnostic =
5987
+ warning.diagnostic ??
5988
+ (() => {
5989
+ switch (warning.code) {
5990
+ case "unsupported_ooxml_preserved":
5991
+ case "unsupported_ooxml_locked":
5992
+ case "import_normalized":
5993
+ case "export_roundtrip_risk":
5994
+ case "comment_anchor_detached":
5995
+ case "revision_anchor_detached":
5996
+ case "workflow_scope_invalidated":
5997
+ case "large_document_degraded":
5998
+ case "font_substitution":
5999
+ case "image_missing":
6000
+ return buildDiagnosticFromLegacyWarningCode(warning.code, {
6001
+ diagnosticId: `warning-diag:${warning.warningId}`,
6002
+ emittedAt: new Date(0).toISOString(),
6003
+ technical: {
6004
+ message: warning.message,
6005
+ source: warning.source,
6006
+ },
6007
+ details: warning.details,
6008
+ affectedAnchor: warning.affectedAnchor
6009
+ ? toPublicAnchorProjection(warning.affectedAnchor)
6010
+ : undefined,
6011
+ });
6012
+ default:
6013
+ return undefined;
6014
+ }
6015
+ })();
5536
6016
  return {
5537
6017
  ...warning,
5538
6018
  affectedAnchor: warning.affectedAnchor
5539
6019
  ? toPublicAnchorProjection(warning.affectedAnchor)
5540
6020
  : undefined,
6021
+ diagnostic,
5541
6022
  };
5542
6023
  }
5543
6024
 
@@ -7143,3 +7624,271 @@ async function upgradeMeasurementProvider(
7143
7624
  // fall through — the empirical backend remains in place
7144
7625
  }
7145
7626
  }
7627
+
7628
+ function rangesOverlap(
7629
+ leftFrom: number,
7630
+ leftTo: number,
7631
+ rightFrom: number,
7632
+ rightTo: number,
7633
+ ): boolean {
7634
+ return leftFrom < rightTo && rightFrom < leftTo;
7635
+ }
7636
+
7637
+ function resolveClearHighlightRange(
7638
+ inputRange: EditorAnchorProjection | undefined,
7639
+ selection: import("../core/state/editor-state.ts").SelectionSnapshot,
7640
+ ): { from: number; to: number } | null {
7641
+ if (inputRange !== undefined) {
7642
+ if (inputRange.kind !== "range") return null;
7643
+ return { from: inputRange.from, to: inputRange.to };
7644
+ }
7645
+ const active = selection.activeRange;
7646
+ if (active.kind !== "range") return null;
7647
+ return { from: active.range.from, to: active.range.to };
7648
+ }
7649
+
7650
+ function expandRangeToHighlightExtent(
7651
+ blocks: readonly SurfaceBlockSnapshot[],
7652
+ inputFrom: number,
7653
+ inputTo: number,
7654
+ ): { from: number; to: number } {
7655
+ let from = inputFrom;
7656
+ let to = inputTo;
7657
+ forEachParagraphBlock(blocks, (paragraph) => {
7658
+ if (!rangesOverlap(from, to, paragraph.from, paragraph.to)) {
7659
+ return;
7660
+ }
7661
+ const textSegments = paragraph.segments.filter(
7662
+ (segment): segment is Extract<SurfaceInlineSegment, { kind: "text" }> =>
7663
+ segment.kind === "text",
7664
+ );
7665
+ const expanded = expandRangeWithinParagraph(textSegments, inputFrom, inputTo);
7666
+ if (expanded.from < from) from = expanded.from;
7667
+ if (expanded.to > to) to = expanded.to;
7668
+ });
7669
+ return { from, to };
7670
+ }
7671
+
7672
+ function expandRangeWithinParagraph(
7673
+ segments: Array<Extract<SurfaceInlineSegment, { kind: "text" }>>,
7674
+ inputFrom: number,
7675
+ inputTo: number,
7676
+ ): { from: number; to: number } {
7677
+ let from = inputFrom;
7678
+ let to = inputTo;
7679
+ let touchedLeftIndex = -1;
7680
+ let touchedRightIndex = -1;
7681
+ for (let i = 0; i < segments.length; i += 1) {
7682
+ const segment = segments[i]!;
7683
+ if (!isHighlightedSegment(segment)) continue;
7684
+ if (rangesOverlap(inputFrom, inputTo, segment.from, segment.to)) {
7685
+ if (touchedLeftIndex === -1) touchedLeftIndex = i;
7686
+ touchedRightIndex = i;
7687
+ }
7688
+ }
7689
+ if (touchedLeftIndex === -1) {
7690
+ return { from, to };
7691
+ }
7692
+ for (let i = touchedLeftIndex; i >= 0; i -= 1) {
7693
+ const segment = segments[i]!;
7694
+ if (!isHighlightedSegment(segment)) break;
7695
+ if (segment.to < from) break;
7696
+ if (segment.from < from) from = segment.from;
7697
+ }
7698
+ for (let i = touchedRightIndex; i < segments.length; i += 1) {
7699
+ const segment = segments[i]!;
7700
+ if (!isHighlightedSegment(segment)) break;
7701
+ if (segment.from > to) break;
7702
+ if (segment.to > to) to = segment.to;
7703
+ }
7704
+ return { from, to };
7705
+ }
7706
+
7707
+ function isHighlightedSegment(
7708
+ segment: Extract<SurfaceInlineSegment, { kind: "text" }>,
7709
+ ): boolean {
7710
+ const bg = segment.markAttrs?.backgroundColor;
7711
+ return typeof bg === "string" && bg.length > 0;
7712
+ }
7713
+
7714
+ function forEachParagraphBlock(
7715
+ blocks: readonly SurfaceBlockSnapshot[],
7716
+ visit: (paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>) => void,
7717
+ ): void {
7718
+ for (const block of blocks) {
7719
+ if (block.kind === "paragraph") {
7720
+ visit(block);
7721
+ continue;
7722
+ }
7723
+ if (block.kind === "table") {
7724
+ for (const row of block.rows) {
7725
+ for (const cell of row.cells) {
7726
+ forEachParagraphBlock(cell.content, visit);
7727
+ }
7728
+ }
7729
+ continue;
7730
+ }
7731
+ if (block.kind === "sdt_block") {
7732
+ forEachParagraphBlock(block.children, visit);
7733
+ }
7734
+ }
7735
+ }
7736
+
7737
+ function findSingleSelectedTextSegment(
7738
+ snapshot: Pick<RuntimeRenderSnapshot, "surface" | "selection">,
7739
+ ): Extract<SurfaceInlineSegment, { kind: "text" }> | null {
7740
+ if (
7741
+ !snapshot.surface ||
7742
+ snapshot.selection.activeRange.kind !== "range" ||
7743
+ snapshot.selection.isCollapsed
7744
+ ) {
7745
+ return null;
7746
+ }
7747
+ const selectionFrom = Math.min(snapshot.selection.anchor, snapshot.selection.head);
7748
+ const selectionTo = Math.max(snapshot.selection.anchor, snapshot.selection.head);
7749
+ const segments = collectSelectedTextSegments(
7750
+ snapshot.surface.blocks,
7751
+ selectionFrom,
7752
+ selectionTo,
7753
+ );
7754
+ if (segments.length !== 1) {
7755
+ return null;
7756
+ }
7757
+ const [segment] = segments;
7758
+ if (!segment || segment.from !== selectionFrom || segment.to !== selectionTo) {
7759
+ return null;
7760
+ }
7761
+ return segment;
7762
+ }
7763
+
7764
+ function collectSelectedTextSegments(
7765
+ blocks: readonly SurfaceBlockSnapshot[],
7766
+ selectionFrom: number,
7767
+ selectionTo: number,
7768
+ output: Array<Extract<SurfaceInlineSegment, { kind: "text" }>> = [],
7769
+ ): Array<Extract<SurfaceInlineSegment, { kind: "text" }>> {
7770
+ for (const block of blocks) {
7771
+ if (block.kind === "paragraph") {
7772
+ for (const segment of block.segments) {
7773
+ if (
7774
+ segment.kind === "text" &&
7775
+ rangesOverlap(selectionFrom, selectionTo, segment.from, segment.to)
7776
+ ) {
7777
+ output.push(segment);
7778
+ }
7779
+ }
7780
+ continue;
7781
+ }
7782
+ if (block.kind === "table") {
7783
+ for (const row of block.rows) {
7784
+ for (const cell of row.cells) {
7785
+ collectSelectedTextSegments(cell.content, selectionFrom, selectionTo, output);
7786
+ }
7787
+ }
7788
+ continue;
7789
+ }
7790
+ if (block.kind === "sdt_block") {
7791
+ collectSelectedTextSegments(block.children, selectionFrom, selectionTo, output);
7792
+ }
7793
+ }
7794
+ return output;
7795
+ }
7796
+
7797
+ function buildRunPropertyBeforeXml(
7798
+ segment: Extract<SurfaceInlineSegment, { kind: "text" }>,
7799
+ ): string {
7800
+ const parts: string[] = [];
7801
+ const marks = new Set(segment.marks ?? []);
7802
+ if (marks.has("bold")) parts.push("<w:b/>");
7803
+ if (marks.has("italic")) parts.push("<w:i/>");
7804
+ if (marks.has("underline")) parts.push("<w:u w:val=\"single\"/>");
7805
+ if (marks.has("strikethrough")) parts.push("<w:strike/>");
7806
+ if (marks.has("superscript")) parts.push("<w:vertAlign w:val=\"superscript\"/>");
7807
+ if (marks.has("subscript")) parts.push("<w:vertAlign w:val=\"subscript\"/>");
7808
+ if (segment.markAttrs?.fontFamily) {
7809
+ parts.push(
7810
+ `<w:rFonts w:ascii="${escapeAttributeXml(segment.markAttrs.fontFamily)}" w:hAnsi="${escapeAttributeXml(segment.markAttrs.fontFamily)}"/>`,
7811
+ );
7812
+ }
7813
+ if (segment.markAttrs?.fontSize !== undefined) {
7814
+ parts.push(`<w:sz w:val="${segment.markAttrs.fontSize}"/>`);
7815
+ }
7816
+ if (segment.markAttrs?.textColor) {
7817
+ parts.push(`<w:color w:val="${escapeAttributeXml(segment.markAttrs.textColor)}"/>`);
7818
+ }
7819
+ if (segment.markAttrs?.backgroundColor) {
7820
+ parts.push(
7821
+ `<w:shd w:val="clear" w:color="auto" w:fill="${escapeAttributeXml(segment.markAttrs.backgroundColor)}"/>`,
7822
+ );
7823
+ }
7824
+ return `<w:rPr>${parts.join("")}</w:rPr>`;
7825
+ }
7826
+
7827
+ function escapeAttributeXml(value: string): string {
7828
+ return value
7829
+ .replace(/&/g, "&amp;")
7830
+ .replace(/</g, "&lt;")
7831
+ .replace(/>/g, "&gt;")
7832
+ .replace(/"/g, "&quot;");
7833
+ }
7834
+
7835
+ function appendPropertyChangeSuggestion(
7836
+ document: CanonicalDocumentEnvelope,
7837
+ anchor: { from: number; to: number },
7838
+ input: {
7839
+ originalRevisionType: "rPrChange" | "pPrChange";
7840
+ xmlTag: "rPrChange" | "pPrChange";
7841
+ beforeXml: string;
7842
+ semanticKind: "formatting-change" | "paragraph-property-change";
7843
+ storyTarget: EditorStoryTarget;
7844
+ authorId?: string;
7845
+ },
7846
+ timestamp: string,
7847
+ ): CanonicalDocumentEnvelope {
7848
+ const existing = document.review.revisions;
7849
+ const changeId = createRuntimeSuggestionChangeId(existing, timestamp);
7850
+ const resolvedAuthorId = input.authorId ?? "unknown";
7851
+ return {
7852
+ ...document,
7853
+ review: {
7854
+ ...document.review,
7855
+ revisions: {
7856
+ ...existing,
7857
+ [changeId]: {
7858
+ changeId,
7859
+ kind: "property-change",
7860
+ anchor: createRangeAnchor(anchor.from, anchor.to, { start: 1, end: -1 }),
7861
+ authorId: resolvedAuthorId,
7862
+ createdAt: timestamp,
7863
+ warningIds: [],
7864
+ metadata: {
7865
+ source: "runtime",
7866
+ storyTarget: input.storyTarget,
7867
+ suggestionId: changeId,
7868
+ semanticKind: input.semanticKind,
7869
+ originalRevisionType: input.originalRevisionType,
7870
+ propertyChangeData: {
7871
+ xmlTag: input.xmlTag,
7872
+ beforeXml: input.beforeXml,
7873
+ },
7874
+ },
7875
+ status: "open",
7876
+ },
7877
+ },
7878
+ },
7879
+ };
7880
+ }
7881
+
7882
+ function createRuntimeSuggestionChangeId(
7883
+ existing: CanonicalDocumentEnvelope["review"]["revisions"],
7884
+ timestamp: string,
7885
+ ): string {
7886
+ const base = `change-${timestamp.replace(/[^0-9]/gu, "")}`;
7887
+ let counter = Object.keys(existing).length + 1;
7888
+ let candidate = `${base}-p${counter}`;
7889
+ while (existing[candidate]) {
7890
+ counter += 1;
7891
+ candidate = `${base}-p${counter}`;
7892
+ }
7893
+ return candidate;
7894
+ }