import { CURSOR_TYPE, MIME_TYPES, THEME } from "@excalidraw/common"; import { isHandToolActive, isEraserActive } from "../appState"; import { bucketFillIconSvgPaths } from "./icons"; import type { AppState, DataURL } from "../types"; import type App from "./App"; const laserPointerCursorSVG_tag = ``; const laserPointerCursorBackgroundSVG = ``; const laserPointerCursorIconSVG = ``; const laserPointerCursorDataURL_lightMode = `data:${ MIME_TYPES.svg },${encodeURIComponent( `${laserPointerCursorSVG_tag}${laserPointerCursorIconSVG}`, )}`; const laserPointerCursorDataURL_darkMode = `data:${ MIME_TYPES.svg },${encodeURIComponent( `${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}`, )}`; const createBucketFillCursorDataURL = (color: string) => { const paths = bucketFillIconSvgPaths .map( (path, index) => ``, ) .join(""); return `data:${MIME_TYPES.svg},${encodeURIComponent( `${paths}${paths}`, )}`; }; /** * Captures all cursor management for the interactive canvas. * * The canvas cursor is set exclusively imperatively, through this class — * never via inline styles or React-rendered `style.cursor`, which React * would rewrite on rerender whenever the computed value changes, clobbering * 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; constructor(private app: App) {} private get canvas() { return this.app.interactiveCanvas; } 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; } }; /** * Resets to the resting cursor — the cursor shown when no interaction or * hover affordance overrides it: the view-mode grab cursor when * drag-to-pan applies, the active tool's cursor otherwise. */ reset = () => { if ( this.app.state.viewModeEnabled && !this.app.isActiveToolPointerCapturing() ) { if (this.app.isNavigationEnabled()) { this.set(CURSOR_TYPE.GRAB); } else { this.clear(); } } else { this.applyForTool(); } }; /** applies the given tool's cursor (defaults to the active tool) */ applyForTool = ( activeTool: AppState["activeTool"] = this.app.state.activeTool, ) => { const canvas = this.canvas; if (!canvas) { return; } const theme = activeTool.type === "eraser" || activeTool.type === "laser" || activeTool.type === "bucketfill" ? this.app.state.theme : undefined; const bucketFillColor = activeTool.type === "bucketfill" ? this.app.bucketFill.getBucketFillBackgroundColor( this.app.state.currentItemBackgroundColor, 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 })) { this.set(CURSOR_TYPE.GRAB); } else if (isEraserActive({ activeTool })) { 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 ? laserPointerCursorDataURL_lightMode : laserPointerCursorDataURL_darkMode; this.set(`url(${url}), auto`); } else if (!["image", "custom"].includes(activeTool.type)) { this.set(CURSOR_TYPE.CROSSHAIR); // do nothing if image tool is selected which suggests there's // an image-preview set as the cursor // Ignore custom type as well and let host decide } else if (activeTool.type !== "image") { this.set(CURSOR_TYPE.AUTO); } this.toolCursorMemo = { canvas, type: activeTool.type, bucketFillColor, theme, }; }; /** * Re-runs the hover cursor logic at the last known pointer position by * re-dispatching a synthetic pointermove — for state changes that alter * what hovering means without the pointer moving (e.g. the tool reverting * to selection after an element is created). Mouse only: touch and pen * have no resting hover to restore. */ refreshHover = () => { const lastEvent = this.app.lastPointerMoveEvent ?? this.app.lastPointerUpEvent; if (!lastEvent || !this.canvas) { return; } const pointerType = (lastEvent as PointerEvent).pointerType; if (pointerType && pointerType !== "mouse") { return; } // jsdom has no PointerEvent; a MouseEvent of type "pointermove" walks // the identical dispatch path const EventConstructor = typeof PointerEvent !== "undefined" ? PointerEvent : MouseEvent; this.canvas.dispatchEvent( new EventConstructor("pointermove", { bubbles: true, cancelable: true, clientX: lastEvent.clientX, clientY: lastEvent.clientY, }), ); }; /** clears the inline cursor so the environment default (CSS) applies */ private clear = () => { this.set(""); }; private applyEraser = () => { const cursorImageSizePx = 20; const theme = this.app.state.theme; if (!this.eraserCanvasCache || this.eraserCanvasCache.theme !== theme) { const isDarkTheme = theme === THEME.DARK; this.eraserCanvasCache = document.createElement("canvas"); this.eraserCanvasCache.theme = theme; this.eraserCanvasCache.height = cursorImageSizePx; this.eraserCanvasCache.width = cursorImageSizePx; const context = this.eraserCanvasCache.getContext("2d")!; context.lineWidth = 1; context.beginPath(); context.arc( this.eraserCanvasCache.width / 2, this.eraserCanvasCache.height / 2, 5, 0, 2 * Math.PI, ); context.fillStyle = isDarkTheme ? "#000" : "#fff"; context.fill(); context.strokeStyle = isDarkTheme ? "#fff" : "#000"; context.stroke(); this.eraserPreviewDataURL = this.eraserCanvasCache.toDataURL( MIME_TYPES.svg, ) as DataURL; } this.set( `url(${this.eraserPreviewDataURL}) ${cursorImageSizePx / 2} ${ cursorImageSizePx / 2 }, auto`, ); }; }