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(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(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); }