Skip to content
Draft
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
56 changes: 56 additions & 0 deletions packages/react-grab/e2e/toolbar-color-picker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test, expect, type ReactGrabPageObject } from "./fixtures.js";

const PICKED_COLOR_HEX = "#1a2b3c";
const COLOR_PICKER_BUTTON_SELECTOR = '[data-react-grab-toolbar-action="color-picker"]';

const waitForToolbar = async (reactGrab: ReactGrabPageObject) => {
await expect.poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }).toBe(true);
};

const stubEyeDropper = async (reactGrab: ReactGrabPageObject) => {
await reactGrab.page.addInitScript((hex) => {
class FakeEyeDropper {
open() {
return Promise.resolve({ sRGBHex: hex });
}
}
(window as unknown as { EyeDropper: typeof FakeEyeDropper }).EyeDropper = FakeEyeDropper;
}, PICKED_COLOR_HEX);
await reactGrab.page.reload({ waitUntil: "domcontentloaded" });
await reactGrab.page.waitForFunction(
() => (window as { __REACT_GRAB__?: unknown }).__REACT_GRAB__ !== undefined,
undefined,
{ timeout: 5000 },
);
await waitForToolbar(reactGrab);
};

const clickColorPicker = async (reactGrab: ReactGrabPageObject) => {
await reactGrab.page.locator(COLOR_PICKER_BUTTON_SELECTOR).first().click();
};

test.describe("Toolbar color picker", () => {
test.beforeEach(async ({ reactGrab }) => {
await stubEyeDropper(reactGrab);
});

test("renders the color picker button when the EyeDropper API is supported", async ({
reactGrab,
}) => {
expect(await reactGrab.getToolbarActionPressed("color-picker")).toBe(false);
});

test("copies the picked color to the clipboard", async ({ reactGrab }) => {
const clipboardWritesPromise = reactGrab.captureNextClipboardWrites();
await clickColorPicker(reactGrab);
const clipboardWrites = await clipboardWritesPromise;

expect(clipboardWrites["text/plain"]).toBe(PICKED_COLOR_HEX.toUpperCase());
});

test("does not enter selection mode when picking a color", async ({ reactGrab }) => {
await clickColorPicker(reactGrab);

expect(await reactGrab.isOverlayVisible()).toBe(false);
});
});
27 changes: 27 additions & 0 deletions packages/react-grab/src/components/icons/icon-eyedropper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Component } from "solid-js";

interface IconEyedropperProps {
size?: number;
class?: string;
}

export const IconEyedropper: Component<IconEyedropperProps> = (props) => {
const size = () => props.size ?? 14;

return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size()}
height={size()}
viewBox="0 0 24 24"
fill="currentColor"
class={props.class}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16.0982 2.59835C17.5627 1.13388 19.9371 1.13388 21.4015 2.59835C22.866 4.06282 22.866 6.43718 21.4015 7.90165C20.9192 8.38401 20.3362 8.7081 19.7198 8.87264L18 9.33348V12C18 12.1989 17.921 12.3897 17.7803 12.5303L17.0303 13.2803C16.7374 13.5732 16.2626 13.5732 15.9697 13.2803L15 12.3107L7.06066 20.25C6.58044 20.7302 5.92913 21 5.25 21C4.9687 21 4.69891 21.1117 4.5 21.3107L3.53033 22.2803C3.23744 22.5732 2.76256 22.5732 2.46967 22.2803L1.71967 21.5303C1.42678 21.2374 1.42678 20.7626 1.71967 20.4697L2.68934 19.5C2.88825 19.3011 3 19.0313 3 18.75C3 18.0709 3.26978 17.4196 3.75 16.9393L11.6893 8.99999L10.7197 8.03031C10.4268 7.73742 10.4268 7.26255 10.7197 6.96966L11.4697 6.21966C11.6103 6.079 11.8011 5.99998 12 5.99998H14.6661L15.127 4.27976C15.2917 3.66322 15.6161 3.08048 16.0982 2.59835ZM12.75 10.0607L4.81066 18C4.61175 18.1989 4.5 18.4687 4.5 18.75C4.5 19.0682 4.44077 19.3803 4.32841 19.6716C4.61967 19.5592 4.93178 19.5 5.25 19.5C5.5313 19.5 5.80109 19.3883 6 19.1893L13.9393 11.25L12.75 10.0607Z"
/>
</svg>
);
};
72 changes: 71 additions & 1 deletion packages/react-grab/src/components/toolbar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { createEffect, createSignal, on, onCleanup, onMount, type Component } from "solid-js";
import { createEffect, createSignal, on, onCleanup, onMount, Show, type Component } from "solid-js";
import type { Position } from "../../types.js";
import { cn } from "../../utils/cn.js";
import { loadToolbarState, saveToolbarState, type SnapEdge, type ToolbarState } from "./state.js";
import { IconSelect } from "../icons/icon-select.jsx";
import { IconComment } from "../icons/icon-comment.jsx";
import { IconStyle } from "../icons/icon-style.jsx";
import { IconEyedropper } from "../icons/icon-eyedropper.jsx";
import { ToolbarActionButton } from "./toolbar-action-button.jsx";
import { copyContent } from "../../utils/copy-content.js";
import { formatColorLabel } from "../../utils/format-color-label.js";
import { isEyedropperSupported } from "../../utils/is-eyedropper-supported.js";
import {
TOOLBAR_SNAP_MARGIN_PX,
TOOLBAR_FADE_IN_DELAY_MS,
Expand All @@ -19,6 +23,8 @@ import {
DEFAULT_ACTION_ID,
COMMENT_ACTION_ID,
EDIT_ACTION_ID,
COLOR_PICKER_ACTION_ID,
COLOR_PICK_FEEDBACK_DURATION_MS,
} from "../../constants.js";
import { freezeUpdates } from "../../utils/freeze-updates.js";
import { freezeGlobalAnimations, unfreezeGlobalAnimations } from "../../utils/freeze-animations.js";
Expand Down Expand Up @@ -281,6 +287,31 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
);
const handleStyle = drag.createDragAwareHandler(() => props.onActivateAction?.(EDIT_ACTION_ID));

const [pickedColorHex, setPickedColorHex] = createSignal<string | null>(null);
let colorFeedbackTimeout: ReturnType<typeof setTimeout> | undefined;

const handleColorPicker = drag.createDragAwareHandler(() => {
const EyeDropperConstructor = window.EyeDropper;
if (!EyeDropperConstructor) return;
setHoveredActionId(null);
// The eyedropper resolves from the user's pick, which keeps transient
// activation alive long enough for copyContent's synchronous execCommand.
new EyeDropperConstructor()
.open()
.then((result) => {
const colorLabel = formatColorLabel(result.sRGBHex);
copyContent(colorLabel);
setPickedColorHex(result.sRGBHex);
clearTimeout(colorFeedbackTimeout);
colorFeedbackTimeout = setTimeout(() => {
setPickedColorHex(null);
}, COLOR_PICK_FEEDBACK_DURATION_MS);
})
.catch(() => {
// The user dismissed the eyedropper (Escape) - nothing to copy.
});
});

const actionButtonClass =
"group contain-layout flex items-center justify-center cursor-pointer interactive-scale a11y-hitbox";
const actionButtonWrapperClass = () =>
Expand Down Expand Up @@ -563,6 +594,7 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
window.visualViewport?.removeEventListener("scroll", handleResize);
clearTimeout(resizeTimeout);
clearTimeout(collapseAnimationTimeout);
clearTimeout(colorFeedbackTimeout);

unfreezeUpdatesCallback?.();
});
Expand Down Expand Up @@ -751,6 +783,44 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
tooltipPosition={tooltipPosition()}
tooltip="Style"
/>
<Show when={isEyedropperSupported()}>
<ToolbarActionButton
actionId={COLOR_PICKER_ACTION_ID}
label="Pick color"
isActive={pickedColorHex() !== null}
class={actionButtonClass}
wrapperClass={actionButtonWrapperClass()}
onClick={handleColorPicker}
{...createFreezeHandlers(COLOR_PICKER_ACTION_ID)}
icon={
<IconEyedropper size={14} class={actionIconClass(pickedColorHex() !== null)} />
}
tooltipVisible={
isTooltipVisible(COLOR_PICKER_ACTION_ID) ||
(pickedColorHex() !== null &&
!isCollapsed() &&
!drag.isDragging() &&
!drag.isSnapping())
}
tooltipPosition={tooltipPosition()}
tooltip={
pickedColorHex() ? (
<span class="flex items-center gap-1.5">
<span
class="size-2.5 rounded-full"
style={{
"background-color": pickedColorHex() ?? "transparent",
"box-shadow": "inset 0 0 0 1px rgba(127, 127, 127, 0.4)",
}}
/>
{`Copied ${formatColorLabel(pickedColorHex() ?? "")}`}
</span>
) : (
"Pick color"
)
}
/>
</Show>
</>
}
/>
Expand Down
2 changes: 2 additions & 0 deletions packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ export const TOOLBAR_DEFAULT_POSITION_RATIO = 0.5;
export const DEFAULT_ACTION_ID = "copy";
export const COMMENT_ACTION_ID = "comment";
export const EDIT_ACTION_ID = "edit";
export const COLOR_PICKER_ACTION_ID = "color-picker";
export const COLOR_PICK_FEEDBACK_DURATION_MS = 2000;

export const TOOLTIP_DELAY_MS = 400;
export const TOOLTIP_GRACE_PERIOD_MS = 800;
Expand Down
16 changes: 16 additions & 0 deletions packages/react-grab/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@ export interface Position {
y: number;
}

export interface EyeDropperResult {
sRGBHex: string;
}

export interface EyeDropperInstance {
open: (options?: { signal?: AbortSignal }) => Promise<EyeDropperResult>;
}

declare global {
interface Window {
EyeDropper?: {
new (): EyeDropperInstance;
};
}
}

export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? T[P] extends (...args: unknown[]) => unknown
Expand Down
2 changes: 2 additions & 0 deletions packages/react-grab/src/utils/is-eyedropper-supported.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const isEyedropperSupported = (): boolean =>
typeof window !== "undefined" && typeof window.EyeDropper === "function";
Loading