import { atom } from "jotai"; import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { COLOR_PALETTE, rgbToHex } from "../colors"; import { EVENT } from "../constants"; import { useUIAppState } from "../context/ui-appState"; import { mutateElement } from "../element/mutateElement"; import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer"; import { useOutsideClick } from "../hooks/useOutsideClick"; import { KEYS } from "../keys"; import { invalidateShapeForElement } from "../renderer/renderElement"; import { getSelectedElements } from "../scene"; import Scene from "../scene/Scene"; import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App"; import "./EyeDropper.scss"; type EyeDropperProperties = { keepOpenOnAlt: boolean; swapPreviewOnAlt?: boolean; onSelect?: (color: string, event: PointerEvent) => void; previewType?: "strokeColor" | "backgroundColor"; }; export const activeEyeDropperAtom = atom(null); export const EyeDropper: React.FC<{ onCancel: () => void; onSelect: Required["onSelect"]; swapPreviewOnAlt?: EyeDropperProperties["swapPreviewOnAlt"]; previewType?: EyeDropperProperties["previewType"]; }> = ({ onCancel, onSelect, swapPreviewOnAlt, previewType = "backgroundColor", }) => { 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 metaStuffRef = useRef({ selectedElements, app }); metaStuffRef.current.selectedElements = selectedElements; metaStuffRef.current.app = app; const { container: excalidrawContainer } = useExcalidrawContainer(); useEffect(() => { const colorPreviewDiv = ref.current; if (!colorPreviewDiv || !app.canvas || !eyeDropperContainer) { return; } let currentColor = COLOR_PALETTE.black; let isHoldingPointerDown = false; const ctx = app.canvas.getContext("2d")!; 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 pixel = ctx.getImageData( clientX * window.devicePixelRatio, clientY * window.devicePixelRatio, 1, 1, ).data; currentColor = rgbToHex(pixel[0], pixel[1], pixel[2]); if (isHoldingPointerDown) { for (const element of metaStuffRef.current.selectedElements) { mutateElement( element, { [altKey && swapPreviewOnAlt ? previewType === "strokeColor" ? "backgroundColor" : "strokeColor" : previewType]: currentColor, }, false, ); invalidateShapeForElement(element); } Scene.getScene( metaStuffRef.current.selectedElements[0], )?.informMutation(); } colorPreviewDiv.style.background = currentColor; }; 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(currentColor, 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: metaStuffRef.current.app.lastViewportPosition.x, clientY: metaStuffRef.current.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); }; }, [ app.canvas, eyeDropperContainer, onCancel, onSelect, swapPreviewOnAlt, previewType, excalidrawContainer, ]); const ref = useRef(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(
, eyeDropperContainer, ); };