@beyondwork/docx-react-component 1.0.37 → 1.0.39

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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -0,0 +1,284 @@
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
+
16
+ import React, { useCallback, useEffect, useRef } from "react";
17
+
18
+ import type {
19
+ TableStructureContextSnapshot,
20
+ WordReviewEditorLayoutFacet,
21
+ } from "../../api/public-types";
22
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
23
+ import type { OverlayCoordinateSpace } from "../chrome-overlay/chrome-overlay-projector";
24
+ import { projectRectToOverlay } from "../chrome-overlay/chrome-overlay-projector";
25
+ import { DEFAULT_PX_PER_TWIP } from "../../runtime/render/render-frame-types";
26
+
27
+ const GRIP_PX = 6;
28
+ const MIN_COLUMN_TWIPS = 720;
29
+ const MIN_ROW_TWIPS = 120;
30
+
31
+ export interface TwTableGripLayerProps {
32
+ facet: WordReviewEditorLayoutFacet;
33
+ tableContext: TableStructureContextSnapshot | null;
34
+ space?: OverlayCoordinateSpace;
35
+ disabled?: boolean;
36
+ onSetColumnWidth?: (columnIndex: number, twips: number) => void;
37
+ onSetRowHeight?: (
38
+ rowIndex: number,
39
+ twips: number,
40
+ rule: "auto" | "atLeast" | "exact",
41
+ ) => void;
42
+ }
43
+
44
+ export function TwTableGripLayer({
45
+ facet,
46
+ tableContext,
47
+ space,
48
+ disabled,
49
+ onSetColumnWidth,
50
+ onSetRowHeight,
51
+ }: TwTableGripLayerProps) {
52
+ if (!tableContext) return null;
53
+
54
+ const frame =
55
+ typeof facet.getRenderFrame === "function"
56
+ ? (facet.getRenderFrame() ?? null)
57
+ : null;
58
+ if (!frame) return null;
59
+
60
+ const blockId = `table-${tableContext.tableBlockIndex}`;
61
+ const pageIndex = facet.getFirstPageIndexForBlock(blockId) ?? 0;
62
+ const plan = facet.getTableRenderPlan(blockId, pageIndex);
63
+ if (!plan) return null;
64
+
65
+ const pxPerTwip =
66
+ typeof facet.getRenderZoom === "function"
67
+ ? (facet.getRenderZoom()?.pxPerTwip ?? DEFAULT_PX_PER_TWIP)
68
+ : DEFAULT_PX_PER_TWIP;
69
+
70
+ return (
71
+ <>
72
+ {plan.columnResizeHandles.map((handle) => {
73
+ const rect = frame.anchorIndex.byTableColumnEdge(
74
+ blockId,
75
+ handle.columnIndex,
76
+ );
77
+ if (!rect) return null;
78
+ const pos = projectRectToOverlay(rect, space);
79
+ return (
80
+ <ColResizeGrip
81
+ key={`col-${blockId}-${handle.columnIndex}`}
82
+ pos={pos}
83
+ colIndex={handle.columnIndex}
84
+ originalTwips={plan.columnsTwips[handle.columnIndex] ?? 720}
85
+ pxPerTwip={pxPerTwip}
86
+ disabled={!!disabled || !onSetColumnWidth}
87
+ onCommit={onSetColumnWidth}
88
+ />
89
+ );
90
+ })}
91
+ {Array.from({ length: tableContext.rowCount }, (_, rowIndex) => {
92
+ const rect = frame.anchorIndex.byTableRowEdge(blockId, rowIndex);
93
+ if (!rect) return null;
94
+ const pos = projectRectToOverlay(rect, space);
95
+ return (
96
+ <RowResizeGrip
97
+ key={`row-${blockId}-${rowIndex}`}
98
+ pos={pos}
99
+ rowIndex={rowIndex}
100
+ pxPerTwip={pxPerTwip}
101
+ disabled={!!disabled || !onSetRowHeight}
102
+ onCommit={onSetRowHeight}
103
+ />
104
+ );
105
+ })}
106
+ </>
107
+ );
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Column resize grip
112
+ // ---------------------------------------------------------------------------
113
+
114
+ interface ColResizeGripProps {
115
+ pos: { left: string; top: string; width: string; height: string };
116
+ colIndex: number;
117
+ originalTwips: number;
118
+ pxPerTwip: number;
119
+ disabled: boolean;
120
+ onCommit?: (columnIndex: number, twips: number) => void;
121
+ }
122
+
123
+ function ColResizeGrip({
124
+ pos,
125
+ colIndex,
126
+ originalTwips,
127
+ pxPerTwip,
128
+ disabled,
129
+ onCommit,
130
+ }: ColResizeGripProps) {
131
+ const dragRef = useRef<{
132
+ startX: number;
133
+ originalTwips: number;
134
+ } | null>(null);
135
+
136
+ const handleMouseDown = useCallback(
137
+ (e: React.MouseEvent<HTMLElement>) => {
138
+ if (disabled) return;
139
+ preserveEditorSelectionMouseDown(e);
140
+ dragRef.current = { startX: e.clientX, originalTwips };
141
+ },
142
+ [disabled, originalTwips],
143
+ );
144
+
145
+ useEffect(() => {
146
+ const handleMove = (e: MouseEvent) => {
147
+ if (!dragRef.current) return;
148
+ e.preventDefault();
149
+ };
150
+ const handleUp = (e: MouseEvent) => {
151
+ if (!dragRef.current) return;
152
+ const deltaX = e.clientX - dragRef.current.startX;
153
+ const deltaTwips = deltaX / pxPerTwip;
154
+ const newTwips = Math.max(
155
+ MIN_COLUMN_TWIPS,
156
+ Math.round(dragRef.current.originalTwips + deltaTwips),
157
+ );
158
+ dragRef.current = null;
159
+ onCommit?.(colIndex, newTwips);
160
+ };
161
+ window.addEventListener("mousemove", handleMove);
162
+ window.addEventListener("mouseup", handleUp);
163
+ return () => {
164
+ window.removeEventListener("mousemove", handleMove);
165
+ window.removeEventListener("mouseup", handleUp);
166
+ };
167
+ }, [colIndex, pxPerTwip, onCommit]);
168
+
169
+ return (
170
+ <div
171
+ role="separator"
172
+ aria-orientation="vertical"
173
+ aria-label={`Resize column ${colIndex + 1}`}
174
+ data-testid={`col-resize-grip-${colIndex}`}
175
+ className={[
176
+ "pointer-events-auto absolute",
177
+ disabled
178
+ ? "cursor-default opacity-0"
179
+ : "cursor-col-resize opacity-0 hover:opacity-100",
180
+ "transition-opacity",
181
+ "bg-accent",
182
+ ].join(" ")}
183
+ style={{
184
+ left: `calc(${pos.left} - ${GRIP_PX / 2}px)`,
185
+ top: pos.top,
186
+ width: `${GRIP_PX}px`,
187
+ height: pos.height,
188
+ }}
189
+ onMouseDown={handleMouseDown}
190
+ />
191
+ );
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Row resize grip
196
+ // ---------------------------------------------------------------------------
197
+
198
+ interface RowResizeGripProps {
199
+ pos: { left: string; top: string; width: string; height: string };
200
+ rowIndex: number;
201
+ pxPerTwip: number;
202
+ disabled: boolean;
203
+ onCommit?: (
204
+ rowIndex: number,
205
+ twips: number,
206
+ rule: "auto" | "atLeast" | "exact",
207
+ ) => void;
208
+ }
209
+
210
+ function RowResizeGrip({
211
+ pos,
212
+ rowIndex,
213
+ pxPerTwip,
214
+ disabled,
215
+ onCommit,
216
+ }: RowResizeGripProps) {
217
+ const dragRef = useRef<{
218
+ startY: number;
219
+ startHeightPx: number;
220
+ } | null>(null);
221
+
222
+ const handleMouseDown = useCallback(
223
+ (e: React.MouseEvent<HTMLElement>) => {
224
+ if (disabled) return;
225
+ preserveEditorSelectionMouseDown(e);
226
+ // Start height is the current visible row height from the grip rect.
227
+ // We use the grip's own top position vs. previous row edge as a proxy.
228
+ dragRef.current = { startY: e.clientY, startHeightPx: 0 };
229
+ },
230
+ [disabled],
231
+ );
232
+
233
+ useEffect(() => {
234
+ const handleMove = (e: MouseEvent) => {
235
+ if (!dragRef.current) return;
236
+ e.preventDefault();
237
+ };
238
+ const handleUp = (e: MouseEvent) => {
239
+ if (!dragRef.current) return;
240
+ const deltaY = e.clientY - dragRef.current.startY;
241
+ if (Math.abs(deltaY) < 2) {
242
+ dragRef.current = null;
243
+ return;
244
+ }
245
+ const deltaTwips = deltaY / pxPerTwip;
246
+ // We don't know the current row height from the grip alone; use atLeast
247
+ // so that a positive drag expands and a negative drag collapses to auto.
248
+ const newTwips = Math.max(MIN_ROW_TWIPS, Math.round(deltaTwips));
249
+ const rule = newTwips > 0 ? "atLeast" : "auto";
250
+ dragRef.current = null;
251
+ onCommit?.(rowIndex, newTwips, rule as "atLeast");
252
+ };
253
+ window.addEventListener("mousemove", handleMove);
254
+ window.addEventListener("mouseup", handleUp);
255
+ return () => {
256
+ window.removeEventListener("mousemove", handleMove);
257
+ window.removeEventListener("mouseup", handleUp);
258
+ };
259
+ }, [rowIndex, pxPerTwip, onCommit]);
260
+
261
+ return (
262
+ <div
263
+ role="separator"
264
+ aria-orientation="horizontal"
265
+ aria-label={`Resize row ${rowIndex + 1}`}
266
+ data-testid={`row-resize-grip-${rowIndex}`}
267
+ className={[
268
+ "pointer-events-auto absolute",
269
+ disabled
270
+ ? "cursor-default opacity-0"
271
+ : "cursor-row-resize opacity-0 hover:opacity-100",
272
+ "transition-opacity",
273
+ "bg-accent",
274
+ ].join(" ")}
275
+ style={{
276
+ left: pos.left,
277
+ top: `calc(${pos.top} - ${GRIP_PX / 2}px)`,
278
+ width: pos.width,
279
+ height: `${GRIP_PX}px`,
280
+ }}
281
+ onMouseDown={handleMouseDown}
282
+ />
283
+ );
284
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Chrome-overlay projector — maps `RenderFrameRect` values into CSS-absolute
3
+ * positions under a chrome overlay's own coordinate space.
4
+ *
5
+ * Per runtime-rendering-and-chrome-phase.md §6.2, every overlay child reads
6
+ * the same rect math so rails, balloons, toolbars, and handles stay aligned
7
+ * on scroll, zoom, and resize. The projector is a tiny pure helper so the
8
+ * rect math can be unit-tested and reused.
9
+ */
10
+
11
+ import type { RenderFrameRect } from "../../runtime/render/index.ts";
12
+ import { recordPerfSample } from "../editor-surface/perf-probe.ts";
13
+
14
+ export interface OverlayCoordinateSpace {
15
+ /** Top-left of the overlay in the document column's coordinate space. */
16
+ originLeftPx: number;
17
+ originTopPx: number;
18
+ }
19
+
20
+ export interface CSSRect {
21
+ left: string;
22
+ top: string;
23
+ width: string;
24
+ height: string;
25
+ }
26
+
27
+ /**
28
+ * Convert a RenderFrameRect (which is relative to the document column's
29
+ * top-left) into CSS absolute-position values relative to the overlay's own
30
+ * coordinate space. Output uses `px` units so consumers can pass the
31
+ * result straight to inline `style={{...}}`.
32
+ */
33
+ export function projectRectToOverlay(
34
+ rect: RenderFrameRect,
35
+ space: OverlayCoordinateSpace = { originLeftPx: 0, originTopPx: 0 },
36
+ ): CSSRect {
37
+ const t0 = typeof performance !== "undefined" ? performance.now() : 0;
38
+ const result: CSSRect = {
39
+ left: `${rect.leftPx - space.originLeftPx}px`,
40
+ top: `${rect.topPx - space.originTopPx}px`,
41
+ width: `${rect.widthPx}px`,
42
+ height: `${rect.heightPx}px`,
43
+ };
44
+ if (t0 > 0) recordPerfSample("chrome.overlay_reposition", performance.now() - t0);
45
+ return result;
46
+ }
47
+
48
+ /**
49
+ * Expand a page-interior rect outward by `padLeftPx` / `padRightPx` so a
50
+ * decoration can spill into the page margin (scope rail gutter). The
51
+ * output rect width is adjusted accordingly.
52
+ */
53
+ export function inflateRect(
54
+ rect: RenderFrameRect,
55
+ pad: {
56
+ leftPx?: number;
57
+ rightPx?: number;
58
+ topPx?: number;
59
+ bottomPx?: number;
60
+ },
61
+ ): RenderFrameRect {
62
+ const padLeft = pad.leftPx ?? 0;
63
+ const padRight = pad.rightPx ?? 0;
64
+ const padTop = pad.topPx ?? 0;
65
+ const padBottom = pad.bottomPx ?? 0;
66
+ return {
67
+ leftPx: rect.leftPx - padLeft,
68
+ topPx: rect.topPx - padTop,
69
+ widthPx: rect.widthPx + padLeft + padRight,
70
+ heightPx: rect.heightPx + padTop + padBottom,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Union of two rects; returns `null` if either input is nullish so callers
76
+ * can safely chain lookups.
77
+ */
78
+ export function unionRect(
79
+ a: RenderFrameRect | null | undefined,
80
+ b: RenderFrameRect | null | undefined,
81
+ ): RenderFrameRect | null {
82
+ if (!a) return b ?? null;
83
+ if (!b) return a;
84
+ const left = Math.min(a.leftPx, b.leftPx);
85
+ const top = Math.min(a.topPx, b.topPx);
86
+ const right = Math.max(a.leftPx + a.widthPx, b.leftPx + b.widthPx);
87
+ const bottom = Math.max(a.topPx + a.heightPx, b.topPx + b.heightPx);
88
+ return {
89
+ leftPx: left,
90
+ topPx: top,
91
+ widthPx: right - left,
92
+ heightPx: bottom - top,
93
+ };
94
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * ChromeOverlay public module entry.
3
+ *
4
+ * Importers should pull the overlay + layer components from here; the
5
+ * internal layer files may be reorganized as the chrome phase continues.
6
+ */
7
+
8
+ export { TwChromeOverlay, type TwChromeOverlayProps } from "./tw-chrome-overlay";
9
+ export { TwScopeRailLayer, type TwScopeRailLayerProps } from "./tw-scope-rail-layer";
10
+ export {
11
+ inflateRect,
12
+ projectRectToOverlay,
13
+ unionRect,
14
+ type CSSRect,
15
+ type OverlayCoordinateSpace,
16
+ } from "./chrome-overlay-projector";
@@ -0,0 +1,96 @@
1
+ /**
2
+ * ChromeOverlay — the single absolute-positioned overlay plane that hosts
3
+ * every over-document surface (scope rail, comment balloons, revision
4
+ * margin bars, object handles, workspace view-switcher dock).
5
+ *
6
+ * Per runtime-rendering-and-chrome-phase.md §6.2, every overlay child
7
+ * receives `ref.layout` and reads its position from the same render-frame
8
+ * anchor index — not DOM rects, not selection rects — so the chrome stays
9
+ * in place across scroll, zoom, and relayout.
10
+ *
11
+ * This component owns only the plane and the shared coordinate space.
12
+ * Each layer it composes is a pure consumer of the facet.
13
+ */
14
+
15
+ import * as React from "react";
16
+ import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
17
+ import type { ScopeRailSegment } from "../../runtime/layout";
18
+ import type {
19
+ TableStructureContextSnapshot,
20
+ WordReviewEditorLayoutFacet,
21
+ } from "../../api/public-types";
22
+ import { TwScopeRailLayer } from "./tw-scope-rail-layer";
23
+ import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
24
+
25
+ export interface TwChromeOverlayProps {
26
+ /** Layout facet the overlay layers read from. */
27
+ facet: WordReviewEditorLayoutFacet;
28
+ /** Optional coordinate space override. Defaults to the overlay origin. */
29
+ space?: OverlayCoordinateSpace;
30
+ /** Active scope id (for emphasis + rail tab sync). */
31
+ activeScopeId?: string | null;
32
+ /** Click handler the rail layer forwards to consumers. */
33
+ onScopeSegmentClick?: (segment: ScopeRailSegment) => void;
34
+ /** Test id applied to the overlay root. */
35
+ "data-testid"?: string;
36
+ /** Optional extra children (e.g., future comment balloon layer). */
37
+ children?: React.ReactNode;
38
+
39
+ // Table grip props (P6) -----------------------------------------------
40
+ /** Active table context — when present, column/row resize grips are shown. */
41
+ tableContext?: TableStructureContextSnapshot | null;
42
+ /** Fires when a column grip drag completes. */
43
+ onSetColumnWidth?: (columnIndex: number, twips: number) => void;
44
+ /** Fires when a row grip drag completes. */
45
+ onSetRowHeight?: (
46
+ rowIndex: number,
47
+ twips: number,
48
+ rule: "auto" | "atLeast" | "exact",
49
+ ) => void;
50
+ }
51
+
52
+ /**
53
+ * Placement contract:
54
+ * - The overlay is an absolutely positioned `div` that fills its parent.
55
+ * - The parent must be `position: relative` so the overlay anchors to
56
+ * the document column (not the viewport).
57
+ * - Pointer events are disabled on the root so the editor surface under
58
+ * the overlay continues to receive input; individual layers opt in to
59
+ * pointer events on their interactive elements (buttons, handles).
60
+ */
61
+ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
62
+ facet,
63
+ space,
64
+ activeScopeId,
65
+ onScopeSegmentClick,
66
+ "data-testid": testId,
67
+ children,
68
+ tableContext,
69
+ onSetColumnWidth,
70
+ onSetRowHeight,
71
+ }) => {
72
+ return (
73
+ <div
74
+ className="wre-chrome-overlay pointer-events-none absolute inset-0 z-30"
75
+ data-testid={testId ?? "chrome-overlay"}
76
+ role="presentation"
77
+ >
78
+ <TwScopeRailLayer
79
+ facet={facet}
80
+ space={space}
81
+ activeScopeId={activeScopeId}
82
+ onSegmentClick={onScopeSegmentClick}
83
+ />
84
+ <TwTableGripLayer
85
+ facet={facet}
86
+ tableContext={tableContext ?? null}
87
+ space={space}
88
+ onSetColumnWidth={onSetColumnWidth}
89
+ onSetRowHeight={onSetRowHeight}
90
+ />
91
+ {children}
92
+ </div>
93
+ );
94
+ };
95
+
96
+ export default TwChromeOverlay;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Scope rail layer — renders workflow scopes as a continuous zone with a
3
+ * gutter icon + label column OUTSIDE the document flow plus a flat block-
4
+ * level tint BEHIND the scoped paragraphs.
5
+ *
6
+ * Per runtime-rendering-and-chrome-phase.md §5, the rail is a projection
7
+ * over canonical workflow scopes; it never lives inside the PM NodeView
8
+ * tree. Positions come from the render kernel's anchor index — not from
9
+ * DOM rect math — so the rail stays aligned across scroll, zoom, resize,
10
+ * and through predicted-text reconciliation.
11
+ */
12
+
13
+ import * as React from "react";
14
+ import {
15
+ inflateRect,
16
+ projectRectToOverlay,
17
+ unionRect,
18
+ type OverlayCoordinateSpace,
19
+ } from "./chrome-overlay-projector";
20
+ import type { RenderFrameRect } from "../../runtime/render";
21
+ import type { ScopeRailSegment, ScopeRailPosture } from "../../runtime/layout";
22
+ import type { WordReviewEditorLayoutFacet } from "../../runtime/layout";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Types
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export interface TwScopeRailLayerProps {
29
+ /** Layout facet that provides segments + anchor index. */
30
+ facet: WordReviewEditorLayoutFacet;
31
+ /** Overlay's coordinate space. Defaults to the overlay's own origin. */
32
+ space?: OverlayCoordinateSpace;
33
+ /** Horizontal padding (px) the rail gutter occupies to the left of body. */
34
+ railLaneWidthPx?: number;
35
+ /** Optional click handler for a segment label (open-scope drawer, etc). */
36
+ onSegmentClick?: (segment: ScopeRailSegment) => void;
37
+ /** Scope id that should render with the `active` emphasis. */
38
+ activeScopeId?: string | null;
39
+ /** Test id applied to the layer root. */
40
+ "data-testid"?: string;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Posture → visual grammar
45
+ // ---------------------------------------------------------------------------
46
+
47
+ interface PostureStyle {
48
+ labelText: string;
49
+ icon: string; // lucide-style key; CSS ::before handles glyph in production
50
+ railToken: string;
51
+ tintToken: string;
52
+ }
53
+
54
+ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
55
+ edit: { labelText: "EDIT", icon: "pencil", railToken: "accent", tintToken: "accent" },
56
+ suggest: { labelText: "SUGGEST", icon: "sparkles", railToken: "warning", tintToken: "warning" },
57
+ comment: { labelText: "COMMENT", icon: "message", railToken: "insert", tintToken: "insert" },
58
+ view: { labelText: "IN SCOPE", icon: "eye", railToken: "secondary", tintToken: "secondary" },
59
+ candidate: { labelText: "PROPOSED", icon: "flag", railToken: "warning", tintToken: "warning" },
60
+ "preserve-only": { labelText: "BLOCKED", icon: "lock", railToken: "danger", tintToken: "danger" },
61
+ "blocked-import": { labelText: "BLOCKED", icon: "lock", railToken: "danger", tintToken: "danger" },
62
+ };
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Component
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const DEFAULT_RAIL_LANE_PX = 120;
69
+ const LABEL_WIDTH_PX = 92;
70
+
71
+ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
72
+ facet,
73
+ space,
74
+ railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
75
+ onSegmentClick,
76
+ activeScopeId,
77
+ "data-testid": testId,
78
+ }) => {
79
+ // Read the render frame once per paint cycle. The facet.subscribe path
80
+ // already invalidates the caller's React state on layout changes, so we
81
+ // just read on render.
82
+ const frame = typeof facet.getRenderFrame === "function"
83
+ ? facet.getRenderFrame() ?? null
84
+ : null;
85
+ const segments = facet.getAllScopeRailSegments();
86
+
87
+ if (!frame || segments.length === 0) {
88
+ return null;
89
+ }
90
+
91
+ // Group segments by scopeId so multi-page scopes render one contiguous
92
+ // tint per page range. (Per-page render happens below because each
93
+ // scope may span pages.)
94
+ const items = segments.map((segment) => {
95
+ const rect = resolveSegmentRect(facet, frame, segment);
96
+ if (!rect) return null;
97
+ const style = POSTURE_STYLES[segment.posture];
98
+ return { segment, rect, style };
99
+ }).filter((item): item is NonNullable<typeof item> => item !== null);
100
+
101
+ const projectorSpace: OverlayCoordinateSpace = space ?? { originLeftPx: 0, originTopPx: 0 };
102
+
103
+ return (
104
+ <div
105
+ className="wre-scope-rail-layer pointer-events-none absolute inset-0 z-20"
106
+ data-testid={testId ?? "scope-rail-layer"}
107
+ aria-hidden="false"
108
+ role="group"
109
+ aria-label="Workflow scope rail"
110
+ >
111
+ {items.map(({ segment, rect, style }) => {
112
+ const isActive = activeScopeId === segment.scopeId || segment.isActiveWorkItem;
113
+ const tintRect = inflateRect(rect, { leftPx: 4, rightPx: 4, topPx: 2, bottomPx: 2 });
114
+ const labelRect: RenderFrameRect = {
115
+ leftPx: rect.leftPx - railLaneWidthPx,
116
+ topPx: rect.topPx,
117
+ widthPx: LABEL_WIDTH_PX,
118
+ heightPx: Math.max(20, Math.min(rect.heightPx, 48)),
119
+ };
120
+
121
+ return (
122
+ <React.Fragment key={`${segment.scopeId}:${segment.pageIndex}:${segment.fromOffset}`}>
123
+ {/* Flat tint behind the scoped block region */}
124
+ <div
125
+ className={`wre-scope-rail-tint wre-scope-rail-tint-${style.tintToken} absolute ${
126
+ isActive ? "wre-scope-rail-tint-active" : ""
127
+ }`}
128
+ data-scope-id={segment.scopeId}
129
+ data-posture={segment.posture}
130
+ style={projectRectToOverlay(tintRect, projectorSpace)}
131
+ />
132
+ {/* Gutter label + icon outside the page frame */}
133
+ <button
134
+ type="button"
135
+ className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken} pointer-events-auto absolute flex flex-col items-center justify-center gap-1 rounded-md text-[10px] font-semibold uppercase tracking-[0.08em] ${
136
+ isActive ? "wre-scope-rail-label-active" : ""
137
+ }`}
138
+ data-scope-id={segment.scopeId}
139
+ data-posture={segment.posture}
140
+ data-icon={style.icon}
141
+ aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
142
+ onClick={onSegmentClick ? () => onSegmentClick(segment) : undefined}
143
+ style={projectRectToOverlay(labelRect, projectorSpace)}
144
+ >
145
+ <span aria-hidden="true" className={`wre-scope-rail-icon wre-scope-rail-icon-${style.icon}`} />
146
+ <span className="wre-scope-rail-label-text">{style.labelText}</span>
147
+ </button>
148
+ </React.Fragment>
149
+ );
150
+ })}
151
+ </div>
152
+ );
153
+ };
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Internals
157
+ // ---------------------------------------------------------------------------
158
+
159
+ function resolveSegmentRect(
160
+ facet: WordReviewEditorLayoutFacet,
161
+ _frame: { anchorIndex: { byRuntimeOffset: (offset: number) => RenderFrameRect | null } },
162
+ segment: ScopeRailSegment,
163
+ ): RenderFrameRect | null {
164
+ const fromRect = _frame.anchorIndex.byRuntimeOffset(segment.fromOffset);
165
+ const toRect = _frame.anchorIndex.byRuntimeOffset(Math.max(segment.fromOffset, segment.toOffset - 1));
166
+ const unioned = unionRect(fromRect, toRect);
167
+ if (unioned) return unioned;
168
+ // Fall back to the page rect so long scopes that can't resolve per-line
169
+ // still render a posture in the gutter.
170
+ const fallbackPage = facet.getPage(segment.pageIndex);
171
+ if (!fallbackPage) return null;
172
+ // Approximate: derive a rect from the page's body region by asking the
173
+ // render frame for the page rect. Without a render kernel kernel this
174
+ // function returns null and the segment is skipped.
175
+ return null;
176
+ }
177
+
178
+ export default TwScopeRailLayer;
@@ -103,6 +103,10 @@ export function createFastTextEditLane(
103
103
  intent: PredictedIntent,
104
104
  buildTx: (tr: Transaction) => Transaction | null,
105
105
  ): void {
106
+ // IME composition holds the DOM range; predicting through it would
107
+ // fight the browser's composition DOM mutations. The bridge's
108
+ // compositionstart/end handlers toggle this flag on the session.
109
+ if (options.session.isComposing()) return;
106
110
  const view = options.getView();
107
111
  const positionMap = options.getPositionMap();
108
112
  if (!view || !positionMap) return;