feat: better laser cursor for dark mode (#7132)
This commit is contained in:
parent
7ad02c359a
commit
26ff3993bb
@ -10,7 +10,7 @@ import { getNormalizedZoom } from "../scene";
|
|||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
|
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
@ -21,6 +21,7 @@ import {
|
|||||||
} from "../appState";
|
} from "../appState";
|
||||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||||
import { Bounds } from "../element/bounds";
|
import { Bounds } from "../element/bounds";
|
||||||
|
import { setCursor } from "../cursor";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { updateActiveTool, resetCursor } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@ -15,6 +15,7 @@ import {
|
|||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { resetCursor } from "../cursor";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
|
@ -4,7 +4,8 @@ import { removeAllElementsFromFrame } from "../frame";
|
|||||||
import { getFrameElements } from "../frame";
|
import { getFrameElements } from "../frame";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { setCursorForShape, updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
|
import { setCursorForShape } from "../cursor";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
||||||
|
@ -241,18 +241,14 @@ import {
|
|||||||
isInputLike,
|
isInputLike,
|
||||||
isToolIcon,
|
isToolIcon,
|
||||||
isWritableElement,
|
isWritableElement,
|
||||||
resetCursor,
|
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
sceneCoordsToViewportCoords,
|
sceneCoordsToViewportCoords,
|
||||||
setCursor,
|
|
||||||
setCursorForShape,
|
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
viewportCoordsToSceneCoords,
|
viewportCoordsToSceneCoords,
|
||||||
withBatchedUpdates,
|
withBatchedUpdates,
|
||||||
wrapEvent,
|
wrapEvent,
|
||||||
withBatchedUpdatesThrottled,
|
withBatchedUpdatesThrottled,
|
||||||
updateObject,
|
updateObject,
|
||||||
setEraserCursor,
|
|
||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
getShortcutKey,
|
getShortcutKey,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
@ -371,6 +367,12 @@ import { Renderer } from "../scene/Renderer";
|
|||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import { LaserToolOverlay } from "./LaserTool/LaserTool";
|
import { LaserToolOverlay } from "./LaserTool/LaserTool";
|
||||||
import { LaserPathManager } from "./LaserTool/LaserPathManager";
|
import { LaserPathManager } from "./LaserTool/LaserPathManager";
|
||||||
|
import {
|
||||||
|
setEraserCursor,
|
||||||
|
setCursor,
|
||||||
|
resetCursor,
|
||||||
|
setCursorForShape,
|
||||||
|
} from "../cursor";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
|
103
src/cursor.ts
Normal file
103
src/cursor.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { CURSOR_TYPE, MIME_TYPES, THEME } from "./constants";
|
||||||
|
import OpenColor from "open-color";
|
||||||
|
import { AppState, DataURL } from "./types";
|
||||||
|
import { isHandToolActive, isEraserActive } from "./appState";
|
||||||
|
|
||||||
|
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>`,
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
export const resetCursor = (interactiveCanvas: HTMLCanvasElement | null) => {
|
||||||
|
if (interactiveCanvas) {
|
||||||
|
interactiveCanvas.style.cursor = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setCursor = (
|
||||||
|
interactiveCanvas: HTMLCanvasElement | null,
|
||||||
|
cursor: string,
|
||||||
|
) => {
|
||||||
|
if (interactiveCanvas) {
|
||||||
|
interactiveCanvas.style.cursor = cursor;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let eraserCanvasCache: any;
|
||||||
|
let previewDataURL: string;
|
||||||
|
export const setEraserCursor = (
|
||||||
|
interactiveCanvas: HTMLCanvasElement | null,
|
||||||
|
theme: AppState["theme"],
|
||||||
|
) => {
|
||||||
|
const cursorImageSizePx = 20;
|
||||||
|
|
||||||
|
const drawCanvas = () => {
|
||||||
|
const isDarkTheme = theme === THEME.DARK;
|
||||||
|
eraserCanvasCache = document.createElement("canvas");
|
||||||
|
eraserCanvasCache.theme = theme;
|
||||||
|
eraserCanvasCache.height = cursorImageSizePx;
|
||||||
|
eraserCanvasCache.width = cursorImageSizePx;
|
||||||
|
const context = eraserCanvasCache.getContext("2d")!;
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(
|
||||||
|
eraserCanvasCache.width / 2,
|
||||||
|
eraserCanvasCache.height / 2,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
2 * Math.PI,
|
||||||
|
);
|
||||||
|
context.fillStyle = isDarkTheme ? OpenColor.black : OpenColor.white;
|
||||||
|
context.fill();
|
||||||
|
context.strokeStyle = isDarkTheme ? OpenColor.white : OpenColor.black;
|
||||||
|
context.stroke();
|
||||||
|
previewDataURL = eraserCanvasCache.toDataURL(MIME_TYPES.svg) as DataURL;
|
||||||
|
};
|
||||||
|
if (!eraserCanvasCache || eraserCanvasCache.theme !== theme) {
|
||||||
|
drawCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursor(
|
||||||
|
interactiveCanvas,
|
||||||
|
`url(${previewDataURL}) ${cursorImageSizePx / 2} ${
|
||||||
|
cursorImageSizePx / 2
|
||||||
|
}, auto`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setCursorForShape = (
|
||||||
|
interactiveCanvas: HTMLCanvasElement | null,
|
||||||
|
appState: Pick<AppState, "activeTool" | "theme">,
|
||||||
|
) => {
|
||||||
|
if (!interactiveCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (appState.activeTool.type === "selection") {
|
||||||
|
resetCursor(interactiveCanvas);
|
||||||
|
} else if (isHandToolActive(appState)) {
|
||||||
|
interactiveCanvas.style.cursor = CURSOR_TYPE.GRAB;
|
||||||
|
} else if (isEraserActive(appState)) {
|
||||||
|
setEraserCursor(interactiveCanvas, appState.theme);
|
||||||
|
// do nothing if image tool is selected which suggests there's
|
||||||
|
// a image-preview set as the cursor
|
||||||
|
// Ignore custom type as well and let host decide
|
||||||
|
} else if (appState.activeTool.type === "laser") {
|
||||||
|
const url =
|
||||||
|
appState.theme === THEME.LIGHT
|
||||||
|
? laserPointerCursorDataURL_lightMode
|
||||||
|
: laserPointerCursorDataURL_darkMode;
|
||||||
|
interactiveCanvas.style.cursor = `url(${url}), auto`;
|
||||||
|
} else if (!["image", "custom"].includes(appState.activeTool.type)) {
|
||||||
|
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
||||||
|
}
|
||||||
|
};
|
@ -2,7 +2,8 @@ import { register } from "../actions/register";
|
|||||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { ExcalidrawProps } from "../types";
|
import { ExcalidrawProps } from "../types";
|
||||||
import { getFontString, setCursorForShape, updateActiveTool } from "../utils";
|
import { getFontString, updateActiveTool } from "../utils";
|
||||||
|
import { setCursorForShape } from "../cursor";
|
||||||
import { newTextElement } from "./newElement";
|
import { newTextElement } from "./newElement";
|
||||||
import { getContainerElement, wrapText } from "./textElement";
|
import { getContainerElement, wrapText } from "./textElement";
|
||||||
import { isEmbeddableElement } from "./typeChecks";
|
import { isEmbeddableElement } from "./typeChecks";
|
||||||
|
96
src/utils.ts
96
src/utils.ts
@ -1,13 +1,9 @@
|
|||||||
import oc from "open-color";
|
|
||||||
import { COLOR_PALETTE } from "./colors";
|
import { COLOR_PALETTE } from "./colors";
|
||||||
import {
|
import {
|
||||||
CURSOR_TYPE,
|
|
||||||
DEFAULT_VERSION,
|
DEFAULT_VERSION,
|
||||||
EVENT,
|
EVENT,
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
isDarwin,
|
isDarwin,
|
||||||
MIME_TYPES,
|
|
||||||
THEME,
|
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import {
|
import {
|
||||||
@ -15,20 +11,11 @@ import {
|
|||||||
FontString,
|
FontString,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { ActiveTool, AppState, DataURL, ToolType, Zoom } from "./types";
|
import { ActiveTool, AppState, ToolType, Zoom } from "./types";
|
||||||
import { unstable_batchedUpdates } from "react-dom";
|
import { unstable_batchedUpdates } from "react-dom";
|
||||||
import { isEraserActive, isHandToolActive } from "./appState";
|
|
||||||
import { ResolutionType } from "./utility-types";
|
import { ResolutionType } from "./utility-types";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
const laserPointerCursorSVG = `<svg viewBox="0 0 20 20" width="20" height="20" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round">
|
|
||||||
<path d="m6.771 10.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.667L10.104 6.78 8.461 8.469l-1.69 1.643v.001Zm10.273 3.606-3.333 3.333m-3.25-6.583 2 2m-7-7 3 3M2.567 2.625l1 1M1.432 5.922l1.407-.144m5.735-2.932-1.118.866M3.188 8.823l.758-1.194m1.863-6.207-.13 1.408" style="fill:none;fill-rule:nonzero;stroke:#1b1b1f;stroke-width:1.25px"/>
|
|
||||||
</svg>`;
|
|
||||||
|
|
||||||
const laserPointerCursorDataURL = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
|
||||||
`${laserPointerCursorSVG}`,
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
let mockDateTime: string | null = null;
|
let mockDateTime: string | null = null;
|
||||||
|
|
||||||
export const setDateTimeForTests = (dateTime: string) => {
|
export const setDateTimeForTests = (dateTime: string) => {
|
||||||
@ -402,87 +389,6 @@ export const updateActiveTool = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resetCursor = (interactiveCanvas: HTMLCanvasElement | null) => {
|
|
||||||
if (interactiveCanvas) {
|
|
||||||
interactiveCanvas.style.cursor = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setCursor = (
|
|
||||||
interactiveCanvas: HTMLCanvasElement | null,
|
|
||||||
cursor: string,
|
|
||||||
) => {
|
|
||||||
if (interactiveCanvas) {
|
|
||||||
interactiveCanvas.style.cursor = cursor;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let eraserCanvasCache: any;
|
|
||||||
let previewDataURL: string;
|
|
||||||
export const setEraserCursor = (
|
|
||||||
interactiveCanvas: HTMLCanvasElement | null,
|
|
||||||
theme: AppState["theme"],
|
|
||||||
) => {
|
|
||||||
const cursorImageSizePx = 20;
|
|
||||||
|
|
||||||
const drawCanvas = () => {
|
|
||||||
const isDarkTheme = theme === THEME.DARK;
|
|
||||||
eraserCanvasCache = document.createElement("canvas");
|
|
||||||
eraserCanvasCache.theme = theme;
|
|
||||||
eraserCanvasCache.height = cursorImageSizePx;
|
|
||||||
eraserCanvasCache.width = cursorImageSizePx;
|
|
||||||
const context = eraserCanvasCache.getContext("2d")!;
|
|
||||||
context.lineWidth = 1;
|
|
||||||
context.beginPath();
|
|
||||||
context.arc(
|
|
||||||
eraserCanvasCache.width / 2,
|
|
||||||
eraserCanvasCache.height / 2,
|
|
||||||
5,
|
|
||||||
0,
|
|
||||||
2 * Math.PI,
|
|
||||||
);
|
|
||||||
context.fillStyle = isDarkTheme ? oc.black : oc.white;
|
|
||||||
context.fill();
|
|
||||||
context.strokeStyle = isDarkTheme ? oc.white : oc.black;
|
|
||||||
context.stroke();
|
|
||||||
previewDataURL = eraserCanvasCache.toDataURL(MIME_TYPES.svg) as DataURL;
|
|
||||||
};
|
|
||||||
if (!eraserCanvasCache || eraserCanvasCache.theme !== theme) {
|
|
||||||
drawCanvas();
|
|
||||||
}
|
|
||||||
|
|
||||||
setCursor(
|
|
||||||
interactiveCanvas,
|
|
||||||
`url(${previewDataURL}) ${cursorImageSizePx / 2} ${
|
|
||||||
cursorImageSizePx / 2
|
|
||||||
}, auto`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setCursorForShape = (
|
|
||||||
interactiveCanvas: HTMLCanvasElement | null,
|
|
||||||
appState: Pick<AppState, "activeTool" | "theme">,
|
|
||||||
) => {
|
|
||||||
if (!interactiveCanvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (appState.activeTool.type === "selection") {
|
|
||||||
resetCursor(interactiveCanvas);
|
|
||||||
} else if (isHandToolActive(appState)) {
|
|
||||||
interactiveCanvas.style.cursor = CURSOR_TYPE.GRAB;
|
|
||||||
} else if (isEraserActive(appState)) {
|
|
||||||
setEraserCursor(interactiveCanvas, appState.theme);
|
|
||||||
// do nothing if image tool is selected which suggests there's
|
|
||||||
// a image-preview set as the cursor
|
|
||||||
// Ignore custom type as well and let host decide
|
|
||||||
} else if (appState.activeTool.type === "laser") {
|
|
||||||
const url = laserPointerCursorDataURL;
|
|
||||||
interactiveCanvas.style.cursor = `url(${url}), auto`;
|
|
||||||
} else if (!["image", "custom"].includes(appState.activeTool.type)) {
|
|
||||||
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isFullScreen = () =>
|
export const isFullScreen = () =>
|
||||||
document.fullscreenElement?.nodeName === "HTML";
|
document.fullscreenElement?.nodeName === "HTML";
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user