@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.
- package/package.json +41 -31
- package/src/api/public-types.ts +496 -1
- package/src/core/commands/section-layout-commands.ts +58 -0
- package/src/core/commands/table-grid.ts +431 -0
- package/src/core/commands/table-structure-commands.ts +845 -56
- package/src/core/commands/text-commands.ts +122 -2
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-main-document.ts +2 -11
- package/src/io/export/serialize-numbering.ts +43 -10
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/export/serialize-tables.ts +74 -0
- package/src/io/export/table-properties-xml.ts +139 -4
- package/src/io/normalize/normalize-text.ts +15 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-footnotes.ts +60 -0
- package/src/io/ooxml/parse-headers-footers.ts +60 -0
- package/src/io/ooxml/parse-main-document.ts +137 -0
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/parse-tables.ts +249 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +117 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-layout.ts +4 -2
- package/src/runtime/document-navigation.ts +1 -1
- package/src/runtime/document-runtime.ts +248 -18
- package/src/runtime/layout/default-page-format.ts +96 -0
- package/src/runtime/layout/index.ts +47 -0
- package/src/runtime/layout/inert-layout-facet.ts +16 -0
- package/src/runtime/layout/layout-engine-instance.ts +100 -23
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/margin-preset-catalog.ts +178 -0
- package/src/runtime/layout/page-format-catalog.ts +233 -0
- package/src/runtime/layout/page-graph.ts +55 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +484 -37
- package/src/runtime/layout/project-block-fragments.ts +225 -0
- package/src/runtime/layout/public-facet.ts +748 -16
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +249 -0
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -0
- package/src/runtime/render/block-fragment-projection.ts +35 -0
- package/src/runtime/render/decoration-resolver.ts +189 -0
- package/src/runtime/render/index.ts +57 -0
- package/src/runtime/render/pending-op-delta-reader.ts +129 -0
- package/src/runtime/render/render-frame-types.ts +317 -0
- package/src/runtime/render/render-kernel.ts +759 -0
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -0
- package/src/runtime/view-state.ts +67 -0
- package/src/runtime/workflow-markup.ts +1 -5
- package/src/runtime/workflow-rail-segments.ts +280 -0
- package/src/ui/WordReviewEditor.tsx +368 -19
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +310 -15
- package/src/ui/headless/scoped-chrome-policy.ts +49 -1
- package/src/ui/headless/selection-tool-types.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
- package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +29 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
- package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
- package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
- package/src/ui-tailwind/theme/editor-theme.css +498 -163
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
- package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
- package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
- package/src/runtime/collab-review-sync.ts +0 -254
- 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;
|