@beyondwork/docx-react-component 1.0.21 → 1.0.23

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 (33) hide show
  1. package/README.md +763 -38
  2. package/package.json +25 -36
  3. package/src/api/public-types.ts +66 -1
  4. package/src/core/commands/index.ts +574 -5
  5. package/src/index.ts +5 -0
  6. package/src/io/docx-session.ts +181 -2
  7. package/src/io/export/serialize-main-document.ts +21 -1
  8. package/src/io/normalize/normalize-text.ts +4 -0
  9. package/src/io/ooxml/parse-main-document.ts +88 -7
  10. package/src/model/canonical-document.ts +22 -0
  11. package/src/review/store/revision-store.ts +1 -0
  12. package/src/review/store/revision-types.ts +2 -0
  13. package/src/runtime/document-runtime.ts +503 -51
  14. package/src/runtime/session-capabilities.ts +6 -5
  15. package/src/runtime/surface-projection.ts +2 -0
  16. package/src/runtime/table-schema.ts +2 -0
  17. package/src/runtime/workflow-markup.ts +5 -1
  18. package/src/ui/WordReviewEditor.tsx +661 -132
  19. package/src/ui/editor-runtime-boundary.ts +10 -1
  20. package/src/ui/editor-shell-view.tsx +8 -0
  21. package/src/ui/editor-surface-controller.tsx +5 -0
  22. package/src/ui/headless/selection-toolbar-model.ts +12 -0
  23. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +139 -0
  24. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +6 -0
  25. package/src/ui-tailwind/editor-surface/pm-decorations.ts +44 -16
  26. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +2 -0
  27. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +4 -0
  28. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +127 -10
  29. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +82 -1
  30. package/src/ui-tailwind/status/tw-status-bar.tsx +4 -1
  31. package/src/ui-tailwind/theme/editor-theme.css +10 -0
  32. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +21 -5
  33. package/src/ui-tailwind/tw-review-workspace.tsx +110 -32
@@ -718,7 +718,10 @@ function createLoadingRuntimeBridge(input: {
718
718
  subscribeToEvents: () => () => undefined,
719
719
  emitBlockedCommand: () => undefined,
720
720
  getRenderSnapshot: () => input.snapshot,
721
+ getCanonicalDocument: () => input.sessionState.canonicalDocument,
722
+ getSourcePackage: () => input.sessionState.sourcePackage,
721
723
  replaceText: () => undefined,
724
+ applyActiveStoryTextCommand: () => undefined,
722
725
  dispatch: () => undefined,
723
726
  undo: () => undefined,
724
727
  redo: () => undefined,
@@ -770,7 +773,7 @@ function createLoadingRuntimeBridge(input: {
770
773
  setWorkflowOverlay: () => undefined,
771
774
  clearWorkflowOverlay: () => undefined,
772
775
  getWorkflowScopeSnapshot: () => null,
773
- getInteractionGuardSnapshot: () => ({ blockedReasons: [] }),
776
+ getInteractionGuardSnapshot: () => ({ effectiveMode: "edit", blockedReasons: [] }),
774
777
  getWorkflowMarkupSnapshot: () => ({
775
778
  totalCount: 0,
776
779
  items: [],
@@ -781,6 +784,12 @@ function createLoadingRuntimeBridge(input: {
781
784
  protectedRanges: [],
782
785
  opaqueFragments: [],
783
786
  }),
787
+ setHostAnnotationOverlay: () => undefined,
788
+ clearHostAnnotationOverlay: () => undefined,
789
+ getHostAnnotationSnapshot: () => ({
790
+ totalCount: 0,
791
+ annotations: [],
792
+ }),
784
793
  getWorkflowCandidateRanges: () => [],
785
794
  replaceWorkflowMarkupText: () => undefined,
786
795
  };
@@ -7,6 +7,7 @@ import type {
7
7
  InteractionGuardSnapshot,
8
8
  RuntimeRenderSnapshot,
9
9
  StyleCatalogSnapshot,
10
+ WordReviewEditorChromeVisibility,
10
11
  WorkflowScopeSnapshot,
11
12
  WorkspaceMode,
12
13
  ZoomLevel,
@@ -16,6 +17,7 @@ import type { MarkupDisplay } from "./headless/comment-decoration-model.ts";
16
17
  import type {
17
18
  SelectionToolbarAnchor,
18
19
  SelectionToolbarModel,
20
+ SuggestionCardModel,
19
21
  } from "./headless/selection-toolbar-model.ts";
20
22
  import type { EditorCommandBag } from "./editor-command-bag.ts";
21
23
  import type { ReviewRailTab } from "../ui-tailwind/review/tw-review-rail.tsx";
@@ -56,17 +58,23 @@ export interface EditorShellViewProps {
56
58
  workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
57
59
  interactionGuardSnapshot?: InteractionGuardSnapshot;
58
60
  selectionToolbar?: SelectionToolbarModel | null;
61
+ suggestionCard?: SuggestionCardModel | null;
59
62
  selectionToolbarAnchor?: SelectionToolbarAnchor | null;
60
63
  documentNavigation?: DocumentNavigationSnapshot;
61
64
  commands: EditorCommandBag;
62
65
  document: ReactNode;
63
66
  onAddCommentFromSelection?: () => void;
67
+ onAddCommentFromSuggestion?: () => void;
68
+ onAcceptSuggestion?: () => void;
69
+ onRejectSuggestion?: () => void;
70
+ onEditSuggestion?: () => void;
64
71
  onDismissSelectionToolbar?: () => void;
65
72
  onSelectionToolbarFocusCapture?: React.FocusEventHandler<HTMLDivElement>;
66
73
  onSelectionToolbarBlurCapture?: React.FocusEventHandler<HTMLDivElement>;
67
74
  selectionToolbarRef?: React.Ref<HTMLDivElement>;
68
75
  activeImageContext?: ActiveImageContext | null;
69
76
  activeObjectContext?: ActiveObjectContext | null;
77
+ chromeVisibility?: Partial<WordReviewEditorChromeVisibility>;
70
78
  }
71
79
 
72
80
  export function EditorShellView(props: EditorShellViewProps) {
@@ -40,11 +40,16 @@ export interface EditorSurfaceControllerProps {
40
40
  onOutdentTab?: () => void;
41
41
  onInsertHardBreak?: () => void;
42
42
  onSplitParagraph?: () => void;
43
+ onUndo?: () => void;
44
+ onRedo?: () => void;
45
+ onBlockedInput?: (command: "paste" | "drop", message: string) => void;
43
46
  onCommentActivated?: (commentId: string) => void;
44
47
  onRevisionActivated?: (revisionId: string) => void;
45
48
  workflowScopes?: readonly WorkflowScope[];
46
49
  workflowCandidates?: readonly WorkflowCandidateRange[];
47
50
  workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[];
51
+ activeWorkflowWorkItemId?: string | null;
52
+ activeWorkflowScopeIds?: readonly string[];
48
53
  }
49
54
 
50
55
  export const EditorSurfaceController = forwardRef<
@@ -14,6 +14,18 @@ export interface SelectionToolbarModel {
14
14
  disabledReason?: string;
15
15
  }
16
16
 
17
+ export interface SuggestionCardModel {
18
+ revisionId: string;
19
+ kindLabel: string;
20
+ previewText: string;
21
+ badges: SelectionToolbarBadge[];
22
+ canAccept: boolean;
23
+ canReject: boolean;
24
+ canEditSuggestion: boolean;
25
+ canAddComment: boolean;
26
+ disabledReason?: string;
27
+ }
28
+
17
29
  export interface SelectionToolbarAnchor {
18
30
  left: number;
19
31
  right: number;
@@ -0,0 +1,139 @@
1
+ import React from "react";
2
+ import type { FocusEventHandler } from "react";
3
+ import * as Tooltip from "@radix-ui/react-tooltip";
4
+ import { Check, MessageSquare, Pencil, X } from "lucide-react";
5
+
6
+ import type { SuggestionCardModel } from "../../ui/headless/selection-toolbar-model";
7
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
8
+
9
+ export interface TwSuggestionCardProps {
10
+ model: SuggestionCardModel;
11
+ onFocusCapture?: FocusEventHandler<HTMLDivElement>;
12
+ onBlurCapture?: FocusEventHandler<HTMLDivElement>;
13
+ onAccept?: () => void;
14
+ onReject?: () => void;
15
+ onEditSuggestion?: () => void;
16
+ onAddComment?: () => void;
17
+ }
18
+
19
+ const focusRingClass =
20
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
21
+
22
+ export function TwSuggestionCard(props: TwSuggestionCardProps) {
23
+ const contextLabel = summarizeSuggestionContext(props.model);
24
+ const commentDisabled = !props.model.canAddComment;
25
+ const tooltipLabel = commentDisabled
26
+ ? props.model.disabledReason ?? "Commenting is unavailable for this selection"
27
+ : "Comment on suggestion";
28
+
29
+ return (
30
+ <div
31
+ data-testid="suggestion-card"
32
+ className="inline-flex max-w-[min(28rem,calc(100vw-2rem))] flex-col gap-2 rounded-2xl border border-border/80 bg-canvas px-3 py-2 shadow-xl ring-1 ring-border/80"
33
+ onFocusCapture={props.onFocusCapture}
34
+ onBlurCapture={props.onBlurCapture}
35
+ role="group"
36
+ aria-label="Suggestion actions"
37
+ >
38
+ <div className="flex items-start justify-between gap-3">
39
+ <div className="min-w-0">
40
+ <div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-warning">
41
+ {props.model.kindLabel}
42
+ </div>
43
+ <div className="mt-1 max-w-[16rem] truncate text-sm text-primary">
44
+ {props.model.previewText}
45
+ </div>
46
+ </div>
47
+ {contextLabel ? (
48
+ <div className="shrink-0 rounded-full bg-accent-soft px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-accent">
49
+ {contextLabel}
50
+ </div>
51
+ ) : null}
52
+ </div>
53
+
54
+ <div className="flex flex-wrap items-center gap-1.5">
55
+ <SuggestionActionButton
56
+ icon={<Check className="h-3.5 w-3.5" />}
57
+ label="Accept suggestion"
58
+ disabled={!props.model.canAccept}
59
+ tone="accept"
60
+ onClick={props.onAccept}
61
+ />
62
+ <SuggestionActionButton
63
+ icon={<X className="h-3.5 w-3.5" />}
64
+ label="Reject suggestion"
65
+ disabled={!props.model.canReject}
66
+ tone="reject"
67
+ onClick={props.onReject}
68
+ />
69
+ <SuggestionActionButton
70
+ icon={<Pencil className="h-3.5 w-3.5" />}
71
+ label="Edit suggestion"
72
+ disabled={!props.model.canEditSuggestion}
73
+ tone="neutral"
74
+ onClick={props.onEditSuggestion}
75
+ />
76
+ <Tooltip.Root>
77
+ <Tooltip.Trigger asChild>
78
+ <button
79
+ type="button"
80
+ aria-label="Comment on suggestion"
81
+ disabled={commentDisabled}
82
+ onMouseDown={preserveEditorSelectionMouseDown}
83
+ onClick={props.onAddComment}
84
+ className={`inline-flex h-8 items-center gap-1 rounded-lg border border-border px-2.5 text-xs font-medium text-secondary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
85
+ >
86
+ <MessageSquare className="h-3.5 w-3.5" />
87
+ Comment
88
+ </button>
89
+ </Tooltip.Trigger>
90
+ <Tooltip.Portal>
91
+ <Tooltip.Content
92
+ className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
93
+ sideOffset={6}
94
+ >
95
+ {tooltipLabel}
96
+ </Tooltip.Content>
97
+ </Tooltip.Portal>
98
+ </Tooltip.Root>
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ function summarizeSuggestionContext(model: SuggestionCardModel): string | null {
105
+ const labels = model.badges.map((badge) => badge.label.trim()).filter(Boolean);
106
+ if (labels.length === 0) {
107
+ return null;
108
+ }
109
+ const summary = labels.slice(0, 2).join(" · ");
110
+ return summary.length > 36 ? `${summary.slice(0, 33)}...` : summary;
111
+ }
112
+
113
+ function SuggestionActionButton(props: {
114
+ icon: React.ReactNode;
115
+ label: string;
116
+ disabled: boolean;
117
+ tone: "accept" | "reject" | "neutral";
118
+ onClick?: () => void;
119
+ }) {
120
+ const toneClass = props.tone === "accept"
121
+ ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300"
122
+ : props.tone === "reject"
123
+ ? "border-rose-500/30 bg-rose-500/10 text-rose-700 hover:bg-rose-500/15 dark:text-rose-300"
124
+ : "border-border text-secondary hover:bg-surface";
125
+
126
+ return (
127
+ <button
128
+ type="button"
129
+ aria-label={props.label}
130
+ disabled={props.disabled}
131
+ onMouseDown={preserveEditorSelectionMouseDown}
132
+ onClick={props.onClick}
133
+ className={`inline-flex h-8 items-center gap-1 rounded-lg border px-2.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${toneClass} ${focusRingClass}`}
134
+ >
135
+ {props.icon}
136
+ {props.label.replace(" suggestion", "").replace(" on suggestion", "")}
137
+ </button>
138
+ );
139
+ }
@@ -19,6 +19,7 @@ export interface CommandBridgeCallbacks {
19
19
  onOutdentTab?: () => void;
20
20
  onUndo: () => void;
21
21
  onRedo: () => void;
22
+ onBlockedInput?: (command: "paste" | "drop", message: string) => void;
22
23
  onSelectionChange: (selection: SelectionSnapshot) => void;
23
24
  getPositionMap: () => PositionMap | null;
24
25
  isSelectionSyncSuppressed?: () => boolean;
@@ -100,17 +101,22 @@ export function createCommandBridgePlugins(
100
101
  },
101
102
  },
102
103
  handleTextInput(_view, _from, _to, text) {
104
+ if (isComposing) {
105
+ return true;
106
+ }
103
107
  callbacks.onInsertText(text);
104
108
  return true; // Block PM from processing
105
109
  },
106
110
 
107
111
  // Block paste (rich paste is not safe, plain paste via text.insert is TODO)
108
112
  handlePaste() {
113
+ callbacks.onBlockedInput?.("paste", "Paste is not supported in the mounted editor yet.");
109
114
  return true; // Block
110
115
  },
111
116
 
112
117
  // Block drop
113
118
  handleDrop() {
119
+ callbacks.onBlockedInput?.("drop", "Drag and drop is not supported in the mounted editor.");
114
120
  return true; // Block
115
121
  },
116
122
  },
@@ -21,18 +21,28 @@ type RailDecorationSpec = {
21
21
  attrs: Record<string, string>;
22
22
  };
23
23
 
24
- function getWorkflowInlineClass(scope: WorkflowScope): string {
25
- if (scope.mode === "edit") return "wre-workflow-inline wre-workflow-inline-edit";
26
- if (scope.mode === "suggest") return "wre-workflow-inline wre-workflow-inline-suggest";
27
- if (scope.mode === "comment") return "wre-workflow-inline wre-workflow-inline-comment";
28
- return "wre-workflow-inline wre-workflow-inline-view";
24
+ function getWorkflowInlineClass(scope: WorkflowScope, isActiveWorkItem: boolean): string {
25
+ const base =
26
+ scope.mode === "edit"
27
+ ? "wre-workflow-inline wre-workflow-inline-edit"
28
+ : scope.mode === "suggest"
29
+ ? "wre-workflow-inline wre-workflow-inline-suggest"
30
+ : scope.mode === "comment"
31
+ ? "wre-workflow-inline wre-workflow-inline-comment"
32
+ : "wre-workflow-inline wre-workflow-inline-view";
33
+ return isActiveWorkItem ? `${base} wre-workflow-inline-active` : base;
29
34
  }
30
35
 
31
- function getWorkflowRailClass(scope: WorkflowScope): string {
32
- if (scope.mode === "edit") return "wre-workflow-rail wre-workflow-rail-edit";
33
- if (scope.mode === "suggest") return "wre-workflow-rail wre-workflow-rail-suggest";
34
- if (scope.mode === "comment") return "wre-workflow-rail wre-workflow-rail-comment";
35
- return "wre-workflow-rail wre-workflow-rail-view";
36
+ function getWorkflowRailClass(scope: WorkflowScope, isActiveWorkItem: boolean): string {
37
+ const base =
38
+ scope.mode === "edit"
39
+ ? "wre-workflow-rail wre-workflow-rail-edit"
40
+ : scope.mode === "suggest"
41
+ ? "wre-workflow-rail wre-workflow-rail-suggest"
42
+ : scope.mode === "comment"
43
+ ? "wre-workflow-rail wre-workflow-rail-comment"
44
+ : "wre-workflow-rail wre-workflow-rail-view";
45
+ return isActiveWorkItem ? `${base} wre-workflow-rail-active` : base;
36
46
  }
37
47
 
38
48
  function getWorkflowCandidateInlineClass(): string {
@@ -142,8 +152,14 @@ function pushRailDecorations(
142
152
  from: number,
143
153
  to: number,
144
154
  spec: RailDecorationSpec,
155
+ rangeCache: Map<string, Array<{ from: number; to: number }>>,
145
156
  ): void {
146
- for (const range of collectRailRanges(doc, from, to)) {
157
+ const cacheKey = `${from}:${to}`;
158
+ const ranges = rangeCache.get(cacheKey) ?? collectRailRanges(doc, from, to);
159
+ if (!rangeCache.has(cacheKey)) {
160
+ rangeCache.set(cacheKey, ranges);
161
+ }
162
+ for (const range of ranges) {
147
163
  decorations.push(
148
164
  Decoration.node(range.from, range.to, {
149
165
  class: spec.className,
@@ -172,8 +188,12 @@ export function buildDecorations(
172
188
  activeStory: EditorStoryTarget = MAIN_STORY_TARGET,
173
189
  workflowCandidates?: readonly WorkflowCandidateRange[],
174
190
  workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[],
191
+ activeWorkflowWorkItemId?: string | null,
192
+ activeWorkflowScopeIds?: readonly string[],
175
193
  ): DecorationSet {
176
194
  const decorations: Decoration[] = [];
195
+ const railRangeCache = new Map<string, Array<{ from: number; to: number }>>();
196
+ const activeScopeIds = new Set(activeWorkflowScopeIds ?? []);
177
197
 
178
198
  // Walk comment threads and create inline decorations
179
199
  if (commentModel) {
@@ -251,25 +271,33 @@ export function buildDecorations(
251
271
  if (!storyTargetsEqual(scopeStoryTarget, activeStory)) continue;
252
272
  const pmRange = buildAnchorPmRange(scope.anchor, positionMap);
253
273
  if (!pmRange) continue;
274
+ const isActiveWorkItem =
275
+ Boolean(activeWorkflowWorkItemId) &&
276
+ (
277
+ scope.workItemId === activeWorkflowWorkItemId ||
278
+ activeScopeIds.has(scope.scopeId)
279
+ );
254
280
 
255
281
  if (pmRange.allowInline && pmRange.from < pmRange.to) {
256
282
  decorations.push(
257
283
  Decoration.inline(pmRange.from, pmRange.to, {
258
- class: getWorkflowInlineClass(scope),
284
+ class: getWorkflowInlineClass(scope, isActiveWorkItem),
259
285
  "data-workflow-scope-id": scope.scopeId,
260
286
  "data-workflow-scope-mode": scope.mode,
287
+ "data-workflow-active": isActiveWorkItem ? "true" : "false",
261
288
  }),
262
289
  );
263
290
  }
264
291
 
265
292
  pushRailDecorations(decorations, doc, pmRange.from, pmRange.to, {
266
293
  railKind: "scope",
267
- className: getWorkflowRailClass(scope),
294
+ className: getWorkflowRailClass(scope, isActiveWorkItem),
268
295
  attrs: {
269
296
  "data-workflow-scope-id": scope.scopeId,
270
297
  "data-workflow-scope-mode": scope.mode,
298
+ "data-workflow-active": isActiveWorkItem ? "true" : "false",
271
299
  },
272
- });
300
+ }, railRangeCache);
273
301
  }
274
302
  }
275
303
 
@@ -295,7 +323,7 @@ export function buildDecorations(
295
323
  attrs: {
296
324
  "data-workflow-candidate-id": candidate.candidateId,
297
325
  },
298
- });
326
+ }, railRangeCache);
299
327
  }
300
328
  }
301
329
 
@@ -327,7 +355,7 @@ export function buildDecorations(
327
355
  attrs: {
328
356
  "data-workflow-blocked-code": reason.code,
329
357
  },
330
- });
358
+ }, railRangeCache);
331
359
  }
332
360
  }
333
361
 
@@ -383,6 +383,8 @@ function buildTable(
383
383
  }
384
384
  rows.push(editorSchema.nodes.table_row.create(
385
385
  {
386
+ gridBefore: row.gridBefore ?? 0,
387
+ gridAfter: row.gridAfter ?? 0,
386
388
  height: row.height ?? null,
387
389
  heightRule: row.heightRule ?? null,
388
390
  isHeader: row.isHeader ?? false,
@@ -41,6 +41,8 @@ export function createSurfaceDecorationKey(input: {
41
41
  workflowScopeSignature?: string;
42
42
  workflowCandidateSignature?: string;
43
43
  workflowBlockedSignature?: string;
44
+ activeWorkflowWorkItemId?: string | null;
45
+ activeWorkflowScopeIds?: readonly string[];
44
46
  }): string {
45
47
  return JSON.stringify({
46
48
  markupDisplay: input.markupDisplay,
@@ -51,5 +53,7 @@ export function createSurfaceDecorationKey(input: {
51
53
  workflowScopeSignature: input.workflowScopeSignature ?? null,
52
54
  workflowCandidateSignature: input.workflowCandidateSignature ?? null,
53
55
  workflowBlockedSignature: input.workflowBlockedSignature ?? null,
56
+ activeWorkflowWorkItemId: input.activeWorkflowWorkItemId ?? null,
57
+ activeWorkflowScopeIds: input.activeWorkflowScopeIds ?? [],
54
58
  });
55
59
  }
@@ -87,6 +87,9 @@ export interface TwProseMirrorSurfaceProps {
87
87
  onOutdentTab?: () => void;
88
88
  onInsertHardBreak?: () => void;
89
89
  onSplitParagraph?: () => void;
90
+ onUndo?: () => void;
91
+ onRedo?: () => void;
92
+ onBlockedInput?: (command: "paste" | "drop", message: string) => void;
90
93
  onCommentActivated?: (commentId: string) => void;
91
94
  onRevisionActivated?: (revisionId: string) => void;
92
95
  onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
@@ -94,6 +97,8 @@ export interface TwProseMirrorSurfaceProps {
94
97
  workflowScopes?: readonly WorkflowScope[];
95
98
  workflowCandidates?: readonly WorkflowCandidateRange[];
96
99
  workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[];
100
+ activeWorkflowWorkItemId?: string | null;
101
+ activeWorkflowScopeIds?: readonly string[];
97
102
  }
98
103
 
99
104
  export interface TwProseMirrorSurfaceRef {
@@ -161,8 +166,11 @@ export const TwProseMirrorSurface = forwardRef<
161
166
  onInsertHardBreak: () => props.onInsertHardBreak?.(),
162
167
  onInsertTab: () => props.onInsertTab?.(),
163
168
  onOutdentTab: () => props.onOutdentTab?.(),
164
- onUndo: () => {}, // Handled by toolbar, not PM
165
- onRedo: () => {}, // Handled by toolbar, not PM
169
+ onUndo: () => props.onUndo?.(),
170
+ onRedo: () => props.onRedo?.(),
171
+ onBlockedInput: (command, message) => {
172
+ props.onBlockedInput?.(command, message);
173
+ },
166
174
  onSelectionChange: (sel) => {
167
175
  pendingSelectionProbeRef.current = startPerfProbe("selection");
168
176
  props.onSelectionChange?.(
@@ -204,9 +212,11 @@ export const TwProseMirrorSurface = forwardRef<
204
212
  canEdit,
205
213
  activeCommentId: snapshot.comments.activeCommentId,
206
214
  activeRevisionId: props.activeRevisionId,
207
- workflowScopeSignature: JSON.stringify(props.workflowScopes ?? []),
208
- workflowCandidateSignature: JSON.stringify(props.workflowCandidates ?? []),
209
- workflowBlockedSignature: JSON.stringify(props.workflowBlockedReasons ?? []),
215
+ workflowScopeSignature: createWorkflowScopeSignature(props.workflowScopes),
216
+ workflowCandidateSignature: createWorkflowCandidateSignature(props.workflowCandidates),
217
+ workflowBlockedSignature: createWorkflowBlockedSignature(props.workflowBlockedReasons),
218
+ activeWorkflowWorkItemId: props.activeWorkflowWorkItemId ?? null,
219
+ activeWorkflowScopeIds: props.activeWorkflowScopeIds ?? [],
210
220
  }),
211
221
  [
212
222
  canEdit,
@@ -214,6 +224,8 @@ export const TwProseMirrorSurface = forwardRef<
214
224
  props.activeRevisionId,
215
225
  props.workflowCandidates,
216
226
  props.workflowBlockedReasons,
227
+ props.activeWorkflowWorkItemId,
228
+ props.activeWorkflowScopeIds,
217
229
  props.workflowScopes,
218
230
  showTrackedChanges,
219
231
  snapshot.comments.activeCommentId,
@@ -233,6 +245,7 @@ export const TwProseMirrorSurface = forwardRef<
233
245
  onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
234
246
  onUndo: () => callbacksRef.current?.onUndo(),
235
247
  onRedo: () => callbacksRef.current?.onRedo(),
248
+ onBlockedInput: (command, message) => callbacksRef.current?.onBlockedInput?.(command, message),
236
249
  onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
237
250
  getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
238
251
  isSelectionSyncSuppressed: () =>
@@ -259,6 +272,8 @@ export const TwProseMirrorSurface = forwardRef<
259
272
  snapshot.activeStory,
260
273
  props.workflowCandidates,
261
274
  props.workflowBlockedReasons,
275
+ props.activeWorkflowWorkItemId,
276
+ props.activeWorkflowScopeIds,
262
277
  );
263
278
  view.setProps({
264
279
  editable: () => canEdit,
@@ -273,13 +288,13 @@ export const TwProseMirrorSurface = forwardRef<
273
288
  commentModel,
274
289
  decorationBuildKey,
275
290
  markupDisplay,
276
- revisionModel,
277
- showTrackedChanges,
291
+ props.activeWorkflowScopeIds,
292
+ props.activeWorkflowWorkItemId,
278
293
  props.workflowBlockedReasons,
279
294
  props.workflowCandidates,
280
295
  props.workflowScopes,
281
- props.workflowCandidates,
282
- props.workflowBlockedReasons,
296
+ revisionModel,
297
+ showTrackedChanges,
283
298
  ],
284
299
  );
285
300
 
@@ -309,6 +324,8 @@ export const TwProseMirrorSurface = forwardRef<
309
324
  snapshot.activeStory,
310
325
  props.workflowCandidates,
311
326
  props.workflowBlockedReasons,
327
+ props.activeWorkflowWorkItemId,
328
+ props.activeWorkflowScopeIds,
312
329
  );
313
330
  recordPerfSample("pm.rebuild");
314
331
  incrementInvalidationCounter("pm.laneA.rebuilds");
@@ -371,6 +388,20 @@ export const TwProseMirrorSurface = forwardRef<
371
388
  applyDecorationProps(view, positionMap);
372
389
  }, [applyDecorationProps, decorationBuildKey, surface]);
373
390
 
391
+ useEffect(() => {
392
+ if (!activeSearchRef.current || !surface) {
393
+ return;
394
+ }
395
+ applySearch(activeSearchRef.current.query, activeSearchRef.current.options);
396
+ }, [
397
+ markupDisplay,
398
+ props.canonicalDocument,
399
+ props.documentNavigation,
400
+ snapshot.activeStory,
401
+ snapshot.trackedChanges,
402
+ surface,
403
+ ]);
404
+
374
405
  useEffect(() => {
375
406
  const view = viewRef.current;
376
407
  const positionMap = positionMapRef.current;
@@ -442,7 +473,15 @@ export const TwProseMirrorSurface = forwardRef<
442
473
  return getTableSelectionDescriptor(view.state);
443
474
  },
444
475
  }),
445
- [snapshot.selection, snapshot.surface],
476
+ [
477
+ markupDisplay,
478
+ props.canonicalDocument,
479
+ props.documentNavigation,
480
+ snapshot.activeStory,
481
+ snapshot.selection,
482
+ snapshot.surface,
483
+ snapshot.trackedChanges,
484
+ ],
446
485
  );
447
486
 
448
487
  function applySearch(query: string, options: SearchOptions): SearchResultSnapshot[] {
@@ -788,6 +827,84 @@ export const TwProseMirrorSurface = forwardRef<
788
827
  }
789
828
  });
790
829
 
830
+ function createWorkflowScopeSignature(scopes: readonly WorkflowScope[] | undefined): string {
831
+ if (!scopes || scopes.length === 0) {
832
+ return "";
833
+ }
834
+ return scopes.map((scope) =>
835
+ [
836
+ scope.scopeId,
837
+ scope.mode,
838
+ scope.workItemId ?? "",
839
+ serializeAnchorSignature(scope.anchor),
840
+ serializeStoryTargetSignature(scope.storyTarget),
841
+ ].join(":")
842
+ ).join("|");
843
+ }
844
+
845
+ function createWorkflowCandidateSignature(
846
+ candidates: readonly WorkflowCandidateRange[] | undefined,
847
+ ): string {
848
+ if (!candidates || candidates.length === 0) {
849
+ return "";
850
+ }
851
+ return candidates.map((candidate) =>
852
+ [
853
+ candidate.candidateId,
854
+ serializeAnchorSignature(candidate.anchor),
855
+ serializeStoryTargetSignature(candidate.storyTarget),
856
+ candidate.source ?? "",
857
+ ].join(":")
858
+ ).join("|");
859
+ }
860
+
861
+ function createWorkflowBlockedSignature(
862
+ blockedReasons: readonly WorkflowBlockedCommandReason[] | undefined,
863
+ ): string {
864
+ if (!blockedReasons || blockedReasons.length === 0) {
865
+ return "";
866
+ }
867
+ return blockedReasons.map((reason) =>
868
+ [
869
+ reason.code,
870
+ reason.scopeId ?? "",
871
+ reason.workItemId ?? "",
872
+ serializeAnchorSignature(reason.anchor),
873
+ serializeStoryTargetSignature(reason.storyTarget),
874
+ ].join(":")
875
+ ).join("|");
876
+ }
877
+
878
+ function serializeAnchorSignature(anchor: WorkflowScope["anchor"] | WorkflowCandidateRange["anchor"] | WorkflowBlockedCommandReason["anchor"] | undefined): string {
879
+ if (!anchor) {
880
+ return "";
881
+ }
882
+ switch (anchor.kind) {
883
+ case "range":
884
+ return `range:${anchor.from}:${anchor.to}:${anchor.assoc.start}:${anchor.assoc.end}`;
885
+ case "node":
886
+ return `node:${anchor.at}:${anchor.assoc}`;
887
+ case "detached":
888
+ return `detached:${anchor.lastKnownRange.from}:${anchor.lastKnownRange.to}:${anchor.reason}`;
889
+ }
890
+ }
891
+
892
+ function serializeStoryTargetSignature(storyTarget: WorkflowScope["storyTarget"] | WorkflowCandidateRange["storyTarget"] | WorkflowBlockedCommandReason["storyTarget"]): string {
893
+ if (!storyTarget) {
894
+ return "";
895
+ }
896
+ switch (storyTarget.kind) {
897
+ case "main":
898
+ return "main";
899
+ case "header":
900
+ case "footer":
901
+ return `${storyTarget.kind}:${storyTarget.relationshipId}:${storyTarget.variant}:${storyTarget.sectionIndex ?? ""}`;
902
+ case "footnote":
903
+ case "endnote":
904
+ return `${storyTarget.kind}:${storyTarget.noteId}`;
905
+ }
906
+ }
907
+
791
908
  function buildSelectionToolbarMeasurementKey(
792
909
  selection: SelectionSnapshot,
793
910
  activeStory: RuntimeRenderSnapshot["activeStory"],