@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
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.84",
4
+ "version": "1.0.86",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -52,6 +52,9 @@ export function buildRefProjections(
52
52
  rejectAll: () => getRef().rejectAllChanges(),
53
53
  acceptGroup: (groupId) => getRef().acceptSuggestionGroup(groupId),
54
54
  rejectGroup: (groupId) => getRef().rejectSuggestionGroup(groupId),
55
+ getCommentThread: (changeId) => getRef().getCommentThreadForChange(changeId),
56
+ ensureCommentThread: (changeId) => getRef().ensureCommentThreadForChange(changeId),
57
+ addReply: (changeId, body) => getRef().addReplyToChange(changeId, body),
55
58
  scrollTo: (revisionId) => getRef().scrollToRevision(revisionId),
56
59
  get: () => getRef().getTrackedChanges(),
57
60
  getSuggestions: () => getRef().getSuggestionsSnapshot(),
@@ -592,6 +592,8 @@ export interface CommentSidebarThreadSnapshot {
592
592
  createdBy: string;
593
593
  warningCount: number;
594
594
  anchorLabel: string;
595
+ /** Present when this thread is the discussion attached to a tracked change. */
596
+ linkedRevisionId?: string;
595
597
  detachedReason?: "incomplete-markers" | "multi-paragraph" | "opaque-region" | "revision-overlap";
596
598
  actionabilityNote?: string;
597
599
  isActive: boolean;
@@ -630,6 +632,10 @@ export interface TrackedChangeEntrySnapshot {
630
632
  warningCount: number;
631
633
  canAccept: boolean;
632
634
  canReject: boolean;
635
+ /** Comment threads whose metadata links them to this revision. */
636
+ commentThreadIds?: string[];
637
+ /** Number of reply entries across linked comment threads. */
638
+ replyCount?: number;
633
639
  preserveOnlyReason?: string;
634
640
  excerpt?: string;
635
641
  detail?: string;
@@ -672,6 +678,10 @@ export interface SuggestionEntrySnapshot {
672
678
  preserveOnlyReason?: string;
673
679
  excerpt?: string;
674
680
  detail?: string;
681
+ /** Comment threads linked to any revision in this suggestion unit. */
682
+ commentThreadIds?: string[];
683
+ /** Number of reply entries across linked comment threads. */
684
+ replyCount?: number;
675
685
  /**
676
686
  * R3 — scope-card-overlay P2. When present, links this entry to a
677
687
  * `SuggestionGroup` via `SuggestionsSnapshot.groups[].groupId`.
@@ -1311,6 +1321,12 @@ export type SurfaceInlineSegment =
1311
1321
  instruction: string;
1312
1322
  refreshStatus: FieldRefreshStatus;
1313
1323
  label: string;
1324
+ /**
1325
+ * Current display text for fields whose visible value is computed by
1326
+ * layout context (PAGE / NUMPAGES / SECTIONPAGES). Consumers fall back
1327
+ * to `label` when absent.
1328
+ */
1329
+ displayText?: string;
1314
1330
  }
1315
1331
  | {
1316
1332
  /**
@@ -4431,6 +4447,9 @@ export interface WordReviewEditorChangesFacet {
4431
4447
  rejectAll(): void;
4432
4448
  acceptGroup(groupId: string): void;
4433
4449
  rejectGroup(groupId: string): void;
4450
+ getCommentThread(changeId: string): CommentSidebarThreadSnapshot | null;
4451
+ ensureCommentThread(changeId: string): AddCommentResult | null;
4452
+ addReply(changeId: string, body: string): AddCommentReplyResult | null;
4434
4453
  scrollTo(revisionId: string): void;
4435
4454
  get(): TrackedChangesSnapshot;
4436
4455
  getSuggestions(): SuggestionsSnapshot;
@@ -4578,6 +4597,25 @@ export interface WordReviewEditorRef {
4578
4597
  commentId: string,
4579
4598
  body: string,
4580
4599
  ): AddCommentReplyResult | null;
4600
+ /**
4601
+ * Return the open/resolved comment thread linked to a tracked change, if
4602
+ * one exists. This is the read side for "reply to tracked change" UX.
4603
+ */
4604
+ getCommentThreadForChange(changeId: string): CommentSidebarThreadSnapshot | null;
4605
+ /**
4606
+ * Create or return the discussion thread attached to a tracked change.
4607
+ * Newly-created threads are anchored to the revision range and opened in
4608
+ * the comments rail by mounted UI callers.
4609
+ */
4610
+ ensureCommentThreadForChange(changeId: string): AddCommentResult | null;
4611
+ /**
4612
+ * Append a reply to a tracked change's linked discussion thread, creating
4613
+ * that thread first when needed.
4614
+ */
4615
+ addReplyToChange(
4616
+ changeId: string,
4617
+ body: string,
4618
+ ): AddCommentReplyResult | null;
4581
4619
  editCommentBody(commentId: string, body: string): void;
4582
4620
  deleteComment(commentId: string): void;
4583
4621
  /**
@@ -53,12 +53,18 @@ export type RuntimeApiHandle = Pick<
53
53
  | "getCanonicalDocument"
54
54
  // Content search + selection (runtime.content + ai.bundle families)
55
55
  | "findAllText"
56
+ | "replaceText"
57
+ // Formatting mutations (runtime.formatting.apply)
58
+ | "applyFormattingOperation"
56
59
  // Review (runtime.review family)
57
60
  | "getReviewWorkSnapshot"
58
61
  | "getSuggestionsSnapshot"
59
62
  | "acceptChange"
60
63
  | "rejectChange"
61
64
  | "resolveComment"
65
+ | "getCommentThreadForChange"
66
+ | "ensureCommentThreadForChange"
67
+ | "addReplyToChange"
62
68
  // Workflow (runtime.workflow + ai.inspect families)
63
69
  | "queryScopes"
64
70
  | "getWorkflowMarkupSnapshot"
@@ -147,11 +153,16 @@ export const RUNTIME_API_HANDLE_SHAPE_CHECK: Record<keyof RuntimeApiHandle, true
147
153
  getRenderSnapshot: true,
148
154
  getCanonicalDocument: true,
149
155
  findAllText: true,
156
+ replaceText: true,
157
+ applyFormattingOperation: true,
150
158
  getReviewWorkSnapshot: true,
151
159
  getSuggestionsSnapshot: true,
152
160
  acceptChange: true,
153
161
  rejectChange: true,
154
162
  resolveComment: true,
163
+ getCommentThreadForChange: true,
164
+ ensureCommentThreadForChange: true,
165
+ addReplyToChange: true,
155
166
  queryScopes: true,
156
167
  getWorkflowMarkupSnapshot: true,
157
168
  getInteractionGuardSnapshot: true,
@@ -12,7 +12,12 @@
12
12
 
13
13
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
14
14
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
15
- import type { CanonicalDocumentFragment } from "../../public-types.ts";
15
+ import type {
16
+ CanonicalDocumentFragment,
17
+ EditorAnchorProjection,
18
+ SurfaceBlockSnapshot,
19
+ SurfaceInlineSegment,
20
+ } from "../../public-types.ts";
16
21
  import { emitUxResponse } from "../_ux-response.ts";
17
22
  import { createScopeCompilerService } from "../../../runtime/scopes/index.ts";
18
23
 
@@ -179,6 +184,18 @@ export function createContentFamily(runtime: RuntimeApiHandle) {
179
184
  // Layer-08 compiler-service facade. Same pipeline as
180
185
  // ai.applyReplacementScope; the host/UI path omits actionId (not
181
186
  // an agent action) and tags origin:"ui" + actorId:"user".
187
+ const tableCellResult = replaceTableCellText(runtime, input);
188
+ if (tableCellResult) {
189
+ emitUxResponse(runtime, {
190
+ apiFn: replaceTextMetadata.name,
191
+ intent: replaceTextMetadata.uxIntent.expectedDelta ?? "",
192
+ mockOrLive: "live",
193
+ uiVisible: true,
194
+ expectedDelta: replaceTextMetadata.uxIntent.expectedDelta,
195
+ });
196
+ return tableCellResult;
197
+ }
198
+
182
199
  const result = compiler.applyReplacement({
183
200
  targetScopeId: input.scopeId,
184
201
  operation: "replace",
@@ -234,3 +251,133 @@ export function createContentFamily(runtime: RuntimeApiHandle) {
234
251
  },
235
252
  };
236
253
  }
254
+
255
+ function replaceTableCellText(
256
+ runtime: RuntimeApiHandle,
257
+ input: ReplaceTextInput,
258
+ ): ReplaceTextResult | null {
259
+ const parsed = parseTableCellScopeId(input.scopeId);
260
+ if (!parsed) {
261
+ return null;
262
+ }
263
+
264
+ const snapshot = runtime.getRenderSnapshot();
265
+ if (!snapshot.isReady || snapshot.readOnly || snapshot.fatalError) {
266
+ return { applied: false, reason: "document-not-editable" };
267
+ }
268
+
269
+ const document = runtime.getCanonicalDocument();
270
+ const rootChild = document.content.children[parsed.blockIndex];
271
+ if (!rootChild || rootChild.type !== "table") {
272
+ return { applied: false, reason: "table-cell-scope-not-resolvable" };
273
+ }
274
+
275
+ const tableOrdinal = document.content.children
276
+ .slice(0, parsed.blockIndex)
277
+ .filter((block) => block.type === "table").length;
278
+ const tableBlock =
279
+ snapshot.surface?.blocks.find(
280
+ (block): block is Extract<SurfaceBlockSnapshot, { kind: "table" }> =>
281
+ block.kind === "table" && block.blockId === `table-${tableOrdinal}`,
282
+ ) ??
283
+ (snapshot.surface?.blocks[parsed.blockIndex]?.kind === "table"
284
+ ? snapshot.surface.blocks[parsed.blockIndex]
285
+ : null);
286
+ if (!tableBlock || tableBlock.kind !== "table") {
287
+ return { applied: false, reason: "table-cell-surface-not-realized" };
288
+ }
289
+
290
+ const cell = tableBlock.rows[parsed.rowIndex]?.cells[parsed.cellIndex];
291
+ if (!cell) {
292
+ return { applied: false, reason: "table-cell-scope-not-resolvable" };
293
+ }
294
+
295
+ const range = resolveTableCellTextRange(cell.content);
296
+ if (range.status === "empty") {
297
+ return { applied: false, reason: "table-cell-empty-text" };
298
+ }
299
+ if (range.status === "ambiguous") {
300
+ return { applied: false, reason: "table-cell-text-scope-ambiguous" };
301
+ }
302
+ const { from, to } = range;
303
+
304
+ const anchor: EditorAnchorProjection = {
305
+ kind: "range",
306
+ from,
307
+ to,
308
+ assoc: { start: -1, end: 1 },
309
+ };
310
+ const beforeRevisionToken = snapshot.revisionToken;
311
+ runtime.replaceText(input.replacement, anchor);
312
+ const afterRevisionToken = runtime.getRenderSnapshot().revisionToken;
313
+ if (afterRevisionToken === beforeRevisionToken) {
314
+ return { applied: false, reason: "replace-text-not-applied" };
315
+ }
316
+ return { applied: true };
317
+ }
318
+
319
+ function parseTableCellScopeId(
320
+ scopeId: string,
321
+ ): { blockIndex: number; rowIndex: number; cellIndex: number } | null {
322
+ const match = /^cell:(\d+):(\d+):(\d+)$/u.exec(scopeId);
323
+ if (!match) return null;
324
+ return {
325
+ blockIndex: Number(match[1]),
326
+ rowIndex: Number(match[2]),
327
+ cellIndex: Number(match[3]),
328
+ };
329
+ }
330
+
331
+ type TableCellTextRangeResult =
332
+ | { status: "ok"; from: number; to: number }
333
+ | { status: "empty" }
334
+ | { status: "ambiguous" };
335
+
336
+ function resolveTableCellTextRange(
337
+ blocks: readonly SurfaceBlockSnapshot[],
338
+ ): TableCellTextRangeResult {
339
+ const paragraphRanges = collectTableCellParagraphTextRanges(blocks);
340
+ if (paragraphRanges.length === 0) {
341
+ return { status: "empty" };
342
+ }
343
+ if (paragraphRanges.length > 1) {
344
+ return { status: "ambiguous" };
345
+ }
346
+ const [range] = paragraphRanges;
347
+ return range && range.from < range.to
348
+ ? { status: "ok", from: range.from, to: range.to }
349
+ : { status: "empty" };
350
+ }
351
+
352
+ function collectTableCellParagraphTextRanges(
353
+ blocks: readonly SurfaceBlockSnapshot[],
354
+ output: Array<{ from: number; to: number }> = [],
355
+ ): Array<{ from: number; to: number }> {
356
+ for (const block of blocks) {
357
+ if (block.kind === "paragraph") {
358
+ const textSegments = block.segments.filter(
359
+ (segment): segment is Extract<SurfaceInlineSegment, { kind: "text" }> =>
360
+ segment.kind === "text" && segment.from < segment.to,
361
+ );
362
+ if (textSegments.length > 0) {
363
+ output.push({
364
+ from: Math.min(...textSegments.map((segment) => segment.from)),
365
+ to: Math.max(...textSegments.map((segment) => segment.to)),
366
+ });
367
+ }
368
+ continue;
369
+ }
370
+ if (block.kind === "table") {
371
+ for (const row of block.rows) {
372
+ for (const cell of row.cells) {
373
+ collectTableCellParagraphTextRanges(cell.content, output);
374
+ }
375
+ }
376
+ continue;
377
+ }
378
+ if (block.kind === "sdt_block") {
379
+ collectTableCellParagraphTextRanges(block.children, output);
380
+ }
381
+ }
382
+ return output;
383
+ }
@@ -13,12 +13,14 @@
13
13
 
14
14
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
15
15
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
16
+ import { emitUxResponse } from "../_ux-response.ts";
16
17
  import {
17
18
  resolveEffectiveFormatting,
18
19
  createFormattingContext,
19
20
  type EffectiveFormatting,
20
21
  type RunProvenance,
21
22
  } from "../../../runtime/formatting/index.ts";
23
+ import type { FormattingOperation } from "../../../core/commands/formatting-commands.ts";
22
24
  import {
23
25
  findParagraphByBlockId,
24
26
  resolveDirectRunFormattingAtSegment,
@@ -132,8 +134,47 @@ export const resolveRunWithProvenanceMetadata: ApiV3FnMetadata = {
132
134
  "§Runtime API § runtime.formatting.resolveRunWithProvenance. Agent-facing provenance view — `source ∈ {direct, characterStyle, style, docDefaults}` plus `sourceId` for style tiers. Substrate for ai.explainFormatting (L09).",
133
135
  };
134
136
 
137
+ export const applyMetadata: ApiV3FnMetadata = {
138
+ name: "runtime.formatting.apply",
139
+ status: "live",
140
+ sourceLayer: "runtime-core",
141
+ liveEvidence: {
142
+ runnerTest: "test/api/v3/runtime/formatting-adapter.test.ts",
143
+ commit: "refactor-03-tracked-changes-v1-formatting-apply-2026-04-23",
144
+ },
145
+ stateClass: "A-canonical",
146
+ persistsTo: "canonical",
147
+ broadcastsVia: "crdt",
148
+ uxIntent: {
149
+ uiVisible: true,
150
+ expectsUxResponse: "inline-change",
151
+ expectedDelta: "formatting changes in the active selection",
152
+ },
153
+ agentMetadata: {
154
+ readOrMutate: "mutate",
155
+ boundedScope: "selection",
156
+ auditCategory: "formatting-write",
157
+ },
158
+ rwdReference:
159
+ "§Runtime API § runtime.formatting.apply. Live adapter over DocumentRuntime.applyFormattingOperation(); supports direct formatting/paragraph alignment/indentation and authors bounded property-change suggestions when the effective mode is suggesting. Style suggestions are intentionally not accepted by this operation shape.",
160
+ };
161
+
135
162
  export function createFormattingFamily(runtime: RuntimeApiHandle) {
136
163
  return {
164
+ apply(operation: FormattingOperation): void {
165
+ // @endStateApi — live. Delegates to the runtime-owned mutation seam
166
+ // so direct edits, workflow blocking, and suggesting-mode property-
167
+ // change authorship all share one implementation.
168
+ runtime.applyFormattingOperation(operation);
169
+ emitUxResponse(runtime, {
170
+ apiFn: applyMetadata.name,
171
+ intent: applyMetadata.uxIntent.expectedDelta ?? "",
172
+ mockOrLive: "live",
173
+ uiVisible: true,
174
+ expectedDelta: applyMetadata.uxIntent.expectedDelta,
175
+ });
176
+ },
177
+
137
178
  getEffective(
138
179
  nodeRef: FormattingNodeRef,
139
180
  opts?: FormattingGetEffectiveOpts,
@@ -8,6 +8,8 @@
8
8
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
9
9
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
10
10
  import type {
11
+ AddCommentReplyResult,
12
+ AddCommentResult,
11
13
  CommentSidebarThreadSnapshot,
12
14
  SuggestionsSnapshot,
13
15
  TrackedChangeEntrySnapshot,
@@ -120,6 +122,56 @@ export const resolveCommentMetadata: ApiV3FnMetadata = {
120
122
  rwdReference: "§Runtime API § runtime.review.resolveComment",
121
123
  };
122
124
 
125
+ export const getCommentThreadForChangeMetadata: ApiV3FnMetadata = {
126
+ name: "runtime.review.getCommentThreadForChange",
127
+ status: "live",
128
+ sourceLayer: "workflow-review",
129
+ liveEvidence: {
130
+ runnerTest: "test/api/v3/create-accepts-handle.test.ts",
131
+ commit: "refactor-03-redline-revision-thread-link",
132
+ },
133
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
134
+ agentMetadata: { readOrMutate: "read", boundedScope: "scope", auditCategory: "review-read" },
135
+ stateClass: "A-canonical",
136
+ persistsTo: "canonical",
137
+ rwdReference:
138
+ "§Runtime API § runtime.review.getCommentThreadForChange. Live adapter over runtime linked revision comment threads.",
139
+ };
140
+
141
+ export const ensureCommentThreadForChangeMetadata: ApiV3FnMetadata = {
142
+ name: "runtime.review.ensureCommentThreadForChange",
143
+ status: "live",
144
+ sourceLayer: "workflow-review",
145
+ liveEvidence: {
146
+ runnerTest: "test/api/v3/create-accepts-handle.test.ts",
147
+ commit: "refactor-03-redline-revision-thread-link",
148
+ },
149
+ uxIntent: { uiVisible: true, expectsUxResponse: "inline-change", expectedDelta: "comment thread opens for tracked change" },
150
+ agentMetadata: { readOrMutate: "mutate", boundedScope: "scope", auditCategory: "comment-add" },
151
+ stateClass: "A-canonical",
152
+ persistsTo: "canonical",
153
+ broadcastsVia: "crdt",
154
+ rwdReference:
155
+ "§Runtime API § runtime.review.ensureCommentThreadForChange. Creates or returns the comment thread attached to a tracked change.",
156
+ };
157
+
158
+ export const addReplyToChangeMetadata: ApiV3FnMetadata = {
159
+ name: "runtime.review.addReplyToChange",
160
+ status: "live",
161
+ sourceLayer: "workflow-review",
162
+ liveEvidence: {
163
+ runnerTest: "test/api/v3/create-accepts-handle.test.ts",
164
+ commit: "refactor-03-redline-revision-thread-link",
165
+ },
166
+ uxIntent: { uiVisible: true, expectsUxResponse: "inline-change", expectedDelta: "reply appears on tracked-change thread" },
167
+ agentMetadata: { readOrMutate: "mutate", boundedScope: "scope", auditCategory: "comment-reply" },
168
+ stateClass: "A-canonical",
169
+ persistsTo: "canonical",
170
+ broadcastsVia: "crdt",
171
+ rwdReference:
172
+ "§Runtime API § runtime.review.addReplyToChange. Appends a reply to the comment thread linked to a tracked change.",
173
+ };
174
+
123
175
  export function createReviewFamily(runtime: RuntimeApiHandle) {
124
176
  return {
125
177
  getComments(): readonly CommentSidebarThreadSnapshot[] {
@@ -140,6 +192,52 @@ export function createReviewFamily(runtime: RuntimeApiHandle) {
140
192
  return runtime.getSuggestionsSnapshot();
141
193
  },
142
194
 
195
+ getCommentThreadForChange(changeId: string): CommentSidebarThreadSnapshot | null {
196
+ // @endStateApi — live. Reads the comment thread linked to a tracked
197
+ // change from the canonical comment projection.
198
+ return runtime.getCommentThreadForChange(changeId);
199
+ },
200
+
201
+ ensureCommentThreadForChange(changeId: string): AddCommentResult | null {
202
+ // @endStateApi — live. Ensures a canonical comment thread exists for
203
+ // a tracked change and emits an inline UX response when created/found.
204
+ const result = runtime.ensureCommentThreadForChange(changeId);
205
+ if (result) {
206
+ emitUxResponse(runtime, {
207
+ apiFn: ensureCommentThreadForChangeMetadata.name,
208
+ intent: ensureCommentThreadForChangeMetadata.uxIntent.expectedDelta ?? "",
209
+ mockOrLive: "live",
210
+ uiVisible: true,
211
+ expectedDelta: ensureCommentThreadForChangeMetadata.uxIntent.expectedDelta,
212
+ actualDelta: {
213
+ kind: "inline-change",
214
+ payload: { changeId, commentId: result.commentId },
215
+ },
216
+ });
217
+ }
218
+ return result;
219
+ },
220
+
221
+ addReplyToChange(changeId: string, body: string): AddCommentReplyResult | null {
222
+ // @endStateApi — live. Appends a canonical comment reply to the
223
+ // thread linked to a tracked change.
224
+ const result = runtime.addReplyToChange(changeId, body);
225
+ if (result) {
226
+ emitUxResponse(runtime, {
227
+ apiFn: addReplyToChangeMetadata.name,
228
+ intent: addReplyToChangeMetadata.uxIntent.expectedDelta ?? "",
229
+ mockOrLive: "live",
230
+ uiVisible: true,
231
+ expectedDelta: addReplyToChangeMetadata.uxIntent.expectedDelta,
232
+ actualDelta: {
233
+ kind: "inline-change",
234
+ payload: { changeId, commentId: result.commentId, entryId: result.entryId },
235
+ },
236
+ });
237
+ }
238
+ return result;
239
+ },
240
+
143
241
  acceptChange(changeId: string): void {
144
242
  // @endStateApi — live. Delegates.
145
243
  runtime.acceptChange(changeId);
@@ -58,7 +58,9 @@ import {
58
58
  } from "./text-commands.ts";
59
59
  import type {
60
60
  BlockNode,
61
+ InlineNode,
61
62
  MutableCanonicalDocument,
63
+ ParagraphNode,
62
64
  RevisionRecord as CanonicalRevisionRecord,
63
65
  } from "../../model/canonical-document.ts";
64
66
  import { remapCommentThreads } from "../../review/store/comment-remapping.ts";
@@ -2314,39 +2316,93 @@ function isSingleParagraphSuggestingRange(
2314
2316
  to: number,
2315
2317
  ): boolean {
2316
2318
  const ranges: Array<{ start: number; end: number }> = [];
2317
- let cursor = 0;
2318
- let previousWasParagraph = false;
2319
+ collectSuggestingParagraphRanges(
2320
+ document.content.children,
2321
+ 0,
2322
+ ranges,
2323
+ true,
2324
+ );
2325
+
2326
+ return ranges.some((range) => from >= range.start && to <= range.end);
2327
+ }
2328
+
2329
+ function collectSuggestingParagraphRanges(
2330
+ blocks: readonly BlockNode[],
2331
+ startCursor: number,
2332
+ output: Array<{ start: number; end: number }>,
2333
+ addRootParagraphBoundaries: boolean,
2334
+ ): number {
2335
+ let cursor = startCursor;
2336
+
2337
+ for (let index = 0; index < blocks.length; index += 1) {
2338
+ const block = blocks[index];
2339
+ if (!block) {
2340
+ continue;
2341
+ }
2319
2342
 
2320
- for (const block of document.content.children) {
2321
2343
  if (block.type === "paragraph") {
2322
- if (previousWasParagraph) {
2323
- cursor += 1;
2324
- }
2325
2344
  const start = cursor;
2326
- cursor += block.children.reduce<number>((size, child) => {
2327
- if (child.type === "text") {
2328
- return size + child.text.length;
2329
- }
2330
- if (child.type === "hyperlink") {
2331
- return size + child.children.reduce<number>((childSize, entry) => {
2332
- if (entry.type === "text") {
2333
- return childSize + entry.text.length;
2334
- }
2335
- return childSize + 1;
2336
- }, 0);
2345
+ cursor += paragraphLogicalLength(block);
2346
+ output.push({ start, end: cursor });
2347
+ } else if (block.type === "table") {
2348
+ for (const row of block.rows) {
2349
+ for (const cell of row.cells) {
2350
+ cursor = collectSuggestingParagraphRanges(
2351
+ cell.children,
2352
+ cursor,
2353
+ output,
2354
+ false,
2355
+ );
2337
2356
  }
2338
- return size + 1;
2339
- }, 0);
2340
- ranges.push({ start, end: cursor });
2341
- previousWasParagraph = true;
2342
- continue;
2357
+ }
2358
+ } else if (block.type === "sdt") {
2359
+ cursor = collectSuggestingParagraphRanges(
2360
+ block.children,
2361
+ cursor,
2362
+ output,
2363
+ false,
2364
+ );
2365
+ } else {
2366
+ cursor += 1;
2343
2367
  }
2344
2368
 
2345
- cursor += 1;
2346
- previousWasParagraph = false;
2369
+ if (
2370
+ addRootParagraphBoundaries &&
2371
+ index < blocks.length - 1 &&
2372
+ blocks[index + 1]?.type === "paragraph"
2373
+ ) {
2374
+ cursor += 1;
2375
+ }
2347
2376
  }
2348
2377
 
2349
- return ranges.some((range) => from >= range.start && to <= range.end);
2378
+ return cursor;
2379
+ }
2380
+
2381
+ function paragraphLogicalLength(paragraph: ParagraphNode): number {
2382
+ return paragraph.children.reduce<number>(
2383
+ (size, child) => size + inlineLogicalLength(child),
2384
+ 0,
2385
+ );
2386
+ }
2387
+
2388
+ function inlineLogicalLength(node: InlineNode): number {
2389
+ switch (node.type) {
2390
+ case "text":
2391
+ return node.text.length;
2392
+ case "hyperlink":
2393
+ case "field":
2394
+ return node.children.reduce<number>(
2395
+ (size, child) => size + inlineLogicalLength(child as InlineNode),
2396
+ 0,
2397
+ );
2398
+ case "bookmark_start":
2399
+ case "bookmark_end":
2400
+ case "scope_marker_start":
2401
+ case "scope_marker_end":
2402
+ return 0;
2403
+ default:
2404
+ return 1;
2405
+ }
2350
2406
  }
2351
2407
 
2352
2408
  function applySuggestingInsert(
@@ -839,6 +839,21 @@ export function normalizeCommentThreadRecord(value: unknown): CommentThreadRecor
839
839
  typeof record.metadata.rootParaId === "string"
840
840
  ? record.metadata.rootParaId
841
841
  : undefined,
842
+ linkedRevisionId:
843
+ typeof record.metadata.linkedRevisionId === "string"
844
+ ? record.metadata.linkedRevisionId
845
+ : undefined,
846
+ detachedReason:
847
+ record.metadata.detachedReason === "incomplete-markers" ||
848
+ record.metadata.detachedReason === "multi-paragraph" ||
849
+ record.metadata.detachedReason === "opaque-region" ||
850
+ record.metadata.detachedReason === "revision-overlap"
851
+ ? record.metadata.detachedReason
852
+ : undefined,
853
+ actionabilityNote:
854
+ typeof record.metadata.actionabilityNote === "string"
855
+ ? record.metadata.actionabilityNote
856
+ : undefined,
842
857
  }
843
858
  : undefined,
844
859
  };
@@ -0,0 +1,38 @@
1
+ import type { HeaderFooterVariant } from "../../model/canonical-document.ts";
2
+
3
+ interface HeaderFooterReferenceElement {
4
+ attributes: Record<string, string>;
5
+ }
6
+
7
+ export interface ParsedHeaderFooterReferenceAttributes {
8
+ variant: HeaderFooterVariant;
9
+ relationshipId: string;
10
+ }
11
+
12
+ export function readHeaderFooterReferenceAttributes(
13
+ element: HeaderFooterReferenceElement,
14
+ ): ParsedHeaderFooterReferenceAttributes | undefined {
15
+ const relationshipId =
16
+ element.attributes["r:id"] ??
17
+ element.attributes["r:Id"] ??
18
+ element.attributes.id ??
19
+ element.attributes.Id;
20
+ if (!relationshipId) {
21
+ return undefined;
22
+ }
23
+
24
+ return {
25
+ variant: toHeaderFooterVariant(element.attributes["w:type"] ?? element.attributes.type),
26
+ relationshipId,
27
+ };
28
+ }
29
+
30
+ export function toHeaderFooterVariant(raw: string | undefined): HeaderFooterVariant {
31
+ if (raw === "first") {
32
+ return "first";
33
+ }
34
+ if (raw === "even") {
35
+ return "even";
36
+ }
37
+ return "default";
38
+ }