feat(editor): eyedropper fixes and improvements (#11859)

Co-authored-by: rafa <rafaondjango@gmail.com>
This commit is contained in:
David Luzar
2026-08-08 23:17:26 +02:00
committed by GitHub
co-authored by rafa
parent 4872083c04
commit cf212b2f33
11 changed files with 234 additions and 47 deletions
+19
View File
@@ -1,6 +1,7 @@
import {
applyDarkModeFilter,
COLOR_PALETTE,
removeDarkModeFilter,
rgbToHex,
} from "@excalidraw/common";
@@ -215,6 +216,24 @@ describe("applyDarkModeFilter", () => {
});
});
describe("removeDarkModeFilter", () => {
it("restores black and white", () => {
expect(removeDarkModeFilter("#ededed")).toBe("#000000");
expect(removeDarkModeFilter("#121212")).toBe("#ffffff");
});
it.each([
COLOR_PALETTE.black,
COLOR_PALETTE.white,
COLOR_PALETTE.red[4],
COLOR_PALETTE.green[4],
COLOR_PALETTE.blue[4],
])("preserves the rendered dark-mode color for %s", (color) => {
const filtered = applyDarkModeFilter(color);
expect(applyDarkModeFilter(removeDarkModeFilter(filtered))).toBe(filtered);
});
});
describe("rgbToHex", () => {
describe("basic RGB conversion", () => {
it("converts black (0,0,0)", () => {
+48 -13
View File
@@ -13,6 +13,9 @@ import type { Degrees } from "@excalidraw/math";
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
typeof window !== "undefined" ? new Map() : null;
const DARK_MODE_FILTER_INVERT_PERCENT = 93;
const DARK_MODE_FILTER_HUE_ROTATE_DEGREES = 180 as Degrees;
function cssHueRotate(
red: number,
green: number,
@@ -96,12 +99,17 @@ export const applyDarkModeFilter = (color: string, enable = true): string => {
// order of operations matters
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
const rgb = tc.toRgb();
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
const inverted = cssInvert(
rgb.r,
rgb.g,
rgb.b,
DARK_MODE_FILTER_INVERT_PERCENT,
);
const rotated = cssHueRotate(
inverted.r,
inverted.g,
inverted.b,
180 as Degrees,
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
);
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
@@ -113,6 +121,44 @@ export const applyDarkModeFilter = (color: string, enable = true): string => {
return result;
};
const _reverseDarkModeInvert = (
r: number,
g: number,
b: number,
): { r: number; g: number; b: number } => {
const p = DARK_MODE_FILTER_INVERT_PERCENT / 100;
const denominator = 1 - 2 * p;
const restore = (color: number) =>
Math.round(clamp((color - 255 * p) / denominator, 0, 255));
return {
r: restore(r),
g: restore(g),
b: restore(b),
};
};
export const removeDarkModeFilter = (color: string): string => {
const tc = tinycolor(color);
const alpha = tc.getAlpha();
const rgb = tc.toRgb();
// 180deg hue rotation is its own inverse, so undo it before the inversion.
const rotatedBack = cssHueRotate(
rgb.r,
rgb.g,
rgb.b,
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
);
const restored = _reverseDarkModeInvert(
rotatedBack.r,
rotatedBack.g,
rotatedBack.b,
);
return rgbToHex(restored.r, restored.g, restored.b, alpha);
};
// ---------------------------------------------------------------------------
// Color palette
// ---------------------------------------------------------------------------
@@ -251,17 +297,6 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
...COMMON_ELEMENT_SHADES,
} as const;
/**
* The color the bucket fill tool actually fills with: the shared
* `currentItemBackgroundColor`, falling back to green when that is
* transparent (the tool's picker doesn't offer transparent, but the shared
* state can hold it from the generic shape picker).
*/
export const getBucketFillBackgroundColor = (backgroundColor: string) =>
isTransparent(backgroundColor)
? COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]
: backgroundColor;
// color palette helpers
// -----------------------------------------------------------------------------
@@ -7,7 +7,6 @@ import {
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
getBucketFillBackgroundColor,
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
DEFAULT_ELEMENT_STROKE_PICKS,
ARROW_TYPE,
@@ -523,7 +522,7 @@ export const actionChangeBucketFillBackgroundColor = register<
excludedColors={[COLOR_PALETTE.transparent]}
type="elementBackground"
label={t("labels.background")}
color={getBucketFillBackgroundColor(
color={app.bucketFill.getBucketFillBackgroundColor(
appState.currentItemBackgroundColor,
)}
onChange={(color) =>
@@ -1,8 +1,11 @@
import {
applyDarkModeFilter,
BUCKET_FILL_BACKGROUND_PICKS,
COLOR_PALETTE,
getBucketFillBackgroundColor,
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
getSizeFromPoints,
isTransparent,
THEME,
} from "@excalidraw/common";
import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
@@ -15,7 +18,10 @@ import {
ShapeCache,
} from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import type {
NonDeletedExcalidrawElement,
Theme,
} from "@excalidraw/element/types";
import { actionChangeBucketFillBackgroundColor } from "../actions";
import { editorJotaiStore } from "../editor-jotai";
@@ -72,7 +78,7 @@ export class AppBucketFill {
};
cycleBackgroundColor = () => {
const currentColor = getBucketFillBackgroundColor(
const currentColor = this.getBucketFillBackgroundColor(
this.app.state.currentItemBackgroundColor,
);
const currentIndex = BUCKET_FILL_KEYBOARD_COLOR_PICKS.indexOf(currentColor);
@@ -124,7 +130,7 @@ export class AppBucketFill {
* element (no-op when nothing would change). One undoable step.
*/
private restyle = (element: NonDeletedExcalidrawElement) => {
const backgroundColor = getBucketFillBackgroundColor(
const backgroundColor = this.getBucketFillBackgroundColor(
this.app.state.currentItemBackgroundColor,
);
if (
@@ -149,7 +155,7 @@ export class AppBucketFill {
private fill = (scenePointer: ScenePoint) => {
// shared with the generic shape background, but a transparent fill would
// be invisible, so fall back to a real color (appState is not mutated)
const backgroundColor = getBucketFillBackgroundColor(
const backgroundColor = this.getBucketFillBackgroundColor(
this.app.state.currentItemBackgroundColor,
);
@@ -277,4 +283,19 @@ export class AppBucketFill {
// (even when the tool isn't locked).
this.app.scheduleCapture();
};
/**
* The color the bucket fill tool actually fills with: the shared
* `currentItemBackgroundColor`, falling back to green when that is
* transparent (the tool's picker doesn't offer transparent, but the shared
* state can hold it from the generic shape picker).
*/
public getBucketFillBackgroundColor = (
backgroundColor: string,
/** supply only for display purposes such as when applying to the cursor */
theme?: Theme,
) =>
isTransparent(backgroundColor)
? COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]
: applyDarkModeFilter(backgroundColor, theme === THEME.DARK);
}
+10 -11
View File
@@ -1,9 +1,4 @@
import {
CURSOR_TYPE,
getBucketFillBackgroundColor,
MIME_TYPES,
THEME,
} from "@excalidraw/common";
import { CURSOR_TYPE, MIME_TYPES, THEME } from "@excalidraw/common";
import { isHandToolActive, isEraserActive } from "../appState";
@@ -105,16 +100,20 @@ export class AppCursor {
return;
}
const theme =
activeTool.type === "eraser" ||
activeTool.type === "laser" ||
activeTool.type === "bucketfill"
? this.app.state.theme
: undefined;
const bucketFillColor =
activeTool.type === "bucketfill"
? getBucketFillBackgroundColor(
? this.app.bucketFill.getBucketFillBackgroundColor(
this.app.state.currentItemBackgroundColor,
theme,
)
: undefined;
const theme =
activeTool.type === "eraser" || activeTool.type === "laser"
? this.app.state.theme
: undefined;
const memo = this.toolCursorMemo;
if (
memo?.canvas === canvas &&
@@ -20,13 +20,13 @@
.excalidraw-eye-dropper-preview {
pointer-events: none;
width: 3rem;
height: 3rem;
width: 2rem;
height: 2rem;
position: absolute;
z-index: var(--zIndex-eyeDropperPreview);
border-radius: 1rem;
border: 1px solid var(--default-border-color);
filter: var(--theme-filter);
border-radius: 50%;
border: 1px solid var(--eye-dropper-preview-border-color, #222);
box-shadow: 2px 2px 0 0 #4949497c;
}
.excalidraw-eye-dropper-trigger {
+39 -5
View File
@@ -1,7 +1,15 @@
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { EVENT, KEYS, rgbToHex } from "@excalidraw/common";
import {
EVENT,
isColorDark,
KEYS,
MIME_TYPES,
THEME,
removeDarkModeFilter,
rgbToHex,
} from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types";
@@ -13,12 +21,20 @@ import { useStable } from "../hooks/useStable";
import { getSelectedElements } from "../scene";
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
import { eyeDropperIconSvgPaths } from "./icons";
import { positionElementBesideCursor } from "./positionElementBesideCursor";
import "./EyeDropper.scss";
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
const eyeDropperCursorPaths = eyeDropperIconSvgPaths
.map((path, idx) => `<path fill="${idx === 0 ? `#fff` : ``}" d="${path}" />`)
.join("");
const eyeDropperCursor = `url(data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke-linecap="round" stroke-linejoin="round"><g stroke="#fff" stroke-width="5">${eyeDropperCursorPaths}</g><g stroke="#1b1b1f" stroke-width="1.25">${eyeDropperCursorPaths}</g></svg>`,
)}) 2 21, auto`;
export type EyeDropperProperties = {
keepOpenOnAlt: boolean;
swapPreviewOnAlt?: boolean;
@@ -66,6 +82,16 @@ export const EyeDropper: React.FC<{
const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => {
if (!eyeDropperContainer) {
return;
}
eyeDropperContainer.style.cursor = eyeDropperCursor;
return () => {
eyeDropperContainer.style.cursor = "";
};
}, [eyeDropperContainer]);
useEffect(() => {
const colorPreviewDiv = ref.current;
@@ -94,6 +120,9 @@ export const EyeDropper: React.FC<{
return rgbToHex(pixel[0], pixel[1], pixel[2]);
};
const getColorToApply = (color: string) =>
appState.theme === THEME.DARK ? removeDarkModeFilter(color) : color;
const mouseMoveListener = ({
clientX,
clientY,
@@ -110,7 +139,7 @@ export const EyeDropper: React.FC<{
height: colorPreviewDiv.offsetHeight,
},
container: eyeDropperContainer.getBoundingClientRect(),
gap: 20,
gap: 7,
});
colorPreviewDiv.style.top = `${top}px`;
@@ -121,13 +150,17 @@ export const EyeDropper: React.FC<{
if (isHoldingPointerDown) {
stableProps.onChange(
colorPickerType,
currentColor,
getColorToApply(currentColor),
stableProps.selectedElements,
{ altKey },
);
}
colorPreviewDiv.style.background = currentColor;
colorPreviewDiv.style.setProperty(
"--eye-dropper-preview-border-color",
isColorDark(currentColor) ? "#fff" : "#222",
);
};
const onCancel = () => {
@@ -158,7 +191,7 @@ export const EyeDropper: React.FC<{
event.stopImmediatePropagation();
event.preventDefault();
onSelect(getCurrentColor(event), event);
onSelect(getColorToApply(getCurrentColor(event)), event);
};
const keyDownListener = (event: KeyboardEvent) => {
@@ -215,6 +248,7 @@ export const EyeDropper: React.FC<{
excalidrawContainer,
appState.offsetLeft,
appState.offsetTop,
appState.theme,
]);
const ref = useRef<HTMLDivElement>(null);
+8 -2
View File
@@ -1961,11 +1961,17 @@ export const alertTriangleIcon = createIcon(
tablerIconProps,
);
export const eyeDropperIconSvgPaths = [
"M4 16l11.7 -11.7a1 1 0 0 1 1.4 0l2.6 2.6a1 1 0 0 1 0 1.4l-11.7 11.7h-4v-4z",
"M11 7l6 6",
] as const;
export const eyeDropperIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M11 7l6 6"></path>
<path d="M4 16l11.7 -11.7a1 1 0 0 1 1.4 0l2.6 2.6a1 1 0 0 1 0 1.4l-11.7 11.7h-4v-4z"></path>
{eyeDropperIconSvgPaths.map((path) => (
<path key={path} d={path} />
))}
</g>,
tablerIconProps,
);
@@ -1,7 +1,6 @@
import {
BUCKET_FILL_BACKGROUND_PICKS,
COLOR_PALETTE,
getBucketFillBackgroundColor,
KEYS,
} from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
@@ -91,7 +90,7 @@ describe("bucket fill tool", () => {
});
selectBucketFill();
const fallbackColor = getBucketFillBackgroundColor(
const fallbackColor = h.app.bucketFill.getBucketFillBackgroundColor(
COLOR_PALETTE.transparent,
);
const fallbackCursor = GlobalTestState.interactiveCanvas.style.cursor;
+76 -2
View File
@@ -1,10 +1,14 @@
import React from "react";
import { KEYS, THEME } from "@excalidraw/common";
import { Excalidraw } from "../index";
import { Keyboard } from "./helpers/ui";
import { fireEvent, GlobalTestState, render, waitFor } from "./test-utils";
const { h } = window;
describe("eye dropper", () => {
it("keeps the color preview within the editor container", async () => {
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
@@ -24,6 +28,10 @@ describe("eye dropper", () => {
GlobalTestState.renderResult.container.querySelector<HTMLDivElement>(
".excalidraw-eye-dropper-backdrop",
)!;
expect(eyeDropperContainer.style.cursor).toMatch(
/^url\(data:image\/svg\+xml/,
);
expect(eyeDropperContainer.style.cursor).toMatch(/\) 2 21, auto$/);
eyeDropperContainer.getBoundingClientRect = () => ({
left: 100,
@@ -44,7 +52,73 @@ describe("eye dropper", () => {
// This position fits within the viewport, but not within the editor.
fireEvent.pointerMove(window, { clientX: 480, clientY: 330 });
expect(preview.style.left).toBe("312px");
expect(preview.style.top).toBe("212px");
expect(preview.style.left).toBe("325px");
expect(preview.style.top).toBe("225px");
});
it("applies the unfiltered color in dark mode", async () => {
await render(
<Excalidraw
autoFocus={true}
handleKeyboardGlobally={true}
theme={THEME.DARK}
/>,
);
const ctx = h.app.canvas.getContext("2d")!;
vi.spyOn(ctx, "getImageData").mockReturnValue({
data: new Uint8ClampedArray([18, 18, 18, 255]),
} as ImageData);
Keyboard.keyPress(KEYS.I);
const eyeDropperContainer = await waitFor(() => {
const element =
GlobalTestState.renderResult.container.querySelector<HTMLDivElement>(
".excalidraw-eye-dropper-backdrop",
);
expect(element).not.toBeNull();
return element!;
});
fireEvent.pointerUp(eyeDropperContainer, {
clientX: 50,
clientY: 50,
});
expect(h.state.currentItemBackgroundColor).toBe("#ffffff");
});
it("contrasts the preview border with the sampled color", async () => {
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
const ctx = h.app.canvas.getContext("2d")!;
const getImageDataSpy = vi.spyOn(ctx, "getImageData").mockReturnValue({
data: new Uint8ClampedArray([18, 18, 18, 255]),
} as ImageData);
Keyboard.keyPress(KEYS.I);
const preview = await waitFor(() => {
const element =
GlobalTestState.renderResult.container.querySelector<HTMLDivElement>(
".excalidraw-eye-dropper-preview",
);
expect(element).not.toBeNull();
return element!;
});
expect(
preview.style.getPropertyValue("--eye-dropper-preview-border-color"),
).toBe("#fff");
getImageDataSpy.mockReturnValue({
data: new Uint8ClampedArray([237, 237, 237, 255]),
} as ImageData);
fireEvent.pointerMove(window, { clientX: 50, clientY: 50 });
expect(
preview.style.getPropertyValue("--eye-dropper-preview-border-color"),
).toBe("#222");
});
});
+1
View File
@@ -1119,6 +1119,7 @@ export type AppClassProperties = {
flowchart: App["flowchart"];
drawShape: App["drawShape"];
cursor: App["cursor"];
bucketFill: App["bucketFill"];
isToolLocked: App["isToolLocked"];
getEffectiveGridSize: App["getEffectiveGridSize"];
setPlugins: App["setPlugins"];