mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
feat(editor): bucketfill cursor + eyedropper support (#11849)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user