@beyondwork/docx-react-component 1.0.17 → 1.0.19

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 (74) hide show
  1. package/README.md +8 -2
  2. package/package.json +32 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +374 -4
  5. package/src/api/session-state.ts +58 -0
  6. package/src/core/commands/formatting-commands.ts +1 -0
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +5 -1
  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 +329 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +1 -1
  16. package/src/index.ts +30 -0
  17. package/src/io/docx-session.ts +260 -39
  18. package/src/io/export/serialize-main-document.ts +202 -5
  19. package/src/io/export/serialize-numbering.ts +28 -7
  20. package/src/io/normalize/normalize-text.ts +63 -25
  21. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  22. package/src/io/ooxml/parse-footnotes.ts +212 -20
  23. package/src/io/ooxml/parse-headers-footers.ts +229 -25
  24. package/src/io/ooxml/parse-inline-media.ts +16 -0
  25. package/src/io/ooxml/parse-main-document.ts +411 -6
  26. package/src/io/ooxml/parse-numbering.ts +7 -0
  27. package/src/io/ooxml/parse-settings.ts +184 -0
  28. package/src/io/ooxml/parse-shapes.ts +25 -0
  29. package/src/io/ooxml/parse-styles.ts +463 -0
  30. package/src/io/ooxml/parse-theme.ts +32 -0
  31. package/src/model/canonical-document.ts +133 -3
  32. package/src/model/cds-1.0.0.ts +13 -0
  33. package/src/model/snapshot.ts +2 -1
  34. package/src/runtime/document-layout.ts +332 -0
  35. package/src/runtime/document-navigation.ts +564 -0
  36. package/src/runtime/document-runtime.ts +265 -35
  37. package/src/runtime/document-search.ts +145 -0
  38. package/src/runtime/numbering-prefix.ts +47 -26
  39. package/src/runtime/page-layout-estimation.ts +212 -0
  40. package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
  41. package/src/runtime/session-capabilities.ts +2 -0
  42. package/src/runtime/story-context.ts +164 -0
  43. package/src/runtime/story-targeting.ts +162 -0
  44. package/src/runtime/surface-projection.ts +239 -12
  45. package/src/runtime/table-schema.ts +87 -5
  46. package/src/runtime/view-state.ts +459 -0
  47. package/src/ui/WordReviewEditor.tsx +1902 -312
  48. package/src/ui/browser-export.ts +52 -0
  49. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  50. package/src/ui/headless/selection-helpers.ts +20 -0
  51. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  52. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  53. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  54. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
  57. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  58. package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
  59. package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
  60. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
  61. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  62. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  63. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
  64. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  65. package/src/ui-tailwind/index.ts +2 -1
  66. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
  68. package/src/ui-tailwind/theme/editor-theme.css +123 -0
  69. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  70. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
  71. package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
  72. package/src/validation/compatibility-engine.ts +92 -20
  73. package/src/validation/diagnostics.ts +1 -0
  74. package/src/validation/docx-comment-proof.ts +487 -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,15 @@ 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,
17
19
  } from "../../api/public-types";
20
+ import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
21
+ import { searchDocument } from "../../runtime/document-search.ts";
18
22
  import {
19
23
  getTableSelectionDescriptor,
20
24
  type TableSelectionDescriptor,
@@ -24,22 +28,27 @@ import {
24
28
  type MarkupDisplay,
25
29
  } from "../../ui/headless/comment-decoration-model";
26
30
  import { createRevisionDecorationModel } from "../../ui/headless/revision-decoration-model";
27
- import { createPMStateFromSnapshot } from "./pm-state-from-snapshot";
31
+ import {
32
+ createPMSelectionFromSnapshot,
33
+ createPMStateFromSnapshot,
34
+ } from "./pm-state-from-snapshot";
28
35
  import {
29
36
  createCommandBridgePlugins,
30
37
  type CommandBridgeCallbacks,
31
38
  } from "./pm-command-bridge";
32
39
  import { buildDecorations } from "./pm-decorations";
40
+ import { createContextualInteractionPlugin } from "./pm-contextual-ui";
41
+ import { finishPerfProbe, startPerfProbe } from "./perf-probe";
33
42
  import type { PositionMap } from "./pm-position-map";
34
43
  import {
35
44
  clearSearch as clearSearchPlugin,
36
- createSearchExcerpt,
37
45
  createSearchPlugin,
38
46
  DEFAULT_SEARCH_HIGHLIGHT_COLOR,
39
47
  performSearch,
40
48
  searchPluginKey,
41
49
  } from "./search-plugin";
42
50
  import { tableNodeViews } from "./tw-table-node-view";
51
+ import type { SelectionToolbarAnchor } from "../../ui/headless/selection-toolbar-model";
43
52
 
44
53
  /**
45
54
  * Same props interface as the legacy TwEditorSurface — drop-in replacement.
@@ -47,10 +56,14 @@ import { tableNodeViews } from "./tw-table-node-view";
47
56
  export interface TwProseMirrorSurfaceProps {
48
57
  currentUser: EditorUser;
49
58
  snapshot: RuntimeRenderSnapshot;
59
+ canonicalDocument: CanonicalDocumentEnvelope;
60
+ documentNavigation: DocumentNavigationSnapshot;
50
61
  reviewMode: "editing" | "review";
51
62
  markupDisplay: MarkupDisplay;
52
63
  activeRevisionId?: string;
53
64
  showTrackedChanges?: boolean;
65
+ /** When true, the surface renders inside the page workspace (vs canvas). */
66
+ isPageWorkspace?: boolean;
54
67
  onFocus: FocusEventHandler<HTMLDivElement>;
55
68
  onBlur: FocusEventHandler<HTMLDivElement>;
56
69
  onSelectionChange?: (selection: SelectionSnapshot) => void;
@@ -58,10 +71,12 @@ export interface TwProseMirrorSurfaceProps {
58
71
  onDeleteBackward?: () => void;
59
72
  onDeleteForward?: () => void;
60
73
  onInsertTab?: () => void;
74
+ onOutdentTab?: () => void;
61
75
  onInsertHardBreak?: () => void;
62
76
  onSplitParagraph?: () => void;
63
77
  onCommentActivated?: (commentId: string) => void;
64
78
  onRevisionActivated?: (revisionId: string) => void;
79
+ onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
65
80
  }
66
81
 
67
82
  export interface TwProseMirrorSurfaceRef {
@@ -92,19 +107,43 @@ export const TwProseMirrorSurface = forwardRef<
92
107
  const positionMapRef = useRef<PositionMap | null>(null);
93
108
  const callbacksRef = useRef<CommandBridgeCallbacks | null>(null);
94
109
  const activeSearchRef = useRef<{ query: string; options: SearchOptions } | null>(null);
110
+ const pendingTypingProbeRef = useRef<string | null>(null);
111
+ const pendingSelectionProbeRef = useRef<string | null>(null);
112
+ const surfaceBuildKeyRef = useRef<string | null>(null);
113
+ const suppressSelectionEchoRef = useRef(false);
114
+ const selectionToolbarFrameRef = useRef<number | null>(null);
115
+ const lastSelectionToolbarMeasurementRef = useRef<{
116
+ key: string | null;
117
+ anchor: SelectionToolbarAnchor | null;
118
+ }>({
119
+ key: null,
120
+ anchor: null,
121
+ });
95
122
 
96
123
  // Keep callbacks ref up to date (avoids stale closures in PM plugins)
97
124
  callbacksRef.current = {
98
- onInsertText: (text) => props.onInsertText?.(text),
125
+ onInsertText: (text) => {
126
+ pendingTypingProbeRef.current = startPerfProbe("typing");
127
+ props.onInsertText?.(text);
128
+ },
99
129
  onDeleteBackward: () => props.onDeleteBackward?.(),
100
130
  onDeleteForward: () => props.onDeleteForward?.(),
101
131
  onSplitParagraph: () => props.onSplitParagraph?.(),
102
132
  onInsertHardBreak: () => props.onInsertHardBreak?.(),
103
133
  onInsertTab: () => props.onInsertTab?.(),
134
+ onOutdentTab: () => props.onOutdentTab?.(),
104
135
  onUndo: () => {}, // Handled by toolbar, not PM
105
136
  onRedo: () => {}, // Handled by toolbar, not PM
106
- onSelectionChange: (sel) => props.onSelectionChange?.(sel),
137
+ onSelectionChange: (sel) => {
138
+ pendingSelectionProbeRef.current = startPerfProbe("selection");
139
+ props.onSelectionChange?.(
140
+ snapshot.activeStory.kind === "main"
141
+ ? sel
142
+ : { ...sel, storyTarget: snapshot.activeStory },
143
+ );
144
+ },
107
145
  getPositionMap: () => positionMapRef.current,
146
+ isSelectionSyncSuppressed: () => suppressSelectionEchoRef.current,
108
147
  };
109
148
 
110
149
  // Comment/revision decoration models
@@ -130,14 +169,19 @@ export const TwProseMirrorSurface = forwardRef<
130
169
  onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
131
170
  onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
132
171
  onInsertTab: () => callbacksRef.current?.onInsertTab(),
172
+ onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
133
173
  onUndo: () => callbacksRef.current?.onUndo(),
134
174
  onRedo: () => callbacksRef.current?.onRedo(),
135
175
  onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
136
176
  getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
137
177
  }),
178
+ createContextualInteractionPlugin({
179
+ onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
180
+ onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
181
+ }),
138
182
  createSearchPlugin(),
139
183
  ];
140
- }, []);
184
+ }, [props.onCommentActivated, props.onRevisionActivated]);
141
185
 
142
186
  // Create or update PM view whenever surface becomes available or changes.
143
187
  // The view is created lazily — if surface is null on first render (loading),
@@ -145,6 +189,34 @@ export const TwProseMirrorSurface = forwardRef<
145
189
  useEffect(() => {
146
190
  if (!mountRef.current || !surface) return;
147
191
 
192
+ const surfaceBuildKey = JSON.stringify({
193
+ revisionToken: snapshot.revisionToken,
194
+ activeStory: snapshot.activeStory,
195
+ markupDisplay,
196
+ canEdit,
197
+ showTrackedChanges,
198
+ });
199
+
200
+ if (viewRef.current && surfaceBuildKeyRef.current === surfaceBuildKey) {
201
+ const positionMap = positionMapRef.current;
202
+ if (!positionMap) {
203
+ return;
204
+ }
205
+ const decorations = buildDecorations(
206
+ viewRef.current.state.doc,
207
+ positionMap,
208
+ commentModel,
209
+ revisionModel,
210
+ markupDisplay,
211
+ showTrackedChanges,
212
+ );
213
+ viewRef.current.setProps({
214
+ editable: () => canEdit,
215
+ decorations: () => decorations,
216
+ });
217
+ return;
218
+ }
219
+
148
220
  const { state, positionMap } = createPMStateFromSnapshot(
149
221
  surface,
150
222
  snapshot.selection,
@@ -180,8 +252,13 @@ export const TwProseMirrorSurface = forwardRef<
180
252
  editable: () => canEdit,
181
253
  decorations: () => decorations,
182
254
  });
255
+ suppressSelectionEchoRef.current = true;
183
256
  viewRef.current.updateState(state);
257
+ queueMicrotask(() => {
258
+ suppressSelectionEchoRef.current = false;
259
+ });
184
260
  }
261
+ surfaceBuildKeyRef.current = surfaceBuildKey;
185
262
 
186
263
  if (activeSearchRef.current) {
187
264
  applySearch(
@@ -189,11 +266,60 @@ export const TwProseMirrorSurface = forwardRef<
189
266
  activeSearchRef.current.options,
190
267
  );
191
268
  }
192
- }, [snapshot.revisionToken, surface, commentModel, revisionModel, markupDisplay, canEdit]);
269
+ if (pendingTypingProbeRef.current) {
270
+ finishPerfProbe(pendingTypingProbeRef.current);
271
+ pendingTypingProbeRef.current = null;
272
+ }
273
+ }, [
274
+ snapshot.activeStory,
275
+ snapshot.revisionToken,
276
+ surface,
277
+ commentModel,
278
+ revisionModel,
279
+ markupDisplay,
280
+ canEdit,
281
+ showTrackedChanges,
282
+ ]);
283
+
284
+ useEffect(() => {
285
+ const view = viewRef.current;
286
+ const positionMap = positionMapRef.current;
287
+ if (!view || !surface || !positionMap) {
288
+ return;
289
+ }
290
+
291
+ const nextSelection = createPMSelectionFromSnapshot(
292
+ view.state.doc,
293
+ positionMap,
294
+ snapshot.selection,
295
+ );
296
+ if (view.state.selection.eq(nextSelection)) {
297
+ return;
298
+ }
299
+
300
+ suppressSelectionEchoRef.current = true;
301
+ view.dispatch(view.state.tr.setSelection(nextSelection));
302
+ queueMicrotask(() => {
303
+ suppressSelectionEchoRef.current = false;
304
+ });
305
+ }, [snapshot.selection, surface]);
306
+
307
+ useEffect(() => {
308
+ if (!pendingSelectionProbeRef.current) {
309
+ return;
310
+ }
311
+ finishPerfProbe(pendingSelectionProbeRef.current);
312
+ pendingSelectionProbeRef.current = null;
313
+ }, [snapshot.selection]);
193
314
 
194
315
  // Cleanup on unmount
195
316
  useEffect(() => {
196
317
  return () => {
318
+ const win = mountRef.current?.ownerDocument.defaultView;
319
+ if (selectionToolbarFrameRef.current !== null && win) {
320
+ win.cancelAnimationFrame(selectionToolbarFrameRef.current);
321
+ selectionToolbarFrameRef.current = null;
322
+ }
197
323
  viewRef.current?.destroy();
198
324
  viewRef.current = null;
199
325
  };
@@ -230,46 +356,27 @@ export const TwProseMirrorSurface = forwardRef<
230
356
 
231
357
  function applySearch(query: string, options: SearchOptions): SearchResultSnapshot[] {
232
358
  const view = viewRef.current;
233
- const positionMap = positionMapRef.current;
234
- if (!view || !positionMap) {
235
- return [];
359
+ if (view) {
360
+ const rawResults = performSearch(view.state, query, options).slice(
361
+ 0,
362
+ options.limit ?? Number.POSITIVE_INFINITY,
363
+ );
364
+ view.dispatch(
365
+ view.state.tr.setMeta(searchPluginKey, {
366
+ results: rawResults,
367
+ highlightColor: DEFAULT_SEARCH_HIGHLIGHT_COLOR,
368
+ }),
369
+ );
236
370
  }
237
371
 
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
- }),
247
- );
248
-
249
- const activeResultIndex = getActiveSearchResultIndex(
250
- rawResults,
251
- (position) => positionMap.pmToRuntime(position),
372
+ return searchDocument(
373
+ props.canonicalDocument,
252
374
  snapshot.selection,
375
+ snapshot.activeStory,
376
+ props.documentNavigation,
377
+ query,
378
+ options,
253
379
  );
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
- };
272
- });
273
380
  }
274
381
 
275
382
  function clearLiveSearch(): void {
@@ -288,37 +395,192 @@ export const TwProseMirrorSurface = forwardRef<
288
395
  ? "font-[family-name:var(--font-legal-sans)]"
289
396
  : "font-[family-name:var(--font-legal-serif)]";
290
397
 
398
+ // Story focus indicator — runtime-backed, not DOM-only
399
+ const storyKind = snapshot.activeStory.kind;
400
+ const storyFocusAttr = storyKind !== "main" ? storyKind : undefined;
401
+
402
+ // Table focus cue — add subtle ring when selection head is inside a table block
403
+ const tableFocusClass = (() => {
404
+ if (!surface || !snapshot.selection) return "";
405
+ const head = snapshot.selection.head;
406
+ const inTable = surface.blocks.some(
407
+ (b) => b.kind === "table" && head >= b.from && head <= b.to,
408
+ );
409
+ return inTable ? "prosemirror-table-focus" : "";
410
+ })();
411
+
412
+ const workspaceLabel = props.isPageWorkspace ? "Document page" : "Document canvas";
413
+
414
+ const selectionToolbarMeasurementKey = useMemo(
415
+ () => buildSelectionToolbarMeasurementKey(snapshot.selection, snapshot.activeStory),
416
+ [snapshot.activeStory, snapshot.selection],
417
+ );
418
+
419
+ const emitSelectionToolbarAnchor = useCallback((): void => {
420
+ const callback = props.onSelectionToolbarAnchorChange;
421
+ if (!callback) {
422
+ return;
423
+ }
424
+
425
+ const nextAnchor = measureSelectionToolbarAnchor();
426
+ const previous = lastSelectionToolbarMeasurementRef.current;
427
+ if (
428
+ previous.key === selectionToolbarMeasurementKey &&
429
+ selectionToolbarAnchorsEqual(previous.anchor, nextAnchor)
430
+ ) {
431
+ return;
432
+ }
433
+
434
+ lastSelectionToolbarMeasurementRef.current = {
435
+ key: selectionToolbarMeasurementKey,
436
+ anchor: nextAnchor,
437
+ };
438
+ callback(nextAnchor);
439
+ }, [
440
+ props.onSelectionToolbarAnchorChange,
441
+ selectionToolbarMeasurementKey,
442
+ snapshot.activeStory,
443
+ snapshot.selection,
444
+ ]);
445
+
446
+ const scheduleSelectionToolbarAnchorUpdate = useCallback((): void => {
447
+ const callback = props.onSelectionToolbarAnchorChange;
448
+ const mount = mountRef.current;
449
+ const win = mount?.ownerDocument.defaultView;
450
+ if (!callback || !win) {
451
+ emitSelectionToolbarAnchor();
452
+ return;
453
+ }
454
+
455
+ if (selectionToolbarFrameRef.current !== null) {
456
+ win.cancelAnimationFrame(selectionToolbarFrameRef.current);
457
+ selectionToolbarFrameRef.current = null;
458
+ }
459
+
460
+ selectionToolbarFrameRef.current = win.requestAnimationFrame(() => {
461
+ selectionToolbarFrameRef.current = null;
462
+ emitSelectionToolbarAnchor();
463
+ });
464
+ }, [emitSelectionToolbarAnchor, props.onSelectionToolbarAnchorChange]);
465
+
466
+ useEffect(() => {
467
+ scheduleSelectionToolbarAnchorUpdate();
468
+ }, [
469
+ scheduleSelectionToolbarAnchorUpdate,
470
+ snapshot.revisionToken,
471
+ snapshot.selection,
472
+ snapshot.surface,
473
+ props.isPageWorkspace,
474
+ ]);
475
+
476
+ useEffect(() => {
477
+ const mount = mountRef.current;
478
+ const callback = props.onSelectionToolbarAnchorChange;
479
+ if (!mount || !callback) {
480
+ return;
481
+ }
482
+
483
+ const updateAnchor = () => {
484
+ scheduleSelectionToolbarAnchorUpdate();
485
+ };
486
+ const scrollRoot = mount.closest<HTMLElement>("[data-wre-scroll-root='true']");
487
+ const win = mount.ownerDocument.defaultView;
488
+ const resizeObserver =
489
+ typeof ResizeObserver !== "undefined"
490
+ ? new ResizeObserver(() => {
491
+ updateAnchor();
492
+ })
493
+ : null;
494
+
495
+ updateAnchor();
496
+ scrollRoot?.addEventListener("scroll", updateAnchor, { passive: true });
497
+ win?.addEventListener("resize", updateAnchor);
498
+ resizeObserver?.observe(mount);
499
+ if (scrollRoot) {
500
+ resizeObserver?.observe(scrollRoot);
501
+ }
502
+
503
+ return () => {
504
+ scrollRoot?.removeEventListener("scroll", updateAnchor);
505
+ win?.removeEventListener("resize", updateAnchor);
506
+ resizeObserver?.disconnect();
507
+ };
508
+ }, [
509
+ props.onSelectionToolbarAnchorChange,
510
+ scheduleSelectionToolbarAnchorUpdate,
511
+ snapshot.revisionToken,
512
+ snapshot.selection,
513
+ ]);
514
+
515
+ useEffect(() => {
516
+ return () => {
517
+ lastSelectionToolbarMeasurementRef.current = {
518
+ key: null,
519
+ anchor: null,
520
+ };
521
+ props.onSelectionToolbarAnchorChange?.(null);
522
+ };
523
+ }, [props.onSelectionToolbarAnchorChange]);
524
+
291
525
  return (
292
- <section aria-label="Document canvas" className="min-w-0">
526
+ <section
527
+ aria-label={workspaceLabel}
528
+ className="min-w-0"
529
+ data-active-story={storyFocusAttr}
530
+ data-workspace={props.isPageWorkspace ? "page" : "canvas"}
531
+ >
293
532
  {/* ProseMirror mount point — document content including headings is editable */}
294
533
  {surface ? (
295
534
  <div
296
535
  ref={mountRef}
297
536
  role="textbox"
537
+ tabIndex={0}
298
538
  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>}
539
+ className={`px-12 py-10 ${fontClass} text-[15px] text-primary leading-relaxed prosemirror-surface outline-none ${tableFocusClass}`}
540
+ onFocus={(event) => {
541
+ onFocus(event);
542
+ if (event.target === event.currentTarget) {
543
+ viewRef.current?.focus();
544
+ }
545
+ }}
301
546
  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
- }
547
+ onKeyDown={(event) => {
548
+ if (event.target !== event.currentTarget) {
549
+ return;
312
550
  }
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
- }
551
+
552
+ switch (event.key) {
553
+ case "Backspace":
554
+ event.preventDefault();
555
+ props.onDeleteBackward?.();
556
+ return;
557
+ case "Delete":
558
+ event.preventDefault();
559
+ props.onDeleteForward?.();
560
+ return;
561
+ case "Enter":
562
+ event.preventDefault();
563
+ if (event.shiftKey) {
564
+ props.onInsertHardBreak?.();
565
+ } else {
566
+ props.onSplitParagraph?.();
567
+ }
568
+ return;
569
+ case "Tab":
570
+ event.preventDefault();
571
+ if (event.shiftKey) {
572
+ props.onOutdentTab?.();
573
+ } else {
574
+ props.onInsertTab?.();
575
+ }
576
+ return;
577
+ default:
578
+ return;
319
579
  }
320
580
  }}
321
581
  aria-label="Document surface"
582
+ data-wre-document-surface="true"
583
+ data-story-focus={storyFocusAttr}
322
584
  />
323
585
  ) : (
324
586
  <div className="px-12 pb-10">
@@ -337,27 +599,82 @@ export const TwProseMirrorSurface = forwardRef<
337
599
  ) : null}
338
600
  </section>
339
601
  );
602
+
603
+ function measureSelectionToolbarAnchor(): SelectionToolbarAnchor | null {
604
+ const callback = props.onSelectionToolbarAnchorChange;
605
+ const view = viewRef.current;
606
+ const mount = mountRef.current;
607
+ const positionMap = positionMapRef.current;
608
+ const range = snapshot.selection.activeRange;
609
+
610
+ if (!callback || !view || !mount || !positionMap || snapshot.selection.isCollapsed || range.kind !== "range") {
611
+ return null;
612
+ }
613
+
614
+ const rootRect = mount.getBoundingClientRect();
615
+ if (rootRect.width <= 0 || rootRect.height <= 0) {
616
+ return null;
617
+ }
618
+
619
+ try {
620
+ const pmFrom = positionMap.runtimeToPm(range.from);
621
+ const pmTo = positionMap.runtimeToPm(range.to);
622
+ const startRect = view.coordsAtPos(pmFrom);
623
+ const endRect = view.coordsAtPos(pmTo);
624
+ const left = Math.max(rootRect.left, Math.min(startRect.left, endRect.left));
625
+ const right = Math.min(rootRect.right, Math.max(startRect.right, endRect.right));
626
+ const top = Math.max(rootRect.top, Math.min(startRect.top, endRect.top));
627
+ const bottom = Math.min(rootRect.bottom, Math.max(startRect.bottom, endRect.bottom));
628
+
629
+ if (
630
+ !Number.isFinite(left) ||
631
+ !Number.isFinite(right) ||
632
+ !Number.isFinite(top) ||
633
+ !Number.isFinite(bottom) ||
634
+ right <= left ||
635
+ bottom <= top ||
636
+ bottom < rootRect.top ||
637
+ top > rootRect.bottom
638
+ ) {
639
+ return null;
640
+ }
641
+
642
+ return { left, right, top, bottom };
643
+ } catch {
644
+ return null;
645
+ }
646
+ }
340
647
  });
341
648
 
342
- function getActiveSearchResultIndex(
343
- results: Array<{ from: number; to: number }>,
344
- toRuntimePosition: (position: number) => number,
649
+ function buildSelectionToolbarMeasurementKey(
345
650
  selection: SelectionSnapshot,
346
- ): number {
347
- if (results.length === 0) {
348
- return -1;
651
+ activeStory: RuntimeRenderSnapshot["activeStory"],
652
+ ): string | null {
653
+ if (selection.isCollapsed || selection.activeRange.kind !== "range") {
654
+ return null;
349
655
  }
350
656
 
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;
657
+ return JSON.stringify({
658
+ story: activeStory,
659
+ from: selection.activeRange.from,
660
+ to: selection.activeRange.to,
360
661
  });
662
+ }
361
663
 
362
- return activeIndex >= 0 ? activeIndex : 0;
664
+ function selectionToolbarAnchorsEqual(
665
+ left: SelectionToolbarAnchor | null,
666
+ right: SelectionToolbarAnchor | null,
667
+ ): boolean {
668
+ if (left === right) {
669
+ return true;
670
+ }
671
+ if (!left || !right) {
672
+ return false;
673
+ }
674
+ return (
675
+ left.left === right.left &&
676
+ left.right === right.right &&
677
+ left.top === right.top &&
678
+ left.bottom === right.bottom
679
+ );
363
680
  }
@@ -350,7 +350,6 @@ export class TableCellNodeView {
350
350
  */
351
351
  export const tableNodeViews: { [node: string]: NodeViewConstructor } = {
352
352
  table: (node: PMNode) => new TableNodeView(node),
353
- table_row: (node: PMNode) => new TableRowNodeView(node),
354
353
  table_cell: (node: PMNode) => new TableCellNodeView(node),
355
354
  table_header_cell: (node: PMNode) => new TableCellNodeView(node),
356
355
  };
@@ -16,8 +16,9 @@ export { TwRevisionSidebar } from "./review/tw-revision-sidebar";
16
16
  export { TwHealthPanel } from "./review/tw-health-panel";
17
17
 
18
18
  // Toolbar
19
- export { TwToolbar, type TwToolbarProps, type ViewMode } from "./toolbar/tw-toolbar";
19
+ export { TwToolbar, type TwToolbarProps } from "./toolbar/tw-toolbar";
20
20
  export { TwToolbarIconButton } from "./toolbar/tw-toolbar-icon-button";
21
+ export type { WorkspaceMode, ZoomLevel } from "../api/public-types";
21
22
 
22
23
  // Status
23
24
  export { TwStatusBar } from "./status/tw-status-bar";