snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Has been cancelled
Some checks failed
Tests / test (push) Has been cancelled
This commit is contained in:
301
packages/excalidraw/components/App.bucketFill.ts
Normal file
301
packages/excalidraw/components/App.bucketFill.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
BUCKET_FILL_BACKGROUND_PICKS,
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
getSizeFromPoints,
|
||||
isTransparent,
|
||||
THEME,
|
||||
} 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,
|
||||
Theme,
|
||||
} 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 = {
|
||||
x: number;
|
||||
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
|
||||
* 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;
|
||||
};
|
||||
|
||||
cycleBackgroundColor = () => {
|
||||
const currentColor = this.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.
|
||||
*/
|
||||
private restyle = (element: NonDeletedExcalidrawElement) => {
|
||||
const backgroundColor = this.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 = this.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();
|
||||
};
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
public getBucketFillBackgroundColor = (
|
||||
backgroundColor: string,
|
||||
/** supply only for display purposes such as when applying to the cursor */
|
||||
theme?: Theme,
|
||||
) =>
|
||||
isTransparent(backgroundColor)
|
||||
? COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]
|
||||
: applyDarkModeFilter(backgroundColor, theme === THEME.DARK);
|
||||
}
|
||||
Reference in New Issue
Block a user