瀏覽代碼

feat: initial Laser Pointer MVP (#6739)

* feat: initial Laser pointer mvp

* feat: add laser-pointer package and integrate it with collab

* chore: fix yarn.lock

* feat: update laser-pointer package, prevent panning from showing

* feat: add laser pointer tool button when collaborating, migrate to official package

* feat: reduce laser tool button size

* update icon

* fix icon & rotate

* fix: lock zoom level

* fix icon

* add `selected` state, simplify and reduce api

* set up pointer callbacks in viewMode if laser tool active

* highlight extra-tools button if one of the nested tools active

* add shortcut to laser pointer

* feat: don't update paths if nothing changed

* ensure we reset flag if no rAF scheduled

* move `lastUpdate` to instance to optimize

* return early

* factor out into constants and add doc

* skip iteration instead of exit

* fix naming

* feat: remove testing variable on window

* destroy on editor unmount

* fix incorrectly resetting `lastUpdate` in `stop()`

---------

Co-authored-by: dwelle <[email protected]>
Are 1 年之前
父節點
當前提交
2e61926a6b

+ 1 - 1
excalidraw-app/data/index.ts

@@ -107,7 +107,7 @@ export type SocketUpdateDataSource = {
     type: "MOUSE_LOCATION";
     payload: {
       socketId: string;
-      pointer: { x: number; y: number };
+      pointer: { x: number; y: number; tool: "pointer" | "laser" };
       button: "down" | "up";
       selectedElementIds: AppState["selectedElementIds"];
       username: string;

+ 1 - 0
package.json

@@ -20,6 +20,7 @@
   },
   "dependencies": {
     "@braintree/sanitize-url": "6.0.2",
+    "@excalidraw/laser-pointer": "1.2.0",
     "@excalidraw/random-username": "1.0.0",
     "@radix-ui/react-popover": "1.0.3",
     "@radix-ui/react-tabs": "1.0.2",

+ 33 - 4
src/components/Actions.tsx

@@ -31,7 +31,12 @@ import {
 
 import "./Actions.scss";
 import DropdownMenu from "./dropdownMenu/DropdownMenu";
-import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
+import {
+  EmbedIcon,
+  extraToolsIcon,
+  frameToolIcon,
+  laserPointerToolIcon,
+} from "./icons";
 import { KEYS } from "../keys";
 
 export const SelectedShapeActions = ({
@@ -222,6 +227,11 @@ export const ShapesSwitcher = ({
 }) => {
   const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
   const device = useDevice();
+
+  const frameToolSelected = activeTool.type === "frame";
+  const laserToolSelected = activeTool.type === "laser";
+  const embeddableToolSelected = activeTool.type === "embeddable";
+
   return (
     <>
       {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@@ -313,7 +323,15 @@ export const ShapesSwitcher = ({
       ) : (
         <DropdownMenu open={isExtraToolsMenuOpen}>
           <DropdownMenu.Trigger
-            className="App-toolbar__extra-tools-trigger"
+            className={clsx("App-toolbar__extra-tools-trigger", {
+              "App-toolbar__extra-tools-trigger--selected":
+                frameToolSelected ||
+                embeddableToolSelected ||
+                // in collab we're already highlighting the laser button
+                // outside toolbar, so let's not highlight extra-tools button
+                // on top of it
+                (laserToolSelected && !app.props.isCollaborating),
+            })}
             onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
             title={t("toolBar.extraTools")}
           >
@@ -331,7 +349,7 @@ export const ShapesSwitcher = ({
               icon={frameToolIcon}
               shortcut={KEYS.F.toLocaleUpperCase()}
               data-testid="toolbar-frame"
-              selected={activeTool.type === "frame"}
+              selected={frameToolSelected}
             >
               {t("toolBar.frame")}
             </DropdownMenu.Item>
@@ -341,10 +359,21 @@ export const ShapesSwitcher = ({
               }}
               icon={EmbedIcon}
               data-testid="toolbar-embeddable"
-              selected={activeTool.type === "embeddable"}
+              selected={embeddableToolSelected}
             >
               {t("toolBar.embeddable")}
             </DropdownMenu.Item>
+            <DropdownMenu.Item
+              onSelect={() => {
+                app.setActiveTool({ type: "laser" });
+              }}
+              icon={laserPointerToolIcon}
+              data-testid="toolbar-laser"
+              selected={laserToolSelected}
+              shortcut={KEYS.K.toLocaleUpperCase()}
+            >
+              {t("toolBar.laser")}
+            </DropdownMenu.Item>
           </DropdownMenu.Content>
         </DropdownMenu>
       )}

+ 44 - 7
src/components/App.tsx

@@ -230,6 +230,7 @@ import {
   SidebarName,
   SidebarTabName,
   KeyboardModifiersObject,
+  CollaboratorPointer,
   ToolType,
 } from "../types";
 import {
@@ -368,6 +369,8 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
 import { StaticCanvas, InteractiveCanvas } from "./canvases";
 import { Renderer } from "../scene/Renderer";
 import { ShapeCache } from "../scene/ShapeCache";
+import { LaserToolOverlay } from "./LaserTool/LaserTool";
+import { LaserPathManager } from "./LaserTool/LaserPathManager";
 
 const AppContext = React.createContext<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
@@ -497,6 +500,8 @@ class App extends React.Component<AppProps, AppState> {
     null;
   lastViewportPosition = { x: 0, y: 0 };
 
+  laserPathManager: LaserPathManager = new LaserPathManager(this);
+
   constructor(props: AppProps) {
     super(props);
     const defaultAppState = getDefaultAppState();
@@ -1205,12 +1210,14 @@ class App extends React.Component<AppProps, AppState> {
                             !this.scene.getElementsIncludingDeleted().length
                           }
                           app={this}
+                          isCollaborating={this.props.isCollaborating}
                         >
                           {this.props.children}
                         </LayerUI>
                         <div className="excalidraw-textEditorContainer" />
                         <div className="excalidraw-contextMenuContainer" />
                         <div className="excalidraw-eye-dropper-container" />
+                        <LaserToolOverlay manager={this.laserPathManager} />
                         {selectedElements.length === 1 &&
                           !this.state.contextMenu &&
                           this.state.showHyperlinkPopup && (
@@ -1738,6 +1745,7 @@ class App extends React.Component<AppProps, AppState> {
     this.removeEventListeners();
     this.scene.destroy();
     this.library.destroy();
+    this.laserPathManager.destroy();
     ShapeCache.destroy();
     SnapCache.destroy();
     clearTimeout(touchTimeout);
@@ -3052,6 +3060,15 @@ class App extends React.Component<AppProps, AppState> {
         }
       }
 
+      if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
+        if (this.state.activeTool.type === "laser") {
+          this.setActiveTool({ type: "selection" });
+        } else {
+          this.setActiveTool({ type: "laser" });
+        }
+        return;
+      }
+
       if (
         event[KEYS.CTRL_OR_CMD] &&
         (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
@@ -4462,6 +4479,10 @@ class App extends React.Component<AppProps, AppState> {
       return;
     }
 
+    if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
+      return;
+    }
+
     this.lastPointerDownEvent = event;
 
     this.setState({
@@ -4470,10 +4491,6 @@ class App extends React.Component<AppProps, AppState> {
     });
     this.savePointer(event.clientX, event.clientY, "down");
 
-    if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
-      return;
-    }
-
     // only handle left mouse button or touch
     if (
       event.button !== POINTER_BUTTON.MAIN &&
@@ -4564,6 +4581,11 @@ class App extends React.Component<AppProps, AppState> {
       setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
     } else if (this.state.activeTool.type === "frame") {
       this.createFrameElementOnPointerDown(pointerDownState);
+    } else if (this.state.activeTool.type === "laser") {
+      this.laserPathManager.startPath(
+        pointerDownState.lastCoords.x,
+        pointerDownState.lastCoords.y,
+      );
     } else if (
       this.state.activeTool.type !== "eraser" &&
       this.state.activeTool.type !== "hand"
@@ -4587,7 +4609,7 @@ class App extends React.Component<AppProps, AppState> {
 
     lastPointerUp = onPointerUp;
 
-    if (!this.state.viewModeEnabled) {
+    if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
       window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
       window.addEventListener(EVENT.POINTER_UP, onPointerUp);
       window.addEventListener(EVENT.KEYDOWN, onKeyDown);
@@ -5783,6 +5805,10 @@ class App extends React.Component<AppProps, AppState> {
         return;
       }
 
+      if (this.state.activeTool.type === "laser") {
+        this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y);
+      }
+
       const [gridX, gridY] = getGridPoint(
         pointerCoords.x,
         pointerCoords.y,
@@ -7029,6 +7055,11 @@ class App extends React.Component<AppProps, AppState> {
           : unbindLinearElements)(this.scene.getSelectedElements(this.state));
       }
 
+      if (activeTool.type === "laser") {
+        this.laserPathManager.endPath();
+        return;
+      }
+
       if (!activeTool.locked && activeTool.type !== "freedraw") {
         resetCursor(this.interactiveCanvas);
         this.setState({
@@ -8273,15 +8304,21 @@ class App extends React.Component<AppProps, AppState> {
     if (!x || !y) {
       return;
     }
-    const pointer = viewportCoordsToSceneCoords(
+    const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
       { clientX: x, clientY: y },
       this.state,
     );
 
-    if (isNaN(pointer.x) || isNaN(pointer.y)) {
+    if (isNaN(sceneX) || isNaN(sceneY)) {
       // sometimes the pointer goes off screen
     }
 
+    const pointer: CollaboratorPointer = {
+      x: sceneX,
+      y: sceneY,
+      tool: this.state.activeTool.type === "laser" ? "laser" : "pointer",
+    };
+
     this.props.onPointerUpdate?.({
       pointer,
       button,

+ 1 - 0
src/components/HelpDialog.tsx

@@ -165,6 +165,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               shortcuts={[KEYS.E, KEYS["0"]]}
             />
             <Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
+            <Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
             <Shortcut
               label={t("labels.eyeDropper")}
               shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}

+ 293 - 0
src/components/LaserTool/LaserPathManager.ts

@@ -0,0 +1,293 @@
+import { LaserPointer } from "@excalidraw/laser-pointer";
+
+import { sceneCoordsToViewportCoords } from "../../utils";
+import App from "../App";
+import { getClientColor } from "../../clients";
+
+// decay time in milliseconds
+const DECAY_TIME = 1000;
+// length of line in points before it starts decaying
+const DECAY_LENGTH = 50;
+
+const average = (a: number, b: number) => (a + b) / 2;
+function getSvgPathFromStroke(points: number[][], closed = true) {
+  const len = points.length;
+
+  if (len < 4) {
+    return ``;
+  }
+
+  let a = points[0];
+  let b = points[1];
+  const c = points[2];
+
+  let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(
+    2,
+  )},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average(
+    b[1],
+    c[1],
+  ).toFixed(2)} T`;
+
+  for (let i = 2, max = len - 1; i < max; i++) {
+    a = points[i];
+    b = points[i + 1];
+    result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(
+      2,
+    )} `;
+  }
+
+  if (closed) {
+    result += "Z";
+  }
+
+  return result;
+}
+
+declare global {
+  interface Window {
+    LPM: LaserPathManager;
+  }
+}
+
+function easeOutCubic(t: number) {
+  return 1 - Math.pow(1 - t, 3);
+}
+
+function instantiateCollabolatorState(): CollabolatorState {
+  return {
+    currentPath: undefined,
+    finishedPaths: [],
+    lastPoint: [-10000, -10000],
+    svg: document.createElementNS("http://www.w3.org/2000/svg", "path"),
+  };
+}
+
+function instantiatePath() {
+  LaserPointer.constants.cornerDetectionMaxAngle = 70;
+
+  return new LaserPointer({
+    simplify: 0,
+    streamline: 0.4,
+    sizeMapping: (c) => {
+      const pt = DECAY_TIME;
+      const pl = DECAY_LENGTH;
+      const t = Math.max(0, 1 - (performance.now() - c.pressure) / pt);
+      const l = (pl - Math.min(pl, c.totalLength - c.currentIndex)) / pl;
+
+      return Math.min(easeOutCubic(l), easeOutCubic(t));
+    },
+  });
+}
+
+type CollabolatorState = {
+  currentPath: LaserPointer | undefined;
+  finishedPaths: LaserPointer[];
+  lastPoint: [number, number];
+  svg: SVGPathElement;
+};
+
+export class LaserPathManager {
+  private ownState: CollabolatorState;
+  private collaboratorsState: Map<string, CollabolatorState> = new Map();
+
+  private rafId: number | undefined;
+  private lastUpdate = 0;
+  private container: SVGSVGElement | undefined;
+
+  constructor(private app: App) {
+    this.ownState = instantiateCollabolatorState();
+  }
+
+  destroy() {
+    this.stop();
+    this.lastUpdate = 0;
+    this.ownState = instantiateCollabolatorState();
+    this.collaboratorsState = new Map();
+  }
+
+  startPath(x: number, y: number) {
+    this.ownState.currentPath = instantiatePath();
+    this.ownState.currentPath.addPoint([x, y, performance.now()]);
+    this.updatePath(this.ownState);
+  }
+
+  addPointToPath(x: number, y: number) {
+    if (this.ownState.currentPath) {
+      this.ownState.currentPath?.addPoint([x, y, performance.now()]);
+      this.updatePath(this.ownState);
+    }
+  }
+
+  endPath() {
+    if (this.ownState.currentPath) {
+      this.ownState.currentPath.close();
+      this.ownState.finishedPaths.push(this.ownState.currentPath);
+      this.updatePath(this.ownState);
+    }
+  }
+
+  private updatePath(state: CollabolatorState) {
+    this.lastUpdate = performance.now();
+
+    if (!this.isRunning) {
+      this.start();
+    }
+  }
+
+  private isRunning = false;
+
+  start(svg?: SVGSVGElement) {
+    if (svg) {
+      this.container = svg;
+      this.container.appendChild(this.ownState.svg);
+    }
+
+    this.stop();
+    this.isRunning = true;
+    this.loop();
+  }
+
+  stop() {
+    this.isRunning = false;
+    if (this.rafId) {
+      cancelAnimationFrame(this.rafId);
+    }
+    this.rafId = undefined;
+  }
+
+  loop() {
+    this.rafId = requestAnimationFrame(this.loop.bind(this));
+
+    this.updateCollabolatorsState();
+
+    if (performance.now() - this.lastUpdate < DECAY_TIME * 2) {
+      this.update();
+    } else {
+      this.isRunning = false;
+    }
+  }
+
+  draw(path: LaserPointer) {
+    const stroke = path
+      .getStrokeOutline(path.options.size / this.app.state.zoom.value)
+      .map(([x, y]) => {
+        const result = sceneCoordsToViewportCoords(
+          { sceneX: x, sceneY: y },
+          this.app.state,
+        );
+
+        return [result.x, result.y];
+      });
+
+    return getSvgPathFromStroke(stroke, true);
+  }
+
+  updateCollabolatorsState() {
+    if (!this.container || !this.app.state.collaborators.size) {
+      return;
+    }
+
+    for (const [key, collabolator] of this.app.state.collaborators.entries()) {
+      if (!this.collaboratorsState.has(key)) {
+        const state = instantiateCollabolatorState();
+        this.container.appendChild(state.svg);
+        this.collaboratorsState.set(key, state);
+
+        this.updatePath(state);
+      }
+
+      const state = this.collaboratorsState.get(key)!;
+
+      if (collabolator.pointer && collabolator.pointer.tool === "laser") {
+        if (collabolator.button === "down" && state.currentPath === undefined) {
+          state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
+          state.currentPath = instantiatePath();
+          state.currentPath.addPoint([
+            collabolator.pointer.x,
+            collabolator.pointer.y,
+            performance.now(),
+          ]);
+
+          this.updatePath(state);
+        }
+
+        if (collabolator.button === "down" && state.currentPath !== undefined) {
+          if (
+            collabolator.pointer.x !== state.lastPoint[0] ||
+            collabolator.pointer.y !== state.lastPoint[1]
+          ) {
+            state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
+            state.currentPath.addPoint([
+              collabolator.pointer.x,
+              collabolator.pointer.y,
+              performance.now(),
+            ]);
+
+            this.updatePath(state);
+          }
+        }
+
+        if (collabolator.button === "up" && state.currentPath !== undefined) {
+          state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
+          state.currentPath.addPoint([
+            collabolator.pointer.x,
+            collabolator.pointer.y,
+            performance.now(),
+          ]);
+          state.currentPath.close();
+
+          state.finishedPaths.push(state.currentPath);
+          state.currentPath = undefined;
+
+          this.updatePath(state);
+        }
+      }
+    }
+  }
+
+  update() {
+    if (!this.container) {
+      return;
+    }
+
+    for (const [key, state] of this.collaboratorsState.entries()) {
+      if (!this.app.state.collaborators.has(key)) {
+        state.svg.remove();
+        this.collaboratorsState.delete(key);
+        continue;
+      }
+
+      state.finishedPaths = state.finishedPaths.filter((path) => {
+        const lastPoint = path.originalPoints[path.originalPoints.length - 1];
+
+        return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
+      });
+
+      let paths = state.finishedPaths.map((path) => this.draw(path)).join(" ");
+
+      if (state.currentPath) {
+        paths += ` ${this.draw(state.currentPath)}`;
+      }
+
+      state.svg.setAttribute("d", paths);
+      state.svg.setAttribute("fill", getClientColor(key));
+    }
+
+    this.ownState.finishedPaths = this.ownState.finishedPaths.filter((path) => {
+      const lastPoint = path.originalPoints[path.originalPoints.length - 1];
+
+      return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
+    });
+
+    let paths = this.ownState.finishedPaths
+      .map((path) => this.draw(path))
+      .join(" ");
+
+    if (this.ownState.currentPath) {
+      paths += ` ${this.draw(this.ownState.currentPath)}`;
+    }
+
+    this.ownState.svg.setAttribute("d", paths);
+    this.ownState.svg.setAttribute("fill", "red");
+  }
+}

+ 41 - 0
src/components/LaserTool/LaserPointerButton.tsx

@@ -0,0 +1,41 @@
+import "../ToolIcon.scss";
+
+import clsx from "clsx";
+import { ToolButtonSize } from "../ToolButton";
+import { laserPointerToolIcon } from "../icons";
+
+type LaserPointerIconProps = {
+  title?: string;
+  name?: string;
+  checked: boolean;
+  onChange?(): void;
+  isMobile?: boolean;
+};
+
+const DEFAULT_SIZE: ToolButtonSize = "small";
+
+export const LaserPointerButton = (props: LaserPointerIconProps) => {
+  return (
+    <label
+      className={clsx(
+        "ToolIcon ToolIcon__LaserPointer",
+        `ToolIcon_size_${DEFAULT_SIZE}`,
+        {
+          "is-mobile": props.isMobile,
+        },
+      )}
+      title={`${props.title}`}
+    >
+      <input
+        className="ToolIcon_type_checkbox"
+        type="checkbox"
+        name={props.name}
+        onChange={props.onChange}
+        checked={props.checked}
+        aria-label={props.title}
+        data-testid="toolbar-LaserPointer"
+      />
+      <div className="ToolIcon__icon">{laserPointerToolIcon}</div>
+    </label>
+  );
+};

+ 27 - 0
src/components/LaserTool/LaserTool.tsx

@@ -0,0 +1,27 @@
+import { useEffect, useRef } from "react";
+import { LaserPathManager } from "./LaserPathManager";
+import "./LaserToolOverlay.scss";
+
+type LaserToolOverlayProps = {
+  manager: LaserPathManager;
+};
+
+export const LaserToolOverlay = ({ manager }: LaserToolOverlayProps) => {
+  const svgRef = useRef<SVGSVGElement | null>(null);
+
+  useEffect(() => {
+    if (svgRef.current) {
+      manager.start(svgRef.current);
+    }
+
+    return () => {
+      manager.stop();
+    };
+  }, [manager]);
+
+  return (
+    <div className="LaserToolOverlay">
+      <svg ref={svgRef} className="LaserToolOverlayCanvas" />
+    </div>
+  );
+};

+ 20 - 0
src/components/LaserTool/LaserToolOverlay.scss

@@ -0,0 +1,20 @@
+.excalidraw {
+  .LaserToolOverlay {
+    pointer-events: none;
+    width: 100vw;
+    height: 100vh;
+    position: fixed;
+    top: 0;
+    left: 0;
+
+    z-index: 2;
+
+    .LaserToolOverlayCanvas {
+      image-rendering: auto;
+      overflow: visible;
+      position: absolute;
+      top: 0;
+      left: 0;
+    }
+  }
+}

+ 21 - 0
src/components/LayerUI.tsx

@@ -55,6 +55,7 @@ import "./Toolbar.scss";
 import { mutateElement } from "../element/mutateElement";
 import { ShapeCache } from "../scene/ShapeCache";
 import Scene from "../scene/Scene";
+import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -77,6 +78,7 @@ interface LayerUIProps {
   renderWelcomeScreen: boolean;
   children?: React.ReactNode;
   app: AppClassProperties;
+  isCollaborating: boolean;
 }
 
 const DefaultMainMenu: React.FC<{
@@ -134,6 +136,7 @@ const LayerUI = ({
   renderWelcomeScreen,
   children,
   app,
+  isCollaborating,
 }: LayerUIProps) => {
   const device = useDevice();
   const tunnels = useInitializeTunnels();
@@ -288,6 +291,24 @@ const LayerUI = ({
                           />
                         </Stack.Row>
                       </Island>
+                      {isCollaborating && (
+                        <Island
+                          style={{
+                            marginLeft: 8,
+                            alignSelf: "center",
+                            height: "fit-content",
+                          }}
+                        >
+                          <LaserPointerButton
+                            title={t("toolBar.laser")}
+                            checked={appState.activeTool.type === "laser"}
+                            onChange={() =>
+                              app.setActiveTool({ type: "laser" })
+                            }
+                            isMobile
+                          />
+                        </Island>
+                      )}
                     </Stack.Row>
                   </Stack.Col>
                 </div>

+ 5 - 0
src/components/ToolIcon.scss

@@ -170,5 +170,10 @@
         height: var(--lg-icon-size);
       }
     }
+
+    .ToolIcon__LaserPointer .ToolIcon__icon {
+      width: var(--default-button-size);
+      height: var(--default-button-size);
+    }
   }
 }

+ 6 - 0
src/components/Toolbar.scss

@@ -28,6 +28,12 @@
       box-shadow: 0 0 0 1px
         var(--button-active-border, var(--color-primary-darkest)) inset;
     }
+
+    &--selected,
+    &--selected:hover {
+      background: var(--color-primary-light);
+      color: var(--color-primary);
+    }
   }
 
   .App-toolbar__extra-tools-dropdown {

+ 19 - 0
src/components/icons.tsx

@@ -1653,3 +1653,22 @@ export const frameToolIcon = createIcon(
   </g>,
   tablerIconProps,
 );
+
+export const laserPointerToolIcon = createIcon(
+  <g
+    fill="none"
+    stroke="currentColor"
+    strokeWidth="1.25"
+    strokeLinecap="round"
+    strokeLinejoin="round"
+    transform="rotate(90 10 10)"
+  >
+    <path
+      clipRule="evenodd"
+      d="m9.644 13.69 7.774-7.773a2.357 2.357 0 0 0-3.334-3.334l-7.773 7.774L8 12l1.643 1.69Z"
+    />
+    <path d="m13.25 3.417 3.333 3.333M10 10l2-2M5 15l3-3M2.156 17.894l1-1M5.453 19.029l-.144-1.407M2.377 11.887l.866 1.118M8.354 17.273l-1.194-.758M.953 14.652l1.408.13" />
+  </g>,
+
+  20,
+);

+ 1 - 0
src/data/restore.ts

@@ -67,6 +67,7 @@ export const AllowedExcalidrawActiveTools: Record<
   frame: true,
   embeddable: true,
   hand: true,
+  laser: false,
 };
 
 export type RestoredDataState = {

+ 2 - 1
src/element/showSelectedShapeActions.ts

@@ -12,6 +12,7 @@ export const showSelectedShapeActions = (
         (appState.editingElement ||
           (appState.activeTool.type !== "selection" &&
             appState.activeTool.type !== "eraser" &&
-            appState.activeTool.type !== "hand"))) ||
+            appState.activeTool.type !== "hand" &&
+            appState.activeTool.type !== "laser"))) ||
         getSelectedElements(elements, appState).length),
   );

+ 1 - 0
src/locales/en.json

@@ -236,6 +236,7 @@
     "eraser": "Eraser",
     "frame": "Frame tool",
     "embeddable": "Web Embed",
+    "laser": "Laser pointer",
     "hand": "Hand (panning tool)",
     "extraTools": "More tools"
   },

+ 10 - 6
src/types.ts

@@ -39,10 +39,7 @@ import { Merge, ForwardRef, ValueOf } from "./utility-types";
 export type Point = Readonly<RoughPoint>;
 
 export type Collaborator = {
-  pointer?: {
-    x: number;
-    y: number;
-  };
+  pointer?: CollaboratorPointer;
   button?: "up" | "down";
   selectedElementIds?: AppState["selectedElementIds"];
   username?: string | null;
@@ -58,6 +55,12 @@ export type Collaborator = {
   id?: string;
 };
 
+export type CollaboratorPointer = {
+  x: number;
+  y: number;
+  tool: "pointer" | "laser";
+};
+
 export type DataURL = string & { _brand: "DataURL" };
 
 export type BinaryFileData = {
@@ -98,7 +101,8 @@ export type ToolType =
   | "eraser"
   | "hand"
   | "frame"
-  | "embeddable";
+  | "embeddable"
+  | "laser";
 
 export type ActiveTool =
   | {
@@ -389,7 +393,7 @@ export interface ExcalidrawProps {
   excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
   isCollaborating?: boolean;
   onPointerUpdate?: (payload: {
-    pointer: { x: number; y: number };
+    pointer: { x: number; y: number; tool: "pointer" | "laser" };
     button: "down" | "up";
     pointersMap: Gesture["pointers"];
   }) => void;

+ 5 - 0
yarn.lock

@@ -1522,6 +1522,11 @@
   resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.3.tgz#2122ef7413ae77874ae9848ce0f1c6b3f0d8bbbd"
   integrity sha512-GemHNF5Z6ga0BWBSX7GJaNBUchLu6RwTcAB84eX1MeckRNhNasAsPCdelDlFalz27iS4RuYEQh0bPE8SRxJgbQ==
 
+"@excalidraw/[email protected]":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.2.0.tgz#cd34ea7d24b11743c726488cc1fcb28c161cacba"
+  integrity sha512-WjFFwLk9ahmKRKku7U0jqYpeM3fe9ZS1K43pfwPREHk4/FYU3iKDKVeS8m4tEAASnRlBt3hhLCBQLBF2uvgOnw==
+
 "@excalidraw/[email protected]":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65"