You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
success/packages/excalidraw/components/EyeDropper.tsx

236 lines
6.5 KiB
TypeScript

import { atom } from "jotai";
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 { ColorPickerType } from "./ColorPicker/colorPickerUtils";
import { ExcalidrawElement } from "../element/types";
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,
);
};