mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: eye dropper (#6615)
This commit is contained in:
parent
644685a5a8
commit
079aa72475
31 changed files with 803 additions and 250 deletions
215
src/components/EyeDropper.tsx
Normal file
215
src/components/EyeDropper.tsx
Normal file
|
@ -0,0 +1,215 @@
|
|||
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,
|
||||
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<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,
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue