Skip to content

Commit 76d6878

Browse files
authored
more refact (#246)
1 parent d1eb32d commit 76d6878

35 files changed

+433
-735
lines changed

packages/design-system/src/index.tsx

Lines changed: 2 additions & 65 deletions
Large diffs are not rendered by default.

packages/react-grab/e2e/fixtures.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export interface ReactGrabPageObject {
105105
rightClickAtPosition: (x: number, y: number) => Promise<void>;
106106
dragSelect: (startSelector: string, endSelector: string) => Promise<void>;
107107
getClipboardContent: () => Promise<string>;
108+
captureNextClipboardWrites: () => Promise<Record<string, string>>;
108109
waitForSelectionBox: () => Promise<void>;
109110
waitForSelectionSource: () => Promise<void>;
110111
isContextMenuVisible: () => Promise<boolean>;
@@ -346,6 +347,35 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
346347
return page.evaluate(() => navigator.clipboard.readText());
347348
};
348349

350+
const captureNextClipboardWrites = async () => {
351+
return page.evaluate(() => {
352+
return new Promise<Record<string, string>>((resolve) => {
353+
const originalSetData = DataTransfer.prototype.setData;
354+
const clipboardWrites: Record<string, string> = {};
355+
DataTransfer.prototype.setData = function (type: string, value: string) {
356+
clipboardWrites[type] = value;
357+
return originalSetData.call(this, type, value);
358+
};
359+
360+
const cleanup = () => {
361+
DataTransfer.prototype.setData = originalSetData;
362+
resolve(clipboardWrites);
363+
};
364+
365+
const safetyTimeout = setTimeout(cleanup, 5000);
366+
367+
document.addEventListener(
368+
"copy",
369+
() => {
370+
clearTimeout(safetyTimeout);
371+
queueMicrotask(cleanup);
372+
},
373+
{ once: true, capture: true },
374+
);
375+
});
376+
});
377+
};
378+
349379
const waitForSelectionBox = async () => {
350380
await page.waitForFunction(
351381
() => {
@@ -2396,6 +2426,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
23962426
rightClickAtPosition,
23972427
dragSelect,
23982428
getClipboardContent,
2429+
captureNextClipboardWrites,
23992430
waitForSelectionBox,
24002431
waitForSelectionSource,
24012432
isContextMenuVisible,

packages/react-grab/e2e/selection.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ test.describe("Element Selection", () => {
4444
.toContain("Todo List");
4545
});
4646

47+
test("should write React Grab clipboard metadata on copy", async ({
48+
reactGrab,
49+
}) => {
50+
await reactGrab.activate();
51+
await reactGrab.hoverElement("[data-testid='todo-list'] h1");
52+
await reactGrab.waitForSelectionBox();
53+
54+
const copyPayloadPromise = reactGrab.captureNextClipboardWrites();
55+
await reactGrab.clickElement("[data-testid='todo-list'] h1");
56+
const copyPayload = await copyPayloadPromise;
57+
const clipboardMetadataText = copyPayload["application/x-react-grab"];
58+
if (!clipboardMetadataText) {
59+
throw new Error("Missing React Grab clipboard metadata");
60+
}
61+
62+
const clipboardMetadata = JSON.parse(clipboardMetadataText);
63+
expect(clipboardMetadata.content).toContain("Todo List");
64+
expect(clipboardMetadata.entries).toHaveLength(1);
65+
expect(clipboardMetadata.entries[0].content).toContain("Todo List");
66+
});
67+
4768
test("should highlight different elements when hovering", async ({
4869
reactGrab,
4970
}) => {

packages/react-grab/src/components/clear-history-prompt.tsx

Lines changed: 10 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
import { Show, onMount, onCleanup } from "solid-js";
22
import type { Component } from "solid-js";
33
import type { DropdownAnchor } from "../types.js";
4-
import { DROPDOWN_EDGE_TRANSFORM_ORIGIN, PANEL_STYLES } from "../constants.js";
4+
import { DROPDOWN_EDGE_TRANSFORM_ORIGIN, Z_INDEX_LABEL } from "../constants.js";
55
import { cn } from "../utils/cn.js";
6-
import { isEventFromOverlay } from "../utils/is-event-from-overlay.js";
7-
import { isKeyboardEventTriggeredByInput } from "../utils/is-keyboard-event-triggered-by-input.js";
86
import { DiscardPrompt } from "./selection-label/discard-prompt.js";
9-
import {
10-
nativeCancelAnimationFrame,
11-
nativeRequestAnimationFrame,
12-
} from "../utils/native-raf.js";
137
import { suppressMenuEvent } from "../utils/suppress-menu-event.js";
148
import { createAnchoredDropdown } from "../utils/create-anchored-dropdown.js";
9+
import { registerOverlayDismiss } from "../utils/register-overlay-dismiss.js";
1510

1611
interface ClearHistoryPromptProps {
1712
position: DropdownAnchor | null;
@@ -31,54 +26,16 @@ export const ClearHistoryPrompt: Component<ClearHistoryPromptProps> = (
3126

3227
onMount(() => {
3328
dropdown.measure();
34-
35-
const handleKeyDown = (event: KeyboardEvent) => {
36-
if (!props.position) return;
37-
if (isKeyboardEventTriggeredByInput(event)) return;
38-
const isEnter = event.code === "Enter";
39-
const isEscape = event.code === "Escape";
40-
if (isEnter || isEscape) {
41-
event.preventDefault();
42-
event.stopImmediatePropagation();
43-
if (isEscape) {
44-
props.onCancel();
45-
} else {
46-
props.onConfirm();
47-
}
48-
}
49-
};
50-
51-
window.addEventListener("keydown", handleKeyDown, { capture: true });
52-
53-
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
54-
if (
55-
!props.position ||
56-
isEventFromOverlay(event, "data-react-grab-ignore-events")
57-
)
58-
return;
59-
props.onCancel();
60-
};
61-
62-
// HACK: Delay mousedown listener to avoid catching the triggering click
63-
const frameId = nativeRequestAnimationFrame(() => {
64-
window.addEventListener("mousedown", handleClickOutside, {
65-
capture: true,
66-
});
67-
window.addEventListener("touchstart", handleClickOutside, {
68-
capture: true,
69-
});
29+
const unregisterOverlayDismiss = registerOverlayDismiss({
30+
isOpen: () => Boolean(props.position),
31+
onDismiss: props.onCancel,
32+
onConfirm: props.onConfirm,
33+
shouldIgnoreInputEvents: true,
7034
});
7135

7236
onCleanup(() => {
73-
nativeCancelAnimationFrame(frameId);
7437
dropdown.clearAnimationHandles();
75-
window.removeEventListener("keydown", handleKeyDown, { capture: true });
76-
window.removeEventListener("mousedown", handleClickOutside, {
77-
capture: true,
78-
});
79-
window.removeEventListener("touchstart", handleClickOutside, {
80-
capture: true,
81-
});
38+
unregisterOverlayDismiss();
8239
});
8340
});
8441

@@ -92,7 +49,7 @@ export const ClearHistoryPrompt: Component<ClearHistoryPromptProps> = (
9249
style={{
9350
top: `${dropdown.displayPosition().top}px`,
9451
left: `${dropdown.displayPosition().left}px`,
95-
"z-index": "2147483647",
52+
"z-index": `${Z_INDEX_LABEL}`,
9653
"pointer-events": dropdown.isAnimatedIn() ? "auto" : "none",
9754
"transform-origin":
9855
DROPDOWN_EDGE_TRANSFORM_ORIGIN[dropdown.lastAnchorEdge()],
@@ -107,7 +64,7 @@ export const ClearHistoryPrompt: Component<ClearHistoryPromptProps> = (
10764
<div
10865
class={cn(
10966
"contain-layout flex flex-col rounded-[10px] antialiased w-fit h-fit [font-synthesis:none] [corner-shape:superellipse(1.25)]",
110-
PANEL_STYLES,
67+
"bg-white",
11168
)}
11269
>
11370
<DiscardPrompt

packages/react-grab/src/components/context-menu.tsx

Lines changed: 16 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,23 @@ import type {
1414
ContextMenuAction,
1515
ContextMenuActionContext,
1616
} from "../types.js";
17-
import { ARROW_HEIGHT_PX, LABEL_GAP_PX, PANEL_STYLES } from "../constants.js";
17+
import {
18+
ARROW_HEIGHT_PX,
19+
DROPDOWN_OFFSCREEN_POSITION,
20+
LABEL_GAP_PX,
21+
Z_INDEX_LABEL,
22+
} from "../constants.js";
1823
import { cn } from "../utils/cn.js";
1924
import { Arrow } from "./selection-label/arrow.js";
2025
import { TagBadge } from "./selection-label/tag-badge.js";
2126
import { BottomSection } from "./selection-label/bottom-section.js";
2227
import { formatShortcut } from "../utils/format-shortcut.js";
2328
import { getTagDisplay } from "../utils/get-tag-display.js";
2429
import { resolveActionEnabled } from "../utils/resolve-action-enabled.js";
25-
import { isEventFromOverlay } from "../utils/is-event-from-overlay.js";
26-
import {
27-
nativeCancelAnimationFrame,
28-
nativeRequestAnimationFrame,
29-
} from "../utils/native-raf.js";
30+
import { nativeRequestAnimationFrame } from "../utils/native-raf.js";
3031
import { createMenuHighlight } from "../utils/create-menu-highlight.js";
3132
import { suppressMenuEvent } from "../utils/suppress-menu-event.js";
33+
import { registerOverlayDismiss } from "../utils/register-overlay-dismiss.js";
3234

3335
interface ContextMenuProps {
3436
position: Position | null;
@@ -92,8 +94,8 @@ export const ContextMenu: Component<ContextMenuProps> = (props) => {
9294

9395
if (labelWidth === 0 || labelHeight === 0 || !bounds || !clickPosition) {
9496
return {
95-
left: -9999,
96-
top: -9999,
97+
left: DROPDOWN_OFFSCREEN_POSITION.left,
98+
top: DROPDOWN_OFFSCREEN_POSITION.top,
9799
arrowLeft: 0,
98100
arrowPosition: "bottom" as const,
99101
};
@@ -166,20 +168,9 @@ export const ContextMenu: Component<ContextMenuProps> = (props) => {
166168
onMount(() => {
167169
measureContainer();
168170

169-
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
170-
if (
171-
!isVisible() ||
172-
isEventFromOverlay(event, "data-react-grab-ignore-events")
173-
)
174-
return;
175-
if (event instanceof MouseEvent && event.button === 2) return;
176-
props.onDismiss();
177-
};
178-
179171
const handleKeyDown = (event: KeyboardEvent) => {
180172
if (!isVisible()) return;
181173

182-
const isEscape = event.code === "Escape";
183174
const isEnter = event.key === "Enter";
184175
const hasModifierKey = event.metaKey || event.ctrlKey;
185176
const keyLower = event.key.toLowerCase();
@@ -197,13 +188,6 @@ export const ContextMenu: Component<ContextMenuProps> = (props) => {
197188
return true;
198189
};
199190

200-
if (isEscape) {
201-
event.preventDefault();
202-
event.stopPropagation();
203-
props.onDismiss();
204-
return;
205-
}
206-
207191
if (isEnter) {
208192
const enterAction = pluginActions.find(
209193
(action) => action.shortcut === "Enter",
@@ -228,25 +212,14 @@ export const ContextMenu: Component<ContextMenuProps> = (props) => {
228212
}
229213
};
230214

231-
// HACK: Delay mousedown/touchstart listener to avoid catching the triggering right-click
232-
const frameId = nativeRequestAnimationFrame(() => {
233-
window.addEventListener("mousedown", handleClickOutside, {
234-
capture: true,
235-
});
236-
window.addEventListener("touchstart", handleClickOutside, {
237-
capture: true,
238-
});
215+
const unregisterOverlayDismiss = registerOverlayDismiss({
216+
isOpen: isVisible,
217+
onDismiss: props.onDismiss,
239218
});
240219
window.addEventListener("keydown", handleKeyDown, { capture: true });
241220

242221
onCleanup(() => {
243-
nativeCancelAnimationFrame(frameId);
244-
window.removeEventListener("mousedown", handleClickOutside, {
245-
capture: true,
246-
});
247-
window.removeEventListener("touchstart", handleClickOutside, {
248-
capture: true,
249-
});
222+
unregisterOverlayDismiss();
250223
window.removeEventListener("keydown", handleKeyDown, { capture: true });
251224
});
252225
});
@@ -261,7 +234,7 @@ export const ContextMenu: Component<ContextMenuProps> = (props) => {
261234
style={{
262235
top: `${computedPosition().top}px`,
263236
left: `${computedPosition().left}px`,
264-
"z-index": "2147483647",
237+
"z-index": `${Z_INDEX_LABEL}`,
265238
"pointer-events": "auto",
266239
}}
267240
onPointerDown={suppressMenuEvent}
@@ -278,7 +251,7 @@ export const ContextMenu: Component<ContextMenuProps> = (props) => {
278251
<div
279252
class={cn(
280253
"contain-layout flex flex-col justify-center items-start rounded-[10px] antialiased w-fit h-fit min-w-[100px] [font-synthesis:none] [corner-shape:superellipse(1.25)]",
281-
PANEL_STYLES,
254+
"bg-white",
282255
)}
283256
>
284257
<div class="contain-layout shrink-0 flex items-center gap-1 pt-1.5 pb-1 w-fit h-fit px-2">

packages/react-grab/src/components/history-dropdown.tsx

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
DROPDOWN_VIEWPORT_PADDING_PX,
1818
FEEDBACK_DURATION_MS,
1919
SAFE_POLYGON_BUFFER_PX,
20-
PANEL_STYLES,
20+
Z_INDEX_LABEL,
2121
} from "../constants.js";
2222
import { createSafePolygonTracker } from "../utils/safe-polygon.js";
2323
import { cn } from "../utils/cn.js";
@@ -28,6 +28,7 @@ import { Tooltip } from "./tooltip.jsx";
2828
import { createMenuHighlight } from "../utils/create-menu-highlight.js";
2929
import { suppressMenuEvent } from "../utils/suppress-menu-event.js";
3030
import { createAnchoredDropdown } from "../utils/create-anchored-dropdown.js";
31+
import { formatRelativeTime } from "../utils/format-relative-time.js";
3132

3233
const ITEM_ACTION_CLASS =
3334
"flex items-center justify-center cursor-pointer text-black/25 transition-colors press-scale";
@@ -47,16 +48,6 @@ interface HistoryDropdownProps {
4748
onDropdownHover?: (isHovered: boolean) => void;
4849
}
4950

50-
const formatRelativeTime = (timestamp: number): string => {
51-
const elapsedSeconds = Math.floor((Date.now() - timestamp) / 1000);
52-
if (elapsedSeconds < 60) return "now";
53-
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
54-
if (elapsedMinutes < 60) return `${elapsedMinutes}m`;
55-
const elapsedHours = Math.floor(elapsedMinutes / 60);
56-
if (elapsedHours < 24) return `${elapsedHours}h`;
57-
return `${Math.floor(elapsedHours / 24)}d`;
58-
};
59-
6051
const getHistoryItemDisplayName = (item: HistoryItem): string => {
6152
if (item.elementsCount && item.elementsCount > 1) {
6253
return `${item.elementsCount} elements`;
@@ -171,7 +162,7 @@ export const HistoryDropdown: Component<HistoryDropdownProps> = (props) => {
171162
style={{
172163
top: `${dropdown.displayPosition().top}px`,
173164
left: `${dropdown.displayPosition().left}px`,
174-
"z-index": "2147483647",
165+
"z-index": `${Z_INDEX_LABEL}`,
175166
"pointer-events": dropdown.isAnimatedIn() ? "auto" : "none",
176167
"transform-origin":
177168
DROPDOWN_EDGE_TRANSFORM_ORIGIN[dropdown.lastAnchorEdge()],
@@ -202,7 +193,7 @@ export const HistoryDropdown: Component<HistoryDropdownProps> = (props) => {
202193
<div
203194
class={cn(
204195
"contain-layout flex flex-col rounded-[10px] antialiased w-fit h-fit overflow-hidden [font-synthesis:none] [corner-shape:superellipse(1.25)]",
205-
PANEL_STYLES,
196+
"bg-white",
206197
)}
207198
style={{
208199
"min-width": `${panelMinWidth()}px`,

packages/react-grab/src/components/overlay-canvas.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
Z_INDEX_OVERLAY_CANVAS,
1717
OVERLAY_BORDER_COLOR_DRAG,
1818
OVERLAY_FILL_COLOR_DRAG,
19+
OPACITY_CONVERGENCE_THRESHOLD,
1920
OVERLAY_BORDER_COLOR_DEFAULT,
2021
OVERLAY_FILL_COLOR_DEFAULT,
2122
} from "../constants.js";
@@ -389,7 +390,7 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
389390
animation.targetOpacity,
390391
lerpFactor,
391392
);
392-
const opacityThreshold = 0.01;
393+
const opacityThreshold = OPACITY_CONVERGENCE_THRESHOLD;
393394
hasOpacityConverged =
394395
Math.abs(lerpedOpacity - animation.targetOpacity) < opacityThreshold;
395396
animation.opacity = hasOpacityConverged
@@ -452,7 +453,8 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
452453

453454
if (isLabelAnimation) {
454455
const hasOpacityConverged =
455-
Math.abs(animation.opacity - animation.targetOpacity) < 0.01;
456+
Math.abs(animation.opacity - animation.targetOpacity) <
457+
OPACITY_CONVERGENCE_THRESHOLD;
456458
if (hasOpacityConverged && animation.targetOpacity === 0) {
457459
return false;
458460
}

0 commit comments

Comments
 (0)