feat(editor): bucket fill (#11799)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
David Luzar
2026-08-04 16:25:16 +02:00
committed by GitHub
co-authored by Claude Fable 5 Mark Tolmacs
parent f1aa8f86a9
commit 792ea3a06c
32 changed files with 5513 additions and 16 deletions
+33
View File
@@ -202,6 +202,17 @@ export const DEFAULT_ELEMENT_BACKGROUND_PICKS = [
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
] as ColorTuple;
// ORDER matters for positioning in quick picker.
// Same as element background picks, with `transparent` (a no-op fill for the
// bucket tool) swapped for white.
export const BUCKET_FILL_BACKGROUND_PICKS = [
COLOR_PALETTE.white,
COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
] as ColorTuple;
// ORDER matters for positioning in quick picker
export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
COLOR_PALETTE.white,
@@ -240,6 +251,17 @@ 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
// -----------------------------------------------------------------------------
@@ -299,6 +321,17 @@ export const isTransparent = (color: string) => {
return tinycolor(color).getAlpha() === 0;
};
/**
* Whether the color is fully opaque (alpha exactly 1).
*
* NOT the negation of `isTransparent`, which is true only at alpha 0 — a
* partial-alpha color (`#RRGGBBAA`, `rgba(…, 0.5)`) is neither transparent
* (it renders) nor opaque (what's beneath shows through it).
*/
export const isOpaqueColor = (color: string) => {
return tinycolor(color).getAlpha() === 1;
};
// -----------------------------------------------------------------------------
// color contract helpers
// -----------------------------------------------------------------------------
+1
View File
@@ -500,6 +500,7 @@ export const TOOL_TYPE = {
embeddable: "embeddable",
laser: "laser",
autoshape: "autoshape",
bucketfill: "bucketfill",
} as const;
export const EDITOR_LS_KEYS = {
+1
View File
@@ -51,6 +51,7 @@ export const KEYS = {
SLASH: "/",
A: "a",
B: "b",
C: "c",
D: "d",
E: "e",
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -8,7 +8,9 @@ export const hasBackground = (type: ElementOrToolType) =>
type === "diamond" ||
type === "line" ||
type === "freedraw" ||
type === "autoshape";
type === "autoshape" ||
// tool-only type; makes the `G` background shortcut work for bucket fill
type === "bucketfill";
export const hasStrokeColor = (type: ElementOrToolType) =>
type === "rectangle" ||
+1
View File
@@ -60,6 +60,7 @@ export * from "./align";
export * from "./arrowEndpointText";
export * from "./binding";
export * from "./bounds";
export * from "./bucketFill";
export * from "./collision";
export * from "./comparisons";
export * from "./containerCache";
+21 -1
View File
@@ -1,5 +1,5 @@
import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import { getStroke, getStrokePoints } from "perfect-freehand";
import { LaserPointer } from "@excalidraw/laser-pointer";
import {
@@ -1253,6 +1253,26 @@ export const getFreedrawOutlinePoints = (
: getVariableWidthFreedrawOutline(element);
};
/**
* The streamline-smoothed centerline the freedraw stroke is rendered
* around, in element-local coordinates. Boundary-sensitive consumers (e.g.
* bucket fill) should use this instead of `element.points`: raw input
* points can sit 20px+ apart and the rendered stroke is smoothed between
* them, so raw chords visibly deviate from what's on screen.
*
* Constant-width ("laser geometry") strokes technically smooth via
* `LaserPointer` instead; the perfect-freehand centerline with the same
* `streamline` is a close approximation the stroke width hides.
*/
export const getFreedrawStrokeCenterPoints = (
element: ExcalidrawFreeDrawElement,
): [number, number][] =>
getStrokePoints(element.points as unknown as number[][], {
size: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR,
streamline: getFreedrawStreamline(element),
last: true,
}).map((strokePoint) => strokePoint.point as [number, number]);
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
File diff suppressed because it is too large Load Diff
@@ -3,8 +3,11 @@ import { pointFrom } from "@excalidraw/math";
import { useEffect, useMemo, useRef, useState } from "react";
import {
BUCKET_FILL_BACKGROUND_PICKS,
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
getBucketFillBackgroundColor,
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
DEFAULT_ELEMENT_STROKE_PICKS,
ARROW_TYPE,
@@ -486,6 +489,55 @@ export const actionChangeBackgroundColor = register<
},
});
export const actionChangeBucketFillBackgroundColor = register<
Pick<AppState, "currentItemBackgroundColor">
>({
name: "changeBucketFillBackgroundColor",
label: "labels.changeBackground",
trackEvent: false,
// the bucket fill tool has no element to mutate; it shares
// `currentItemBackgroundColor` but hides `transparent` (an invisible fill
// would be a no-op) and shows the effective fallback color instead
perform: (elements, appState, value) => {
return {
appState: {
...appState,
...value,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const { stylesPanelMode } = getStylesPanelInfo(app);
return (
<>
{stylesPanelMode === "full" && (
<h3 aria-hidden="true">{t("labels.background")}</h3>
)}
<ColorPicker
topPicks={BUCKET_FILL_BACKGROUND_PICKS}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
// hidden rather than removed from the palette so the remaining
// colors keep their usual hotkeys (w for white etc.)
excludedColors={[COLOR_PALETTE.transparent]}
type="elementBackground"
label={t("labels.background")}
color={getBucketFillBackgroundColor(
appState.currentItemBackgroundColor,
)}
onChange={(color) =>
updateData({ currentItemBackgroundColor: color })
}
elements={elements}
appState={appState}
updateData={updateData}
/>
</>
);
},
});
export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
name: "changeFillStyle",
label: "labels.fill",
+1
View File
@@ -10,6 +10,7 @@ export { actionDuplicateSelection } from "./actionDuplicateSelection";
export {
actionChangeStrokeColor,
actionChangeBackgroundColor,
actionChangeBucketFillBackgroundColor,
actionChangeStrokeWidth,
actionChangeFillStyle,
actionChangeSloppiness,
+1
View File
@@ -64,6 +64,7 @@ export type ActionName =
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
| "changeBucketFillBackgroundColor"
| "changeFillStyle"
| "changeStrokeWidth"
| "changeSloppiness"
+24 -3
View File
@@ -152,6 +152,18 @@ export const SelectedShapeActions = ({
app,
);
// the bucket fill tool configures only the fill it creates: color, fill
// style, and opacity (shared `currentItem*` values; no stroke properties)
if (appState.activeTool.type === "bucketfill") {
return (
<div className="selected-shape-actions">
<div>{renderAction("changeBucketFillBackgroundColor")}</div>
{renderAction("changeFillStyle")}
{renderAction("changeOpacity")}
</div>
);
}
return (
<div className="selected-shape-actions">
<div>{predicates.strokeColor && renderAction("changeStrokeColor")}</div>
@@ -626,10 +638,14 @@ export const CompactShapeActions = ({
</div>
)}
{/* Background Color */}
{/* Background Color (the bucket fill variant excludes `transparent`) */}
{predicates.backgroundColor && (
<div className="compact-action-item">
{renderAction("changeBackgroundColor")}
{renderAction(
appState.activeTool.type === "bucketfill"
? "changeBucketFillBackgroundColor"
: "changeBackgroundColor",
)}
</div>
)}
@@ -780,9 +796,14 @@ export const MobileShapeActions = ({
{renderAction("changeStrokeColor")}
</div>
)}
{/* Background Color (the bucket fill variant excludes `transparent`) */}
{predicates.backgroundColor && (
<div className="compact-action-item">
{renderAction("changeBackgroundColor")}
{renderAction(
appState.activeTool.type === "bucketfill"
? "changeBucketFillBackgroundColor"
: "changeBackgroundColor",
)}
</div>
)}
<CombinedShapeProperties
@@ -0,0 +1,220 @@
import {
getBucketFillBackgroundColor,
getSizeFromPoints,
} from "@excalidraw/common";
import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
import {
computeBucketFillPolygon,
isBucketFillCompatible,
isFrameLikeElement,
isRestylableFill,
newLinearElement,
ShapeCache,
} from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../i18n";
import type App from "./App";
type ScenePoint = {
x: number;
y: number;
};
/** Owns bucket-fill interaction and App-bound scene mutations. */
export class AppBucketFill {
constructor(private app: App) {}
/**
* 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
* long-press context menu, or a pointercancel abort the fill — on pointer
* down the editor cannot yet know the gesture will stay a single-pointer
* click.
*/
private pending: ScenePoint | null = null;
/** Arm a fill at the pointer-down position; nothing mutates yet. */
handlePointerDown = (scenePointer: ScenePoint) => {
this.pending = scenePointer;
};
/**
* Commit the armed fill, at the pointer-DOWN position (what the user
* aimed at). No-op when the gesture was aborted in the meantime.
*/
handlePointerUp = () => {
const scenePointer = this.pending;
this.pending = null;
if (scenePointer) {
this.fill(scenePointer);
}
};
/** Abort the armed fill (second finger, context menu, pointercancel). */
cancel = () => {
this.pending = null;
};
/**
* Apply the current bucket fill settings to an existing fill-compatible
* element (no-op when nothing would change). One undoable step.
*/
private restyle = (element: NonDeletedExcalidrawElement) => {
const backgroundColor = getBucketFillBackgroundColor(
this.app.state.currentItemBackgroundColor,
);
if (
element.backgroundColor === backgroundColor &&
element.fillStyle === this.app.state.currentItemFillStyle &&
element.opacity === this.app.state.currentItemOpacity
) {
return;
}
this.app.scene.mutateElement(element, {
backgroundColor,
fillStyle: this.app.state.currentItemFillStyle,
opacity: this.app.state.currentItemOpacity,
});
// the roughjs drawable is cached by element reference; in-place
// mutation keeps the reference, so evict it or the canvas won't
// reflect the new style
ShapeCache.delete(element);
this.app.scheduleCapture();
};
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(
this.app.state.currentItemBackgroundColor,
);
const elements = this.app.scene.getNonDeletedElements();
const elementsMap = this.app.scene.getNonDeletedElementsMap();
const point = pointFrom<GlobalPoint>(scenePointer.x, scenePointer.y);
const hitElement = this.app.getElementAtPosition(point[0], point[1]);
// gap bridging is covered by the default gapTolerance (a bucket-specific
// constant): connector edges close visual gaps without distorting the
// shape, so no zoom-dependent tuning is needed
const result = computeBucketFillPolygon({ point, elements, elementsMap });
if (!result.ok) {
// no region could be derived, but the click still landed on existing
// paint (e.g. an orphaned fill whose boundaries were deleted, or a
// hand-drawn strokeless bg polygon on empty canvas) — restyling it is
// the sensible outcome, and beats a toast
if (hitElement && isBucketFillCompatible(hitElement)) {
this.restyle(hitElement);
return;
}
if (result.reason === "too_complex") {
this.app.setToast({
message: t("bucketfill.tooComplex"),
duration: 3000,
});
} else if (
result.reason === "open_region" ||
result.reason === "too_small" ||
result.reason === "invalid_polygon"
) {
this.app.setToast({
message: t("bucketfill.noRegion"),
duration: 3000,
});
}
// "no_owner" stays silent — clicking empty canvas shouldn't nag
return;
}
// re-clicking an already-filled region shouldn't stack a duplicate:
// restyle the existing fill with the current settings, or no-op when
// nothing would change
if (
hitElement &&
isRestylableFill({
hitElement,
scenePoints: result.scenePoints,
elementsMap,
})
) {
this.restyle(hitElement);
return;
}
const { width, height } = getSizeFromPoints(result.scenePoints);
// linear elements are normalized so points[0] must be [0, 0]; use the
// first scene point as the element origin
const [originX, originY] = result.scenePoints[0];
const points = result.scenePoints.map((p) =>
pointFrom<LocalPoint>(p[0] - originX, p[1] - originY),
);
const owner = result.ownerId ? elementsMap.get(result.ownerId) : null;
// owner-less fills (regions formed by open lines) take their frame from
// the click position and only inherit a group shared by every boundary
const frameId = owner
? isFrameLikeElement(owner)
? owner.id
: owner.frameId
: this.app.getTopLayerFrameAtSceneCoords(scenePointer)?.id ?? null;
const groupIds = owner
? owner.groupIds
: result.boundaryElementIds
.map((id) => elementsMap.get(id)?.groupIds)
.filter((groupIds): groupIds is string[] => !!groupIds)
.reduce(
(common, groupIds) =>
common === null
? groupIds
: common.filter((groupId) => groupIds.includes(groupId)),
null as string[] | null,
) ?? [];
const fill = newLinearElement({
type: "line",
x: originX,
y: originY,
width,
height,
points,
polygon: true,
strokeColor: "transparent",
backgroundColor,
fillStyle: this.app.state.currentItemFillStyle,
strokeWidth: 1,
strokeStyle: "solid",
roughness: 0,
roundness: null,
opacity: this.app.state.currentItemOpacity,
frameId,
groupIds,
// no marker: fills are recognized by shape (`isBucketFillCompatible`),
// since metadata would go stale the moment the user restyles the fill
});
// resolve the geometry's relative placement against the
// deleted-inclusive array that `insertElementsAtIndex` operates on
const anchorIndex = this.app.scene
.getElementsIncludingDeleted()
.findIndex((el) => el.id === result.insertion.elementId);
this.app.scene.insertElementsAtIndex(
[fill],
anchorIndex < 0
? null
: result.insertion.placement === "above"
? anchorIndex + 1
: anchorIndex,
);
// Keep the bucket fill tool active and do NOT select the new fill, so the
// user can keep filling regions back-to-back without re-selecting the tool
// (even when the tool isn't locked).
this.app.scheduleCapture();
};
}
+44 -2
View File
@@ -432,6 +432,7 @@ import ConvertElementTypePopup, {
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { AppArrowText } from "./App.arrowText";
import { AppBucketFill } from "./App.bucketFill";
import { AppCursor } from "./App.cursor";
import { AppDrawShape } from "./App.drawshape";
import { AppFlowchart } from "./App.flowchart";
@@ -681,6 +682,7 @@ class App extends React.Component<AppProps, AppState> {
public onStateChange: OnStateChange = this.appStateObserver.onStateChange;
public bucketFill: AppBucketFill = new AppBucketFill(this);
public flowchart: AppFlowchart = new AppFlowchart(this);
public cursor: AppCursor = new AppCursor(this);
public arrowText: AppArrowText = new AppArrowText(this);
@@ -3085,6 +3087,8 @@ class App extends React.Component<AppProps, AppState> {
}
});
public scheduleCapture = () => this.store.scheduleCapture();
// Lifecycle
private onBlur = withBatchedUpdates(() => {
@@ -4981,6 +4985,12 @@ class App extends React.Component<AppProps, AppState> {
this.resetContextMenuTimer();
}
if (event.type === "pointercancel") {
// the browser took the pointer over (scroll, palm rejection) — no
// pointerup will follow, so the armed bucket fill must not commit
this.bucketFill.cancel();
}
const wasMultiTouchGesture = gesture.pointers.size >= 2;
gesture.pointers.delete(event.pointerId);
@@ -6427,7 +6437,7 @@ class App extends React.Component<AppProps, AppState> {
};
// NOTE: Hot path for hit testing, so avoid unnecessary computations
getElementAtPosition(
public getElementAtPosition(
x: number,
y: number,
opts?: (
@@ -7399,7 +7409,7 @@ class App extends React.Component<AppProps, AppState> {
* finds candidate frame under cursor (when dragging frame children/elements
* inside frames)
*/
private getTopLayerFrameAtSceneCoords = (
public getTopLayerFrameAtSceneCoords = (
/**
* should be already grid aligned (basically should be what the call site
* sets the element's coords to, if applicable)
@@ -8716,6 +8726,17 @@ class App extends React.Component<AppProps, AppState> {
);
} else if (this.state.activeTool.type === "autoshape") {
this.drawShape.handlePointerDown(pointerDownState);
} else if (this.state.activeTool.type === TOOL_TYPE.bucketfill) {
// one-shot click tool: pointer down only ARMS the fill — it commits in
// the shared pointer-up teardown, and only when the interaction stayed
// a single-pointer click (a second finger, a context menu, or a
// pointercancel aborts it). Dispatched like any other tool (laser has
// the same shape) so the shared pointer lifecycle below — public
// onPointerDown/onPointerUp callbacks, pointer-up teardown,
// missing-pointer-up cleanup — runs for bucket clicks too. In view
// mode this branch is unreachable:
// `handleCanvasPanUsingWheelOrSpaceDrag` swallows the pointer-down.
this.bucketFill.handlePointerDown(scenePointer);
} else if (
this.state.activeTool.type !== "eraser" &&
this.state.activeTool.type !== "hand" &&
@@ -11376,6 +11397,20 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.eventListeners.onMove) {
pointerDownState.eventListeners.onMove.flush();
}
// an armed bucket fill commits only on a GENUINE pointer up. The
// missing-pointer-up cleanup replays this handler with the pointer
// DOWN event (e.g. when a second finger lands mid-press — pinch/pan
// intent), and a tool switch mid-press orphans the click — both must
// discard the fill instead of committing an unwanted edit.
if (
childEvent.type === "pointerup" &&
this.state.activeTool.type === TOOL_TYPE.bucketfill
) {
this.bucketFill.handlePointerUp();
} else {
this.bucketFill.cancel();
}
const {
newElement,
resizingElement,
@@ -12347,6 +12382,9 @@ class App extends React.Component<AppProps, AppState> {
if (
!this.isToolLocked() &&
activeTool.type !== "freedraw" &&
// bucket fill stays active for back-to-back fills regardless of the
// tool lock (paint-bucket UX)
activeTool.type !== TOOL_TYPE.bucketfill &&
(activeTool.type !== "lasso" ||
// if lasso is turned on but from selection => reset to selection
(activeTool.type === "lasso" && activeTool.fromSelection))
@@ -13078,6 +13116,10 @@ class App extends React.Component<AppProps, AppState> {
return;
}
// a context menu during a press (touch long-press) means the user is
// not committing a bucket click
this.bucketFill.cancel();
if (
(("pointerType" in event.nativeEvent &&
event.nativeEvent.pointerType === "touch") ||
@@ -52,6 +52,8 @@ interface ColorPickerProps {
palette?: ColorPaletteCustom | null;
topPicks?: ColorTuple;
updateData: (formData?: any) => void;
/** palette colors to hide from the popup, keeping hotkey positions */
excludedColors?: readonly string[];
}
const ColorPickerPopupContent = ({
@@ -64,6 +66,7 @@ const ColorPickerPopupContent = ({
updateData,
getOpenPopup,
appState,
excludedColors,
}: Pick<
ColorPickerProps,
| "type"
@@ -74,6 +77,7 @@ const ColorPickerPopupContent = ({
| "palette"
| "updateData"
| "appState"
| "excludedColors"
> & {
getOpenPopup: () => AppState["openPopup"];
}) => {
@@ -151,6 +155,7 @@ const ColorPickerPopupContent = ({
<Picker
ref={colorPickerContentRef}
palette={palette}
excludedColors={excludedColors}
color={color}
onChange={(changedColor) => {
// Save caret position before color change if editing text
@@ -289,6 +294,7 @@ export const ColorPicker = ({
topPicks,
updateData,
appState,
excludedColors,
}: ColorPickerProps) => {
const openRef = useRef(appState.openPopup);
useEffect(() => {
@@ -352,6 +358,7 @@ export const ColorPicker = ({
label={label}
elements={elements}
palette={palette}
excludedColors={excludedColors}
updateData={updateData}
getOpenPopup={() => openRef.current}
appState={appState}
@@ -41,6 +41,7 @@ interface PickerProps {
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
showHotKey?: boolean;
excludedColors?: readonly string[];
}
export const Picker = React.forwardRef(
@@ -57,6 +58,7 @@ export const Picker = React.forwardRef(
onEyeDropperToggle,
onEscape,
showHotKey = true,
excludedColors,
}: PickerProps,
ref,
) => {
@@ -155,6 +157,7 @@ export const Picker = React.forwardRef(
updateData,
activeShade,
onEscape,
excludedColors,
});
if (handled) {
@@ -190,6 +193,7 @@ export const Picker = React.forwardRef(
onChange={onChange}
activeShade={activeShade}
showHotKey={showHotKey}
excludedColors={excludedColors}
/>
</div>
@@ -21,6 +21,11 @@ interface PickerColorListProps {
onChange: (color: string) => void;
activeShade: number;
showHotKey?: boolean;
/**
* palette colors to hide. Hidden entries keep their position in the hotkey
* order (their hotkey goes dead instead of remapping the colors after them).
*/
excludedColors?: readonly string[];
}
const PickerColorList = ({
@@ -29,6 +34,7 @@ const PickerColorList = ({
onChange,
activeShade,
showHotKey = true,
excludedColors,
}: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromColor({
color,
@@ -52,6 +58,19 @@ const PickerColorList = ({
const color =
(Array.isArray(value) ? value[activeShade] : value) || "transparent";
if (excludedColors?.includes(color)) {
// invisible placeholder so the remaining colors keep their exact
// grid positions (and hotkeys) — muscle memory stays intact
return (
<div
key={key}
className="color-picker__button color-picker__button--large"
style={{ visibility: "hidden" }}
aria-hidden="true"
/>
);
}
const keybinding = colorPickerHotkeyBindings[index];
const label = t(
`colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
@@ -1,4 +1,7 @@
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "@excalidraw/common";
import {
isTransparent,
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
} from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types";
@@ -67,7 +70,9 @@ export const getMostUsedCustomColors = (
const color =
element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"];
return isCustomColor({ color, palette });
// fully-transparent colors are "no color", not a reusable custom color
// (matters for palettes that exclude `transparent`, e.g. bucket fill)
return !isTransparent(color) && isCustomColor({ color, palette });
});
const colorCountMap = new Map<string, number>();
@@ -55,6 +55,7 @@ interface HotkeyHandlerProps {
update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
) => void;
activeShade: number;
excludedColors?: readonly string[];
}
/**
@@ -68,6 +69,7 @@ const hotkeyHandler = ({
customColors,
setActiveColorPickerSection,
activeShade,
excludedColors,
}: HotkeyHandlerProps): boolean => {
if (colorObj?.shade != null) {
// shift + numpad is extremely messed up on windows apparently
@@ -98,6 +100,14 @@ const hotkeyHandler = ({
const r = Array.isArray(paletteValue)
? paletteValue[activeShade]
: paletteValue;
// hotkeys of excluded (hidden) or absent palette entries are dead, not
// remapped — this keeps every other color on its usual key. Still
// HANDLED: within the modal's keyboard scope the key is deliberately
// inert, and reporting it unhandled would let it escape to global
// shortcuts (`q` toggles the tool lock)
if (r == null || excludedColors?.includes(r)) {
return true;
}
onChange(r);
setActiveColorPickerSection("baseColors");
return true;
@@ -119,6 +129,7 @@ interface ColorPickerKeyNavHandlerProps {
activeShade: number;
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
excludedColors?: readonly string[];
}
/**
@@ -136,6 +147,7 @@ export const colorPickerKeyNavHandler = ({
activeShade,
onEyeDropperToggle,
onEscape,
excludedColors,
}: ColorPickerKeyNavHandlerProps): boolean => {
if (event[KEYS.CTRL_OR_CMD]) {
return false;
@@ -226,6 +238,7 @@ export const colorPickerKeyNavHandler = ({
customColors,
setActiveColorPickerSection,
activeShade,
excludedColors,
})
) {
return true;
@@ -249,12 +262,28 @@ export const colorPickerKeyNavHandler = ({
const colorNames = Object.keys(palette) as (keyof ColorPalette)[];
const indexOfColorName = colorNames.indexOf(colorName);
const newColorIndex = arrowHandler(
let newColorIndex = arrowHandler(
event.key,
indexOfColorName,
colorNames.length,
);
// step over excluded (hidden) palette entries, continuing in the
// arrow's direction
let guard = 0;
while (newColorIndex !== undefined && guard++ < colorNames.length) {
const value = palette[colorNames[newColorIndex]];
const resolved = Array.isArray(value) ? value[activeShade] : value;
if (!excludedColors?.includes(resolved)) {
break;
}
newColorIndex = arrowHandler(
event.key,
newColorIndex,
colorNames.length,
);
}
if (newColorIndex !== undefined) {
const newColorName = colorNames[newColorIndex];
const newColorNameValue = palette[newColorName];
@@ -186,6 +186,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/>
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
<Shortcut label={t("toolBar.bucketfill")} shortcuts={[KEYS.B]} />
<Shortcut
label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
@@ -30,6 +30,7 @@ import {
EmbedIcon,
laserPointerToolIcon,
drawShapeToolIcon,
bucketFillIcon,
mermaidLogoIcon,
MagicIcon,
} from "./icons";
@@ -76,6 +77,7 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
const drawShapeToolSelected = activeTool.type === "autoshape";
const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable";
const bucketFillToolSelected = activeTool.type === "bucketfill";
const { TTDDialogTriggerTunnel } = useTunnels();
@@ -110,7 +112,14 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
const showFrameToolOutside = toolbarWidth >= MIN_WIDTH + 3 * ADDITIONAL_WIDTH;
const extraTools: readonly typeof activeTool.type[] = (
["text", "frame", "embeddable", "laser", "magicframe"] as const
[
"text",
"frame",
"embeddable",
"laser",
"bucketfill",
"magicframe",
] as const
).filter((tool) => {
if (showTextToolOutside && tool === "text") {
return false;
@@ -132,6 +141,8 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
? EmbedIcon
: activeTool.type === "laser"
? laserPointerToolIcon
: activeTool.type === "bucketfill"
? bucketFillIcon
: activeTool.type === "magicframe"
? MagicIcon
: DotsIcon
@@ -308,6 +319,16 @@ export const MobileToolbar = ({ app, setAppState }: MobileToolbarProps) => {
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "bucketfill" })}
icon={bucketFillIcon}
data-testid="toolbar-bucketfill"
selected={bucketFillToolSelected}
shortcut={KEYS.B.toLocaleUpperCase()}
disabled={isToolButtonDisabled(app, "bucketfill")}
>
{t("toolBar.bucketfill")}
</DropdownMenu.Item>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
@@ -19,6 +19,7 @@ import {
frameToolIcon,
LassoIcon,
laserPointerToolIcon,
bucketFillIcon,
MagicIcon,
mermaidLogoIcon,
DotsIcon,
@@ -65,6 +66,7 @@ const ExtraToolsDropdown = ({
const frameToolSelected = activeTool.type === "frame";
const drawShapeToolSelected = activeTool.type === "autoshape";
const laserToolSelected = activeTool.type === "laser";
const bucketFillToolSelected = activeTool.type === "bucketfill";
const lassoToolSelected =
isFullStylesPanel &&
activeTool.type === "lasso" &&
@@ -80,6 +82,7 @@ const ExtraToolsDropdown = ({
embeddableToolSelected ||
(isFullStylesPanel && drawShapeToolSelected) ||
lassoToolSelected ||
bucketFillToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
// on top of it
@@ -101,6 +104,8 @@ const ExtraToolsDropdown = ({
? laserPointerToolIcon
: lassoToolSelected
? LassoIcon
: bucketFillToolSelected
? bucketFillIcon
: DotsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
@@ -147,6 +152,16 @@ const ExtraToolsDropdown = ({
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "bucketfill" })}
icon={bucketFillIcon}
data-testid="toolbar-bucketfill"
selected={bucketFillToolSelected}
shortcut={KEYS.B.toLocaleUpperCase()}
disabled={isToolButtonDisabled(app, "bucketfill")}
>
{t("toolBar.bucketfill")}
</DropdownMenu.Item>
{isFullStylesPanel && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
+5
View File
@@ -24,6 +24,7 @@ import {
ImageIcon,
EraserIcon,
laserPointerToolIcon,
bucketFillIcon,
LassoIcon,
handIcon,
frameToolIcon,
@@ -143,6 +144,10 @@ export const TOOLS = defineTools({
icon: laserPointerToolIcon,
letterKey: KEYS.K,
},
bucketfill: {
icon: bucketFillIcon,
letterKey: KEYS.B,
},
lasso: {
icon: LassoIcon,
fillable: false,
@@ -113,6 +113,9 @@ export const getShapeActionPredicates = (
strokeColor: canChangeStrokeColor(appState, targetElements),
backgroundColor: canChangeBackgroundColor(appState, targetElements),
fill:
// bucket fill never renders transparent (it falls back to a real
// color), so its fill style stays relevant either way
activeToolType === "bucketfill" ||
(hasBackground(activeToolType) &&
!isTransparent(appState.currentItemBackgroundColor)) ||
targetElements.some(
+1
View File
@@ -219,6 +219,7 @@ export const AllowedExcalidrawActiveTools: Record<
laser: false,
autoshape: false,
magicframe: false,
bucketfill: true,
};
export type RestoredDataState = {
+5
View File
@@ -332,11 +332,16 @@
"embeddable": "Web Embed",
"laser": "Laser pointer",
"autoshape": "Draw to shape",
"bucketfill": "Bucket fill",
"hand": "Hand (panning tool)",
"extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw",
"convertElementType": "Toggle shape type"
},
"bucketfill": {
"noRegion": "Couldn't find an enclosed region to fill here.",
"tooComplex": "This region is too complex to fill."
},
"element": {
"rectangle": "Rectangle",
"diamond": "Diamond",
@@ -0,0 +1,707 @@
import { KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { pointFrom } from "@excalidraw/math";
import type { LocalPoint } from "@excalidraw/math";
import type { ExcalidrawLineElement } from "@excalidraw/element/types";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { Keyboard, Pointer } from "./helpers/ui";
import {
act,
fireEvent,
GlobalTestState,
render,
togglePopover,
} from "./test-utils";
const { h } = window;
const mouse = new Pointer("mouse");
const selectBucketFill = () => {
act(() => {
h.app.setActiveTool({ type: "bucketfill" });
});
};
describe("bucket fill tool", () => {
beforeAll(() => {
// radix popovers (font family, color picker) need a ResizeObserver;
// jsdom has none (same stub as test-utils' togglePopover)
(global as any).ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
});
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
const seedRectangle = () => {
const rect = API.createElement({
type: "rectangle",
x: 20,
y: 20,
width: 120,
height: 100,
roundness: null,
backgroundColor: "transparent",
});
// commit the owner as its own history increment so the bucket fill is a
// separate, independently-undoable step
act(() => {
h.app.updateScene({
elements: [rect],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
return rect;
};
it("creates a unselected line polygon with the current background color", () => {
const rect = seedRectangle();
act(() => {
// the bucket fill color is the shared shape background color
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
const fill = h.elements.find(
(el) => el.type === "line" && !el.isDeleted,
) as ExcalidrawLineElement | undefined;
expect(fill).toBeDefined();
expect(fill!.polygon).toBe(true);
expect(fill!.backgroundColor).toBe("#ffec99");
expect(fill!.strokeColor).toBe("transparent");
expect(fill!.fillStyle).toBe("solid");
// no metadata marker: fills are recognized by shape, not provenance
expect(fill!.customData).toBeUndefined();
// linear elements must be normalized: points[0] === [0, 0], and the
// polygon is explicitly closed
expect(fill!.points[0]).toEqual([0, 0]);
expect(fill!.points[fill!.points.length - 1]).toEqual([0, 0]);
// inserted immediately below (before) the owner in z-order
expect(h.elements[0].id).toBe(fill!.id);
expect(h.elements[1].id).toBe(rect.id);
// the fill is NOT selected and the tool stays active so the user can keep
// filling regions back-to-back
expect(h.state.selectedElementIds[fill!.id]).not.toBe(true);
expect(h.state.activeTool.type).toBe("bucketfill");
});
it("fires the public onPointerDown/onPointerUp callbacks", async () => {
// regression: the bucket branch used to early-return before the shared
// pointer lifecycle, silently skipping the public callback contract
const onPointerDown = vi.fn();
const onPointerUp = vi.fn();
await render(
<Excalidraw
handleKeyboardGlobally={true}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
/>,
);
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
expect(onPointerDown).toHaveBeenCalledTimes(1);
expect(onPointerUp).toHaveBeenCalledTimes(1);
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(1);
});
it("recovers from a lost pointer-up via the shared cleanup", () => {
// regression: the old bespoke once-listener only reset pointer state on
// a real pointer-up; the shared missing-pointer-up cleanup now covers
// lost ones (e.g. window blurred mid-click)
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.downAt(80, 70);
expect(h.state.cursorButton).toBe("down");
act(() => {
(h.app as any).maybeCleanupAfterMissingPointerUp(null);
});
expect(h.state.cursorButton).toBe("up");
// the click never completed, so the armed fill is discarded — an
// interrupted interaction must not leave a permanent edit
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(0);
mouse.upAt(80, 70);
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(0);
});
it("dragging with the bucket tool neither selects nor creates extras", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.downAt(80, 70);
mouse.moveTo(120, 100); // drag well past any threshold
mouse.upAt(120, 100);
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(1);
expect(h.state.selectionElement).toBeNull();
expect(Object.keys(h.state.selectedElementIds)).toHaveLength(0);
expect(h.state.activeTool.type).toBe("bucketfill");
});
it("re-clicking a filled region does not stack a duplicate fill", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
mouse.clickAt(80, 70);
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(1);
});
it("re-clicking with a changed color restyles the existing fill", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffc9c9" });
});
mouse.clickAt(80, 70);
const fills = h.elements.filter(
(el) => el.type === "line" && !el.isDeleted,
);
expect(fills).toHaveLength(1);
expect(fills[0].backgroundColor).toBe("#ffc9c9");
// the restyle is its own undoable step
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
const afterUndo = h.elements.filter(
(el) => el.type === "line" && !el.isDeleted,
);
expect(afterUndo).toHaveLength(1);
expect(afterUndo[0].backgroundColor).toBe("#ffec99");
});
it("restyles an orphaned fill even when no region can be derived", () => {
const rect = seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
// delete the rectangle: the fill's boundaries are gone, so no region
// can be derived around the click anymore
act(() => {
h.app.updateScene({
elements: h.elements.filter((el) => el.id !== rect.id),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffc9c9" });
});
mouse.clickAt(80, 70); // on the orphaned paint
const fills = h.elements.filter(
(el) => el.type === "line" && !el.isDeleted,
);
expect(fills).toHaveLength(1);
expect(fills[0].backgroundColor).toBe("#ffc9c9");
});
it("clicking a since-subdivided part of a filled region creates a new fill", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70); // fill the whole rectangle
// split the region with a line drawn afterwards
const splitter = API.createElement({
type: "line",
x: 10,
y: 70,
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(140, 0)],
});
act(() => {
h.app.updateScene({
elements: [...h.elements, splitter],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
mouse.clickAt(80, 45); // top half: a different (smaller) region now
const fills = h.elements.filter(
(el) => el.type === "line" && !el.isDeleted && el.id !== splitter.id,
);
expect(fills).toHaveLength(2);
});
it("can fill multiple regions in a row without re-selecting the tool", () => {
const first = API.createElement({
type: "rectangle",
x: 20,
y: 20,
width: 100,
height: 80,
roundness: null,
backgroundColor: "transparent",
});
const second = API.createElement({
type: "rectangle",
x: 200,
y: 20,
width: 100,
height: 80,
roundness: null,
backgroundColor: "transparent",
});
act(() => {
h.app.updateScene({
elements: [first, second],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(70, 60); // inside first
expect(h.state.activeTool.type).toBe("bucketfill");
mouse.clickAt(250, 60); // inside second, tool still active
const fills = h.elements.filter(
(el) => el.type === "line" && !el.isDeleted,
);
expect(fills).toHaveLength(2);
expect(h.state.activeTool.type).toBe("bucketfill");
});
it("shows fill color, fill style and opacity in the panel when the tool is active", () => {
seedRectangle();
selectBucketFill();
const panel = document.querySelector(".selected-shape-actions");
expect(panel).not.toBeNull();
// the fill (background) color picker is shown...
expect(panel!.querySelector('[aria-label="Background"]')).not.toBeNull();
// ...along with the fill style + opacity controls for the created fills...
expect(panel!.querySelector('[data-testid="fill-hachure"]')).not.toBeNull();
expect(panel!.querySelector('[data-testid="opacity"]')).not.toBeNull();
// ...and nothing else from the generic shape panel (e.g. stroke)
expect(panel!.querySelector('[aria-label="Stroke"]')).toBeNull();
});
it("uses the current fill style and opacity for created fills", () => {
seedRectangle();
act(() => {
API.setAppState({
currentItemBackgroundColor: "#ffec99",
currentItemFillStyle: "hachure",
currentItemOpacity: 60,
});
});
selectBucketFill();
mouse.clickAt(80, 70);
const fill = h.elements.find(
(el) => el.type === "line" && !el.isDeleted,
) as ExcalidrawLineElement | undefined;
expect(fill).toBeDefined();
expect(fill!.fillStyle).toBe("hachure");
expect(fill!.opacity).toBe(60);
});
it("selects the tool with the b shortcut", () => {
expect(h.state.activeTool.type).toBe("selection");
Keyboard.keyPress(KEYS.B);
expect(h.state.activeTool.type).toBe("bucketfill");
});
it("opens the background color popup with the g shortcut", () => {
selectBucketFill();
Keyboard.keyPress(KEYS.G);
expect(h.state.openPopup).toBe("elementBackground");
});
it("swallows the hidden transparent entry's hotkey (q) inside the picker", () => {
// regression: the hidden transparent entry's `q` returned "unhandled",
// so the picker didn't stop propagation and App.onKeyDown toggled the
// global tool lock while the color dialog was open
seedRectangle();
selectBucketFill();
togglePopover("Background");
const picker = document.querySelector(".color-picker-content")!;
expect(picker).not.toBeNull();
expect(h.state.activeTool.locked).toBe(false);
const before = h.state.currentItemBackgroundColor;
// `q` maps to the hidden transparent swatch AND to the global tool-lock
// shortcut — inside the modal it must be dead, not leaked
fireEvent.keyDown(picker, { key: "q" });
expect(h.state.activeTool.locked).toBe(false);
expect(h.state.currentItemBackgroundColor).toBe(before);
// the other hotkeys keep working on their usual keys (w = white)
fireEvent.keyDown(picker, { key: "w" });
expect(h.state.currentItemBackgroundColor).not.toBe(before);
expect(h.state.currentItemBackgroundColor).not.toBe("transparent");
});
it("shift+f opens the font popup instead of switching to bucket fill", () => {
// regression: bucket fill was originally bound to shift+f, shadowing the
// long-standing "show fonts" shortcut for text elements
const text = API.createElement({ type: "text", x: 20, y: 20 });
act(() => {
h.app.updateScene({
elements: [text],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
API.setAppState({ selectedElementIds: { [text.id]: true } });
});
Keyboard.withModifierKeys({ shift: true }, () => {
Keyboard.keyPress("F");
});
expect(h.state.activeTool.type).not.toBe("bucketfill");
expect(h.state.openPopup).toBe("fontFamily");
});
it("falls back to green when the shared background color is transparent", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "transparent" });
});
selectBucketFill();
mouse.clickAt(80, 70);
const fill = h.elements.find(
(el) => el.type === "line" && !el.isDeleted,
) as ExcalidrawLineElement | undefined;
expect(fill).toBeDefined();
expect(fill!.backgroundColor).toBe("#b2f2bb");
// the shared appState value is left untouched
expect(h.state.currentItemBackgroundColor).toBe("transparent");
});
it("inserts the overlap fill below the lowest participating shape", () => {
const below = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
roundness: null,
backgroundColor: "transparent",
});
const owner = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 100,
height: 100,
roundness: null,
backgroundColor: "transparent",
});
act(() => {
h.app.updateScene({
elements: [below, owner],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
// click the overlap lens (inside both rectangles)
mouse.clickAt(75, 75);
const fill = h.elements.find((el) => el.type === "line" && !el.isDeleted)!;
expect(fill).toBeDefined();
// both rectangles bound the lens, so the fill goes below the lower one
expect(h.elements[0].id).toBe(fill.id);
expect(h.elements[1].id).toBe(below.id);
expect(h.elements[2].id).toBe(owner.id);
});
it("inserts the fill above a participant whose opaque background covers it", () => {
// lower rectangle is filled red: its background would hide a fill placed
// beneath it, so the bucket fill must go above it (but below the
// transparent owner, whose outline should stay visible)
const below = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
roundness: null,
backgroundColor: "#ff0000",
});
const owner = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 100,
height: 100,
roundness: null,
backgroundColor: "transparent",
});
act(() => {
h.app.updateScene({
elements: [below, owner],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
});
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(75, 75);
const fill = h.elements.find((el) => el.type === "line" && !el.isDeleted)!;
expect(fill).toBeDefined();
// order: below (red, covered) -> fill -> owner (transparent outline on top)
expect(h.elements[0].id).toBe(below.id);
expect(h.elements[1].id).toBe(fill.id);
expect(h.elements[2].id).toBe(owner.id);
});
it("does nothing when clicking empty canvas", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(400, 400);
expect(h.elements.filter((el) => el.type === "line")).toHaveLength(0);
});
it("does not fill on right-click or middle-click", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
for (const button of [1, 2]) {
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button,
pointerId: 1,
pointerType: "mouse",
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button,
pointerId: 1,
pointerType: "mouse",
});
}
expect(h.elements.filter((el) => el.type === "line")).toHaveLength(0);
});
it("a normal touch tap fills once, on release", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button: 0,
pointerId: 1,
pointerType: "touch",
});
// the fill is only armed while the finger is down — nothing committed yet
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(0);
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
pointerId: 1,
pointerType: "touch",
});
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(1);
});
it("a second touch pointer aborts the fill (pinch/pan intent)", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button: 0,
pointerId: 1,
pointerType: "touch",
});
// a second finger lands while the first is still down
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 100,
clientY: 90,
button: 0,
pointerId: 2,
pointerType: "touch",
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
pointerId: 2,
pointerType: "touch",
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
pointerId: 1,
pointerType: "touch",
});
// the user meant to pinch/pan, not to fill — no permanent edit
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(0);
});
it("pointercancel aborts the fill", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button: 0,
pointerId: 1,
pointerType: "touch",
});
// the browser takes the pointer over (scroll, palm rejection) — no
// pointerup will follow
fireEvent.pointerCancel(GlobalTestState.interactiveCanvas, {
pointerId: 1,
pointerType: "touch",
});
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(0);
// and the next tap fills normally
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
button: 0,
pointerId: 2,
pointerType: "touch",
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
clientX: 80,
clientY: 70,
pointerId: 2,
pointerType: "touch",
});
expect(
h.elements.filter((el) => el.type === "line" && !el.isDeleted),
).toHaveLength(1);
});
it("does not fill in view mode", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
act(() => {
API.setAppState({ viewModeEnabled: true });
});
mouse.clickAt(80, 70);
expect(h.elements.filter((el) => el.type === "line")).toHaveLength(0);
});
it("undo removes the generated fill in one step", () => {
seedRectangle();
act(() => {
API.setAppState({ currentItemBackgroundColor: "#ffec99" });
});
selectBucketFill();
mouse.clickAt(80, 70);
const fill = h.elements.find((el) => el.type === "line" && !el.isDeleted)!;
expect(fill).toBeDefined();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
expect(
h.elements.find((el) => el.id === fill.id && !el.isDeleted),
).toBeUndefined();
// the owner rectangle survives
expect(
h.elements.filter((el) => el.type === "rectangle" && !el.isDeleted),
).toHaveLength(1);
});
});
@@ -0,0 +1,82 @@
import { COLOR_PALETTE } from "@excalidraw/common";
import type { ColorPaletteCustom } from "@excalidraw/common";
import { colorPickerKeyNavHandler } from "../components/ColorPicker/keyboardNavHandlers";
const handlerArgs = (
key: string,
palette: ColorPaletteCustom,
onChange: (color: string) => void,
excludedColors?: readonly string[],
) => ({
event: {
key,
ctrlKey: false,
metaKey: false,
shiftKey: false,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.KeyboardEvent,
activeColorPickerSection: null,
palette,
color: null,
onChange,
customColors: [],
setActiveColorPickerSection: () => {},
updateData: () => {},
activeShade: 1,
onEyeDropperToggle: () => {},
onEscape: () => {},
excludedColors,
});
describe("color picker hotkeys", () => {
it("reports an excluded (hidden) entry's hotkey as handled without selecting it", () => {
// regression: returning "unhandled" let the key escape the modal and hit
// global shortcuts (`q` toggles the tool lock; see the integration
// regression in bucketFill.test.tsx)
const onChange = vi.fn();
const palette = {
transparent: COLOR_PALETTE.transparent,
red: "#ff0000",
} as ColorPaletteCustom;
const handled = colorPickerKeyNavHandler(
handlerArgs("q", palette, onChange, [COLOR_PALETTE.transparent]),
);
expect(handled).toBe(true);
expect(onChange).not.toHaveBeenCalled();
});
it("reports a hotkey past the end of a short custom palette as handled", () => {
// a host-provided palette with fewer entries than hotkey bindings must
// not leak the unbound keys either
const onChange = vi.fn();
const palette = { red: "#ff0000" } as ColorPaletteCustom;
// `b` is the 15th binding — far past the single palette entry
const handled = colorPickerKeyNavHandler(
handlerArgs("b", palette, onChange),
);
expect(handled).toBe(true);
expect(onChange).not.toHaveBeenCalled();
});
it("still selects a present, non-excluded entry", () => {
const onChange = vi.fn();
const palette = {
transparent: COLOR_PALETTE.transparent,
red: "#ff0000",
} as ColorPaletteCustom;
const handled = colorPickerKeyNavHandler(
handlerArgs("w", palette, onChange, [COLOR_PALETTE.transparent]),
);
expect(handled).toBe(true);
expect(onChange).toHaveBeenCalledWith("#ff0000");
});
});
+2
View File
@@ -207,6 +207,7 @@ export class API {
? ExcalidrawTextElement["containerId"]
: never;
points?: T extends "arrow" | "line" | "freedraw" ? readonly LocalPoint[] : never;
polygon?: T extends "line" ? boolean : never;
strokeOptions?: T extends "freedraw"
? ExcalidrawFreeDrawElement["strokeOptions"]
: never;
@@ -358,6 +359,7 @@ export class API {
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 100),
],
polygon: rest.polygon,
});
break;
case "image":
+2 -1
View File
@@ -1351,7 +1351,8 @@ describe("tool locking & selection", () => {
// no top-level toolbar button (rendered in the extra-tools dropdown)
value !== "frame" &&
value !== "embeddable" &&
value !== "autoshape"
value !== "autoshape" &&
value !== "bucketfill"
) {
const element = UI.createElement(value);
expect(h.state.selectedElementIds[element.id]).not.toBe(true);
+2 -1
View File
@@ -161,7 +161,8 @@ export type ToolType =
| "magicframe"
| "embeddable"
| "laser"
| "autoshape";
| "autoshape"
| "bucketfill";
export type ElementOrToolType = ExcalidrawElementType | ToolType | "custom";
+7 -3
View File
@@ -87,12 +87,15 @@ export function polygonIsClosed<Point extends LocalPoint | GlobalPoint>(
* The signed area of a polygon via the shoelace formula. Positive when the
* vertices wind counter-clockwise in a y-down coordinate system.
*
* The polygon may be given open or closed; a closing vertex is ignored.
* The polygon may be given open or closed; a closing vertex
*/
export function polygonSignedArea<Point extends LocalPoint | GlobalPoint>(
polygon: readonly Point[],
tolerance: number = PRECISION,
): number {
const pts = polygonIsClosed(polygon) ? polygon.slice(0, -1) : polygon;
const pts = polygonIsClosed(polygon, tolerance)
? polygon.slice(0, -1)
: polygon;
let sum = 0;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
sum += pts[j][0] * pts[i][1] - pts[i][0] * pts[j][1];
@@ -102,8 +105,9 @@ export function polygonSignedArea<Point extends LocalPoint | GlobalPoint>(
export function polygonArea<Point extends LocalPoint | GlobalPoint>(
polygon: readonly Point[],
tolerance: number = PRECISION,
): number {
return Math.abs(polygonSignedArea(polygon));
return Math.abs(polygonSignedArea(polygon, tolerance));
}
/**