@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -1,6 +1,7 @@
1
1
  import React, {
2
2
  forwardRef,
3
3
  type FocusEventHandler,
4
+ useCallback,
4
5
  useEffect,
5
6
  useImperativeHandle,
6
7
  useMemo,
@@ -9,12 +10,16 @@ import React, {
9
10
  import { EditorView } from "prosemirror-view";
10
11
 
11
12
  import type {
13
+ DocumentNavigationSnapshot,
12
14
  EditorUser,
13
15
  RuntimeRenderSnapshot,
14
16
  SearchOptions,
15
17
  SearchResultSnapshot,
16
18
  SelectionSnapshot,
19
+ WorkflowScope,
17
20
  } from "../../api/public-types";
21
+ import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
22
+ import { searchDocument } from "../../runtime/document-search.ts";
18
23
  import {
19
24
  getTableSelectionDescriptor,
20
25
  type TableSelectionDescriptor,
@@ -24,22 +29,37 @@ import {
24
29
  type MarkupDisplay,
25
30
  } from "../../ui/headless/comment-decoration-model";
26
31
  import { createRevisionDecorationModel } from "../../ui/headless/revision-decoration-model";
27
- import { createPMStateFromSnapshot } from "./pm-state-from-snapshot";
32
+ import {
33
+ createPMSelectionFromSnapshot,
34
+ createPMStateFromSnapshot,
35
+ } from "./pm-state-from-snapshot";
28
36
  import {
29
37
  createCommandBridgePlugins,
30
38
  type CommandBridgeCallbacks,
31
39
  } from "./pm-command-bridge";
32
40
  import { buildDecorations } from "./pm-decorations";
41
+ import { createContextualInteractionPlugin } from "./pm-contextual-ui";
42
+ import {
43
+ finishPerfProbe,
44
+ incrementInvalidationCounter,
45
+ recordPerfSample,
46
+ startPerfProbe,
47
+ } from "./perf-probe";
33
48
  import type { PositionMap } from "./pm-position-map";
34
49
  import {
35
50
  clearSearch as clearSearchPlugin,
36
- createSearchExcerpt,
37
51
  createSearchPlugin,
38
52
  DEFAULT_SEARCH_HIGHLIGHT_COLOR,
39
53
  performSearch,
40
54
  searchPluginKey,
41
55
  } from "./search-plugin";
56
+ import {
57
+ createSurfaceDecorationKey,
58
+ createSurfaceDocumentBuildKey,
59
+ } from "./surface-build-keys";
42
60
  import { tableNodeViews } from "./tw-table-node-view";
61
+ import type { SelectionToolbarAnchor } from "../../ui/headless/selection-toolbar-model";
62
+ import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
43
63
 
44
64
  /**
45
65
  * Same props interface as the legacy TwEditorSurface — drop-in replacement.
@@ -47,10 +67,14 @@ import { tableNodeViews } from "./tw-table-node-view";
47
67
  export interface TwProseMirrorSurfaceProps {
48
68
  currentUser: EditorUser;
49
69
  snapshot: RuntimeRenderSnapshot;
70
+ canonicalDocument: CanonicalDocumentEnvelope;
71
+ documentNavigation: DocumentNavigationSnapshot;
50
72
  reviewMode: "editing" | "review";
51
73
  markupDisplay: MarkupDisplay;
52
74
  activeRevisionId?: string;
53
75
  showTrackedChanges?: boolean;
76
+ /** When true, the surface renders inside the page workspace (vs canvas). */
77
+ isPageWorkspace?: boolean;
54
78
  onFocus: FocusEventHandler<HTMLDivElement>;
55
79
  onBlur: FocusEventHandler<HTMLDivElement>;
56
80
  onSelectionChange?: (selection: SelectionSnapshot) => void;
@@ -58,10 +82,14 @@ export interface TwProseMirrorSurfaceProps {
58
82
  onDeleteBackward?: () => void;
59
83
  onDeleteForward?: () => void;
60
84
  onInsertTab?: () => void;
85
+ onOutdentTab?: () => void;
61
86
  onInsertHardBreak?: () => void;
62
87
  onSplitParagraph?: () => void;
63
88
  onCommentActivated?: (commentId: string) => void;
64
89
  onRevisionActivated?: (revisionId: string) => void;
90
+ onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
91
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
92
+ workflowScopes?: readonly WorkflowScope[];
65
93
  }
66
94
 
67
95
  export interface TwProseMirrorSurfaceRef {
@@ -82,6 +110,17 @@ export const TwProseMirrorSurface = forwardRef<
82
110
  onBlur,
83
111
  } = props;
84
112
  const surface = snapshot.surface;
113
+ const mediaPreviewKey = useMemo(
114
+ () =>
115
+ Object.entries(props.mediaPreviews ?? {})
116
+ .sort(([leftId], [rightId]) => leftId.localeCompare(rightId))
117
+ .map(
118
+ ([mediaId, preview]) =>
119
+ `${mediaId}:${preview.widthEmu ?? ""}:${preview.heightEmu ?? ""}:${preview.src}`,
120
+ )
121
+ .join("|"),
122
+ [props.mediaPreviews],
123
+ );
85
124
 
86
125
  const canEdit = Boolean(
87
126
  surface && snapshot.isReady && !snapshot.readOnly && !snapshot.fatalError,
@@ -92,19 +131,44 @@ export const TwProseMirrorSurface = forwardRef<
92
131
  const positionMapRef = useRef<PositionMap | null>(null);
93
132
  const callbacksRef = useRef<CommandBridgeCallbacks | null>(null);
94
133
  const activeSearchRef = useRef<{ query: string; options: SearchOptions } | null>(null);
134
+ const pendingTypingProbeRef = useRef<string | null>(null);
135
+ const pendingSelectionProbeRef = useRef<string | null>(null);
136
+ const documentBuildKeyRef = useRef<string | null>(null);
137
+ const decorationBuildKeyRef = useRef<string | null>(null);
138
+ const suppressSelectionEchoRef = useRef(false);
139
+ const selectionToolbarFrameRef = useRef<number | null>(null);
140
+ const lastSelectionToolbarMeasurementRef = useRef<{
141
+ key: string | null;
142
+ anchor: SelectionToolbarAnchor | null;
143
+ }>({
144
+ key: null,
145
+ anchor: null,
146
+ });
95
147
 
96
148
  // Keep callbacks ref up to date (avoids stale closures in PM plugins)
97
149
  callbacksRef.current = {
98
- onInsertText: (text) => props.onInsertText?.(text),
150
+ onInsertText: (text) => {
151
+ pendingTypingProbeRef.current = startPerfProbe("typing");
152
+ props.onInsertText?.(text);
153
+ },
99
154
  onDeleteBackward: () => props.onDeleteBackward?.(),
100
155
  onDeleteForward: () => props.onDeleteForward?.(),
101
156
  onSplitParagraph: () => props.onSplitParagraph?.(),
102
157
  onInsertHardBreak: () => props.onInsertHardBreak?.(),
103
158
  onInsertTab: () => props.onInsertTab?.(),
159
+ onOutdentTab: () => props.onOutdentTab?.(),
104
160
  onUndo: () => {}, // Handled by toolbar, not PM
105
161
  onRedo: () => {}, // Handled by toolbar, not PM
106
- onSelectionChange: (sel) => props.onSelectionChange?.(sel),
162
+ onSelectionChange: (sel) => {
163
+ pendingSelectionProbeRef.current = startPerfProbe("selection");
164
+ props.onSelectionChange?.(
165
+ snapshot.activeStory.kind === "main"
166
+ ? sel
167
+ : { ...sel, storyTarget: snapshot.activeStory },
168
+ );
169
+ },
107
170
  getPositionMap: () => positionMapRef.current,
171
+ isSelectionSyncSuppressed: () => suppressSelectionEchoRef.current,
108
172
  };
109
173
 
110
174
  // Comment/revision decoration models
@@ -119,6 +183,34 @@ export const TwProseMirrorSurface = forwardRef<
119
183
  () => createRevisionDecorationModel(snapshot.trackedChanges, props.activeRevisionId),
120
184
  [snapshot.trackedChanges, props.activeRevisionId],
121
185
  );
186
+ const documentBuildKey = useMemo(
187
+ () =>
188
+ createSurfaceDocumentBuildKey({
189
+ surface,
190
+ activeStory: snapshot.activeStory,
191
+ mediaPreviewKey,
192
+ }),
193
+ [mediaPreviewKey, snapshot.activeStory, surface],
194
+ );
195
+ const decorationBuildKey = useMemo(
196
+ () =>
197
+ createSurfaceDecorationKey({
198
+ markupDisplay,
199
+ showTrackedChanges,
200
+ canEdit,
201
+ activeCommentId: snapshot.comments.activeCommentId,
202
+ activeRevisionId: props.activeRevisionId,
203
+ workflowScopeSignature: JSON.stringify(props.workflowScopes ?? []),
204
+ }),
205
+ [
206
+ canEdit,
207
+ markupDisplay,
208
+ props.activeRevisionId,
209
+ props.workflowScopes,
210
+ showTrackedChanges,
211
+ snapshot.comments.activeCommentId,
212
+ ],
213
+ );
122
214
 
123
215
  // Create PM plugins (stable across renders — callbacks accessed via ref)
124
216
  const plugins = useMemo(() => {
@@ -130,28 +222,68 @@ export const TwProseMirrorSurface = forwardRef<
130
222
  onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
131
223
  onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
132
224
  onInsertTab: () => callbacksRef.current?.onInsertTab(),
225
+ onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
133
226
  onUndo: () => callbacksRef.current?.onUndo(),
134
227
  onRedo: () => callbacksRef.current?.onRedo(),
135
228
  onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
136
229
  getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
230
+ isSelectionSyncSuppressed: () =>
231
+ callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
232
+ }),
233
+ createContextualInteractionPlugin({
234
+ onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
235
+ onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
137
236
  }),
138
237
  createSearchPlugin(),
139
238
  ];
140
- }, []);
239
+ }, [props.onCommentActivated, props.onRevisionActivated]);
141
240
 
142
- // Create or update PM view whenever surface becomes available or changes.
143
- // The view is created lazily if surface is null on first render (loading),
144
- // it will be created when the runtime provides a real snapshot.
241
+ const applyDecorationProps = useCallback(
242
+ (view: EditorView, positionMap: PositionMap): void => {
243
+ const decorations = buildDecorations(
244
+ view.state.doc,
245
+ positionMap,
246
+ commentModel,
247
+ revisionModel,
248
+ markupDisplay,
249
+ showTrackedChanges,
250
+ props.workflowScopes,
251
+ snapshot.activeStory,
252
+ );
253
+ view.setProps({
254
+ editable: () => canEdit,
255
+ decorations: () => decorations,
256
+ });
257
+ decorationBuildKeyRef.current = decorationBuildKey;
258
+ recordPerfSample("pm.decorations");
259
+ incrementInvalidationCounter("pm.laneB.decorationUpdates");
260
+ },
261
+ [
262
+ canEdit,
263
+ commentModel,
264
+ decorationBuildKey,
265
+ markupDisplay,
266
+ revisionModel,
267
+ showTrackedChanges,
268
+ props.workflowScopes,
269
+ ],
270
+ );
271
+
272
+ // Create or update the PM document only when the structural key changes.
145
273
  useEffect(() => {
146
274
  if (!mountRef.current || !surface) return;
147
275
 
276
+ if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
277
+ return;
278
+ }
279
+
148
280
  const { state, positionMap } = createPMStateFromSnapshot(
149
281
  surface,
150
282
  snapshot.selection,
151
283
  plugins,
284
+ props.mediaPreviews,
152
285
  );
153
286
  positionMapRef.current = positionMap;
154
-
155
287
  const decorations = buildDecorations(
156
288
  state.doc,
157
289
  positionMap,
@@ -159,7 +291,11 @@ export const TwProseMirrorSurface = forwardRef<
159
291
  revisionModel,
160
292
  markupDisplay,
161
293
  showTrackedChanges,
294
+ props.workflowScopes,
295
+ snapshot.activeStory,
162
296
  );
297
+ recordPerfSample("pm.rebuild");
298
+ incrementInvalidationCounter("pm.laneA.rebuilds");
163
299
 
164
300
  if (!viewRef.current) {
165
301
  // First time surface is available — create the EditorView
@@ -174,14 +310,16 @@ export const TwProseMirrorSurface = forwardRef<
174
310
  },
175
311
  });
176
312
  viewRef.current = view;
313
+ recordPerfSample("pm.mount");
177
314
  } else {
178
- // View exists — update state and decorations
179
- viewRef.current.setProps({
180
- editable: () => canEdit,
181
- decorations: () => decorations,
182
- });
315
+ suppressSelectionEchoRef.current = true;
183
316
  viewRef.current.updateState(state);
317
+ queueMicrotask(() => {
318
+ suppressSelectionEchoRef.current = false;
319
+ });
184
320
  }
321
+ documentBuildKeyRef.current = documentBuildKey;
322
+ applyDecorationProps(viewRef.current, positionMap);
185
323
 
186
324
  if (activeSearchRef.current) {
187
325
  applySearch(
@@ -189,11 +327,74 @@ export const TwProseMirrorSurface = forwardRef<
189
327
  activeSearchRef.current.options,
190
328
  );
191
329
  }
192
- }, [snapshot.revisionToken, surface, commentModel, revisionModel, markupDisplay, canEdit]);
330
+ if (pendingTypingProbeRef.current) {
331
+ finishPerfProbe(pendingTypingProbeRef.current);
332
+ pendingTypingProbeRef.current = null;
333
+ }
334
+ }, [
335
+ applyDecorationProps,
336
+ documentBuildKey,
337
+ surface,
338
+ snapshot.selection,
339
+ plugins,
340
+ props.mediaPreviews,
341
+ ]);
342
+
343
+ // Update decorations and editability without rebuilding the PM document.
344
+ useEffect(() => {
345
+ const view = viewRef.current;
346
+ const positionMap = positionMapRef.current;
347
+ if (!view || !surface || !positionMap) {
348
+ return;
349
+ }
350
+
351
+ if (decorationBuildKeyRef.current === decorationBuildKey) {
352
+ return;
353
+ }
354
+
355
+ applyDecorationProps(view, positionMap);
356
+ }, [applyDecorationProps, decorationBuildKey, surface]);
357
+
358
+ useEffect(() => {
359
+ const view = viewRef.current;
360
+ const positionMap = positionMapRef.current;
361
+ if (!view || !surface || !positionMap) {
362
+ return;
363
+ }
364
+
365
+ const nextSelection = createPMSelectionFromSnapshot(
366
+ view.state.doc,
367
+ positionMap,
368
+ snapshot.selection,
369
+ );
370
+ if (view.state.selection.eq(nextSelection)) {
371
+ return;
372
+ }
373
+
374
+ suppressSelectionEchoRef.current = true;
375
+ view.dispatch(view.state.tr.setSelection(nextSelection));
376
+ recordPerfSample("selection.sync");
377
+ queueMicrotask(() => {
378
+ suppressSelectionEchoRef.current = false;
379
+ });
380
+ }, [snapshot.selection, surface]);
381
+
382
+ useEffect(() => {
383
+ if (!pendingSelectionProbeRef.current) {
384
+ return;
385
+ }
386
+ finishPerfProbe(pendingSelectionProbeRef.current);
387
+ pendingSelectionProbeRef.current = null;
388
+ }, [snapshot.selection]);
193
389
 
194
390
  // Cleanup on unmount
195
391
  useEffect(() => {
196
392
  return () => {
393
+ const win = mountRef.current?.ownerDocument.defaultView;
394
+ if (selectionToolbarFrameRef.current !== null && win) {
395
+ win.cancelAnimationFrame(selectionToolbarFrameRef.current);
396
+ selectionToolbarFrameRef.current = null;
397
+ }
197
398
  viewRef.current?.destroy();
198
399
  viewRef.current = null;
199
400
  };
@@ -230,45 +431,77 @@ export const TwProseMirrorSurface = forwardRef<
230
431
 
231
432
  function applySearch(query: string, options: SearchOptions): SearchResultSnapshot[] {
232
433
  const view = viewRef.current;
233
- const positionMap = positionMapRef.current;
234
- if (!view || !positionMap) {
235
- return [];
434
+ const hiddenDeletionRanges =
435
+ markupDisplay === "clean"
436
+ ? snapshot.trackedChanges.revisions
437
+ .filter(
438
+ (
439
+ revision,
440
+ ): revision is typeof revision & {
441
+ anchor: Extract<typeof revision.anchor, { kind: "range" }>;
442
+ } =>
443
+ revision.kind === "deletion" &&
444
+ revision.status === "active" &&
445
+ revision.anchor.kind === "range",
446
+ )
447
+ .map((revision) => ({
448
+ from: revision.anchor.from,
449
+ to: revision.anchor.to,
450
+ }))
451
+ : [];
452
+ if (view) {
453
+ const rawResults = performSearch(view.state, query, options)
454
+ .filter((result) => {
455
+ if (hiddenDeletionRanges.length === 0) {
456
+ return true;
457
+ }
458
+ const positionMap = positionMapRef.current;
459
+ if (!positionMap) {
460
+ return true;
461
+ }
462
+ const runtimeFrom = positionMap.pmToRuntime(result.from);
463
+ const runtimeTo = positionMap.pmToRuntime(result.to);
464
+ return !hiddenDeletionRanges.some(
465
+ (range) => runtimeFrom < range.to && runtimeTo > range.from,
466
+ );
467
+ })
468
+ .slice(0, options.limit ?? Number.POSITIVE_INFINITY);
469
+ view.dispatch(
470
+ view.state.tr.setMeta(searchPluginKey, {
471
+ results: rawResults,
472
+ highlightColor: DEFAULT_SEARCH_HIGHLIGHT_COLOR,
473
+ }),
474
+ );
236
475
  }
237
476
 
238
- const rawResults = performSearch(view.state, query, options).slice(
239
- 0,
240
- options.limit ?? Number.POSITIVE_INFINITY,
241
- );
242
- view.dispatch(
243
- view.state.tr.setMeta(searchPluginKey, {
244
- results: rawResults,
245
- highlightColor: DEFAULT_SEARCH_HIGHLIGHT_COLOR,
246
- }),
477
+ return filterHiddenDeletionSearchResults(
478
+ searchDocument(
479
+ props.canonicalDocument,
480
+ snapshot.selection,
481
+ snapshot.activeStory,
482
+ props.documentNavigation,
483
+ query,
484
+ options,
485
+ ),
486
+ hiddenDeletionRanges,
247
487
  );
488
+ }
248
489
 
249
- const activeResultIndex = getActiveSearchResultIndex(
250
- rawResults,
251
- (position) => positionMap.pmToRuntime(position),
252
- snapshot.selection,
253
- );
254
- const plainText = snapshot.surface?.plainText ?? "";
255
- return rawResults.map((result, index) => {
256
- const runtimeFrom = positionMap.pmToRuntime(result.from);
257
- const runtimeTo = positionMap.pmToRuntime(result.to);
258
- return {
259
- resultId: `search-result-${index}`,
260
- anchor: {
261
- kind: "range",
262
- from: runtimeFrom,
263
- to: runtimeTo,
264
- assoc: {
265
- start: -1,
266
- end: 1,
267
- },
268
- },
269
- excerpt: createSearchExcerpt(plainText, runtimeFrom, runtimeTo),
270
- isActive: index === activeResultIndex,
271
- };
490
+ function filterHiddenDeletionSearchResults(
491
+ results: SearchResultSnapshot[],
492
+ hiddenRanges: Array<{ from: number; to: number }>,
493
+ ): SearchResultSnapshot[] {
494
+ if (hiddenRanges.length === 0) {
495
+ return results;
496
+ }
497
+ return results.filter((result) => {
498
+ const anchor = result.anchor;
499
+ if (anchor.kind !== "range") {
500
+ return true;
501
+ }
502
+ return !hiddenRanges.some(
503
+ (range) => anchor.from < range.to && anchor.to > range.from,
504
+ );
272
505
  });
273
506
  }
274
507
 
@@ -288,37 +521,192 @@ export const TwProseMirrorSurface = forwardRef<
288
521
  ? "font-[family-name:var(--font-legal-sans)]"
289
522
  : "font-[family-name:var(--font-legal-serif)]";
290
523
 
524
+ // Story focus indicator — runtime-backed, not DOM-only
525
+ const storyKind = snapshot.activeStory.kind;
526
+ const storyFocusAttr = storyKind !== "main" ? storyKind : undefined;
527
+
528
+ // Table focus cue — add subtle ring when selection head is inside a table block
529
+ const tableFocusClass = (() => {
530
+ if (!surface || !snapshot.selection) return "";
531
+ const head = snapshot.selection.head;
532
+ const inTable = surface.blocks.some(
533
+ (b) => b.kind === "table" && head >= b.from && head <= b.to,
534
+ );
535
+ return inTable ? "prosemirror-table-focus" : "";
536
+ })();
537
+
538
+ const workspaceLabel = props.isPageWorkspace ? "Document page" : "Document canvas";
539
+
540
+ const selectionToolbarMeasurementKey = useMemo(
541
+ () => buildSelectionToolbarMeasurementKey(snapshot.selection, snapshot.activeStory),
542
+ [snapshot.activeStory, snapshot.selection],
543
+ );
544
+
545
+ const emitSelectionToolbarAnchor = useCallback((): void => {
546
+ const callback = props.onSelectionToolbarAnchorChange;
547
+ if (!callback) {
548
+ return;
549
+ }
550
+
551
+ const nextAnchor = measureSelectionToolbarAnchor();
552
+ const previous = lastSelectionToolbarMeasurementRef.current;
553
+ if (
554
+ previous.key === selectionToolbarMeasurementKey &&
555
+ selectionToolbarAnchorsEqual(previous.anchor, nextAnchor)
556
+ ) {
557
+ return;
558
+ }
559
+
560
+ lastSelectionToolbarMeasurementRef.current = {
561
+ key: selectionToolbarMeasurementKey,
562
+ anchor: nextAnchor,
563
+ };
564
+ callback(nextAnchor);
565
+ }, [
566
+ props.onSelectionToolbarAnchorChange,
567
+ selectionToolbarMeasurementKey,
568
+ snapshot.activeStory,
569
+ snapshot.selection,
570
+ ]);
571
+
572
+ const scheduleSelectionToolbarAnchorUpdate = useCallback((): void => {
573
+ const callback = props.onSelectionToolbarAnchorChange;
574
+ const mount = mountRef.current;
575
+ const win = mount?.ownerDocument.defaultView;
576
+ if (!callback || !win) {
577
+ emitSelectionToolbarAnchor();
578
+ return;
579
+ }
580
+
581
+ if (selectionToolbarFrameRef.current !== null) {
582
+ win.cancelAnimationFrame(selectionToolbarFrameRef.current);
583
+ selectionToolbarFrameRef.current = null;
584
+ }
585
+
586
+ selectionToolbarFrameRef.current = win.requestAnimationFrame(() => {
587
+ selectionToolbarFrameRef.current = null;
588
+ emitSelectionToolbarAnchor();
589
+ });
590
+ }, [emitSelectionToolbarAnchor, props.onSelectionToolbarAnchorChange]);
591
+
592
+ useEffect(() => {
593
+ scheduleSelectionToolbarAnchorUpdate();
594
+ }, [
595
+ scheduleSelectionToolbarAnchorUpdate,
596
+ snapshot.revisionToken,
597
+ snapshot.selection,
598
+ snapshot.surface,
599
+ props.isPageWorkspace,
600
+ ]);
601
+
602
+ useEffect(() => {
603
+ const mount = mountRef.current;
604
+ const callback = props.onSelectionToolbarAnchorChange;
605
+ if (!mount || !callback) {
606
+ return;
607
+ }
608
+
609
+ const updateAnchor = () => {
610
+ scheduleSelectionToolbarAnchorUpdate();
611
+ };
612
+ const scrollRoot = mount.closest<HTMLElement>("[data-wre-scroll-root='true']");
613
+ const win = mount.ownerDocument.defaultView;
614
+ const resizeObserver =
615
+ typeof ResizeObserver !== "undefined"
616
+ ? new ResizeObserver(() => {
617
+ updateAnchor();
618
+ })
619
+ : null;
620
+
621
+ updateAnchor();
622
+ scrollRoot?.addEventListener("scroll", updateAnchor, { passive: true });
623
+ win?.addEventListener("resize", updateAnchor);
624
+ resizeObserver?.observe(mount);
625
+ if (scrollRoot) {
626
+ resizeObserver?.observe(scrollRoot);
627
+ }
628
+
629
+ return () => {
630
+ scrollRoot?.removeEventListener("scroll", updateAnchor);
631
+ win?.removeEventListener("resize", updateAnchor);
632
+ resizeObserver?.disconnect();
633
+ };
634
+ }, [
635
+ props.onSelectionToolbarAnchorChange,
636
+ scheduleSelectionToolbarAnchorUpdate,
637
+ snapshot.revisionToken,
638
+ snapshot.selection,
639
+ ]);
640
+
641
+ useEffect(() => {
642
+ return () => {
643
+ lastSelectionToolbarMeasurementRef.current = {
644
+ key: null,
645
+ anchor: null,
646
+ };
647
+ props.onSelectionToolbarAnchorChange?.(null);
648
+ };
649
+ }, [props.onSelectionToolbarAnchorChange]);
650
+
291
651
  return (
292
- <section aria-label="Document canvas" className="min-w-0">
652
+ <section
653
+ aria-label={workspaceLabel}
654
+ className="min-w-0"
655
+ data-active-story={storyFocusAttr}
656
+ data-workspace={props.isPageWorkspace ? "page" : "canvas"}
657
+ >
293
658
  {/* ProseMirror mount point — document content including headings is editable */}
294
659
  {surface ? (
295
660
  <div
296
661
  ref={mountRef}
297
662
  role="textbox"
663
+ tabIndex={0}
298
664
  aria-multiline="true"
299
- className={`px-12 py-10 ${fontClass} text-[15px] text-primary leading-relaxed prosemirror-surface outline-none`}
300
- onFocus={onFocus as unknown as React.FocusEventHandler<HTMLDivElement>}
665
+ className={`px-12 py-10 ${fontClass} text-[15px] text-primary leading-relaxed prosemirror-surface outline-none ${tableFocusClass}`}
666
+ onFocus={(event) => {
667
+ onFocus(event);
668
+ if (event.target === event.currentTarget) {
669
+ viewRef.current?.focus();
670
+ }
671
+ }}
301
672
  onBlur={onBlur as unknown as React.FocusEventHandler<HTMLDivElement>}
302
- onClick={(e) => {
303
- // Activate comment or revision when clicking on decorated text
304
- const target = e.target as HTMLElement;
305
- const commentEl = target.closest?.("[data-comment-id]");
306
- if (commentEl) {
307
- const commentId = commentEl.getAttribute("data-comment-id");
308
- if (commentId) {
309
- props.onCommentActivated?.(commentId);
310
- return;
311
- }
673
+ onKeyDown={(event) => {
674
+ if (event.target !== event.currentTarget) {
675
+ return;
312
676
  }
313
- const revisionEl = target.closest?.("[data-revision-id]");
314
- if (revisionEl) {
315
- const revisionId = revisionEl.getAttribute("data-revision-id");
316
- if (revisionId) {
317
- props.onRevisionActivated?.(revisionId);
318
- }
677
+
678
+ switch (event.key) {
679
+ case "Backspace":
680
+ event.preventDefault();
681
+ props.onDeleteBackward?.();
682
+ return;
683
+ case "Delete":
684
+ event.preventDefault();
685
+ props.onDeleteForward?.();
686
+ return;
687
+ case "Enter":
688
+ event.preventDefault();
689
+ if (event.shiftKey) {
690
+ props.onInsertHardBreak?.();
691
+ } else {
692
+ props.onSplitParagraph?.();
693
+ }
694
+ return;
695
+ case "Tab":
696
+ event.preventDefault();
697
+ if (event.shiftKey) {
698
+ props.onOutdentTab?.();
699
+ } else {
700
+ props.onInsertTab?.();
701
+ }
702
+ return;
703
+ default:
704
+ return;
319
705
  }
320
706
  }}
321
707
  aria-label="Document surface"
708
+ data-wre-document-surface="true"
709
+ data-story-focus={storyFocusAttr}
322
710
  />
323
711
  ) : (
324
712
  <div className="px-12 pb-10">
@@ -337,27 +725,82 @@ export const TwProseMirrorSurface = forwardRef<
337
725
  ) : null}
338
726
  </section>
339
727
  );
728
+
729
+ function measureSelectionToolbarAnchor(): SelectionToolbarAnchor | null {
730
+ const callback = props.onSelectionToolbarAnchorChange;
731
+ const view = viewRef.current;
732
+ const mount = mountRef.current;
733
+ const positionMap = positionMapRef.current;
734
+ const range = snapshot.selection.activeRange;
735
+
736
+ if (!callback || !view || !mount || !positionMap || snapshot.selection.isCollapsed || range.kind !== "range") {
737
+ return null;
738
+ }
739
+
740
+ const rootRect = mount.getBoundingClientRect();
741
+ if (rootRect.width <= 0 || rootRect.height <= 0) {
742
+ return null;
743
+ }
744
+
745
+ try {
746
+ const pmFrom = positionMap.runtimeToPm(range.from);
747
+ const pmTo = positionMap.runtimeToPm(range.to);
748
+ const startRect = view.coordsAtPos(pmFrom);
749
+ const endRect = view.coordsAtPos(pmTo);
750
+ const left = Math.max(rootRect.left, Math.min(startRect.left, endRect.left));
751
+ const right = Math.min(rootRect.right, Math.max(startRect.right, endRect.right));
752
+ const top = Math.max(rootRect.top, Math.min(startRect.top, endRect.top));
753
+ const bottom = Math.min(rootRect.bottom, Math.max(startRect.bottom, endRect.bottom));
754
+
755
+ if (
756
+ !Number.isFinite(left) ||
757
+ !Number.isFinite(right) ||
758
+ !Number.isFinite(top) ||
759
+ !Number.isFinite(bottom) ||
760
+ right <= left ||
761
+ bottom <= top ||
762
+ bottom < rootRect.top ||
763
+ top > rootRect.bottom
764
+ ) {
765
+ return null;
766
+ }
767
+
768
+ return { left, right, top, bottom };
769
+ } catch {
770
+ return null;
771
+ }
772
+ }
340
773
  });
341
774
 
342
- function getActiveSearchResultIndex(
343
- results: Array<{ from: number; to: number }>,
344
- toRuntimePosition: (position: number) => number,
775
+ function buildSelectionToolbarMeasurementKey(
345
776
  selection: SelectionSnapshot,
346
- ): number {
347
- if (results.length === 0) {
348
- return -1;
777
+ activeStory: RuntimeRenderSnapshot["activeStory"],
778
+ ): string | null {
779
+ if (selection.isCollapsed || selection.activeRange.kind !== "range") {
780
+ return null;
349
781
  }
350
782
 
351
- const selectionFrom = Math.min(selection.anchor, selection.head);
352
- const selectionTo = Math.max(selection.anchor, selection.head);
353
- const activeIndex = results.findIndex((result) => {
354
- const from = toRuntimePosition(result.from);
355
- const to = toRuntimePosition(result.to);
356
- if (selectionFrom === selectionTo) {
357
- return selectionFrom >= from && selectionFrom <= to;
358
- }
359
- return selectionFrom < to && selectionTo > from;
783
+ return JSON.stringify({
784
+ story: activeStory,
785
+ from: selection.activeRange.from,
786
+ to: selection.activeRange.to,
360
787
  });
788
+ }
361
789
 
362
- return activeIndex >= 0 ? activeIndex : 0;
790
+ function selectionToolbarAnchorsEqual(
791
+ left: SelectionToolbarAnchor | null,
792
+ right: SelectionToolbarAnchor | null,
793
+ ): boolean {
794
+ if (left === right) {
795
+ return true;
796
+ }
797
+ if (!left || !right) {
798
+ return false;
799
+ }
800
+ return (
801
+ left.left === right.left &&
802
+ left.right === right.right &&
803
+ left.top === right.top &&
804
+ left.bottom === right.bottom
805
+ );
363
806
  }