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 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(
``,
)}`;
};
/**
* 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`,
);
};
}