aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/EyeDropper.tsx
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/EyeDropper.tsx
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/EyeDropper.tsx')
-rw-r--r--packages/excalidraw/components/EyeDropper.tsx235
1 files changed, 235 insertions, 0 deletions
diff --git a/packages/excalidraw/components/EyeDropper.tsx b/packages/excalidraw/components/EyeDropper.tsx
new file mode 100644
index 0000000..429c68a
--- /dev/null
+++ b/packages/excalidraw/components/EyeDropper.tsx
@@ -0,0 +1,235 @@
+import { useEffect, useRef } from "react";
+import { createPortal } from "react-dom";
+import { rgbToHex } from "../colors";
+import { EVENT } from "../constants";
+import { useUIAppState } from "../context/ui-appState";
+import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
+import { useOutsideClick } from "../hooks/useOutsideClick";
+import { KEYS } from "../keys";
+import { getSelectedElements } from "../scene";
+import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
+import { useStable } from "../hooks/useStable";
+
+import "./EyeDropper.scss";
+import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
+import type { ExcalidrawElement } from "../element/types";
+import { atom } from "../editor-jotai";
+
+export type EyeDropperProperties = {
+ keepOpenOnAlt: boolean;
+ swapPreviewOnAlt?: boolean;
+ /** called when user picks color (on pointerup) */
+ onSelect: (color: string, event: PointerEvent) => void;
+ /**
+ * property of selected elements to update live when alt-dragging.
+ * Supply `null` if not applicable (e.g. updating the canvas bg instead of
+ * elements)
+ **/
+ colorPickerType: ColorPickerType;
+};
+
+export const activeEyeDropperAtom = atom<null | EyeDropperProperties>(null);
+
+export const EyeDropper: React.FC<{
+ onCancel: () => void;
+ onSelect: EyeDropperProperties["onSelect"];
+ /** called when color changes, on pointerdown for preview */
+ onChange: (
+ type: ColorPickerType,
+ color: string,
+ selectedElements: ExcalidrawElement[],
+ event: { altKey: boolean },
+ ) => void;
+ colorPickerType: EyeDropperProperties["colorPickerType"];
+}> = ({ onCancel, onChange, onSelect, colorPickerType }) => {
+ const eyeDropperContainer = useCreatePortalContainer({
+ className: "excalidraw-eye-dropper-backdrop",
+ parentSelector: ".excalidraw-eye-dropper-container",
+ });
+ const appState = useUIAppState();
+ const elements = useExcalidrawElements();
+ const app = useApp();
+
+ const selectedElements = getSelectedElements(elements, appState);
+
+ const stableProps = useStable({
+ app,
+ onCancel,
+ onChange,
+ onSelect,
+ selectedElements,
+ });
+
+ const { container: excalidrawContainer } = useExcalidrawContainer();
+
+ useEffect(() => {
+ const colorPreviewDiv = ref.current;
+
+ if (!colorPreviewDiv || !app.canvas || !eyeDropperContainer) {
+ return;
+ }
+
+ let isHoldingPointerDown = false;
+
+ const ctx = app.canvas.getContext("2d")!;
+
+ const getCurrentColor = ({
+ clientX,
+ clientY,
+ }: {
+ clientX: number;
+ clientY: number;
+ }) => {
+ const pixel = ctx.getImageData(
+ (clientX - appState.offsetLeft) * window.devicePixelRatio,
+ (clientY - appState.offsetTop) * window.devicePixelRatio,
+ 1,
+ 1,
+ ).data;
+
+ return rgbToHex(pixel[0], pixel[1], pixel[2]);
+ };
+
+ const mouseMoveListener = ({
+ clientX,
+ clientY,
+ altKey,
+ }: {
+ clientX: number;
+ clientY: number;
+ altKey: boolean;
+ }) => {
+ // FIXME swap offset when the preview gets outside viewport
+ colorPreviewDiv.style.top = `${clientY + 20}px`;
+ colorPreviewDiv.style.left = `${clientX + 20}px`;
+
+ const currentColor = getCurrentColor({ clientX, clientY });
+
+ if (isHoldingPointerDown) {
+ stableProps.onChange(
+ colorPickerType,
+ currentColor,
+ stableProps.selectedElements,
+ { altKey },
+ );
+ }
+
+ colorPreviewDiv.style.background = currentColor;
+ };
+
+ const onCancel = () => {
+ stableProps.onCancel();
+ };
+
+ const onSelect: Required<EyeDropperProperties>["onSelect"] = (
+ color,
+ event,
+ ) => {
+ stableProps.onSelect(color, event);
+ };
+
+ const pointerDownListener = (event: PointerEvent) => {
+ isHoldingPointerDown = true;
+ // NOTE we can't event.preventDefault() as that would stop
+ // pointermove events
+ event.stopImmediatePropagation();
+ };
+
+ const pointerUpListener = (event: PointerEvent) => {
+ isHoldingPointerDown = false;
+
+ // since we're not preventing default on pointerdown, the focus would
+ // goes back to `body` so we want to refocus the editor container instead
+ excalidrawContainer?.focus();
+
+ event.stopImmediatePropagation();
+ event.preventDefault();
+
+ onSelect(getCurrentColor(event), event);
+ };
+
+ const keyDownListener = (event: KeyboardEvent) => {
+ if (event.key === KEYS.ESCAPE) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ onCancel();
+ }
+ };
+
+ // -------------------------------------------------------------------------
+
+ eyeDropperContainer.tabIndex = -1;
+ // focus container so we can listen on keydown events
+ eyeDropperContainer.focus();
+
+ // init color preview else it would show only after the first mouse move
+ mouseMoveListener({
+ clientX: stableProps.app.lastViewportPosition.x,
+ clientY: stableProps.app.lastViewportPosition.y,
+ altKey: false,
+ });
+
+ eyeDropperContainer.addEventListener(EVENT.KEYDOWN, keyDownListener);
+ eyeDropperContainer.addEventListener(
+ EVENT.POINTER_DOWN,
+ pointerDownListener,
+ );
+ eyeDropperContainer.addEventListener(EVENT.POINTER_UP, pointerUpListener);
+ window.addEventListener("pointermove", mouseMoveListener, {
+ passive: true,
+ });
+ window.addEventListener(EVENT.BLUR, onCancel);
+
+ return () => {
+ isHoldingPointerDown = false;
+ eyeDropperContainer.removeEventListener(EVENT.KEYDOWN, keyDownListener);
+ eyeDropperContainer.removeEventListener(
+ EVENT.POINTER_DOWN,
+ pointerDownListener,
+ );
+ eyeDropperContainer.removeEventListener(
+ EVENT.POINTER_UP,
+ pointerUpListener,
+ );
+ window.removeEventListener("pointermove", mouseMoveListener);
+ window.removeEventListener(EVENT.BLUR, onCancel);
+ };
+ }, [
+ stableProps,
+ app.canvas,
+ eyeDropperContainer,
+ colorPickerType,
+ excalidrawContainer,
+ appState.offsetLeft,
+ appState.offsetTop,
+ ]);
+
+ const ref = useRef<HTMLDivElement>(null);
+
+ useOutsideClick(
+ ref,
+ () => {
+ onCancel();
+ },
+ (event) => {
+ if (
+ event.target.closest(
+ ".excalidraw-eye-dropper-trigger, .excalidraw-eye-dropper-backdrop",
+ )
+ ) {
+ return true;
+ }
+ // consider all other clicks as outside
+ return false;
+ },
+ );
+
+ if (!eyeDropperContainer) {
+ return null;
+ }
+
+ return createPortal(
+ <div ref={ref} className="excalidraw-eye-dropper-preview" />,
+ eyeDropperContainer,
+ );
+};