@beyondwork/docx-react-component 1.0.39 → 1.0.41

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.
@@ -11,6 +11,14 @@
11
11
  * `onSetRowHeight` callbacks (which route to `ref.tables.apply(op)`).
12
12
  *
13
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.
14
22
  */
15
23
 
16
24
  import React, { useCallback, useEffect, useRef } from "react";
@@ -19,15 +27,53 @@ import type {
19
27
  TableStructureContextSnapshot,
20
28
  WordReviewEditorLayoutFacet,
21
29
  } from "../../api/public-types";
22
- import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
23
30
  import type { OverlayCoordinateSpace } from "../chrome-overlay/chrome-overlay-projector";
24
31
  import { projectRectToOverlay } from "../chrome-overlay/chrome-overlay-projector";
25
32
  import { DEFAULT_PX_PER_TWIP } from "../../runtime/render/render-frame-types";
26
33
 
27
34
  const GRIP_PX = 6;
35
+ const DRAG_THRESHOLD_PX = 3;
28
36
  const MIN_COLUMN_TWIPS = 720;
29
37
  const MIN_ROW_TWIPS = 120;
30
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
+
31
77
  export interface TwTableGripLayerProps {
32
78
  facet: WordReviewEditorLayoutFacet;
33
79
  tableContext: TableStructureContextSnapshot | null;
@@ -131,31 +177,46 @@ function ColResizeGrip({
131
177
  const dragRef = useRef<{
132
178
  startX: number;
133
179
  originalTwips: number;
180
+ dragStarted: boolean;
181
+ gripEl: HTMLElement;
134
182
  } | null>(null);
135
183
 
136
184
  const handleMouseDown = useCallback(
137
185
  (e: React.MouseEvent<HTMLElement>) => {
138
186
  if (disabled) return;
139
- preserveEditorSelectionMouseDown(e);
140
- dragRef.current = { startX: e.clientX, originalTwips };
187
+ dragRef.current = {
188
+ startX: e.clientX,
189
+ originalTwips,
190
+ dragStarted: false,
191
+ gripEl: e.currentTarget,
192
+ };
141
193
  },
142
194
  [disabled, originalTwips],
143
195
  );
144
196
 
145
197
  useEffect(() => {
146
198
  const handleMove = (e: MouseEvent) => {
147
- if (!dragRef.current) return;
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
+ }
148
205
  e.preventDefault();
149
206
  };
150
207
  const handleUp = (e: MouseEvent) => {
151
- if (!dragRef.current) return;
152
- const deltaX = e.clientX - dragRef.current.startX;
153
- const deltaTwips = deltaX / pxPerTwip;
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;
154
216
  const newTwips = Math.max(
155
217
  MIN_COLUMN_TWIPS,
156
- Math.round(dragRef.current.originalTwips + deltaTwips),
218
+ Math.round(drag.originalTwips + deltaTwips),
157
219
  );
158
- dragRef.current = null;
159
220
  onCommit?.(colIndex, newTwips);
160
221
  };
161
222
  window.addEventListener("mousemove", handleMove);
@@ -216,38 +277,46 @@ function RowResizeGrip({
216
277
  }: RowResizeGripProps) {
217
278
  const dragRef = useRef<{
218
279
  startY: number;
219
- startHeightPx: number;
280
+ dragStarted: boolean;
281
+ gripEl: HTMLElement;
220
282
  } | null>(null);
221
283
 
222
284
  const handleMouseDown = useCallback(
223
285
  (e: React.MouseEvent<HTMLElement>) => {
224
286
  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 };
287
+ dragRef.current = {
288
+ startY: e.clientY,
289
+ dragStarted: false,
290
+ gripEl: e.currentTarget,
291
+ };
229
292
  },
230
293
  [disabled],
231
294
  );
232
295
 
233
296
  useEffect(() => {
234
297
  const handleMove = (e: MouseEvent) => {
235
- if (!dragRef.current) return;
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
+ }
236
304
  e.preventDefault();
237
305
  };
238
306
  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;
307
+ const drag = dragRef.current;
308
+ if (!drag) return;
309
+ dragRef.current = null;
310
+ if (!drag.dragStarted) {
311
+ forwardNonDragClick(drag.gripEl, e);
243
312
  return;
244
313
  }
314
+ const deltaY = e.clientY - drag.startY;
245
315
  const deltaTwips = deltaY / pxPerTwip;
246
316
  // We don't know the current row height from the grip alone; use atLeast
247
317
  // so that a positive drag expands and a negative drag collapses to auto.
248
318
  const newTwips = Math.max(MIN_ROW_TWIPS, Math.round(deltaTwips));
249
319
  const rule = newTwips > 0 ? "atLeast" : "auto";
250
- dragRef.current = null;
251
320
  onCommit?.(rowIndex, newTwips, rule as "atLeast");
252
321
  };
253
322
  window.addEventListener("mousemove", handleMove);
@@ -7,6 +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 { TwScopeCard, type TwScopeCardProps } from "./tw-scope-card";
11
+ export { TwScopeCardLayer, type TwScopeCardLayerProps } from "./tw-scope-card-layer";
10
12
  export {
11
13
  inflateRect,
12
14
  projectRectToOverlay,
@@ -16,10 +16,13 @@ import * as React from "react";
16
16
  import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
17
17
  import type { ScopeRailSegment } from "../../runtime/layout";
18
18
  import type {
19
+ ScopeIssueAction,
19
20
  TableStructureContextSnapshot,
20
21
  WordReviewEditorLayoutFacet,
22
+ WorkflowScopeMode,
21
23
  } from "../../api/public-types";
22
24
  import { TwScopeRailLayer } from "./tw-scope-rail-layer";
25
+ import { TwScopeCardLayer } from "./tw-scope-card-layer";
23
26
  import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
24
27
 
25
28
  export interface TwChromeOverlayProps {
@@ -29,8 +32,38 @@ export interface TwChromeOverlayProps {
29
32
  space?: OverlayCoordinateSpace;
30
33
  /** Active scope id (for emphasis + rail tab sync). */
31
34
  activeScopeId?: string | null;
32
- /** Click handler the rail layer forwards to consumers. */
35
+ /**
36
+ * Click handler fired when the user clicks a scope rail stripe.
37
+ * P0 wires this to open the scope card (P1 ships the card layer).
38
+ */
39
+ onScopeStripeClick?: (segment: ScopeRailSegment) => void;
40
+ /**
41
+ * Legacy click handler kept for compatibility with consumers that
42
+ * subscribed before the stripe affordance existed. Called alongside
43
+ * `onScopeStripeClick` on a stripe click.
44
+ */
33
45
  onScopeSegmentClick?: (segment: ScopeRailSegment) => void;
46
+ /**
47
+ * Fires when the scope card is dismissed (close button, Escape, or
48
+ * click-outside). The owner uses this to clear `activeScopeId`.
49
+ */
50
+ onScopeCardClose?: () => void;
51
+ /**
52
+ * Fires when a mode button inside the scope card is clicked. The
53
+ * owner relays this into a `scope-mode-change-requested` event for
54
+ * the host (which then drives the overlay-apply path).
55
+ */
56
+ onScopeCardModeChange?: (scopeId: string, mode: WorkflowScopeMode) => void;
57
+ /**
58
+ * Fires when an issue action button (Resolve/Waive/Escalate) inside
59
+ * the scope card is clicked. The owner relays this into a
60
+ * `scope-issue-action-requested` event for the host.
61
+ */
62
+ onScopeCardIssueAction?: (
63
+ scopeId: string,
64
+ issueId: string,
65
+ action: ScopeIssueAction,
66
+ ) => void;
34
67
  /** Test id applied to the overlay root. */
35
68
  "data-testid"?: string;
36
69
  /** Optional extra children (e.g., future comment balloon layer). */
@@ -62,7 +95,11 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
62
95
  facet,
63
96
  space,
64
97
  activeScopeId,
98
+ onScopeStripeClick,
65
99
  onScopeSegmentClick,
100
+ onScopeCardClose,
101
+ onScopeCardModeChange,
102
+ onScopeCardIssueAction,
66
103
  "data-testid": testId,
67
104
  children,
68
105
  tableContext,
@@ -79,8 +116,17 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
79
116
  facet={facet}
80
117
  space={space}
81
118
  activeScopeId={activeScopeId}
119
+ onStripeClick={onScopeStripeClick}
82
120
  onSegmentClick={onScopeSegmentClick}
83
121
  />
122
+ <TwScopeCardLayer
123
+ facet={facet}
124
+ activeScopeId={activeScopeId ?? null}
125
+ onClose={onScopeCardClose ?? noop}
126
+ onModeChange={onScopeCardModeChange ?? noopModeChange}
127
+ onIssueAction={onScopeCardIssueAction ?? noopIssueAction}
128
+ space={space}
129
+ />
84
130
  <TwTableGripLayer
85
131
  facet={facet}
86
132
  tableContext={tableContext ?? null}
@@ -93,4 +139,12 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
93
139
  );
94
140
  };
95
141
 
142
+ const noop = () => undefined;
143
+ const noopModeChange = (_scopeId: string, _mode: WorkflowScopeMode) => undefined;
144
+ const noopIssueAction = (
145
+ _scopeId: string,
146
+ _issueId: string,
147
+ _action: ScopeIssueAction,
148
+ ) => undefined;
149
+
96
150
  export default TwChromeOverlay;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * TwScopeCardLayer — renders at most one scope card at a time, chosen
3
+ * by `activeScopeId`. Consumes `facet.getAllScopeCardModels()` to
4
+ * look up the model, then positions the card 8px above the model's
5
+ * `primaryAnchorRect`.
6
+ *
7
+ * Per docs/plans/scope-card-overlay.md P1, the layer keeps
8
+ * positioning pure: it never calls `getBoundingClientRect` or reads
9
+ * DOM rects. Coordinates come from the render kernel's anchor index
10
+ * through `ScopeCardModel.primaryAnchorRect`, projected into the
11
+ * overlay's own coordinate space via the shared projector.
12
+ *
13
+ * Auto-flip, pin, and overlapping-scope stacking land in P3. P1
14
+ * renders a single card above the scope's first line and falls back
15
+ * to a top-left overlay placement when no rect is resolvable.
16
+ */
17
+
18
+ import * as React from "react";
19
+ import {
20
+ projectRectToOverlay,
21
+ type OverlayCoordinateSpace,
22
+ } from "./chrome-overlay-projector";
23
+ import { TwScopeCard } from "./tw-scope-card";
24
+ import type {
25
+ ScopeIssueAction,
26
+ WorkflowScopeMode,
27
+ } from "../../api/public-types";
28
+ import type { RenderFrameRect } from "../../runtime/render";
29
+ import type { WordReviewEditorLayoutFacet } from "../../runtime/layout";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export interface TwScopeCardLayerProps {
36
+ facet: WordReviewEditorLayoutFacet;
37
+ activeScopeId: string | null;
38
+ onClose: () => void;
39
+ onModeChange: (scopeId: string, mode: WorkflowScopeMode) => void;
40
+ onIssueAction: (
41
+ scopeId: string,
42
+ issueId: string,
43
+ action: ScopeIssueAction,
44
+ ) => void;
45
+ space?: OverlayCoordinateSpace;
46
+ "data-testid"?: string;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Component
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const CARD_GAP_PX = 8;
54
+ const CARD_ESTIMATED_HEIGHT_PX = 160;
55
+ const CARD_FALLBACK_LEFT_PX = 16;
56
+ const CARD_FALLBACK_TOP_PX = 16;
57
+
58
+ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
59
+ facet,
60
+ activeScopeId,
61
+ onClose,
62
+ onModeChange,
63
+ onIssueAction,
64
+ space,
65
+ "data-testid": testId,
66
+ }) => {
67
+ if (!activeScopeId) return null;
68
+ const models =
69
+ typeof facet.getAllScopeCardModels === "function"
70
+ ? facet.getAllScopeCardModels()
71
+ : [];
72
+ const model = models.find((m) => m.scopeId === activeScopeId);
73
+ if (!model) return null;
74
+
75
+ const projectorSpace: OverlayCoordinateSpace =
76
+ space ?? { originLeftPx: 0, originTopPx: 0 };
77
+ const positioned = resolveCardPosition(
78
+ model.primaryAnchorRect,
79
+ projectorSpace,
80
+ );
81
+
82
+ return (
83
+ <div
84
+ className="wre-scope-card-layer pointer-events-none absolute inset-0 z-30"
85
+ data-testid={testId ?? "scope-card-layer"}
86
+ data-active-scope-id={activeScopeId}
87
+ >
88
+ <div style={positioned} className="absolute">
89
+ <TwScopeCard
90
+ model={model}
91
+ onClose={onClose}
92
+ onModeChange={(mode) => onModeChange(model.scopeId, mode)}
93
+ onIssueAction={(action) => {
94
+ if (!model.issue) return;
95
+ onIssueAction(model.scopeId, model.issue.issueId, action);
96
+ }}
97
+ />
98
+ </div>
99
+ </div>
100
+ );
101
+ };
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Positioning
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function resolveCardPosition(
108
+ anchor: RenderFrameRect | null,
109
+ space: OverlayCoordinateSpace,
110
+ ): React.CSSProperties {
111
+ if (!anchor) {
112
+ return {
113
+ left: `${CARD_FALLBACK_LEFT_PX}px`,
114
+ top: `${CARD_FALLBACK_TOP_PX}px`,
115
+ };
116
+ }
117
+ // Position the card above the anchor's first line, leaving
118
+ // `CARD_GAP_PX` between anchor top and card bottom. P3 adds auto-
119
+ // flip based on viewport clipping.
120
+ const targetRect: RenderFrameRect = {
121
+ leftPx: anchor.leftPx,
122
+ topPx: Math.max(0, anchor.topPx - CARD_ESTIMATED_HEIGHT_PX - CARD_GAP_PX),
123
+ widthPx: 1,
124
+ heightPx: 1,
125
+ };
126
+ const projected = projectRectToOverlay(targetRect, space);
127
+ return {
128
+ left: projected.left,
129
+ top: projected.top,
130
+ };
131
+ }
132
+
133
+ export default TwScopeCardLayer;