Browse Source

feat: better laser cursor for dark mode (#7132)

David Luzar 1 year ago
parent
commit
26ff3993bb

+ 2 - 1
src/actions/actionCanvas.tsx

@@ -10,7 +10,7 @@ import { getNormalizedZoom } from "../scene";
 import { centerScrollOn } from "../scene/scroll";
 import { getStateForZoom } from "../scene/zoom";
 import { AppState, NormalizedZoomValue } from "../types";
-import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
+import { getShortcutKey, updateActiveTool } from "../utils";
 import { register } from "./register";
 import { Tooltip } from "../components/Tooltip";
 import { newElementWith } from "../element/mutateElement";
@@ -21,6 +21,7 @@ import {
 } from "../appState";
 import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
 import { Bounds } from "../element/bounds";
+import { setCursor } from "../cursor";
 
 export const actionChangeViewBackgroundColor = register({
   name: "changeViewBackgroundColor",

+ 2 - 1
src/actions/actionFinalize.tsx

@@ -1,6 +1,6 @@
 import { KEYS } from "../keys";
 import { isInvisiblySmallElement } from "../element";
-import { updateActiveTool, resetCursor } from "../utils";
+import { updateActiveTool } from "../utils";
 import { ToolButton } from "../components/ToolButton";
 import { done } from "../components/icons";
 import { t } from "../i18n";
@@ -15,6 +15,7 @@ import {
 } from "../element/binding";
 import { isBindingElement, isLinearElement } from "../element/typeChecks";
 import { AppState } from "../types";
+import { resetCursor } from "../cursor";
 
 export const actionFinalize = register({
   name: "finalize",

+ 2 - 1
src/actions/actionFrame.ts

@@ -4,7 +4,8 @@ import { removeAllElementsFromFrame } from "../frame";
 import { getFrameElements } from "../frame";
 import { KEYS } from "../keys";
 import { AppClassProperties, AppState } from "../types";
-import { setCursorForShape, updateActiveTool } from "../utils";
+import { updateActiveTool } from "../utils";
+import { setCursorForShape } from "../cursor";
 import { register } from "./register";
 
 const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {

+ 6 - 4
src/components/App.tsx

@@ -241,18 +241,14 @@ import {
   isInputLike,
   isToolIcon,
   isWritableElement,
-  resetCursor,
   resolvablePromise,
   sceneCoordsToViewportCoords,
-  setCursor,
-  setCursorForShape,
   tupleToCoors,
   viewportCoordsToSceneCoords,
   withBatchedUpdates,
   wrapEvent,
   withBatchedUpdatesThrottled,
   updateObject,
-  setEraserCursor,
   updateActiveTool,
   getShortcutKey,
   isTransparent,
@@ -371,6 +367,12 @@ import { Renderer } from "../scene/Renderer";
 import { ShapeCache } from "../scene/ShapeCache";
 import { LaserToolOverlay } from "./LaserTool/LaserTool";
 import { LaserPathManager } from "./LaserTool/LaserPathManager";
+import {
+  setEraserCursor,
+  setCursor,
+  resetCursor,
+  setCursorForShape,
+} from "../cursor";
 
 const AppContext = React.createContext<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);

+ 103 - 0
src/cursor.ts

@@ -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 - 1
src/element/embeddable.ts

@@ -2,7 +2,8 @@ import { register } from "../actions/register";
 import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
 import { t } from "../i18n";
 import { ExcalidrawProps } from "../types";
-import { getFontString, setCursorForShape, updateActiveTool } from "../utils";
+import { getFontString, updateActiveTool } from "../utils";
+import { setCursorForShape } from "../cursor";
 import { newTextElement } from "./newElement";
 import { getContainerElement, wrapText } from "./textElement";
 import { isEmbeddableElement } from "./typeChecks";

+ 1 - 95
src/utils.ts

@@ -1,13 +1,9 @@
-import oc from "open-color";
 import { COLOR_PALETTE } from "./colors";
 import {
-  CURSOR_TYPE,
   DEFAULT_VERSION,
   EVENT,
   FONT_FAMILY,
   isDarwin,
-  MIME_TYPES,
-  THEME,
   WINDOWS_EMOJI_FALLBACK_FONT,
 } from "./constants";
 import {
@@ -15,20 +11,11 @@ import {
   FontString,
   NonDeletedExcalidrawElement,
 } 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 { isEraserActive, isHandToolActive } from "./appState";
 import { ResolutionType } from "./utility-types";
 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;
 
 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 = () =>
   document.fullscreenElement?.nodeName === "HTML";