@beyondwork/docx-react-component 1.0.28 → 1.0.30

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 (92) hide show
  1. package/package.json +26 -37
  2. package/src/api/public-types.ts +531 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/index.ts +201 -79
  5. package/src/core/commands/table-structure-commands.ts +138 -5
  6. package/src/core/state/text-transaction.ts +370 -3
  7. package/src/index.ts +41 -0
  8. package/src/io/docx-session.ts +318 -25
  9. package/src/io/export/serialize-footnotes.ts +41 -46
  10. package/src/io/export/serialize-headers-footers.ts +36 -40
  11. package/src/io/export/serialize-main-document.ts +55 -89
  12. package/src/io/export/serialize-numbering.ts +104 -4
  13. package/src/io/export/serialize-runtime-revisions.ts +196 -2
  14. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +252 -0
  15. package/src/io/export/table-properties-xml.ts +318 -0
  16. package/src/io/normalize/normalize-text.ts +34 -3
  17. package/src/io/ooxml/parse-comments.ts +6 -0
  18. package/src/io/ooxml/parse-footnotes.ts +69 -13
  19. package/src/io/ooxml/parse-headers-footers.ts +54 -11
  20. package/src/io/ooxml/parse-main-document.ts +112 -42
  21. package/src/io/ooxml/parse-numbering.ts +341 -26
  22. package/src/io/ooxml/parse-revisions.ts +118 -4
  23. package/src/io/ooxml/parse-styles.ts +176 -0
  24. package/src/io/ooxml/parse-tables.ts +34 -25
  25. package/src/io/ooxml/revision-boundaries.ts +127 -3
  26. package/src/io/ooxml/workflow-payload.ts +544 -0
  27. package/src/model/canonical-document.ts +91 -1
  28. package/src/model/snapshot.ts +112 -1
  29. package/src/preservation/store.ts +73 -3
  30. package/src/review/store/comment-store.ts +19 -1
  31. package/src/review/store/revision-actions.ts +29 -0
  32. package/src/review/store/revision-store.ts +12 -1
  33. package/src/review/store/revision-types.ts +11 -0
  34. package/src/runtime/context-analytics.ts +824 -0
  35. package/src/runtime/document-locations.ts +521 -0
  36. package/src/runtime/document-navigation.ts +14 -1
  37. package/src/runtime/document-outline.ts +440 -0
  38. package/src/runtime/document-runtime.ts +941 -45
  39. package/src/runtime/event-refresh-hints.ts +137 -0
  40. package/src/runtime/numbering-prefix.ts +67 -39
  41. package/src/runtime/page-layout-estimation.ts +100 -7
  42. package/src/runtime/resolved-numbering-geometry.ts +293 -0
  43. package/src/runtime/session-capabilities.ts +2 -2
  44. package/src/runtime/suggestions-snapshot.ts +137 -0
  45. package/src/runtime/surface-projection.ts +223 -27
  46. package/src/runtime/table-style-resolver.ts +409 -0
  47. package/src/runtime/view-state.ts +17 -1
  48. package/src/runtime/workflow-markup.ts +54 -14
  49. package/src/ui/WordReviewEditor.tsx +1269 -87
  50. package/src/ui/editor-command-bag.ts +7 -0
  51. package/src/ui/editor-runtime-boundary.ts +111 -10
  52. package/src/ui/editor-shell-view.tsx +17 -15
  53. package/src/ui/editor-surface-controller.tsx +5 -0
  54. package/src/ui/headless/selection-tool-context.ts +19 -0
  55. package/src/ui/headless/selection-tool-resolver.ts +752 -0
  56. package/src/ui/headless/selection-tool-types.ts +129 -0
  57. package/src/ui/headless/selection-toolbar-model.ts +10 -33
  58. package/src/ui/runtime-shortcut-dispatch.ts +365 -0
  59. package/src/ui-tailwind/chrome/chrome-preset-model.ts +107 -0
  60. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +15 -0
  61. package/src/ui-tailwind/chrome/review-queue-bar.tsx +97 -0
  62. package/src/ui-tailwind/chrome/tw-context-analytics-summary.tsx +122 -0
  63. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +1 -9
  64. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +1 -5
  65. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +8 -29
  66. package/src/ui-tailwind/chrome/tw-selection-tool-blocked.tsx +23 -0
  67. package/src/ui-tailwind/chrome/tw-selection-tool-comment.tsx +35 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +37 -0
  69. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +298 -0
  70. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +116 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-suggestion.tsx +29 -0
  72. package/src/ui-tailwind/chrome/tw-selection-tool-workflow.tsx +27 -0
  73. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +3 -3
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +3 -3
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +86 -14
  76. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +57 -52
  77. package/src/ui-tailwind/editor-surface/pm-decorations.ts +36 -52
  78. package/src/ui-tailwind/editor-surface/pm-schema.ts +56 -5
  79. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +87 -24
  80. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +4 -0
  81. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +135 -32
  82. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +74 -7
  83. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +17 -17
  84. package/src/ui-tailwind/review/tw-review-rail.tsx +19 -17
  85. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +10 -10
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +10 -6
  87. package/src/ui-tailwind/theme/editor-theme.css +58 -40
  88. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -4
  89. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +250 -181
  90. package/src/ui-tailwind/tw-review-workspace.tsx +323 -280
  91. package/src/validation/compatibility-engine.ts +246 -2
  92. package/src/validation/docx-comment-proof.ts +24 -11
@@ -0,0 +1,824 @@
1
+ import type {
2
+ CompatibilityReport,
3
+ EditorAnchorProjection,
4
+ EditorStoryTarget,
5
+ EditorWarning,
6
+ InteractionGuardSnapshot,
7
+ ReviewWorkSnapshot,
8
+ RuntimeContextAnalyticsActionHint,
9
+ RuntimeContextAnalyticsBadge,
10
+ RuntimeContextAnalyticsProvenance,
11
+ RuntimeContextAnalyticsQuery,
12
+ RuntimeContextAnalyticsSnapshot,
13
+ RuntimeRenderSnapshot,
14
+ SuggestionsSnapshot,
15
+ WorkflowBlockedCommandReason,
16
+ WorkflowMarkupItem,
17
+ WorkflowMarkupSnapshot,
18
+ WorkflowOverlay,
19
+ WorkflowScope,
20
+ WorkflowScopeSnapshot,
21
+ WorkflowWorkItem,
22
+ } from "../api/public-types";
23
+ import { MAIN_STORY_TARGET, storyTargetsEqual } from "../core/selection/mapping.ts";
24
+
25
+ const DERIVED_FROM: RuntimeContextAnalyticsProvenance["derivedFrom"] = [
26
+ "render",
27
+ "workflowScope",
28
+ "interactionGuard",
29
+ "workflowMarkup",
30
+ "reviewWork",
31
+ "warnings",
32
+ "compatibility",
33
+ ];
34
+
35
+ const UNAVAILABLE_FIELDS: RuntimeContextAnalyticsProvenance["unavailable"] = [
36
+ "tasksRemaining",
37
+ "approvalsRemaining",
38
+ "overdueItems",
39
+ "tags",
40
+ "assignee",
41
+ "dueDate",
42
+ "lastTouchedAt",
43
+ ];
44
+
45
+ type AnalyticsInput = {
46
+ query?: RuntimeContextAnalyticsQuery;
47
+ renderSnapshot: RuntimeRenderSnapshot;
48
+ workflowOverlay: WorkflowOverlay | null;
49
+ workflowScopeSnapshot: WorkflowScopeSnapshot | null;
50
+ interactionGuardSnapshot: InteractionGuardSnapshot;
51
+ workflowMarkupSnapshot: WorkflowMarkupSnapshot;
52
+ suggestionsSnapshot: SuggestionsSnapshot;
53
+ reviewWorkSnapshot: ReviewWorkSnapshot;
54
+ warnings: EditorWarning[];
55
+ compatibility: CompatibilityReport;
56
+ };
57
+
58
+ export function createRuntimeContextAnalyticsSnapshot(
59
+ input: AnalyticsInput,
60
+ ): RuntimeContextAnalyticsSnapshot | null {
61
+ const scopeKind = input.query?.scopeKind ?? "selection";
62
+ switch (scopeKind) {
63
+ case "document":
64
+ return buildDocumentAnalytics(input);
65
+ case "workflow_scope":
66
+ return buildWorkflowScopeAnalytics(
67
+ input,
68
+ input.query?.scopeId ?? input.interactionGuardSnapshot.matchedScopeId,
69
+ );
70
+ case "work_item":
71
+ return buildWorkItemAnalytics(
72
+ input,
73
+ input.query?.workItemId ??
74
+ input.workflowOverlay?.activeWorkItemId ??
75
+ input.workflowScopeSnapshot?.activeWorkItemId ??
76
+ undefined,
77
+ );
78
+ case "selection":
79
+ default:
80
+ return buildSelectionAnalytics(input);
81
+ }
82
+ }
83
+
84
+ export function resolveCurrentContextAnalyticsQuery(input: {
85
+ workflowScopeSnapshot: WorkflowScopeSnapshot | null;
86
+ interactionGuardSnapshot: InteractionGuardSnapshot;
87
+ }): RuntimeContextAnalyticsQuery | undefined {
88
+ const activeWorkItemId = input.workflowScopeSnapshot?.activeWorkItemId ?? null;
89
+ if (activeWorkItemId) {
90
+ return {
91
+ scopeKind: "work_item",
92
+ workItemId: activeWorkItemId,
93
+ };
94
+ }
95
+ if (input.interactionGuardSnapshot.matchedScopeId) {
96
+ return {
97
+ scopeKind: "workflow_scope",
98
+ scopeId: input.interactionGuardSnapshot.matchedScopeId,
99
+ };
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ export function runtimeContextAnalyticsSnapshotsEqual(
105
+ left: RuntimeContextAnalyticsSnapshot | null,
106
+ right: RuntimeContextAnalyticsSnapshot | null,
107
+ ): boolean {
108
+ if (left === right) {
109
+ return true;
110
+ }
111
+ if (!left || !right) {
112
+ return false;
113
+ }
114
+ return (
115
+ left.scopeKind === right.scopeKind &&
116
+ left.scopeId === right.scopeId &&
117
+ left.workItemId === right.workItemId &&
118
+ analyticsBadgesEqual(left.badges, right.badges) &&
119
+ analyticsActionHintsEqual(left.nextActions, right.nextActions) &&
120
+ analyticsCountsEqual(left.counts, right.counts) &&
121
+ analyticsStateEqual(left.state, right.state) &&
122
+ analyticsProvenanceEqual(left.provenance, right.provenance)
123
+ );
124
+ }
125
+
126
+ function buildDocumentAnalytics(input: AnalyticsInput): RuntimeContextAnalyticsSnapshot {
127
+ const warningCount = getDocumentWarningCount(input.compatibility, input.warnings);
128
+ const errorCount = getDocumentErrorCount(input.compatibility);
129
+ const exportReadiness = getExportReadiness({
130
+ compatibility: input.compatibility,
131
+ warnings: warningCount,
132
+ errors: errorCount,
133
+ workflowMarkup: input.workflowMarkupSnapshot,
134
+ });
135
+ const openWorkItems = countOpenWorkItems(input.workflowOverlay, input.workflowScopeSnapshot);
136
+ const unresolvedComments = input.reviewWorkSnapshot.openCommentCount;
137
+ const pendingRevisions = input.reviewWorkSnapshot.actionableRevisionCount;
138
+ const pendingSuggestions = countActiveSuggestions(input.suggestionsSnapshot);
139
+ const badges = [
140
+ openWorkItems > 0 ? createCountBadge("open-work-items", openWorkItems, "open work item") : null,
141
+ unresolvedComments > 0
142
+ ? createCountBadge("document-comments", unresolvedComments, "unresolved comment")
143
+ : null,
144
+ pendingSuggestions > 0
145
+ ? createCountBadge("document-suggestions", pendingSuggestions, "pending suggestion")
146
+ : pendingRevisions > 0
147
+ ? createCountBadge("document-revisions", pendingRevisions, "pending change")
148
+ : null,
149
+ createExportBadge(exportReadiness),
150
+ ].filter((badge): badge is RuntimeContextAnalyticsBadge => Boolean(badge));
151
+ const nextActions = [
152
+ openWorkItems > 0
153
+ ? createActionHint(
154
+ "open-work-items",
155
+ `Continue ${openWorkItems} open ${pluralize(openWorkItems, "work item")}`,
156
+ "next_step",
157
+ )
158
+ : null,
159
+ unresolvedComments > 0
160
+ ? createActionHint(
161
+ "resolve-comments",
162
+ `Resolve ${unresolvedComments} ${pluralize(unresolvedComments, "comment")}`,
163
+ "next_step",
164
+ )
165
+ : null,
166
+ pendingSuggestions > 0
167
+ ? createActionHint(
168
+ "review-suggestions",
169
+ `Review ${pendingSuggestions} pending ${pluralize(pendingSuggestions, "suggestion")}`,
170
+ "next_step",
171
+ )
172
+ : pendingRevisions > 0
173
+ ? createActionHint(
174
+ "review-revisions",
175
+ `Review ${pendingRevisions} pending ${pluralize(pendingRevisions, "change")}`,
176
+ "next_step",
177
+ )
178
+ : null,
179
+ exportReadiness === "blocked"
180
+ ? createActionHint("export-blocked", "Export is currently blocked", "blocker")
181
+ : exportReadiness === "warning"
182
+ ? createActionHint("export-warning", "Export has compatibility warnings", "warning")
183
+ : null,
184
+ ].filter((action): action is RuntimeContextAnalyticsActionHint => Boolean(action));
185
+
186
+ return {
187
+ scopeKind: "document",
188
+ badges,
189
+ nextActions,
190
+ counts: {
191
+ unresolvedComments,
192
+ pendingRevisions,
193
+ pendingSuggestions,
194
+ warnings: warningCount,
195
+ errors: errorCount,
196
+ openWorkItems,
197
+ exportBlockers: input.compatibility.blockExport ? 1 : 0,
198
+ },
199
+ state: {
200
+ isBlocked: exportReadiness === "blocked",
201
+ completionState:
202
+ openWorkItems === 0 && unresolvedComments === 0 && pendingRevisions === 0 && exportReadiness === "ready"
203
+ ? "ready"
204
+ : "in_progress",
205
+ exportReadiness,
206
+ },
207
+ provenance: createProvenance(),
208
+ };
209
+ }
210
+
211
+ function buildSelectionAnalytics(input: AnalyticsInput): RuntimeContextAnalyticsSnapshot {
212
+ const selectionAnchor = getSelectionAnchor(input.renderSnapshot);
213
+ const activeStory = input.renderSnapshot.activeStory;
214
+ const matchingScope = findMatchingScopeForSelection(
215
+ input.workflowOverlay,
216
+ input.workflowScopeSnapshot,
217
+ input.interactionGuardSnapshot,
218
+ selectionAnchor,
219
+ activeStory,
220
+ );
221
+ const matchingItems = input.workflowMarkupSnapshot.items.filter((item) =>
222
+ markupMatchesAnchor(item, selectionAnchor, activeStory),
223
+ );
224
+ const unresolvedComments = countUnresolvedComments(matchingItems);
225
+ const pendingRevisions = countPendingRevisions(matchingItems);
226
+ const pendingSuggestions = countPendingSuggestionsForMarkupItems(
227
+ matchingItems,
228
+ input.suggestionsSnapshot,
229
+ );
230
+ const blockedCommands = input.interactionGuardSnapshot.blockedReasons.length;
231
+ const badges = [
232
+ createModeBadge(input.interactionGuardSnapshot.effectiveMode),
233
+ unresolvedComments > 0
234
+ ? createCountBadge("selection-comments", unresolvedComments, "comment")
235
+ : null,
236
+ pendingSuggestions > 0
237
+ ? createCountBadge("selection-suggestions", pendingSuggestions, "pending suggestion")
238
+ : pendingRevisions > 0
239
+ ? createCountBadge("selection-revisions", pendingRevisions, "pending change")
240
+ : null,
241
+ ].filter((badge): badge is RuntimeContextAnalyticsBadge => Boolean(badge));
242
+
243
+ return {
244
+ scopeKind: "selection",
245
+ ...(matchingScope?.scopeId ? { scopeId: matchingScope.scopeId } : {}),
246
+ ...(matchingScope?.workItemId ? { workItemId: matchingScope.workItemId } : {}),
247
+ badges,
248
+ nextActions: createAttentionHints({
249
+ unresolvedComments,
250
+ pendingRevisions,
251
+ pendingSuggestions,
252
+ blockedReasons: input.interactionGuardSnapshot.blockedReasons,
253
+ }),
254
+ counts: {
255
+ unresolvedComments,
256
+ pendingRevisions,
257
+ pendingSuggestions,
258
+ blockedCommands,
259
+ },
260
+ state: {
261
+ effectiveMode: input.interactionGuardSnapshot.effectiveMode,
262
+ isBlocked: input.interactionGuardSnapshot.effectiveMode === "blocked",
263
+ ...(input.interactionGuardSnapshot.disabledReason
264
+ ? { disabledReason: input.interactionGuardSnapshot.disabledReason }
265
+ : {}),
266
+ completionState:
267
+ unresolvedComments === 0 &&
268
+ pendingRevisions === 0 &&
269
+ input.interactionGuardSnapshot.blockedReasons.length === 0
270
+ ? "ready"
271
+ : "in_progress",
272
+ },
273
+ provenance: createProvenance(),
274
+ };
275
+ }
276
+
277
+ function buildWorkflowScopeAnalytics(
278
+ input: AnalyticsInput,
279
+ scopeId: string | undefined,
280
+ ): RuntimeContextAnalyticsSnapshot | null {
281
+ if (!scopeId) {
282
+ return null;
283
+ }
284
+ const scope = getAllScopes(input.workflowOverlay, input.workflowScopeSnapshot).find(
285
+ (entry) => entry.scopeId === scopeId,
286
+ );
287
+ if (!scope) {
288
+ return null;
289
+ }
290
+ const matchingItems = input.workflowMarkupSnapshot.items.filter((item) =>
291
+ markupMatchesScope(item, scope),
292
+ );
293
+ const blockedReasons = (input.workflowScopeSnapshot?.blockedReasons ?? []).filter(
294
+ (reason) => reason.scopeId === scope.scopeId,
295
+ );
296
+ const unresolvedComments = countUnresolvedComments(matchingItems);
297
+ const pendingRevisions = countPendingRevisions(matchingItems);
298
+ const effectiveMode = deriveBlockedAwareMode(scope.mode, blockedReasons);
299
+
300
+ return {
301
+ scopeKind: "workflow_scope",
302
+ scopeId: scope.scopeId,
303
+ ...(scope.workItemId ? { workItemId: scope.workItemId } : {}),
304
+ badges: [
305
+ scope.label ? { key: "scope-label", label: scope.label, tone: "accent" } : null,
306
+ createModeBadge(effectiveMode),
307
+ unresolvedComments > 0 ? createCountBadge("scope-comments", unresolvedComments, "comment") : null,
308
+ pendingRevisions > 0 ? createCountBadge("scope-revisions", pendingRevisions, "pending change") : null,
309
+ ].filter((badge): badge is RuntimeContextAnalyticsBadge => Boolean(badge)),
310
+ nextActions: createAttentionHints({
311
+ unresolvedComments,
312
+ pendingRevisions,
313
+ blockedReasons,
314
+ }),
315
+ counts: {
316
+ unresolvedComments,
317
+ pendingRevisions,
318
+ blockedCommands: blockedReasons.length,
319
+ },
320
+ state: {
321
+ effectiveMode,
322
+ isBlocked: effectiveMode === "blocked",
323
+ ...(blockedReasons[0]?.message ? { disabledReason: blockedReasons[0].message } : {}),
324
+ completionState:
325
+ unresolvedComments === 0 && pendingRevisions === 0 && blockedReasons.length === 0
326
+ ? "ready"
327
+ : "in_progress",
328
+ },
329
+ provenance: createProvenance(),
330
+ };
331
+ }
332
+
333
+ function buildWorkItemAnalytics(
334
+ input: AnalyticsInput,
335
+ workItemId: string | undefined,
336
+ ): RuntimeContextAnalyticsSnapshot | null {
337
+ if (!workItemId) {
338
+ return null;
339
+ }
340
+ const workItem = getWorkflowWorkItem(input.workflowOverlay, input.workflowScopeSnapshot, workItemId);
341
+ const scopes = getAllScopes(input.workflowOverlay, input.workflowScopeSnapshot).filter(
342
+ (scope) => scope.workItemId === workItemId,
343
+ );
344
+ if (!workItem && scopes.length === 0) {
345
+ return null;
346
+ }
347
+ const matchingItems = input.workflowMarkupSnapshot.items.filter((item) =>
348
+ scopes.some((scope) => markupMatchesScope(item, scope)),
349
+ );
350
+ const blockedReasons = (input.workflowScopeSnapshot?.blockedReasons ?? []).filter(
351
+ (reason) => reason.workItemId === workItemId,
352
+ );
353
+ const unresolvedComments = countUnresolvedComments(matchingItems);
354
+ const pendingRevisions = countPendingRevisions(matchingItems);
355
+ const effectiveMode =
356
+ scopes.length === 1
357
+ ? deriveBlockedAwareMode(scopes[0]!.mode, blockedReasons)
358
+ : undefined;
359
+
360
+ return {
361
+ scopeKind: "work_item",
362
+ workItemId,
363
+ ...(scopes.length === 1 ? { scopeId: scopes[0]!.scopeId } : {}),
364
+ badges: [
365
+ workItem?.title ? { key: "work-item-title", label: workItem.title, tone: "accent" } : null,
366
+ effectiveMode ? createModeBadge(effectiveMode) : null,
367
+ unresolvedComments > 0
368
+ ? createCountBadge("work-item-comments", unresolvedComments, "comment")
369
+ : null,
370
+ pendingRevisions > 0
371
+ ? createCountBadge("work-item-revisions", pendingRevisions, "pending change")
372
+ : null,
373
+ ].filter((badge): badge is RuntimeContextAnalyticsBadge => Boolean(badge)),
374
+ nextActions: createAttentionHints({
375
+ unresolvedComments,
376
+ pendingRevisions,
377
+ blockedReasons,
378
+ }),
379
+ counts: {
380
+ unresolvedComments,
381
+ pendingRevisions,
382
+ blockedCommands: blockedReasons.length,
383
+ },
384
+ state: {
385
+ ...(effectiveMode ? { effectiveMode } : {}),
386
+ isBlocked: blockedReasons.length > 0,
387
+ ...(blockedReasons[0]?.message ? { disabledReason: blockedReasons[0].message } : {}),
388
+ completionState: resolveCompletionState(workItem, unresolvedComments, pendingRevisions, blockedReasons.length > 0),
389
+ },
390
+ provenance: createProvenance(),
391
+ };
392
+ }
393
+
394
+ function getSelectionAnchor(snapshot: RuntimeRenderSnapshot): EditorAnchorProjection {
395
+ if (!snapshot.selection.isCollapsed) {
396
+ return snapshot.selection.activeRange;
397
+ }
398
+ return {
399
+ kind: "node",
400
+ at: snapshot.selection.head,
401
+ assoc: 1,
402
+ };
403
+ }
404
+
405
+ function findMatchingScopeForSelection(
406
+ workflowOverlay: WorkflowOverlay | null,
407
+ workflowScopeSnapshot: WorkflowScopeSnapshot | null,
408
+ interactionGuardSnapshot: InteractionGuardSnapshot,
409
+ selectionAnchor: EditorAnchorProjection,
410
+ activeStory: EditorStoryTarget,
411
+ ): WorkflowScope | null {
412
+ if (interactionGuardSnapshot.matchedScopeId) {
413
+ return (
414
+ getAllScopes(workflowOverlay, workflowScopeSnapshot).find(
415
+ (scope) => scope.scopeId === interactionGuardSnapshot.matchedScopeId,
416
+ ) ?? null
417
+ );
418
+ }
419
+ return (
420
+ getAllScopes(workflowOverlay, workflowScopeSnapshot).find((scope) =>
421
+ anchorsOverlap(scope.anchor, scope.storyTarget, selectionAnchor, activeStory),
422
+ ) ?? null
423
+ );
424
+ }
425
+
426
+ function getAllScopes(
427
+ workflowOverlay: WorkflowOverlay | null,
428
+ workflowScopeSnapshot: WorkflowScopeSnapshot | null,
429
+ ): WorkflowScope[] {
430
+ return workflowOverlay?.scopes ?? workflowScopeSnapshot?.scopes ?? [];
431
+ }
432
+
433
+ function getWorkflowWorkItem(
434
+ workflowOverlay: WorkflowOverlay | null,
435
+ workflowScopeSnapshot: WorkflowScopeSnapshot | null,
436
+ workItemId: string,
437
+ ): WorkflowWorkItem | null {
438
+ if (workflowOverlay?.workItems) {
439
+ return workflowOverlay.workItems.find((item) => item.workItemId === workItemId) ?? null;
440
+ }
441
+ if (workflowScopeSnapshot?.activeWorkItem?.workItemId === workItemId) {
442
+ return workflowScopeSnapshot.activeWorkItem;
443
+ }
444
+ return null;
445
+ }
446
+
447
+ function countOpenWorkItems(
448
+ workflowOverlay: WorkflowOverlay | null,
449
+ workflowScopeSnapshot: WorkflowScopeSnapshot | null,
450
+ ): number {
451
+ if (workflowOverlay?.workItems?.length) {
452
+ return workflowOverlay.workItems.filter((item) => item.status !== "done").length;
453
+ }
454
+ const uniqueWorkItemIds = new Set(
455
+ (workflowScopeSnapshot?.scopes ?? [])
456
+ .map((scope) => scope.workItemId)
457
+ .filter((workItemId): workItemId is string => Boolean(workItemId)),
458
+ );
459
+ return uniqueWorkItemIds.size;
460
+ }
461
+
462
+ function markupMatchesScope(item: WorkflowMarkupItem, scope: WorkflowScope): boolean {
463
+ return anchorsOverlap(item.anchor, item.storyTarget, scope.anchor, scope.storyTarget);
464
+ }
465
+
466
+ function markupMatchesAnchor(
467
+ item: WorkflowMarkupItem,
468
+ anchor: EditorAnchorProjection,
469
+ storyTarget: EditorStoryTarget,
470
+ ): boolean {
471
+ return anchorsOverlap(item.anchor, item.storyTarget, anchor, storyTarget);
472
+ }
473
+
474
+ function anchorsOverlap(
475
+ left: EditorAnchorProjection,
476
+ leftStoryTarget: EditorStoryTarget | undefined,
477
+ right: EditorAnchorProjection,
478
+ rightStoryTarget: EditorStoryTarget | undefined,
479
+ ): boolean {
480
+ if (
481
+ !storyTargetsEqual(leftStoryTarget ?? MAIN_STORY_TARGET, rightStoryTarget ?? MAIN_STORY_TARGET)
482
+ ) {
483
+ return false;
484
+ }
485
+ const leftRange = toComparableRange(left);
486
+ const rightRange = toComparableRange(right);
487
+ if (!leftRange || !rightRange) {
488
+ return false;
489
+ }
490
+ return leftRange.from <= rightRange.to && rightRange.from <= leftRange.to;
491
+ }
492
+
493
+ function toComparableRange(
494
+ anchor: EditorAnchorProjection,
495
+ ): { from: number; to: number } | null {
496
+ if (anchor.kind === "detached") {
497
+ return null;
498
+ }
499
+ if (anchor.kind === "range") {
500
+ return {
501
+ from: Math.min(anchor.from, anchor.to),
502
+ to: Math.max(anchor.from, anchor.to),
503
+ };
504
+ }
505
+ return {
506
+ from: anchor.at,
507
+ to: anchor.at,
508
+ };
509
+ }
510
+
511
+ function countUnresolvedComments(items: readonly WorkflowMarkupItem[]): number {
512
+ return items.filter((item) => item.kind === "comment" && item.status === "open").length;
513
+ }
514
+
515
+ function countPendingRevisions(items: readonly WorkflowMarkupItem[]): number {
516
+ return items.filter(
517
+ (item) =>
518
+ item.kind === "revision" &&
519
+ item.status === "active" &&
520
+ item.actionability === "actionable",
521
+ ).length;
522
+ }
523
+
524
+ function getDocumentWarningCount(
525
+ compatibility: CompatibilityReport,
526
+ warnings: readonly EditorWarning[],
527
+ ): number {
528
+ return (
529
+ warnings.length +
530
+ compatibility.featureEntries.filter((entry) => entry.featureClass !== "supported-roundtrip").length
531
+ );
532
+ }
533
+
534
+ function getDocumentErrorCount(compatibility: CompatibilityReport): number {
535
+ return compatibility.errors.length;
536
+ }
537
+
538
+ function getExportReadiness(input: {
539
+ compatibility: CompatibilityReport;
540
+ workflowMarkup: WorkflowMarkupSnapshot;
541
+ warnings: number;
542
+ errors: number;
543
+ }): "ready" | "warning" | "blocked" {
544
+ if (input.compatibility.blockExport || input.errors > 0) {
545
+ return "blocked";
546
+ }
547
+ if (
548
+ input.warnings > 0 ||
549
+ input.workflowMarkup.opaqueFragments.length > 0 ||
550
+ input.workflowMarkup.protectedRanges.some((item) => item.enforced)
551
+ ) {
552
+ return "warning";
553
+ }
554
+ return "ready";
555
+ }
556
+
557
+ function createAttentionHints(input: {
558
+ unresolvedComments: number;
559
+ pendingRevisions: number;
560
+ pendingSuggestions?: number;
561
+ blockedReasons: readonly WorkflowBlockedCommandReason[];
562
+ }): RuntimeContextAnalyticsActionHint[] {
563
+ return [
564
+ input.blockedReasons[0]
565
+ ? createActionHint("blocked", input.blockedReasons[0].message, "blocker")
566
+ : null,
567
+ input.pendingSuggestions && input.pendingSuggestions > 0
568
+ ? createActionHint(
569
+ "review-suggestions",
570
+ `Review ${input.pendingSuggestions} pending ${pluralize(input.pendingSuggestions, "suggestion")}`,
571
+ "next_step",
572
+ )
573
+ : input.pendingRevisions > 0
574
+ ? createActionHint(
575
+ "review-revisions",
576
+ `Review ${input.pendingRevisions} pending ${pluralize(input.pendingRevisions, "change")}`,
577
+ "next_step",
578
+ )
579
+ : null,
580
+ input.unresolvedComments > 0
581
+ ? createActionHint(
582
+ "resolve-comments",
583
+ `Resolve ${input.unresolvedComments} ${pluralize(input.unresolvedComments, "comment")}`,
584
+ "next_step",
585
+ )
586
+ : null,
587
+ ].filter((action): action is RuntimeContextAnalyticsActionHint => Boolean(action));
588
+ }
589
+
590
+ function createModeBadge(
591
+ mode: "edit" | "suggest" | "comment" | "view" | "blocked",
592
+ ): RuntimeContextAnalyticsBadge | null {
593
+ switch (mode) {
594
+ case "suggest":
595
+ return { key: "mode", label: "Suggest", tone: "accent" };
596
+ case "comment":
597
+ return { key: "mode", label: "Comment only", tone: "accent" };
598
+ case "view":
599
+ return { key: "mode", label: "View only" };
600
+ case "blocked":
601
+ return { key: "mode", label: "Blocked", tone: "danger" };
602
+ case "edit":
603
+ default:
604
+ return null;
605
+ }
606
+ }
607
+
608
+ function createCountBadge(
609
+ key: string,
610
+ count: number,
611
+ singularLabel: string,
612
+ ): RuntimeContextAnalyticsBadge {
613
+ return {
614
+ key,
615
+ label: `${count} ${pluralize(count, singularLabel)}`,
616
+ };
617
+ }
618
+
619
+ function createExportBadge(
620
+ exportReadiness: "ready" | "warning" | "blocked",
621
+ ): RuntimeContextAnalyticsBadge {
622
+ return {
623
+ key: "export",
624
+ label:
625
+ exportReadiness === "blocked"
626
+ ? "Export blocked"
627
+ : exportReadiness === "warning"
628
+ ? "Export warnings"
629
+ : "Export ready",
630
+ tone:
631
+ exportReadiness === "blocked"
632
+ ? "danger"
633
+ : exportReadiness === "warning"
634
+ ? "warning"
635
+ : "success",
636
+ };
637
+ }
638
+
639
+ function createActionHint(
640
+ key: string,
641
+ label: string,
642
+ kind: RuntimeContextAnalyticsActionHint["kind"],
643
+ ): RuntimeContextAnalyticsActionHint {
644
+ return { key, label, kind };
645
+ }
646
+
647
+ function countActiveSuggestions(snapshot: SuggestionsSnapshot): number {
648
+ return snapshot.suggestions.filter(
649
+ (suggestion) =>
650
+ suggestion.status === "active" &&
651
+ suggestion.actionability === "actionable",
652
+ ).length;
653
+ }
654
+
655
+ function countPendingSuggestionsForMarkupItems(
656
+ items: readonly WorkflowMarkupItem[],
657
+ suggestionsSnapshot: SuggestionsSnapshot,
658
+ ): number {
659
+ if (items.length === 0) {
660
+ return 0;
661
+ }
662
+ const revisionIds = new Set(
663
+ items
664
+ .filter((item): item is Extract<WorkflowMarkupItem, { kind: "revision" }> => item.kind === "revision")
665
+ .filter((item) => item.status === "active" && item.actionability === "actionable")
666
+ .map((item) => item.revisionId),
667
+ );
668
+ if (revisionIds.size === 0) {
669
+ return 0;
670
+ }
671
+ return suggestionsSnapshot.suggestions.filter(
672
+ (suggestion) =>
673
+ suggestion.status === "active" &&
674
+ suggestion.actionability === "actionable" &&
675
+ suggestion.changeIds.some((changeId) => revisionIds.has(changeId)),
676
+ ).length;
677
+ }
678
+
679
+ function createProvenance(): RuntimeContextAnalyticsProvenance {
680
+ return {
681
+ kind: "runtime-derived",
682
+ derivedFrom: [...DERIVED_FROM],
683
+ unavailable: [...UNAVAILABLE_FIELDS],
684
+ };
685
+ }
686
+
687
+ function deriveBlockedAwareMode(
688
+ mode: WorkflowScope["mode"],
689
+ blockedReasons: readonly WorkflowBlockedCommandReason[],
690
+ ): "edit" | "suggest" | "comment" | "view" | "blocked" {
691
+ const primaryBlockedReason = blockedReasons[0];
692
+ if (!primaryBlockedReason) {
693
+ return mode;
694
+ }
695
+ if (primaryBlockedReason.code === "workflow_comment_only") {
696
+ return "comment";
697
+ }
698
+ if (primaryBlockedReason.code === "workflow_view_only") {
699
+ return "view";
700
+ }
701
+ return "blocked";
702
+ }
703
+
704
+ function resolveCompletionState(
705
+ workItem: WorkflowWorkItem | null,
706
+ unresolvedComments: number,
707
+ pendingRevisions: number,
708
+ isBlocked: boolean,
709
+ ): "not_started" | "in_progress" | "ready" | "done" {
710
+ if (workItem?.status === "done") {
711
+ return "done";
712
+ }
713
+ if (isBlocked || unresolvedComments > 0 || pendingRevisions > 0 || workItem?.status === "active") {
714
+ return "in_progress";
715
+ }
716
+ if (workItem?.status === "pending") {
717
+ return "not_started";
718
+ }
719
+ return "ready";
720
+ }
721
+
722
+ function pluralize(count: number, singular: string): string {
723
+ return count === 1 ? singular : `${singular}s`;
724
+ }
725
+
726
+ function analyticsBadgesEqual(
727
+ left: readonly RuntimeContextAnalyticsBadge[],
728
+ right: readonly RuntimeContextAnalyticsBadge[],
729
+ ): boolean {
730
+ if (left.length !== right.length) {
731
+ return false;
732
+ }
733
+ for (let index = 0; index < left.length; index += 1) {
734
+ const leftBadge = left[index]!;
735
+ const rightBadge = right[index]!;
736
+ if (
737
+ leftBadge.key !== rightBadge.key ||
738
+ leftBadge.label !== rightBadge.label ||
739
+ leftBadge.value !== rightBadge.value ||
740
+ leftBadge.tone !== rightBadge.tone ||
741
+ leftBadge.priority !== rightBadge.priority
742
+ ) {
743
+ return false;
744
+ }
745
+ }
746
+ return true;
747
+ }
748
+
749
+ function analyticsActionHintsEqual(
750
+ left: readonly RuntimeContextAnalyticsActionHint[],
751
+ right: readonly RuntimeContextAnalyticsActionHint[],
752
+ ): boolean {
753
+ if (left.length !== right.length) {
754
+ return false;
755
+ }
756
+ for (let index = 0; index < left.length; index += 1) {
757
+ const leftAction = left[index]!;
758
+ const rightAction = right[index]!;
759
+ if (
760
+ leftAction.key !== rightAction.key ||
761
+ leftAction.label !== rightAction.label ||
762
+ leftAction.kind !== rightAction.kind
763
+ ) {
764
+ return false;
765
+ }
766
+ }
767
+ return true;
768
+ }
769
+
770
+ function analyticsCountsEqual(
771
+ left: RuntimeContextAnalyticsSnapshot["counts"],
772
+ right: RuntimeContextAnalyticsSnapshot["counts"],
773
+ ): boolean {
774
+ return (
775
+ left.tasksRemaining === right.tasksRemaining &&
776
+ left.unresolvedComments === right.unresolvedComments &&
777
+ left.pendingRevisions === right.pendingRevisions &&
778
+ left.pendingSuggestions === right.pendingSuggestions &&
779
+ left.blockedCommands === right.blockedCommands &&
780
+ left.approvalsRemaining === right.approvalsRemaining &&
781
+ left.overdueItems === right.overdueItems &&
782
+ left.warnings === right.warnings &&
783
+ left.errors === right.errors &&
784
+ left.openWorkItems === right.openWorkItems &&
785
+ left.exportBlockers === right.exportBlockers
786
+ );
787
+ }
788
+
789
+ function analyticsStateEqual(
790
+ left: RuntimeContextAnalyticsSnapshot["state"],
791
+ right: RuntimeContextAnalyticsSnapshot["state"],
792
+ ): boolean {
793
+ return (
794
+ left.effectiveMode === right.effectiveMode &&
795
+ left.isBlocked === right.isBlocked &&
796
+ left.disabledReason === right.disabledReason &&
797
+ left.completionState === right.completionState &&
798
+ left.exportReadiness === right.exportReadiness &&
799
+ left.lastTouchedAt === right.lastTouchedAt
800
+ );
801
+ }
802
+
803
+ function analyticsProvenanceEqual(
804
+ left: RuntimeContextAnalyticsProvenance,
805
+ right: RuntimeContextAnalyticsProvenance,
806
+ ): boolean {
807
+ return (
808
+ left.kind === right.kind &&
809
+ stringArraysEqual(left.derivedFrom, right.derivedFrom) &&
810
+ stringArraysEqual(left.unavailable, right.unavailable)
811
+ );
812
+ }
813
+
814
+ function stringArraysEqual(left: readonly string[], right: readonly string[]): boolean {
815
+ if (left.length !== right.length) {
816
+ return false;
817
+ }
818
+ for (let index = 0; index < left.length; index += 1) {
819
+ if (left[index] !== right[index]) {
820
+ return false;
821
+ }
822
+ }
823
+ return true;
824
+ }