@beyondwork/docx-react-component 1.0.84 → 1.0.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/internal/build-ref-projections.ts +3 -0
  3. package/src/api/public-types.ts +38 -0
  4. package/src/api/v3/_runtime-handle.ts +11 -0
  5. package/src/api/v3/runtime/content.ts +148 -1
  6. package/src/api/v3/runtime/formatting.ts +41 -0
  7. package/src/api/v3/runtime/review.ts +98 -0
  8. package/src/core/commands/index.ts +81 -25
  9. package/src/core/state/editor-state.ts +15 -0
  10. package/src/io/ooxml/header-footer-reference.ts +38 -0
  11. package/src/io/ooxml/parse-headers-footers.ts +11 -23
  12. package/src/io/ooxml/parse-main-document.ts +7 -10
  13. package/src/model/canonical-document.ts +9 -0
  14. package/src/model/review/comment-types.ts +2 -0
  15. package/src/runtime/document-runtime.ts +677 -54
  16. package/src/runtime/formatting/field/resolver.ts +73 -8
  17. package/src/runtime/layout/layout-engine-version.ts +31 -12
  18. package/src/runtime/layout/paginated-layout-engine.ts +18 -11
  19. package/src/runtime/layout/public-facet.ts +119 -16
  20. package/src/runtime/layout/resolve-page-fields.ts +68 -6
  21. package/src/runtime/layout/resolve-page-previews.ts +1 -1
  22. package/src/runtime/suggestions-snapshot.ts +24 -0
  23. package/src/runtime/surface-projection.ts +59 -2
  24. package/src/shell/ref-commands.ts +3 -354
  25. package/src/shell/session-bootstrap.ts +8 -0
  26. package/src/ui/WordReviewEditor.tsx +192 -35
  27. package/src/ui/editor-command-bag.ts +7 -1
  28. package/src/ui/editor-shell-view.tsx +1 -0
  29. package/src/ui/headless/revision-decoration-model.ts +13 -0
  30. package/src/ui/headless/selection-tool-types.ts +2 -0
  31. package/src/ui-tailwind/chrome/editor-action-registry.ts +7 -26
  32. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +6 -2
  33. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +7 -3
  34. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +175 -25
  35. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -1
  36. package/src/ui-tailwind/editor-surface/pm-decorations.ts +12 -0
  37. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +18 -30
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +1 -1
  39. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +1 -1
  40. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +20 -11
  41. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +9 -4
  42. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +12 -7
  43. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +29 -10
  44. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +1 -1
  45. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +8 -3
  46. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -0
  47. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +46 -31
  48. package/src/ui-tailwind/review/tw-workflow-tab.tsx +159 -8
  49. package/src/ui-tailwind/review-workspace/types.ts +7 -2
  50. package/src/ui-tailwind/review-workspace/use-page-markers.ts +11 -1
  51. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +8 -9
  52. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +21 -16
  53. package/src/ui-tailwind/tw-review-workspace.tsx +27 -4
@@ -153,6 +153,7 @@ function CommentThreadCard(props: {
153
153
  }, [presentation]);
154
154
  const leadEntry = thread.entries[0];
155
155
  const isDraftThread = thread.status === "open" && thread.entryCount === 1 && isEmptyCommentBody(leadEntry?.body);
156
+ const isLinkedRevisionThread = thread.linkedRevisionId != null;
156
157
  const isOwnComment = props.currentUserId != null && leadEntry?.authorId === props.currentUserId;
157
158
  const canEdit = isOwnComment && thread.status === "open" && props.onEditBody != null;
158
159
  const hasNoBody = isEmptyCommentBody(leadEntry?.body);
@@ -205,6 +206,7 @@ function CommentThreadCard(props: {
205
206
  {formatCommentDate(thread.createdAt)}
206
207
  </span>
207
208
  <span className="flex-1" />
209
+ {isLinkedRevisionThread ? <StatusBadge label="tracked change" tone="revision" /> : null}
208
210
  {isDraftThread ? <StatusBadge label="draft" tone="draft" /> : null}
209
211
  {thread.status === "resolved" ? <StatusBadge label="resolved" tone="resolved" /> : null}
210
212
  </div>
@@ -222,7 +224,9 @@ function CommentThreadCard(props: {
222
224
  body={leadEntry?.body ?? ""}
223
225
  autoFocus={isActive && hasNoBody}
224
226
  onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
225
- label={isDraftThread ? "New comment" : undefined}
227
+ label={isDraftThread
228
+ ? (isLinkedRevisionThread ? "Tracked change discussion" : "New comment")
229
+ : undefined}
226
230
  />
227
231
  ) : presentation ? (
228
232
  <CommentMarkdownRenderer
@@ -247,7 +251,7 @@ function CommentThreadCard(props: {
247
251
  props.onOpenComment?.(thread);
248
252
  }}
249
253
  >
250
- New comment
254
+ {isLinkedRevisionThread ? "Tracked change discussion" : "New comment"}
251
255
  </p>
252
256
  ) : null}
253
257
 
@@ -494,11 +498,12 @@ function formatCommentDate(raw: string): string {
494
498
  }
495
499
  }
496
500
 
497
- function StatusBadge(props: { label: string; tone: "resolved" | "detached" | "draft" }) {
501
+ function StatusBadge(props: { label: string; tone: "resolved" | "detached" | "draft" | "revision" }) {
498
502
  const styles: Record<string, string> = {
499
503
  resolved: "text-insert bg-insert-soft",
500
504
  detached: "text-comment bg-warning-soft",
501
505
  draft: "text-secondary bg-subtle",
506
+ revision: "text-accent bg-accent-soft",
502
507
  };
503
508
  return (
504
509
  <span
@@ -111,6 +111,7 @@ export interface TwReviewRailProps {
111
111
  onAddReply?: (commentId: string, body: string) => void;
112
112
  onEditBody?: (commentId: string, body: string) => void;
113
113
  onOpenRevision?: (revision: TrackedChangeEntrySnapshot) => void;
114
+ onReplyToRevision?: (revision: TrackedChangeEntrySnapshot) => void;
114
115
  onAcceptRevision?: (revisionId: string) => void;
115
116
  onRejectRevision?: (revisionId: string) => void;
116
117
  onAcceptAllChanges?: () => void;
@@ -285,6 +286,7 @@ export function TwReviewRail(props: TwReviewRailProps) {
285
286
  markupDisplay={props.markupDisplay}
286
287
  activeRevisionId={props.activeRevisionId}
287
288
  onOpenRevision={props.onOpenRevision}
289
+ onReplyToRevision={props.onReplyToRevision}
288
290
  onAcceptRevision={props.onAcceptRevision}
289
291
  onRejectRevision={props.onRejectRevision}
290
292
  onAcceptAllChanges={props.onAcceptAllChanges}
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Check, X } from "lucide-react";
2
+ import { Check, MessageSquare, X } from "lucide-react";
3
3
 
4
4
  import type { TrackedChangesSnapshot, TrackedChangeEntrySnapshot } from "../../api/public-types";
5
5
  import { selectVisibleRevisions } from "../../ui/shared/revision-filters";
@@ -10,6 +10,7 @@ export interface TwRevisionSidebarProps {
10
10
  markupDisplay: MarkupDisplay;
11
11
  activeRevisionId?: string;
12
12
  onOpenRevision?: (revision: TrackedChangeEntrySnapshot) => void;
13
+ onReplyToRevision?: (revision: TrackedChangeEntrySnapshot) => void;
13
14
  onAcceptRevision?: (revisionId: string) => void;
14
15
  onRejectRevision?: (revisionId: string) => void;
15
16
  onAcceptAllChanges?: () => void;
@@ -120,46 +121,61 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
120
121
  const isActive = activeRevisionId === rev.revisionId;
121
122
 
122
123
  return (
123
- <button
124
+ <div
124
125
  key={rev.revisionId}
125
- type="button"
126
- className={`w-full text-left flex cursor-pointer rounded-md bg-surface/90 transition-colors ring-1 ring-border ${focusRingClass} ${isActive ? "bg-accent-soft/40 ring-accent/25" : "hover:bg-surface"}`}
127
- onClick={() => props.onOpenRevision?.(rev)}
126
+ className={`w-full text-left flex rounded-md bg-surface/90 transition-colors ring-1 ring-border ${isActive ? "bg-accent-soft/40 ring-accent/25" : "hover:bg-surface"}`}
128
127
  >
129
128
  <div className={`w-0.5 shrink-0 rounded-l-md ${
130
129
  rev.kind === "insertion" ? "bg-insert"
131
130
  : rev.kind === "deletion" ? "bg-danger"
132
131
  : "bg-tertiary"
133
132
  }`} />
134
- <div className="p-2 flex-1 min-w-0">
135
- <div className="mb-0.5 flex items-start justify-between gap-2">
136
- <span className="text-[11px] font-medium text-primary">{rev.anchorLabel}</span>
137
- <RevisionBadge status={rev.status} actionability={rev.actionability} />
138
- </div>
139
- <p className="mb-1 text-[10px] text-tertiary">{rev.authorId} · {rev.createdAt}</p>
140
- {rev.excerpt ? (
141
- <p className={`text-[11px] ${
142
- rev.kind === "insertion" ? "text-insert"
143
- : rev.kind === "deletion" ? "text-danger line-through"
144
- : "text-secondary"
145
- }`}>
146
- {rev.excerpt}
147
- </p>
148
- ) : (
149
- <p className="text-[11px] text-secondary">{rev.label}</p>
150
- )}
151
- {rev.detail ? (
152
- <p className="mt-1 text-[10px] text-secondary">{rev.detail}</p>
153
- ) : null}
154
- <div className="mt-2 flex gap-1.5">
133
+ <div className="flex-1 min-w-0">
134
+ <button
135
+ type="button"
136
+ className={`w-full p-2 text-left ${focusRingClass}`}
137
+ onClick={() => props.onOpenRevision?.(rev)}
138
+ >
139
+ <div className="mb-0.5 flex items-start justify-between gap-2">
140
+ <span className="text-[11px] font-medium text-primary">{rev.anchorLabel}</span>
141
+ <RevisionBadge status={rev.status} actionability={rev.actionability} />
142
+ </div>
143
+ <p className="mb-1 text-[10px] text-tertiary">{rev.authorId} · {rev.createdAt}</p>
144
+ {rev.excerpt ? (
145
+ <p className={`text-[11px] ${
146
+ rev.kind === "insertion" ? "text-insert"
147
+ : rev.kind === "deletion" ? "text-danger line-through"
148
+ : "text-secondary"
149
+ }`}>
150
+ {rev.excerpt}
151
+ </p>
152
+ ) : (
153
+ <p className="text-[11px] text-secondary">{rev.label}</p>
154
+ )}
155
+ {rev.detail ? (
156
+ <p className="mt-1 text-[10px] text-secondary">{rev.detail}</p>
157
+ ) : null}
158
+ </button>
159
+ <div className="flex flex-wrap gap-1.5 px-2 pb-2">
160
+ {props.onReplyToRevision ? (
161
+ <button
162
+ type="button"
163
+ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-secondary hover:bg-canvas transition-colors"
164
+ onClick={() => props.onReplyToRevision?.(rev)}
165
+ >
166
+ <MessageSquare className="h-3 w-3" />
167
+ {rev.replyCount && rev.replyCount > 0
168
+ ? `Reply ${rev.replyCount}`
169
+ : "Reply"}
170
+ </button>
171
+ ) : null}
155
172
  {rev.actionability === "actionable" ? (
156
173
  <>
157
174
  <button
158
175
  type="button"
159
176
  disabled={!rev.canAccept || rev.status === "accepted"}
160
177
  className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-accent hover:bg-accent-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
161
- onClick={(e) => {
162
- e.stopPropagation();
178
+ onClick={() => {
163
179
  props.onAcceptRevision?.(rev.revisionId);
164
180
  }}
165
181
  >
@@ -169,8 +185,7 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
169
185
  type="button"
170
186
  disabled={!rev.canReject || rev.status === "rejected"}
171
187
  className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-danger hover:bg-danger-soft transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
172
- onClick={(e) => {
173
- e.stopPropagation();
188
+ onClick={() => {
174
189
  props.onRejectRevision?.(rev.revisionId);
175
190
  }}
176
191
  >
@@ -182,7 +197,7 @@ export function TwRevisionSidebar(props: TwRevisionSidebarProps) {
182
197
  )}
183
198
  </div>
184
199
  </div>
185
- </button>
200
+ </div>
186
201
  );
187
202
  })}
188
203
  </div>
@@ -36,6 +36,24 @@ const POSTURE_META: Record<
36
36
  "blocked-import": { chip: "BLOCKED REGION", kind: "danger" },
37
37
  };
38
38
 
39
+ const focusRingClass =
40
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
41
+
42
+ type ScopeFilterKey = "edit" | "suggest" | "comment" | "view" | "candidate" | "blocked";
43
+
44
+ const SCOPE_FILTERS: ReadonlyArray<{
45
+ key: ScopeFilterKey;
46
+ label: string;
47
+ postures: readonly ScopeRailPosture[];
48
+ }> = [
49
+ { key: "edit", label: "Edit", postures: ["edit"] },
50
+ { key: "suggest", label: "Suggest", postures: ["suggest"] },
51
+ { key: "comment", label: "Comment", postures: ["comment"] },
52
+ { key: "view", label: "Review", postures: ["view"] },
53
+ { key: "candidate", label: "Scheduled", postures: ["candidate"] },
54
+ { key: "blocked", label: "Blocked", postures: ["preserve-only", "blocked-import"] },
55
+ ];
56
+
39
57
  export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
40
58
  segments,
41
59
  activeScopeId,
@@ -43,13 +61,38 @@ export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
43
61
  onActiveScopeChange,
44
62
  }) => {
45
63
  // Dedupe by scopeId so a scope spanning multiple pages shows once.
46
- const byScopeId = new Map<string, ScopeRailSegment>();
47
- for (const segment of segments) {
48
- if (!byScopeId.has(segment.scopeId)) {
49
- byScopeId.set(segment.scopeId, segment);
64
+ const uniqueSegments = React.useMemo(() => {
65
+ const byScopeId = new Map<string, ScopeRailSegment>();
66
+ for (const segment of segments) {
67
+ if (!byScopeId.has(segment.scopeId)) {
68
+ byScopeId.set(segment.scopeId, segment);
69
+ }
50
70
  }
51
- }
52
- const uniqueSegments = Array.from(byScopeId.values());
71
+ return Array.from(byScopeId.values()).sort(compareWorkflowSegments(activeScopeId ?? null));
72
+ }, [activeScopeId, segments]);
73
+ const [query, setQuery] = React.useState("");
74
+ const [enabledFilters, setEnabledFilters] = React.useState<ReadonlySet<ScopeFilterKey>>(
75
+ () => new Set(SCOPE_FILTERS.map((filter) => filter.key)),
76
+ );
77
+ const availableFilters = React.useMemo(() => {
78
+ const presentPostures = new Set(uniqueSegments.map((segment) => segment.posture));
79
+ return SCOPE_FILTERS.filter((filter) =>
80
+ filter.postures.some((posture) => presentPostures.has(posture)),
81
+ );
82
+ }, [uniqueSegments]);
83
+ const visibleSegments = React.useMemo(() => {
84
+ const normalizedQuery = normalizeScopeQuery(query);
85
+ return uniqueSegments.filter((segment) => {
86
+ const filterKey = filterKeyForPosture(segment.posture);
87
+ if (!enabledFilters.has(filterKey)) {
88
+ return false;
89
+ }
90
+ if (!normalizedQuery) {
91
+ return true;
92
+ }
93
+ return scopeSearchText(segment).includes(normalizedQuery);
94
+ });
95
+ }, [enabledFilters, query, uniqueSegments]);
53
96
 
54
97
  if (uniqueSegments.length === 0) {
55
98
  return (
@@ -73,9 +116,79 @@ export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
73
116
  <div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-accent">
74
117
  Document Intelligence
75
118
  </div>
76
- <div className="text-[15px] font-semibold text-primary">Workflow Scopes</div>
119
+ <div className="flex items-baseline justify-between gap-3">
120
+ <div className="text-[15px] font-semibold text-primary">Workflow Scopes</div>
121
+ <div
122
+ className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary"
123
+ data-testid="workflow-scope-count"
124
+ >
125
+ {visibleSegments.length}/{uniqueSegments.length} shown
126
+ </div>
127
+ </div>
77
128
  </div>
78
- {uniqueSegments.map((segment) => {
129
+
130
+ <div
131
+ className="wre-workflow-tab-controls rounded-lg border border-border bg-surface/55 p-2"
132
+ data-testid="workflow-scope-controls"
133
+ >
134
+ <input
135
+ aria-label="Search workflow scopes"
136
+ className={`h-8 w-full rounded-md border border-border bg-canvas px-2 text-[12px] text-primary placeholder:text-tertiary ${focusRingClass}`}
137
+ placeholder="Search scope, page, section..."
138
+ type="search"
139
+ value={query}
140
+ onChange={(event) => setQuery(event.currentTarget.value)}
141
+ />
142
+ {availableFilters.length > 1 ? (
143
+ <div
144
+ aria-label="Workflow scope layers"
145
+ className="mt-2 flex flex-wrap gap-1"
146
+ role="group"
147
+ >
148
+ {availableFilters.map((filter) => {
149
+ const isEnabled = enabledFilters.has(filter.key);
150
+ return (
151
+ <button
152
+ key={filter.key}
153
+ type="button"
154
+ aria-pressed={isEnabled}
155
+ className={[
156
+ "rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] transition-colors",
157
+ isEnabled
158
+ ? "border-accent/50 bg-accent/10 text-accent"
159
+ : "border-border bg-canvas text-tertiary hover:text-secondary",
160
+ ].join(" ")}
161
+ data-testid={`workflow-scope-filter-${filter.key}`}
162
+ onClick={() => {
163
+ setEnabledFilters((current) => {
164
+ const next = new Set(current);
165
+ if (next.has(filter.key)) {
166
+ next.delete(filter.key);
167
+ } else {
168
+ next.add(filter.key);
169
+ }
170
+ return next;
171
+ });
172
+ }}
173
+ >
174
+ {filter.label}
175
+ </button>
176
+ );
177
+ })}
178
+ </div>
179
+ ) : null}
180
+ </div>
181
+
182
+ {visibleSegments.length === 0 ? (
183
+ <div
184
+ className="rounded-md border border-dashed border-border bg-canvas/50 p-3 text-[11px] text-tertiary"
185
+ data-testid="workflow-scope-filter-empty"
186
+ >
187
+ No workflow scopes match the current search or layer filters.
188
+ </div>
189
+ ) : null}
190
+
191
+ {visibleSegments.map((segment) => {
79
192
  const meta = POSTURE_META[segment.posture];
80
193
  const isActive = activeScopeId === segment.scopeId || segment.isActiveWorkItem;
81
194
  return (
@@ -115,4 +228,42 @@ export const TwWorkflowTab: React.FC<TwWorkflowTabProps> = ({
115
228
  );
116
229
  };
117
230
 
231
+ function compareWorkflowSegments(activeScopeId: string | null) {
232
+ return (left: ScopeRailSegment, right: ScopeRailSegment): number => {
233
+ const leftActive = left.scopeId === activeScopeId || left.isActiveWorkItem;
234
+ const rightActive = right.scopeId === activeScopeId || right.isActiveWorkItem;
235
+ if (leftActive !== rightActive) {
236
+ return leftActive ? -1 : 1;
237
+ }
238
+ if (left.pageIndex !== right.pageIndex) {
239
+ return left.pageIndex - right.pageIndex;
240
+ }
241
+ if (left.sectionIndex !== right.sectionIndex) {
242
+ return left.sectionIndex - right.sectionIndex;
243
+ }
244
+ return (left.label || left.scopeId).localeCompare(right.label || right.scopeId);
245
+ };
246
+ }
247
+
248
+ function filterKeyForPosture(posture: ScopeRailPosture): ScopeFilterKey {
249
+ if (posture === "preserve-only" || posture === "blocked-import") {
250
+ return "blocked";
251
+ }
252
+ return posture;
253
+ }
254
+
255
+ function normalizeScopeQuery(value: string): string {
256
+ return value.trim().toLocaleLowerCase();
257
+ }
258
+
259
+ function scopeSearchText(segment: ScopeRailSegment): string {
260
+ return normalizeScopeQuery([
261
+ segment.label,
262
+ segment.scopeId,
263
+ segment.posture,
264
+ `page ${segment.pageIndex + 1}`,
265
+ `section ${segment.sectionIndex + 1}`,
266
+ ].filter(Boolean).join(" "));
267
+ }
268
+
118
269
  export default TwWorkflowTab;
@@ -145,6 +145,9 @@ export interface TwReviewWorkspaceProps {
145
145
  activeRailTab: ReviewRailTab;
146
146
  activeCommentId?: string;
147
147
  activeRevisionId?: string;
148
+ /** Authoring mode toggle state: whether new edits are recorded as tracked changes. */
149
+ trackedChangesAuthoringEnabled?: boolean;
150
+ /** Visual markup state: whether tracked-change decorations are currently shown. */
148
151
  showTrackedChanges: boolean;
149
152
  workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
150
153
  interactionGuardSnapshot?: InteractionGuardSnapshot;
@@ -192,6 +195,7 @@ export interface TwReviewWorkspaceProps {
192
195
  onZoomChange?: (level: ZoomLevel) => void;
193
196
  onActiveRailTabChange?: (value: ReviewRailTab) => void;
194
197
  onShowTrackedChangesChange?: (show: boolean) => void;
198
+ onReviewMarkupModeChange?: (mode: MarkupDisplay) => void;
195
199
  onUndo?: () => void;
196
200
  onRedo?: () => void;
197
201
  onSetParagraphStyle?: (styleId: string) => void;
@@ -285,6 +289,7 @@ export interface TwReviewWorkspaceProps {
285
289
  onAddReply?: (commentId: string, body: string) => void;
286
290
  onEditBody?: (commentId: string, body: string) => void;
287
291
  onOpenRevision?: (revision: TrackedChangeEntrySnapshot) => void;
292
+ onReplyToRevision?: (revision: TrackedChangeEntrySnapshot) => void;
288
293
  onAcceptRevision?: (revisionId: string) => void;
289
294
  onRejectRevision?: (revisionId: string) => void;
290
295
  onAcceptAllChanges?: () => void;
@@ -293,7 +298,7 @@ export interface TwReviewWorkspaceProps {
293
298
  /**
294
299
  * @deprecated P8.11 — the workspace no longer renders a workspace-level
295
300
  * header band with an "Edit header" button; per-page header bands route
296
- * clicks via `onOpenStory` / `runtime.openStory` directly. The prop
301
+ * double-clicks via `onOpenStory` / `runtime.openStory` directly. The prop
297
302
  * remains optional for one release so existing hosts continue to
298
303
  * compile; supplying it emits a `console.warn` on mount.
299
304
  */
@@ -313,7 +318,7 @@ export interface TwReviewWorkspaceProps {
313
318
  onOpenFooterStoryForPage?: (pageIndex: number) => void;
314
319
  /**
315
320
  * P8.11 — fired when a per-page chrome band (header / footer) is
316
- * clicked to promote it into the active editing surface. Wire to
321
+ * double-clicked to promote it into the active editing surface. Wire to
317
322
  * `runtime.openStory(target)`; the chrome layer's portal mechanism
318
323
  * then reparents the PM surface into the matching band's active slot.
319
324
  */
@@ -212,6 +212,16 @@ export function usePageMarkers(options: UsePageMarkersOptions): PageMarkersResul
212
212
  const [pageMarkers, setPageMarkers] = useState<readonly HTMLElement[]>([]);
213
213
 
214
214
  useEffect(() => {
215
+ if (geometryFacet && layoutFacet) {
216
+ // Warm path: render-kernel geometry gives us page frames and the layout
217
+ // facet gives us page offsets, so DOM page-break markers are only legacy
218
+ // fallback. Skipping the marker scan avoids querySelectorAll +
219
+ // IntersectionObserver churn when PM swaps virtualized blocks at page
220
+ // dividers during scroll.
221
+ setPageMarkers((prev) => (prev.length === 0 ? prev : []));
222
+ return undefined;
223
+ }
224
+
215
225
  const root = pageStackScrollRoot;
216
226
  if (!root) {
217
227
  setPageMarkers((prev) => (prev.length === 0 ? prev : []));
@@ -283,7 +293,7 @@ export function usePageMarkers(options: UsePageMarkersOptions): PageMarkersResul
283
293
  // every requestViewportRefresh(), which would add two extra render passes per
284
294
  // scroll event. The page-0 fallback uses -1 when page1First is unknown,
285
295
  // which is correct (no page-0 blocks → synthetic marker contributes nothing).
286
- }, [pageStackScrollRoot, snapshot.revisionToken]);
296
+ }, [geometryFacet, layoutFacet, pageStackScrollRoot, snapshot.revisionToken]);
287
297
 
288
298
  const selectionBlockIndex = useMemo(() => {
289
299
  const sel = snapshot.selection;
@@ -25,8 +25,6 @@ import {
25
25
  ChevronLeft,
26
26
  ChevronRight,
27
27
  CircleOff,
28
- Eye,
29
- EyeOff,
30
28
  FileDiff,
31
29
  Flag,
32
30
  Hand,
@@ -226,32 +224,33 @@ function RoleActionButton(arg: RoleActionButtonProps): React.JSX.Element | null
226
224
  </Tooltip.Portal>
227
225
  </Tooltip.Root>
228
226
  );
229
- case "tracked-changes-toggle":
227
+ case "tracked-changes-toggle": {
228
+ const trackChangesLabel = (props.showTrackedChanges ?? false)
229
+ ? "Stop tracking changes"
230
+ : "Start tracking changes";
230
231
  return (
231
232
  <Tooltip.Root>
232
233
  <Tooltip.Trigger asChild>
233
234
  <Toggle.Root
234
235
  pressed={props.showTrackedChanges ?? false}
235
236
  onPressedChange={(v) => props.onShowTrackedChangesChange?.(v)}
237
+ aria-label={trackChangesLabel}
236
238
  disabled={props.capabilities ? !props.capabilities.trackChangesSupported : false}
237
239
  onMouseDown={preserveEditorSelectionMouseDown}
238
240
  className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-canvas data-[state=on]:text-accent data-[state=on]:ring-1 data-[state=on]:ring-accent/30 data-[state=on]:shadow-sm outline-none disabled:opacity-40 ${focusRingClass}`}
239
241
  data-testid="role-tracked-changes-toggle"
240
242
  >
241
- {(props.showTrackedChanges ?? false) ? (
242
- <Eye className="h-3.5 w-3.5" />
243
- ) : (
244
- <EyeOff className="h-3.5 w-3.5" />
245
- )}
243
+ <FileDiff className="h-3.5 w-3.5" />
246
244
  </Toggle.Root>
247
245
  </Tooltip.Trigger>
248
246
  <Tooltip.Portal>
249
247
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
250
- {(props.showTrackedChanges ?? false) ? "Hide tracked changes" : "Show tracked changes"}
248
+ {trackChangesLabel}
251
249
  </Tooltip.Content>
252
250
  </Tooltip.Portal>
253
251
  </Tooltip.Root>
254
252
  );
253
+ }
255
254
  case "review-sidebar-tracked-changes":
256
255
  return (
257
256
  <Tooltip.Root>
@@ -15,8 +15,7 @@ import {
15
15
  Bold,
16
16
  ChevronDown,
17
17
  Download,
18
- Eye,
19
- EyeOff,
18
+ FileDiff,
20
19
  FileText,
21
20
  Highlighter,
22
21
  ImagePlus,
@@ -89,7 +88,7 @@ export interface TwToolbarProps {
89
88
  formattingState?: FormattingStateSnapshot;
90
89
  activeListContext?: ActiveListContext | null;
91
90
  styleCatalog?: StyleCatalogSnapshot;
92
- /** Display toggle for tracked change decorations (not a runtime mutation toggle). */
91
+ /** Authoring toggle for recording new edits as tracked changes. */
93
92
  showTrackedChanges: boolean;
94
93
  /** Active story target — shows a breadcrumb when editing a secondary story. */
95
94
  activeStory?: EditorStoryTarget;
@@ -378,7 +377,7 @@ export function TwToolbar(props: TwToolbarProps) {
378
377
  label="Bold"
379
378
  shortcut="⌘B"
380
379
  active={props.formattingState?.bold ?? false}
381
- disabled={!canEdit}
380
+ disabled={!canEdit || !props.onToggleBold}
382
381
  onClick={props.onToggleBold}
383
382
  />
384
383
  <TwToolbarIconButton
@@ -386,7 +385,7 @@ export function TwToolbar(props: TwToolbarProps) {
386
385
  label="Italic"
387
386
  shortcut="⌘I"
388
387
  active={props.formattingState?.italic ?? false}
389
- disabled={!canEdit}
388
+ disabled={!canEdit || !props.onToggleItalic}
390
389
  onClick={props.onToggleItalic}
391
390
  />
392
391
  <TwToolbarIconButton
@@ -394,12 +393,17 @@ export function TwToolbar(props: TwToolbarProps) {
394
393
  label="Underline"
395
394
  shortcut="⌘U"
396
395
  active={props.formattingState?.underline ?? false}
397
- disabled={!canEdit}
396
+ disabled={!canEdit || !props.onToggleUnderline}
398
397
  onClick={props.onToggleUnderline}
399
398
  />
400
399
  {showAdvancedFormatting ? (
401
400
  <ToolbarFormattingOverflow
402
- disabled={!canEdit}
401
+ disabled={
402
+ !canEdit ||
403
+ (!props.onToggleStrikethrough &&
404
+ !props.onToggleSuperscript &&
405
+ !props.onToggleSubscript)
406
+ }
403
407
  formattingState={props.formattingState}
404
408
  onToggleStrikethrough={props.onToggleStrikethrough}
405
409
  onToggleSuperscript={props.onToggleSuperscript}
@@ -452,14 +456,14 @@ export function TwToolbar(props: TwToolbarProps) {
452
456
  icon={List}
453
457
  label="Bulleted list"
454
458
  active={Boolean(props.activeListContext && !props.activeListContext.isOrdered)}
455
- disabled={!canEdit}
459
+ disabled={!canEdit || !props.onToggleBulletedList}
456
460
  onClick={props.onToggleBulletedList}
457
461
  />
458
462
  <TwToolbarIconButton
459
463
  icon={Rows3}
460
464
  label="Numbered list"
461
465
  active={Boolean(props.activeListContext?.isOrdered)}
462
- disabled={!canEdit}
466
+ disabled={!canEdit || !props.onToggleNumberedList}
463
467
  onClick={props.onToggleNumberedList}
464
468
  />
465
469
  </>
@@ -469,13 +473,13 @@ export function TwToolbar(props: TwToolbarProps) {
469
473
  <TwToolbarIconButton
470
474
  icon={Outdent}
471
475
  label="Outdent"
472
- disabled={!canEdit}
476
+ disabled={!canEdit || !props.onOutdent}
473
477
  onClick={props.onOutdent}
474
478
  />
475
479
  <TwToolbarIconButton
476
480
  icon={Indent}
477
481
  label="Indent"
478
- disabled={!canEdit}
482
+ disabled={!canEdit || !props.onIndent}
479
483
  onClick={props.onIndent}
480
484
  />
481
485
  </>
@@ -669,11 +673,12 @@ export function TwToolbar(props: TwToolbarProps) {
669
673
  <Toggle.Root
670
674
  pressed={props.showTrackedChanges}
671
675
  onPressedChange={props.onShowTrackedChangesChange}
676
+ aria-label={props.showTrackedChanges ? "Stop tracking changes" : "Start tracking changes"}
672
677
  disabled={caps ? !caps.trackChangesSupported : false}
673
678
  onMouseDown={preserveEditorSelectionMouseDown}
674
679
  className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-canvas data-[state=on]:text-accent data-[state=on]:ring-1 data-[state=on]:ring-accent/30 data-[state=on]:shadow-sm outline-none disabled:opacity-40 ${focusRingClass}`}
675
680
  >
676
- {props.showTrackedChanges ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
681
+ <FileDiff className="h-3.5 w-3.5" />
677
682
  </Toggle.Root>
678
683
  </Tooltip.Trigger>
679
684
  <Tooltip.Portal>
@@ -681,7 +686,7 @@ export function TwToolbar(props: TwToolbarProps) {
681
686
  className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
682
687
  sideOffset={6}
683
688
  >
684
- {props.showTrackedChanges ? "Hide tracked changes" : "Show tracked changes"}
689
+ {props.showTrackedChanges ? "Stop tracking changes" : "Start tracking changes"}
685
690
  </Tooltip.Content>
686
691
  </Tooltip.Portal>
687
692
  </Tooltip.Root>
@@ -1419,7 +1424,7 @@ function ToolbarFormattingOverflow(props: {
1419
1424
  <ToolbarPopoverActionButton
1420
1425
  active={props.formattingState?.strikethrough ?? false}
1421
1426
  ariaLabel="Strikethrough"
1422
- disabled={props.disabled}
1427
+ disabled={props.disabled || !props.onToggleStrikethrough}
1423
1428
  icon={<Strikethrough className="h-3.5 w-3.5" />}
1424
1429
  onClick={() => {
1425
1430
  props.onToggleStrikethrough?.();
@@ -1429,7 +1434,7 @@ function ToolbarFormattingOverflow(props: {
1429
1434
  <ToolbarPopoverActionButton
1430
1435
  active={props.formattingState?.superscript ?? false}
1431
1436
  ariaLabel="Superscript"
1432
- disabled={props.disabled}
1437
+ disabled={props.disabled || !props.onToggleSuperscript}
1433
1438
  icon={<Superscript className="h-3.5 w-3.5" />}
1434
1439
  onClick={() => {
1435
1440
  props.onToggleSuperscript?.();
@@ -1439,7 +1444,7 @@ function ToolbarFormattingOverflow(props: {
1439
1444
  <ToolbarPopoverActionButton
1440
1445
  active={props.formattingState?.subscript ?? false}
1441
1446
  ariaLabel="Subscript"
1442
- disabled={props.disabled}
1447
+ disabled={props.disabled || !props.onToggleSubscript}
1443
1448
  icon={<Subscript className="h-3.5 w-3.5" />}
1444
1449
  onClick={() => {
1445
1450
  props.onToggleSubscript?.();