@beyondwork/docx-react-component 1.0.56 → 1.0.58

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 (113) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +330 -0
  4. package/src/compare/diff-engine.ts +3 -0
  5. package/src/core/commands/formatting-commands.ts +1 -0
  6. package/src/core/commands/index.ts +17 -11
  7. package/src/core/selection/mapping.ts +18 -1
  8. package/src/core/selection/review-anchors.ts +29 -18
  9. package/src/io/chart-preview-resolver.ts +175 -41
  10. package/src/io/docx-session.ts +57 -2
  11. package/src/io/export/serialize-main-document.ts +82 -0
  12. package/src/io/export/serialize-styles.ts +61 -3
  13. package/src/io/export/table-properties-xml.ts +19 -4
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-anchor.ts +182 -0
  16. package/src/io/ooxml/parse-drawing.ts +319 -0
  17. package/src/io/ooxml/parse-fields.ts +115 -2
  18. package/src/io/ooxml/parse-fill.ts +215 -0
  19. package/src/io/ooxml/parse-font-table.ts +190 -0
  20. package/src/io/ooxml/parse-footnotes.ts +52 -1
  21. package/src/io/ooxml/parse-main-document.ts +241 -1
  22. package/src/io/ooxml/parse-numbering.ts +96 -0
  23. package/src/io/ooxml/parse-picture.ts +158 -0
  24. package/src/io/ooxml/parse-settings.ts +34 -0
  25. package/src/io/ooxml/parse-shapes.ts +87 -0
  26. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  27. package/src/io/ooxml/parse-styles.ts +74 -1
  28. package/src/io/ooxml/parse-theme.ts +60 -0
  29. package/src/io/paste/html-clipboard.ts +449 -0
  30. package/src/io/paste/word-clipboard.ts +5 -1
  31. package/src/legal/_document-root.ts +26 -0
  32. package/src/legal/bookmarks.ts +4 -3
  33. package/src/legal/cross-references.ts +3 -2
  34. package/src/legal/defined-terms.ts +2 -1
  35. package/src/legal/signature-blocks.ts +2 -1
  36. package/src/model/canonical-document.ts +421 -3
  37. package/src/runtime/chart/chart-model-store.ts +73 -10
  38. package/src/runtime/document-runtime.ts +760 -41
  39. package/src/runtime/document-search.ts +61 -0
  40. package/src/runtime/edit-ops/index.ts +129 -0
  41. package/src/runtime/event-refresh-hints.ts +7 -0
  42. package/src/runtime/field-resolver.ts +341 -0
  43. package/src/runtime/footnote-resolver.ts +55 -0
  44. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  45. package/src/runtime/object-grab/index.ts +51 -0
  46. package/src/runtime/paragraph-style-resolver.ts +105 -0
  47. package/src/runtime/query-scopes.ts +186 -0
  48. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  49. package/src/runtime/scope-resolver.ts +60 -0
  50. package/src/runtime/selection/cursor-ops.ts +186 -15
  51. package/src/runtime/selection/index.ts +17 -1
  52. package/src/runtime/structure-ops/index.ts +77 -0
  53. package/src/runtime/styles-cascade.ts +33 -0
  54. package/src/runtime/surface-projection.ts +192 -12
  55. package/src/runtime/theme-color-resolver.ts +189 -44
  56. package/src/runtime/units.ts +46 -0
  57. package/src/runtime/view-state.ts +13 -2
  58. package/src/ui/WordReviewEditor.tsx +239 -11
  59. package/src/ui/editor-runtime-boundary.ts +97 -1
  60. package/src/ui/editor-shell-view.tsx +1 -1
  61. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  62. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  63. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  64. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  65. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  66. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  67. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  68. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  69. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  70. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  71. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  72. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  73. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  74. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  75. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  76. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  77. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  78. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  79. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  80. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  81. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +24 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +157 -0
  86. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  88. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  89. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  90. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  91. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  92. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  93. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  94. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  95. package/src/ui-tailwind/editor-surface/pm-schema.ts +214 -11
  96. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +32 -2
  97. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  98. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  99. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  100. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  101. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  102. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  103. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  104. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  105. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  106. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  107. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  108. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  109. package/src/ui-tailwind/theme/tokens.css +6 -0
  110. package/src/ui-tailwind/theme/tokens.ts +10 -0
  111. package/src/ui-tailwind/tw-review-workspace.tsx +23 -0
  112. package/src/validation/compatibility-engine.ts +2 -0
  113. package/src/validation/docx-comment-proof.ts +12 -3
@@ -74,12 +74,50 @@ export interface PageOverlayRect {
74
74
  export interface PageBoundaryMeasurement {
75
75
  prevPageId: string;
76
76
  nextPageId: string;
77
+ boundaryIndex?: number;
77
78
  /** Widget top edge = bottom of the previous page. */
78
79
  topPx: number;
79
80
  /** Widget bottom edge = top of the next page. */
80
81
  bottomPx: number;
81
82
  }
82
83
 
84
+ export interface VisiblePageIndexRange {
85
+ start: number;
86
+ end: number;
87
+ }
88
+
89
+ function normalizeVisiblePageIndexRange(
90
+ range: VisiblePageIndexRange | null | undefined,
91
+ pageCount: number,
92
+ ): VisiblePageIndexRange | null {
93
+ if (!range || pageCount <= 0) return null;
94
+ const start = Math.max(0, Math.min(range.start, pageCount));
95
+ const end = Math.max(start, Math.min(range.end, pageCount));
96
+ if (start >= end) return null;
97
+ return { start, end };
98
+ }
99
+
100
+ function collectBoundaryIndicesForVisibleRange(
101
+ range: VisiblePageIndexRange,
102
+ pageCount: number,
103
+ ): number[] {
104
+ if (pageCount <= 1) return [];
105
+ const startBoundaryIndex = Math.max(0, range.start - 1);
106
+ const endBoundaryIndex = Math.min(pageCount - 2, range.end - 1);
107
+ if (startBoundaryIndex > endBoundaryIndex) return [];
108
+ const indices: number[] = [];
109
+ for (let index = startBoundaryIndex; index <= endBoundaryIndex; index += 1) {
110
+ indices.push(index);
111
+ }
112
+ return indices;
113
+ }
114
+
115
+ function parsePageBoundaryIndex(prevPageId: string): number | undefined {
116
+ const match = /^page-(\d+)$/.exec(prevPageId);
117
+ if (!match) return undefined;
118
+ return Number.parseInt(match[1] ?? "", 10);
119
+ }
120
+
83
121
  /**
84
122
  * Pure helper: turn pre-measured page-boundary widget positions into
85
123
  * one `PageOverlayRect` per page. No DOM access — the caller supplies
@@ -104,6 +142,8 @@ export function resolvePageOverlayRects(
104
142
  pageCount: number;
105
143
  /** Total scroll-root height in the overlay's coordinate space. */
106
144
  scrollHeight: number;
145
+ /** Optional viewport-bounded page range. */
146
+ visiblePageIndexRange?: VisiblePageIndexRange | null;
107
147
  }
108
148
  // Legacy two-arg path preserved for backward compat. Walks
109
149
  // offsetTop chain inside the scroll-root; used by harness code
@@ -158,12 +198,26 @@ export function resolvePageOverlayRects(
158
198
  // emits widgets out-of-order (it doesn't today, but the cost is
159
199
  // negligible).
160
200
  const boundaries = [...widgets].sort((a, b) => a.topPx - b.topPx);
201
+ const normalizedVisiblePageIndexRange = Array.isArray(input)
202
+ ? null
203
+ : normalizeVisiblePageIndexRange(input.visiblePageIndexRange, pageCount);
204
+ const boundaryByIndex = new Map<number, PageBoundaryMeasurement>();
205
+ boundaries.forEach((boundary, index) => {
206
+ const boundaryIndex =
207
+ boundary.boundaryIndex ??
208
+ parsePageBoundaryIndex(boundary.prevPageId) ??
209
+ index;
210
+ boundaryByIndex.set(boundaryIndex, boundary);
211
+ });
212
+ const pageStart = normalizedVisiblePageIndexRange?.start ?? 0;
213
+ const pageEnd = normalizedVisiblePageIndexRange?.end ?? pageCount;
161
214
 
162
215
  const rects: PageOverlayRect[] = [];
163
- for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
164
- const boundaryBefore = pageIndex === 0 ? null : boundaries[pageIndex - 1];
216
+ for (let pageIndex = pageStart; pageIndex < pageEnd; pageIndex += 1) {
217
+ const boundaryBefore =
218
+ pageIndex === 0 ? null : (boundaryByIndex.get(pageIndex - 1) ?? null);
165
219
  const boundaryAfter =
166
- pageIndex === pageCount - 1 ? null : boundaries[pageIndex];
220
+ pageIndex === pageCount - 1 ? null : (boundaryByIndex.get(pageIndex) ?? null);
167
221
 
168
222
  let pageId: string | null = null;
169
223
  if (boundaryBefore) pageId = boundaryBefore.nextPageId;
@@ -204,14 +258,36 @@ export function resolvePageOverlayRects(
204
258
  * ancestor.
205
259
  */
206
260
  export function measureWidgetsViaBoundingRect(
207
- queryRoot: Pick<HTMLElement, "querySelectorAll"> | null,
261
+ queryRoot: Pick<HTMLElement, "querySelectorAll"> &
262
+ Partial<Pick<HTMLElement, "querySelector">> | null,
208
263
  originElement: HTMLElement | null,
264
+ options?: {
265
+ pageCount?: number;
266
+ visiblePageIndexRange?: VisiblePageIndexRange | null;
267
+ },
209
268
  ): PageBoundaryMeasurement[] {
210
269
  if (!queryRoot || !originElement) return [];
211
270
  const originRect = originElement.getBoundingClientRect();
212
- const widgets = Array.from(
213
- queryRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
271
+ const normalizedVisiblePageIndexRange = normalizeVisiblePageIndexRange(
272
+ options?.visiblePageIndexRange,
273
+ options?.pageCount ?? 0,
214
274
  );
275
+ const queryOne =
276
+ typeof queryRoot.querySelector === "function"
277
+ ? queryRoot.querySelector.bind(queryRoot)
278
+ : null;
279
+ const widgets = normalizedVisiblePageIndexRange && queryOne && options?.pageCount
280
+ ? collectBoundaryIndicesForVisibleRange(
281
+ normalizedVisiblePageIndexRange,
282
+ options.pageCount,
283
+ )
284
+ .map((boundaryIndex) =>
285
+ queryOne(`[data-page-frame-end="page-${boundaryIndex}"]`),
286
+ )
287
+ .filter((widget): widget is HTMLElement => widget !== null)
288
+ : Array.from(
289
+ queryRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
290
+ );
215
291
  const out: PageBoundaryMeasurement[] = [];
216
292
  for (const widget of widgets) {
217
293
  const prevPageId = widget.getAttribute("data-page-frame-end");
@@ -221,6 +297,7 @@ export function measureWidgetsViaBoundingRect(
221
297
  out.push({
222
298
  prevPageId,
223
299
  nextPageId,
300
+ boundaryIndex: parsePageBoundaryIndex(prevPageId),
224
301
  topPx: rect.top - originRect.top,
225
302
  bottomPx: rect.bottom - originRect.top,
226
303
  });
@@ -242,11 +319,33 @@ export function measureWidgetsViaBoundingRect(
242
319
  * "scroll root is often not the right origin", not about arithmetic.
243
320
  */
244
321
  export function measureWidgetsViaOffsetChain(
245
- scrollRoot: Pick<HTMLElement, "clientHeight" | "querySelectorAll">,
322
+ scrollRoot: Pick<HTMLElement, "clientHeight" | "querySelectorAll"> &
323
+ Partial<Pick<HTMLElement, "querySelector">>,
324
+ options?: {
325
+ pageCount?: number;
326
+ visiblePageIndexRange?: VisiblePageIndexRange | null;
327
+ },
246
328
  ): PageBoundaryMeasurement[] {
247
- const widgets = Array.from(
248
- scrollRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
329
+ const normalizedVisiblePageIndexRange = normalizeVisiblePageIndexRange(
330
+ options?.visiblePageIndexRange,
331
+ options?.pageCount ?? 0,
249
332
  );
333
+ const queryOne =
334
+ typeof scrollRoot.querySelector === "function"
335
+ ? scrollRoot.querySelector.bind(scrollRoot)
336
+ : null;
337
+ const widgets = normalizedVisiblePageIndexRange && queryOne && options?.pageCount
338
+ ? collectBoundaryIndicesForVisibleRange(
339
+ normalizedVisiblePageIndexRange,
340
+ options.pageCount,
341
+ )
342
+ .map((boundaryIndex) =>
343
+ queryOne(`[data-page-frame-end="page-${boundaryIndex}"]`),
344
+ )
345
+ .filter((widget): widget is HTMLElement => widget !== null)
346
+ : Array.from(
347
+ scrollRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
348
+ );
250
349
  const out: PageBoundaryMeasurement[] = [];
251
350
  for (const widget of widgets) {
252
351
  const prevPageId = widget.getAttribute("data-page-frame-end");
@@ -254,7 +353,13 @@ export function measureWidgetsViaOffsetChain(
254
353
  if (!prevPageId || !nextPageId) continue;
255
354
  const topPx = resolveOffsetTop(widget, scrollRoot);
256
355
  const bottomPx = topPx + resolveOffsetHeight(widget);
257
- out.push({ prevPageId, nextPageId, topPx, bottomPx });
356
+ out.push({
357
+ prevPageId,
358
+ nextPageId,
359
+ boundaryIndex: parsePageBoundaryIndex(prevPageId),
360
+ topPx,
361
+ bottomPx,
362
+ });
258
363
  }
259
364
  return out;
260
365
  }
@@ -297,6 +402,7 @@ export interface TwPageStackOverlayLayerProps {
297
402
  * time this changes so the overlays stay aligned with content.
298
403
  */
299
404
  renderFrameRevision: number;
405
+ visiblePageIndexRange?: VisiblePageIndexRange | null;
300
406
  /** Optional test id applied to the overlay root. */
301
407
  "data-testid"?: string;
302
408
  }
@@ -313,6 +419,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
313
419
  facet,
314
420
  scrollRoot,
315
421
  renderFrameRevision,
422
+ visiblePageIndexRange,
316
423
  "data-testid": testId,
317
424
  }) => {
318
425
  const [rects, setRects] = React.useState<readonly PageOverlayRect[]>([]);
@@ -351,7 +458,10 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
351
458
  const origin = overlayRootRef.current;
352
459
  const pageCount = facet.getPageCount();
353
460
  if (origin) {
354
- const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin);
461
+ const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
462
+ pageCount,
463
+ visiblePageIndexRange,
464
+ });
355
465
  const originRect = origin.getBoundingClientRect();
356
466
  setRects(
357
467
  resolvePageOverlayRects({
@@ -359,12 +469,24 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
359
469
  pageCount,
360
470
  scrollHeight:
361
471
  origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
472
+ visiblePageIndexRange,
362
473
  }),
363
474
  );
364
475
  } else {
365
- setRects(resolvePageOverlayRects([scrollRoot, pageCount]));
476
+ const widgets = measureWidgetsViaOffsetChain(scrollRoot, {
477
+ pageCount,
478
+ visiblePageIndexRange,
479
+ });
480
+ setRects(
481
+ resolvePageOverlayRects({
482
+ widgets,
483
+ pageCount,
484
+ scrollHeight: scrollRoot.clientHeight,
485
+ visiblePageIndexRange,
486
+ }),
487
+ );
366
488
  }
367
- }, [facet, scrollRoot]);
489
+ }, [facet, scrollRoot, visiblePageIndexRange]);
368
490
 
369
491
  const refreshRects = React.useCallback(() => {
370
492
  if (!scrollRoot) {
@@ -0,0 +1,51 @@
1
+ import * as React from "react";
2
+ import type { RenderBlockDecoration } from "../../runtime/render/render-frame-types";
3
+ import { AUTHOR_PALETTE } from "../../ui/headless/revision-decoration-model";
4
+
5
+ const BAR_WIDTH_PX = 3;
6
+ const BAR_LEFT_OFFSET_PX = 8;
7
+
8
+ export interface TwRevisionMarginBarLayerProps {
9
+ /** Decoration rects from `frame.decorationIndex.revisions`. */
10
+ revisionDecorations: readonly RenderBlockDecoration[];
11
+ /**
12
+ * Author palette index keyed by revisionId.
13
+ * Build from `RevisionDecorationEntry.authorPaletteIndex` before passing.
14
+ */
15
+ authorPaletteIndexById: ReadonlyMap<string, number>;
16
+ /** Left edge of the page body in overlay coordinates (px). */
17
+ pageBodyLeftPx: number;
18
+ }
19
+
20
+ export const TwRevisionMarginBarLayer = React.memo(function TwRevisionMarginBarLayer({
21
+ revisionDecorations,
22
+ authorPaletteIndexById,
23
+ pageBodyLeftPx,
24
+ }: TwRevisionMarginBarLayerProps) {
25
+ if (revisionDecorations.length === 0) return null;
26
+
27
+ return (
28
+ <>
29
+ {revisionDecorations.map((dec, idx) => {
30
+ const paletteIdx = authorPaletteIndexById.get(dec.refId) ?? 0;
31
+ const color = AUTHOR_PALETTE[paletteIdx % AUTHOR_PALETTE.length];
32
+ return (
33
+ <div
34
+ key={`rev-bar-${dec.refId}-${idx}`}
35
+ aria-hidden
36
+ style={{
37
+ position: "absolute",
38
+ top: dec.frame.topPx,
39
+ left: pageBodyLeftPx - BAR_LEFT_OFFSET_PX - BAR_WIDTH_PX,
40
+ width: BAR_WIDTH_PX,
41
+ height: dec.frame.heightPx,
42
+ backgroundColor: color,
43
+ borderRadius: 1,
44
+ pointerEvents: "none",
45
+ }}
46
+ />
47
+ );
48
+ })}
49
+ </>
50
+ );
51
+ });
@@ -114,10 +114,18 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
114
114
  const pinnedModel = pinnedScopeId
115
115
  ? models.find((m) => m.scopeId === pinnedScopeId) ?? null
116
116
  : null;
117
- if (pinnedScopeId && !pinnedModel) {
118
- // Async cleanup via effect to avoid setState during render.
119
- queueMicrotask(() => setPinnedScopeId(null));
120
- }
117
+
118
+ // R2.b when a pinned scope disappears from the model list (e.g. the
119
+ // host cleared the overlay), drop the pin. Must run in an effect, not
120
+ // inline in render: a render-phase setState call violates React's
121
+ // render-purity contract and can trigger "Maximum update depth
122
+ // exceeded" in React 18 concurrent mode where renders are retried
123
+ // before commit.
124
+ React.useEffect(() => {
125
+ if (pinnedScopeId && !models.find((m) => m.scopeId === pinnedScopeId)) {
126
+ setPinnedScopeId(null);
127
+ }
128
+ }, [pinnedScopeId, models]);
121
129
 
122
130
  const effectiveScopeId = pinnedModel ? pinnedScopeId : activeScopeId;
123
131
  if (!effectiveScopeId) return null;
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import * as React from "react";
23
+ import { X } from "lucide-react";
23
24
  import type {
24
25
  EditorRole,
25
26
  IssueMetadataValue,
@@ -148,13 +149,25 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
148
149
  const headerId = React.useId();
149
150
  const liveRegionId = React.useId();
150
151
 
151
- // --- Focus management ----------------------------------------------------
152
+ // Cached focusable list — populated on mount and on subtree mutations so
153
+ // Tab handler never calls getFocusable() per-keystroke (perf §7).
154
+ const focusablesRef = React.useRef<HTMLElement[]>([]);
155
+
156
+ // --- Focus management + focusable cache init ----------------------------
152
157
  React.useEffect(() => {
153
158
  const root = rootRef.current;
154
159
  if (!root) return undefined;
155
- const first = getFocusable(root)[0];
160
+
161
+ const refresh = () => { focusablesRef.current = getFocusable(root); };
162
+ refresh();
163
+
164
+ const observer = new MutationObserver(refresh);
165
+ observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["disabled", "hidden", "inert"] });
166
+
167
+ const first = focusablesRef.current[0];
156
168
  first?.focus();
157
- return undefined;
169
+
170
+ return () => observer.disconnect();
158
171
  }, []);
159
172
 
160
173
  // --- Escape + click-outside ---------------------------------------------
@@ -183,18 +196,17 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
183
196
  };
184
197
  }, [onClose, pinned]);
185
198
 
186
- // --- Focus trap ----------------------------------------------------------
199
+ // --- Focus trap (reads cached list — no DOM query per Tab) --------------
187
200
  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
188
201
  if (event.key !== "Tab") return;
189
- const root = rootRef.current;
190
- if (!root) return;
191
- const focusables = getFocusable(root);
202
+ const focusables = focusablesRef.current;
192
203
  if (focusables.length === 0) return;
193
204
  const first = focusables[0];
194
205
  const last = focusables[focusables.length - 1];
195
206
  const active = document.activeElement;
207
+ const root = rootRef.current;
196
208
  if (event.shiftKey) {
197
- if (active === first || !root.contains(active)) {
209
+ if (active === first || !root?.contains(active)) {
198
210
  event.preventDefault();
199
211
  last.focus();
200
212
  }
@@ -266,7 +278,7 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
266
278
  onClick={onClose}
267
279
  data-testid="scope-card-close"
268
280
  >
269
- ×
281
+ <X className="h-3.5 w-3.5" aria-hidden="true" />
270
282
  </button>
271
283
  </div>
272
284
  </div>
@@ -614,9 +626,17 @@ function formatRelative(isoString: string): string {
614
626
  function getFocusable(root: HTMLElement): HTMLElement[] {
615
627
  const selector =
616
628
  'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])';
617
- return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
618
- (el) => !el.hasAttribute("inert") && el.offsetParent !== null,
619
- );
629
+ return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter((el) => {
630
+ if (el.hasAttribute("inert")) return false;
631
+ // Walk the ancestor chain for visibility/display — tolerates display:contents.
632
+ let node: HTMLElement | null = el;
633
+ while (node && node !== root) {
634
+ const s = getComputedStyle(node);
635
+ if (s.display === "none" || s.visibility === "hidden") return false;
636
+ node = node.parentElement;
637
+ }
638
+ return true;
639
+ });
620
640
  }
621
641
 
622
642
  function posturePresentationLabel(posture: ScopeCardModel["posture"]): string {
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Lane 6d — Slice N8: TOC outline sidebar (P11.8).
3
+ *
4
+ * Renders the formal TOC field entries from `getTocSnapshot()` as a
5
+ * collapsible left panel. Unlike the heading-based nav panel (which
6
+ * uses `DocumentNavigationSnapshot.headings`), this sidebar reflects the
7
+ * actual `<w:sdt>` / `TOC` field content — complete with the document
8
+ * author's chosen heading levels, custom styles, and page numbers.
9
+ *
10
+ * Navigation: clicking an entry calls `onNavigate(entry)`. The host
11
+ * is responsible for resolving `entry.anchor` or `entry.bookmarkName`
12
+ * into a runtime scroll (typically via `runtime.setSelection` or
13
+ * `onNavigateHeading(entry.headingId)`).
14
+ *
15
+ * `status === "stale"` shows a banner so the reviewer knows the TOC
16
+ * needs a refresh before page numbers are reliable.
17
+ */
18
+
19
+ import React from "react";
20
+ import type { TocSnapshot, TocEntrySnapshot } from "../../api/public-types";
21
+
22
+ export interface TwTocOutlineSidebarProps {
23
+ /** `null` when the document has no TOC field. */
24
+ tocSnapshot: TocSnapshot | null;
25
+ /** Called when the user clicks a TOC entry. */
26
+ onNavigate: (entry: TocEntrySnapshot) => void;
27
+ /** Whether the sidebar is currently open. */
28
+ open: boolean;
29
+ /** Called when the close button is pressed. */
30
+ onClose: () => void;
31
+ }
32
+
33
+ /**
34
+ * Indentation step per outline level (px). Levels start at 1 so
35
+ * level-1 gets 0 extra indentation; level-2 gets 12 px, etc.
36
+ */
37
+ const INDENT_PX_PER_LEVEL = 12;
38
+
39
+ export function TwTocOutlineSidebar({
40
+ tocSnapshot,
41
+ onNavigate,
42
+ open,
43
+ onClose,
44
+ }: TwTocOutlineSidebarProps): React.ReactElement | null {
45
+ if (!open) return null;
46
+
47
+ const entries = tocSnapshot?.entries ?? [];
48
+ const isStale = tocSnapshot?.status === "stale";
49
+
50
+ return (
51
+ <aside
52
+ aria-label="Table of contents"
53
+ data-toc-sidebar=""
54
+ className="flex h-full w-56 shrink-0 flex-col border-r border-border bg-surface"
55
+ >
56
+ {/* Header */}
57
+ <div className="flex items-center justify-between border-b border-border px-3 py-2">
58
+ <span className="text-xs font-medium uppercase tracking-wider text-secondary">
59
+ Table of Contents
60
+ </span>
61
+ <button
62
+ type="button"
63
+ aria-label="Close table of contents"
64
+ data-toc-close=""
65
+ onClick={onClose}
66
+ className="inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface-hover"
67
+ >
68
+ {/* × */}
69
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
70
+ <path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
71
+ </svg>
72
+ </button>
73
+ </div>
74
+
75
+ {/* Stale banner */}
76
+ {isStale ? (
77
+ <div
78
+ role="status"
79
+ data-toc-stale=""
80
+ className="border-b border-border bg-[var(--color-warning-soft,theme(colors.amber.50))] px-3 py-1.5 text-[11px] text-[var(--color-warning,theme(colors.amber.700))]"
81
+ >
82
+ Table of contents may be out of date.
83
+ </div>
84
+ ) : null}
85
+
86
+ {/* Entry list */}
87
+ <nav
88
+ className="flex-1 overflow-y-auto px-2 py-2"
89
+ aria-label="Document outline"
90
+ >
91
+ {entries.length > 0 ? (
92
+ <ul className="space-y-px">
93
+ {entries.map((entry) => {
94
+ const indent = (entry.level - 1) * INDENT_PX_PER_LEVEL;
95
+ return (
96
+ <li key={entry.tocEntryId}>
97
+ <button
98
+ type="button"
99
+ data-toc-entry-id={entry.tocEntryId}
100
+ data-toc-level={entry.level}
101
+ className="flex w-full items-baseline gap-1 rounded-md px-2 py-1 text-left text-xs text-primary transition-colors hover:bg-surface-hover"
102
+ style={{ paddingLeft: `${8 + indent}px` }}
103
+ onClick={() => onNavigate(entry)}
104
+ >
105
+ <span className="flex-1 truncate">{entry.text}</span>
106
+ {entry.pageIndex != null ? (
107
+ <span
108
+ aria-label={`page ${entry.pageIndex + 1}`}
109
+ className="shrink-0 tabular-nums text-tertiary"
110
+ >
111
+ {entry.pageIndex + 1}
112
+ </span>
113
+ ) : null}
114
+ </button>
115
+ </li>
116
+ );
117
+ })}
118
+ </ul>
119
+ ) : tocSnapshot === null ? (
120
+ <p className="px-2 py-4 text-xs text-tertiary">
121
+ No table of contents found in this document.
122
+ </p>
123
+ ) : (
124
+ <p className="px-2 py-4 text-xs text-tertiary">
125
+ The table of contents is empty.
126
+ </p>
127
+ )}
128
+ </nav>
129
+ </aside>
130
+ );
131
+ }
132
+
133
+ export default TwTocOutlineSidebar;
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import React from "react";
15
+ import { flushSync } from "react-dom";
15
16
  import { createRoot, type Root } from "react-dom/client";
16
17
  import type { Node as PMNode } from "prosemirror-model";
17
18
  import type { NodeViewConstructor } from "prosemirror-view";
@@ -24,6 +25,7 @@ const DEFAULT_HEIGHT = 336;
24
25
  class ChartNodeViewInstance {
25
26
  readonly dom: HTMLElement;
26
27
  private _root: Root | null = null;
28
+ private _mountedChartId: string | null = null;
27
29
 
28
30
  constructor(node: PMNode) {
29
31
  this.dom = document.createElement("span");
@@ -33,41 +35,78 @@ class ChartNodeViewInstance {
33
35
  this._mount(node);
34
36
  }
35
37
 
38
+ /**
39
+ * Mount or update the React root to render the chart whose id lives in
40
+ * `node.attrs.parsedChartId`. Transitions handled explicitly:
41
+ *
42
+ * - id present, entry in store → create/reuse root, render ChartSurface.
43
+ * - id present, entry missing → unmount any prior root (don't leave a
44
+ * stale chart from a previous id mounted).
45
+ * - id null / undefined → unmount any prior root (no chart).
46
+ *
47
+ * The previous implementation handled only the first case and silently
48
+ * left the prior React tree mounted on null / missing-entry transitions.
49
+ */
36
50
  private _mount(node: PMNode): void {
37
- const parsedChartId = node.attrs.parsedChartId as string | null;
38
- if (!parsedChartId) return;
51
+ const parsedChartId = (node.attrs.parsedChartId as string | null) ?? null;
52
+
53
+ if (!parsedChartId) {
54
+ this._unmountRoot();
55
+ return;
56
+ }
39
57
 
40
58
  const entry = chartModelStore.get(parsedChartId);
41
- if (!entry) return;
59
+ if (!entry) {
60
+ this._unmountRoot();
61
+ return;
62
+ }
42
63
 
43
64
  const width = (node.attrs.widthPx as number | null) ?? entry.widthPx ?? DEFAULT_WIDTH;
44
65
  const height = (node.attrs.heightPx as number | null) ?? entry.heightPx ?? DEFAULT_HEIGHT;
66
+ const previewMediaId = node.attrs.previewMediaId as string | null;
67
+ const previewSrc = node.attrs.previewSrc as string | null;
45
68
 
46
69
  const el = React.createElement(ChartSurface, {
47
70
  model: entry.model,
48
71
  width,
49
72
  height,
50
73
  theme: entry.theme,
74
+ ...(previewMediaId ? { previewMediaId } : {}),
75
+ ...(previewMediaId && previewSrc
76
+ ? {
77
+ resolveMediaUrl: (mediaId: string) =>
78
+ mediaId === previewMediaId ? previewSrc : undefined,
79
+ }
80
+ : {}),
51
81
  });
52
82
 
53
83
  if (!this._root) {
54
84
  this._root = createRoot(this.dom);
55
85
  }
56
- this._root.render(el);
86
+ flushSync(() => {
87
+ this._root?.render(el);
88
+ });
89
+ this._mountedChartId = parsedChartId;
90
+ }
91
+
92
+ private _unmountRoot(): void {
93
+ if (this._root) {
94
+ this._root.unmount();
95
+ this._root = null;
96
+ }
97
+ this._mountedChartId = null;
57
98
  }
58
99
 
59
100
  update(node: PMNode): boolean {
60
- const parsedChartId = node.attrs.parsedChartId as string | null;
61
- if (!parsedChartId) return false;
101
+ // Handle every `parsedChartId` transition in-place so PM doesn't
102
+ // destroy/recreate this NodeView on attr-only changes. Returning true
103
+ // tells PM "I handled this update."
62
104
  this._mount(node);
63
105
  return true;
64
106
  }
65
107
 
66
108
  destroy(): void {
67
- if (this._root) {
68
- this._root.unmount();
69
- this._root = null;
70
- }
109
+ this._unmountRoot();
71
110
  }
72
111
 
73
112
  stopEvent(): boolean {