mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
217 lines
6.3 KiB
TypeScript
217 lines
6.3 KiB
TypeScript
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 | EyeDropperProperties>(null);
|
|
|
|
export const EyeDropper: React.FC<{
|
|
onCancel: () => void;
|
|
onSelect: Required<EyeDropperProperties>["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 - appState.offsetLeft,
|
|
clientY * window.devicePixelRatio - appState.offsetTop,
|
|
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,
|
|
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,
|
|
);
|
|
};
|