@beyondwork/docx-react-component 1.0.52 → 1.0.54

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 (103) hide show
  1. package/package.json +31 -40
  2. package/src/api/public-types.ts +67 -7
  3. package/src/io/chart-preview-resolver.ts +41 -0
  4. package/src/io/docx-session.ts +217 -23
  5. package/src/runtime/collab/checkpoint-store.ts +1 -1
  6. package/src/runtime/collab/event-types.ts +4 -0
  7. package/src/runtime/collab/runtime-collab-sync.ts +88 -8
  8. package/src/runtime/document-runtime.ts +182 -9
  9. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  10. package/src/runtime/layout/layout-engine-version.ts +97 -2
  11. package/src/runtime/layout/layout-invalidation.ts +150 -30
  12. package/src/runtime/layout/page-graph.ts +19 -0
  13. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  14. package/src/runtime/layout/project-block-fragments.ts +27 -0
  15. package/src/runtime/layout/public-facet.ts +70 -1
  16. package/src/runtime/prerender/cache-envelope.ts +30 -0
  17. package/src/runtime/prerender/customxml-cache.ts +17 -3
  18. package/src/runtime/prerender/prerender-document.ts +17 -1
  19. package/src/runtime/render/render-frame-diff.ts +38 -2
  20. package/src/runtime/render/render-kernel.ts +67 -19
  21. package/src/runtime/surface-projection.ts +28 -0
  22. package/src/runtime/table-schema.ts +27 -0
  23. package/src/runtime/table-style-resolver.ts +51 -0
  24. package/src/ui/WordReviewEditor.tsx +6 -3
  25. package/src/ui/editor-runtime-boundary.ts +39 -2
  26. package/src/ui/headless/comment-decoration-model.ts +60 -5
  27. package/src/ui/headless/revision-decoration-model.ts +94 -6
  28. package/src/ui/shared/revision-filters.ts +16 -6
  29. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  30. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  31. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  32. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  33. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  34. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  35. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  36. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  37. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  38. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  39. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  40. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  41. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  42. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  43. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  44. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  45. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  46. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  47. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  48. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  49. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  50. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  51. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  52. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  53. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  54. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  55. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  56. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  57. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  58. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  59. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  60. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  61. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  62. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  63. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  64. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  65. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  66. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  67. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  68. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  69. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  70. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  71. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  72. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  73. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  74. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  75. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
  78. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  79. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
  80. package/src/ui-tailwind/index.ts +11 -0
  81. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +274 -0
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +15 -2
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +15 -2
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +19 -147
  86. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  87. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  88. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  89. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  90. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  91. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  92. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  93. package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
  94. package/src/ui-tailwind/theme/editor-theme.css +275 -46
  95. package/src/ui-tailwind/theme/tokens.css +345 -0
  96. package/src/ui-tailwind/theme/tokens.ts +313 -0
  97. package/src/ui-tailwind/theme/use-density.ts +60 -0
  98. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  99. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  100. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  101. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  102. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  103. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -97,10 +97,17 @@ const MODE_OPTIONS: ReadonlyArray<{ mode: WorkflowScopeMode; label: string }> =
97
97
  ];
98
98
 
99
99
  const SEVERITY_COLOR: Record<IssueSeverity, string> = {
100
- low: "var(--color-secondary)",
101
- medium: "var(--color-warning)",
102
- high: "var(--color-warning)",
103
- blocker: "var(--color-danger)",
100
+ low: "var(--color-semantic-info)",
101
+ medium: "var(--color-semantic-warning)",
102
+ high: "var(--color-semantic-error)",
103
+ blocker: "var(--color-semantic-error)",
104
+ };
105
+
106
+ const SEVERITY_SOFT: Record<IssueSeverity, string> = {
107
+ low: "var(--color-semantic-info-soft)",
108
+ medium: "var(--color-semantic-warning-soft)",
109
+ high: "var(--color-semantic-error-soft)",
110
+ blocker: "var(--color-semantic-error-soft)",
104
111
  };
105
112
 
106
113
  const SEVERITY_LABEL: Record<IssueSeverity, string> = {
@@ -151,14 +158,18 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
151
158
  }, []);
152
159
 
153
160
  // --- Escape + click-outside ---------------------------------------------
161
+ // Pinned cards survive Escape and click-outside; only unpinned cards
162
+ // are dismissed by those gestures.
154
163
  React.useEffect(() => {
155
164
  const onKey = (event: KeyboardEvent) => {
156
165
  if (event.key === "Escape") {
166
+ if (pinned) return; // pinned cards are immune to Escape
157
167
  event.stopPropagation();
158
168
  onClose();
159
169
  }
160
170
  };
161
171
  const onPointerDown = (event: PointerEvent) => {
172
+ if (pinned) return; // pinned cards are immune to click-outside
162
173
  const root = rootRef.current;
163
174
  if (!root) return;
164
175
  if (event.target instanceof Node && root.contains(event.target)) return;
@@ -170,7 +181,7 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
170
181
  window.removeEventListener("keydown", onKey, true);
171
182
  window.removeEventListener("pointerdown", onPointerDown, true);
172
183
  };
173
- }, [onClose]);
184
+ }, [onClose, pinned]);
174
185
 
175
186
  // --- Focus trap ----------------------------------------------------------
176
187
  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
@@ -201,7 +212,8 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
201
212
  return (
202
213
  <div
203
214
  ref={rootRef}
204
- className="wre-scope-card pointer-events-auto absolute flex w-80 max-w-[22rem] flex-col gap-2 rounded-lg border border-border bg-canvas p-3 text-sm shadow-[var(--shadow-float)]"
215
+ className="wre-scope-card pointer-events-auto absolute flex min-w-[320px] max-w-[500px] flex-col gap-2 rounded-[var(--radius-xl)] border border-[var(--color-border-default)] bg-[var(--color-bg-canvas)] p-[14px] text-sm shadow-[var(--shadow-float)]"
216
+ style={{ padding: "calc(14px * var(--space-density-multiplier))" }}
205
217
  role="dialog"
206
218
  aria-modal="false"
207
219
  aria-labelledby={headerId}
@@ -215,7 +227,7 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
215
227
  <div className="flex items-center justify-between gap-2">
216
228
  <div
217
229
  id={headerId}
218
- className="flex min-w-0 flex-1 items-center gap-2 text-xs font-medium text-primary"
230
+ className="flex min-w-0 flex-1 items-center gap-2 text-xs font-medium text-[var(--color-text-primary)]"
219
231
  >
220
232
  <span
221
233
  className={`wre-scope-rail-icon wre-scope-rail-icon-${posturePresentationIcon(model.posture)}`}
@@ -226,7 +238,7 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
226
238
  {postureLabel}
227
239
  </span>
228
240
  {model.label ? (
229
- <span className="truncate text-tertiary">· {model.label}</span>
241
+ <span className="truncate text-[var(--color-text-tertiary)]">· {model.label}</span>
230
242
  ) : null}
231
243
  </div>
232
244
  <div className="flex items-center gap-1">
@@ -237,8 +249,8 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
237
249
  aria-pressed={pinned ? "true" : "false"}
238
250
  className={`flex h-6 w-6 items-center justify-center rounded-sm text-[10px] font-semibold uppercase tracking-[0.06em] transition-colors ${
239
251
  pinned
240
- ? "bg-surface text-accent"
241
- : "text-tertiary hover:bg-surface hover:text-primary"
252
+ ? "bg-surface text-[var(--color-accent-primary)]"
253
+ : "text-[var(--color-text-tertiary)] hover:bg-surface hover:text-[var(--color-text-primary)]"
242
254
  }`}
243
255
  onClick={onTogglePin}
244
256
  data-testid="scope-card-pin"
@@ -264,7 +276,7 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
264
276
  <div
265
277
  role="group"
266
278
  aria-label="Scope mode"
267
- className="flex gap-1 rounded-md border border-border bg-surface p-0.5"
279
+ className="flex items-center gap-0 border-b border-[var(--color-border-default)]"
268
280
  >
269
281
  {MODE_OPTIONS.map(({ mode, label }) => {
270
282
  const active = model.posture === mode;
@@ -273,10 +285,10 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
273
285
  key={mode}
274
286
  type="button"
275
287
  aria-pressed={active ? "true" : "false"}
276
- className={`flex-1 rounded-sm px-2 py-1 text-xs font-medium transition-colors ${
288
+ className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
277
289
  active
278
- ? "bg-canvas text-primary shadow-sm"
279
- : "text-secondary hover:bg-canvas hover:text-primary"
290
+ ? "border-[var(--color-accent-primary)] text-[var(--color-text-primary)]"
291
+ : "border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
280
292
  }`}
281
293
  onClick={() => onModeChange(mode)}
282
294
  data-testid={`scope-card-mode-${mode}`}
@@ -382,10 +394,13 @@ const IssueRow: React.FC<{
382
394
  <div className="flex items-center gap-1.5 text-[11px]">
383
395
  <span
384
396
  aria-hidden="true"
385
- className="inline-block h-2 w-2 rounded-full"
386
- style={{ background: SEVERITY_COLOR[issue.severity] }}
387
- />
388
- <span className="font-semibold text-primary">
397
+ className="inline-flex items-center rounded-[var(--radius-sm)] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
398
+ style={{
399
+ color: SEVERITY_COLOR[issue.severity],
400
+ backgroundColor: SEVERITY_SOFT[issue.severity],
401
+ }}
402
+ data-testid="scope-card-severity-badge"
403
+ >
389
404
  {SEVERITY_LABEL[issue.severity]}
390
405
  </span>
391
406
  {issue.owner ? (
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Renders repeated header rows for a multi-page table on a continuation page.
3
+ *
4
+ * Architecture: one PM view → one `<table>` DOM element per table. NodeView
5
+ * cannot clip rows by page, so header repetition on continuation pages must
6
+ * come from a chrome overlay. This component renders an absolutely-positioned
7
+ * `<table>` (header rows only) at the Y-offset of the table's first row on
8
+ * this page, computed from `facet.getTableBodyYOffsetOnPage`. Visual-only
9
+ * (`aria-hidden`, `pointer-events: none`); no effect on PM doc structure.
10
+ */
11
+
12
+ import React from "react";
13
+ import type { SurfaceTableRowSnapshot } from "../../api/public-types.ts";
14
+ import type { WordReviewEditorLayoutFacet } from "../../runtime/layout/public-facet.ts";
15
+
16
+ export interface TwTableContinuationHeaderProps {
17
+ blockId: string;
18
+ pageIndex: number;
19
+ facet: WordReviewEditorLayoutFacet;
20
+ /** Pre-resolved header rows (from `SurfaceTableBlock.rows[ref.sourceRowIndex]`). */
21
+ headerRows: readonly SurfaceTableRowSnapshot[];
22
+ /** px-per-twip conversion factor from the page layout. */
23
+ pxPerTwip: number;
24
+ /** Top edge of the page body in px (absolute within the page frame). */
25
+ bodyOriginTopPx: number;
26
+ }
27
+
28
+ function TwTableContinuationHeaderInner({
29
+ blockId,
30
+ pageIndex,
31
+ facet,
32
+ headerRows,
33
+ pxPerTwip,
34
+ bodyOriginTopPx,
35
+ }: TwTableContinuationHeaderProps): React.ReactElement | null {
36
+ const yOffsetTwips = facet.getTableBodyYOffsetOnPage(blockId, pageIndex);
37
+ if (yOffsetTwips === null || headerRows.length === 0) return null;
38
+
39
+ const topPx = bodyOriginTopPx + yOffsetTwips * pxPerTwip;
40
+
41
+ return (
42
+ <div
43
+ aria-hidden
44
+ style={{
45
+ position: "absolute",
46
+ top: `${topPx}px`,
47
+ left: 0,
48
+ right: 0,
49
+ pointerEvents: "none",
50
+ overflow: "hidden",
51
+ }}
52
+ >
53
+ <table style={{ width: "100%", borderCollapse: "collapse" }}>
54
+ <tbody>
55
+ {headerRows.map((row, ri) => (
56
+ <tr key={ri}>
57
+ {row.cells.map((cell, ci) => (
58
+ <td
59
+ key={ci}
60
+ colSpan={cell.colspan ?? 1}
61
+ style={{
62
+ backgroundColor: cell.backgroundColor ?? undefined,
63
+ verticalAlign: cell.verticalAlign ?? "top",
64
+ borderTop: cell.borderTop ?? undefined,
65
+ borderRight: cell.borderRight ?? undefined,
66
+ borderBottom: cell.borderBottom ?? undefined,
67
+ borderLeft: cell.borderLeft ?? undefined,
68
+ padding: "2px 4px",
69
+ }}
70
+ >
71
+ {cell.content
72
+ .filter((b) => b.kind === "paragraph")
73
+ .map((b, bi) =>
74
+ b.kind === "paragraph" ? (
75
+ <span key={bi}>
76
+ {b.segments
77
+ .map((s) => ("text" in s ? s.text : ""))
78
+ .join("")}
79
+ </span>
80
+ ) : null,
81
+ )}
82
+ </td>
83
+ ))}
84
+ </tr>
85
+ ))}
86
+ </tbody>
87
+ </table>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ export const TwTableContinuationHeader = React.memo(
93
+ TwTableContinuationHeaderInner,
94
+ );
@@ -6,6 +6,7 @@ export type PerfProbeKind =
6
6
  | "selection"
7
7
  | "runtime.create"
8
8
  | "loadSession.laycacheProbe"
9
+ | "loadSession.compatReportCached"
9
10
  | "snapshot.surface"
10
11
  | "snapshot.compatibility"
11
12
  | "snapshot.navigation"
@@ -308,24 +308,37 @@ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
308
308
  line.style.right = "0";
309
309
  line.style.top = `${Math.round(input.interGapPx / 2)}px`;
310
310
  line.style.height = "0";
311
- line.style.borderTop = "1px dotted var(--color-border, rgba(0,0,0,0.3))";
311
+ line.style.borderTop = "1px dotted var(--color-border-default)";
312
312
  root.appendChild(line);
313
313
 
314
+ // L6d.U2: badge is now a true pill — `--radius-pill` geometry,
315
+ // hairline `--color-border-default` border, subtle `--shadow-soft`
316
+ // so the callout reads as a card floating over the seam line rather
317
+ // than text painted on top of it. The pill is 18 px tall (10 px
318
+ // font + 8 px vertical padding + hairline border); re-center so the
319
+ // seam line bisects the pill vertically.
320
+ const PILL_HEIGHT_PX = 18;
314
321
  const badge = document.createElement("span");
315
322
  badge.className = "wre-page-chrome-canvas-badge";
316
323
  badge.setAttribute("data-kind", "canvas-seam-badge");
324
+ badge.setAttribute("data-variant", "pill");
317
325
  badge.textContent = input.nextPageLabel;
318
326
  badge.style.position = "absolute";
319
- badge.style.top = `${Math.round(input.interGapPx / 2) - 9}px`;
327
+ badge.style.top = `${Math.round(input.interGapPx / 2) - Math.round(PILL_HEIGHT_PX / 2)}px`;
320
328
  badge.style.left = "50%";
321
329
  badge.style.transform = "translateX(-50%)";
330
+ badge.style.display = "inline-flex";
331
+ badge.style.alignItems = "center";
332
+ badge.style.height = `${PILL_HEIGHT_PX}px`;
333
+ badge.style.padding = "0 10px";
322
334
  badge.style.fontSize = "10px";
323
335
  badge.style.letterSpacing = "0.12em";
324
336
  badge.style.textTransform = "uppercase";
325
- badge.style.color = "var(--color-text-tertiary, #6b7280)";
326
- badge.style.backgroundColor =
327
- "var(--color-surface, rgba(255,255,255,0.9))";
328
- badge.style.padding = "0 8px";
337
+ badge.style.color = "var(--color-text-tertiary)";
338
+ badge.style.backgroundColor = "var(--color-surface)";
339
+ badge.style.border = "1px solid var(--color-border-default)";
340
+ badge.style.borderRadius = "var(--radius-pill)";
341
+ badge.style.boxShadow = "var(--shadow-soft)";
329
342
  root.appendChild(badge);
330
343
  return root;
331
344
  }
@@ -360,7 +373,7 @@ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
360
373
  label.style.fontSize = "10px";
361
374
  label.style.letterSpacing = "0.12em";
362
375
  label.style.textTransform = "uppercase";
363
- label.style.color = "var(--color-tertiary, #6b7280)";
376
+ label.style.color = "var(--color-text-tertiary)";
364
377
  // Intentionally no background, no border, no padding — the label floats
365
378
  // transparently in the workspace-canvas gap.
366
379
  root.appendChild(label);
@@ -519,6 +519,8 @@ function buildTable(
519
519
  borderRight: cell.borderRight ?? null,
520
520
  borderBottom: cell.borderBottom ?? null,
521
521
  borderLeft: cell.borderLeft ?? null,
522
+ // R3.a Phase 2 — per-cell text-flow direction.
523
+ textDirection: cell.textDirection ?? null,
522
524
  bandClasses: cell.bandClasses ?? null,
523
525
  },
524
526
  Fragment.from(cellContent),
@@ -536,6 +538,20 @@ function buildTable(
536
538
  Fragment.from(cells),
537
539
  ));
538
540
  }
541
+ // R3.a Phase 2 — convert each typed BorderSpec on `tableResolved.borders`
542
+ // into a CSS shorthand string ("1px solid #000000") so the schema attrs
543
+ // carry render-ready values. The four outer sides are applied as inline
544
+ // styles by the node-view; insideH / insideV ride along for round-trip
545
+ // and future use but do not change CSS at the table level (the per-cell
546
+ // border resolver already paints them on each cell).
547
+ const tr = block.tableResolved;
548
+ const tableBorderTop = tr?.borders?.top ? borderSpecToCss(tr.borders.top) : null;
549
+ const tableBorderRight = tr?.borders?.right ? borderSpecToCss(tr.borders.right) : null;
550
+ const tableBorderBottom = tr?.borders?.bottom ? borderSpecToCss(tr.borders.bottom) : null;
551
+ const tableBorderLeft = tr?.borders?.left ? borderSpecToCss(tr.borders.left) : null;
552
+ const tableBorderInsideH = tr?.borders?.insideH ? borderSpecToCss(tr.borders.insideH) : null;
553
+ const tableBorderInsideV = tr?.borders?.insideV ? borderSpecToCss(tr.borders.insideV) : null;
554
+
539
555
  return editorSchema.nodes.table.create(
540
556
  {
541
557
  styleId: block.styleId ?? null,
@@ -548,11 +564,49 @@ function buildTable(
548
564
  tblLookNoHBand: block.tblLook?.noHBand ?? false,
549
565
  tblLookNoVBand: block.tblLook?.noVBand ?? false,
550
566
  tblLookVal: block.tblLook?.val ?? null,
567
+ // R3.a Phase 2 — table-level resolved properties.
568
+ tableWidth: tr?.width ?? null,
569
+ tableWidthType: tr?.widthType ?? null,
570
+ tableLayoutMode: tr?.layoutMode ?? null,
571
+ tableCellSpacing: tr?.cellSpacing ?? null,
572
+ tableCellSpacingType: tr?.cellSpacingType ?? null,
573
+ tableBorderTop,
574
+ tableBorderRight,
575
+ tableBorderBottom,
576
+ tableBorderLeft,
577
+ tableBorderInsideH,
578
+ tableBorderInsideV,
551
579
  },
552
580
  Fragment.from(rows),
553
581
  );
554
582
  }
555
583
 
584
+ /**
585
+ * R3.a Phase 2 — convert a parsed OOXML `BorderSpec` shape (size in eighths of
586
+ * a point, color hex without `#`, value like "single"/"double"/"dashed") to a
587
+ * CSS shorthand string. Mirrors the cell-side helper at
588
+ * `src/runtime/surface-projection.ts:506` so cell + table borders look the
589
+ * same. Returns `null` for absent / "none" / "nil" specs so callers can stamp
590
+ * `null` cleanly into PM attrs.
591
+ */
592
+ function borderSpecToCss(
593
+ spec: { value?: string; size?: number; color?: string } | null | undefined,
594
+ ): string | null {
595
+ if (!spec) return null;
596
+ if (spec.value === "none" || spec.value === "nil") return null;
597
+ const width = spec.size ? `${Math.max(1, Math.round(spec.size / 8))}px` : "1px";
598
+ const style =
599
+ spec.value === "double"
600
+ ? "double"
601
+ : spec.value === "dashed" || spec.value === "dashSmallGap"
602
+ ? "dashed"
603
+ : spec.value === "dotted"
604
+ ? "dotted"
605
+ : "solid";
606
+ const color = spec.color && spec.color !== "auto" ? `#${spec.color}` : "currentColor";
607
+ return `${width} ${style} ${color}`;
608
+ }
609
+
556
610
  function buildSdtBlock(
557
611
  block: Extract<SurfaceBlockSnapshot, { kind: "sdt_block" }>,
558
612
  mediaPreviews: Record<string, MediaPreviewDescriptor>,
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Lane 6d — Slice U3: workspace-mode scroll anchoring.
3
+ *
4
+ * Capturing a scroll anchor before toggling `workspaceMode` between
5
+ * `canvas` and `page` — then restoring it after the new chrome has
6
+ * painted — keeps the user's viewport over the same paragraph even
7
+ * though the page-break widgets add ~112 px of vertical spacer per
8
+ * boundary in page mode.
9
+ *
10
+ * The anchor is keyed on `data-block-id` rather than pixel offset so
11
+ * it survives arbitrary height changes across the toggle. Each helper
12
+ * does a single DOM read or a single `scrollTop` write — no mutation
13
+ * observers, no rAF loops — so the inverted-PM contract is preserved.
14
+ */
15
+
16
+ export interface ScrollAnchor {
17
+ /** `data-block-id` of the topmost block straddling or below scrollTop. */
18
+ blockId: string;
19
+ /**
20
+ * Offset of the viewport's top edge within that block's rect, in CSS
21
+ * pixels. `restoreScrollAnchor` adds this back so the block's
22
+ * leading edge lands in the same relative spot after the toggle.
23
+ */
24
+ offsetWithinBlock: number;
25
+ }
26
+
27
+ /**
28
+ * Find the scroll anchor for `root` — the scroll container whose
29
+ * `scrollTop` / `getBoundingClientRect` we will read.
30
+ *
31
+ * Returns `null` when the root has no `[data-block-id]` descendants or
32
+ * when the root itself has zero height (e.g. disconnected during the
33
+ * toggle).
34
+ */
35
+ export function findScrollAnchor(root: HTMLElement | null): ScrollAnchor | null {
36
+ if (!root) return null;
37
+ const blocks = root.querySelectorAll<HTMLElement>("[data-block-id]");
38
+ if (blocks.length === 0) return null;
39
+ const rootRect = root.getBoundingClientRect();
40
+ const rootTop = rootRect.top;
41
+ // Walk blocks in document order; pick the first one whose bottom is
42
+ // >= the scroll root's top edge. That's the topmost block at least
43
+ // partially visible (or the one just below when the viewport sits in
44
+ // a gap between blocks).
45
+ for (const block of blocks) {
46
+ const rect = block.getBoundingClientRect();
47
+ if (rect.bottom < rootTop) continue;
48
+ const blockId = block.getAttribute("data-block-id");
49
+ if (!blockId) continue;
50
+ return {
51
+ blockId,
52
+ offsetWithinBlock: rootTop - rect.top,
53
+ };
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Restore an earlier anchor by finding the same `data-block-id` under
60
+ * `root` and adjusting `scrollTop` so the block's leading edge sits at
61
+ * the same offset relative to the viewport.
62
+ *
63
+ * Graceful no-op when the anchor's block no longer exists (deletion
64
+ * mid-toggle, doc swap) or when the new root is empty.
65
+ */
66
+ export function restoreScrollAnchor(
67
+ root: HTMLElement | null,
68
+ anchor: ScrollAnchor | null,
69
+ ): void {
70
+ if (!root || !anchor) return;
71
+ const selector = `[data-block-id="${cssEscape(anchor.blockId)}"]`;
72
+ const block = root.querySelector<HTMLElement>(selector);
73
+ if (!block) return;
74
+ const rootRect = root.getBoundingClientRect();
75
+ const blockRect = block.getBoundingClientRect();
76
+ // We want, post-restore, `blockRect.top === rootRect.top - offsetWithinBlock`
77
+ // (i.e. the block's leading edge sits `offsetWithinBlock` px above the
78
+ // viewport top because the viewport sat that far INTO the block at
79
+ // capture time). Scrolling by `delta` shifts rects by `-delta`, so
80
+ // solve for delta: delta = blockRect.top - rootRect.top + offsetWithinBlock.
81
+ const delta = blockRect.top - rootRect.top + anchor.offsetWithinBlock;
82
+ root.scrollTop = root.scrollTop + delta;
83
+ }
84
+
85
+ /**
86
+ * Minimal CSS.escape shim for attribute selectors — `data-block-id`
87
+ * values include UUIDs or runtime-offset strings, so escape anything
88
+ * that isn't a safe word-char. Avoids depending on DOM `CSS.escape`
89
+ * which may be absent under some JSDOM builds.
90
+ */
91
+ function cssEscape(value: string): string {
92
+ return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`);
93
+ }
@@ -282,11 +282,33 @@ function requestTableLayoutSync(start: HTMLElement): void {
282
282
  table?.dispatchEvent(new Event(TABLE_LAYOUT_SYNC_EVENT));
283
283
  }
284
284
 
285
+ // R3.a Phase 2 — single source of truth for the inline style properties
286
+ // `applyTableAttrs` writes back. Adding a new managed property requires only
287
+ // adding it here (and the matching conditional assignment below); the reset
288
+ // loop will pick it up automatically so stale values can never persist
289
+ // across PM snapshot updates.
290
+ const R3A_MANAGED_TABLE_STYLES = [
291
+ "marginLeft",
292
+ "marginRight",
293
+ "width",
294
+ "tableLayout",
295
+ "borderCollapse",
296
+ "borderSpacing",
297
+ "borderTop",
298
+ "borderRight",
299
+ "borderBottom",
300
+ "borderLeft",
301
+ ] as const;
302
+
285
303
  function applyTableAttrs(table: HTMLTableElement, node: PMNode): void {
286
- table.className = "border-collapse w-full my-2 text-sm";
304
+ // Reset every R3.a-managed inline style first so re-application doesn't
305
+ // accumulate stale values across PM updates (e.g. width changes, layout
306
+ // mode flips). The base Tailwind className is rebuilt below to match.
287
307
  table.setAttribute("data-pm-table-root", "true");
288
- table.style.marginLeft = "";
289
- table.style.marginRight = "";
308
+ for (const prop of R3A_MANAGED_TABLE_STYLES) {
309
+ table.style[prop] = "";
310
+ }
311
+
290
312
  const alignment = node.attrs.alignment as string | null | undefined;
291
313
  if (alignment === "center") {
292
314
  table.style.marginLeft = "auto";
@@ -294,6 +316,67 @@ function applyTableAttrs(table: HTMLTableElement, node: PMNode): void {
294
316
  } else if (alignment === "right") {
295
317
  table.style.marginLeft = "auto";
296
318
  }
319
+
320
+ // R3.a Phase 2 — table-level inline styles from resolved properties.
321
+ // Width: pct → "%", dxa → "pt" (twips/20), auto → "auto", null → unset.
322
+ // When an explicit width is present we drop Tailwind's `w-full` so the
323
+ // explicit value wins; otherwise keep the responsive default.
324
+ const tableWidth = node.attrs.tableWidth as number | null | undefined;
325
+ const tableWidthType = node.attrs.tableWidthType as string | null | undefined;
326
+ let baseClasses = "border-collapse w-full my-2 text-sm";
327
+ if (tableWidthType === "pct" && typeof tableWidth === "number") {
328
+ // OOXML pct widths are fiftieths of a percent (5000 = 100%).
329
+ table.style.width = `${tableWidth / 50}%`;
330
+ baseClasses = "border-collapse my-2 text-sm";
331
+ } else if (tableWidthType === "dxa" && typeof tableWidth === "number") {
332
+ table.style.width = `${tableWidth / 20}pt`;
333
+ baseClasses = "border-collapse my-2 text-sm";
334
+ } else if (tableWidthType === "auto") {
335
+ table.style.width = "auto";
336
+ baseClasses = "border-collapse my-2 text-sm";
337
+ }
338
+
339
+ // Layout mode: w:tblLayout/@w:type. "fixed" → CSS `table-layout: fixed`
340
+ // (column widths from <colgroup> rule); "autofit" falls through to the
341
+ // browser default ("auto").
342
+ const layoutMode = node.attrs.tableLayoutMode as string | null | undefined;
343
+ if (layoutMode === "fixed") {
344
+ table.style.tableLayout = "fixed";
345
+ }
346
+
347
+ // Cell spacing: w:tblCellSpacing. Non-zero spacing requires
348
+ // `border-collapse: separate` in CSS (border-collapse: collapse ignores
349
+ // border-spacing).
350
+ // ECMA-376 §17.4.44: w:tblCellSpacing is total spacing between cells in
351
+ // twentieths of a point; direct CSS border-spacing equivalent.
352
+ const cellSpacing = node.attrs.tableCellSpacing as number | null | undefined;
353
+ const cellSpacingType = node.attrs.tableCellSpacingType as string | null | undefined;
354
+ if (typeof cellSpacing === "number" && cellSpacing > 0 && cellSpacingType === "dxa") {
355
+ table.style.borderCollapse = "separate";
356
+ table.style.borderSpacing = `${cellSpacing / 20}pt`;
357
+ // `border-collapse` overrides Tailwind's `border-collapse` utility, so
358
+ // strip it from the base class set to avoid the conflicting cascade
359
+ // result that some browsers cache.
360
+ baseClasses = baseClasses.replace("border-collapse", "border-separate");
361
+ }
362
+
363
+ // Outer borders (top / right / bottom / left) — apply directly as inline
364
+ // styles. insideH / insideV are intentionally NOT applied at the table
365
+ // level because CSS has no native "table-inside-border" property; their
366
+ // visual effect is realized through per-cell borders projected on the
367
+ // cell snapshot via resolveCellBorderStyles in surface-projection.
368
+ // (See Lane 3b R3.a Phase 2 close-out plan: "option 1 — cell-level
369
+ // fallback".)
370
+ const tBorderTop = node.attrs.tableBorderTop as string | null | undefined;
371
+ const tBorderRight = node.attrs.tableBorderRight as string | null | undefined;
372
+ const tBorderBottom = node.attrs.tableBorderBottom as string | null | undefined;
373
+ const tBorderLeft = node.attrs.tableBorderLeft as string | null | undefined;
374
+ if (tBorderTop) table.style.borderTop = tBorderTop;
375
+ if (tBorderRight) table.style.borderRight = tBorderRight;
376
+ if (tBorderBottom) table.style.borderBottom = tBorderBottom;
377
+ if (tBorderLeft) table.style.borderLeft = tBorderLeft;
378
+
379
+ table.className = baseClasses;
297
380
  syncColgroup(table, node);
298
381
  }
299
382
 
@@ -423,6 +506,27 @@ function applyCellAttrs(cell: HTMLTableCellElement, node: PMNode, isHeader: bool
423
506
  cell.style.borderRight = borderRight ?? "";
424
507
  cell.style.borderBottom = borderBottom ?? "";
425
508
  cell.style.borderLeft = borderLeft ?? "";
509
+
510
+ // R3.a Phase 2 — vertical text direction. OOXML: tbRl = top→bottom, right→left
511
+ // (most common for vertical headers, reads when tilting the head right);
512
+ // btLr = bottom→top, left→right (rare, reads tilting head left); lrTb is the
513
+ // default horizontal flow. CSS `writing-mode` is the direct equivalent.
514
+ const textDirection = node.attrs.textDirection as
515
+ | "lrTb"
516
+ | "tbRl"
517
+ | "btLr"
518
+ | null
519
+ | undefined;
520
+ cell.style.writingMode = "";
521
+ if (textDirection === "tbRl") {
522
+ cell.style.writingMode = "vertical-rl";
523
+ cell.setAttribute("data-text-direction", "tbRl");
524
+ } else if (textDirection === "btLr") {
525
+ cell.style.writingMode = "vertical-lr";
526
+ cell.setAttribute("data-text-direction", "btLr");
527
+ } else {
528
+ cell.removeAttribute("data-text-direction");
529
+ }
426
530
  }
427
531
 
428
532
  /**
@@ -56,6 +56,17 @@ export {
56
56
  type TwModeDockAction,
57
57
  type TwModeDockProps,
58
58
  } from "./chrome/tw-mode-dock";
59
+ export {
60
+ TwPasteDropToast,
61
+ type TwPasteDropToastProps,
62
+ } from "./chrome/tw-paste-drop-toast";
63
+ export {
64
+ TwCommandPalette,
65
+ isCommandPaletteOpenShortcut,
66
+ type CommandPaletteGroup,
67
+ type CommandPaletteItem,
68
+ type TwCommandPaletteProps,
69
+ } from "./chrome/tw-command-palette";
59
70
 
60
71
  // Collab chrome (P9) — mount when chromePreset === "collab"; each
61
72
  // component is pure presentational and takes snapshots + callbacks.
@@ -32,7 +32,7 @@ export interface TwFootnoteAreaProps {
32
32
  "data-testid"?: string;
33
33
  }
34
34
 
35
- export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = ({
35
+ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = React.memo(({
36
36
  pageIndex,
37
37
  blocks,
38
38
  topPx,
@@ -66,6 +66,6 @@ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = ({
66
66
  <TwRegionBlockRenderer blocks={blocks} />
67
67
  </div>
68
68
  );
69
- };
69
+ });
70
70
 
71
71
  export default TwFootnoteArea;