@beyondwork/docx-react-component 1.0.84 → 1.0.86

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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/internal/build-ref-projections.ts +3 -0
  3. package/src/api/public-types.ts +38 -0
  4. package/src/api/v3/_runtime-handle.ts +11 -0
  5. package/src/api/v3/runtime/content.ts +148 -1
  6. package/src/api/v3/runtime/formatting.ts +41 -0
  7. package/src/api/v3/runtime/review.ts +98 -0
  8. package/src/core/commands/index.ts +81 -25
  9. package/src/core/state/editor-state.ts +15 -0
  10. package/src/io/ooxml/header-footer-reference.ts +38 -0
  11. package/src/io/ooxml/parse-headers-footers.ts +11 -23
  12. package/src/io/ooxml/parse-main-document.ts +7 -10
  13. package/src/model/canonical-document.ts +9 -0
  14. package/src/model/review/comment-types.ts +2 -0
  15. package/src/runtime/document-runtime.ts +677 -54
  16. package/src/runtime/formatting/field/resolver.ts +73 -8
  17. package/src/runtime/layout/layout-engine-version.ts +31 -12
  18. package/src/runtime/layout/paginated-layout-engine.ts +18 -11
  19. package/src/runtime/layout/public-facet.ts +119 -16
  20. package/src/runtime/layout/resolve-page-fields.ts +68 -6
  21. package/src/runtime/layout/resolve-page-previews.ts +1 -1
  22. package/src/runtime/suggestions-snapshot.ts +24 -0
  23. package/src/runtime/surface-projection.ts +59 -2
  24. package/src/shell/ref-commands.ts +3 -354
  25. package/src/shell/session-bootstrap.ts +8 -0
  26. package/src/ui/WordReviewEditor.tsx +192 -35
  27. package/src/ui/editor-command-bag.ts +7 -1
  28. package/src/ui/editor-shell-view.tsx +1 -0
  29. package/src/ui/headless/revision-decoration-model.ts +13 -0
  30. package/src/ui/headless/selection-tool-types.ts +2 -0
  31. package/src/ui-tailwind/chrome/editor-action-registry.ts +7 -26
  32. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +6 -2
  33. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +7 -3
  34. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +175 -25
  35. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -1
  36. package/src/ui-tailwind/editor-surface/pm-decorations.ts +12 -0
  37. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +18 -30
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +1 -1
  39. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +1 -1
  40. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +20 -11
  41. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +9 -4
  42. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +12 -7
  43. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +29 -10
  44. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +1 -1
  45. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +8 -3
  46. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -0
  47. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +46 -31
  48. package/src/ui-tailwind/review/tw-workflow-tab.tsx +159 -8
  49. package/src/ui-tailwind/review-workspace/types.ts +7 -2
  50. package/src/ui-tailwind/review-workspace/use-page-markers.ts +11 -1
  51. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +8 -9
  52. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +21 -16
  53. package/src/ui-tailwind/tw-review-workspace.tsx +27 -4
@@ -188,7 +188,11 @@ import {
188
188
  insertScopeMarkers,
189
189
  removeScopeMarkers,
190
190
  } from "../core/commands/add-scope.ts";
191
- import { applyFormattingOperationToDocument } from "../core/commands/formatting-commands.ts";
191
+ import {
192
+ applyFormattingOperationToDocument,
193
+ type FormattingOperation,
194
+ } from "../core/commands/formatting-commands.ts";
195
+ import { resolveActiveParagraphIndex } from "../core/commands/paragraph-layout-commands.ts";
192
196
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
193
197
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
194
198
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
@@ -207,7 +211,6 @@ import {
207
211
  } from "./workflow/rail/compose.ts";
208
212
  import {
209
213
  createDocumentNavigationSnapshot,
210
- findPageForOffset,
211
214
  } from "./document-navigation.ts";
212
215
  import {
213
216
  createDocxFontLoader,
@@ -326,7 +329,8 @@ import {
326
329
  import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
327
330
  import type { SharedWorkflowState } from "./collab/workflow-shared.ts";
328
331
  import { mapLocalSelectionOnRemoteReplay } from "./collab/map-local-selection-on-remote-replay.ts";
329
- import { formatPageNumber } from "./formatting/field/page-number-format.ts";
332
+ import { resolvePageFieldDisplayText } from "./layout/resolve-page-fields.ts";
333
+ import type { RuntimePageGraph, RuntimePageNode } from "./layout/page-graph.ts";
330
334
 
331
335
  /** Internal extension of ExportDocxOptions that threads the collected
332
336
  * editorState payload from the runtime to the docx serializer. */
@@ -371,6 +375,7 @@ export interface DocumentRuntime {
371
375
  name: string,
372
376
  ): NonNullable<CanonicalDocumentEnvelope["fontTable"]>["fonts"][string] | undefined;
373
377
  replaceText(text: string, target?: EditorAnchorProjection, formatting?: TextFormattingDirective): void;
378
+ applyFormattingOperation(operation: FormattingOperation): void;
374
379
  insertFragment(fragment: CanonicalDocumentFragment, target?: EditorAnchorProjection): void;
375
380
  /**
376
381
  * I2 Tier B Slice 4b — serialize the selection range to a
@@ -516,6 +521,16 @@ export interface DocumentRuntime {
516
521
  body: string,
517
522
  authorId?: string,
518
523
  ): AddCommentReplyResult | null;
524
+ getCommentThreadForChange(changeId: string): CommentSidebarThreadSnapshot | null;
525
+ ensureCommentThreadForChange(
526
+ changeId: string,
527
+ authorId?: string,
528
+ ): AddCommentResult | null;
529
+ addReplyToChange(
530
+ changeId: string,
531
+ body: string,
532
+ authorId?: string,
533
+ ): AddCommentReplyResult | null;
519
534
  editCommentBody(commentId: string, body: string): void;
520
535
  addScope(params: AddScopeParams): AddScopeResult;
521
536
  getScope(scopeId: string): WorkflowScope | null;
@@ -3079,6 +3094,167 @@ export function createDocumentRuntime(
3079
3094
  emitError(toRuntimeError(error));
3080
3095
  }
3081
3096
  },
3097
+ applyFormattingOperation(operation) {
3098
+ try {
3099
+ const commandName = getFormattingOperationCommandName(operation);
3100
+ const snapshot = cachedRenderSnapshot;
3101
+ if (!snapshot.isReady || snapshot.readOnly || snapshot.fatalError) {
3102
+ return;
3103
+ }
3104
+
3105
+ const blockedReasons = workflowCoordinator.evaluateBlockedReasons(
3106
+ state.selection,
3107
+ commandName,
3108
+ );
3109
+ if (blockedReasons.length > 0) {
3110
+ this.emitBlockedCommand(commandName, blockedReasons);
3111
+ return;
3112
+ }
3113
+
3114
+ const persistedDocument = state.document;
3115
+ const localDocument =
3116
+ activeStory.kind === "main"
3117
+ ? persistedDocument
3118
+ : {
3119
+ ...persistedDocument,
3120
+ content: {
3121
+ type: "doc" as const,
3122
+ children: [...getStoryBlocks(persistedDocument, activeStory)],
3123
+ },
3124
+ };
3125
+ const localSnapshot: RuntimeRenderSnapshot =
3126
+ activeStory.kind === "main"
3127
+ ? snapshot
3128
+ : {
3129
+ ...snapshot,
3130
+ activeStory: MAIN_STORY_TARGET,
3131
+ selection: stripStoryTarget(snapshot.selection),
3132
+ };
3133
+
3134
+ const suggesting =
3135
+ workflowCoordinator.getEffectiveDocumentMode(state.selection) === "suggesting";
3136
+ const timestamp = clock();
3137
+
3138
+ if (suggesting) {
3139
+ if (activeStory.kind !== "main") {
3140
+ this.emitBlockedCommand(commandName, [{
3141
+ code: "suggesting_unsupported",
3142
+ message: `"${commandName}" is not supported in suggesting mode for this story.`,
3143
+ }]);
3144
+ return;
3145
+ }
3146
+
3147
+ if (
3148
+ operation.type === "set-alignment" ||
3149
+ operation.type === "indent" ||
3150
+ operation.type === "outdent"
3151
+ ) {
3152
+ const paragraphContext = resolveActiveParagraphContext(localSnapshot);
3153
+ if (!paragraphContext) {
3154
+ return;
3155
+ }
3156
+ const beforeXml = buildParagraphPropertyBeforeXml(paragraphContext.paragraph);
3157
+ const result = applyFormattingOperationToDocument(
3158
+ localDocument,
3159
+ localSnapshot,
3160
+ operation,
3161
+ );
3162
+ if (!result.changed) {
3163
+ return;
3164
+ }
3165
+ const nextDocument = appendPropertyChangeSuggestion(
3166
+ result.document,
3167
+ {
3168
+ from: paragraphContext.paragraph.from,
3169
+ to: paragraphContext.paragraph.to,
3170
+ },
3171
+ {
3172
+ originalRevisionType: "pPrChange",
3173
+ xmlTag: "pPrChange",
3174
+ beforeXml,
3175
+ semanticKind: "paragraph-property-change",
3176
+ storyTarget: activeStory,
3177
+ authorId: defaultAuthorId ?? undefined,
3178
+ },
3179
+ timestamp,
3180
+ );
3181
+ this.dispatch({
3182
+ type: "document.replace",
3183
+ document: { ...nextDocument, updatedAt: timestamp },
3184
+ mapping: createEmptyMapping(),
3185
+ selection: toInternalSelectionSnapshot(result.selection),
3186
+ origin: createOrigin("api", timestamp),
3187
+ });
3188
+ return;
3189
+ }
3190
+
3191
+ const segment = findSingleSelectedTextSegment(localSnapshot);
3192
+ if (!segment) {
3193
+ this.emitBlockedCommand(commandName, [{
3194
+ code: "suggesting_unsupported",
3195
+ message: `"${commandName}" requires one bounded text segment in suggesting mode.`,
3196
+ }]);
3197
+ return;
3198
+ }
3199
+ const beforeXml = buildRunPropertyBeforeXml(segment);
3200
+ const result = applyFormattingOperationToDocument(
3201
+ localDocument,
3202
+ localSnapshot,
3203
+ operation,
3204
+ );
3205
+ if (!result.changed) {
3206
+ return;
3207
+ }
3208
+ const nextDocument = appendPropertyChangeSuggestion(
3209
+ result.document,
3210
+ { from: segment.from, to: segment.to },
3211
+ {
3212
+ originalRevisionType: "rPrChange",
3213
+ xmlTag: "rPrChange",
3214
+ beforeXml,
3215
+ semanticKind: "formatting-change",
3216
+ storyTarget: activeStory,
3217
+ authorId: defaultAuthorId ?? undefined,
3218
+ },
3219
+ timestamp,
3220
+ );
3221
+ this.dispatch({
3222
+ type: "document.replace",
3223
+ document: { ...nextDocument, updatedAt: timestamp },
3224
+ mapping: createEmptyMapping(),
3225
+ selection: toInternalSelectionSnapshot(result.selection),
3226
+ origin: createOrigin("api", timestamp),
3227
+ });
3228
+ return;
3229
+ }
3230
+
3231
+ const result = applyFormattingOperationToDocument(
3232
+ localDocument,
3233
+ localSnapshot,
3234
+ operation,
3235
+ );
3236
+ if (!result.changed) {
3237
+ return;
3238
+ }
3239
+ const nextDocument =
3240
+ activeStory.kind === "main"
3241
+ ? result.document
3242
+ : replaceStoryBlocks(
3243
+ persistedDocument,
3244
+ activeStory,
3245
+ result.document.content.children,
3246
+ );
3247
+ this.dispatch({
3248
+ type: "document.replace",
3249
+ document: { ...nextDocument, updatedAt: timestamp },
3250
+ mapping: createEmptyMapping(),
3251
+ selection: toInternalSelectionSnapshot(result.selection),
3252
+ origin: createOrigin("api", timestamp),
3253
+ });
3254
+ } catch (error) {
3255
+ emitError(toRuntimeError(error));
3256
+ }
3257
+ },
3082
3258
  applyScopeReplacement(plan: RuntimeOperationPlan) {
3083
3259
  // Layer-08 Slice 5. Each step lowers to an existing command; the
3084
3260
  // apply pipeline (`src/runtime/scopes/replacement/apply.ts`) owns
@@ -3534,6 +3710,137 @@ export function createDocumentRuntime(
3534
3710
  const last = entries[entries.length - 1]!;
3535
3711
  return { commentId, entryId: last.entryId };
3536
3712
  },
3713
+ getCommentThreadForChange(changeId) {
3714
+ return (
3715
+ this.getRenderSnapshot().comments.threads.find(
3716
+ (thread) => thread.linkedRevisionId === changeId,
3717
+ ) ?? null
3718
+ );
3719
+ },
3720
+ ensureCommentThreadForChange(changeId, authorId) {
3721
+ const existing = this.getCommentThreadForChange(changeId);
3722
+ if (existing) {
3723
+ this.openComment(existing.commentId);
3724
+ return { commentId: existing.commentId, anchor: existing.anchor };
3725
+ }
3726
+
3727
+ if (viewState.documentMode === "viewing") {
3728
+ return null;
3729
+ }
3730
+ const revision = state.document.review.revisions[changeId];
3731
+ if (!revision || revision.anchor.kind !== "range") {
3732
+ return null;
3733
+ }
3734
+ const rejectionReason = commentAnchorRejectionReason(
3735
+ cachedRenderSnapshot.surface,
3736
+ revision.anchor,
3737
+ );
3738
+ if (rejectionReason !== null) {
3739
+ return null;
3740
+ }
3741
+
3742
+ const commentId = createEntityId("comment", state.document.review.comments, clock());
3743
+ const createdBy = authorId ?? defaultAuthorId ?? "unknown";
3744
+ const createdAt = clock();
3745
+ const entryId = `${commentId}-entry-1`;
3746
+ const comment: CommentThreadRecord = {
3747
+ commentId,
3748
+ anchor: revision.anchor,
3749
+ createdAt,
3750
+ createdBy,
3751
+ authorId: createdBy,
3752
+ body: "",
3753
+ entries: [
3754
+ {
3755
+ entryId,
3756
+ authorId: createdBy,
3757
+ body: "",
3758
+ createdAt,
3759
+ },
3760
+ ],
3761
+ status: "open",
3762
+ warningIds: [],
3763
+ isResolved: false,
3764
+ metadata: {
3765
+ source: "runtime",
3766
+ linkedRevisionId: changeId,
3767
+ },
3768
+ };
3769
+
3770
+ this.dispatch({
3771
+ type: "comment.add",
3772
+ comment,
3773
+ selection: createSelectionFromPublicAnchor(
3774
+ toPublicAnchorProjection(revision.anchor),
3775
+ ),
3776
+ origin: createOrigin("api", clock()),
3777
+ });
3778
+
3779
+ return {
3780
+ commentId,
3781
+ anchor: toPublicAnchorProjection(revision.anchor),
3782
+ };
3783
+ },
3784
+ addReplyToChange(changeId, body, authorId) {
3785
+ const existing = this.getCommentThreadForChange(changeId);
3786
+ if (existing) {
3787
+ return this.addCommentReply(existing.commentId, body, authorId);
3788
+ }
3789
+
3790
+ if (viewState.documentMode === "viewing") {
3791
+ return null;
3792
+ }
3793
+ const revision = state.document.review.revisions[changeId];
3794
+ if (!revision || revision.anchor.kind !== "range") {
3795
+ return null;
3796
+ }
3797
+ const rejectionReason = commentAnchorRejectionReason(
3798
+ cachedRenderSnapshot.surface,
3799
+ revision.anchor,
3800
+ );
3801
+ if (rejectionReason !== null) {
3802
+ return null;
3803
+ }
3804
+
3805
+ const commentId = createEntityId("comment", state.document.review.comments, clock());
3806
+ const createdBy = authorId ?? defaultAuthorId ?? "unknown";
3807
+ const createdAt = clock();
3808
+ const entryId = `${commentId}-entry-1`;
3809
+ const comment: CommentThreadRecord = {
3810
+ commentId,
3811
+ anchor: revision.anchor,
3812
+ createdAt,
3813
+ createdBy,
3814
+ authorId: createdBy,
3815
+ body,
3816
+ entries: [
3817
+ {
3818
+ entryId,
3819
+ authorId: createdBy,
3820
+ body,
3821
+ createdAt,
3822
+ },
3823
+ ],
3824
+ status: "open",
3825
+ warningIds: [],
3826
+ isResolved: false,
3827
+ metadata: {
3828
+ source: "runtime",
3829
+ linkedRevisionId: changeId,
3830
+ },
3831
+ };
3832
+
3833
+ this.dispatch({
3834
+ type: "comment.add",
3835
+ comment,
3836
+ selection: createSelectionFromPublicAnchor(
3837
+ toPublicAnchorProjection(revision.anchor),
3838
+ ),
3839
+ origin: createOrigin("api", clock()),
3840
+ });
3841
+
3842
+ return { commentId, entryId };
3843
+ },
3537
3844
  editCommentBody(commentId, body) {
3538
3845
  this.dispatch({
3539
3846
  type: "comment.edit-body",
@@ -3857,13 +4164,16 @@ export function createDocumentRuntime(
3857
4164
  zoomLevel: viewState.zoomLevel,
3858
4165
  },
3859
4166
  });
3860
- const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
3861
4167
  const bookmarkMap = buildBookmarkNameMap(state.document);
3862
4168
  const paragraphContexts = collectParagraphContexts(state.document.content.children);
3863
4169
  const paragraphOffsets = paragraphContexts.map((p) => p.startOffset);
3864
4170
  return createFieldResolver({
3865
4171
  pageGraph,
3866
- activePageIndex: navigation.activePageIndex,
4172
+ activePageIndex: resolveActivePageIndexFromPageGraph(
4173
+ pageGraph,
4174
+ state.selection.head,
4175
+ activeStory,
4176
+ ),
3867
4177
  bookmarkMap,
3868
4178
  paragraphOffsets,
3869
4179
  styles: state.document.styles,
@@ -4033,10 +4343,19 @@ export function createDocumentRuntime(
4033
4343
  return buildFieldSnapshot(state.document);
4034
4344
  },
4035
4345
  updateFields(options?: UpdateFieldsOptions): UpdateFieldsResult {
4346
+ const pageGraph = layoutEngine.getPageGraph({
4347
+ document: state.document,
4348
+ viewState: {
4349
+ activeStory,
4350
+ workspaceMode: viewState.workspaceMode,
4351
+ zoomLevel: viewState.zoomLevel,
4352
+ },
4353
+ });
4036
4354
  const refreshed = refreshDocumentFields(
4037
4355
  state.document,
4038
4356
  state.selection.head,
4039
4357
  activeStory,
4358
+ pageGraph,
4040
4359
  options,
4041
4360
  );
4042
4361
  if (refreshed.changed) {
@@ -6111,6 +6430,7 @@ function toPublicCommentSidebarSnapshot(
6111
6430
  createdBy: thread.createdBy,
6112
6431
  warningCount: thread.warningCount,
6113
6432
  anchorLabel: thread.anchorLabel,
6433
+ linkedRevisionId: sourceThread?.metadata?.linkedRevisionId,
6114
6434
  detachedReason: sourceThread?.metadata?.detachedReason,
6115
6435
  actionabilityNote: sourceThread?.metadata?.actionabilityNote,
6116
6436
  isActive: thread.isActive,
@@ -6129,6 +6449,7 @@ function toPublicTrackedChangesSnapshot(
6129
6449
  createRevisionStoreFromDocument(state),
6130
6450
  );
6131
6451
  const storyPlainTextCache = new Map<string, string>();
6452
+ const commentLinks = collectRevisionCommentLinks(state);
6132
6453
 
6133
6454
  return {
6134
6455
  pendingChangeIds: projection.activeRevisionIds,
@@ -6147,6 +6468,7 @@ function toPublicTrackedChangesSnapshot(
6147
6468
  createDetachedAnchor({ from: 0, to: 0 }, "importAmbiguity"),
6148
6469
  getStoryPlainText(state.document, storyTarget, storyPlainTextCache),
6149
6470
  );
6471
+ const linkedComments = commentLinks.get(revision.revisionId);
6150
6472
 
6151
6473
  return {
6152
6474
  revisionId: revision.revisionId,
@@ -6170,6 +6492,12 @@ function toPublicTrackedChangesSnapshot(
6170
6492
  warningCount: revision.warningCount,
6171
6493
  canAccept: revision.canAccept,
6172
6494
  canReject: revision.canReject,
6495
+ ...(linkedComments
6496
+ ? {
6497
+ commentThreadIds: linkedComments.commentThreadIds,
6498
+ replyCount: linkedComments.replyCount,
6499
+ }
6500
+ : {}),
6173
6501
  importedRevisionForm: sourceRevision?.metadata?.importedRevisionForm,
6174
6502
  preserveOnlyReason: revision.preserveOnlyReason,
6175
6503
  excerpt: preview.excerpt,
@@ -6179,6 +6507,23 @@ function toPublicTrackedChangesSnapshot(
6179
6507
  };
6180
6508
  }
6181
6509
 
6510
+ function collectRevisionCommentLinks(
6511
+ state: Pick<EditorState, "document">,
6512
+ ): Map<string, { commentThreadIds: string[]; replyCount: number }> {
6513
+ const links = new Map<string, { commentThreadIds: string[]; replyCount: number }>();
6514
+ for (const thread of Object.values(state.document.review.comments)) {
6515
+ const revisionId = thread.metadata?.linkedRevisionId;
6516
+ if (!revisionId) {
6517
+ continue;
6518
+ }
6519
+ const current = links.get(revisionId) ?? { commentThreadIds: [], replyCount: 0 };
6520
+ current.commentThreadIds.push(thread.commentId);
6521
+ current.replyCount += Math.max(0, (thread.entries?.length ?? 0) - 1);
6522
+ links.set(revisionId, current);
6523
+ }
6524
+ return links;
6525
+ }
6526
+
6182
6527
  function createRevisionStoreFromDocument(
6183
6528
  state: Pick<EditorState, "document">,
6184
6529
  ): RevisionStore {
@@ -6601,6 +6946,7 @@ function refreshDocumentFields(
6601
6946
  document: CanonicalDocumentEnvelope,
6602
6947
  selectionHead: number,
6603
6948
  activeStory: EditorStoryTarget,
6949
+ pageGraph: RuntimePageGraph,
6604
6950
  options?: UpdateFieldsOptions,
6605
6951
  ): {
6606
6952
  document: CanonicalDocumentEnvelope;
@@ -6611,7 +6957,11 @@ function refreshDocumentFields(
6611
6957
  const supportedOnly = options?.supportedOnly ?? true;
6612
6958
  const bookmarkMap = buildBookmarkNameMap(document);
6613
6959
  const paragraphs = collectParagraphContexts(document.content.children);
6614
- const navigation = createDocumentNavigationSnapshot(document, selectionHead, activeStory);
6960
+ const activePageIndex = resolveActivePageIndexFromPageGraph(
6961
+ pageGraph,
6962
+ selectionHead,
6963
+ activeStory,
6964
+ );
6615
6965
  let updatedCount = 0;
6616
6966
  let changed = false;
6617
6967
  let changedFrom: number | undefined;
@@ -6636,7 +6986,8 @@ function refreshDocumentFields(
6636
6986
  document,
6637
6987
  bookmarkMap,
6638
6988
  paragraphs,
6639
- navigation,
6989
+ pageGraph,
6990
+ activePageIndex,
6640
6991
  storyTarget,
6641
6992
  );
6642
6993
  if (!display) {
@@ -7172,7 +7523,8 @@ function resolveSupportedFieldDisplay(
7172
7523
  document: CanonicalDocumentEnvelope,
7173
7524
  bookmarkMap: Map<string, { bookmarkId: string; paragraphIndex: number }>,
7174
7525
  paragraphs: readonly ParagraphContext[],
7175
- navigation: DocumentNavigationSnapshot,
7526
+ pageGraph: RuntimePageGraph,
7527
+ activePageIndex: number,
7176
7528
  storyTarget: EditorStoryTarget,
7177
7529
  ): { displayText: string; refreshStatus: FieldRefreshStatus } | undefined {
7178
7530
  if (!field.fieldFamily || !isSupportedFieldFamily(field.fieldFamily)) {
@@ -7187,33 +7539,41 @@ function resolveSupportedFieldDisplay(
7187
7539
  return { displayText: result.text, refreshStatus: result.refreshStatus };
7188
7540
  }
7189
7541
  if (field.fieldFamily === "SECTIONPAGES") {
7190
- const sectionIndex = "sectionIndex" in storyTarget && typeof storyTarget.sectionIndex === "number"
7191
- ? storyTarget.sectionIndex
7192
- : navigation.activeSectionIndex;
7193
- const sectionPages = navigation.pages.filter((p) => p.sectionIndex === sectionIndex);
7194
- if (sectionPages.length === 0) return { displayText: "", refreshStatus: "unresolvable" };
7195
- const fmt = sectionPages[0]?.layout.pageNumbering?.format;
7542
+ const page = resolveRepresentativePageForStory(pageGraph, activePageIndex, storyTarget);
7543
+ if (!page) {
7544
+ return { displayText: "", refreshStatus: "unresolvable" };
7545
+ }
7196
7546
  return {
7197
- displayText: formatPageNumber(sectionPages.length, fmt),
7547
+ displayText: resolvePageFieldDisplayText("SECTIONPAGES", "", {
7548
+ page,
7549
+ graph: pageGraph,
7550
+ }),
7198
7551
  refreshStatus: "current",
7199
7552
  };
7200
7553
  }
7201
7554
  if (field.fieldFamily === "PAGE") {
7202
- const page = resolveRepresentativePageForStory(navigation, storyTarget);
7555
+ const page = resolveRepresentativePageForStory(pageGraph, activePageIndex, storyTarget);
7203
7556
  if (!page) {
7204
7557
  return { displayText: "", refreshStatus: "unresolvable" };
7205
7558
  }
7206
7559
  return {
7207
- displayText: resolveDisplayedPageNumber(page),
7560
+ displayText: resolvePageFieldDisplayText("PAGE", "", {
7561
+ page,
7562
+ graph: pageGraph,
7563
+ }),
7208
7564
  refreshStatus: "current",
7209
7565
  };
7210
7566
  }
7211
7567
  if (field.fieldFamily === "NUMPAGES") {
7212
- if (navigation.pageCount === 0) {
7568
+ const page = pageGraph.pages[activePageIndex] ?? firstContentPage(pageGraph);
7569
+ if (!page || pageGraph.contentPageCount === 0) {
7213
7570
  return { displayText: "", refreshStatus: "unresolvable" };
7214
7571
  }
7215
7572
  return {
7216
- displayText: String(navigation.pageCount),
7573
+ displayText: resolvePageFieldDisplayText("NUMPAGES", "", {
7574
+ page,
7575
+ graph: pageGraph,
7576
+ }),
7217
7577
  refreshStatus: "current",
7218
7578
  };
7219
7579
  }
@@ -7238,20 +7598,28 @@ function resolveSupportedFieldDisplay(
7238
7598
 
7239
7599
  // \p switch: emit relative position text ("above" / "below" / "on this page")
7240
7600
  if (field.switches?.relativePosition === true) {
7241
- const fieldPage = resolveRepresentativePageForStory(navigation, storyTarget);
7242
- const targetPageIndex = findPageForOffset(navigation.pages, paragraph.startOffset);
7601
+ const fieldPage = resolveRepresentativePageForStory(pageGraph, activePageIndex, storyTarget);
7602
+ const targetPageIndex = findPageIndexForRuntimeOffset(
7603
+ pageGraph.pages,
7604
+ paragraph.startOffset,
7605
+ );
7243
7606
  const fieldPageIndex = fieldPage
7244
- ? navigation.pages.indexOf(fieldPage)
7245
- : navigation.activePageIndex;
7607
+ ? pageGraph.pages.findIndex((page) => page.pageId === fieldPage.pageId)
7608
+ : activePageIndex;
7246
7609
  if (targetPageIndex < fieldPageIndex) return { displayText: "above", refreshStatus: "current" };
7247
7610
  if (targetPageIndex > fieldPageIndex) return { displayText: "below", refreshStatus: "current" };
7248
7611
  return { displayText: "on this page", refreshStatus: "current" };
7249
7612
  }
7250
7613
 
7251
- const pageIndex = findPageForOffset(navigation.pages, paragraph.startOffset);
7252
- const page = navigation.pages[pageIndex] ?? navigation.pages[0];
7614
+ const page = pageForRuntimeOffset(pageGraph, paragraph.startOffset);
7253
7615
  return page
7254
- ? { displayText: resolveDisplayedPageNumber(page), refreshStatus: "current" }
7616
+ ? {
7617
+ displayText: resolvePageFieldDisplayText("PAGE", "", {
7618
+ page,
7619
+ graph: pageGraph,
7620
+ }),
7621
+ refreshStatus: "current",
7622
+ }
7255
7623
  : { displayText: "", refreshStatus: "unresolvable" };
7256
7624
  }
7257
7625
  if (field.fieldFamily === "NOTEREF") {
@@ -7267,52 +7635,133 @@ function resolveSupportedFieldDisplay(
7267
7635
  return undefined;
7268
7636
  }
7269
7637
 
7270
- function resolveRepresentativePageForStory(
7271
- navigation: DocumentNavigationSnapshot,
7638
+ function resolveActivePageIndexFromPageGraph(
7639
+ graph: RuntimePageGraph,
7640
+ selectionHead: number,
7272
7641
  storyTarget: EditorStoryTarget,
7273
- ): DocumentNavigationSnapshot["pages"][number] | undefined {
7642
+ ): number {
7643
+ if (graph.pages.length === 0) {
7644
+ return 0;
7645
+ }
7646
+
7274
7647
  if (storyTarget.kind === "main") {
7275
- return navigation.pages[navigation.activePageIndex] ?? navigation.pages[0];
7648
+ return findPageForRuntimeOffset(graph.pages, selectionHead);
7276
7649
  }
7277
7650
 
7278
7651
  if (storyTarget.kind === "header" || storyTarget.kind === "footer") {
7279
- const sectionIndex = storyTarget.sectionIndex ?? navigation.activeSectionIndex;
7280
- const sectionPages = navigation.pages.filter((page) => page.sectionIndex === sectionIndex);
7281
- if (sectionPages.length === 0) {
7282
- return navigation.pages[0];
7652
+ const matchingPageIndex = graph.pages.findIndex((page) => {
7653
+ if (page.isBlankFiller) return false;
7654
+ const activeStory =
7655
+ storyTarget.kind === "header" ? page.stories.header : page.stories.footer;
7656
+ return activeStory ? storyTargetsMatchForPageInstance(activeStory, storyTarget) : false;
7657
+ });
7658
+ if (matchingPageIndex >= 0) {
7659
+ return matchingPageIndex;
7283
7660
  }
7284
- if (storyTarget.variant === "first") {
7285
- return sectionPages[0];
7661
+
7662
+ const sectionIndex = storyTarget.sectionIndex ?? graph.pages[0]!.sectionIndex;
7663
+ const sectionPageIndex = graph.pages.findIndex(
7664
+ (page) => page.sectionIndex === sectionIndex && !page.isBlankFiller,
7665
+ );
7666
+ return sectionPageIndex >= 0 ? sectionPageIndex : 0;
7667
+ }
7668
+
7669
+ return findPageForRuntimeOffset(graph.pages, selectionHead);
7670
+ }
7671
+
7672
+ function findPageForRuntimeOffset(
7673
+ pages: RuntimePageGraph["pages"],
7674
+ offset: number,
7675
+ ): number {
7676
+ return findPageIndexForRuntimeOffset(pages, offset);
7677
+ }
7678
+
7679
+ function findPageIndexForRuntimeOffset(
7680
+ pages: RuntimePageGraph["pages"],
7681
+ offset: number,
7682
+ ): number {
7683
+ let lastContentPageIndex = -1;
7684
+ for (let i = 0; i < pages.length; i += 1) {
7685
+ const page = pages[i]!;
7686
+ if (page.isBlankFiller) {
7687
+ continue;
7286
7688
  }
7287
- if (storyTarget.variant === "even") {
7288
- return sectionPages.find((page) => (page.pageInSection + 1) % 2 === 0) ?? sectionPages[0];
7689
+ lastContentPageIndex = i;
7690
+ if (offset < page.endOffset) {
7691
+ return i;
7289
7692
  }
7290
- return (
7291
- sectionPages.find((page) => isDefaultHeaderFooterPage(page)) ??
7292
- sectionPages[0]
7293
- );
7294
7693
  }
7694
+ if (lastContentPageIndex >= 0) {
7695
+ return lastContentPageIndex;
7696
+ }
7697
+ return Math.max(0, pages.length - 1);
7698
+ }
7295
7699
 
7296
- return navigation.pages[navigation.activePageIndex] ?? navigation.pages[0];
7700
+ function pageForRuntimeOffset(
7701
+ graph: RuntimePageGraph,
7702
+ offset: number,
7703
+ ): RuntimePageNode | undefined {
7704
+ return graph.pages[findPageIndexForRuntimeOffset(graph.pages, offset)];
7297
7705
  }
7298
7706
 
7299
- function isDefaultHeaderFooterPage(
7300
- page: DocumentNavigationSnapshot["pages"][number],
7707
+ function resolveRepresentativePageForStory(
7708
+ graph: RuntimePageGraph,
7709
+ activePageIndex: number,
7710
+ storyTarget: EditorStoryTarget,
7711
+ ): RuntimePageNode | undefined {
7712
+ if (storyTarget.kind === "main") {
7713
+ return graph.pages[activePageIndex] ?? firstContentPage(graph);
7714
+ }
7715
+
7716
+ if (storyTarget.kind === "header" || storyTarget.kind === "footer") {
7717
+ const matchedPage = graph.pages.find((page) => {
7718
+ if (page.isBlankFiller) return false;
7719
+ const activeStory =
7720
+ storyTarget.kind === "header" ? page.stories.header : page.stories.footer;
7721
+ return activeStory ? storyTargetsMatchForPageInstance(activeStory, storyTarget) : false;
7722
+ });
7723
+ if (matchedPage) {
7724
+ return matchedPage;
7725
+ }
7726
+ if (typeof storyTarget.sectionIndex === "number") {
7727
+ return graph.pages.find(
7728
+ (page) => page.sectionIndex === storyTarget.sectionIndex && !page.isBlankFiller,
7729
+ ) ?? firstContentPage(graph);
7730
+ }
7731
+ return firstContentPage(graph);
7732
+ }
7733
+
7734
+ return graph.pages[activePageIndex] ?? firstContentPage(graph);
7735
+ }
7736
+
7737
+ function storyTargetsMatchForPageInstance(
7738
+ activeStory: EditorStoryTarget,
7739
+ requestedStory: EditorStoryTarget,
7301
7740
  ): boolean {
7302
- if (page.layout.differentFirstPage && page.pageInSection === 0) {
7741
+ if (activeStory.kind !== requestedStory.kind) {
7303
7742
  return false;
7304
7743
  }
7305
- if (page.layout.differentOddEvenPages) {
7306
- return (page.pageInSection + 1) % 2 === 1;
7744
+ if (activeStory.kind === "header" && requestedStory.kind === "header") {
7745
+ return (
7746
+ activeStory.relationshipId === requestedStory.relationshipId &&
7747
+ activeStory.variant === requestedStory.variant &&
7748
+ (requestedStory.sectionIndex === undefined ||
7749
+ activeStory.sectionIndex === requestedStory.sectionIndex)
7750
+ );
7751
+ }
7752
+ if (activeStory.kind === "footer" && requestedStory.kind === "footer") {
7753
+ return (
7754
+ activeStory.relationshipId === requestedStory.relationshipId &&
7755
+ activeStory.variant === requestedStory.variant &&
7756
+ (requestedStory.sectionIndex === undefined ||
7757
+ activeStory.sectionIndex === requestedStory.sectionIndex)
7758
+ );
7307
7759
  }
7308
- return true;
7760
+ return storyTargetsEqual(activeStory, requestedStory);
7309
7761
  }
7310
7762
 
7311
- function resolveDisplayedPageNumber(
7312
- page: DocumentNavigationSnapshot["pages"][number],
7313
- ): string {
7314
- const n = (page.layout.pageNumbering?.start ?? 1) + page.pageInSection;
7315
- return formatPageNumber(n, page.layout.pageNumbering?.format);
7763
+ function firstContentPage(graph: RuntimePageGraph): RuntimePageNode | undefined {
7764
+ return graph.pages.find((page) => !page.isBlankFiller) ?? graph.pages[0];
7316
7765
  }
7317
7766
 
7318
7767
  interface ParagraphContext {
@@ -7730,6 +8179,93 @@ function isHighlightedSegment(
7730
8179
  return typeof bg === "string" && bg.length > 0;
7731
8180
  }
7732
8181
 
8182
+ function resolveActiveParagraphContext(
8183
+ snapshot: Pick<RuntimeRenderSnapshot, "surface" | "selection">,
8184
+ ): {
8185
+ paragraphIndex: number;
8186
+ paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
8187
+ atParagraphStart: boolean;
8188
+ isEmpty: boolean;
8189
+ } | null {
8190
+ if (!snapshot.surface) {
8191
+ return null;
8192
+ }
8193
+
8194
+ const paragraphIndex = resolveActiveParagraphIndex(
8195
+ snapshot.surface.blocks,
8196
+ snapshot.selection,
8197
+ );
8198
+ if (paragraphIndex === null) {
8199
+ return null;
8200
+ }
8201
+
8202
+ const selectionPosition =
8203
+ snapshot.selection.activeRange.kind === "node"
8204
+ ? snapshot.selection.activeRange.at
8205
+ : snapshot.selection.head;
8206
+ const paragraph = findSurfaceParagraphAtPosition(
8207
+ snapshot.surface.blocks,
8208
+ selectionPosition,
8209
+ );
8210
+ if (!paragraph) {
8211
+ return null;
8212
+ }
8213
+
8214
+ return {
8215
+ paragraphIndex,
8216
+ paragraph,
8217
+ atParagraphStart:
8218
+ snapshot.selection.isCollapsed &&
8219
+ snapshot.selection.activeRange.kind !== "node" &&
8220
+ snapshot.selection.anchor === snapshot.selection.head &&
8221
+ snapshot.selection.head === paragraph.from,
8222
+ isEmpty: isSurfaceParagraphEmpty(paragraph),
8223
+ };
8224
+ }
8225
+
8226
+ function findSurfaceParagraphAtPosition(
8227
+ blocks: readonly SurfaceBlockSnapshot[],
8228
+ position: number,
8229
+ ): Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null {
8230
+ for (const block of blocks) {
8231
+ if (position < block.from || position > block.to) {
8232
+ continue;
8233
+ }
8234
+ if (block.kind === "paragraph") {
8235
+ return block;
8236
+ }
8237
+ if (block.kind === "table") {
8238
+ for (const row of block.rows) {
8239
+ for (const cell of row.cells) {
8240
+ const paragraph = findSurfaceParagraphAtPosition(cell.content, position);
8241
+ if (paragraph) {
8242
+ return paragraph;
8243
+ }
8244
+ }
8245
+ }
8246
+ continue;
8247
+ }
8248
+ if (block.kind === "sdt_block") {
8249
+ const paragraph = findSurfaceParagraphAtPosition(block.children, position);
8250
+ if (paragraph) {
8251
+ return paragraph;
8252
+ }
8253
+ }
8254
+ }
8255
+ return null;
8256
+ }
8257
+
8258
+ function isSurfaceParagraphEmpty(
8259
+ paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
8260
+ ): boolean {
8261
+ if (paragraph.segments.length === 0) {
8262
+ return true;
8263
+ }
8264
+ return paragraph.segments.every(
8265
+ (segment) => segment.kind === "text" && segment.text.length === 0,
8266
+ );
8267
+ }
8268
+
7733
8269
  function forEachParagraphBlock(
7734
8270
  blocks: readonly SurfaceBlockSnapshot[],
7735
8271
  visit: (paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>) => void,
@@ -7843,6 +8379,44 @@ function buildRunPropertyBeforeXml(
7843
8379
  return `<w:rPr>${parts.join("")}</w:rPr>`;
7844
8380
  }
7845
8381
 
8382
+ function buildParagraphPropertyBeforeXml(
8383
+ paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
8384
+ ): string {
8385
+ const parts: string[] = [];
8386
+ if (paragraph.styleId) {
8387
+ parts.push(`<w:pStyle w:val="${escapeAttributeXml(paragraph.styleId)}"/>`);
8388
+ }
8389
+ if (paragraph.numbering) {
8390
+ parts.push(
8391
+ `<w:numPr><w:ilvl w:val="${paragraph.numbering.level}"/><w:numId w:val="${escapeAttributeXml(
8392
+ paragraph.numbering.numberingInstanceId.replace(/^num:/u, ""),
8393
+ )}"/></w:numPr>`,
8394
+ );
8395
+ }
8396
+ if (paragraph.alignment) {
8397
+ parts.push(`<w:jc w:val="${escapeAttributeXml(paragraph.alignment)}"/>`);
8398
+ }
8399
+ if (paragraph.indentation) {
8400
+ const attrs: string[] = [];
8401
+ if (paragraph.indentation.left !== undefined) {
8402
+ attrs.push(`w:left="${paragraph.indentation.left}"`);
8403
+ }
8404
+ if (paragraph.indentation.right !== undefined) {
8405
+ attrs.push(`w:right="${paragraph.indentation.right}"`);
8406
+ }
8407
+ if (paragraph.indentation.firstLine !== undefined) {
8408
+ attrs.push(`w:firstLine="${paragraph.indentation.firstLine}"`);
8409
+ }
8410
+ if (paragraph.indentation.hanging !== undefined) {
8411
+ attrs.push(`w:hanging="${paragraph.indentation.hanging}"`);
8412
+ }
8413
+ if (attrs.length > 0) {
8414
+ parts.push(`<w:ind ${attrs.join(" ")}/>`);
8415
+ }
8416
+ }
8417
+ return `<w:pPr>${parts.join("")}</w:pPr>`;
8418
+ }
8419
+
7846
8420
  function escapeAttributeXml(value: string): string {
7847
8421
  return value
7848
8422
  .replace(/&/g, "&amp;")
@@ -7851,6 +8425,55 @@ function escapeAttributeXml(value: string): string {
7851
8425
  .replace(/"/g, "&quot;");
7852
8426
  }
7853
8427
 
8428
+ function stripStoryTarget(selection: SelectionSnapshot): SelectionSnapshot {
8429
+ const { storyTarget: _storyTarget, ...rest } = selection;
8430
+ return rest;
8431
+ }
8432
+
8433
+ function toInternalSelectionSnapshot(
8434
+ selection: SelectionSnapshot,
8435
+ ): EditorState["selection"] {
8436
+ return {
8437
+ anchor: selection.anchor,
8438
+ head: selection.head,
8439
+ isCollapsed: selection.isCollapsed,
8440
+ activeRange:
8441
+ selection.activeRange.kind === "range"
8442
+ ? createRangeAnchor(
8443
+ selection.activeRange.from,
8444
+ selection.activeRange.to,
8445
+ selection.activeRange.assoc,
8446
+ )
8447
+ : selection.activeRange.kind === "node"
8448
+ ? createNodeAnchor(selection.activeRange.at, selection.activeRange.assoc)
8449
+ : createDetachedAnchor(
8450
+ selection.activeRange.lastKnownRange,
8451
+ selection.activeRange.reason,
8452
+ ),
8453
+ };
8454
+ }
8455
+
8456
+ function getFormattingOperationCommandName(operation: FormattingOperation): string {
8457
+ switch (operation.type) {
8458
+ case "toggle":
8459
+ return `toggle${operation.mark.charAt(0).toUpperCase()}${operation.mark.slice(1)}`;
8460
+ case "set-font-family":
8461
+ return "setFontFamily";
8462
+ case "set-font-size":
8463
+ return "setFontSize";
8464
+ case "set-text-color":
8465
+ return "setTextColor";
8466
+ case "set-highlight-color":
8467
+ return "setHighlightColor";
8468
+ case "set-alignment":
8469
+ return "setAlignment";
8470
+ case "indent":
8471
+ return "indent";
8472
+ case "outdent":
8473
+ return "outdent";
8474
+ }
8475
+ }
8476
+
7854
8477
  function appendPropertyChangeSuggestion(
7855
8478
  document: CanonicalDocumentEnvelope,
7856
8479
  anchor: { from: number; to: number },