@beyondwork/docx-react-component 1.0.19 → 1.0.21

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 (71) hide show
  1. package/package.json +44 -25
  2. package/src/api/public-types.ts +336 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/formatting-commands.ts +1 -1
  5. package/src/core/commands/index.ts +14 -2
  6. package/src/core/search/search-text.ts +28 -0
  7. package/src/core/state/editor-state.ts +3 -0
  8. package/src/index.ts +21 -0
  9. package/src/io/docx-session.ts +363 -17
  10. package/src/io/export/serialize-comments.ts +104 -34
  11. package/src/io/export/serialize-footnotes.ts +198 -1
  12. package/src/io/export/serialize-headers-footers.ts +203 -10
  13. package/src/io/export/serialize-main-document.ts +83 -3
  14. package/src/io/export/split-review-boundaries.ts +181 -19
  15. package/src/io/normalize/normalize-text.ts +82 -8
  16. package/src/io/ooxml/highlight-colors.ts +39 -0
  17. package/src/io/ooxml/parse-comments.ts +85 -19
  18. package/src/io/ooxml/parse-fields.ts +396 -0
  19. package/src/io/ooxml/parse-footnotes.ts +240 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +431 -7
  21. package/src/io/ooxml/parse-inline-media.ts +15 -1
  22. package/src/io/ooxml/parse-main-document.ts +396 -14
  23. package/src/io/ooxml/parse-revisions.ts +317 -38
  24. package/src/legal/bookmarks.ts +44 -0
  25. package/src/legal/cross-references.ts +59 -1
  26. package/src/model/canonical-document.ts +117 -1
  27. package/src/model/snapshot.ts +85 -1
  28. package/src/review/store/revision-store.ts +6 -0
  29. package/src/review/store/revision-types.ts +1 -0
  30. package/src/runtime/document-navigation.ts +52 -13
  31. package/src/runtime/document-runtime.ts +1521 -75
  32. package/src/runtime/read-only-diagnostics-runtime.ts +8 -0
  33. package/src/runtime/session-capabilities.ts +33 -3
  34. package/src/runtime/surface-projection.ts +86 -25
  35. package/src/runtime/table-schema.ts +2 -2
  36. package/src/runtime/view-state.ts +24 -6
  37. package/src/runtime/workflow-markup.ts +349 -0
  38. package/src/ui/WordReviewEditor.tsx +915 -1314
  39. package/src/ui/editor-command-bag.ts +120 -0
  40. package/src/ui/editor-runtime-boundary.ts +1448 -0
  41. package/src/ui/editor-shell-view.tsx +134 -0
  42. package/src/ui/editor-surface-controller.tsx +55 -0
  43. package/src/ui/headless/revision-decoration-model.ts +4 -4
  44. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  45. package/src/ui/workflow-surface-blocked-rails.ts +94 -0
  46. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  47. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  48. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  49. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +27 -2
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  52. package/src/ui-tailwind/editor-surface/perf-probe.ts +86 -14
  53. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +2 -2
  54. package/src/ui-tailwind/editor-surface/pm-decorations.ts +237 -0
  55. package/src/ui-tailwind/editor-surface/pm-position-map.ts +1 -1
  56. package/src/ui-tailwind/editor-surface/pm-schema.ts +139 -8
  57. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +98 -48
  58. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +55 -0
  59. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +190 -48
  61. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  62. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +7 -7
  63. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  64. package/src/ui-tailwind/review/tw-review-rail.tsx +3 -3
  65. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  66. package/src/ui-tailwind/theme/editor-theme.css +130 -0
  67. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +543 -5
  68. package/src/ui-tailwind/tw-review-workspace.tsx +316 -19
  69. package/src/validation/compatibility-engine.ts +27 -4
  70. package/src/validation/compatibility-report.ts +1 -0
  71. package/src/validation/docx-comment-proof.ts +220 -0
@@ -17,6 +17,7 @@ import type {
17
17
  CommentSidebarSnapshot,
18
18
  CommentSidebarThreadSnapshot,
19
19
  CompatibilityReport,
20
+ DocumentMode,
20
21
  DocumentNavigationSnapshot,
21
22
  EditorSessionState,
22
23
  EditorAnchorProjection,
@@ -24,17 +25,31 @@ import type {
24
25
  EditorStoryTarget,
25
26
  EditorViewStateSnapshot,
26
27
  EditorWarning,
28
+ FieldEntrySnapshot,
29
+ FieldSnapshot,
27
30
  HeaderFooterLinkPatch,
28
31
  ExportDocxOptions,
29
32
  ExportResult,
33
+ InteractionGuardSnapshot,
30
34
  PageLayoutSnapshot,
31
35
  PersistedEditorSnapshot,
36
+ ProtectionSnapshot,
32
37
  RuntimeRenderSnapshot,
33
38
  SelectionSnapshot,
34
39
  StyleCatalogSnapshot,
40
+ TocRefreshOptions,
41
+ TocRefreshResult,
35
42
  TrackedChangeEntrySnapshot,
36
43
  TrackedChangesSnapshot,
44
+ UpdateFieldsOptions,
45
+ UpdateFieldsResult,
37
46
  ViewMode,
47
+ WorkflowCandidateRange,
48
+ WorkflowCandidateRangeOptions,
49
+ WorkflowBlockedCommandReason,
50
+ WorkflowMarkupSnapshot,
51
+ WorkflowOverlay,
52
+ WorkflowScopeSnapshot,
38
53
  WorkspaceMode,
39
54
  WordReviewEditorEvent,
40
55
  ZoomLevel,
@@ -53,13 +68,20 @@ import {
53
68
  import { insertText } from "../core/commands/text-commands.ts";
54
69
  import {
55
70
  createDetachedAnchor,
71
+ createEmptyMapping,
56
72
  createNodeAnchor,
57
73
  createRangeAnchor,
74
+ mapRange,
58
75
  MAIN_STORY_TARGET,
59
76
  storyTargetsEqual,
60
77
  type EditorAnchorProjection as InternalEditorAnchorProjection,
61
78
  } from "../core/selection/mapping.ts";
62
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";
63
85
  import { createCommentSidebarProjection } from "../review/store/comment-store.ts";
64
86
  import { createCommentStoreFromRuntimeComments } from "../review/store/runtime-comment-store.ts";
65
87
  import {
@@ -69,7 +91,14 @@ import {
69
91
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
70
92
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
71
93
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
72
- import { createDocumentNavigationSnapshot } from "./document-navigation.ts";
94
+ import {
95
+ collectWorkflowMarkupSnapshot,
96
+ deriveWorkflowCandidateRangesFromMarkup,
97
+ } from "./workflow-markup.ts";
98
+ import {
99
+ createDocumentNavigationSnapshot,
100
+ findPageForOffset,
101
+ } from "./document-navigation.ts";
73
102
  import {
74
103
  buildPageLayoutSnapshot,
75
104
  buildResolvedSections,
@@ -80,6 +109,7 @@ import { storyTargetKey } from "./story-targeting.ts";
80
109
  import {
81
110
  createViewState,
82
111
  setViewMode as applyViewMode,
112
+ setDocumentMode as applyDocumentMode,
83
113
  setWorkspaceMode as applyWorkspaceMode,
84
114
  setZoomLevel as applyZoomLevel,
85
115
  setFocused as applyFocused,
@@ -89,7 +119,25 @@ import {
89
119
  createEditorViewStateSnapshot,
90
120
  type ViewState,
91
121
  } from "./view-state.ts";
92
- import type { PageMargins } from "../model/canonical-document.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";
93
141
 
94
142
  export type Unsubscribe = () => void;
95
143
 
@@ -111,6 +159,7 @@ export interface DocumentRuntime {
111
159
  redo(): void;
112
160
  focus(): void;
113
161
  blur(): void;
162
+ setDefaultAuthorId?(authorId?: string): void;
114
163
  addComment(params: AddCommentParams): string;
115
164
  openComment(commentId: string): void;
116
165
  resolveComment(commentId: string): void;
@@ -126,15 +175,27 @@ export interface DocumentRuntime {
126
175
  getActiveStory(): EditorStoryTarget;
127
176
  getViewState(): EditorViewStateSnapshot;
128
177
  setViewMode(mode: ViewMode): void;
178
+ setDocumentMode(mode: DocumentMode): void;
179
+ getProtectionSnapshot(): ProtectionSnapshot;
129
180
  setWorkspaceMode(mode: WorkspaceMode): void;
130
181
  setZoom(level: ZoomLevel): void;
131
182
  getPageLayoutSnapshot(): PageLayoutSnapshot | null;
132
183
  getDocumentNavigationSnapshot(): DocumentNavigationSnapshot;
184
+ getFieldSnapshot(): FieldSnapshot;
185
+ updateFields(options?: UpdateFieldsOptions): UpdateFieldsResult;
186
+ updateTableOfContents(options?: TocRefreshOptions): TocRefreshResult;
133
187
  getSessionState(): EditorSessionState;
134
188
  getPersistedSnapshot(): PersistedEditorSnapshot;
135
189
  getCompatibilityReport(): CompatibilityReport;
136
190
  getWarnings(): EditorWarning[];
137
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;
138
199
  }
139
200
 
140
201
  export interface CreateDocumentRuntimeOptions {
@@ -157,6 +218,7 @@ export interface CreateDocumentRuntimeOptions {
157
218
  onWarning?: (warning: EditorWarning) => void;
158
219
  onError?: (error: EditorError) => void;
159
220
  initialViewState?: Partial<ViewState>;
221
+ protectionSnapshot?: ProtectionSnapshot;
160
222
  }
161
223
 
162
224
  interface HistoryState {
@@ -169,6 +231,7 @@ export function createDocumentRuntime(
169
231
  ): DocumentRuntime {
170
232
  const clock = options.clock ?? (() => new Date().toISOString());
171
233
  const editorBuild = options.editorBuild ?? "dev";
234
+ let defaultAuthorId = options.defaultAuthorId;
172
235
  const sessionId = createSessionId(options.documentId, clock());
173
236
  const listeners = new Set<() => void>();
174
237
  const eventListeners = new Set<(event: DocumentRuntimeEvent) => void>();
@@ -180,6 +243,17 @@ export function createDocumentRuntime(
180
243
  let activeStory: EditorStoryTarget = MAIN_STORY_TARGET;
181
244
  const storySelections = new Map<string, EditorState["selection"]>();
182
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;
183
257
  const initialPersistedSnapshot = options.initialSessionState
184
258
  ? persistedSnapshotFromEditorSessionState(options.initialSessionState, {
185
259
  savedAt: options.initialSessionState.updatedAt,
@@ -196,7 +270,573 @@ export function createDocumentRuntime(
196
270
  fatalError: options.fatalError as never,
197
271
  });
198
272
  storySelections.set(storyTargetKey(MAIN_STORY_TARGET), state.selection);
199
- let cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
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();
200
840
 
201
841
  emit({
202
842
  type: "ready",
@@ -239,16 +879,33 @@ export function createDocumentRuntime(
239
879
  return cachedRenderSnapshot;
240
880
  },
241
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
+
242
898
  if (command.type === "history.undo") {
899
+ if (viewState.documentMode === "viewing") return;
243
900
  applyHistory("undo");
244
901
  return;
245
902
  }
246
903
 
247
904
  if (command.type === "history.redo") {
905
+ if (viewState.documentMode === "viewing") return;
248
906
  applyHistory("redo");
249
907
  return;
250
908
  }
251
-
252
909
  try {
253
910
  const transaction = executeEditorCommand(state, command, {
254
911
  timestamp: command.origin?.timestamp ?? clock(),
@@ -286,12 +943,25 @@ export function createDocumentRuntime(
286
943
  origin: createOrigin("api", clock()),
287
944
  });
288
945
  },
946
+ setDefaultAuthorId(authorId) {
947
+ defaultAuthorId = authorId;
948
+ },
289
949
  replaceText(text, target) {
290
950
  try {
291
951
  const timestamp = clock();
292
952
  const selection = target
293
953
  ? createSelectionFromPublicAnchor(target)
294
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
+ }
295
965
  const result = insertText(state.document, selection, text, { timestamp });
296
966
 
297
967
  this.dispatch({
@@ -299,6 +969,7 @@ export function createDocumentRuntime(
299
969
  document: result.document,
300
970
  mapping: result.mapping,
301
971
  selection: result.selection,
972
+ protectionSelection: selection,
302
973
  origin: createOrigin("api", timestamp),
303
974
  });
304
975
  } catch (error) {
@@ -306,10 +977,16 @@ export function createDocumentRuntime(
306
977
  }
307
978
  },
308
979
  addComment(params) {
980
+ if (viewState.documentMode === "viewing") {
981
+ throw new Error("Cannot add comments in viewing mode.");
982
+ }
309
983
  const commentId = createEntityId("comment", state.document.review.comments, clock());
310
984
  const anchor = params.anchor
311
985
  ? toInternalAnchorProjection(params.anchor)
312
986
  : state.selection.activeRange;
987
+ const selection = params.anchor
988
+ ? createSelectionFromPublicAnchor(params.anchor)
989
+ : state.selection;
313
990
  if (!canCreateDocxCommentAnchor(state.document.content, anchor)) {
314
991
  const message =
315
992
  "DOCX comments must use a non-empty range that stays within a single paragraph.";
@@ -322,7 +999,7 @@ export function createDocumentRuntime(
322
999
  });
323
1000
  throw new Error(message);
324
1001
  }
325
- const authorId = params.authorId ?? options.defaultAuthorId ?? "unknown";
1002
+ const authorId = params.authorId ?? defaultAuthorId ?? "unknown";
326
1003
  const createdAt = clock();
327
1004
  const entries: CommentEntryRecord[] = [
328
1005
  {
@@ -351,6 +1028,7 @@ export function createDocumentRuntime(
351
1028
  this.dispatch({
352
1029
  type: "comment.add",
353
1030
  comment,
1031
+ selection,
354
1032
  origin: createOrigin("api", clock()),
355
1033
  });
356
1034
 
@@ -367,7 +1045,7 @@ export function createDocumentRuntime(
367
1045
  this.dispatch({
368
1046
  type: "comment.resolve",
369
1047
  commentId,
370
- resolvedBy: options.defaultAuthorId ?? "unknown",
1048
+ resolvedBy: defaultAuthorId ?? "unknown",
371
1049
  origin: createOrigin("api", clock()),
372
1050
  });
373
1051
  },
@@ -383,7 +1061,7 @@ export function createDocumentRuntime(
383
1061
  type: "comment.add-reply",
384
1062
  commentId,
385
1063
  body,
386
- authorId: authorId ?? options.defaultAuthorId,
1064
+ authorId: authorId ?? defaultAuthorId,
387
1065
  origin: createOrigin("api", clock()),
388
1066
  });
389
1067
  },
@@ -449,51 +1127,88 @@ export function createDocumentRuntime(
449
1127
  return activeStory;
450
1128
  },
451
1129
  getViewState() {
452
- const surface = cachedRenderSnapshot.surface;
453
- const mainSurface =
454
- activeStory.kind === "main"
455
- ? surface
456
- : createEditorSurfaceSnapshot(state.document, state.selection, MAIN_STORY_TARGET);
457
- return createEditorViewStateSnapshot(
458
- viewState,
459
- activeStory,
460
- toPublicSelectionSnapshot(state.selection, activeStory),
461
- surface,
462
- mainSurface,
463
- cachedRenderSnapshot.pageLayout,
464
- state.document.numbering,
465
- );
1130
+ return getCachedViewStateSnapshot();
466
1131
  },
467
1132
  setViewMode(mode) {
468
1133
  viewState = applyViewMode(viewState, mode);
469
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
1134
+ cachedRenderSnapshot = refreshRenderSnapshot();
1135
+ for (const listener of listeners) {
1136
+ listener();
1137
+ }
1138
+ },
1139
+ setDocumentMode(mode) {
1140
+ viewState = applyDocumentMode(viewState, mode);
1141
+ cachedRenderSnapshot = refreshRenderSnapshot();
470
1142
  for (const listener of listeners) {
471
1143
  listener();
472
1144
  }
473
1145
  },
1146
+ getProtectionSnapshot() {
1147
+ return cachedRenderSnapshot.protectionSnapshot;
1148
+ },
474
1149
  setWorkspaceMode(mode) {
475
1150
  viewState = applyWorkspaceMode(viewState, mode);
476
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
1151
+ cachedRenderSnapshot = refreshRenderSnapshot();
477
1152
  for (const listener of listeners) {
478
1153
  listener();
479
1154
  }
480
1155
  },
481
1156
  setZoom(level) {
482
1157
  viewState = applyZoomLevel(viewState, level);
483
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
1158
+ cachedRenderSnapshot = refreshRenderSnapshot();
484
1159
  for (const listener of listeners) {
485
1160
  listener();
486
1161
  }
487
1162
  },
488
1163
  getPageLayoutSnapshot() {
489
- return derivePageLayoutSnapshot(state, activeStory, storySelections);
1164
+ return getCachedPageLayoutSnapshot(state, activeStory);
490
1165
  },
491
1166
  getDocumentNavigationSnapshot() {
492
- return createDocumentNavigationSnapshot(
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(
493
1197
  state.document,
494
1198
  state.selection.head,
495
1199
  activeStory,
1200
+ options,
496
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;
497
1212
  },
498
1213
  getSessionState() {
499
1214
  const compatibility = createDerivedCompatibility(state);
@@ -502,6 +1217,7 @@ export function createDocumentRuntime(
502
1217
  editorBuild,
503
1218
  savedAt: clock(),
504
1219
  compatibility,
1220
+ protectionSnapshot,
505
1221
  }) as unknown as PersistedEditorSnapshot,
506
1222
  );
507
1223
  },
@@ -542,6 +1258,75 @@ export function createDocumentRuntime(
542
1258
 
543
1259
  return result;
544
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
+ },
545
1330
  };
546
1331
 
547
1332
  function applyHistory(direction: "undo" | "redo"): void {
@@ -560,7 +1345,7 @@ export function createDocumentRuntime(
560
1345
  // autosave/export checkpoint dedup treats it as fresh content.
561
1346
  state = finalizeState(target, true, clock());
562
1347
  storySelections.set(storyTargetKey(activeStory), state.selection);
563
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
1348
+ cachedRenderSnapshot = refreshRenderSnapshot();
564
1349
  notify(previous, state, {
565
1350
  nextState: state,
566
1351
  mapping: { steps: [] },
@@ -581,9 +1366,10 @@ export function createDocumentRuntime(
581
1366
  history.future = [];
582
1367
  }
583
1368
 
1369
+ protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
584
1370
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
585
1371
  storySelections.set(storyTargetKey(activeStory), state.selection);
586
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
1372
+ cachedRenderSnapshot = refreshRenderSnapshot();
587
1373
  notify(previous, state, transaction);
588
1374
  }
589
1375
 
@@ -680,7 +1466,7 @@ export function createDocumentRuntime(
680
1466
  };
681
1467
  state = nextState;
682
1468
  storySelections.set(storyTargetKey(activeStory), state.selection);
683
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
1469
+ cachedRenderSnapshot = refreshRenderSnapshot();
684
1470
  const publicError = toPublicError(error);
685
1471
  options.onError?.(publicError);
686
1472
  emit({
@@ -706,7 +1492,7 @@ export function createDocumentRuntime(
706
1492
  selection: restoredSelection,
707
1493
  };
708
1494
  storySelections.set(storyTargetKey(target), restoredSelection);
709
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
1495
+ cachedRenderSnapshot = refreshRenderSnapshot();
710
1496
 
711
1497
  if (selectionChanged(previousSelection, restoredSelection)) {
712
1498
  emit({
@@ -800,51 +1586,6 @@ function toRuntimeError(error: unknown): InternalEditorError {
800
1586
  };
801
1587
  }
802
1588
 
803
- function createPublicRenderSnapshot(
804
- state: EditorState,
805
- history: HistoryState,
806
- activeStory: EditorStoryTarget,
807
- ): RuntimeRenderSnapshot {
808
- const compatibility = createDerivedCompatibility(state);
809
- const surface = createEditorSurfaceSnapshot(state.document, state.selection, activeStory);
810
- const comments = toPublicCommentSidebarSnapshot(state);
811
- const trackedChanges = toPublicTrackedChangesSnapshot(state, surface.plainText);
812
- const pageLayout = derivePageLayoutSnapshot(state, activeStory);
813
-
814
- return {
815
- documentId: state.documentId,
816
- sessionId: state.sessionId,
817
- sourceLabel: state.sourceLabel,
818
- revisionToken: state.revisionToken,
819
- isReady: state.phase === "ready",
820
- isDirty: state.isDirty,
821
- readOnly: state.readOnly,
822
- selection: toPublicSelectionSnapshot(state.selection, activeStory),
823
- activeStory,
824
- pageLayout: pageLayout ?? undefined,
825
- documentStats: toPublicDocumentStats(state),
826
- comments,
827
- trackedChanges,
828
- compatibility: {
829
- blockExport: compatibility.blockExport,
830
- blockExportReasons: listBlockExportReasons(compatibility),
831
- warningCount: compatibility.warnings.length,
832
- errorCount: compatibility.errors.length,
833
- featureEntries: compatibility.featureEntries.map((entry) =>
834
- toPublicCompatibilityFeatureEntry(entry),
835
- ),
836
- },
837
- warnings: state.warnings.map((warning) => toPublicWarning(warning)),
838
- fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
839
- commandState: {
840
- canUndo: history.past.length > 0,
841
- canRedo: history.future.length > 0,
842
- readOnly: state.readOnly,
843
- },
844
- surface,
845
- };
846
- }
847
-
848
1589
  function toPublicDocumentStats(state: Pick<EditorState, "document">) {
849
1590
  const stats = deriveDocumentStats(state);
850
1591
  return {
@@ -1259,3 +2000,708 @@ function derivePageLayoutSnapshot(
1259
2000
  function isRecord(value: unknown): value is Record<string, unknown> {
1260
2001
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1261
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
+ }