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:
238
packages/excalidraw/components/App.cursor.ts
Normal file
238
packages/excalidraw/components/App.cursor.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
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 = `<svg viewBox="0 0 24 24" stroke-width="1" width="28" height="28" xmlns="http://www.w3.org/2000/svg">`;
|
||||
const laserPointerCursorBackgroundSVG = `<path d="M6.164 11.755a5.314 5.314 0 0 1-4.932-5.298 5.314 5.314 0 0 1 5.311-5.311 5.314 5.314 0 0 1 5.307 5.113l8.773 8.773a3.322 3.322 0 0 1 0 4.696l-.895.895a3.322 3.322 0 0 1-4.696 0l-8.868-8.868Z" style="fill:#fff"/>`;
|
||||
const laserPointerCursorIconSVG = `<path stroke="#1b1b1f" fill="#fff" d="m7.868 11.113 7.773 7.774a2.359 2.359 0 0 0 1.667.691 2.368 2.368 0 0 0 2.357-2.358c0-.625-.248-1.225-.69-1.667L11.201 7.78 9.558 9.469l-1.69 1.643v.001Zm10.273 3.606-3.333 3.333m-3.25-6.583 2 2m-7-7 3 3M3.664 3.625l1 1M2.529 6.922l1.407-.144m5.735-2.932-1.118.866M4.285 9.823l.758-1.194m1.863-6.207-.13 1.408"/>`;
|
||||
|
||||
const laserPointerCursorDataURL_lightMode = `data:${
|
||||
MIME_TYPES.svg
|
||||
},${encodeURIComponent(
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
const laserPointerCursorDataURL_darkMode = `data:${
|
||||
MIME_TYPES.svg
|
||||
},${encodeURIComponent(
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
|
||||
const createBucketFillCursorDataURL = (color: string) => {
|
||||
const paths = bucketFillIconSvgPaths
|
||||
.map(
|
||||
(path, index) =>
|
||||
`<path d="${path}" ${index < 2 ? `fill="${color}"` : ""} />`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
`<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke-linecap="round" stroke-linejoin="round"><g stroke="#fff" fill="#fff" stroke-width="5">${paths}</g><g stroke="#1b1b1f" stroke-width="1.25">${paths}</g></svg>`,
|
||||
)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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`,
|
||||
);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user