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 createBucketFillCursorDataURL = (color: string) => {
+ const paths = bucketFillIconSvgPaths
+ .map(
+ (path, index) =>
+ ``,
+ )
+ .join("");
+
+ return `data:${MIME_TYPES.svg},${encodeURIComponent(
+ ``,
+ )}`;
+};
+
/**
* 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(() => {