@beyondwork/docx-react-component 1.0.85 → 1.0.87

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/public-types.ts +49 -0
  3. package/src/api/v3/ui/chrome-composition.ts +2 -11
  4. package/src/api/v3/ui/chrome.ts +6 -8
  5. package/src/index.ts +5 -0
  6. package/src/io/export/serialize-main-document.ts +215 -6
  7. package/src/io/ooxml/parse-drawing.ts +15 -1
  8. package/src/io/ooxml/parse-fields.ts +410 -12
  9. package/src/model/canonical-document.ts +177 -2
  10. package/src/model/layout/page-layout-snapshot.ts +2 -0
  11. package/src/model/layout/runtime-page-graph-types.ts +6 -0
  12. package/src/preservation/store.ts +4 -5
  13. package/src/runtime/document-outline.ts +80 -0
  14. package/src/runtime/document-runtime.ts +338 -13
  15. package/src/runtime/formatting/field/page-number-format.ts +49 -0
  16. package/src/runtime/formatting/field/resolver.ts +61 -40
  17. package/src/runtime/layout/layout-engine-instance.ts +18 -1
  18. package/src/runtime/layout/layout-engine-version.ts +19 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
  20. package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
  21. package/src/runtime/layout/page-graph.ts +13 -2
  22. package/src/runtime/layout/paginated-layout-engine.ts +440 -117
  23. package/src/runtime/layout/project-block-fragments.ts +87 -4
  24. package/src/runtime/layout/resolve-page-fields.ts +8 -5
  25. package/src/runtime/layout/table-row-split.ts +97 -23
  26. package/src/runtime/surface-projection.ts +227 -27
  27. package/src/shell/session-bootstrap.ts +6 -1
  28. package/src/ui/WordReviewEditor.tsx +112 -33
  29. package/src/ui/editor-command-bag.ts +4 -0
  30. package/src/ui/editor-shell-view.tsx +1 -0
  31. package/src/ui/editor-surface-controller.tsx +1 -0
  32. package/src/ui/headless/revision-decoration-model.ts +11 -13
  33. package/src/ui-tailwind/chrome/editor-action-registry.ts +7 -26
  34. package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
  35. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
  36. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +6 -2
  37. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
  38. package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
  39. package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
  40. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
  41. package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
  42. package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
  43. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +41 -44
  44. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +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 +75 -31
  48. package/src/ui-tailwind/review/tw-workflow-tab.tsx +159 -8
  49. package/src/ui-tailwind/review-workspace/types.ts +4 -0
  50. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
  51. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +8 -10
  52. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +21 -17
  53. package/src/ui-tailwind/tw-review-workspace.tsx +27 -2
@@ -1062,7 +1062,6 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1062
1062
  } = props;
1063
1063
 
1064
1064
  const [activeRailTab, setActiveRailTab] = useState<ReviewRailTab>("comments");
1065
- const [showTrackedChanges, setShowTrackedChanges] = useState(() => suggestionsEnabled);
1066
1065
  const [localMarkupDisplay, setLocalMarkupDisplay] =
1067
1066
  useState<WorkflowMarkupMode | null>(() => suggestionsEnabled ? "all" : null);
1068
1067
  const [activeRevisionId, setActiveRevisionId] = useState<string | undefined>();
@@ -1368,6 +1367,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1368
1367
  const isPageWorkspace = viewState.workspaceMode === "page";
1369
1368
  const liveMarkupDisplay = localMarkupDisplay ??
1370
1369
  __resolveLiveMarkupDisplay(markupDisplay, isPageWorkspace);
1370
+ const trackedChangesAuthoringEnabled = viewState.documentMode === "suggesting";
1371
+ const showTrackedChanges = toWorkflowMarkupMode(liveMarkupDisplay) !== "clean";
1371
1372
  const documentNavigation = useRuntimeValue(
1372
1373
  runtime
1373
1374
  ? {
@@ -1510,16 +1511,15 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1510
1511
  const setReviewMarkupMode = useCallback((mode: MarkupDisplay) => {
1511
1512
  const workflowMode = toWorkflowMarkupMode(mode);
1512
1513
  setLocalMarkupDisplay(workflowMode);
1513
- setShowTrackedChanges(workflowMode !== "clean");
1514
1514
  api.ui?.viewport.setLocalMarkupMode(workflowMode);
1515
1515
  }, [api]);
1516
1516
 
1517
1517
  const setTrackedChangesAuthoring = useCallback((enabled: boolean) => {
1518
- const workflowMode: WorkflowMarkupMode = enabled ? "all" : "clean";
1519
- setShowTrackedChanges(enabled);
1520
- setLocalMarkupDisplay(workflowMode);
1521
1518
  api.runtime.document.setMode(enabled ? "suggesting" : "editing");
1522
- api.ui?.viewport.setLocalMarkupMode(workflowMode);
1519
+ if (enabled) {
1520
+ setLocalMarkupDisplay("all");
1521
+ api.ui?.viewport.setLocalMarkupMode("all");
1522
+ }
1523
1523
  }, [api]);
1524
1524
 
1525
1525
  useEffect(() => {
@@ -1527,7 +1527,6 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1527
1527
  setTrackedChangesAuthoring(true);
1528
1528
  return;
1529
1529
  }
1530
- setShowTrackedChanges(false);
1531
1530
  api.runtime.document.setMode("editing");
1532
1531
  }, [api, setTrackedChangesAuthoring, suggestionsEnabled]);
1533
1532
 
@@ -2467,13 +2466,15 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2467
2466
 
2468
2467
  function addReviewComment(): string | null {
2469
2468
  try {
2469
+ const currentSnapshot = activeRuntime.getRenderSnapshot();
2470
2470
  const { commentId } = activeRuntime.addComment({
2471
- anchor: resolveCommentCommandAnchor(snapshot),
2471
+ anchor: resolveCommentCommandAnchor(currentSnapshot),
2472
2472
  body: "",
2473
2473
  authorId: currentUser.userId,
2474
2474
  snapToSafeBoundary: true,
2475
2475
  });
2476
2476
  activeRuntime.openComment(commentId);
2477
+ setActiveReviewQueueItemId(`comment:${commentId}`);
2477
2478
  setActiveRailTab("comments");
2478
2479
  return commentId;
2479
2480
  } catch {
@@ -2814,11 +2815,49 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2814
2815
  }, [
2815
2816
  activeRuntime,
2816
2817
  activeSelectionTool,
2818
+ addReviewComment,
2817
2819
  currentUser.userId,
2818
2820
  dismissSelectionToolbar,
2819
2821
  focusDocumentSurface,
2820
2822
  ]);
2821
2823
 
2824
+ const acceptActiveSuggestion = useCallback(() => {
2825
+ if (activeSelectionTool?.kind !== "suggestion-review") {
2826
+ return;
2827
+ }
2828
+ for (const changeId of activeSelectionTool.changeIds) {
2829
+ activeRuntime.acceptChange(changeId);
2830
+ }
2831
+ dismissSelectionToolbar("chrome-action");
2832
+ }, [activeRuntime, activeSelectionTool, dismissSelectionToolbar]);
2833
+
2834
+ const rejectActiveSuggestion = useCallback(() => {
2835
+ if (activeSelectionTool?.kind !== "suggestion-review") {
2836
+ return;
2837
+ }
2838
+ for (const changeId of activeSelectionTool.changeIds) {
2839
+ activeRuntime.rejectChange(changeId);
2840
+ }
2841
+ dismissSelectionToolbar("chrome-action");
2842
+ }, [activeRuntime, activeSelectionTool, dismissSelectionToolbar]);
2843
+
2844
+ const editActiveSuggestion = useCallback(() => {
2845
+ if (activeSelectionTool?.kind !== "suggestion-review") {
2846
+ return;
2847
+ }
2848
+ setSuppressedSuggestionRevisionId(activeSelectionTool.suggestionId);
2849
+ const activeSuggestion = suggestionsSnapshot.suggestions.find(
2850
+ (suggestion) => suggestion.suggestionId === activeSelectionTool.suggestionId,
2851
+ );
2852
+ if (activeSuggestion) {
2853
+ applyRuntimeSelection(
2854
+ activeRuntime,
2855
+ createSelectionFromAnchor(activeSuggestion.anchor, activeSuggestion.storyTarget),
2856
+ );
2857
+ }
2858
+ setSelectionToolbarFocusWithin(true);
2859
+ }, [activeRuntime, activeSelectionTool, suggestionsSnapshot.suggestions]);
2860
+
2822
2861
  const handleSelectionToolbarAnchorChange = useCallback(
2823
2862
  (nextAnchor: SelectionToolbarAnchor | null) => {
2824
2863
  setSelectionToolbarAnchor((current) =>
@@ -3209,6 +3248,19 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3209
3248
  focusAnchor(revision.anchor, revision.storyTarget);
3210
3249
  setActiveRailTab("changes");
3211
3250
  },
3251
+ onReplyToRevision: (revision: typeof snapshot.trackedChanges.revisions[number]) => {
3252
+ const linkedThread = activeRuntime.ensureCommentThreadForChange(
3253
+ revision.revisionId,
3254
+ currentUser.userId,
3255
+ );
3256
+ if (!linkedThread?.commentId) {
3257
+ return;
3258
+ }
3259
+ setActiveRevisionId(revision.revisionId);
3260
+ focusAnchor(revision.anchor, revision.storyTarget);
3261
+ activeRuntime.openComment(linkedThread.commentId);
3262
+ setActiveRailTab("comments");
3263
+ },
3212
3264
  onAcceptRevision: (revisionId: string) => {
3213
3265
  activeRuntime.acceptChange(revisionId);
3214
3266
  setActiveRailTab("changes");
@@ -3237,6 +3289,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3237
3289
  onActiveRailTabChange: setActiveRailTab,
3238
3290
  onShowTrackedChangesChange: setTrackedChangesAuthoring,
3239
3291
  onReviewMarkupModeChange: setReviewMarkupMode,
3292
+ onChromePinChange: (surface, pin) => activeRuntime.setChromePin(surface, pin),
3240
3293
  onToggleBold: () =>
3241
3294
  applyRuntimeFormattingOperation(activeRuntime, { type: "toggle", mark: "bold" }),
3242
3295
  onToggleItalic: () =>
@@ -3449,6 +3502,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3449
3502
  onInsertPageBreak: commands.onInsertPageBreak,
3450
3503
  onInsertSectionBreak: (type) => commands.onInsertSectionBreak?.(type),
3451
3504
  onInsertTable: commands.onInsertTable,
3505
+ onInsertImage: commands.onInsertImage
3506
+ ? () => requestImageInsertFromPicker(commands.onInsertImage)
3507
+ : undefined,
3452
3508
  onAddComment: commands.onAddComment,
3453
3509
  onFindRequested: onFindRequested
3454
3510
  ? () => onFindRequested({ selectionText: "", selectionRange: snapshot.selection })
@@ -3546,6 +3602,14 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3546
3602
  setActiveRevisionId(revisionId);
3547
3603
  setActiveRailTab("changes");
3548
3604
  }}
3605
+ onRevisionHovered={(revisionId) => {
3606
+ if (!revisionId) {
3607
+ return;
3608
+ }
3609
+ setActiveRevisionId(revisionId);
3610
+ setActiveRailTab("changes");
3611
+ onReviewSidebarTrackedChanges?.();
3612
+ }}
3549
3613
  />
3550
3614
  );
3551
3615
 
@@ -3592,6 +3656,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3592
3656
  activeRailTab={activeRailTab}
3593
3657
  activeCommentId={snapshot.comments.activeCommentId}
3594
3658
  activeRevisionId={activeRevisionId}
3659
+ trackedChangesAuthoringEnabled={trackedChangesAuthoringEnabled}
3595
3660
  showTrackedChanges={showTrackedChanges}
3596
3661
  workflowScopeSnapshot={workflowScopeSnapshot}
3597
3662
  layoutFacet={activeRuntime.layout}
@@ -3626,35 +3691,13 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3626
3691
  onAddCommentFromSelection={addSelectionToolbarComment}
3627
3692
  onAddCommentFromSuggestion={addSelectionToolbarComment}
3628
3693
  onAcceptSuggestion={activeSelectionTool?.kind === "suggestion-review"
3629
- ? () => {
3630
- for (const changeId of activeSelectionTool.changeIds) {
3631
- activeRuntime.acceptChange(changeId);
3632
- }
3633
- dismissSelectionToolbar("chrome-action");
3634
- }
3694
+ ? acceptActiveSuggestion
3635
3695
  : undefined}
3636
3696
  onRejectSuggestion={activeSelectionTool?.kind === "suggestion-review"
3637
- ? () => {
3638
- for (const changeId of activeSelectionTool.changeIds) {
3639
- activeRuntime.rejectChange(changeId);
3640
- }
3641
- dismissSelectionToolbar("chrome-action");
3642
- }
3697
+ ? rejectActiveSuggestion
3643
3698
  : undefined}
3644
3699
  onEditSuggestion={activeSelectionTool?.kind === "suggestion-review"
3645
- ? () => {
3646
- setSuppressedSuggestionRevisionId(activeSelectionTool.suggestionId);
3647
- const activeSuggestion = suggestionsSnapshot.suggestions.find(
3648
- (suggestion) => suggestion.suggestionId === activeSelectionTool.suggestionId,
3649
- );
3650
- if (activeSuggestion) {
3651
- applyRuntimeSelection(
3652
- activeRuntime,
3653
- createSelectionFromAnchor(activeSuggestion.anchor, activeSuggestion.storyTarget),
3654
- );
3655
- }
3656
- setSelectionToolbarFocusWithin(true);
3657
- }
3700
+ ? editActiveSuggestion
3658
3701
  : undefined}
3659
3702
  onDismissSelectionToolbar={() => dismissSelectionToolbar("chrome-action")}
3660
3703
  onSelectionToolbarFocusCapture={handleSelectionToolbarFocusCapture}
@@ -4296,6 +4339,42 @@ function resolveCommentCommandAnchor(
4296
4339
  : selection.activeRange;
4297
4340
  }
4298
4341
 
4342
+ function requestImageInsertFromPicker(
4343
+ onInsertImage: ((options: InsertImageOptions) => void) | undefined,
4344
+ ): void {
4345
+ const document = globalThis.document;
4346
+ if (!onInsertImage || typeof document?.createElement !== "function" || !document.body) {
4347
+ return;
4348
+ }
4349
+
4350
+ const input = document.createElement("input");
4351
+ input.type = "file";
4352
+ input.accept = "image/png,image/jpeg,image/gif";
4353
+ input.style.position = "fixed";
4354
+ input.style.left = "-9999px";
4355
+ input.style.top = "-9999px";
4356
+ input.addEventListener("change", () => {
4357
+ const file = input.files?.[0];
4358
+ if (!file) {
4359
+ input.remove();
4360
+ return;
4361
+ }
4362
+ void file.arrayBuffer()
4363
+ .then((buffer) => {
4364
+ onInsertImage({
4365
+ data: new Uint8Array(buffer),
4366
+ mimeType: file.type || "image/png",
4367
+ altText: file.name,
4368
+ });
4369
+ })
4370
+ .finally(() => {
4371
+ input.remove();
4372
+ });
4373
+ }, { once: true });
4374
+ document.body.appendChild(input);
4375
+ input.click();
4376
+ }
4377
+
4299
4378
  function resolveCollapsedCommentRange(
4300
4379
  surface: RuntimeRenderSnapshot["surface"],
4301
4380
  selection: RuntimeRenderSnapshot["selection"],
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useRef } from "react";
2
2
 
3
3
  import type {
4
+ ChromePinSurface,
4
5
  CommentSidebarThreadSnapshot,
5
6
  EditorStoryTarget,
6
7
  FormattingAlignment,
@@ -9,6 +10,7 @@ import type {
9
10
  SectionBreakType,
10
11
  SectionLayoutPatch,
11
12
  SectionPageNumberingPatch,
13
+ PinState,
12
14
  TrackedChangeEntrySnapshot,
13
15
  ZoomLevel,
14
16
  WorkspaceMode,
@@ -24,6 +26,7 @@ export interface EditorCommandBag {
24
26
  onActiveRailTabChange(value: ReviewRailTab): void;
25
27
  onShowTrackedChangesChange(show: boolean): void;
26
28
  onReviewMarkupModeChange?(mode: MarkupDisplay): void;
29
+ onChromePinChange?(surface: ChromePinSurface, pin: PinState | null): void;
27
30
  onUndo(): void;
28
31
  onRedo(): void;
29
32
  onSetParagraphStyle?(styleId: string): void;
@@ -85,6 +88,7 @@ export interface EditorCommandBag {
85
88
  onAddReply?(commentId: string, body: string): void;
86
89
  onEditBody?(commentId: string, body: string): void;
87
90
  onOpenRevision(revision: TrackedChangeEntrySnapshot): void;
91
+ onReplyToRevision?(revision: TrackedChangeEntrySnapshot): void;
88
92
  onAcceptRevision(revisionId: string): void;
89
93
  onRejectRevision(revisionId: string): void;
90
94
  onAcceptAllChanges(): void;
@@ -55,6 +55,7 @@ export interface EditorShellViewProps {
55
55
  activeRailTab: ReviewRailTab;
56
56
  activeCommentId?: string;
57
57
  activeRevisionId?: string;
58
+ trackedChangesAuthoringEnabled?: boolean;
58
59
  showTrackedChanges: boolean;
59
60
  workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
60
61
  /**
@@ -102,6 +102,7 @@ export interface EditorSurfaceControllerProps {
102
102
  }) => void;
103
103
  onCommentActivated?: (commentId: string) => void;
104
104
  onRevisionActivated?: (revisionId: string) => void;
105
+ onRevisionHovered?: (revisionId: string | null) => void;
105
106
  workflowScopes?: readonly WorkflowScope[];
106
107
  workflowCandidates?: readonly WorkflowCandidateRange[];
107
108
  workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[];
@@ -204,18 +204,16 @@ export function buildClassFromRevisionDisplay(
204
204
 
205
205
  // Insertion underline (simple / all markup, insertion kind).
206
206
  if (display.insertionUnderline) {
207
- // Matches the underline visual used in the review-store path for
208
- // "simple-markup" insertions; keeps the two paths producing the
209
- // same class bundle so a mid-flight migration does not produce a
210
- // visual diff.
211
- parts.push(
212
- "underline decoration-insert/60 decoration-1 underline-offset-2 text-primary",
213
- );
207
+ parts.push(display.markupMode === "all"
208
+ ? "rounded-[2px] bg-insert-soft/35 px-[1px] text-primary underline decoration-insert decoration-2 underline-offset-2"
209
+ : "rounded-[2px] px-[1px] text-primary underline decoration-insert decoration-2 underline-offset-2");
214
210
  }
215
211
 
216
212
  // Strikethrough (deletion kind, simple or all markup).
217
213
  if (display.strikethrough) {
218
- parts.push("text-danger line-through decoration-danger/80 decoration-1");
214
+ parts.push(display.markupMode === "all"
215
+ ? "rounded-[2px] bg-delete-soft/35 px-[1px] text-danger line-through decoration-danger decoration-2"
216
+ : "text-danger line-through decoration-danger decoration-2");
219
217
  }
220
218
 
221
219
  // De-emphasize (e.g. inactive revision in all-markup; reviewer
@@ -234,7 +232,7 @@ export function buildClassFromRevisionDisplay(
234
232
  (display.kind === "formatting" || display.kind === "property-change")
235
233
  ) {
236
234
  parts.push(
237
- "underline decoration-accent/70 decoration-dotted decoration-1 underline-offset-2",
235
+ "rounded-[2px] bg-accent-soft/70 px-[1px] underline decoration-accent/80 decoration-dotted decoration-2 underline-offset-2",
238
236
  );
239
237
  }
240
238
 
@@ -277,18 +275,18 @@ export function getRevisionHighlightClass(
277
275
  return "";
278
276
  case "simple-markup":
279
277
  if (state.hasInsertions) {
280
- return `underline decoration-insert/60 decoration-1 underline-offset-2 text-primary${activeRing}`;
278
+ return `rounded-[2px] px-[1px] text-primary underline decoration-insert decoration-2 underline-offset-2${activeRing}`;
281
279
  }
282
280
  if (state.hasDeletions) {
283
- return `text-secondary line-through decoration-danger/70 decoration-1${activeRing}`;
281
+ return `text-danger line-through decoration-danger decoration-2${activeRing}`;
284
282
  }
285
283
  return activeRing;
286
284
  case "all-markup":
287
285
  if (state.hasInsertions) {
288
- return `text-primary bg-insert-soft/80 ring-1 ring-insert/20${activeRing}`;
286
+ return `rounded-[2px] bg-insert-soft/35 px-[1px] text-primary underline decoration-insert decoration-2 underline-offset-2${activeRing}`;
289
287
  }
290
288
  if (state.hasDeletions) {
291
- return `text-danger line-through decoration-danger/80 decoration-1 bg-delete-soft/70${activeRing}`;
289
+ return `rounded-[2px] bg-delete-soft/35 px-[1px] text-danger line-through decoration-danger decoration-2${activeRing}`;
292
290
  }
293
291
  return activeRing;
294
292
  }
@@ -3,16 +3,17 @@
3
3
  * contextual actions exist, keyed by `TargetKind`.
4
4
  *
5
5
  * Consumed by three access routes so DESIGN-EDITOR.md §6.4 holds by
6
- * construction ("right-click cannot be richer than the floating surface;
7
- * cannot duplicate command trees"):
6
+ * construction for general editor actions:
8
7
  *
9
8
  * 1. Right-click context menu (via `build-context-menu-entries.ts`)
10
9
  * 2. Inline "More…" affordance on reduced floating toolbars (Phase D)
11
10
  * 3. Command palette groups (Phase C.δ)
12
11
  *
13
- * All three routes produce the same actions from this registry — they
14
- * cannot diverge. This is the load-bearing decision that makes the
15
- * floating-menu demotion legal.
12
+ * All three routes produce the same general actions from this registry.
13
+ * Suggestion-specific review actions are intentionally absent for now:
14
+ * they require an exact revision/suggestion id payload, so they live on
15
+ * the floating suggestion card and Changes rail until the context-menu
16
+ * target resolver can supply that payload.
16
17
  *
17
18
  * Perf discipline: pure data + pure filter helpers. No DOM reads, no
18
19
  * runtime calls, no observers. Actions are dispatched through host-
@@ -106,10 +107,6 @@ export interface EditorActionHostCallbacks {
106
107
  readonly onPrintRequested?: () => void;
107
108
  readonly onGoToRequested?: () => void;
108
109
 
109
- // Tracked-change operations (suggestion target)
110
- readonly onAcceptSuggestion?: () => void;
111
- readonly onRejectSuggestion?: () => void;
112
-
113
110
  // Comment operations (comment-anchor target)
114
111
  readonly onResolveComment?: () => void;
115
112
  readonly onReplyToComment?: () => void;
@@ -576,7 +573,7 @@ export const EDITOR_ACTION_REGISTRY: readonly EditorAction[] = [
576
573
  mkImportant({
577
574
  id: "insert-image",
578
575
  label: "Insert image…",
579
- description: "Needs a host file picker before it can run.",
576
+ description: "Choose an image file and insert it at the caret.",
580
577
  group: "misc",
581
578
  targetKinds: [],
582
579
  callback: "onInsertImage",
@@ -636,22 +633,6 @@ export const EDITOR_ACTION_REGISTRY: readonly EditorAction[] = [
636
633
  callback: "onPrintRequested",
637
634
  }),
638
635
 
639
- // -------- Suggestion / tracked change --------
640
- mk({
641
- id: "accept-suggestion",
642
- label: "Accept suggestion",
643
- group: "suggestion",
644
- targetKinds: ["suggestion"],
645
- callback: "onAcceptSuggestion",
646
- }),
647
- mk({
648
- id: "reject-suggestion",
649
- label: "Reject suggestion",
650
- group: "suggestion",
651
- targetKinds: ["suggestion"],
652
- callback: "onRejectSuggestion",
653
- }),
654
-
655
636
  // -------- Comment --------
656
637
  mk({
657
638
  id: "resolve-comment",
@@ -17,11 +17,11 @@ export function isNarrowChromeViewport(viewportWidth?: number): boolean {
17
17
  return typeof viewportWidth === "number" && viewportWidth <= NARROW_CHROME_MAX_WIDTH;
18
18
  }
19
19
 
20
- export function getInitialReviewRailOpen(input: {
20
+ export function getInitialReviewRailOpen(_input: {
21
21
  viewportWidth?: number;
22
22
  reviewRailAvailable: boolean;
23
23
  }): boolean {
24
- return input.reviewRailAvailable && !isNarrowChromeViewport(input.viewportWidth);
24
+ return false;
25
25
  }
26
26
 
27
27
  export function resolveResponsiveChromeState(
@@ -123,6 +123,7 @@ export function TwAlertBanner(
123
123
  // 3. Workflow blocked — host policy refuses a command, per reasons.
124
124
  if (workflowBlockedReasons.length > 0) {
125
125
  const firstReason = workflowBlockedReasons[0]!;
126
+ const hint = getWorkflowBlockedHint(firstReason);
126
127
  return renderBanner({
127
128
  severity: "warning",
128
129
  icon: (
@@ -131,6 +132,7 @@ export function TwAlertBanner(
131
132
  message: (
132
133
  <>
133
134
  {firstReason.message}
135
+ {hint ? <span className="opacity-80"> {hint}</span> : null}
134
136
  {workflowBlockedReasons.length > 1
135
137
  ? ` (+${workflowBlockedReasons.length - 1} more)`
136
138
  : ""}
@@ -161,3 +163,28 @@ export function TwAlertBanner(
161
163
 
162
164
  return null;
163
165
  }
166
+
167
+ function getWorkflowBlockedHint(
168
+ reason: WorkflowBlockedCommandReason,
169
+ ): string | null {
170
+ switch (reason.code) {
171
+ case "suggesting_unsupported":
172
+ return "Switch to Edit for this command, or insert plain text.";
173
+ case "workflow_comment_only":
174
+ return "Add a comment, or use an editing scope.";
175
+ case "outside_workflow_scope":
176
+ return "Move into an editable workflow scope.";
177
+ case "workflow_view_only":
178
+ case "document_viewing_mode":
179
+ case "document_read_only":
180
+ return "Open an editable copy or request edit access.";
181
+ case "workflow_preserve_only":
182
+ case "workflow_blocked_import":
183
+ case "protected_range":
184
+ case "unsupported_surface":
185
+ return "Use detail for the safe path.";
186
+ case "workflow_round_locked":
187
+ return "Wait for the round to unlock or request approval.";
188
+ }
189
+ return null;
190
+ }
@@ -43,6 +43,10 @@ export function TwDetachHandle(props: TwDetachHandleProps): React.JSX.Element {
43
43
  const { surface, pin, onChange, label } = props;
44
44
  const isDetached = pin?.detached ?? false;
45
45
  const offset = pin?.offset ?? { x: 0, y: 0 };
46
+ const dragHandleTestId =
47
+ surface === "selectionTier" ? "selection-tool-drag-handle" : `${surface}-detach-drag-handle`;
48
+ const toggleTestId =
49
+ surface === "selectionTier" ? "selection-tool-attach-toggle" : `${surface}-detach-toggle`;
46
50
  const dragState = useRef<
47
51
  | {
48
52
  startX: number;
@@ -117,7 +121,7 @@ export function TwDetachHandle(props: TwDetachHandleProps): React.JSX.Element {
117
121
  <button
118
122
  type="button"
119
123
  aria-label={isDetached ? "Drag floating menu" : "Drag to float menu"}
120
- data-testid={`${surface}-detach-drag-handle`}
124
+ data-testid={dragHandleTestId}
121
125
  className="inline-flex h-6 items-center justify-center rounded-md border border-transparent px-1.5 text-tertiary transition-colors hover:border-border/60 hover:bg-surface hover:text-primary"
122
126
  onMouseDown={beginDrag}
123
127
  >
@@ -133,7 +137,7 @@ export function TwDetachHandle(props: TwDetachHandleProps): React.JSX.Element {
133
137
  type="button"
134
138
  aria-label={isDetached ? "Dock menu" : "Float menu"}
135
139
  aria-pressed={isDetached}
136
- data-testid={`${surface}-detach-toggle`}
140
+ data-testid={toggleTestId}
137
141
  className="inline-flex h-6 items-center rounded-md border border-border/60 px-2 text-[10px] font-medium text-secondary transition-colors hover:bg-surface hover:text-primary"
138
142
  onMouseDown={preserveEditorSelectionMouseDown}
139
143
  onClick={toggle}
@@ -1,7 +1,10 @@
1
1
  import type { Transaction } from "prosemirror-state";
2
2
  import type { EditorView } from "prosemirror-view";
3
3
 
4
- import type { TextCommandAck } from "../../api/public-types.ts";
4
+ import type {
5
+ TextCommandAck,
6
+ TextCommandRefreshClass,
7
+ } from "../../api/public-types.ts";
5
8
  import type {
6
9
  LocalEditSessionState,
7
10
  PendingOp,
@@ -78,7 +81,11 @@ export interface FastTextEditLaneOptions {
78
81
  /** Optional probe hooks for perf instrumentation. */
79
82
  probe?: {
80
83
  markPredicted(opId: string): void;
81
- markReconciled(opId: string, kind: TextCommandAck["kind"]): void;
84
+ markReconciled(
85
+ opId: string,
86
+ kind: TextCommandAck["kind"],
87
+ refreshClass: TextCommandRefreshClass,
88
+ ): void;
82
89
  };
83
90
  }
84
91
 
@@ -96,6 +103,22 @@ function allocOpId(): string {
96
103
  return `op-${Date.now().toString(36)}-${nextOpIdCounter}`;
97
104
  }
98
105
 
106
+ export function getTextCommandRefreshClass(
107
+ ack: TextCommandAck,
108
+ ): TextCommandRefreshClass {
109
+ if (ack.refreshClass) return ack.refreshClass;
110
+ switch (ack.kind) {
111
+ case "equivalent":
112
+ return "local-text-equivalent";
113
+ case "adjusted":
114
+ return "surface-only";
115
+ case "rejected":
116
+ return "blocked";
117
+ case "structural-divergence":
118
+ return "full-projection";
119
+ }
120
+ }
121
+
99
122
  export function createFastTextEditLane(
100
123
  options: FastTextEditLaneOptions,
101
124
  ): FastTextEditLane {
@@ -136,9 +159,11 @@ export function createFastTextEditLane(
136
159
 
137
160
  if (options.shouldBailBeforePredict?.(intent, fromRuntime, toRuntime)) {
138
161
  const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
162
+ const refreshClass = getTextCommandRefreshClass(ack);
139
163
  incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.bailBeforePredict);
140
- markLaneDebugReconciled(debugEntry, ack.kind, true);
141
- options.probe?.markReconciled(opId, ack.kind);
164
+ incrementRefreshClassCounter(refreshClass);
165
+ markLaneDebugReconciled(debugEntry, ack.kind, refreshClass, true);
166
+ options.probe?.markReconciled(opId, ack.kind, refreshClass);
142
167
  switch (ack.kind) {
143
168
  case "equivalent":
144
169
  options.session.advanceToRevision({
@@ -184,8 +209,10 @@ export function createFastTextEditLane(
184
209
  op.predictedSelectionHead = view.state.selection.head;
185
210
 
186
211
  const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
187
- markLaneDebugReconciled(debugEntry, ack.kind, false);
188
- options.probe?.markReconciled(opId, ack.kind);
212
+ const refreshClass = getTextCommandRefreshClass(ack);
213
+ markLaneDebugReconciled(debugEntry, ack.kind, refreshClass, false);
214
+ incrementRefreshClassCounter(refreshClass);
215
+ options.probe?.markReconciled(opId, ack.kind, refreshClass);
189
216
 
190
217
  switch (ack.kind) {
191
218
  case "equivalent":
@@ -284,6 +311,8 @@ interface LaneDebugEntry {
284
311
  toRuntime: number;
285
312
  /** Dispatch → reconcile observation. Filled by `markLaneDebugReconciled`. */
286
313
  ackKind?: TextCommandAck["kind"];
314
+ /** Narrow refresh tier derived from the runtime ack. */
315
+ refreshClass?: TextCommandRefreshClass;
287
316
  /** Wall-clock ms between `pushLaneDebug` and `markLaneDebugReconciled`. */
288
317
  reconcileMs?: number;
289
318
  /** Whether the lane short-circuited to dispatch-only (no predicted TX). */
@@ -336,10 +365,12 @@ function pushLaneDebug(
336
365
  function markLaneDebugReconciled(
337
366
  entry: LaneDebugEntry | null,
338
367
  ackKind: TextCommandAck["kind"],
368
+ refreshClass: TextCommandRefreshClass,
339
369
  bailed: boolean,
340
370
  ): void {
341
371
  if (!entry) return;
342
372
  entry.ackKind = ackKind;
373
+ entry.refreshClass = refreshClass;
343
374
  entry.bailed = bailed;
344
375
  const now =
345
376
  typeof performance !== "undefined" && typeof performance.now === "function"
@@ -348,6 +379,26 @@ function markLaneDebugReconciled(
348
379
  entry.reconcileMs = now - entry.startedAtMs;
349
380
  }
350
381
 
382
+ function incrementRefreshClassCounter(refreshClass: TextCommandRefreshClass): void {
383
+ switch (refreshClass) {
384
+ case "selection-only":
385
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.refreshSelectionOnly);
386
+ return;
387
+ case "local-text-equivalent":
388
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.refreshLocalTextEquivalent);
389
+ return;
390
+ case "surface-only":
391
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.refreshSurfaceOnly);
392
+ return;
393
+ case "full-projection":
394
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.refreshFullProjection);
395
+ return;
396
+ case "blocked":
397
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.refreshBlocked);
398
+ return;
399
+ }
400
+ }
401
+
351
402
  function buildTxCompat(
352
403
  view: EditorView,
353
404
  _intent: PredictedIntent,
@@ -13,3 +13,20 @@ export function sliceBlocksForPage(
13
13
  (b) => b.from < page.endOffset && b.to > page.startOffset,
14
14
  );
15
15
  }
16
+
17
+ export function findBlockIndexRangeForPage(
18
+ blocks: readonly SurfaceBlockSnapshot[],
19
+ page: Pick<DocumentPageSnapshot, "startOffset" | "endOffset">,
20
+ ): { first: number; last: number } | null {
21
+ if (page.endOffset <= page.startOffset) return null;
22
+ let first = -1;
23
+ let last = -1;
24
+ for (let index = 0; index < blocks.length; index += 1) {
25
+ const block = blocks[index]!;
26
+ if (block.from < page.endOffset && block.to > page.startOffset) {
27
+ if (first === -1) first = index;
28
+ last = index;
29
+ }
30
+ }
31
+ return first === -1 ? null : { first, last };
32
+ }
@@ -41,6 +41,11 @@ export const PREDICTED_LANE_COUNTERS = {
41
41
  rollback: "predictions.rollback",
42
42
  structuralDivergence: "predictions.structuralDivergence",
43
43
  bailBeforePredict: "predictions.bailBeforePredict",
44
+ refreshSelectionOnly: "predictions.refresh.selectionOnly",
45
+ refreshLocalTextEquivalent: "predictions.refresh.localTextEquivalent",
46
+ refreshSurfaceOnly: "predictions.refresh.surfaceOnly",
47
+ refreshFullProjection: "predictions.refresh.fullProjection",
48
+ refreshBlocked: "predictions.refresh.blocked",
44
49
  } as const;
45
50
 
46
51
  export interface PerfProbeSample {