@beyondwork/docx-react-component 1.0.38 → 1.0.40

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 (85) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +305 -6
  3. package/src/core/commands/table-structure-commands.ts +31 -2
  4. package/src/core/commands/text-commands.ts +122 -2
  5. package/src/index.ts +9 -0
  6. package/src/io/docx-session.ts +1 -0
  7. package/src/io/export/serialize-numbering.ts +42 -8
  8. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  9. package/src/io/export/serialize-run-formatting.ts +90 -0
  10. package/src/io/export/serialize-styles.ts +212 -0
  11. package/src/io/ooxml/parse-fields.ts +10 -3
  12. package/src/io/ooxml/parse-numbering.ts +41 -1
  13. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  14. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  15. package/src/io/ooxml/parse-styles.ts +31 -0
  16. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  17. package/src/io/ooxml/xml-element.ts +19 -0
  18. package/src/model/canonical-document.ts +83 -3
  19. package/src/runtime/collab/event-types.ts +165 -0
  20. package/src/runtime/collab/index.ts +22 -0
  21. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  22. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  23. package/src/runtime/document-runtime.ts +141 -18
  24. package/src/runtime/layout/docx-font-loader.ts +30 -11
  25. package/src/runtime/layout/index.ts +2 -0
  26. package/src/runtime/layout/inert-layout-facet.ts +3 -0
  27. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  28. package/src/runtime/layout/layout-invalidation.ts +14 -5
  29. package/src/runtime/layout/page-graph.ts +36 -0
  30. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  31. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  32. package/src/runtime/layout/project-block-fragments.ts +154 -20
  33. package/src/runtime/layout/public-facet.ts +81 -1
  34. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  35. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  36. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  37. package/src/runtime/layout/table-render-plan.ts +21 -1
  38. package/src/runtime/numbering-prefix.ts +5 -0
  39. package/src/runtime/paragraph-style-resolver.ts +194 -0
  40. package/src/runtime/render/render-kernel.ts +5 -1
  41. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  42. package/src/runtime/surface-projection.ts +129 -9
  43. package/src/runtime/table-schema.ts +11 -0
  44. package/src/runtime/workflow-rail-segments.ts +149 -1
  45. package/src/ui/WordReviewEditor.tsx +302 -5
  46. package/src/ui/editor-command-bag.ts +4 -0
  47. package/src/ui/editor-runtime-boundary.ts +16 -0
  48. package/src/ui/editor-shell-view.tsx +22 -0
  49. package/src/ui/editor-surface-controller.tsx +9 -1
  50. package/src/ui/headless/chrome-registry.ts +34 -5
  51. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  52. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  53. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  54. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
  55. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  57. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  58. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
  61. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  62. package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
  63. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
  65. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
  66. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
  67. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  68. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  69. package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
  70. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  71. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  72. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
  73. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  74. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  75. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  76. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  77. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  78. package/src/ui-tailwind/index.ts +6 -5
  79. package/src/ui-tailwind/theme/editor-theme.css +108 -15
  80. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  81. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  82. package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
  83. package/src/runtime/collab-review-sync.ts +0 -254
  84. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  85. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -21,6 +21,12 @@ export interface TwTableContextToolbarProps {
21
21
  onSplitCell?: () => void;
22
22
  onSetCellBackground?: (color: string) => void;
23
23
  onDeleteTable?: () => void;
24
+ // P6: new ops surfaced from P2 capability flags
25
+ onToggleRowHeader?: () => void;
26
+ onToggleRowCantSplit?: () => void;
27
+ onDistributeColumnsEvenly?: () => void;
28
+ onSetTableAlignment?: (alignment: "left" | "center" | "right") => void;
29
+ onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
24
30
  }
25
31
 
26
32
  const CELL_COLORS = [
@@ -113,6 +119,24 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
113
119
  <ToolbarBadge tone="accent">Header row</ToolbarBadge>
114
120
  ) : null}
115
121
 
122
+ {/* T5 whole-table: table alignment */}
123
+ {tier === "whole-table" ? (
124
+ <ToolbarSection label="Align">
125
+ {(["left", "center", "right"] as const).map((align) => (
126
+ <ToolbarButton
127
+ key={align}
128
+ ariaLabel={`Align table ${align}`}
129
+ capability={tableContext?.operations.setTableAlignment}
130
+ disabled={props.disabled}
131
+ onClick={() => props.onSetTableAlignment?.(align)}
132
+ active={tableContext?.currentCell != null && align === "left"}
133
+ >
134
+ {align[0]!.toUpperCase()}
135
+ </ToolbarButton>
136
+ ))}
137
+ </ToolbarSection>
138
+ ) : null}
139
+
116
140
  {/* T5 whole-table: style selector (primary), delete table */}
117
141
  {tier === "whole-table" ? (
118
142
  <ToolbarSection label="Style">
@@ -162,15 +186,34 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
162
186
  Below
163
187
  </ToolbarButton>
164
188
  {tier === "row-selected" ? (
165
- <ToolbarButton
166
- ariaLabel="Delete row"
167
- capability={tableContext?.operations.deleteRow}
168
- disabled={props.disabled}
169
- onClick={props.onDeleteRow}
170
- danger
171
- >
172
- Delete row
173
- </ToolbarButton>
189
+ <>
190
+ <ToolbarButton
191
+ ariaLabel="Delete row"
192
+ capability={tableContext?.operations.deleteRow}
193
+ disabled={props.disabled}
194
+ onClick={props.onDeleteRow}
195
+ danger
196
+ >
197
+ Delete row
198
+ </ToolbarButton>
199
+ <ToolbarButton
200
+ ariaLabel="Toggle header row"
201
+ capability={tableContext?.operations.setRowIsHeader}
202
+ disabled={props.disabled}
203
+ onClick={props.onToggleRowHeader}
204
+ active={tableContext?.currentCell.isHeader}
205
+ >
206
+ Header
207
+ </ToolbarButton>
208
+ <ToolbarButton
209
+ ariaLabel="Toggle row can't split"
210
+ capability={tableContext?.operations.setRowCantSplit}
211
+ disabled={props.disabled}
212
+ onClick={props.onToggleRowCantSplit}
213
+ >
214
+ No break
215
+ </ToolbarButton>
216
+ </>
174
217
  ) : null}
175
218
  </ToolbarSection>
176
219
  ) : null}
@@ -195,15 +238,25 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
195
238
  Right
196
239
  </ToolbarButton>
197
240
  {tier === "column-selected" ? (
198
- <ToolbarButton
199
- ariaLabel="Delete column"
200
- capability={tableContext?.operations.deleteColumn}
201
- disabled={props.disabled}
202
- onClick={props.onDeleteColumn}
203
- danger
204
- >
205
- Delete column
206
- </ToolbarButton>
241
+ <>
242
+ <ToolbarButton
243
+ ariaLabel="Delete column"
244
+ capability={tableContext?.operations.deleteColumn}
245
+ disabled={props.disabled}
246
+ onClick={props.onDeleteColumn}
247
+ danger
248
+ >
249
+ Delete column
250
+ </ToolbarButton>
251
+ <ToolbarButton
252
+ ariaLabel="Distribute columns evenly"
253
+ capability={tableContext?.operations.distributeColumnsEvenly}
254
+ disabled={props.disabled}
255
+ onClick={props.onDistributeColumnsEvenly}
256
+ >
257
+ Distribute
258
+ </ToolbarButton>
259
+ </>
207
260
  ) : null}
208
261
  </ToolbarSection>
209
262
  ) : null}
@@ -257,6 +310,29 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
257
310
  </ToolbarSection>
258
311
  ) : null}
259
312
 
313
+ {/* Cell vertical alignment (caret-in-cell + multi-cell) */}
314
+ {(tier === "caret-in-cell" || tier === "multi-cell") ? (
315
+ <ToolbarSection label="V-Align">
316
+ {(
317
+ [
318
+ ["top", "Top"],
319
+ ["center", "Mid"],
320
+ ["bottom", "Bot"],
321
+ ] as const
322
+ ).map(([align, label]) => (
323
+ <ToolbarButton
324
+ key={align}
325
+ ariaLabel={`Cell vertical align ${align}`}
326
+ capability={tableContext?.operations.setCellVerticalAlign}
327
+ disabled={props.disabled}
328
+ onClick={() => props.onSetCellVerticalAlign?.(align)}
329
+ >
330
+ {label}
331
+ </ToolbarButton>
332
+ ))}
333
+ </ToolbarSection>
334
+ ) : null}
335
+
260
336
  {/* T5 only: delete table (danger) */}
261
337
  {tier === "whole-table" ? (
262
338
  <ToolbarSection label="Table">
@@ -362,6 +438,7 @@ function ToolbarButton(props: {
362
438
  danger?: boolean;
363
439
  disabled: boolean;
364
440
  onClick?: () => void;
441
+ active?: boolean;
365
442
  }) {
366
443
  const capabilityEnabled = props.capability?.enabled ?? true;
367
444
  const title = !capabilityEnabled ? props.capability?.reason : undefined;
@@ -369,14 +446,17 @@ function ToolbarButton(props: {
369
446
  <button
370
447
  type="button"
371
448
  aria-label={props.ariaLabel}
449
+ aria-pressed={props.active}
372
450
  disabled={props.disabled || !props.onClick || !capabilityEnabled}
373
451
  onMouseDown={preserveEditorSelectionMouseDown}
374
452
  onClick={props.onClick}
375
453
  title={title}
376
454
  className={`inline-flex h-7 items-center rounded-md px-2 text-[11px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
377
- props.danger
378
- ? "text-danger hover:bg-danger/10"
379
- : "text-primary hover:bg-surface"
455
+ props.active
456
+ ? "bg-accent/15 text-accent"
457
+ : props.danger
458
+ ? "text-danger hover:bg-danger/10"
459
+ : "text-primary hover:bg-surface"
380
460
  }`}
381
461
  >
382
462
  {props.children}
@@ -0,0 +1,353 @@
1
+ /**
2
+ * TwTableGripLayer — column-resize and row-resize grips for the active table.
3
+ *
4
+ * Grips are positioned via the render-kernel anchor index (P4):
5
+ * - Column grips: `anchorIndex.byTableColumnEdge(blockId, colIndex)`
6
+ * - Row grips: `anchorIndex.byTableRowEdge(blockId, rowIndex)`
7
+ *
8
+ * This is a pure overlay consumer: no DOM measurement, no selection mutation,
9
+ * no PM transactions. Drag deltas are converted from px → twips using the
10
+ * kernel's `pxPerTwip` ratio and dispatched through `onSetColumnWidth` /
11
+ * `onSetRowHeight` callbacks (which route to `ref.tables.apply(op)`).
12
+ *
13
+ * Drag state lives in `useRef` so moves don't trigger re-renders mid-drag.
14
+ *
15
+ * Click-trap fix: the grip is an invisible 6px-wide (column) / 6px-tall (row)
16
+ * strip with `pointer-events-auto`, so a click landing near a cell edge hits
17
+ * the grip instead of cell text. To keep non-drag clicks from being swallowed,
18
+ * mousedown no longer calls `preventDefault` — that's deferred to the first
19
+ * mousemove that crosses `DRAG_THRESHOLD_PX`. A mouseup without any crossing
20
+ * is forwarded via `document.elementFromPoint` to the element beneath the
21
+ * grip so PM still receives the click and places the caret.
22
+ */
23
+
24
+ import React, { useCallback, useEffect, useRef } from "react";
25
+
26
+ import type {
27
+ TableStructureContextSnapshot,
28
+ WordReviewEditorLayoutFacet,
29
+ } from "../../api/public-types";
30
+ import type { OverlayCoordinateSpace } from "../chrome-overlay/chrome-overlay-projector";
31
+ import { projectRectToOverlay } from "../chrome-overlay/chrome-overlay-projector";
32
+ import { DEFAULT_PX_PER_TWIP } from "../../runtime/render/render-frame-types";
33
+
34
+ const GRIP_PX = 6;
35
+ const DRAG_THRESHOLD_PX = 3;
36
+ const MIN_COLUMN_TWIPS = 720;
37
+ const MIN_ROW_TWIPS = 120;
38
+
39
+ /**
40
+ * Re-dispatch a click that landed on an invisible resize grip to the element
41
+ * beneath it. Called when a mouseup fires without any drag movement, so the
42
+ * user's intended target (typically PM-rendered cell text) still receives
43
+ * mousedown/mouseup/click and can place the caret.
44
+ */
45
+ function forwardNonDragClick(gripEl: HTMLElement, event: MouseEvent): void {
46
+ const previous = gripEl.style.pointerEvents;
47
+ gripEl.style.pointerEvents = "none";
48
+ try {
49
+ const beneath = gripEl.ownerDocument?.elementFromPoint(
50
+ event.clientX,
51
+ event.clientY,
52
+ );
53
+ if (!beneath || beneath === gripEl) return;
54
+ const init: MouseEventInit = {
55
+ bubbles: true,
56
+ cancelable: true,
57
+ view: gripEl.ownerDocument?.defaultView ?? window,
58
+ clientX: event.clientX,
59
+ clientY: event.clientY,
60
+ screenX: event.screenX,
61
+ screenY: event.screenY,
62
+ button: event.button,
63
+ buttons: event.buttons,
64
+ ctrlKey: event.ctrlKey,
65
+ metaKey: event.metaKey,
66
+ shiftKey: event.shiftKey,
67
+ altKey: event.altKey,
68
+ };
69
+ beneath.dispatchEvent(new MouseEvent("mousedown", init));
70
+ beneath.dispatchEvent(new MouseEvent("mouseup", init));
71
+ beneath.dispatchEvent(new MouseEvent("click", init));
72
+ } finally {
73
+ gripEl.style.pointerEvents = previous;
74
+ }
75
+ }
76
+
77
+ export interface TwTableGripLayerProps {
78
+ facet: WordReviewEditorLayoutFacet;
79
+ tableContext: TableStructureContextSnapshot | null;
80
+ space?: OverlayCoordinateSpace;
81
+ disabled?: boolean;
82
+ onSetColumnWidth?: (columnIndex: number, twips: number) => void;
83
+ onSetRowHeight?: (
84
+ rowIndex: number,
85
+ twips: number,
86
+ rule: "auto" | "atLeast" | "exact",
87
+ ) => void;
88
+ }
89
+
90
+ export function TwTableGripLayer({
91
+ facet,
92
+ tableContext,
93
+ space,
94
+ disabled,
95
+ onSetColumnWidth,
96
+ onSetRowHeight,
97
+ }: TwTableGripLayerProps) {
98
+ if (!tableContext) return null;
99
+
100
+ const frame =
101
+ typeof facet.getRenderFrame === "function"
102
+ ? (facet.getRenderFrame() ?? null)
103
+ : null;
104
+ if (!frame) return null;
105
+
106
+ const blockId = `table-${tableContext.tableBlockIndex}`;
107
+ const pageIndex = facet.getFirstPageIndexForBlock(blockId) ?? 0;
108
+ const plan = facet.getTableRenderPlan(blockId, pageIndex);
109
+ if (!plan) return null;
110
+
111
+ const pxPerTwip =
112
+ typeof facet.getRenderZoom === "function"
113
+ ? (facet.getRenderZoom()?.pxPerTwip ?? DEFAULT_PX_PER_TWIP)
114
+ : DEFAULT_PX_PER_TWIP;
115
+
116
+ return (
117
+ <>
118
+ {plan.columnResizeHandles.map((handle) => {
119
+ const rect = frame.anchorIndex.byTableColumnEdge(
120
+ blockId,
121
+ handle.columnIndex,
122
+ );
123
+ if (!rect) return null;
124
+ const pos = projectRectToOverlay(rect, space);
125
+ return (
126
+ <ColResizeGrip
127
+ key={`col-${blockId}-${handle.columnIndex}`}
128
+ pos={pos}
129
+ colIndex={handle.columnIndex}
130
+ originalTwips={plan.columnsTwips[handle.columnIndex] ?? 720}
131
+ pxPerTwip={pxPerTwip}
132
+ disabled={!!disabled || !onSetColumnWidth}
133
+ onCommit={onSetColumnWidth}
134
+ />
135
+ );
136
+ })}
137
+ {Array.from({ length: tableContext.rowCount }, (_, rowIndex) => {
138
+ const rect = frame.anchorIndex.byTableRowEdge(blockId, rowIndex);
139
+ if (!rect) return null;
140
+ const pos = projectRectToOverlay(rect, space);
141
+ return (
142
+ <RowResizeGrip
143
+ key={`row-${blockId}-${rowIndex}`}
144
+ pos={pos}
145
+ rowIndex={rowIndex}
146
+ pxPerTwip={pxPerTwip}
147
+ disabled={!!disabled || !onSetRowHeight}
148
+ onCommit={onSetRowHeight}
149
+ />
150
+ );
151
+ })}
152
+ </>
153
+ );
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Column resize grip
158
+ // ---------------------------------------------------------------------------
159
+
160
+ interface ColResizeGripProps {
161
+ pos: { left: string; top: string; width: string; height: string };
162
+ colIndex: number;
163
+ originalTwips: number;
164
+ pxPerTwip: number;
165
+ disabled: boolean;
166
+ onCommit?: (columnIndex: number, twips: number) => void;
167
+ }
168
+
169
+ function ColResizeGrip({
170
+ pos,
171
+ colIndex,
172
+ originalTwips,
173
+ pxPerTwip,
174
+ disabled,
175
+ onCommit,
176
+ }: ColResizeGripProps) {
177
+ const dragRef = useRef<{
178
+ startX: number;
179
+ originalTwips: number;
180
+ dragStarted: boolean;
181
+ gripEl: HTMLElement;
182
+ } | null>(null);
183
+
184
+ const handleMouseDown = useCallback(
185
+ (e: React.MouseEvent<HTMLElement>) => {
186
+ if (disabled) return;
187
+ dragRef.current = {
188
+ startX: e.clientX,
189
+ originalTwips,
190
+ dragStarted: false,
191
+ gripEl: e.currentTarget,
192
+ };
193
+ },
194
+ [disabled, originalTwips],
195
+ );
196
+
197
+ useEffect(() => {
198
+ const handleMove = (e: MouseEvent) => {
199
+ const drag = dragRef.current;
200
+ if (!drag) return;
201
+ if (!drag.dragStarted) {
202
+ if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD_PX) return;
203
+ drag.dragStarted = true;
204
+ }
205
+ e.preventDefault();
206
+ };
207
+ const handleUp = (e: MouseEvent) => {
208
+ const drag = dragRef.current;
209
+ if (!drag) return;
210
+ dragRef.current = null;
211
+ if (!drag.dragStarted) {
212
+ forwardNonDragClick(drag.gripEl, e);
213
+ return;
214
+ }
215
+ const deltaTwips = (e.clientX - drag.startX) / pxPerTwip;
216
+ const newTwips = Math.max(
217
+ MIN_COLUMN_TWIPS,
218
+ Math.round(drag.originalTwips + deltaTwips),
219
+ );
220
+ onCommit?.(colIndex, newTwips);
221
+ };
222
+ window.addEventListener("mousemove", handleMove);
223
+ window.addEventListener("mouseup", handleUp);
224
+ return () => {
225
+ window.removeEventListener("mousemove", handleMove);
226
+ window.removeEventListener("mouseup", handleUp);
227
+ };
228
+ }, [colIndex, pxPerTwip, onCommit]);
229
+
230
+ return (
231
+ <div
232
+ role="separator"
233
+ aria-orientation="vertical"
234
+ aria-label={`Resize column ${colIndex + 1}`}
235
+ data-testid={`col-resize-grip-${colIndex}`}
236
+ className={[
237
+ "pointer-events-auto absolute",
238
+ disabled
239
+ ? "cursor-default opacity-0"
240
+ : "cursor-col-resize opacity-0 hover:opacity-100",
241
+ "transition-opacity",
242
+ "bg-accent",
243
+ ].join(" ")}
244
+ style={{
245
+ left: `calc(${pos.left} - ${GRIP_PX / 2}px)`,
246
+ top: pos.top,
247
+ width: `${GRIP_PX}px`,
248
+ height: pos.height,
249
+ }}
250
+ onMouseDown={handleMouseDown}
251
+ />
252
+ );
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Row resize grip
257
+ // ---------------------------------------------------------------------------
258
+
259
+ interface RowResizeGripProps {
260
+ pos: { left: string; top: string; width: string; height: string };
261
+ rowIndex: number;
262
+ pxPerTwip: number;
263
+ disabled: boolean;
264
+ onCommit?: (
265
+ rowIndex: number,
266
+ twips: number,
267
+ rule: "auto" | "atLeast" | "exact",
268
+ ) => void;
269
+ }
270
+
271
+ function RowResizeGrip({
272
+ pos,
273
+ rowIndex,
274
+ pxPerTwip,
275
+ disabled,
276
+ onCommit,
277
+ }: RowResizeGripProps) {
278
+ const dragRef = useRef<{
279
+ startY: number;
280
+ dragStarted: boolean;
281
+ gripEl: HTMLElement;
282
+ } | null>(null);
283
+
284
+ const handleMouseDown = useCallback(
285
+ (e: React.MouseEvent<HTMLElement>) => {
286
+ if (disabled) return;
287
+ dragRef.current = {
288
+ startY: e.clientY,
289
+ dragStarted: false,
290
+ gripEl: e.currentTarget,
291
+ };
292
+ },
293
+ [disabled],
294
+ );
295
+
296
+ useEffect(() => {
297
+ const handleMove = (e: MouseEvent) => {
298
+ const drag = dragRef.current;
299
+ if (!drag) return;
300
+ if (!drag.dragStarted) {
301
+ if (Math.abs(e.clientY - drag.startY) < DRAG_THRESHOLD_PX) return;
302
+ drag.dragStarted = true;
303
+ }
304
+ e.preventDefault();
305
+ };
306
+ const handleUp = (e: MouseEvent) => {
307
+ const drag = dragRef.current;
308
+ if (!drag) return;
309
+ dragRef.current = null;
310
+ if (!drag.dragStarted) {
311
+ forwardNonDragClick(drag.gripEl, e);
312
+ return;
313
+ }
314
+ const deltaY = e.clientY - drag.startY;
315
+ const deltaTwips = deltaY / pxPerTwip;
316
+ // We don't know the current row height from the grip alone; use atLeast
317
+ // so that a positive drag expands and a negative drag collapses to auto.
318
+ const newTwips = Math.max(MIN_ROW_TWIPS, Math.round(deltaTwips));
319
+ const rule = newTwips > 0 ? "atLeast" : "auto";
320
+ onCommit?.(rowIndex, newTwips, rule as "atLeast");
321
+ };
322
+ window.addEventListener("mousemove", handleMove);
323
+ window.addEventListener("mouseup", handleUp);
324
+ return () => {
325
+ window.removeEventListener("mousemove", handleMove);
326
+ window.removeEventListener("mouseup", handleUp);
327
+ };
328
+ }, [rowIndex, pxPerTwip, onCommit]);
329
+
330
+ return (
331
+ <div
332
+ role="separator"
333
+ aria-orientation="horizontal"
334
+ aria-label={`Resize row ${rowIndex + 1}`}
335
+ data-testid={`row-resize-grip-${rowIndex}`}
336
+ className={[
337
+ "pointer-events-auto absolute",
338
+ disabled
339
+ ? "cursor-default opacity-0"
340
+ : "cursor-row-resize opacity-0 hover:opacity-100",
341
+ "transition-opacity",
342
+ "bg-accent",
343
+ ].join(" ")}
344
+ style={{
345
+ left: pos.left,
346
+ top: `calc(${pos.top} - ${GRIP_PX / 2}px)`,
347
+ width: pos.width,
348
+ height: `${GRIP_PX}px`,
349
+ }}
350
+ onMouseDown={handleMouseDown}
351
+ />
352
+ );
353
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { RenderFrameRect } from "../../runtime/render/index.ts";
12
+ import { recordPerfSample } from "../editor-surface/perf-probe.ts";
12
13
 
13
14
  export interface OverlayCoordinateSpace {
14
15
  /** Top-left of the overlay in the document column's coordinate space. */
@@ -33,12 +34,15 @@ export function projectRectToOverlay(
33
34
  rect: RenderFrameRect,
34
35
  space: OverlayCoordinateSpace = { originLeftPx: 0, originTopPx: 0 },
35
36
  ): CSSRect {
36
- return {
37
+ const t0 = typeof performance !== "undefined" ? performance.now() : 0;
38
+ const result: CSSRect = {
37
39
  left: `${rect.leftPx - space.originLeftPx}px`,
38
40
  top: `${rect.topPx - space.originTopPx}px`,
39
41
  width: `${rect.widthPx}px`,
40
42
  height: `${rect.heightPx}px`,
41
43
  };
44
+ if (t0 > 0) recordPerfSample("chrome.overlay_reposition", performance.now() - t0);
45
+ return result;
42
46
  }
43
47
 
44
48
  /**
@@ -7,12 +7,8 @@
7
7
 
8
8
  export { TwChromeOverlay, type TwChromeOverlayProps } from "./tw-chrome-overlay";
9
9
  export { TwScopeRailLayer, type TwScopeRailLayerProps } from "./tw-scope-rail-layer";
10
- export {
11
- TwWorkspaceViewSwitcher,
12
- type TwWorkspaceViewSwitcherProps,
13
- type WorkspaceView,
14
- type WorkspaceViewAction,
15
- } from "./tw-workspace-view-switcher";
10
+ export { TwScopeCard, type TwScopeCardProps } from "./tw-scope-card";
11
+ export { TwScopeCardLayer, type TwScopeCardLayerProps } from "./tw-scope-card-layer";
16
12
  export {
17
13
  inflateRect,
18
14
  projectRectToOverlay,