diff --git a/packages/excalidraw/actions/actionDeselect.ts b/packages/excalidraw/actions/actionDeselect.ts index 86f08957cc..0a97a5f882 100644 --- a/packages/excalidraw/actions/actionDeselect.ts +++ b/packages/excalidraw/actions/actionDeselect.ts @@ -67,6 +67,7 @@ export const actionDeselect = register({ trackEvent: false, perform: (_elements, appState, _, app) => { const activeTool = getNextActiveTool(appState, app); + app.cursor.applyForTool(activeTool); if (appState.editingGroupId) { const nonDeletedElements = app.scene.getNonDeletedElements(); diff --git a/packages/excalidraw/components/App.bucketFill.ts b/packages/excalidraw/components/App.bucketFill.ts index a16324ac72..69dd1992f1 100644 --- a/packages/excalidraw/components/App.bucketFill.ts +++ b/packages/excalidraw/components/App.bucketFill.ts @@ -1,4 +1,6 @@ import { + BUCKET_FILL_BACKGROUND_PICKS, + COLOR_PALETTE, getBucketFillBackgroundColor, getSizeFromPoints, } from "@excalidraw/common"; @@ -15,8 +17,12 @@ import { import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; +import { actionChangeBucketFillBackgroundColor } from "../actions"; +import { editorJotaiStore } from "../editor-jotai"; import { t } from "../i18n"; +import { activeEyeDropperAtom, type EyeDropperProperties } from "./EyeDropper"; + import type App from "./App"; type ScenePoint = { @@ -24,10 +30,16 @@ type ScenePoint = { y: number; }; +const BUCKET_FILL_KEYBOARD_COLOR_PICKS = BUCKET_FILL_BACKGROUND_PICKS.filter( + (color) => color !== COLOR_PALETTE.white, +); + /** Owns bucket-fill interaction and App-bound scene mutations. */ export class AppBucketFill { constructor(private app: App) {} + private temporaryEyeDropper: EyeDropperProperties | null = null; + /** * The click armed on pointer down, committed on pointer up. Deferring the * scene mutation to pointer up is what lets a pinch/pan second finger, a @@ -59,6 +71,54 @@ export class AppBucketFill { this.pending = null; }; + cycleBackgroundColor = () => { + const currentColor = getBucketFillBackgroundColor( + this.app.state.currentItemBackgroundColor, + ); + const currentIndex = BUCKET_FILL_KEYBOARD_COLOR_PICKS.indexOf(currentColor); + const nextColor = + BUCKET_FILL_KEYBOARD_COLOR_PICKS[ + (currentIndex + 1) % BUCKET_FILL_KEYBOARD_COLOR_PICKS.length + ]; + + this.app.actionManager.executeAction( + actionChangeBucketFillBackgroundColor, + "keyboard", + { currentItemBackgroundColor: nextColor }, + ); + }; + + openTemporaryEyeDropper = () => { + if (editorJotaiStore.get(activeEyeDropperAtom)) { + return; + } + + const eyeDropper: EyeDropperProperties = { + colorPickerType: "elementBackground", + keepOpenOnAlt: true, + onSelect: (color) => { + this.app.actionManager.executeAction( + actionChangeBucketFillBackgroundColor, + "ui", + { currentItemBackgroundColor: color }, + ); + }, + }; + this.temporaryEyeDropper = eyeDropper; + this.app.updateEditorAtom(activeEyeDropperAtom, eyeDropper); + }; + + closeTemporaryEyeDropper = () => { + const eyeDropper = this.temporaryEyeDropper; + this.temporaryEyeDropper = null; + if ( + eyeDropper && + editorJotaiStore.get(activeEyeDropperAtom) === eyeDropper + ) { + this.app.updateEditorAtom(activeEyeDropperAtom, null); + } + }; + /** * Apply the current bucket fill settings to an existing fill-compatible * element (no-op when nothing would change). One undoable step. diff --git a/packages/excalidraw/components/App.cursor.ts b/packages/excalidraw/components/App.cursor.ts index acc8f39594..97c79902ad 100644 --- a/packages/excalidraw/components/App.cursor.ts +++ b/packages/excalidraw/components/App.cursor.ts @@ -1,9 +1,16 @@ -import { CURSOR_TYPE, MIME_TYPES, THEME } from "@excalidraw/common"; +import { + CURSOR_TYPE, + getBucketFillBackgroundColor, + MIME_TYPES, + THEME, +} from "@excalidraw/common"; import { isHandToolActive, isEraserActive } from "../appState"; -import type App from "./App"; +import { bucketFillIconSvgPaths } from "./icons"; + import type { AppState, DataURL } from "../types"; +import type App from "./App"; const laserPointerCursorSVG_tag = ``; const laserPointerCursorBackgroundSVG = ``; @@ -20,6 +27,19 @@ const laserPointerCursorDataURL_darkMode = `data:${ `${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}`, )}`; +const createBucketFillCursorDataURL = (color: string) => { + const paths = bucketFillIconSvgPaths + .map( + (path, index) => + ``, + ) + .join(""); + + return `data:${MIME_TYPES.svg},${encodeURIComponent( + `${paths}${paths}`, + )}`; +}; + /** * Captures all cursor management for the interactive canvas. * @@ -29,16 +49,18 @@ const laserPointerCursorDataURL_darkMode = `data:${ * cursors set here. */ export class AppCursor { + private toolCursorMemo: { + canvas: HTMLCanvasElement; + type: AppState["activeTool"]["type"]; + bucketFillColor?: string; + theme?: AppState["theme"]; + } | null = null; + private eraserCanvasCache: | (HTMLCanvasElement & { theme?: AppState["theme"] }) | null = null; private eraserPreviewDataURL: DataURL | null = null; - private drawShapeCanvasCache: - | (HTMLCanvasElement & { theme?: AppState["theme"] }) - | null = null; - private drawShapePreviewDataURL: DataURL | null = null; - constructor(private app: App) {} private get canvas() { @@ -46,6 +68,9 @@ export class AppCursor { } set = (cursor: string) => { + // Hover and interaction cursors override the resting tool cursor. The + // next applyForTool() must restore it even if the tool itself is unchanged. + this.toolCursorMemo = null; if (this.canvas) { this.canvas.style.cursor = cursor; } @@ -75,9 +100,31 @@ export class AppCursor { applyForTool = ( activeTool: AppState["activeTool"] = this.app.state.activeTool, ) => { - if (!this.canvas) { + const canvas = this.canvas; + if (!canvas) { return; } + + const bucketFillColor = + activeTool.type === "bucketfill" + ? getBucketFillBackgroundColor( + this.app.state.currentItemBackgroundColor, + ) + : undefined; + const theme = + activeTool.type === "eraser" || activeTool.type === "laser" + ? this.app.state.theme + : undefined; + const memo = this.toolCursorMemo; + if ( + memo?.canvas === canvas && + memo.type === activeTool.type && + memo.bucketFillColor === bucketFillColor && + memo.theme === theme + ) { + return; + } + if (activeTool.type === "selection") { this.clear(); } else if (isHandToolActive({ activeTool })) { @@ -86,6 +133,11 @@ export class AppCursor { this.applyEraser(); } else if (activeTool.type === "autoshape") { this.set(CURSOR_TYPE.CROSSHAIR); + } else if (activeTool.type === "bucketfill") { + // The hotspot is the center of the paint droplet in the icon. + this.set( + `url(${createBucketFillCursorDataURL(bucketFillColor!)}) 5 18, auto`, + ); } else if (activeTool.type === "laser") { const url = this.app.state.theme === THEME.LIGHT @@ -100,13 +152,18 @@ export class AppCursor { } else if (activeTool.type !== "image") { this.set(CURSOR_TYPE.AUTO); } + + this.toolCursorMemo = { + canvas, + type: activeTool.type, + bucketFillColor, + theme, + }; }; /** clears the inline cursor so the environment default (CSS) applies */ private clear = () => { - if (this.canvas) { - this.canvas.style.cursor = ""; - } + this.set(""); }; private applyEraser = () => { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 55b242c83a..d0c75ad30f 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -4145,6 +4145,13 @@ class App extends React.Component { ) { this.cursor.applyForTool(); } + if ( + this.state.activeTool.type === "bucketfill" && + prevState.currentItemBackgroundColor !== + this.state.currentItemBackgroundColor + ) { + this.cursor.applyForTool(); + } // Hide hyperlink popup if shown when element type is not selection if ( @@ -5478,7 +5485,11 @@ class App extends React.Component { // Handle Alt key for bind mode if (event.key === KEYS.ALT) { - if (getFeatureFlag("COMPLEX_BINDINGS")) { + if (this.state.activeTool.type === "bucketfill") { + this.bucketFill.openTemporaryEyeDropper(); + event.preventDefault(); + return; + } else if (getFeatureFlag("COMPLEX_BINDINGS")) { this.handleSkipBindMode(); } else { maybeHandleArrowPointlikeDrag({ app: this, event }); @@ -5544,7 +5555,15 @@ class App extends React.Component { ); } - if (shape === "lasso" && this.state.activeTool.type === "laser") { + if ( + shape === "bucketfill" && + this.state.activeTool.type === "bucketfill" + ) { + this.bucketFill.cycleBackgroundColor(); + } else if ( + shape === "lasso" && + this.state.activeTool.type === "laser" + ) { this.setActiveTool({ type: this.state.preferredSelectionTool.type, }); @@ -5815,6 +5834,7 @@ class App extends React.Component { } if (event.key === KEYS.ALT) { + this.bucketFill.closeTemporaryEyeDropper(); maybeHandleArrowPointlikeDrag({ app: this, event }); } diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 707198cbe3..cc86028c37 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -623,12 +623,18 @@ export const palette = createIcon( "M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z", ); +export const bucketFillIconSvgPaths = [ + "M5 16l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737z", + "M13.737 9.737c2.299 -2.3 3.23 -5.095 2.081 -6.245c-1.15 -1.15 -3.945 -.217 -6.244 2.082c-2.3 2.299 -3.231 5.095 -2.082 6.244c1.15 1.15 3.946 .218 6.245 -2.081z", + "M7.492 11.818c.362 .362 .768 .676 1.208 .934l6.895 4.047c1.078 .557 2.255 -.075 3.692 -1.512c1.437 -1.437 2.07 -2.614 1.512 -3.692c-.372 -.718 -1.72 -3.017 -4.047 -6.895a6.015 6.015 0 0 0 -.934 -1.208", +] as const; + export const bucketFillIcon = createIcon( - - - + {bucketFillIconSvgPaths.map((path) => ( + + ))} , tablerIconProps, ); diff --git a/packages/excalidraw/tests/bucketFill.test.tsx b/packages/excalidraw/tests/bucketFill.test.tsx index f53a11634e..f4dbc02dad 100644 --- a/packages/excalidraw/tests/bucketFill.test.tsx +++ b/packages/excalidraw/tests/bucketFill.test.tsx @@ -1,4 +1,9 @@ -import { KEYS } from "@excalidraw/common"; +import { + BUCKET_FILL_BACKGROUND_PICKS, + COLOR_PALETTE, + getBucketFillBackgroundColor, + KEYS, +} from "@excalidraw/common"; import { CaptureUpdateAction } from "@excalidraw/element"; import { pointFrom } from "@excalidraw/math"; @@ -7,6 +12,8 @@ import type { LocalPoint } from "@excalidraw/math"; import type { ExcalidrawLineElement } from "@excalidraw/element/types"; import { Excalidraw } from "../index"; +import { activeEyeDropperAtom } from "../components/EyeDropper"; +import { editorJotaiStore } from "../editor-jotai"; import { API } from "./helpers/api"; import { Keyboard, Pointer } from "./helpers/ui"; @@ -16,6 +23,7 @@ import { GlobalTestState, render, togglePopover, + waitFor, } from "./test-utils"; const { h } = window; @@ -64,6 +72,131 @@ describe("bucket fill tool", () => { return rect; }; + it("uses the paint droplet as the bucket fill cursor hotspot", () => { + selectBucketFill(); + + expect(GlobalTestState.interactiveCanvas.style.cursor).toMatch( + /^url\(data:image\/svg\+xml/, + ); + expect(GlobalTestState.interactiveCanvas.style.cursor).toMatch( + /\) 5 18, auto$/, + ); + }); + + it("colorizes the cursor with the effective fill color and keeps it updated", () => { + act(() => { + API.setAppState({ + currentItemBackgroundColor: COLOR_PALETTE.transparent, + }); + }); + selectBucketFill(); + + const fallbackColor = getBucketFillBackgroundColor( + COLOR_PALETTE.transparent, + ); + const fallbackCursor = GlobalTestState.interactiveCanvas.style.cursor; + expect(decodeURIComponent(fallbackCursor)).toContain( + `fill="${fallbackColor}"`, + ); + + const nextColor = "#ffec99"; + act(() => { + API.setAppState({ currentItemBackgroundColor: nextColor }); + }); + + const updatedCursor = GlobalTestState.interactiveCanvas.style.cursor; + expect(updatedCursor).not.toBe(fallbackCursor); + expect(decodeURIComponent(updatedCursor)).toContain(`fill="${nextColor}"`); + }); + + it("does not rebuild or reapply an unchanged bucket fill cursor", () => { + selectBucketFill(); + const encodeURIComponentSpy = vi.spyOn(globalThis, "encodeURIComponent"); + const cursorSetterSpy = vi.spyOn( + GlobalTestState.interactiveCanvas.style, + "cursor", + "set", + ); + + h.app.cursor.applyForTool(); + + expect(encodeURIComponentSpy).not.toHaveBeenCalled(); + expect(cursorSetterSpy).not.toHaveBeenCalled(); + encodeURIComponentSpy.mockRestore(); + cursorSetterSpy.mockRestore(); + }); + + it("cycles the non-white top picks on repeated B presses", () => { + const colorPicks = BUCKET_FILL_BACKGROUND_PICKS.filter( + (color) => color !== COLOR_PALETTE.white, + ); + act(() => { + API.setAppState({ currentItemBackgroundColor: COLOR_PALETTE.white }); + }); + + Keyboard.keyPress(KEYS.B); + expect(h.state.activeTool.type).toBe("bucketfill"); + expect(h.state.currentItemBackgroundColor).toBe(COLOR_PALETTE.white); + + for (const color of colorPicks) { + Keyboard.keyPress(KEYS.B); + expect(h.state.currentItemBackgroundColor).toBe(color); + expect( + decodeURIComponent(GlobalTestState.interactiveCanvas.style.cursor), + ).toContain(`fill="${color}"`); + } + + Keyboard.keyPress(KEYS.B); + expect(h.state.currentItemBackgroundColor).toBe(colorPicks[0]); + }); + + it("temporarily activates the background eye dropper while Alt is held", async () => { + selectBucketFill(); + + Keyboard.withModifierKeys({ alt: true }, () => { + Keyboard.keyDown(KEYS.ALT); + }); + + const eyeDropper = editorJotaiStore.get(activeEyeDropperAtom); + expect(eyeDropper).not.toBeNull(); + expect(eyeDropper!.colorPickerType).toBe("elementBackground"); + expect(eyeDropper!.keepOpenOnAlt).toBe(true); + expect(eyeDropper!.swapPreviewOnAlt).toBeUndefined(); + await waitFor(() => { + expect( + GlobalTestState.renderResult.container.querySelector( + ".excalidraw-eye-dropper-preview", + ), + ).not.toBeNull(); + }); + + const color = "#ff8787"; + act(() => { + eyeDropper!.onSelect(color, { altKey: true } as PointerEvent); + }); + expect(h.state.currentItemBackgroundColor).toBe(color); + + Keyboard.keyUp(KEYS.ALT); + expect(editorJotaiStore.get(activeEyeDropperAtom)).toBeNull(); + await waitFor(() => { + expect( + GlobalTestState.renderResult.container.querySelector( + ".excalidraw-eye-dropper-preview", + ), + ).toBeNull(); + }); + }); + + it("clears the bucket fill cursor immediately when Escape exits the tool", () => { + selectBucketFill(); + expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("url("); + + Keyboard.keyPress(KEYS.ESCAPE); + + expect(h.state.activeTool.type).toBe("selection"); + expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(""); + }); + it("creates a unselected line polygon with the current background color", () => { const rect = seedRectangle(); act(() => {