feat(editor): bucketfill cursor + eyedropper support (#11849)

This commit is contained in:
David Luzar
2026-08-07 14:07:53 +02:00
committed by GitHub
parent ea74787447
commit 4872083c04
6 changed files with 294 additions and 17 deletions
@@ -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();
@@ -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.
+68 -11
View File
@@ -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 = `<svg viewBox="0 0 24 24" stroke-width="1" width="28" height="28" xmlns="http://www.w3.org/2000/svg">`;
const laserPointerCursorBackgroundSVG = `<path d="M6.164 11.755a5.314 5.314 0 0 1-4.932-5.298 5.314 5.314 0 0 1 5.311-5.311 5.314 5.314 0 0 1 5.307 5.113l8.773 8.773a3.322 3.322 0 0 1 0 4.696l-.895.895a3.322 3.322 0 0 1-4.696 0l-8.868-8.868Z" style="fill:#fff"/>`;
@@ -20,6 +27,19 @@ const laserPointerCursorDataURL_darkMode = `data:${
`${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}</svg>`,
)}`;
const createBucketFillCursorDataURL = (color: string) => {
const paths = bucketFillIconSvgPaths
.map(
(path, index) =>
`<path d="${path}" ${index < 2 ? `fill="${color}"` : ""} />`,
)
.join("");
return `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" fill="#fff" stroke-width="5">${paths}</g><g stroke="#1b1b1f" stroke-width="1.25">${paths}</g></svg>`,
)}`;
};
/**
* 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 = () => {
+22 -2
View File
@@ -4145,6 +4145,13 @@ class App extends React.Component<AppProps, AppState> {
) {
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<AppProps, AppState> {
// 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<AppProps, AppState> {
);
}
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<AppProps, AppState> {
}
if (event.key === KEYS.ALT) {
this.bucketFill.closeTemporaryEyeDropper();
maybeHandleArrowPointlikeDrag({ app: this, event });
}
+9 -3
View File
@@ -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(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 16l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737z" />
<path d="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" />
<path d="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" />
{bucketFillIconSvgPaths.map((path) => (
<path key={path} d={path} />
))}
</g>,
tablerIconProps,
);
+134 -1
View File
@@ -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(() => {