diff --git a/packages/common/src/colors.test.ts b/packages/common/src/colors.test.ts index 4d846c1ef0..d4d9add9d0 100644 --- a/packages/common/src/colors.test.ts +++ b/packages/common/src/colors.test.ts @@ -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)", () => { diff --git a/packages/common/src/colors.ts b/packages/common/src/colors.ts index e0f1d600cf..fa674fc6f8 100644 --- a/packages/common/src/colors.ts +++ b/packages/common/src/colors.ts @@ -13,6 +13,9 @@ import type { Degrees } from "@excalidraw/math"; const DARK_MODE_COLORS_CACHE: Map | 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 // ----------------------------------------------------------------------------- diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 3ea13e7b2a..13973804ea 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -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) => diff --git a/packages/excalidraw/components/App.bucketFill.ts b/packages/excalidraw/components/App.bucketFill.ts index 69dd1992f1..03bc3087d5 100644 --- a/packages/excalidraw/components/App.bucketFill.ts +++ b/packages/excalidraw/components/App.bucketFill.ts @@ -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); } diff --git a/packages/excalidraw/components/App.cursor.ts b/packages/excalidraw/components/App.cursor.ts index 97c79902ad..812a30985f 100644 --- a/packages/excalidraw/components/App.cursor.ts +++ b/packages/excalidraw/components/App.cursor.ts @@ -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 && diff --git a/packages/excalidraw/components/EyeDropper.scss b/packages/excalidraw/components/EyeDropper.scss index d597e16567..9fff114de9 100644 --- a/packages/excalidraw/components/EyeDropper.scss +++ b/packages/excalidraw/components/EyeDropper.scss @@ -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 { diff --git a/packages/excalidraw/components/EyeDropper.tsx b/packages/excalidraw/components/EyeDropper.tsx index aea5f93cf6..02ec1fc129 100644 --- a/packages/excalidraw/components/EyeDropper.tsx +++ b/packages/excalidraw/components/EyeDropper.tsx @@ -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) => ``) + .join(""); +const eyeDropperCursor = `url(data:${MIME_TYPES.svg},${encodeURIComponent( + `${eyeDropperCursorPaths}${eyeDropperCursorPaths}`, +)}) 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(null); diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index cc86028c37..de4f899435 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -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( - - + {eyeDropperIconSvgPaths.map((path) => ( + + ))} , tablerIconProps, ); diff --git a/packages/excalidraw/tests/bucketFill.test.tsx b/packages/excalidraw/tests/bucketFill.test.tsx index f4dbc02dad..c25fc54c38 100644 --- a/packages/excalidraw/tests/bucketFill.test.tsx +++ b/packages/excalidraw/tests/bucketFill.test.tsx @@ -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; diff --git a/packages/excalidraw/tests/eyeDropper.test.tsx b/packages/excalidraw/tests/eyeDropper.test.tsx index 24a38f61ab..9a861f1bf3 100644 --- a/packages/excalidraw/tests/eyeDropper.test.tsx +++ b/packages/excalidraw/tests/eyeDropper.test.tsx @@ -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(); @@ -24,6 +28,10 @@ describe("eye dropper", () => { GlobalTestState.renderResult.container.querySelector( ".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( + , + ); + + 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( + ".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(); + + 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( + ".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"); }); }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index f215168999..dbf5097359 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -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"];