@beyondwork/docx-react-component 1.0.45 → 1.0.47

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.
package/README.md CHANGED
@@ -708,7 +708,21 @@ Fired when a comment is resolved (via `resolveComment` or the sidebar).
708
708
  }
709
709
  ```
710
710
 
711
- There is no separate `comment_removed` event deletions are silent. Query `getComments()` after a `dirty_changed` event if you need to detect deletions.
711
+ For a general-purpose "something about comments changed" signal (covering deletions, reply appends, body edits, reopen, and every other mutation including Yjs-driven mutations that route through the editor's imperative ref), subscribe to `comments_changed`.
712
+
713
+ #### `comments_changed`
714
+
715
+ Fired once per commit whenever the runtime's comment snapshot differs from the previous commit. Use this when you render comments in a sibling component and need to know when to re-fetch via `editorRef.current.getComments()`.
716
+
717
+ ```ts
718
+ {
719
+ type: "comments_changed";
720
+ documentId: string;
721
+ changedCommentIds: string[]; // always non-empty
722
+ }
723
+ ```
724
+
725
+ Covers: `addComment`, `deleteComment`, `resolveComment`, `reopenComment`, `addCommentReply`, `editCommentBody`, and any remote-origin mutation that funnels through the same runtime APIs. Does NOT fire on selection/focus changes.
712
726
 
713
727
  #### `change_accepted`
714
728
 
@@ -944,6 +958,9 @@ function CommentButton({ editorRef }: { editorRef: React.RefObject<WordReviewEdi
944
958
  case "comment_resolved":
945
959
  console.log("comment resolved:", event.commentId);
946
960
  break;
961
+ case "comments_changed":
962
+ console.log("comments changed:", event.changedCommentIds);
963
+ break;
947
964
  case "change_accepted":
948
965
  console.log("change accepted:", event.changeId);
949
966
  break;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.45",
4
+ "version": "1.0.47",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "packageManager": "pnpm@10.30.3",
7
7
  "type": "module",
@@ -2416,6 +2416,23 @@ export type WordReviewEditorEvent =
2416
2416
  documentId: string;
2417
2417
  commentId: string;
2418
2418
  }
2419
+ | {
2420
+ type: "comments_changed";
2421
+ documentId: string;
2422
+ /**
2423
+ * Stable ids of the comment threads whose state differs between
2424
+ * the previous and current runtime render snapshot. Includes
2425
+ * additions, deletions, body edits, reply appends, and
2426
+ * resolution-state transitions. Consumers can call
2427
+ * `editorRef.current.getComments()` to obtain the full updated
2428
+ * `CommentSidebarSnapshot`.
2429
+ *
2430
+ * The array is always non-empty when this event fires. When the
2431
+ * snapshot's `comments` reference is unchanged between commits,
2432
+ * no event fires.
2433
+ */
2434
+ changedCommentIds: string[];
2435
+ }
2419
2436
  | {
2420
2437
  type: "change_accepted";
2421
2438
  documentId: string;
@@ -34,7 +34,10 @@ import {
34
34
  outdentParagraphAtSelection,
35
35
  splitParagraph,
36
36
  } from "./text-commands.ts";
37
- import type { RevisionRecord as CanonicalRevisionRecord } from "../../model/canonical-document.ts";
37
+ import type {
38
+ BlockNode,
39
+ RevisionRecord as CanonicalRevisionRecord,
40
+ } from "../../model/canonical-document.ts";
38
41
  import { remapCommentThreads } from "../../review/store/comment-remapping.ts";
39
42
  import { collectScopeTagTouches } from "../../review/store/scope-tag-diff.ts";
40
43
  import { applyRevisionRuntimeCommand } from "../../runtime/revision-runtime.ts";
@@ -86,6 +89,15 @@ import {
86
89
  import { insertPageBreak, insertTable } from "./text-commands.ts";
87
90
  import type { TableSelectionDescriptor } from "../../runtime/table-commands.ts";
88
91
 
92
+ export type ContentChildrenPatch =
93
+ | { kind: "keep-all" }
94
+ | { kind: "replace-all"; children: BlockNode[] }
95
+ | {
96
+ kind: "index-map";
97
+ baseLength: number;
98
+ entries: ReadonlyArray<{ index: number; block: BlockNode }>;
99
+ };
100
+
89
101
  export interface CommandOrigin {
90
102
  source:
91
103
  | "keyboard"
@@ -112,6 +124,15 @@ export type EditorCommand =
112
124
  protectionSelection?: SelectionSnapshot;
113
125
  origin?: CommandOrigin;
114
126
  }
127
+ | {
128
+ type: "document.patch";
129
+ patch: Partial<CanonicalDocumentEnvelope>;
130
+ contentChildren?: ContentChildrenPatch;
131
+ mapping?: TransactionMapping;
132
+ selection?: SelectionSnapshot;
133
+ protectionSelection?: SelectionSnapshot;
134
+ origin?: CommandOrigin;
135
+ }
115
136
  | {
116
137
  type: "text.insert";
117
138
  text: string;
@@ -482,6 +503,53 @@ export function executeEditorCommand(
482
503
  },
483
504
  );
484
505
  }
506
+ case "document.patch": {
507
+ const mapping = command.mapping ?? createEmptyMapping();
508
+ const nextDocument = applyDocumentPatch(
509
+ state.document,
510
+ command.patch,
511
+ command.contentChildren,
512
+ );
513
+ const selection =
514
+ command.selection ?? remapSelection(state.selection, mapping);
515
+ const reviewState = remapReviewStateAfterContentChange(
516
+ state,
517
+ nextDocument,
518
+ mapping,
519
+ );
520
+
521
+ return createTransaction(
522
+ {
523
+ ...state,
524
+ document: reviewState.document,
525
+ selection,
526
+ warnings: reviewState.warnings,
527
+ runtime: {
528
+ ...state.runtime,
529
+ activeCommentId: reviewState.activeCommentId,
530
+ },
531
+ compatibility: {
532
+ ...state.compatibility,
533
+ generatedAt: context.timestamp,
534
+ warnings: reviewState.warnings,
535
+ featureEntries: state.compatibility.featureEntries.map((entry) =>
536
+ entry.affectedAnchor
537
+ ? {
538
+ ...entry,
539
+ affectedAnchor: mapAnchor(entry.affectedAnchor, mapping),
540
+ }
541
+ : entry,
542
+ ),
543
+ },
544
+ },
545
+ {
546
+ historyBoundary: "push",
547
+ markDirty: true,
548
+ mapping,
549
+ effects: reviewState.effects,
550
+ },
551
+ );
552
+ }
485
553
  case "text.insert": {
486
554
  const suggestingResult = context.documentMode === "suggesting"
487
555
  ? applySuggestingInsert(state, command.text, context)
@@ -1141,6 +1209,57 @@ export function executeEditorCommand(
1141
1209
  }
1142
1210
  }
1143
1211
 
1212
+ export function applyDocumentPatch(
1213
+ base: CanonicalDocumentEnvelope,
1214
+ patch: Partial<CanonicalDocumentEnvelope>,
1215
+ contentChildren?: ContentChildrenPatch,
1216
+ ): CanonicalDocumentEnvelope {
1217
+ const nextContent = resolvePatchedContent(base, patch, contentChildren);
1218
+ const merged: CanonicalDocumentEnvelope = { ...base, ...patch };
1219
+ if (nextContent) {
1220
+ merged.content = nextContent;
1221
+ }
1222
+ return merged;
1223
+ }
1224
+
1225
+ function resolvePatchedContent(
1226
+ base: CanonicalDocumentEnvelope,
1227
+ patch: Partial<CanonicalDocumentEnvelope>,
1228
+ contentChildren: ContentChildrenPatch | undefined,
1229
+ ): CanonicalDocumentEnvelope["content"] | null {
1230
+ if (!contentChildren) {
1231
+ return patch.content ?? null;
1232
+ }
1233
+ if (contentChildren.kind === "keep-all") {
1234
+ return { ...base.content, children: base.content.children };
1235
+ }
1236
+ if (contentChildren.kind === "replace-all") {
1237
+ return {
1238
+ ...(patch.content ?? base.content),
1239
+ children: contentChildren.children,
1240
+ };
1241
+ }
1242
+ const baseChildren = base.content.children;
1243
+ if (contentChildren.baseLength !== baseChildren.length) {
1244
+ throw new Error(
1245
+ `applyDocumentPatch: index-map baseLength ${contentChildren.baseLength} does not match current children length ${baseChildren.length}`,
1246
+ );
1247
+ }
1248
+ const nextChildren: BlockNode[] = baseChildren.slice();
1249
+ for (const entry of contentChildren.entries) {
1250
+ if (entry.index < 0 || entry.index >= nextChildren.length) {
1251
+ throw new Error(
1252
+ `applyDocumentPatch: index-map entry index ${entry.index} out of range [0, ${nextChildren.length})`,
1253
+ );
1254
+ }
1255
+ nextChildren[entry.index] = entry.block;
1256
+ }
1257
+ return {
1258
+ ...(patch.content ?? base.content),
1259
+ children: nextChildren,
1260
+ };
1261
+ }
1262
+
1144
1263
  function buildDocumentReplaceTransaction(
1145
1264
  state: EditorState,
1146
1265
  context: CommandExecutionContext,
@@ -59,14 +59,12 @@ import {
59
59
  getDocumentBackedWorkflowMetadata,
60
60
  parseWorkflowPayloadEnvelopeFromPackage,
61
61
  resolvePayloadPartPath,
62
+ resolveWorkflowPayloadPartPaths,
62
63
  WORKFLOW_PAYLOAD_CONTENT_TYPE,
63
64
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_CONTENT_TYPE,
64
65
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
65
66
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_RELATIONSHIP_TYPE,
66
67
  WORKFLOW_PAYLOAD_ITEM_PROPS_CONTENT_TYPE,
67
- WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH,
68
- WORKFLOW_PAYLOAD_PART_PATH,
69
- WORKFLOW_PAYLOAD_RELATIONSHIP_TYPE,
70
68
  } from "./ooxml/workflow-payload.ts";
71
69
  import {
72
70
  classifyCorruptPackageError,
@@ -1736,12 +1734,17 @@ function exportDocxEditorSession(
1736
1734
  }
1737
1735
  }
1738
1736
 
1737
+ const workflowPayloadPartPaths = resolveWorkflowPayloadPartPaths(
1738
+ state.sourcePackage,
1739
+ sessionState.documentId,
1740
+ );
1741
+
1739
1742
  const exportSession = createExportSession(state.sourcePackage, [
1740
1743
  state.sourceDocumentPartPath,
1741
1744
  APP_PROPERTIES_PART_PATH,
1742
1745
  CORE_PROPERTIES_PART_PATH,
1743
- WORKFLOW_PAYLOAD_PART_PATH,
1744
- WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH,
1746
+ workflowPayloadPartPaths.payloadPartPath,
1747
+ workflowPayloadPartPaths.itemPropsPartPath,
1745
1748
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
1746
1749
  numberingPartPath,
1747
1750
  commentsPartPath,
@@ -1974,7 +1977,14 @@ function exportDocxEditorSession(
1974
1977
  ensureHostMetadataParts(exportSession, state.sourcePackage, currentDocument);
1975
1978
  // Schema 1.2: pass through editorState payload collected by the runtime channel.
1976
1979
  const internalEditorState = (options as { _editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload } | undefined)?._editorState;
1977
- ensureWorkflowPayloadParts(exportSession, sessionState, currentDocument, state.sourcePackage, internalEditorState);
1980
+ ensureWorkflowPayloadParts(
1981
+ exportSession,
1982
+ sessionState,
1983
+ currentDocument,
1984
+ state.sourcePackage,
1985
+ internalEditorState,
1986
+ workflowPayloadPartPaths,
1987
+ );
1978
1988
 
1979
1989
  return {
1980
1990
  bytes: exportSession.serialize(),
@@ -3701,6 +3711,7 @@ function ensureWorkflowPayloadParts(
3701
3711
  document: CanonicalDocumentEnvelope,
3702
3712
  sourcePackage: OpcPackage,
3703
3713
  editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload,
3714
+ precomputedPartPaths?: { payloadPartPath: string; itemPropsPartPath: string },
3704
3715
  ): void {
3705
3716
  const payloadParts = buildWorkflowPayloadParts({
3706
3717
  sourcePackage,
@@ -3716,19 +3727,37 @@ function ensureWorkflowPayloadParts(
3716
3727
  return;
3717
3728
  }
3718
3729
 
3730
+ // Export ownership is resolved before session creation, so any precomputed
3731
+ // paths must stay in lockstep with buildWorkflowPayloadParts for the same
3732
+ // source package and documentId.
3733
+ if (precomputedPartPaths) {
3734
+ if (
3735
+ precomputedPartPaths.payloadPartPath !== payloadParts.payloadPartPath ||
3736
+ precomputedPartPaths.itemPropsPartPath !== payloadParts.itemPropsPartPath
3737
+ ) {
3738
+ throw new Error(
3739
+ `ensureWorkflowPayloadParts: precomputed payload part paths `
3740
+ + `(${precomputedPartPaths.payloadPartPath} / ${precomputedPartPaths.itemPropsPartPath}) `
3741
+ + `do not match buildWorkflowPayloadParts output `
3742
+ + `(${payloadParts.payloadPartPath} / ${payloadParts.itemPropsPartPath}). `
3743
+ + `This is a bug in the export orchestration.`,
3744
+ );
3745
+ }
3746
+ }
3747
+
3719
3748
  const payloadPart = sourcePackage.parts.get(payloadParts.payloadPartPath);
3720
3749
  const itemPropsPart = sourcePackage.parts.get(payloadParts.itemPropsPartPath);
3721
3750
  const customPropsPart = sourcePackage.parts.get(WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH);
3722
3751
 
3723
3752
  exportSession.replaceOwnedPart({
3724
- path: payloadParts.payloadPartPath,
3753
+ path: precomputedPartPaths?.payloadPartPath ?? payloadParts.payloadPartPath,
3725
3754
  bytes: new TextEncoder().encode(payloadParts.payloadPartXml),
3726
3755
  contentType: payloadPart?.contentType ?? WORKFLOW_PAYLOAD_CONTENT_TYPE,
3727
3756
  relationships: payloadParts.payloadRelationships,
3728
3757
  compression: payloadPart?.compression,
3729
3758
  });
3730
3759
  exportSession.replaceOwnedPart({
3731
- path: payloadParts.itemPropsPartPath,
3760
+ path: precomputedPartPaths?.itemPropsPartPath ?? payloadParts.itemPropsPartPath,
3732
3761
  bytes: new TextEncoder().encode(payloadParts.itemPropsXml),
3733
3762
  contentType: itemPropsPart?.contentType ?? WORKFLOW_PAYLOAD_ITEM_PROPS_CONTENT_TYPE,
3734
3763
  compression: itemPropsPart?.compression,
@@ -1158,6 +1158,33 @@ function resolveDescriptor(sourcePackage: OpcPackage, documentId: string): Embed
1158
1158
  };
1159
1159
  }
1160
1160
 
1161
+ /**
1162
+ * Resolve the OPC part paths the workflow-payload serializer will write
1163
+ * to on export, based on the source package's existing customXml slots.
1164
+ *
1165
+ * - If the source already contains a BW workflow payload, the existing
1166
+ * `/customXml/itemN.xml` path is reused (via `resolvePayloadPartPath`).
1167
+ * - Otherwise, `findNextAvailableItemNumber(sourcePackage)` picks the
1168
+ * next free slot (e.g. `item5.xml` when `item1..4.xml` already exist
1169
+ * in the source).
1170
+ *
1171
+ * The returned paths MUST be passed to `createExportSession` as owned
1172
+ * paths so `replaceOwnedPart(...)` does not throw when
1173
+ * `ensureWorkflowPayloadParts` later writes to them. The custom-props
1174
+ * part lives at the fixed path `WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH`
1175
+ * and is not part of this resolver.
1176
+ */
1177
+ export function resolveWorkflowPayloadPartPaths(
1178
+ sourcePackage: OpcPackage,
1179
+ documentId: string,
1180
+ ): { payloadPartPath: string; itemPropsPartPath: string } {
1181
+ const descriptor = resolveDescriptor(sourcePackage, documentId);
1182
+ return {
1183
+ payloadPartPath: descriptor.payloadPartPath,
1184
+ itemPropsPartPath: descriptor.itemPropsPartPath,
1185
+ };
1186
+ }
1187
+
1161
1188
  export function resolvePayloadPartPath(sourcePackage: OpcPackage): string | null {
1162
1189
  const mirroredItemId = readCustomPropertyValue(
1163
1190
  sourcePackage,
@@ -18,11 +18,15 @@ import type { EditorStoryTarget } from "../../api/public-types.ts";
18
18
  * Events are meant to be small deltas (text insertions, comment adds,
19
19
  * change accepts, etc.). They are NOT full-document snapshots.
20
20
  *
21
- * `document.replace` is a special case: some formatting/table/list
22
- * operations currently produce `document.replace` commands that carry
23
- * a full `CanonicalDocumentEnvelope`. These events are bandwidth-heavy
24
- * but functionally correct. A follow-up will promote them to first-class
25
- * delta commands.
21
+ * `document.replace` carries a full `CanonicalDocumentEnvelope` and is
22
+ * therefore bandwidth-heavy. The collab sync bridge transparently
23
+ * converts broadcast `document.replace` commands to `document.patch`
24
+ * deltas by diffing against the pre-commit document snapshot, so the
25
+ * shared event log only ever contains the changed top-level keys (and,
26
+ * for `content.children`, only the changed block indices). Remote
27
+ * replicas apply `document.patch` by merging the delta onto their
28
+ * current canonical state — see `applyDocumentPatch` in
29
+ * `core/commands/index.ts`.
26
30
  */
27
31
  export interface CommandEvent {
28
32
  /** UUID — dedup key for defensive idempotency. */
@@ -74,6 +78,7 @@ export const BROADCAST_COMMAND_TYPES: ReadonlySet<EditorCommand["type"]> = new S
74
78
  "text.insert-hard-break",
75
79
  "paragraph.split",
76
80
  "document.replace",
81
+ "document.patch",
77
82
  "comment.add",
78
83
  "comment.resolve",
79
84
  "comment.reopen",
@@ -2,9 +2,12 @@ import * as Y from "yjs";
2
2
 
3
3
  import type {
4
4
  CommandExecutionContext,
5
+ ContentChildrenPatch,
5
6
  EditorCommand,
6
7
  EditorTransaction,
7
8
  } from "../../core/commands/index.ts";
9
+ import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
10
+ import type { BlockNode } from "../../model/canonical-document.ts";
8
11
  import type {
9
12
  CommandAppliedMeta,
10
13
  DocumentRuntime,
@@ -17,6 +20,122 @@ import {
17
20
  type CommandEvent,
18
21
  } from "./event-types.ts";
19
22
 
23
+ const PATCHABLE_TOP_LEVEL_KEYS = [
24
+ "updatedAt",
25
+ "metadata",
26
+ "styles",
27
+ "numbering",
28
+ "media",
29
+ "content",
30
+ "review",
31
+ "preservation",
32
+ "diagnostics",
33
+ "subParts",
34
+ "fieldRegistry",
35
+ ] as const satisfies ReadonlyArray<keyof CanonicalDocumentEnvelope>;
36
+
37
+ export function compressDocumentReplaceForBroadcast(
38
+ command: EditorCommand,
39
+ priorDocument: CanonicalDocumentEnvelope,
40
+ ): EditorCommand {
41
+ if (command.type !== "document.replace") {
42
+ return command;
43
+ }
44
+ const next = command.document;
45
+ if (next === priorDocument) {
46
+ return command;
47
+ }
48
+
49
+ const patch: Partial<CanonicalDocumentEnvelope> = {};
50
+ let anyTopLevelChange = false;
51
+ for (const key of PATCHABLE_TOP_LEVEL_KEYS) {
52
+ if (key === "content") continue;
53
+ if (next[key] !== priorDocument[key]) {
54
+ (patch as Record<string, unknown>)[key] = next[key];
55
+ anyTopLevelChange = true;
56
+ }
57
+ }
58
+
59
+ const contentChildrenPatch = diffContentChildren(
60
+ priorDocument.content.children,
61
+ next.content.children,
62
+ );
63
+ const contentMetaChanged =
64
+ next.content !== priorDocument.content &&
65
+ !contentReferenceEqualExceptChildren(priorDocument.content, next.content);
66
+ if (contentMetaChanged) {
67
+ const { children: _children, ...rest } = next.content;
68
+ patch.content = { ...rest, children: [] } as CanonicalDocumentEnvelope["content"];
69
+ }
70
+
71
+ const contentChanged =
72
+ contentChildrenPatch.kind !== "keep-all" || contentMetaChanged;
73
+ if (!contentChanged && !anyTopLevelChange) {
74
+ return {
75
+ type: "document.patch",
76
+ patch: {},
77
+ contentChildren: { kind: "keep-all" },
78
+ mapping: command.mapping,
79
+ selection: command.selection,
80
+ protectionSelection: command.protectionSelection,
81
+ origin: command.origin,
82
+ };
83
+ }
84
+
85
+ return {
86
+ type: "document.patch",
87
+ patch,
88
+ contentChildren: contentChildrenPatch,
89
+ mapping: command.mapping,
90
+ selection: command.selection,
91
+ protectionSelection: command.protectionSelection,
92
+ origin: command.origin,
93
+ };
94
+ }
95
+
96
+ function contentReferenceEqualExceptChildren(
97
+ a: CanonicalDocumentEnvelope["content"],
98
+ b: CanonicalDocumentEnvelope["content"],
99
+ ): boolean {
100
+ const aKeys = Object.keys(a);
101
+ const bKeys = Object.keys(b);
102
+ if (aKeys.length !== bKeys.length) return false;
103
+ for (const key of aKeys) {
104
+ if (key === "children") continue;
105
+ if ((a as unknown as Record<string, unknown>)[key] !== (b as unknown as Record<string, unknown>)[key]) {
106
+ return false;
107
+ }
108
+ }
109
+ return true;
110
+ }
111
+
112
+ function diffContentChildren(
113
+ prior: readonly BlockNode[],
114
+ next: readonly BlockNode[],
115
+ ): ContentChildrenPatch {
116
+ if (prior === next) {
117
+ return { kind: "keep-all" };
118
+ }
119
+ if (prior.length === next.length) {
120
+ const entries: Array<{ index: number; block: BlockNode }> = [];
121
+ for (let i = 0; i < next.length; i += 1) {
122
+ const priorBlock = prior[i];
123
+ const nextBlock = next[i];
124
+ if (priorBlock === undefined || nextBlock === undefined) {
125
+ return { kind: "replace-all", children: next.slice() };
126
+ }
127
+ if (priorBlock !== nextBlock) {
128
+ entries.push({ index: i, block: nextBlock });
129
+ }
130
+ }
131
+ if (entries.length === 0) {
132
+ return { kind: "keep-all" };
133
+ }
134
+ return { kind: "index-map", baseLength: prior.length, entries };
135
+ }
136
+ return { kind: "replace-all", children: next.slice() };
137
+ }
138
+
20
139
  export type RuntimeCommandAppliedListener = (
21
140
  command: EditorCommand,
22
141
  transaction: EditorTransaction,
@@ -73,8 +192,13 @@ export function createRuntimeCollabSync(
73
192
  return;
74
193
  }
75
194
 
76
- const event = createCommandEvent({
195
+ const broadcastCommand = compressDocumentReplaceForBroadcast(
77
196
  command,
197
+ meta.priorDocument,
198
+ );
199
+
200
+ const event = createCommandEvent({
201
+ command: broadcastCommand,
78
202
  originClientId: ydoc.clientID,
79
203
  authorId,
80
204
  timestamp: context.timestamp,
@@ -406,6 +406,7 @@ export interface DocumentRuntime {
406
406
  export interface CommandAppliedMeta {
407
407
  preSelection: import("../core/state/editor-state.ts").SelectionSnapshot;
408
408
  activeStory: EditorStoryTarget;
409
+ priorDocument: CanonicalDocumentEnvelope;
409
410
  }
410
411
 
411
412
  export interface CreateDocumentRuntimeOptions {
@@ -2111,6 +2112,7 @@ export function createDocumentRuntime(
2111
2112
  options.onCommandApplied?.(command, noopTransaction, context, {
2112
2113
  preSelection: state.selection,
2113
2114
  activeStory,
2115
+ priorDocument: state.document,
2114
2116
  });
2115
2117
  return;
2116
2118
  }
@@ -2124,11 +2126,13 @@ export function createDocumentRuntime(
2124
2126
  } as const;
2125
2127
  const preSelection = commandSelection;
2126
2128
  const preActiveStory = activeStory;
2129
+ const priorDocument = state.document;
2127
2130
  const transaction = executeEditorCommand(state, command, context);
2128
2131
  commit(transaction);
2129
2132
  options.onCommandApplied?.(command, transaction, context, {
2130
2133
  preSelection,
2131
2134
  activeStory: preActiveStory,
2135
+ priorDocument,
2132
2136
  });
2133
2137
  } catch (error) {
2134
2138
  emitError(toRuntimeError(error));
@@ -3071,6 +3075,20 @@ export function createDocumentRuntime(
3071
3075
  });
3072
3076
  }
3073
3077
 
3078
+ if (previous.document.review.comments !== next.document.review.comments) {
3079
+ const changedCommentIds = diffCommentMapKeys(
3080
+ previous.document.review.comments,
3081
+ next.document.review.comments,
3082
+ );
3083
+ if (changedCommentIds.length > 0) {
3084
+ emit({
3085
+ type: "comments_changed",
3086
+ documentId: next.documentId,
3087
+ changedCommentIds,
3088
+ });
3089
+ }
3090
+ }
3091
+
3074
3092
  if (transaction.effects.changeAccepted) {
3075
3093
  emit({
3076
3094
  type: "change_accepted",
@@ -3269,12 +3287,14 @@ export function createDocumentRuntime(
3269
3287
 
3270
3288
  const preSelection = selection;
3271
3289
  const preActiveStory = activeStory;
3290
+ const priorDocument = state.document;
3272
3291
  if (activeStory.kind === "main") {
3273
3292
  const mainTransaction = executeEditorCommand(baseState, command, context);
3274
3293
  commit(mainTransaction);
3275
3294
  options.onCommandApplied?.(command, mainTransaction, context, {
3276
3295
  preSelection,
3277
3296
  activeStory: preActiveStory,
3297
+ priorDocument,
3278
3298
  });
3279
3299
  return classifyAck({
3280
3300
  command,
@@ -3356,6 +3376,7 @@ export function createDocumentRuntime(
3356
3376
  options.onCommandApplied?.(broadcastCommand, mergedTransaction, context, {
3357
3377
  preSelection,
3358
3378
  activeStory: preActiveStory,
3379
+ priorDocument,
3359
3380
  });
3360
3381
  return classifyAck({
3361
3382
  command,
@@ -3988,6 +4009,27 @@ function createSelectionFromPublicAnchor(
3988
4009
  }
3989
4010
  }
3990
4011
 
4012
+ /**
4013
+ * Collect the stable ids of comment threads whose entry differs
4014
+ * (present in one side but not the other, OR present in both but
4015
+ * referencing a different object — which indicates any mutation to
4016
+ * the thread, since the runtime treats `review.comments` as
4017
+ * immutable per commit). Used by the `comments_changed` event.
4018
+ */
4019
+ function diffCommentMapKeys(
4020
+ previous: CanonicalDocumentEnvelope["review"]["comments"],
4021
+ next: CanonicalDocumentEnvelope["review"]["comments"],
4022
+ ): string[] {
4023
+ const changed = new Set<string>();
4024
+ for (const id of Object.keys(previous)) {
4025
+ if (previous[id] !== next[id]) changed.add(id);
4026
+ }
4027
+ for (const id of Object.keys(next)) {
4028
+ if (previous[id] !== next[id]) changed.add(id);
4029
+ }
4030
+ return Array.from(changed);
4031
+ }
4032
+
3991
4033
  function toPublicCompatibilityReport(
3992
4034
  report: InternalCompatibilityReport,
3993
4035
  ): CompatibilityReport {
@@ -43,6 +43,7 @@ export function describeEventImpact(
43
43
  case "suggestion_updated":
44
44
  case "comment_added":
45
45
  case "comment_resolved":
46
+ case "comments_changed":
46
47
  return {
47
48
  invalidate: [
48
49
  "render",
@@ -45,12 +45,35 @@ export interface DocxFontLoader {
45
45
  refresh(input: FontLoaderInput): void;
46
46
  }
47
47
 
48
+ interface MinimalFontFace {
49
+ load(): Promise<MinimalFontFace>;
50
+ }
51
+
52
+ interface MinimalFontFaceDescriptors {
53
+ weight?: string;
54
+ style?: string;
55
+ }
56
+
57
+ interface MinimalFontFaceConstructor {
58
+ new (
59
+ family: string,
60
+ source: ArrayBuffer | ArrayBufferView | string,
61
+ descriptors?: MinimalFontFaceDescriptors,
62
+ ): MinimalFontFace;
63
+ }
64
+
65
+ interface MinimalFontFaceSet {
66
+ add(face: MinimalFontFace): void;
67
+ check(font: string): boolean;
68
+ ready: Promise<MinimalFontFaceSet>;
69
+ }
70
+
48
71
  export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
72
+ const globalDocument = (globalThis as unknown as { document?: { fonts?: unknown } }).document;
49
73
  const supported =
50
- typeof document !== "undefined" &&
74
+ globalDocument !== undefined &&
51
75
  typeof (globalThis as { FontFace?: unknown }).FontFace !== "undefined" &&
52
- // Guard against jsdom which exposes FontFace but not document.fonts
53
- Boolean((document as Document & { fonts?: FontFaceSet }).fonts);
76
+ Boolean(globalDocument.fonts);
54
77
 
55
78
  let current: FontLoaderInput = initial;
56
79
  let readyPromise: Promise<void>;
@@ -58,7 +81,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
58
81
 
59
82
  function run(input: FontLoaderInput): Promise<void> {
60
83
  if (!supported) return Promise.resolve();
61
- const fontSet = (document as Document & { fonts?: FontFaceSet }).fonts;
84
+ const fontSet = globalDocument?.fonts as MinimalFontFaceSet | undefined;
62
85
  if (!fontSet) return Promise.resolve();
63
86
 
64
87
  const pending: Array<Promise<unknown>> = [];
@@ -70,10 +93,8 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
70
93
 
71
94
  for (const [descriptor, data] of variantsOf(variants)) {
72
95
  try {
73
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
- const FontFaceCtor = (globalThis as any).FontFace as {
75
- new (family: string, source: BufferSource, descriptors?: FontFaceDescriptors): FontFace;
76
- };
96
+ const FontFaceCtor = (globalThis as { FontFace?: MinimalFontFaceConstructor }).FontFace;
97
+ if (!FontFaceCtor) continue;
77
98
  const face = new FontFaceCtor(family, data, descriptor);
78
99
  pending.push(
79
100
  face.load().then((loaded) => {
@@ -88,8 +109,6 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
88
109
  }
89
110
  }
90
111
 
91
- // Mark declared families as registered if the browser already resolves
92
- // them (e.g. system fonts like Calibri, Arial).
93
112
  for (const family of input.families) {
94
113
  try {
95
114
  const probe = `12px "${family.replace(/"/g, "'")}", serif`;
@@ -127,7 +146,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
127
146
 
128
147
  function* variantsOf(
129
148
  variants: EmbeddedFontBytes,
130
- ): IterableIterator<[FontFaceDescriptors, ArrayBuffer]> {
149
+ ): IterableIterator<[MinimalFontFaceDescriptors, ArrayBuffer]> {
131
150
  if (variants.regular) {
132
151
  yield [{ weight: "400", style: "normal" }, variants.regular];
133
152
  }
@@ -24,8 +24,26 @@
24
24
  * internal cachedGraph + cachedKey without triggering fullRebuild.
25
25
  * Does not change geometry — but the public interface changed, so
26
26
  * persisted envelopes MUST re-derive their cacheKey under 3.
27
+ * 4 — PR #188 `fix(export)` bumped the version to satisfy the
28
+ * `src/runtime/layout/**` gate, even though the font-loader
29
+ * type-def restoration intended for that PR did not survive the
30
+ * squash-merge. Runtime and cached geometry unchanged.
31
+ * 5 — PR #187 `joakim/commentevents` restores the local `Minimal*`
32
+ * font-loader type defs under `src/runtime/layout/docx-font-loader.ts`
33
+ * (re-application of previously-shipped PRs #162/#163 which a
34
+ * subsequent merge reverted) so downstream consumers whose
35
+ * TS `lib` does not expose `FontFaceSet.add` can type-check the
36
+ * package, and adds the `comments_changed` event plumbing in
37
+ * runtime domains outside `src/runtime/layout/**`. TypeScript-
38
+ * surface-only from the layout engine's perspective: no cached
39
+ * geometry or cache-key derivation changes. The bump exists
40
+ * solely to satisfy the
41
+ * `scripts/ci-check-layout-engine-version.mjs` gate because a
42
+ * file under `src/runtime/layout/**` changed. Safe to treat
43
+ * versions 3, 4, and 5 as cache-compatible if a migration ever
44
+ * needs to collapse them.
27
45
  */
28
- export const LAYOUT_ENGINE_VERSION = 3 as const;
46
+ export const LAYOUT_ENGINE_VERSION = 5 as const;
29
47
 
30
48
  /**
31
49
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -3,9 +3,9 @@
3
3
  * as `adjustedRange`. The semantics: the union of all step post-edit ranges
4
4
  * (where each step covers `[step.from, step.from + step.insertSize]` in the
5
5
  * new document). When the transaction has no steps (e.g., a non-main-story
6
- * `document.replace` that uses `createEmptyMapping()`), fall back to the
7
- * normalized prior selection range so consumers still receive a meaningful
8
- * pointer.
6
+ * `document.replace` or `document.patch` that uses `createEmptyMapping()`),
7
+ * fall back to the normalized prior selection range so consumers still
8
+ * receive a meaningful pointer.
9
9
  *
10
10
  * Note: only `step.from` and `step.insertSize` are considered; `step.to`
11
11
  * (the pre-edit end of the replaced range) is intentionally ignored. A pure