@beyondwork/docx-react-component 1.0.18 → 1.0.19

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 (74) 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 +374 -4
  5. package/src/api/session-state.ts +58 -0
  6. package/src/core/commands/formatting-commands.ts +1 -0
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +5 -1
  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 +329 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +1 -1
  16. package/src/index.ts +30 -0
  17. package/src/io/docx-session.ts +260 -39
  18. package/src/io/export/serialize-main-document.ts +202 -5
  19. package/src/io/export/serialize-numbering.ts +28 -7
  20. package/src/io/normalize/normalize-text.ts +63 -25
  21. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  22. package/src/io/ooxml/parse-footnotes.ts +212 -20
  23. package/src/io/ooxml/parse-headers-footers.ts +229 -25
  24. package/src/io/ooxml/parse-inline-media.ts +16 -0
  25. package/src/io/ooxml/parse-main-document.ts +411 -6
  26. package/src/io/ooxml/parse-numbering.ts +7 -0
  27. package/src/io/ooxml/parse-settings.ts +184 -0
  28. package/src/io/ooxml/parse-shapes.ts +25 -0
  29. package/src/io/ooxml/parse-styles.ts +463 -0
  30. package/src/io/ooxml/parse-theme.ts +32 -0
  31. package/src/model/canonical-document.ts +133 -3
  32. package/src/model/cds-1.0.0.ts +13 -0
  33. package/src/model/snapshot.ts +2 -1
  34. package/src/runtime/document-layout.ts +332 -0
  35. package/src/runtime/document-navigation.ts +564 -0
  36. package/src/runtime/document-runtime.ts +265 -35
  37. package/src/runtime/document-search.ts +145 -0
  38. package/src/runtime/numbering-prefix.ts +47 -26
  39. package/src/runtime/page-layout-estimation.ts +212 -0
  40. package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
  41. package/src/runtime/session-capabilities.ts +2 -0
  42. package/src/runtime/story-context.ts +164 -0
  43. package/src/runtime/story-targeting.ts +162 -0
  44. package/src/runtime/surface-projection.ts +239 -12
  45. package/src/runtime/table-schema.ts +87 -5
  46. package/src/runtime/view-state.ts +459 -0
  47. package/src/ui/WordReviewEditor.tsx +1902 -312
  48. package/src/ui/browser-export.ts +52 -0
  49. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  50. package/src/ui/headless/selection-helpers.ts +20 -0
  51. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  52. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  53. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  54. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
  57. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  58. package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
  59. package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
  60. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
  61. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  62. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  63. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
  64. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  65. package/src/ui-tailwind/index.ts +2 -1
  66. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
  68. package/src/ui-tailwind/theme/editor-theme.css +123 -0
  69. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  70. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
  71. package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
  72. package/src/validation/compatibility-engine.ts +92 -20
  73. package/src/validation/diagnostics.ts +1 -0
  74. package/src/validation/docx-comment-proof.ts +487 -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,32 @@ import type {
17
17
  CommentSidebarSnapshot,
18
18
  CommentSidebarThreadSnapshot,
19
19
  CompatibilityReport,
20
+ DocumentNavigationSnapshot,
21
+ EditorSessionState,
20
22
  EditorAnchorProjection,
21
23
  EditorError,
24
+ EditorStoryTarget,
25
+ EditorViewStateSnapshot,
22
26
  EditorWarning,
27
+ HeaderFooterLinkPatch,
23
28
  ExportDocxOptions,
24
29
  ExportResult,
30
+ PageLayoutSnapshot,
25
31
  PersistedEditorSnapshot,
26
32
  RuntimeRenderSnapshot,
27
33
  SelectionSnapshot,
34
+ StyleCatalogSnapshot,
28
35
  TrackedChangeEntrySnapshot,
29
36
  TrackedChangesSnapshot,
37
+ ViewMode,
38
+ WorkspaceMode,
30
39
  WordReviewEditorEvent,
40
+ ZoomLevel,
31
41
  } from "../api/public-types";
42
+ import {
43
+ editorSessionStateFromPersistedSnapshot,
44
+ persistedSnapshotFromEditorSessionState,
45
+ } from "../api/session-state.ts";
32
46
  import {
33
47
  executeEditorCommand,
34
48
  selectionChanged,
@@ -41,6 +55,8 @@ import {
41
55
  createDetachedAnchor,
42
56
  createNodeAnchor,
43
57
  createRangeAnchor,
58
+ MAIN_STORY_TARGET,
59
+ storyTargetsEqual,
44
60
  type EditorAnchorProjection as InternalEditorAnchorProjection,
45
61
  } from "../core/selection/mapping.ts";
46
62
  import { canCreateDocxCommentAnchor } from "../core/selection/review-anchors.ts";
@@ -53,21 +69,48 @@ import {
53
69
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
54
70
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
55
71
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
56
- import { getFormattingStateFromRenderSnapshot } from "../core/commands/formatting-commands.ts";
72
+ import { createDocumentNavigationSnapshot } from "./document-navigation.ts";
73
+ import {
74
+ buildPageLayoutSnapshot,
75
+ buildResolvedSections,
76
+ resolveActiveSection,
77
+ } from "./document-layout.ts";
78
+ import { normalizeHeaderFooterTarget } from "./story-context.ts";
79
+ import { storyTargetKey } from "./story-targeting.ts";
80
+ import {
81
+ createViewState,
82
+ setViewMode as applyViewMode,
83
+ setWorkspaceMode as applyWorkspaceMode,
84
+ setZoomLevel as applyZoomLevel,
85
+ setFocused as applyFocused,
86
+ setCaretAffinity as applyCaretAffinity,
87
+ setActivePageRegion as applyActivePageRegion,
88
+ setActiveObjectFrame as applyActiveObjectFrame,
89
+ createEditorViewStateSnapshot,
90
+ type ViewState,
91
+ } from "./view-state.ts";
92
+ import type { PageMargins } from "../model/canonical-document.ts";
57
93
 
58
94
  export type Unsubscribe = () => void;
59
95
 
96
+ type RuntimeReadySource = "docx" | "session" | "snapshot" | "datastore" | "canonical";
97
+
98
+ export type DocumentRuntimeEvent =
99
+ | (Omit<Extract<WordReviewEditorEvent, { type: "ready" }>, "source"> & {
100
+ source: RuntimeReadySource;
101
+ })
102
+ | Exclude<WordReviewEditorEvent, { type: "ready" }>;
103
+
60
104
  export interface DocumentRuntime {
61
105
  subscribe(listener: () => void): Unsubscribe;
62
- subscribeToEvents(listener: (event: WordReviewEditorEvent) => void): Unsubscribe;
106
+ subscribeToEvents(listener: (event: DocumentRuntimeEvent) => void): Unsubscribe;
63
107
  getRenderSnapshot(): RuntimeRenderSnapshot;
64
- getFormattingState(): import("../api/public-types").FormattingStateSnapshot;
108
+ replaceText(text: string, target?: EditorAnchorProjection): void;
65
109
  dispatch(command: EditorCommand): void;
66
110
  undo(): void;
67
111
  redo(): void;
68
112
  focus(): void;
69
113
  blur(): void;
70
- replaceText(text: string, target?: EditorAnchorProjection): void;
71
114
  addComment(params: AddCommentParams): string;
72
115
  openComment(commentId: string): void;
73
116
  resolveComment(commentId: string): void;
@@ -78,6 +121,16 @@ export interface DocumentRuntime {
78
121
  rejectChange(changeId: string): void;
79
122
  acceptAllChanges(): void;
80
123
  rejectAllChanges(): void;
124
+ openStory(target: EditorStoryTarget): boolean;
125
+ closeStory(): void;
126
+ getActiveStory(): EditorStoryTarget;
127
+ getViewState(): EditorViewStateSnapshot;
128
+ setViewMode(mode: ViewMode): void;
129
+ setWorkspaceMode(mode: WorkspaceMode): void;
130
+ setZoom(level: ZoomLevel): void;
131
+ getPageLayoutSnapshot(): PageLayoutSnapshot | null;
132
+ getDocumentNavigationSnapshot(): DocumentNavigationSnapshot;
133
+ getSessionState(): EditorSessionState;
81
134
  getPersistedSnapshot(): PersistedEditorSnapshot;
82
135
  getCompatibilityReport(): CompatibilityReport;
83
136
  getWarnings(): EditorWarning[];
@@ -86,22 +139,24 @@ export interface DocumentRuntime {
86
139
 
87
140
  export interface CreateDocumentRuntimeOptions {
88
141
  documentId: string;
142
+ initialSessionState?: EditorSessionState;
89
143
  initialSnapshot?: PersistedEditorSnapshot;
90
144
  initialCanonicalDocument?: CanonicalDocumentEnvelope;
91
145
  sourceLabel?: string;
92
- sourceKind?: "docx" | "snapshot" | "datastore" | "canonical";
146
+ sourceKind?: RuntimeReadySource;
93
147
  readOnly?: boolean;
94
148
  editorBuild?: string;
95
149
  defaultAuthorId?: string;
96
150
  fatalError?: EditorError;
97
151
  clock?: () => string;
98
152
  exportDocx?: (
99
- snapshot: PersistedEditorSnapshot,
153
+ sessionState: EditorSessionState,
100
154
  options?: ExportDocxOptions,
101
155
  ) => Promise<ExportResult>;
102
- onEvent?: (event: WordReviewEditorEvent) => void;
156
+ onEvent?: (event: DocumentRuntimeEvent) => void;
103
157
  onWarning?: (warning: EditorWarning) => void;
104
158
  onError?: (error: EditorError) => void;
159
+ initialViewState?: Partial<ViewState>;
105
160
  }
106
161
 
107
162
  interface HistoryState {
@@ -116,28 +171,44 @@ export function createDocumentRuntime(
116
171
  const editorBuild = options.editorBuild ?? "dev";
117
172
  const sessionId = createSessionId(options.documentId, clock());
118
173
  const listeners = new Set<() => void>();
119
- const eventListeners = new Set<(event: WordReviewEditorEvent) => void>();
174
+ const eventListeners = new Set<(event: DocumentRuntimeEvent) => void>();
120
175
  const history: HistoryState = {
121
176
  past: [],
122
177
  future: [],
123
178
  };
124
179
 
180
+ let activeStory: EditorStoryTarget = MAIN_STORY_TARGET;
181
+ const storySelections = new Map<string, EditorState["selection"]>();
182
+ let viewState: ViewState = createViewState(options.initialViewState);
183
+ const initialPersistedSnapshot = options.initialSessionState
184
+ ? persistedSnapshotFromEditorSessionState(options.initialSessionState, {
185
+ savedAt: options.initialSessionState.updatedAt,
186
+ })
187
+ : options.initialSnapshot;
188
+
125
189
  let state = createEditorState({
126
190
  documentId: options.documentId,
127
191
  sessionId,
128
192
  sourceLabel: options.sourceLabel,
129
193
  readOnly: options.readOnly,
130
- persistedSnapshot: options.initialSnapshot as never,
194
+ persistedSnapshot: initialPersistedSnapshot as never,
131
195
  canonicalDocument: options.initialCanonicalDocument,
132
196
  fatalError: options.fatalError as never,
133
197
  });
134
- let cachedRenderSnapshot = createPublicRenderSnapshot(state, history);
198
+ storySelections.set(storyTargetKey(MAIN_STORY_TARGET), state.selection);
199
+ let cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
135
200
 
136
201
  emit({
137
202
  type: "ready",
138
203
  documentId: state.documentId,
139
204
  sessionId: state.sessionId,
140
- source: options.sourceKind ?? (options.initialSnapshot ? "snapshot" : "canonical"),
205
+ source:
206
+ options.sourceKind ??
207
+ (options.initialSessionState
208
+ ? "session"
209
+ : options.initialSnapshot
210
+ ? "snapshot"
211
+ : "canonical"),
141
212
  stats: toPublicDocumentStats(state),
142
213
  compatibility: toPublicCompatibilityReport(createDerivedCompatibility(state)),
143
214
  comments: cachedRenderSnapshot.comments,
@@ -167,9 +238,6 @@ export function createDocumentRuntime(
167
238
  getRenderSnapshot() {
168
239
  return cachedRenderSnapshot;
169
240
  },
170
- getFormattingState() {
171
- return getFormattingStateFromRenderSnapshot(cachedRenderSnapshot);
172
- },
173
241
  dispatch(command) {
174
242
  if (command.type === "history.undo") {
175
243
  applyHistory("undo");
@@ -203,6 +271,7 @@ export function createDocumentRuntime(
203
271
  });
204
272
  },
205
273
  focus() {
274
+ viewState = applyFocused(viewState, true);
206
275
  this.dispatch({
207
276
  type: "runtime.focus",
208
277
  focused: true,
@@ -210,6 +279,7 @@ export function createDocumentRuntime(
210
279
  });
211
280
  },
212
281
  blur() {
282
+ viewState = applyFocused(viewState, false);
213
283
  this.dispatch({
214
284
  type: "runtime.focus",
215
285
  focused: false,
@@ -351,13 +421,94 @@ export function createDocumentRuntime(
351
421
  origin: createOrigin("api", clock()),
352
422
  });
353
423
  },
354
- getPersistedSnapshot() {
424
+ openStory(target) {
425
+ const normalizedTarget =
426
+ target.kind === "header" || target.kind === "footer"
427
+ ? normalizeHeaderFooterTarget(
428
+ state.document,
429
+ target,
430
+ cachedRenderSnapshot.pageLayout?.sectionIndex,
431
+ ) ?? target
432
+ : target;
433
+ if (storyTargetsEqual(activeStory, normalizedTarget)) {
434
+ return true;
435
+ }
436
+ if (!isValidStoryTarget(state, normalizedTarget)) {
437
+ return false;
438
+ }
439
+ switchActiveStory(normalizedTarget);
440
+ return true;
441
+ },
442
+ closeStory() {
443
+ if (activeStory.kind === "main") {
444
+ return;
445
+ }
446
+ switchActiveStory(MAIN_STORY_TARGET);
447
+ },
448
+ getActiveStory() {
449
+ return activeStory;
450
+ },
451
+ 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
+ );
466
+ },
467
+ setViewMode(mode) {
468
+ viewState = applyViewMode(viewState, mode);
469
+ cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
470
+ for (const listener of listeners) {
471
+ listener();
472
+ }
473
+ },
474
+ setWorkspaceMode(mode) {
475
+ viewState = applyWorkspaceMode(viewState, mode);
476
+ cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
477
+ for (const listener of listeners) {
478
+ listener();
479
+ }
480
+ },
481
+ setZoom(level) {
482
+ viewState = applyZoomLevel(viewState, level);
483
+ cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
484
+ for (const listener of listeners) {
485
+ listener();
486
+ }
487
+ },
488
+ getPageLayoutSnapshot() {
489
+ return derivePageLayoutSnapshot(state, activeStory, storySelections);
490
+ },
491
+ getDocumentNavigationSnapshot() {
492
+ return createDocumentNavigationSnapshot(
493
+ state.document,
494
+ state.selection.head,
495
+ activeStory,
496
+ );
497
+ },
498
+ getSessionState() {
355
499
  const compatibility = createDerivedCompatibility(state);
356
- return createPersistedEditorSnapshot(state, {
357
- editorBuild,
500
+ return editorSessionStateFromPersistedSnapshot(
501
+ createPersistedEditorSnapshot(state, {
502
+ editorBuild,
503
+ savedAt: clock(),
504
+ compatibility,
505
+ }) as unknown as PersistedEditorSnapshot,
506
+ );
507
+ },
508
+ getPersistedSnapshot() {
509
+ return persistedSnapshotFromEditorSessionState(this.getSessionState(), {
358
510
  savedAt: clock(),
359
- compatibility,
360
- }) as unknown as PersistedEditorSnapshot;
511
+ });
361
512
  },
362
513
  getCompatibilityReport() {
363
514
  return toPublicCompatibilityReport(createDerivedCompatibility(state));
@@ -381,14 +532,7 @@ export function createDocumentRuntime(
381
532
  throw new Error(error.message);
382
533
  }
383
534
 
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
- );
535
+ const result = await options.exportDocx(this.getSessionState(), exportOptions);
392
536
 
393
537
  emit({
394
538
  type: "export_completed",
@@ -415,7 +559,8 @@ export function createDocumentRuntime(
415
559
  // Undo/redo changes the document — must mint a new revisionToken so
416
560
  // autosave/export checkpoint dedup treats it as fresh content.
417
561
  state = finalizeState(target, true, clock());
418
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history);
562
+ storySelections.set(storyTargetKey(activeStory), state.selection);
563
+ cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
419
564
  notify(previous, state, {
420
565
  nextState: state,
421
566
  mapping: { steps: [] },
@@ -437,7 +582,8 @@ export function createDocumentRuntime(
437
582
  }
438
583
 
439
584
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
440
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history);
585
+ storySelections.set(storyTargetKey(activeStory), state.selection);
586
+ cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
441
587
  notify(previous, state, transaction);
442
588
  }
443
589
 
@@ -458,7 +604,7 @@ export function createDocumentRuntime(
458
604
  emit({
459
605
  type: "selection_changed",
460
606
  documentId: next.documentId,
461
- selection: toPublicSelectionSnapshot(next.selection),
607
+ selection: toPublicSelectionSnapshot(next.selection, activeStory),
462
608
  });
463
609
  }
464
610
 
@@ -519,7 +665,7 @@ export function createDocumentRuntime(
519
665
  }
520
666
  }
521
667
 
522
- function emit(event: WordReviewEditorEvent): void {
668
+ function emit(event: DocumentRuntimeEvent): void {
523
669
  options.onEvent?.(event);
524
670
  for (const listener of eventListeners) {
525
671
  listener(event);
@@ -533,7 +679,8 @@ export function createDocumentRuntime(
533
679
  fatalError: error.isFatal ? error : state.fatalError,
534
680
  };
535
681
  state = nextState;
536
- cachedRenderSnapshot = createPublicRenderSnapshot(state, history);
682
+ storySelections.set(storyTargetKey(activeStory), state.selection);
683
+ cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
537
684
  const publicError = toPublicError(error);
538
685
  options.onError?.(publicError);
539
686
  emit({
@@ -545,6 +692,39 @@ export function createDocumentRuntime(
545
692
  listener();
546
693
  }
547
694
  }
695
+
696
+ function switchActiveStory(target: EditorStoryTarget): void {
697
+ const previousStory = activeStory;
698
+ const previousSelection = state.selection;
699
+ storySelections.set(storyTargetKey(previousStory), previousSelection);
700
+
701
+ const restoredSelection =
702
+ storySelections.get(storyTargetKey(target)) ?? createSelectionSnapshot(0, 0);
703
+ activeStory = target;
704
+ state = {
705
+ ...state,
706
+ selection: restoredSelection,
707
+ };
708
+ storySelections.set(storyTargetKey(target), restoredSelection);
709
+ cachedRenderSnapshot = createPublicRenderSnapshot(state, history, activeStory);
710
+
711
+ if (selectionChanged(previousSelection, restoredSelection)) {
712
+ emit({
713
+ type: "selection_changed",
714
+ documentId: state.documentId,
715
+ selection: toPublicSelectionSnapshot(restoredSelection, activeStory),
716
+ });
717
+ }
718
+
719
+ emit({
720
+ type: "story_changed",
721
+ documentId: state.documentId,
722
+ activeStory,
723
+ });
724
+ for (const listener of listeners) {
725
+ listener();
726
+ }
727
+ }
548
728
  }
549
729
 
550
730
  function createSessionId(documentId: string, timestamp: string): string {
@@ -623,11 +803,13 @@ function toRuntimeError(error: unknown): InternalEditorError {
623
803
  function createPublicRenderSnapshot(
624
804
  state: EditorState,
625
805
  history: HistoryState,
806
+ activeStory: EditorStoryTarget,
626
807
  ): RuntimeRenderSnapshot {
627
808
  const compatibility = createDerivedCompatibility(state);
628
- const surface = createEditorSurfaceSnapshot(state.document, state.selection);
809
+ const surface = createEditorSurfaceSnapshot(state.document, state.selection, activeStory);
629
810
  const comments = toPublicCommentSidebarSnapshot(state);
630
811
  const trackedChanges = toPublicTrackedChangesSnapshot(state, surface.plainText);
812
+ const pageLayout = derivePageLayoutSnapshot(state, activeStory);
631
813
 
632
814
  return {
633
815
  documentId: state.documentId,
@@ -637,7 +819,9 @@ function createPublicRenderSnapshot(
637
819
  isReady: state.phase === "ready",
638
820
  isDirty: state.isDirty,
639
821
  readOnly: state.readOnly,
640
- selection: toPublicSelectionSnapshot(state.selection),
822
+ selection: toPublicSelectionSnapshot(state.selection, activeStory),
823
+ activeStory,
824
+ pageLayout: pageLayout ?? undefined,
641
825
  documentStats: toPublicDocumentStats(state),
642
826
  comments,
643
827
  trackedChanges,
@@ -673,12 +857,14 @@ function toPublicDocumentStats(state: Pick<EditorState, "document">) {
673
857
 
674
858
  function toPublicSelectionSnapshot(
675
859
  selection: EditorState["selection"],
860
+ storyTarget?: EditorStoryTarget,
676
861
  ): SelectionSnapshot {
677
862
  return {
678
863
  anchor: selection.anchor,
679
864
  head: selection.head,
680
865
  isCollapsed: selection.isCollapsed,
681
866
  activeRange: toPublicAnchorProjection(selection.activeRange),
867
+ ...(storyTarget && storyTarget.kind !== "main" ? { storyTarget } : {}),
682
868
  };
683
869
  }
684
870
 
@@ -1026,6 +1212,50 @@ function summarizeRevisionExcerpt(
1026
1212
  return collapsed.length > 96 ? `${collapsed.slice(0, 93)}...` : collapsed;
1027
1213
  }
1028
1214
 
1215
+ function isValidStoryTarget(
1216
+ state: EditorState,
1217
+ target: EditorStoryTarget,
1218
+ ): boolean {
1219
+ if (target.kind === "main") return true;
1220
+ const subParts = state.document.subParts;
1221
+ if (!subParts) return false;
1222
+
1223
+ switch (target.kind) {
1224
+ case "header":
1225
+ return Boolean(normalizeHeaderFooterTarget(state.document, target));
1226
+ case "footer":
1227
+ return Boolean(normalizeHeaderFooterTarget(state.document, target));
1228
+ case "footnote":
1229
+ return Boolean(subParts.footnoteCollection?.footnotes?.[target.noteId]);
1230
+ case "endnote":
1231
+ return Boolean(subParts.footnoteCollection?.endnotes?.[target.noteId]);
1232
+ }
1233
+ }
1234
+
1235
+ function derivePageLayoutSnapshot(
1236
+ state: EditorState,
1237
+ activeStory: EditorStoryTarget,
1238
+ storySelections?: ReadonlyMap<string, EditorState["selection"]>,
1239
+ ): PageLayoutSnapshot | null {
1240
+ const subParts = state.document.subParts;
1241
+ const sections = buildResolvedSections(state.document);
1242
+ if (!subParts && sections.length === 0) {
1243
+ return null;
1244
+ }
1245
+
1246
+ const activeSection = resolveActiveSection(
1247
+ state,
1248
+ activeStory,
1249
+ sections,
1250
+ storySelections,
1251
+ );
1252
+ return buildPageLayoutSnapshot(
1253
+ activeSection?.index ?? 0,
1254
+ activeSection?.properties ?? subParts?.finalSectionProperties,
1255
+ subParts,
1256
+ );
1257
+ }
1258
+
1029
1259
  function isRecord(value: unknown): value is Record<string, unknown> {
1030
1260
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1031
1261
  }
@@ -0,0 +1,145 @@
1
+ import type {
2
+ DocumentNavigationSnapshot,
3
+ EditorStoryTarget,
4
+ SearchOptions,
5
+ SearchResultSnapshot,
6
+ SelectionSnapshot,
7
+ } from "../api/public-types";
8
+ import {
9
+ MAIN_STORY_TARGET,
10
+ storyTargetsEqual,
11
+ } from "../core/selection/mapping.ts";
12
+ import {
13
+ createSelectionSnapshot,
14
+ type CanonicalDocumentEnvelope,
15
+ } from "../core/state/editor-state.ts";
16
+ import {
17
+ searchSecondaryStories,
18
+ searchSurfaceBlocks,
19
+ } from "../core/search/search-text.ts";
20
+ import { findPageForOffset } from "./document-navigation.ts";
21
+ import {
22
+ buildResolvedSections,
23
+ resolveSectionForStoryTarget,
24
+ } from "./document-layout.ts";
25
+ import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
26
+
27
+ export function searchDocument(
28
+ document: CanonicalDocumentEnvelope,
29
+ selection: SelectionSnapshot,
30
+ activeStory: EditorStoryTarget,
31
+ navigation: DocumentNavigationSnapshot,
32
+ query: string,
33
+ options: SearchOptions = {},
34
+ ): SearchResultSnapshot[] {
35
+ const normalizedQuery = query.trim();
36
+ if (!normalizedQuery) {
37
+ return [];
38
+ }
39
+
40
+ const mainSurface = createEditorSurfaceSnapshot(
41
+ document,
42
+ createSelectionSnapshot(selection.anchor, selection.head),
43
+ MAIN_STORY_TARGET,
44
+ );
45
+ const sections = buildResolvedSections(document);
46
+ const combined: SearchResultSnapshot[] = [];
47
+
48
+ for (const match of searchSurfaceBlocks(mainSurface.blocks, normalizedQuery, options)) {
49
+ const pageIndex = findPageForOffset(navigation.pages, match.from);
50
+ combined.push({
51
+ resultId: `search-main-${combined.length}`,
52
+ anchor: {
53
+ kind: "range",
54
+ from: match.from,
55
+ to: match.to,
56
+ assoc: {
57
+ start: -1,
58
+ end: 1,
59
+ },
60
+ },
61
+ excerpt: match.excerpt,
62
+ isActive: false,
63
+ storyTarget: MAIN_STORY_TARGET,
64
+ sectionIndex: navigation.pages[pageIndex]?.sectionIndex ?? 0,
65
+ pageIndex,
66
+ });
67
+ }
68
+
69
+ for (const match of searchSecondaryStories(
70
+ mainSurface.secondaryStories,
71
+ normalizedQuery,
72
+ options,
73
+ )) {
74
+ const section = resolveSectionForStoryTarget(
75
+ document,
76
+ sections,
77
+ match.storyTarget,
78
+ );
79
+ const pageIndex =
80
+ section === undefined
81
+ ? undefined
82
+ : navigation.pages.find((page) => page.sectionIndex === section.index)
83
+ ?.pageIndex ?? 0;
84
+
85
+ combined.push({
86
+ resultId: `search-secondary-${combined.length}`,
87
+ anchor: {
88
+ kind: "range",
89
+ from: match.from,
90
+ to: match.to,
91
+ assoc: {
92
+ start: -1,
93
+ end: 1,
94
+ },
95
+ },
96
+ excerpt: match.excerpt,
97
+ isActive: false,
98
+ storyTarget: match.storyTarget,
99
+ ...(section ? { sectionIndex: section.index } : {}),
100
+ ...(pageIndex !== undefined ? { pageIndex } : {}),
101
+ });
102
+ }
103
+
104
+ const limited = combined.slice(0, options.limit ?? Number.POSITIVE_INFINITY);
105
+ const activeIndex = getActiveSearchResultIndex(limited, selection, activeStory);
106
+
107
+ return limited.map((result, index) => ({
108
+ ...result,
109
+ isActive: index === activeIndex,
110
+ }));
111
+ }
112
+
113
+ function getActiveSearchResultIndex(
114
+ results: readonly SearchResultSnapshot[],
115
+ selection: SelectionSnapshot,
116
+ activeStory: EditorStoryTarget,
117
+ ): number {
118
+ if (results.length === 0) {
119
+ return -1;
120
+ }
121
+
122
+ const selectionFrom = Math.min(selection.anchor, selection.head);
123
+ const selectionTo = Math.max(selection.anchor, selection.head);
124
+ const activeIndex = results.findIndex((result) => {
125
+ if (!result.storyTarget || !storyTargetsEqual(result.storyTarget, activeStory)) {
126
+ return false;
127
+ }
128
+
129
+ if (result.anchor.kind !== "range") {
130
+ return false;
131
+ }
132
+
133
+ if (selectionFrom === selectionTo) {
134
+ return (
135
+ selectionFrom >= result.anchor.from && selectionFrom <= result.anchor.to
136
+ );
137
+ }
138
+
139
+ return (
140
+ selectionFrom < result.anchor.to && selectionTo > result.anchor.from
141
+ );
142
+ });
143
+
144
+ return activeIndex >= 0 ? activeIndex : 0;
145
+ }