Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 2 additions & 65 deletions packages/design-system/src/index.tsx

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions packages/react-grab/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export interface ReactGrabPageObject {
rightClickAtPosition: (x: number, y: number) => Promise<void>;
dragSelect: (startSelector: string, endSelector: string) => Promise<void>;
getClipboardContent: () => Promise<string>;
captureNextClipboardWrites: () => Promise<Record<string, string>>;
waitForSelectionBox: () => Promise<void>;
waitForSelectionSource: () => Promise<void>;
isContextMenuVisible: () => Promise<boolean>;
Expand Down Expand Up @@ -346,6 +347,35 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
return page.evaluate(() => navigator.clipboard.readText());
};

const captureNextClipboardWrites = async () => {
return page.evaluate(() => {
return new Promise<Record<string, string>>((resolve) => {
const originalSetData = DataTransfer.prototype.setData;
const clipboardWrites: Record<string, string> = {};
DataTransfer.prototype.setData = function (type: string, value: string) {
clipboardWrites[type] = value;
return originalSetData.call(this, type, value);
};

const cleanup = () => {
DataTransfer.prototype.setData = originalSetData;
resolve(clipboardWrites);
};

const safetyTimeout = setTimeout(cleanup, 5000);

document.addEventListener(
"copy",
() => {
clearTimeout(safetyTimeout);
queueMicrotask(cleanup);
},
{ once: true, capture: true },
);
});
});
};

const waitForSelectionBox = async () => {
await page.waitForFunction(
() => {
Expand Down Expand Up @@ -2396,6 +2426,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
rightClickAtPosition,
dragSelect,
getClipboardContent,
captureNextClipboardWrites,
waitForSelectionBox,
waitForSelectionSource,
isContextMenuVisible,
Expand Down
21 changes: 21 additions & 0 deletions packages/react-grab/e2e/selection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,27 @@ test.describe("Element Selection", () => {
.toContain("Todo List");
});

test("should write React Grab clipboard metadata on copy", async ({
reactGrab,
}) => {
await reactGrab.activate();
await reactGrab.hoverElement("[data-testid='todo-list'] h1");
await reactGrab.waitForSelectionBox();

const copyPayloadPromise = reactGrab.captureNextClipboardWrites();
await reactGrab.clickElement("[data-testid='todo-list'] h1");
const copyPayload = await copyPayloadPromise;
const clipboardMetadataText = copyPayload["application/x-react-grab"];
if (!clipboardMetadataText) {
throw new Error("Missing React Grab clipboard metadata");
}

const clipboardMetadata = JSON.parse(clipboardMetadataText);
expect(clipboardMetadata.content).toContain("Todo List");
expect(clipboardMetadata.entries).toHaveLength(1);
expect(clipboardMetadata.entries[0].content).toContain("Todo List");
});

test("should highlight different elements when hovering", async ({
reactGrab,
}) => {
Expand Down
63 changes: 10 additions & 53 deletions packages/react-grab/src/components/clear-history-prompt.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import { Show, onMount, onCleanup } from "solid-js";
import type { Component } from "solid-js";
import type { DropdownAnchor } from "../types.js";
import { DROPDOWN_EDGE_TRANSFORM_ORIGIN, PANEL_STYLES } from "../constants.js";
import { DROPDOWN_EDGE_TRANSFORM_ORIGIN, Z_INDEX_LABEL } from "../constants.js";
import { cn } from "../utils/cn.js";
import { isEventFromOverlay } from "../utils/is-event-from-overlay.js";
import { isKeyboardEventTriggeredByInput } from "../utils/is-keyboard-event-triggered-by-input.js";
import { DiscardPrompt } from "./selection-label/discard-prompt.js";
import {
nativeCancelAnimationFrame,
nativeRequestAnimationFrame,
} from "../utils/native-raf.js";
import { suppressMenuEvent } from "../utils/suppress-menu-event.js";
import { createAnchoredDropdown } from "../utils/create-anchored-dropdown.js";
import { registerOverlayDismiss } from "../utils/register-overlay-dismiss.js";

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

onMount(() => {
dropdown.measure();

const handleKeyDown = (event: KeyboardEvent) => {
if (!props.position) return;
if (isKeyboardEventTriggeredByInput(event)) return;
const isEnter = event.code === "Enter";
const isEscape = event.code === "Escape";
if (isEnter || isEscape) {
event.preventDefault();
event.stopImmediatePropagation();
if (isEscape) {
props.onCancel();
} else {
props.onConfirm();
}
}
};

window.addEventListener("keydown", handleKeyDown, { capture: true });

const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
!props.position ||
isEventFromOverlay(event, "data-react-grab-ignore-events")
)
return;
props.onCancel();
};

// HACK: Delay mousedown listener to avoid catching the triggering click
const frameId = nativeRequestAnimationFrame(() => {
window.addEventListener("mousedown", handleClickOutside, {
capture: true,
});
window.addEventListener("touchstart", handleClickOutside, {
capture: true,
});
const unregisterOverlayDismiss = registerOverlayDismiss({
isOpen: () => Boolean(props.position),
onDismiss: props.onCancel,
onConfirm: props.onConfirm,
shouldIgnoreInputEvents: true,
});

onCleanup(() => {
nativeCancelAnimationFrame(frameId);
dropdown.clearAnimationHandles();
window.removeEventListener("keydown", handleKeyDown, { capture: true });
window.removeEventListener("mousedown", handleClickOutside, {
capture: true,
});
window.removeEventListener("touchstart", handleClickOutside, {
capture: true,
});
unregisterOverlayDismiss();
});
});

Expand All @@ -92,7 +49,7 @@ export const ClearHistoryPrompt: Component<ClearHistoryPromptProps> = (
style={{
top: `${dropdown.displayPosition().top}px`,
left: `${dropdown.displayPosition().left}px`,
"z-index": "2147483647",
"z-index": `${Z_INDEX_LABEL}`,
"pointer-events": dropdown.isAnimatedIn() ? "auto" : "none",
"transform-origin":
DROPDOWN_EDGE_TRANSFORM_ORIGIN[dropdown.lastAnchorEdge()],
Expand All @@ -107,7 +64,7 @@ export const ClearHistoryPrompt: Component<ClearHistoryPromptProps> = (
<div
class={cn(
"contain-layout flex flex-col rounded-[10px] antialiased w-fit h-fit [font-synthesis:none] [corner-shape:superellipse(1.25)]",
PANEL_STYLES,
"bg-white",
)}
>
<DiscardPrompt
Expand Down
59 changes: 16 additions & 43 deletions packages/react-grab/src/components/context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,23 @@ import type {
ContextMenuAction,
ContextMenuActionContext,
} from "../types.js";
import { ARROW_HEIGHT_PX, LABEL_GAP_PX, PANEL_STYLES } from "../constants.js";
import {
ARROW_HEIGHT_PX,
DROPDOWN_OFFSCREEN_POSITION,
LABEL_GAP_PX,
Z_INDEX_LABEL,
} from "../constants.js";
import { cn } from "../utils/cn.js";
import { Arrow } from "./selection-label/arrow.js";
import { TagBadge } from "./selection-label/tag-badge.js";
import { BottomSection } from "./selection-label/bottom-section.js";
import { formatShortcut } from "../utils/format-shortcut.js";
import { getTagDisplay } from "../utils/get-tag-display.js";
import { resolveActionEnabled } from "../utils/resolve-action-enabled.js";
import { isEventFromOverlay } from "../utils/is-event-from-overlay.js";
import {
nativeCancelAnimationFrame,
nativeRequestAnimationFrame,
} from "../utils/native-raf.js";
import { nativeRequestAnimationFrame } from "../utils/native-raf.js";
import { createMenuHighlight } from "../utils/create-menu-highlight.js";
import { suppressMenuEvent } from "../utils/suppress-menu-event.js";
import { registerOverlayDismiss } from "../utils/register-overlay-dismiss.js";

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

if (labelWidth === 0 || labelHeight === 0 || !bounds || !clickPosition) {
return {
left: -9999,
top: -9999,
left: DROPDOWN_OFFSCREEN_POSITION.left,
top: DROPDOWN_OFFSCREEN_POSITION.top,
arrowLeft: 0,
arrowPosition: "bottom" as const,
};
Expand Down Expand Up @@ -166,20 +168,9 @@ export const ContextMenu: Component<ContextMenuProps> = (props) => {
onMount(() => {
measureContainer();

const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
!isVisible() ||
isEventFromOverlay(event, "data-react-grab-ignore-events")
)
return;
if (event instanceof MouseEvent && event.button === 2) return;
props.onDismiss();
};

const handleKeyDown = (event: KeyboardEvent) => {
if (!isVisible()) return;

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

if (isEscape) {
event.preventDefault();
event.stopPropagation();
props.onDismiss();
return;
}

if (isEnter) {
const enterAction = pluginActions.find(
(action) => action.shortcut === "Enter",
Expand All @@ -228,25 +212,14 @@ export const ContextMenu: Component<ContextMenuProps> = (props) => {
}
};

// HACK: Delay mousedown/touchstart listener to avoid catching the triggering right-click
const frameId = nativeRequestAnimationFrame(() => {
window.addEventListener("mousedown", handleClickOutside, {
capture: true,
});
window.addEventListener("touchstart", handleClickOutside, {
capture: true,
});
const unregisterOverlayDismiss = registerOverlayDismiss({
isOpen: isVisible,
onDismiss: props.onDismiss,
});
window.addEventListener("keydown", handleKeyDown, { capture: true });

onCleanup(() => {
nativeCancelAnimationFrame(frameId);
window.removeEventListener("mousedown", handleClickOutside, {
capture: true,
});
window.removeEventListener("touchstart", handleClickOutside, {
capture: true,
});
unregisterOverlayDismiss();
window.removeEventListener("keydown", handleKeyDown, { capture: true });
});
});
Expand All @@ -261,7 +234,7 @@ export const ContextMenu: Component<ContextMenuProps> = (props) => {
style={{
top: `${computedPosition().top}px`,
left: `${computedPosition().left}px`,
"z-index": "2147483647",
"z-index": `${Z_INDEX_LABEL}`,
"pointer-events": "auto",
}}
onPointerDown={suppressMenuEvent}
Expand All @@ -278,7 +251,7 @@ export const ContextMenu: Component<ContextMenuProps> = (props) => {
<div
class={cn(
"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)]",
PANEL_STYLES,
"bg-white",
)}
>
<div class="contain-layout shrink-0 flex items-center gap-1 pt-1.5 pb-1 w-fit h-fit px-2">
Expand Down
17 changes: 4 additions & 13 deletions packages/react-grab/src/components/history-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
DROPDOWN_VIEWPORT_PADDING_PX,
FEEDBACK_DURATION_MS,
SAFE_POLYGON_BUFFER_PX,
PANEL_STYLES,
Z_INDEX_LABEL,
} from "../constants.js";
import { createSafePolygonTracker } from "../utils/safe-polygon.js";
import { cn } from "../utils/cn.js";
Expand All @@ -28,6 +28,7 @@ import { Tooltip } from "./tooltip.jsx";
import { createMenuHighlight } from "../utils/create-menu-highlight.js";
import { suppressMenuEvent } from "../utils/suppress-menu-event.js";
import { createAnchoredDropdown } from "../utils/create-anchored-dropdown.js";
import { formatRelativeTime } from "../utils/format-relative-time.js";

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

const formatRelativeTime = (timestamp: number): string => {
const elapsedSeconds = Math.floor((Date.now() - timestamp) / 1000);
if (elapsedSeconds < 60) return "now";
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
if (elapsedMinutes < 60) return `${elapsedMinutes}m`;
const elapsedHours = Math.floor(elapsedMinutes / 60);
if (elapsedHours < 24) return `${elapsedHours}h`;
return `${Math.floor(elapsedHours / 24)}d`;
};

const getHistoryItemDisplayName = (item: HistoryItem): string => {
if (item.elementsCount && item.elementsCount > 1) {
return `${item.elementsCount} elements`;
Expand Down Expand Up @@ -171,7 +162,7 @@ export const HistoryDropdown: Component<HistoryDropdownProps> = (props) => {
style={{
top: `${dropdown.displayPosition().top}px`,
left: `${dropdown.displayPosition().left}px`,
"z-index": "2147483647",
"z-index": `${Z_INDEX_LABEL}`,
"pointer-events": dropdown.isAnimatedIn() ? "auto" : "none",
"transform-origin":
DROPDOWN_EDGE_TRANSFORM_ORIGIN[dropdown.lastAnchorEdge()],
Expand Down Expand Up @@ -202,7 +193,7 @@ export const HistoryDropdown: Component<HistoryDropdownProps> = (props) => {
<div
class={cn(
"contain-layout flex flex-col rounded-[10px] antialiased w-fit h-fit overflow-hidden [font-synthesis:none] [corner-shape:superellipse(1.25)]",
PANEL_STYLES,
"bg-white",
)}
style={{
"min-width": `${panelMinWidth()}px`,
Expand Down
6 changes: 4 additions & 2 deletions packages/react-grab/src/components/overlay-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Z_INDEX_OVERLAY_CANVAS,
OVERLAY_BORDER_COLOR_DRAG,
OVERLAY_FILL_COLOR_DRAG,
OPACITY_CONVERGENCE_THRESHOLD,
OVERLAY_BORDER_COLOR_DEFAULT,
OVERLAY_FILL_COLOR_DEFAULT,
} from "../constants.js";
Expand Down Expand Up @@ -389,7 +390,7 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
animation.targetOpacity,
lerpFactor,
);
const opacityThreshold = 0.01;
const opacityThreshold = OPACITY_CONVERGENCE_THRESHOLD;
hasOpacityConverged =
Math.abs(lerpedOpacity - animation.targetOpacity) < opacityThreshold;
animation.opacity = hasOpacityConverged
Expand Down Expand Up @@ -452,7 +453,8 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {

if (isLabelAnimation) {
const hasOpacityConverged =
Math.abs(animation.opacity - animation.targetOpacity) < 0.01;
Math.abs(animation.opacity - animation.targetOpacity) <
OPACITY_CONVERGENCE_THRESHOLD;
if (hasOpacityConverged && animation.targetOpacity === 0) {
return false;
}
Expand Down
Loading
Loading