@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  createEditorState,
3
- createSelectionSnapshot,
4
3
  createPersistedEditorSnapshot,
5
4
  deriveDocumentStats,
5
+ createSelectionSnapshot,
6
6
  type CanonicalDocumentEnvelope,
7
7
  type CommentEntryRecord,
8
8
  type CommentThreadRecord,
@@ -17,18 +17,47 @@ import type {
17
17
  CommentSidebarSnapshot,
18
18
  CommentSidebarThreadSnapshot,
19
19
  CompatibilityReport,
20
+ DocumentMode,
21
+ DocumentNavigationSnapshot,
22
+ EditorSessionState,
20
23
  EditorAnchorProjection,
21
24
  EditorError,
25
+ EditorStoryTarget,
26
+ EditorViewStateSnapshot,
22
27
  EditorWarning,
28
+ FieldEntrySnapshot,
29
+ FieldSnapshot,
30
+ HeaderFooterLinkPatch,
23
31
  ExportDocxOptions,
24
32
  ExportResult,
33
+ InteractionGuardSnapshot,
34
+ PageLayoutSnapshot,
25
35
  PersistedEditorSnapshot,
36
+ ProtectionSnapshot,
26
37
  RuntimeRenderSnapshot,
27
38
  SelectionSnapshot,
39
+ StyleCatalogSnapshot,
40
+ TocRefreshOptions,
41
+ TocRefreshResult,
28
42
  TrackedChangeEntrySnapshot,
29
43
  TrackedChangesSnapshot,
44
+ UpdateFieldsOptions,
45
+ UpdateFieldsResult,
46
+ ViewMode,
47
+ WorkflowCandidateRange,
48
+ WorkflowCandidateRangeOptions,
49
+ WorkflowBlockedCommandReason,
50
+ WorkflowMarkupSnapshot,
51
+ WorkflowOverlay,
52
+ WorkflowScopeSnapshot,
53
+ WorkspaceMode,
30
54
  WordReviewEditorEvent,
55
+ ZoomLevel,
31
56
  } from "../api/public-types";
57
+ import {
58
+ editorSessionStateFromPersistedSnapshot,
59
+ persistedSnapshotFromEditorSessionState,
60
+ } from "../api/session-state.ts";
32
61
  import {
33
62
  executeEditorCommand,
34
63
  selectionChanged,
@@ -39,11 +68,20 @@ import {
39
68
  import { insertText } from "../core/commands/text-commands.ts";
40
69
  import {
41
70
  createDetachedAnchor,
71
+ createEmptyMapping,
42
72
  createNodeAnchor,
43
73
  createRangeAnchor,
74
+ mapRange,
75
+ MAIN_STORY_TARGET,
76
+ storyTargetsEqual,
44
77
  type EditorAnchorProjection as InternalEditorAnchorProjection,
45
78
  } from "../core/selection/mapping.ts";
46
79
  import { canCreateDocxCommentAnchor } from "../core/selection/review-anchors.ts";
80
+ import { buildBookmarkNameMap } from "../legal/bookmarks.ts";
81
+ import {
82
+ describeOpaqueFragment,
83
+ findOpaqueFragmentsIntersectingRange,
84
+ } from "../preservation/store.ts";
47
85
  import { createCommentSidebarProjection } from "../review/store/comment-store.ts";
48
86
  import { createCommentStoreFromRuntimeComments } from "../review/store/runtime-comment-store.ts";
49
87
  import {
@@ -53,21 +91,75 @@ import {
53
91
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
54
92
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
55
93
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
56
- import { getFormattingStateFromRenderSnapshot } from "../core/commands/formatting-commands.ts";
94
+ import {
95
+ collectWorkflowMarkupSnapshot,
96
+ deriveWorkflowCandidateRangesFromMarkup,
97
+ } from "./workflow-markup.ts";
98
+ import {
99
+ createDocumentNavigationSnapshot,
100
+ findPageForOffset,
101
+ } from "./document-navigation.ts";
102
+ import {
103
+ buildPageLayoutSnapshot,
104
+ buildResolvedSections,
105
+ resolveActiveSection,
106
+ } from "./document-layout.ts";
107
+ import { normalizeHeaderFooterTarget } from "./story-context.ts";
108
+ import { storyTargetKey } from "./story-targeting.ts";
109
+ import {
110
+ createViewState,
111
+ setViewMode as applyViewMode,
112
+ setDocumentMode as applyDocumentMode,
113
+ setWorkspaceMode as applyWorkspaceMode,
114
+ setZoomLevel as applyZoomLevel,
115
+ setFocused as applyFocused,
116
+ setCaretAffinity as applyCaretAffinity,
117
+ setActivePageRegion as applyActivePageRegion,
118
+ setActiveObjectFrame as applyActiveObjectFrame,
119
+ createEditorViewStateSnapshot,
120
+ type ViewState,
121
+ } from "./view-state.ts";
122
+ import type {
123
+ BlockNode,
124
+ FieldNode,
125
+ FieldRefreshStatus,
126
+ InlineNode,
127
+ PageMargins,
128
+ ParagraphNode,
129
+ SubPartsCatalog,
130
+ } from "../model/canonical-document.ts";
131
+ import {
132
+ buildFieldRegistry,
133
+ isSupportedFieldFamily,
134
+ parseTocLevelRange,
135
+ resolveRefFieldText,
136
+ } from "../io/ooxml/parse-fields.ts";
137
+ import {
138
+ incrementInvalidationCounter,
139
+ recordPerfSample,
140
+ } from "../ui-tailwind/editor-surface/perf-probe.ts";
57
141
 
58
142
  export type Unsubscribe = () => void;
59
143
 
144
+ type RuntimeReadySource = "docx" | "session" | "snapshot" | "datastore" | "canonical";
145
+
146
+ export type DocumentRuntimeEvent =
147
+ | (Omit<Extract<WordReviewEditorEvent, { type: "ready" }>, "source"> & {
148
+ source: RuntimeReadySource;
149
+ })
150
+ | Exclude<WordReviewEditorEvent, { type: "ready" }>;
151
+
60
152
  export interface DocumentRuntime {
61
153
  subscribe(listener: () => void): Unsubscribe;
62
- subscribeToEvents(listener: (event: WordReviewEditorEvent) => void): Unsubscribe;
154
+ subscribeToEvents(listener: (event: DocumentRuntimeEvent) => void): Unsubscribe;
63
155
  getRenderSnapshot(): RuntimeRenderSnapshot;
64
- getFormattingState(): import("../api/public-types").FormattingStateSnapshot;
156
+ replaceText(text: string, target?: EditorAnchorProjection): void;
65
157
  dispatch(command: EditorCommand): void;
66
158
  undo(): void;
67
159
  redo(): void;
68
160
  focus(): void;
69
161
  blur(): void;
70
- replaceText(text: string, target?: EditorAnchorProjection): void;
162
+ setDefaultAuthorId?(authorId?: string): void;
71
163
  addComment(params: AddCommentParams): string;
72
164
  openComment(commentId: string): void;
73
165
  resolveComment(commentId: string): void;
@@ -78,30 +170,55 @@ export interface DocumentRuntime {
78
170
  rejectChange(changeId: string): void;
79
171
  acceptAllChanges(): void;
80
172
  rejectAllChanges(): void;
173
+ openStory(target: EditorStoryTarget): boolean;
174
+ closeStory(): void;
175
+ getActiveStory(): EditorStoryTarget;
176
+ getViewState(): EditorViewStateSnapshot;
177
+ setViewMode(mode: ViewMode): void;
178
+ setDocumentMode(mode: DocumentMode): void;
179
+ getProtectionSnapshot(): ProtectionSnapshot;
180
+ setWorkspaceMode(mode: WorkspaceMode): void;
181
+ setZoom(level: ZoomLevel): void;
182
+ getPageLayoutSnapshot(): PageLayoutSnapshot | null;
183
+ getDocumentNavigationSnapshot(): DocumentNavigationSnapshot;
184
+ getFieldSnapshot(): FieldSnapshot;
185
+ updateFields(options?: UpdateFieldsOptions): UpdateFieldsResult;
186
+ updateTableOfContents(options?: TocRefreshOptions): TocRefreshResult;
187
+ getSessionState(): EditorSessionState;
81
188
  getPersistedSnapshot(): PersistedEditorSnapshot;
82
189
  getCompatibilityReport(): CompatibilityReport;
83
190
  getWarnings(): EditorWarning[];
84
191
  exportDocx(options?: ExportDocxOptions): Promise<ExportResult>;
192
+ setWorkflowOverlay(overlay: WorkflowOverlay): void;
193
+ clearWorkflowOverlay(): void;
194
+ getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
195
+ getInteractionGuardSnapshot(): InteractionGuardSnapshot;
196
+ getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
197
+ getWorkflowCandidateRanges(options?: WorkflowCandidateRangeOptions): WorkflowCandidateRange[];
198
+ replaceWorkflowMarkupText(markupId: string, text: string): void;
85
199
  }
86
200
 
87
201
  export interface CreateDocumentRuntimeOptions {
88
202
  documentId: string;
203
+ initialSessionState?: EditorSessionState;
89
204
  initialSnapshot?: PersistedEditorSnapshot;
90
205
  initialCanonicalDocument?: CanonicalDocumentEnvelope;
91
206
  sourceLabel?: string;
92
- sourceKind?: "docx" | "snapshot" | "datastore" | "canonical";
207
+ sourceKind?: RuntimeReadySource;
93
208
  readOnly?: boolean;
94
209
  editorBuild?: string;
95
210
  defaultAuthorId?: string;
96
211
  fatalError?: EditorError;
97
212
  clock?: () => string;
98
213
  exportDocx?: (
99
- snapshot: PersistedEditorSnapshot,
214
+ sessionState: EditorSessionState,
100
215
  options?: ExportDocxOptions,
101
216
  ) => Promise<ExportResult>;
102
- onEvent?: (event: WordReviewEditorEvent) => void;
217
+ onEvent?: (event: DocumentRuntimeEvent) => void;
103
218
  onWarning?: (warning: EditorWarning) => void;
104
219
  onError?: (error: EditorError) => void;
220
+ initialViewState?: Partial<ViewState>;
221
+ protectionSnapshot?: ProtectionSnapshot;
105
222
  }
106
223
 
107
224
  interface HistoryState {
@@ -114,30 +231,624 @@ export function createDocumentRuntime(
114
231
  ): DocumentRuntime {
115
232
  const clock = options.clock ?? (() => new Date().toISOString());
116
233
  const editorBuild = options.editorBuild ?? "dev";
234
+ let defaultAuthorId = options.defaultAuthorId;
117
235
  const sessionId = createSessionId(options.documentId, clock());
118
236
  const listeners = new Set<() => void>();
119
- const eventListeners = new Set<(event: WordReviewEditorEvent) => void>();
237
+ const eventListeners = new Set<(event: DocumentRuntimeEvent) => void>();
120
238
  const history: HistoryState = {
121
239
  past: [],
122
240
  future: [],
123
241
  };
124
242
 
243
+ let activeStory: EditorStoryTarget = MAIN_STORY_TARGET;
244
+ const storySelections = new Map<string, EditorState["selection"]>();
245
+ let viewState: ViewState = createViewState(options.initialViewState);
246
+ let protectionSnapshot: ProtectionSnapshot =
247
+ options.protectionSnapshot ??
248
+ options.initialSessionState?.protectionSnapshot ??
249
+ options.initialSnapshot?.protectionSnapshot ?? {
250
+ hasDocumentProtection: false,
251
+ enforcementActive: false,
252
+ ranges: [],
253
+ enforcedRangeCount: 0,
254
+ preservedRangeCount: 0,
255
+ };
256
+ let workflowOverlay: WorkflowOverlay | null = null;
257
+ const initialPersistedSnapshot = options.initialSessionState
258
+ ? persistedSnapshotFromEditorSessionState(options.initialSessionState, {
259
+ savedAt: options.initialSessionState.updatedAt,
260
+ })
261
+ : options.initialSnapshot;
262
+
125
263
  let state = createEditorState({
126
264
  documentId: options.documentId,
127
265
  sessionId,
128
266
  sourceLabel: options.sourceLabel,
129
267
  readOnly: options.readOnly,
130
- persistedSnapshot: options.initialSnapshot as never,
268
+ persistedSnapshot: initialPersistedSnapshot as never,
131
269
  canonicalDocument: options.initialCanonicalDocument,
132
270
  fatalError: options.fatalError as never,
133
271
  });
134
- let cachedRenderSnapshot = createPublicRenderSnapshot(state, history);
272
+ storySelections.set(storyTargetKey(MAIN_STORY_TARGET), state.selection);
273
+ let cachedSurface:
274
+ | {
275
+ revisionToken: string;
276
+ activeStoryKey: string;
277
+ snapshot: RuntimeRenderSnapshot["surface"];
278
+ }
279
+ | undefined;
280
+ let cachedCompatibility:
281
+ | {
282
+ revisionToken: string;
283
+ warnings: EditorState["warnings"];
284
+ fatalError: EditorState["fatalError"];
285
+ report: RuntimeRenderSnapshot["compatibility"];
286
+ }
287
+ | undefined;
288
+ let cachedComments:
289
+ | {
290
+ comments: CanonicalDocumentEnvelope["review"]["comments"];
291
+ activeCommentId: EditorState["runtime"]["activeCommentId"];
292
+ snapshot: CommentSidebarSnapshot;
293
+ }
294
+ | undefined;
295
+ let cachedTrackedChanges:
296
+ | {
297
+ revisions: CanonicalDocumentEnvelope["review"]["revisions"];
298
+ plainText: string;
299
+ snapshot: TrackedChangesSnapshot;
300
+ }
301
+ | undefined;
302
+ let cachedPageLayout:
303
+ | {
304
+ revisionToken: string;
305
+ activeStoryKey: string;
306
+ activeSectionIndex: number | string;
307
+ snapshot: PageLayoutSnapshot | null;
308
+ }
309
+ | undefined;
310
+ let cachedNavigation:
311
+ | {
312
+ revisionToken: string;
313
+ activeStoryKey: string;
314
+ selectionHead: number;
315
+ snapshot: DocumentNavigationSnapshot;
316
+ }
317
+ | undefined;
318
+ let cachedViewStateSnapshot:
319
+ | {
320
+ revisionToken: string;
321
+ activeStoryKey: string;
322
+ selection: EditorState["selection"];
323
+ viewStateRef: ViewState;
324
+ pageLayout: PageLayoutSnapshot | null | undefined;
325
+ snapshot: EditorViewStateSnapshot;
326
+ }
327
+ | undefined;
328
+ let cachedInteractionGuardSnapshot:
329
+ | {
330
+ revisionToken: string;
331
+ activeStoryKey: string;
332
+ selection: EditorState["selection"];
333
+ readOnly: boolean;
334
+ documentMode: DocumentMode;
335
+ protectionSnapshot: ProtectionSnapshot;
336
+ workflowOverlay: WorkflowOverlay | null;
337
+ snapshot: InteractionGuardSnapshot;
338
+ }
339
+ | undefined;
340
+ let cachedWorkflowScopeSnapshot:
341
+ | {
342
+ workflowOverlay: WorkflowOverlay;
343
+ interactionGuardSnapshot: InteractionGuardSnapshot;
344
+ snapshot: WorkflowScopeSnapshot;
345
+ }
346
+ | undefined;
347
+
348
+ function getCachedSurface(
349
+ document: CanonicalDocumentEnvelope,
350
+ nextActiveStory: EditorStoryTarget,
351
+ ): RuntimeRenderSnapshot["surface"] {
352
+ const activeStoryKey = storyTargetKey(nextActiveStory);
353
+ if (
354
+ cachedSurface &&
355
+ cachedSurface.revisionToken === state.revisionToken &&
356
+ cachedSurface.activeStoryKey === activeStoryKey
357
+ ) {
358
+ return cachedSurface.snapshot;
359
+ }
360
+
361
+ const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory);
362
+ recordPerfSample("snapshot.surface");
363
+ incrementInvalidationCounter("runtime.snapshot.surfaceMisses");
364
+ cachedSurface = {
365
+ revisionToken: state.revisionToken,
366
+ activeStoryKey,
367
+ snapshot,
368
+ };
369
+ return snapshot;
370
+ }
371
+
372
+ function getCachedCompatibilityReport(
373
+ nextState: EditorState,
374
+ ): RuntimeRenderSnapshot["compatibility"] {
375
+ if (
376
+ cachedCompatibility &&
377
+ cachedCompatibility.revisionToken === nextState.revisionToken &&
378
+ cachedCompatibility.warnings === nextState.warnings &&
379
+ cachedCompatibility.fatalError === nextState.fatalError
380
+ ) {
381
+ return cachedCompatibility.report;
382
+ }
383
+
384
+ const derived = createDerivedCompatibility(nextState);
385
+ recordPerfSample("snapshot.compatibility");
386
+ incrementInvalidationCounter("runtime.snapshot.compatibilityMisses");
387
+ const report = {
388
+ blockExport: derived.blockExport,
389
+ blockExportReasons: listBlockExportReasons(derived),
390
+ warningCount: derived.warnings.length,
391
+ errorCount: derived.errors.length,
392
+ featureEntries: derived.featureEntries.map((entry) =>
393
+ toPublicCompatibilityFeatureEntry(entry),
394
+ ),
395
+ };
396
+ cachedCompatibility = {
397
+ revisionToken: nextState.revisionToken,
398
+ warnings: nextState.warnings,
399
+ fatalError: nextState.fatalError,
400
+ report,
401
+ };
402
+ return report;
403
+ }
404
+
405
+ function getCachedCommentSidebarSnapshot(nextState: EditorState): CommentSidebarSnapshot {
406
+ if (
407
+ cachedComments &&
408
+ cachedComments.comments === nextState.document.review.comments &&
409
+ cachedComments.activeCommentId === nextState.runtime.activeCommentId
410
+ ) {
411
+ return cachedComments.snapshot;
412
+ }
413
+
414
+ const snapshot = toPublicCommentSidebarSnapshot(nextState);
415
+ cachedComments = {
416
+ comments: nextState.document.review.comments,
417
+ activeCommentId: nextState.runtime.activeCommentId,
418
+ snapshot,
419
+ };
420
+ return snapshot;
421
+ }
422
+
423
+ function getCachedTrackedChangesSnapshot(
424
+ nextState: EditorState,
425
+ surface: RuntimeRenderSnapshot["surface"],
426
+ ): TrackedChangesSnapshot {
427
+ const plainText = surface?.plainText ?? "";
428
+ if (
429
+ cachedTrackedChanges &&
430
+ cachedTrackedChanges.revisions === nextState.document.review.revisions &&
431
+ cachedTrackedChanges.plainText === plainText
432
+ ) {
433
+ return cachedTrackedChanges.snapshot;
434
+ }
435
+
436
+ const snapshot = toPublicTrackedChangesSnapshot(nextState, plainText);
437
+ cachedTrackedChanges = {
438
+ revisions: nextState.document.review.revisions,
439
+ plainText,
440
+ snapshot,
441
+ };
442
+ return snapshot;
443
+ }
444
+
445
+ function getCachedDocumentNavigationSnapshot(
446
+ nextState: EditorState,
447
+ nextActiveStory: EditorStoryTarget,
448
+ ): DocumentNavigationSnapshot {
449
+ const activeStoryKey = storyTargetKey(nextActiveStory);
450
+ if (
451
+ cachedNavigation &&
452
+ cachedNavigation.revisionToken === nextState.revisionToken &&
453
+ cachedNavigation.activeStoryKey === activeStoryKey
454
+ ) {
455
+ if (cachedNavigation.selectionHead === nextState.selection.head) {
456
+ return cachedNavigation.snapshot;
457
+ }
458
+
459
+ const snapshot = createDocumentNavigationSnapshot(
460
+ nextState.document,
461
+ nextState.selection.head,
462
+ nextActiveStory,
463
+ );
464
+ if (
465
+ snapshot.activePageIndex === cachedNavigation.snapshot.activePageIndex &&
466
+ snapshot.activeSectionIndex === cachedNavigation.snapshot.activeSectionIndex
467
+ ) {
468
+ cachedNavigation = {
469
+ revisionToken: nextState.revisionToken,
470
+ activeStoryKey,
471
+ selectionHead: nextState.selection.head,
472
+ snapshot: cachedNavigation.snapshot,
473
+ };
474
+ return cachedNavigation.snapshot;
475
+ }
476
+ cachedNavigation = {
477
+ revisionToken: nextState.revisionToken,
478
+ activeStoryKey,
479
+ selectionHead: nextState.selection.head,
480
+ snapshot,
481
+ };
482
+ return snapshot;
483
+ }
484
+
485
+ const snapshot = createDocumentNavigationSnapshot(
486
+ nextState.document,
487
+ nextState.selection.head,
488
+ nextActiveStory,
489
+ );
490
+ recordPerfSample("snapshot.navigation");
491
+ incrementInvalidationCounter("runtime.snapshot.navigationMisses");
492
+ cachedNavigation = {
493
+ revisionToken: nextState.revisionToken,
494
+ activeStoryKey,
495
+ selectionHead: nextState.selection.head,
496
+ snapshot,
497
+ };
498
+ return snapshot;
499
+ }
500
+
501
+ function resolvePageLayoutActiveSectionIndex(
502
+ nextState: EditorState,
503
+ nextActiveStory: EditorStoryTarget,
504
+ ): number | string {
505
+ if (nextActiveStory.kind === "main") {
506
+ return getCachedDocumentNavigationSnapshot(nextState, nextActiveStory).activeSectionIndex;
507
+ }
508
+
509
+ if ("sectionIndex" in nextActiveStory && typeof nextActiveStory.sectionIndex === "number") {
510
+ return nextActiveStory.sectionIndex;
511
+ }
512
+
513
+ return storyTargetKey(nextActiveStory);
514
+ }
515
+
516
+ function getCachedPageLayoutSnapshot(
517
+ nextState: EditorState,
518
+ nextActiveStory: EditorStoryTarget,
519
+ ): PageLayoutSnapshot | null {
520
+ const activeStoryKey = storyTargetKey(nextActiveStory);
521
+ const activeSectionIndex = resolvePageLayoutActiveSectionIndex(
522
+ nextState,
523
+ nextActiveStory,
524
+ );
525
+ if (
526
+ cachedPageLayout &&
527
+ cachedPageLayout.revisionToken === nextState.revisionToken &&
528
+ cachedPageLayout.activeStoryKey === activeStoryKey &&
529
+ cachedPageLayout.activeSectionIndex === activeSectionIndex
530
+ ) {
531
+ return cachedPageLayout.snapshot;
532
+ }
533
+
534
+ const snapshot = derivePageLayoutSnapshot(nextState, nextActiveStory, storySelections);
535
+ cachedPageLayout = {
536
+ revisionToken: nextState.revisionToken,
537
+ activeStoryKey,
538
+ activeSectionIndex,
539
+ snapshot,
540
+ };
541
+ return snapshot;
542
+ }
543
+
544
+ function evaluateWorkflowBlockedReasons(
545
+ selection: EditorState["selection"],
546
+ commandType?: string,
547
+ ): WorkflowBlockedCommandReason[] {
548
+ const reasons: WorkflowBlockedCommandReason[] = [];
549
+ const selectionBounds = {
550
+ from: Math.min(selection.anchor, selection.head),
551
+ to: Math.max(selection.anchor, selection.head),
552
+ };
553
+ const selectionRange = expandSelectionRange(selectionBounds);
554
+ const opaqueReason = deriveOpaqueWorkflowBlockedReason(selectionRange);
555
+
556
+ if (opaqueReason) {
557
+ reasons.push(opaqueReason);
558
+ }
559
+
560
+ if (state.readOnly) {
561
+ reasons.push({
562
+ code: "document_read_only",
563
+ message: "Document is in read-only mode.",
564
+ });
565
+ }
566
+
567
+ if (viewState.documentMode === "viewing") {
568
+ reasons.push({
569
+ code: "document_viewing_mode",
570
+ message: "Document is in viewing mode.",
571
+ });
572
+ }
573
+
574
+ if (
575
+ isBlockedByProtection(protectionSnapshot, selection)
576
+ ) {
577
+ reasons.push({
578
+ code: "protected_range",
579
+ message: "Selection falls within a protected range.",
580
+ });
581
+ }
582
+
583
+ if (workflowOverlay) {
584
+ const activeScopes = getEffectiveWorkflowScopes(workflowOverlay);
585
+ const matchingScope = activeScopes.find((scope) => {
586
+ if (scope.anchor.kind === "detached") return false;
587
+ const scopeFrom = scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
588
+ const scopeTo = scope.anchor.kind === "range" ? scope.anchor.to : scope.anchor.at;
589
+ return selectionBounds.from >= scopeFrom && selectionBounds.to <= scopeTo;
590
+ });
591
+
592
+ if (!matchingScope && workflowOverlay.scopes.length > 0) {
593
+ reasons.push({
594
+ code: "outside_workflow_scope",
595
+ message: "Selection is outside any active workflow scope.",
596
+ });
597
+ } else if (matchingScope) {
598
+ if (matchingScope.mode === "comment") {
599
+ const isCommentCommand =
600
+ commandType?.startsWith("comment.") ?? false;
601
+ if (!isCommentCommand) {
602
+ reasons.push({
603
+ code: "workflow_comment_only",
604
+ message: `Scope "${matchingScope.label ?? matchingScope.scopeId}" allows comments only.`,
605
+ scopeId: matchingScope.scopeId,
606
+ workItemId: matchingScope.workItemId,
607
+ });
608
+ }
609
+ } else if (matchingScope.mode === "view") {
610
+ reasons.push({
611
+ code: "workflow_view_only",
612
+ message: `Scope "${matchingScope.label ?? matchingScope.scopeId}" is view-only.`,
613
+ scopeId: matchingScope.scopeId,
614
+ workItemId: matchingScope.workItemId,
615
+ });
616
+ }
617
+ }
618
+ }
619
+
620
+ return reasons;
621
+ }
622
+
623
+ function expandSelectionRange(
624
+ range: { from: number; to: number },
625
+ ): { from: number; to: number } {
626
+ return {
627
+ from: range.from,
628
+ to: range.to > range.from ? range.to : range.from + 1,
629
+ };
630
+ }
631
+
632
+ function deriveOpaqueWorkflowBlockedReason(
633
+ range: { from: number; to: number },
634
+ ): WorkflowBlockedCommandReason | null {
635
+ const fragments = findOpaqueFragmentsIntersectingRange(
636
+ state.document.preservation,
637
+ range,
638
+ );
639
+
640
+ if (fragments.length === 0) {
641
+ return null;
642
+ }
643
+
644
+ const blockedImportFeatureKeys = new Set([
645
+ "alt-chunk",
646
+ "alternate-content",
647
+ "custom-xml",
648
+ ]);
649
+ const blockedImportFragment =
650
+ fragments.find((fragment) =>
651
+ blockedImportFeatureKeys.has(describeOpaqueFragment(fragment).featureKey),
652
+ ) ?? null;
653
+ const fragment = blockedImportFragment ?? fragments[0]!;
654
+ const descriptor = describeOpaqueFragment(fragment);
655
+ const isBlockedImport = blockedImportFragment !== null;
656
+
657
+ return {
658
+ code: isBlockedImport ? "workflow_blocked_import" : "workflow_preserve_only",
659
+ message: isBlockedImport
660
+ ? `${descriptor.label} remains a blocked import and cannot be edited.`
661
+ : `${descriptor.label} remains preserve-only and cannot be edited.`,
662
+ anchor: toPublicAnchorProjection(
663
+ createRangeAnchor(fragment.lastKnownRange.from, fragment.lastKnownRange.to, {
664
+ start: -1,
665
+ end: 1,
666
+ }),
667
+ ),
668
+ storyTarget: activeStory,
669
+ };
670
+ }
671
+
672
+ function deriveWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
673
+ if (!workflowOverlay) return null;
674
+ const blockedReasons = getCachedInteractionGuardSnapshot().blockedReasons;
675
+ const activeItem = workflowOverlay.activeWorkItemId
676
+ ? workflowOverlay.workItems?.find(
677
+ (item) => item.workItemId === workflowOverlay!.activeWorkItemId,
678
+ )
679
+ : undefined;
680
+ return {
681
+ overlayPresent: true,
682
+ activeWorkItemId: workflowOverlay.activeWorkItemId ?? null,
683
+ activeWorkItem: activeItem,
684
+ scopes: workflowOverlay.scopes,
685
+ candidates: workflowOverlay.candidates ?? [],
686
+ blockedReasons,
687
+ };
688
+ }
689
+
690
+ function getEffectiveWorkflowScopes(overlay: WorkflowOverlay): WorkflowOverlay["scopes"] {
691
+ const activeWorkItemId = overlay.activeWorkItemId ?? null;
692
+ const activeWorkItemScopeIds =
693
+ activeWorkItemId === null
694
+ ? null
695
+ : new Set(
696
+ overlay.workItems?.find((item) => item.workItemId === activeWorkItemId)?.scopeIds ?? [],
697
+ );
698
+
699
+ return overlay.scopes.filter((scope) => {
700
+ const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
701
+ if (!storyTargetsEqual(scopeStoryTarget, activeStory)) {
702
+ return false;
703
+ }
704
+
705
+ if (activeWorkItemId === null) {
706
+ return true;
707
+ }
708
+
709
+ return (
710
+ scope.workItemId === activeWorkItemId ||
711
+ activeWorkItemScopeIds?.has(scope.scopeId) === true
712
+ );
713
+ });
714
+ }
715
+
716
+ function getCachedInteractionGuardSnapshot(): InteractionGuardSnapshot {
717
+ const activeStoryKey = storyTargetKey(activeStory);
718
+ if (
719
+ cachedInteractionGuardSnapshot &&
720
+ cachedInteractionGuardSnapshot.revisionToken === state.revisionToken &&
721
+ cachedInteractionGuardSnapshot.activeStoryKey === activeStoryKey &&
722
+ cachedInteractionGuardSnapshot.selection === state.selection &&
723
+ cachedInteractionGuardSnapshot.readOnly === state.readOnly &&
724
+ cachedInteractionGuardSnapshot.documentMode === viewState.documentMode &&
725
+ cachedInteractionGuardSnapshot.protectionSnapshot === protectionSnapshot &&
726
+ cachedInteractionGuardSnapshot.workflowOverlay === workflowOverlay
727
+ ) {
728
+ return cachedInteractionGuardSnapshot.snapshot;
729
+ }
730
+
731
+ const snapshot = {
732
+ blockedReasons: evaluateWorkflowBlockedReasons(state.selection),
733
+ };
734
+ cachedInteractionGuardSnapshot = {
735
+ revisionToken: state.revisionToken,
736
+ activeStoryKey,
737
+ selection: state.selection,
738
+ readOnly: state.readOnly,
739
+ documentMode: viewState.documentMode,
740
+ protectionSnapshot,
741
+ workflowOverlay,
742
+ snapshot,
743
+ };
744
+ return snapshot;
745
+ }
746
+
747
+ function getCachedWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
748
+ if (!workflowOverlay) {
749
+ return null;
750
+ }
751
+
752
+ const interactionGuardSnapshot = getCachedInteractionGuardSnapshot();
753
+ if (
754
+ cachedWorkflowScopeSnapshot &&
755
+ cachedWorkflowScopeSnapshot.workflowOverlay === workflowOverlay &&
756
+ cachedWorkflowScopeSnapshot.interactionGuardSnapshot === interactionGuardSnapshot
757
+ ) {
758
+ return cachedWorkflowScopeSnapshot.snapshot;
759
+ }
760
+
761
+ const snapshot = deriveWorkflowScopeSnapshot()!;
762
+ cachedWorkflowScopeSnapshot = {
763
+ workflowOverlay,
764
+ interactionGuardSnapshot,
765
+ snapshot,
766
+ };
767
+ return snapshot;
768
+ }
769
+
770
+ function refreshRenderSnapshot(): RuntimeRenderSnapshot {
771
+ const surface = getCachedSurface(state.document, activeStory);
772
+ return {
773
+ documentId: state.documentId,
774
+ sessionId: state.sessionId,
775
+ sourceLabel: state.sourceLabel,
776
+ revisionToken: state.revisionToken,
777
+ isReady: state.phase === "ready",
778
+ isDirty: state.isDirty,
779
+ readOnly: state.readOnly,
780
+ documentMode: viewState.documentMode,
781
+ selection: toPublicSelectionSnapshot(state.selection, activeStory),
782
+ activeStory,
783
+ pageLayout: getCachedPageLayoutSnapshot(state, activeStory) ?? undefined,
784
+ documentStats: toPublicDocumentStats(state),
785
+ comments: getCachedCommentSidebarSnapshot(state),
786
+ trackedChanges: getCachedTrackedChangesSnapshot(state, surface),
787
+ compatibility: getCachedCompatibilityReport(state),
788
+ warnings: state.warnings.map((warning) => toPublicWarning(warning)),
789
+ fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
790
+ commandState: {
791
+ canUndo: history.past.length > 0,
792
+ canRedo: history.future.length > 0,
793
+ readOnly: state.readOnly,
794
+ },
795
+ surface,
796
+ protectionSnapshot,
797
+ };
798
+ }
799
+
800
+ function getCachedViewStateSnapshot(): EditorViewStateSnapshot {
801
+ const activeStoryKey = storyTargetKey(activeStory);
802
+ const pageLayout = cachedRenderSnapshot.pageLayout;
803
+ if (
804
+ cachedViewStateSnapshot &&
805
+ cachedViewStateSnapshot.revisionToken === state.revisionToken &&
806
+ cachedViewStateSnapshot.activeStoryKey === activeStoryKey &&
807
+ cachedViewStateSnapshot.selection === state.selection &&
808
+ cachedViewStateSnapshot.viewStateRef === viewState &&
809
+ cachedViewStateSnapshot.pageLayout === pageLayout
810
+ ) {
811
+ return cachedViewStateSnapshot.snapshot;
812
+ }
813
+
814
+ const surface = cachedRenderSnapshot.surface;
815
+ const mainSurface =
816
+ activeStory.kind === "main"
817
+ ? surface
818
+ : getCachedSurface(state.document, MAIN_STORY_TARGET);
819
+ const snapshot = createEditorViewStateSnapshot(
820
+ viewState,
821
+ activeStory,
822
+ toPublicSelectionSnapshot(state.selection, activeStory),
823
+ surface,
824
+ mainSurface,
825
+ pageLayout,
826
+ state.document.numbering,
827
+ );
828
+ cachedViewStateSnapshot = {
829
+ revisionToken: state.revisionToken,
830
+ activeStoryKey,
831
+ selection: state.selection,
832
+ viewStateRef: viewState,
833
+ pageLayout,
834
+ snapshot,
835
+ };
836
+ return snapshot;
837
+ }
838
+
839
+ let cachedRenderSnapshot = refreshRenderSnapshot();
135
840
 
136
841
  emit({
137
842
  type: "ready",
138
843
  documentId: state.documentId,
139
844
  sessionId: state.sessionId,
140
- source: options.sourceKind ?? (options.initialSnapshot ? "snapshot" : "canonical"),
845
+ source:
846
+ options.sourceKind ??
847
+ (options.initialSessionState
848
+ ? "session"
849
+ : options.initialSnapshot
850
+ ? "snapshot"
851
+ : "canonical"),
141
852
  stats: toPublicDocumentStats(state),
142
853
  compatibility: toPublicCompatibilityReport(createDerivedCompatibility(state)),
143
854
  comments: cachedRenderSnapshot.comments,
@@ -167,20 +878,34 @@ export function createDocumentRuntime(
167
878
  getRenderSnapshot() {
168
879
  return cachedRenderSnapshot;
169
880
  },
170
- getFormattingState() {
171
- return getFormattingStateFromRenderSnapshot(cachedRenderSnapshot);
172
- },
173
881
  dispatch(command) {
882
+ if (isMutationCommand(command)) {
883
+ const blockedReasons = evaluateWorkflowBlockedReasons(
884
+ getCommandSelection(command, state.selection),
885
+ command.type,
886
+ );
887
+ if (blockedReasons.length > 0) {
888
+ emit({
889
+ type: "command_blocked",
890
+ documentId: state.documentId,
891
+ command: command.type,
892
+ reasons: blockedReasons,
893
+ });
894
+ return;
895
+ }
896
+ }
897
+
174
898
  if (command.type === "history.undo") {
899
+ if (viewState.documentMode === "viewing") return;
175
900
  applyHistory("undo");
176
901
  return;
177
902
  }
178
903
 
179
904
  if (command.type === "history.redo") {
905
+ if (viewState.documentMode === "viewing") return;
180
906
  applyHistory("redo");
181
907
  return;
182
908
  }
183
-
184
909
  try {
185
910
  const transaction = executeEditorCommand(state, command, {
186
911
  timestamp: command.origin?.timestamp ?? clock(),
@@ -203,6 +928,7 @@ export function createDocumentRuntime(
203
928
  });
204
929
  },
205
930
  focus() {
931
+ viewState = applyFocused(viewState, true);
206
932
  this.dispatch({
207
933
  type: "runtime.focus",
208
934
  focused: true,
@@ -210,18 +936,32 @@ export function createDocumentRuntime(
210
936
  });
211
937
  },
212
938
  blur() {
939
+ viewState = applyFocused(viewState, false);
213
940
  this.dispatch({
214
941
  type: "runtime.focus",
215
942
  focused: false,
216
943
  origin: createOrigin("api", clock()),
217
944
  });
218
945
  },
946
+ setDefaultAuthorId(authorId) {
947
+ defaultAuthorId = authorId;
948
+ },
219
949
  replaceText(text, target) {
220
950
  try {
221
951
  const timestamp = clock();
222
952
  const selection = target
223
953
  ? createSelectionFromPublicAnchor(target)
224
954
  : state.selection;
955
+ const blockedReasons = evaluateWorkflowBlockedReasons(selection, "text.insert");
956
+ if (blockedReasons.length > 0) {
957
+ emit({
958
+ type: "command_blocked",
959
+ documentId: state.documentId,
960
+ command: "replaceText",
961
+ reasons: blockedReasons,
962
+ });
963
+ return;
964
+ }
225
965
  const result = insertText(state.document, selection, text, { timestamp });
226
966
 
227
967
  this.dispatch({
@@ -229,6 +969,7 @@ export function createDocumentRuntime(
229
969
  document: result.document,
230
970
  mapping: result.mapping,
231
971
  selection: result.selection,
972
+ protectionSelection: selection,
232
973
  origin: createOrigin("api", timestamp),
233
974
  });
234
975
  } catch (error) {
@@ -236,10 +977,16 @@ export function createDocumentRuntime(
236
977
  }
237
978
  },
238
979
  addComment(params) {
980
+ if (viewState.documentMode === "viewing") {
981
+ throw new Error("Cannot add comments in viewing mode.");
982
+ }
239
983
  const commentId = createEntityId("comment", state.document.review.comments, clock());
240
984
  const anchor = params.anchor
241
985
  ? toInternalAnchorProjection(params.anchor)
242
986
  : state.selection.activeRange;
987
+ const selection = params.anchor
988
+ ? createSelectionFromPublicAnchor(params.anchor)
989
+ : state.selection;
243
990
  if (!canCreateDocxCommentAnchor(state.document.content, anchor)) {
244
991
  const message =
245
992
  "DOCX comments must use a non-empty range that stays within a single paragraph.";
@@ -252,7 +999,7 @@ export function createDocumentRuntime(
252
999
  });
253
1000
  throw new Error(message);
254
1001
  }
255
- const authorId = params.authorId ?? options.defaultAuthorId ?? "unknown";
1002
+ const authorId = params.authorId ?? defaultAuthorId ?? "unknown";
256
1003
  const createdAt = clock();
257
1004
  const entries: CommentEntryRecord[] = [
258
1005
  {
@@ -281,6 +1028,7 @@ export function createDocumentRuntime(
281
1028
  this.dispatch({
282
1029
  type: "comment.add",
283
1030
  comment,
1031
+ selection,
284
1032
  origin: createOrigin("api", clock()),
285
1033
  });
286
1034
 
@@ -297,7 +1045,7 @@ export function createDocumentRuntime(
297
1045
  this.dispatch({
298
1046
  type: "comment.resolve",
299
1047
  commentId,
300
- resolvedBy: options.defaultAuthorId ?? "unknown",
1048
+ resolvedBy: defaultAuthorId ?? "unknown",
301
1049
  origin: createOrigin("api", clock()),
302
1050
  });
303
1051
  },
@@ -313,7 +1061,7 @@ export function createDocumentRuntime(
313
1061
  type: "comment.add-reply",
314
1062
  commentId,
315
1063
  body,
316
- authorId: authorId ?? options.defaultAuthorId,
1064
+ authorId: authorId ?? defaultAuthorId,
317
1065
  origin: createOrigin("api", clock()),
318
1066
  });
319
1067
  },
@@ -351,13 +1099,132 @@ export function createDocumentRuntime(
351
1099
  origin: createOrigin("api", clock()),
352
1100
  });
353
1101
  },
354
- getPersistedSnapshot() {
1102
+ openStory(target) {
1103
+ const normalizedTarget =
1104
+ target.kind === "header" || target.kind === "footer"
1105
+ ? normalizeHeaderFooterTarget(
1106
+ state.document,
1107
+ target,
1108
+ cachedRenderSnapshot.pageLayout?.sectionIndex,
1109
+ ) ?? target
1110
+ : target;
1111
+ if (storyTargetsEqual(activeStory, normalizedTarget)) {
1112
+ return true;
1113
+ }
1114
+ if (!isValidStoryTarget(state, normalizedTarget)) {
1115
+ return false;
1116
+ }
1117
+ switchActiveStory(normalizedTarget);
1118
+ return true;
1119
+ },
1120
+ closeStory() {
1121
+ if (activeStory.kind === "main") {
1122
+ return;
1123
+ }
1124
+ switchActiveStory(MAIN_STORY_TARGET);
1125
+ },
1126
+ getActiveStory() {
1127
+ return activeStory;
1128
+ },
1129
+ getViewState() {
1130
+ return getCachedViewStateSnapshot();
1131
+ },
1132
+ setViewMode(mode) {
1133
+ viewState = applyViewMode(viewState, mode);
1134
+ cachedRenderSnapshot = refreshRenderSnapshot();
1135
+ for (const listener of listeners) {
1136
+ listener();
1137
+ }
1138
+ },
1139
+ setDocumentMode(mode) {
1140
+ viewState = applyDocumentMode(viewState, mode);
1141
+ cachedRenderSnapshot = refreshRenderSnapshot();
1142
+ for (const listener of listeners) {
1143
+ listener();
1144
+ }
1145
+ },
1146
+ getProtectionSnapshot() {
1147
+ return cachedRenderSnapshot.protectionSnapshot;
1148
+ },
1149
+ setWorkspaceMode(mode) {
1150
+ viewState = applyWorkspaceMode(viewState, mode);
1151
+ cachedRenderSnapshot = refreshRenderSnapshot();
1152
+ for (const listener of listeners) {
1153
+ listener();
1154
+ }
1155
+ },
1156
+ setZoom(level) {
1157
+ viewState = applyZoomLevel(viewState, level);
1158
+ cachedRenderSnapshot = refreshRenderSnapshot();
1159
+ for (const listener of listeners) {
1160
+ listener();
1161
+ }
1162
+ },
1163
+ getPageLayoutSnapshot() {
1164
+ return getCachedPageLayoutSnapshot(state, activeStory);
1165
+ },
1166
+ getDocumentNavigationSnapshot() {
1167
+ return getCachedDocumentNavigationSnapshot(state, activeStory);
1168
+ },
1169
+ getFieldSnapshot() {
1170
+ return buildFieldSnapshot(state.document);
1171
+ },
1172
+ updateFields(options?: UpdateFieldsOptions): UpdateFieldsResult {
1173
+ const refreshed = refreshDocumentFields(
1174
+ state.document,
1175
+ state.selection.head,
1176
+ activeStory,
1177
+ options,
1178
+ );
1179
+ if (refreshed.changed) {
1180
+ this.dispatch({
1181
+ type: "document.replace",
1182
+ document: refreshed.document,
1183
+ mapping: createEmptyMapping(),
1184
+ protectionSelection: refreshed.protectionSelection,
1185
+ origin: createOrigin("api", clock()),
1186
+ });
1187
+ }
1188
+ const snapshot = buildFieldSnapshot(refreshed.document);
1189
+ return {
1190
+ totalCount: snapshot.totalCount,
1191
+ updatedCount: refreshed.updatedCount,
1192
+ preserveOnlyCount: snapshot.preserveOnlyCount,
1193
+ };
1194
+ },
1195
+ updateTableOfContents(options?: TocRefreshOptions): TocRefreshResult {
1196
+ const refreshed = refreshDocumentTableOfContents(
1197
+ state.document,
1198
+ state.selection.head,
1199
+ activeStory,
1200
+ options,
1201
+ );
1202
+ if (refreshed.changed) {
1203
+ this.dispatch({
1204
+ type: "document.replace",
1205
+ document: refreshed.document,
1206
+ mapping: createEmptyMapping(),
1207
+ protectionSelection: refreshed.protectionSelection,
1208
+ origin: createOrigin("api", clock()),
1209
+ });
1210
+ }
1211
+ return refreshed.result;
1212
+ },
1213
+ getSessionState() {
355
1214
  const compatibility = createDerivedCompatibility(state);
356
- return createPersistedEditorSnapshot(state, {
357
- editorBuild,
1215
+ return editorSessionStateFromPersistedSnapshot(
1216
+ createPersistedEditorSnapshot(state, {
1217
+ editorBuild,
1218
+ savedAt: clock(),
1219
+ compatibility,
1220
+ protectionSnapshot,
1221
+ }) as unknown as PersistedEditorSnapshot,
1222
+ );
1223
+ },
1224
+ getPersistedSnapshot() {
1225
+ return persistedSnapshotFromEditorSessionState(this.getSessionState(), {
358
1226
  savedAt: clock(),
359
- compatibility,
360
- }) as unknown as PersistedEditorSnapshot;
1227
+ });
361
1228
  },
362
1229
  getCompatibilityReport() {
363
1230
  return toPublicCompatibilityReport(createDerivedCompatibility(state));
@@ -381,14 +1248,7 @@ export function createDocumentRuntime(
381
1248
  throw new Error(error.message);
382
1249
  }
383
1250
 
384
- const result = await options.exportDocx(
385
- createPersistedEditorSnapshot(state, {
386
- editorBuild,
387
- savedAt: clock(),
388
- compatibility: createDerivedCompatibility(state),
389
- }) as unknown as PersistedEditorSnapshot,
390
- exportOptions,
391
- );
1251
+ const result = await options.exportDocx(this.getSessionState(), exportOptions);
392
1252
 
393
1253
  emit({
394
1254
  type: "export_completed",
@@ -398,6 +1258,75 @@ export function createDocumentRuntime(
398
1258
 
399
1259
  return result;
400
1260
  },
1261
+ setWorkflowOverlay(overlay) {
1262
+ workflowOverlay = structuredClone(overlay);
1263
+ cachedRenderSnapshot = refreshRenderSnapshot();
1264
+ const snapshot = deriveWorkflowScopeSnapshot()!;
1265
+ emit({
1266
+ type: "workflow_overlay_changed",
1267
+ documentId: state.documentId,
1268
+ snapshot,
1269
+ });
1270
+ if (workflowOverlay.activeWorkItemId !== undefined) {
1271
+ emit({
1272
+ type: "workflow_active_work_item_changed",
1273
+ documentId: state.documentId,
1274
+ activeWorkItemId: workflowOverlay.activeWorkItemId ?? null,
1275
+ });
1276
+ }
1277
+ for (const listener of listeners) {
1278
+ listener();
1279
+ }
1280
+ },
1281
+ clearWorkflowOverlay() {
1282
+ workflowOverlay = null;
1283
+ cachedRenderSnapshot = refreshRenderSnapshot();
1284
+ emit({
1285
+ type: "workflow_active_work_item_changed",
1286
+ documentId: state.documentId,
1287
+ activeWorkItemId: null,
1288
+ });
1289
+ emit({
1290
+ type: "workflow_overlay_changed",
1291
+ documentId: state.documentId,
1292
+ snapshot: {
1293
+ overlayPresent: false,
1294
+ activeWorkItemId: null,
1295
+ scopes: [],
1296
+ candidates: [],
1297
+ blockedReasons: [],
1298
+ },
1299
+ });
1300
+ for (const listener of listeners) {
1301
+ listener();
1302
+ }
1303
+ },
1304
+ getWorkflowScopeSnapshot() {
1305
+ return getCachedWorkflowScopeSnapshot();
1306
+ },
1307
+ getInteractionGuardSnapshot() {
1308
+ return getCachedInteractionGuardSnapshot();
1309
+ },
1310
+ getWorkflowMarkupSnapshot() {
1311
+ return collectWorkflowMarkupSnapshot({
1312
+ renderSnapshot: this.getRenderSnapshot(),
1313
+ fieldSnapshot: this.getFieldSnapshot(),
1314
+ protectionSnapshot,
1315
+ preservation: state.document.preservation,
1316
+ });
1317
+ },
1318
+ getWorkflowCandidateRanges(options) {
1319
+ return deriveWorkflowCandidateRangesFromMarkup(this.getWorkflowMarkupSnapshot(), options);
1320
+ },
1321
+ replaceWorkflowMarkupText(markupId, text) {
1322
+ const target = this
1323
+ .getWorkflowMarkupSnapshot()
1324
+ .items.find((item) => item.markupId === markupId);
1325
+ if (!target || target.anchor.kind === "detached") {
1326
+ return;
1327
+ }
1328
+ this.replaceText(text, target.anchor);
1329
+ },
401
1330
  };
402
1331
 
403
1332
  function applyHistory(direction: "undo" | "redo"): void {
@@ -415,7 +1344,8 @@ export function createDocumentRuntime(
415
1344
  // Undo/redo changes the document — must mint a new revisionToken so
416
1345
  // autosave/export checkpoint dedup treats it as fresh content.
417
1346
  state = finalizeState(target, true, clock());
418
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history);
1347
+ storySelections.set(storyTargetKey(activeStory), state.selection);
1348
+ cachedRenderSnapshot = refreshRenderSnapshot();
419
1349
  notify(previous, state, {
420
1350
  nextState: state,
421
1351
  mapping: { steps: [] },
@@ -436,8 +1366,10 @@ export function createDocumentRuntime(
436
1366
  history.future = [];
437
1367
  }
438
1368
 
1369
+ protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
439
1370
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
440
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history);
1371
+ storySelections.set(storyTargetKey(activeStory), state.selection);
1372
+ cachedRenderSnapshot = refreshRenderSnapshot();
441
1373
  notify(previous, state, transaction);
442
1374
  }
443
1375
 
@@ -458,7 +1390,7 @@ export function createDocumentRuntime(
458
1390
  emit({
459
1391
  type: "selection_changed",
460
1392
  documentId: next.documentId,
461
- selection: toPublicSelectionSnapshot(next.selection),
1393
+ selection: toPublicSelectionSnapshot(next.selection, activeStory),
462
1394
  });
463
1395
  }
464
1396
 
@@ -519,7 +1451,7 @@ export function createDocumentRuntime(
519
1451
  }
520
1452
  }
521
1453
 
522
- function emit(event: WordReviewEditorEvent): void {
1454
+ function emit(event: DocumentRuntimeEvent): void {
523
1455
  options.onEvent?.(event);
524
1456
  for (const listener of eventListeners) {
525
1457
  listener(event);
@@ -533,7 +1465,8 @@ export function createDocumentRuntime(
533
1465
  fatalError: error.isFatal ? error : state.fatalError,
534
1466
  };
535
1467
  state = nextState;
536
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history);
1468
+ storySelections.set(storyTargetKey(activeStory), state.selection);
1469
+ cachedRenderSnapshot = refreshRenderSnapshot();
537
1470
  const publicError = toPublicError(error);
538
1471
  options.onError?.(publicError);
539
1472
  emit({
@@ -545,6 +1478,39 @@ export function createDocumentRuntime(
545
1478
  listener();
546
1479
  }
547
1480
  }
1481
+
1482
+ function switchActiveStory(target: EditorStoryTarget): void {
1483
+ const previousStory = activeStory;
1484
+ const previousSelection = state.selection;
1485
+ storySelections.set(storyTargetKey(previousStory), previousSelection);
1486
+
1487
+ const restoredSelection =
1488
+ storySelections.get(storyTargetKey(target)) ?? createSelectionSnapshot(0, 0);
1489
+ activeStory = target;
1490
+ state = {
1491
+ ...state,
1492
+ selection: restoredSelection,
1493
+ };
1494
+ storySelections.set(storyTargetKey(target), restoredSelection);
1495
+ cachedRenderSnapshot = refreshRenderSnapshot();
1496
+
1497
+ if (selectionChanged(previousSelection, restoredSelection)) {
1498
+ emit({
1499
+ type: "selection_changed",
1500
+ documentId: state.documentId,
1501
+ selection: toPublicSelectionSnapshot(restoredSelection, activeStory),
1502
+ });
1503
+ }
1504
+
1505
+ emit({
1506
+ type: "story_changed",
1507
+ documentId: state.documentId,
1508
+ activeStory,
1509
+ });
1510
+ for (const listener of listeners) {
1511
+ listener();
1512
+ }
1513
+ }
548
1514
  }
549
1515
 
550
1516
  function createSessionId(documentId: string, timestamp: string): string {
@@ -620,47 +1586,6 @@ function toRuntimeError(error: unknown): InternalEditorError {
620
1586
  };
621
1587
  }
622
1588
 
623
- function createPublicRenderSnapshot(
624
- state: EditorState,
625
- history: HistoryState,
626
- ): RuntimeRenderSnapshot {
627
- const compatibility = createDerivedCompatibility(state);
628
- const surface = createEditorSurfaceSnapshot(state.document, state.selection);
629
- const comments = toPublicCommentSidebarSnapshot(state);
630
- const trackedChanges = toPublicTrackedChangesSnapshot(state, surface.plainText);
631
-
632
- return {
633
- documentId: state.documentId,
634
- sessionId: state.sessionId,
635
- sourceLabel: state.sourceLabel,
636
- revisionToken: state.revisionToken,
637
- isReady: state.phase === "ready",
638
- isDirty: state.isDirty,
639
- readOnly: state.readOnly,
640
- selection: toPublicSelectionSnapshot(state.selection),
641
- documentStats: toPublicDocumentStats(state),
642
- comments,
643
- trackedChanges,
644
- compatibility: {
645
- blockExport: compatibility.blockExport,
646
- blockExportReasons: listBlockExportReasons(compatibility),
647
- warningCount: compatibility.warnings.length,
648
- errorCount: compatibility.errors.length,
649
- featureEntries: compatibility.featureEntries.map((entry) =>
650
- toPublicCompatibilityFeatureEntry(entry),
651
- ),
652
- },
653
- warnings: state.warnings.map((warning) => toPublicWarning(warning)),
654
- fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
655
- commandState: {
656
- canUndo: history.past.length > 0,
657
- canRedo: history.future.length > 0,
658
- readOnly: state.readOnly,
659
- },
660
- surface,
661
- };
662
- }
663
-
664
1589
  function toPublicDocumentStats(state: Pick<EditorState, "document">) {
665
1590
  const stats = deriveDocumentStats(state);
666
1591
  return {
@@ -673,12 +1598,14 @@ function toPublicDocumentStats(state: Pick<EditorState, "document">) {
673
1598
 
674
1599
  function toPublicSelectionSnapshot(
675
1600
  selection: EditorState["selection"],
1601
+ storyTarget?: EditorStoryTarget,
676
1602
  ): SelectionSnapshot {
677
1603
  return {
678
1604
  anchor: selection.anchor,
679
1605
  head: selection.head,
680
1606
  isCollapsed: selection.isCollapsed,
681
1607
  activeRange: toPublicAnchorProjection(selection.activeRange),
1608
+ ...(storyTarget && storyTarget.kind !== "main" ? { storyTarget } : {}),
682
1609
  };
683
1610
  }
684
1611
 
@@ -1026,6 +1953,755 @@ function summarizeRevisionExcerpt(
1026
1953
  return collapsed.length > 96 ? `${collapsed.slice(0, 93)}...` : collapsed;
1027
1954
  }
1028
1955
 
1956
+ function isValidStoryTarget(
1957
+ state: EditorState,
1958
+ target: EditorStoryTarget,
1959
+ ): boolean {
1960
+ if (target.kind === "main") return true;
1961
+ const subParts = state.document.subParts;
1962
+ if (!subParts) return false;
1963
+
1964
+ switch (target.kind) {
1965
+ case "header":
1966
+ return Boolean(normalizeHeaderFooterTarget(state.document, target));
1967
+ case "footer":
1968
+ return Boolean(normalizeHeaderFooterTarget(state.document, target));
1969
+ case "footnote":
1970
+ return Boolean(subParts.footnoteCollection?.footnotes?.[target.noteId]);
1971
+ case "endnote":
1972
+ return Boolean(subParts.footnoteCollection?.endnotes?.[target.noteId]);
1973
+ }
1974
+ }
1975
+
1976
+ function derivePageLayoutSnapshot(
1977
+ state: EditorState,
1978
+ activeStory: EditorStoryTarget,
1979
+ storySelections?: ReadonlyMap<string, EditorState["selection"]>,
1980
+ ): PageLayoutSnapshot | null {
1981
+ const subParts = state.document.subParts;
1982
+ const sections = buildResolvedSections(state.document);
1983
+ if (!subParts && sections.length === 0) {
1984
+ return null;
1985
+ }
1986
+
1987
+ const activeSection = resolveActiveSection(
1988
+ state,
1989
+ activeStory,
1990
+ sections,
1991
+ storySelections,
1992
+ );
1993
+ return buildPageLayoutSnapshot(
1994
+ activeSection?.index ?? 0,
1995
+ activeSection?.properties ?? subParts?.finalSectionProperties,
1996
+ subParts,
1997
+ );
1998
+ }
1999
+
1029
2000
  function isRecord(value: unknown): value is Record<string, unknown> {
1030
2001
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1031
2002
  }
2003
+
2004
+ /** Commands that are safe in viewing mode (no document mutation). */
2005
+ const NON_MUTATION_COMMANDS = new Set([
2006
+ "selection.set",
2007
+ "runtime.set-read-only",
2008
+ "runtime.focus",
2009
+ "warning.add",
2010
+ "warning.clear",
2011
+ "comment.open",
2012
+ ]);
2013
+
2014
+ function isMutationCommand(command: EditorCommand): boolean {
2015
+ return !NON_MUTATION_COMMANDS.has(command.type);
2016
+ }
2017
+
2018
+ // ── Field snapshot helpers ──────────────────────────────────────────────────
2019
+
2020
+ function buildFieldSnapshot(document: CanonicalDocumentEnvelope): FieldSnapshot {
2021
+ const entries: FieldEntrySnapshot[] = [];
2022
+ let index = 0;
2023
+ for (const block of document.content.children) {
2024
+ index = collectFieldsFromBlock(block, entries, index);
2025
+ }
2026
+ index = collectFieldsFromSubParts(document.subParts, entries, index);
2027
+ const supportedCount = entries.filter((e) => e.supported).length;
2028
+ return {
2029
+ totalCount: entries.length,
2030
+ supportedCount,
2031
+ preserveOnlyCount: entries.length - supportedCount,
2032
+ fields: entries,
2033
+ };
2034
+ }
2035
+
2036
+ function collectFieldsFromBlock(
2037
+ block: BlockNode,
2038
+ entries: FieldEntrySnapshot[],
2039
+ index: number,
2040
+ ): number {
2041
+ if (block.type === "paragraph") {
2042
+ for (const child of block.children) {
2043
+ index = collectFieldsFromInline(child, entries, index);
2044
+ }
2045
+ } else if (block.type === "table") {
2046
+ for (const row of block.rows) {
2047
+ for (const cell of row.cells) {
2048
+ for (const child of cell.children) {
2049
+ index = collectFieldsFromBlock(child, entries, index);
2050
+ }
2051
+ }
2052
+ }
2053
+ } else if (block.type === "sdt" || block.type === "custom_xml") {
2054
+ for (const child of block.children) {
2055
+ index = collectFieldsFromBlock(child, entries, index);
2056
+ }
2057
+ }
2058
+ return index;
2059
+ }
2060
+
2061
+ function collectFieldsFromInline(
2062
+ node: InlineNode,
2063
+ entries: FieldEntrySnapshot[],
2064
+ index: number,
2065
+ ): number {
2066
+ if (node.type === "field") {
2067
+ const fieldFamily = node.fieldFamily ?? "UNKNOWN";
2068
+ const supported = isSupportedFieldFamily(fieldFamily);
2069
+ const displayText = extractFieldDisplayText(node);
2070
+ entries.push({
2071
+ index,
2072
+ fieldFamily,
2073
+ supported,
2074
+ instruction: node.instruction,
2075
+ fieldTarget: node.fieldTarget,
2076
+ refreshStatus: node.refreshStatus ?? (supported ? "stale" : "preserve-only"),
2077
+ displayText,
2078
+ });
2079
+ index++;
2080
+ // Also walk children — fields can contain nested fields
2081
+ for (const child of node.children) {
2082
+ index = collectFieldsFromInline(child, entries, index);
2083
+ }
2084
+ } else if (node.type === "hyperlink") {
2085
+ for (const child of node.children) {
2086
+ index = collectFieldsFromInline(child, entries, index);
2087
+ }
2088
+ }
2089
+ return index;
2090
+ }
2091
+
2092
+ function extractFieldDisplayText(field: FieldNode): string {
2093
+ return flattenInlineDisplayText(field.children);
2094
+ }
2095
+
2096
+ function flattenInlineDisplayText(children: readonly InlineNode[]): string {
2097
+ return children
2098
+ .map((child) => {
2099
+ switch (child.type) {
2100
+ case "text":
2101
+ return child.text;
2102
+ case "tab":
2103
+ return "\t";
2104
+ case "hard_break":
2105
+ case "column_break":
2106
+ return "\n";
2107
+ case "hyperlink":
2108
+ case "field":
2109
+ return flattenInlineDisplayText(child.children);
2110
+ case "footnote_ref":
2111
+ return child.noteId;
2112
+ default:
2113
+ return "";
2114
+ }
2115
+ })
2116
+ .join("");
2117
+ }
2118
+
2119
+ function refreshDocumentFields(
2120
+ document: CanonicalDocumentEnvelope,
2121
+ selectionHead: number,
2122
+ activeStory: EditorStoryTarget,
2123
+ options?: UpdateFieldsOptions,
2124
+ ): {
2125
+ document: CanonicalDocumentEnvelope;
2126
+ updatedCount: number;
2127
+ changed: boolean;
2128
+ protectionSelection?: import("../core/state/editor-state.ts").SelectionSnapshot;
2129
+ } {
2130
+ const supportedOnly = options?.supportedOnly ?? true;
2131
+ const bookmarkMap = buildBookmarkNameMap(document);
2132
+ const paragraphs = collectParagraphContexts(document.content.children);
2133
+ const navigation = createDocumentNavigationSnapshot(document, selectionHead, activeStory);
2134
+ let updatedCount = 0;
2135
+ let changed = false;
2136
+ let changedFrom: number | undefined;
2137
+ let changedTo: number | undefined;
2138
+
2139
+ const nextChildren = refreshBlocksWithCursor(document.content.children, (field, range) => {
2140
+ if (!field.fieldFamily || !isSupportedFieldFamily(field.fieldFamily)) {
2141
+ return field;
2142
+ }
2143
+ if (supportedOnly && field.fieldFamily === "TOC") {
2144
+ return field;
2145
+ }
2146
+ const display = resolveSupportedFieldDisplay(
2147
+ field,
2148
+ document,
2149
+ bookmarkMap,
2150
+ paragraphs,
2151
+ navigation,
2152
+ );
2153
+ if (!display) {
2154
+ return field;
2155
+ }
2156
+ updatedCount += 1;
2157
+ const nextField: FieldNode = {
2158
+ ...field,
2159
+ children: buildInlineNodesFromDisplayText(display.displayText),
2160
+ refreshStatus: display.refreshStatus,
2161
+ };
2162
+ if (
2163
+ nextField.refreshStatus !== field.refreshStatus ||
2164
+ flattenInlineDisplayText(nextField.children) !== flattenInlineDisplayText(field.children)
2165
+ ) {
2166
+ changed = true;
2167
+ changedFrom = changedFrom === undefined ? range.from : Math.min(changedFrom, range.from);
2168
+ changedTo = changedTo === undefined ? range.to : Math.max(changedTo, range.to);
2169
+ }
2170
+ return nextField;
2171
+ }).blocks;
2172
+ if (!changed) {
2173
+ return { document, updatedCount, changed: false };
2174
+ }
2175
+
2176
+ const nextDocument: CanonicalDocumentEnvelope = {
2177
+ ...document,
2178
+ content: {
2179
+ ...document.content,
2180
+ children: nextChildren,
2181
+ },
2182
+ };
2183
+ const nextRegistry = buildFieldRegistry({
2184
+ content: nextDocument.content,
2185
+ styles: nextDocument.styles,
2186
+ subParts: nextDocument.subParts,
2187
+ });
2188
+ nextDocument.fieldRegistry = nextRegistry;
2189
+ let protectionSelection:
2190
+ | import("../core/state/editor-state.ts").SelectionSnapshot
2191
+ | undefined;
2192
+ if (changedFrom !== undefined && changedTo !== undefined) {
2193
+ protectionSelection = createSelectionSnapshot(changedFrom, changedTo);
2194
+ }
2195
+ return {
2196
+ document: nextDocument,
2197
+ updatedCount,
2198
+ changed: true,
2199
+ ...(protectionSelection ? { protectionSelection } : {}),
2200
+ };
2201
+ }
2202
+
2203
+ function refreshDocumentTableOfContents(
2204
+ document: CanonicalDocumentEnvelope,
2205
+ selectionHead: number,
2206
+ activeStory: EditorStoryTarget,
2207
+ options?: TocRefreshOptions,
2208
+ ): {
2209
+ document: CanonicalDocumentEnvelope;
2210
+ result: TocRefreshResult;
2211
+ changed: boolean;
2212
+ protectionSelection?: import("../core/state/editor-state.ts").SelectionSnapshot;
2213
+ } {
2214
+ const navigation = createDocumentNavigationSnapshot(document, selectionHead, activeStory);
2215
+ let changed = false;
2216
+ let resultEntries: Array<{ level: number; text: string; pageIndex: number }> = [];
2217
+ let changedFrom: number | undefined;
2218
+ let changedTo: number | undefined;
2219
+ const nextChildren = refreshBlocksWithCursor(document.content.children, (field, range) => {
2220
+ if (field.fieldFamily !== "TOC") {
2221
+ return field;
2222
+ }
2223
+ const levelRange = options?.maxLevel
2224
+ ? { from: 1, to: options.maxLevel }
2225
+ : parseTocLevelRange(field.instruction);
2226
+ const entries = navigation.headings
2227
+ .filter((heading) => heading.level >= levelRange.from && heading.level <= levelRange.to)
2228
+ .map((heading) => ({
2229
+ level: heading.level,
2230
+ text: heading.text,
2231
+ pageIndex: heading.pageIndex,
2232
+ }));
2233
+ if (resultEntries.length === 0) {
2234
+ resultEntries = entries;
2235
+ }
2236
+ const nextField: FieldNode = {
2237
+ ...field,
2238
+ children: buildTocInlineNodes(entries),
2239
+ refreshStatus: "current",
2240
+ };
2241
+ if (flattenInlineDisplayText(nextField.children) !== flattenInlineDisplayText(field.children)) {
2242
+ changed = true;
2243
+ changedFrom = changedFrom === undefined ? range.from : Math.min(changedFrom, range.from);
2244
+ changedTo = changedTo === undefined ? range.to : Math.max(changedTo, range.to);
2245
+ }
2246
+ return nextField;
2247
+ }).blocks;
2248
+ if (!changed) {
2249
+ return {
2250
+ document,
2251
+ result: { entryCount: resultEntries.length, entries: resultEntries },
2252
+ changed: false,
2253
+ };
2254
+ }
2255
+
2256
+ const nextDocument: CanonicalDocumentEnvelope = {
2257
+ ...document,
2258
+ content: {
2259
+ ...document.content,
2260
+ children: nextChildren,
2261
+ },
2262
+ };
2263
+ const nextRegistry = buildFieldRegistry({
2264
+ content: nextDocument.content,
2265
+ styles: nextDocument.styles,
2266
+ subParts: nextDocument.subParts,
2267
+ });
2268
+ nextDocument.fieldRegistry = nextRegistry.tocStructure
2269
+ ? {
2270
+ ...nextRegistry,
2271
+ tocStructure: {
2272
+ ...nextRegistry.tocStructure,
2273
+ status: "current",
2274
+ },
2275
+ }
2276
+ : nextRegistry;
2277
+ let protectionSelection:
2278
+ | import("../core/state/editor-state.ts").SelectionSnapshot
2279
+ | undefined;
2280
+ if (changedFrom !== undefined && changedTo !== undefined) {
2281
+ protectionSelection = createSelectionSnapshot(changedFrom, changedTo);
2282
+ }
2283
+
2284
+ return {
2285
+ document: nextDocument,
2286
+ result: { entryCount: resultEntries.length, entries: resultEntries },
2287
+ changed: true,
2288
+ ...(protectionSelection ? { protectionSelection } : {}),
2289
+ };
2290
+ }
2291
+
2292
+ function refreshBlocksWithCursor(
2293
+ blocks: readonly BlockNode[],
2294
+ visitField: (field: FieldNode, range: { from: number; to: number }) => FieldNode,
2295
+ cursor = 0,
2296
+ previousParagraph = false,
2297
+ ): {
2298
+ blocks: BlockNode[];
2299
+ cursor: number;
2300
+ previousParagraph: boolean;
2301
+ } {
2302
+ const nextBlocks = blocks.map((block) => {
2303
+ if (block.type === "paragraph") {
2304
+ const paragraphStart = previousParagraph ? cursor + 1 : cursor;
2305
+ const refreshedChildren = refreshInlineNodesWithCursor(
2306
+ block.children,
2307
+ visitField,
2308
+ paragraphStart,
2309
+ );
2310
+ cursor = paragraphStart + refreshedChildren.cursor;
2311
+ previousParagraph = true;
2312
+ return {
2313
+ ...block,
2314
+ children: refreshedChildren.nodes,
2315
+ };
2316
+ }
2317
+ if (block.type === "table") {
2318
+ cursor += 1;
2319
+ previousParagraph = false;
2320
+ return {
2321
+ ...block,
2322
+ rows: block.rows.map((row) => ({
2323
+ ...row,
2324
+ cells: row.cells.map((cell) => ({
2325
+ ...cell,
2326
+ children: (() => {
2327
+ const refreshed = refreshBlocksWithCursor(cell.children, visitField, cursor, false);
2328
+ cursor = refreshed.cursor;
2329
+ return refreshed.blocks;
2330
+ })(),
2331
+ })),
2332
+ })),
2333
+ };
2334
+ }
2335
+ if (block.type === "sdt" || block.type === "custom_xml") {
2336
+ const refreshed = refreshBlocksWithCursor(
2337
+ block.children,
2338
+ visitField,
2339
+ cursor,
2340
+ previousParagraph,
2341
+ );
2342
+ cursor = refreshed.cursor;
2343
+ previousParagraph = refreshed.previousParagraph;
2344
+ return {
2345
+ ...block,
2346
+ children: refreshed.blocks,
2347
+ };
2348
+ }
2349
+ cursor += 1;
2350
+ previousParagraph = false;
2351
+ return block;
2352
+ });
2353
+ return { blocks: nextBlocks, cursor, previousParagraph };
2354
+ }
2355
+
2356
+ function refreshInlineNodesWithCursor(
2357
+ nodes: readonly InlineNode[],
2358
+ visitField: (field: FieldNode, range: { from: number; to: number }) => FieldNode,
2359
+ cursor = 0,
2360
+ ): {
2361
+ nodes: InlineNode[];
2362
+ cursor: number;
2363
+ } {
2364
+ const nextNodes = nodes.map((node) => {
2365
+ if (node.type === "field") {
2366
+ const fieldStart = cursor;
2367
+ const refreshedChildren = refreshInlineNodesWithCursor(node.children, visitField, cursor);
2368
+ const fieldLength = measureInlineNodes(node.children);
2369
+ cursor = fieldStart + fieldLength;
2370
+ return visitField({
2371
+ ...node,
2372
+ children: refreshedChildren.nodes,
2373
+ }, {
2374
+ from: fieldStart,
2375
+ to: fieldStart + fieldLength,
2376
+ });
2377
+ }
2378
+ if (node.type === "hyperlink") {
2379
+ cursor += measureInlineNodes(node.children);
2380
+ return {
2381
+ ...node,
2382
+ // Hyperlinks only contain text-like children in the canonical model.
2383
+ children: [...node.children],
2384
+ };
2385
+ }
2386
+ cursor += measureInlineNode(node);
2387
+ return node;
2388
+ });
2389
+ return { nodes: nextNodes, cursor };
2390
+ }
2391
+
2392
+ function buildInlineNodesFromDisplayText(text: string): InlineNode[] {
2393
+ if (text.length === 0) {
2394
+ return [];
2395
+ }
2396
+ const children: InlineNode[] = [];
2397
+ let buffer = "";
2398
+ const flushBuffer = () => {
2399
+ if (buffer.length > 0) {
2400
+ children.push({ type: "text", text: buffer });
2401
+ buffer = "";
2402
+ }
2403
+ };
2404
+ for (const character of text) {
2405
+ if (character === "\t") {
2406
+ flushBuffer();
2407
+ children.push({ type: "tab" });
2408
+ continue;
2409
+ }
2410
+ if (character === "\n") {
2411
+ flushBuffer();
2412
+ children.push({ type: "hard_break" });
2413
+ continue;
2414
+ }
2415
+ buffer += character;
2416
+ }
2417
+ flushBuffer();
2418
+ return children;
2419
+ }
2420
+
2421
+ function buildTocInlineNodes(
2422
+ entries: ReadonlyArray<{ level: number; text: string; pageIndex: number }>,
2423
+ ): InlineNode[] {
2424
+ const children: InlineNode[] = [];
2425
+ entries.forEach((entry, index) => {
2426
+ children.push({ type: "text", text: entry.text });
2427
+ children.push({ type: "tab" });
2428
+ children.push({ type: "text", text: String(entry.pageIndex + 1) });
2429
+ if (index < entries.length - 1) {
2430
+ children.push({ type: "hard_break" });
2431
+ }
2432
+ });
2433
+ return children;
2434
+ }
2435
+
2436
+ function collectFieldsFromSubParts(
2437
+ subParts: SubPartsCatalog | undefined,
2438
+ entries: FieldEntrySnapshot[],
2439
+ index: number,
2440
+ ): number {
2441
+ if (!subParts) {
2442
+ return index;
2443
+ }
2444
+ let nextIndex = index;
2445
+ for (const header of subParts.headers) {
2446
+ for (const block of header.blocks) {
2447
+ nextIndex = collectFieldsFromBlock(block, entries, nextIndex);
2448
+ }
2449
+ }
2450
+ for (const footer of subParts.footers) {
2451
+ for (const block of footer.blocks) {
2452
+ nextIndex = collectFieldsFromBlock(block, entries, nextIndex);
2453
+ }
2454
+ }
2455
+ if (subParts.footnoteCollection) {
2456
+ for (const note of Object.values(subParts.footnoteCollection.footnotes)) {
2457
+ for (const block of note.blocks) {
2458
+ nextIndex = collectFieldsFromBlock(block, entries, nextIndex);
2459
+ }
2460
+ }
2461
+ for (const note of Object.values(subParts.footnoteCollection.endnotes)) {
2462
+ for (const block of note.blocks) {
2463
+ nextIndex = collectFieldsFromBlock(block, entries, nextIndex);
2464
+ }
2465
+ }
2466
+ }
2467
+ return nextIndex;
2468
+ }
2469
+
2470
+ function resolveSupportedFieldDisplay(
2471
+ field: FieldNode,
2472
+ document: CanonicalDocumentEnvelope,
2473
+ bookmarkMap: Map<string, { bookmarkId: string; paragraphIndex: number }>,
2474
+ paragraphs: readonly ParagraphContext[],
2475
+ navigation: DocumentNavigationSnapshot,
2476
+ ): { displayText: string; refreshStatus: FieldRefreshStatus } | undefined {
2477
+ if (!field.fieldFamily || !isSupportedFieldFamily(field.fieldFamily)) {
2478
+ return undefined;
2479
+ }
2480
+ if (!field.fieldTarget) {
2481
+ return field.fieldFamily === "TOC"
2482
+ ? undefined
2483
+ : { displayText: "", refreshStatus: "unresolvable" };
2484
+ }
2485
+ if (field.fieldFamily === "REF") {
2486
+ const result = resolveRefFieldText(document, bookmarkMap, field.fieldTarget);
2487
+ return result
2488
+ ? { displayText: result.text, refreshStatus: result.refreshStatus }
2489
+ : { displayText: "", refreshStatus: "unresolvable" };
2490
+ }
2491
+ const bookmark = bookmarkMap.get(field.fieldTarget);
2492
+ if (!bookmark) {
2493
+ return { displayText: "", refreshStatus: "unresolvable" };
2494
+ }
2495
+ if (field.fieldFamily === "PAGEREF") {
2496
+ const paragraph = paragraphs[bookmark.paragraphIndex];
2497
+ if (!paragraph) {
2498
+ return { displayText: "", refreshStatus: "unresolvable" };
2499
+ }
2500
+ const pageIndex = findPageForOffset(navigation.pages, paragraph.startOffset);
2501
+ return { displayText: String(pageIndex + 1), refreshStatus: "current" };
2502
+ }
2503
+ if (field.fieldFamily === "NOTEREF") {
2504
+ const paragraph = paragraphs[bookmark.paragraphIndex]?.paragraph;
2505
+ if (!paragraph) {
2506
+ return { displayText: "", refreshStatus: "unresolvable" };
2507
+ }
2508
+ const noteText = resolveNoteReferenceText(paragraph, bookmark.bookmarkId);
2509
+ return noteText
2510
+ ? { displayText: noteText, refreshStatus: "current" }
2511
+ : { displayText: "", refreshStatus: "unresolvable" };
2512
+ }
2513
+ return undefined;
2514
+ }
2515
+
2516
+ interface ParagraphContext {
2517
+ paragraph: ParagraphNode;
2518
+ startOffset: number;
2519
+ }
2520
+
2521
+ function collectParagraphContexts(blocks: readonly BlockNode[]): ParagraphContext[] {
2522
+ const paragraphs: ParagraphContext[] = [];
2523
+ collectParagraphContextsFromBlocks(blocks, paragraphs, 0, false);
2524
+ return paragraphs;
2525
+ }
2526
+
2527
+ function collectParagraphContextsFromBlocks(
2528
+ blocks: readonly BlockNode[],
2529
+ paragraphs: ParagraphContext[],
2530
+ cursor: number,
2531
+ previousParagraph: boolean,
2532
+ ): { cursor: number; previousParagraph: boolean } {
2533
+ let nextCursor = cursor;
2534
+ let nextPreviousParagraph = previousParagraph;
2535
+ for (const block of blocks) {
2536
+ if (block.type === "paragraph") {
2537
+ if (nextPreviousParagraph) {
2538
+ nextCursor += 1;
2539
+ }
2540
+ paragraphs.push({ paragraph: block, startOffset: nextCursor });
2541
+ nextCursor += measureInlineNodes(block.children);
2542
+ nextPreviousParagraph = true;
2543
+ continue;
2544
+ }
2545
+ if (block.type === "table") {
2546
+ nextCursor += 1;
2547
+ nextPreviousParagraph = false;
2548
+ for (const row of block.rows) {
2549
+ for (const cell of row.cells) {
2550
+ const result = collectParagraphContextsFromBlocks(
2551
+ cell.children,
2552
+ paragraphs,
2553
+ nextCursor,
2554
+ false,
2555
+ );
2556
+ nextCursor = result.cursor;
2557
+ }
2558
+ }
2559
+ continue;
2560
+ }
2561
+ if (block.type === "sdt" || block.type === "custom_xml") {
2562
+ const result = collectParagraphContextsFromBlocks(
2563
+ block.children,
2564
+ paragraphs,
2565
+ nextCursor,
2566
+ nextPreviousParagraph,
2567
+ );
2568
+ nextCursor = result.cursor;
2569
+ nextPreviousParagraph = result.previousParagraph;
2570
+ continue;
2571
+ }
2572
+ nextCursor += 1;
2573
+ nextPreviousParagraph = false;
2574
+ }
2575
+ return { cursor: nextCursor, previousParagraph: nextPreviousParagraph };
2576
+ }
2577
+
2578
+ function measureInlineNodes(nodes: readonly InlineNode[]): number {
2579
+ return nodes.reduce((size, node) => size + measureInlineNode(node), 0);
2580
+ }
2581
+
2582
+ function measureInlineNode(node: InlineNode): number {
2583
+ switch (node.type) {
2584
+ case "text":
2585
+ return node.text.length;
2586
+ case "tab":
2587
+ case "hard_break":
2588
+ case "column_break":
2589
+ case "footnote_ref":
2590
+ case "image":
2591
+ case "opaque_inline":
2592
+ case "bookmark_start":
2593
+ case "bookmark_end":
2594
+ return 1;
2595
+ case "hyperlink":
2596
+ case "field":
2597
+ return measureInlineNodes(node.children);
2598
+ default:
2599
+ return 1;
2600
+ }
2601
+ }
2602
+
2603
+ function resolveNoteReferenceText(paragraph: ParagraphNode, bookmarkId: string): string | undefined {
2604
+ let inside = false;
2605
+ let sawBoundary = false;
2606
+ for (const child of paragraph.children) {
2607
+ if (child.type === "bookmark_start" && child.bookmarkId === bookmarkId) {
2608
+ inside = true;
2609
+ sawBoundary = true;
2610
+ continue;
2611
+ }
2612
+ if (child.type === "bookmark_end" && child.bookmarkId === bookmarkId) {
2613
+ break;
2614
+ }
2615
+ if (!inside) {
2616
+ continue;
2617
+ }
2618
+ if (child.type === "footnote_ref") {
2619
+ return child.noteId;
2620
+ }
2621
+ }
2622
+ return sawBoundary ? undefined : undefined;
2623
+ }
2624
+
2625
+ function getCommandSelection(
2626
+ command: EditorCommand,
2627
+ fallbackSelection: import("../core/state/editor-state.ts").SelectionSnapshot,
2628
+ ): import("../core/state/editor-state.ts").SelectionSnapshot {
2629
+ if ("protectionSelection" in command && command.protectionSelection) {
2630
+ return command.protectionSelection;
2631
+ }
2632
+ if ("selection" in command && command.selection) {
2633
+ return command.selection;
2634
+ }
2635
+ return fallbackSelection;
2636
+ }
2637
+
2638
+ function isBlockedByProtection(
2639
+ protection: ProtectionSnapshot,
2640
+ selection: import("../core/state/editor-state.ts").SelectionSnapshot,
2641
+ ): boolean {
2642
+ const enforcedRanges = protection.ranges.filter(
2643
+ (range): range is typeof range & { start: number; end: number } =>
2644
+ range.enforced && typeof range.start === "number" && typeof range.end === "number",
2645
+ );
2646
+ if (enforcedRanges.length === 0) {
2647
+ return false;
2648
+ }
2649
+ const from = Math.min(selection.anchor, selection.head);
2650
+ const to = Math.max(selection.anchor, selection.head);
2651
+ return !enforcedRanges.some((range) =>
2652
+ from >= range.start && to <= range.end,
2653
+ );
2654
+ }
2655
+
2656
+ function remapProtectionSnapshot(
2657
+ protection: ProtectionSnapshot,
2658
+ mapping: import("../core/selection/mapping.ts").TransactionMapping,
2659
+ ): ProtectionSnapshot {
2660
+ if (mapping.steps.length === 0 || protection.ranges.length === 0) {
2661
+ return protection;
2662
+ }
2663
+ let changed = false;
2664
+ const nextRanges = protection.ranges.map((range) => {
2665
+ if (
2666
+ !range.enforced ||
2667
+ typeof range.start !== "number" ||
2668
+ typeof range.end !== "number"
2669
+ ) {
2670
+ return range;
2671
+ }
2672
+ const mapped = mapRange(
2673
+ { from: range.start, to: range.end },
2674
+ { start: -1, end: 1 },
2675
+ mapping,
2676
+ );
2677
+ if (mapped.kind === "detached") {
2678
+ changed = true;
2679
+ return {
2680
+ ...range,
2681
+ start: undefined,
2682
+ end: undefined,
2683
+ enforced: false,
2684
+ enforcementReason:
2685
+ "preserve-only: permission range could not be remapped after runtime edits",
2686
+ };
2687
+ }
2688
+ if (mapped.range.from !== range.start || mapped.range.to !== range.end) {
2689
+ changed = true;
2690
+ return {
2691
+ ...range,
2692
+ start: mapped.range.from,
2693
+ end: mapped.range.to,
2694
+ };
2695
+ }
2696
+ return range;
2697
+ });
2698
+ if (!changed) {
2699
+ return protection;
2700
+ }
2701
+ return {
2702
+ ...protection,
2703
+ ranges: nextRanges,
2704
+ enforcedRangeCount: nextRanges.filter((range) => range.enforced).length,
2705
+ preservedRangeCount: nextRanges.filter((range) => !range.enforced).length,
2706
+ };
2707
+ }