Browse Source

feat: add eraser tool trail (#7511)

Co-authored-by: dwelle <[email protected]>
Are 1 year ago
parent
commit
86cfeb714c

+ 148 - 0
packages/excalidraw/animated-trail.ts

@@ -0,0 +1,148 @@
+import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer";
+import { AnimationFrameHandler } from "./animation-frame-handler";
+import { AppState } from "./types";
+import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
+import type App from "./components/App";
+import { SVG_NS } from "./constants";
+
+export interface Trail {
+  start(container: SVGSVGElement): void;
+  stop(): void;
+
+  startPath(x: number, y: number): void;
+  addPointToPath(x: number, y: number): void;
+  endPath(): void;
+}
+
+export interface AnimatedTrailOptions {
+  fill: (trail: AnimatedTrail) => string;
+}
+
+export class AnimatedTrail implements Trail {
+  private currentTrail?: LaserPointer;
+  private pastTrails: LaserPointer[] = [];
+
+  private container?: SVGSVGElement;
+  private trailElement: SVGPathElement;
+
+  constructor(
+    private animationFrameHandler: AnimationFrameHandler,
+    private app: App,
+    private options: Partial<LaserPointerOptions> &
+      Partial<AnimatedTrailOptions>,
+  ) {
+    this.animationFrameHandler.register(this, this.onFrame.bind(this));
+
+    this.trailElement = document.createElementNS(SVG_NS, "path");
+  }
+
+  get hasCurrentTrail() {
+    return !!this.currentTrail;
+  }
+
+  hasLastPoint(x: number, y: number) {
+    if (this.currentTrail) {
+      const len = this.currentTrail.originalPoints.length;
+      return (
+        this.currentTrail.originalPoints[len - 1][0] === x &&
+        this.currentTrail.originalPoints[len - 1][1] === y
+      );
+    }
+
+    return false;
+  }
+
+  start(container?: SVGSVGElement) {
+    if (container) {
+      this.container = container;
+    }
+
+    if (this.trailElement.parentNode !== this.container && this.container) {
+      this.container.appendChild(this.trailElement);
+    }
+
+    this.animationFrameHandler.start(this);
+  }
+
+  stop() {
+    this.animationFrameHandler.stop(this);
+
+    if (this.trailElement.parentNode === this.container) {
+      this.container?.removeChild(this.trailElement);
+    }
+  }
+
+  startPath(x: number, y: number) {
+    this.currentTrail = new LaserPointer(this.options);
+
+    this.currentTrail.addPoint([x, y, performance.now()]);
+
+    this.update();
+  }
+
+  addPointToPath(x: number, y: number) {
+    if (this.currentTrail) {
+      this.currentTrail.addPoint([x, y, performance.now()]);
+      this.update();
+    }
+  }
+
+  endPath() {
+    if (this.currentTrail) {
+      this.currentTrail.close();
+      this.currentTrail.options.keepHead = false;
+      this.pastTrails.push(this.currentTrail);
+      this.currentTrail = undefined;
+      this.update();
+    }
+  }
+
+  private update() {
+    this.start();
+  }
+
+  private onFrame() {
+    const paths: string[] = [];
+
+    for (const trail of this.pastTrails) {
+      paths.push(this.drawTrail(trail, this.app.state));
+    }
+
+    if (this.currentTrail) {
+      const currentPath = this.drawTrail(this.currentTrail, this.app.state);
+
+      paths.push(currentPath);
+    }
+
+    this.pastTrails = this.pastTrails.filter((trail) => {
+      return trail.getStrokeOutline().length !== 0;
+    });
+
+    if (paths.length === 0) {
+      this.stop();
+    }
+
+    const svgPaths = paths.join(" ").trim();
+
+    this.trailElement.setAttribute("d", svgPaths);
+    this.trailElement.setAttribute(
+      "fill",
+      (this.options.fill ?? (() => "black"))(this),
+    );
+  }
+
+  private drawTrail(trail: LaserPointer, state: AppState): string {
+    const stroke = trail
+      .getStrokeOutline(trail.options.size / state.zoom.value)
+      .map(([x, y]) => {
+        const result = sceneCoordsToViewportCoords(
+          { sceneX: x, sceneY: y },
+          state,
+        );
+
+        return [result.x, result.y];
+      });
+
+    return getSvgPathFromStroke(stroke, true);
+  }
+}

+ 79 - 0
packages/excalidraw/animation-frame-handler.ts

@@ -0,0 +1,79 @@
+export type AnimationCallback = (timestamp: number) => void | boolean;
+
+export type AnimationTarget = {
+  callback: AnimationCallback;
+  stopped: boolean;
+};
+
+export class AnimationFrameHandler {
+  private targets = new WeakMap<object, AnimationTarget>();
+  private rafIds = new WeakMap<object, number>();
+
+  register(key: object, callback: AnimationCallback) {
+    this.targets.set(key, { callback, stopped: true });
+  }
+
+  start(key: object) {
+    const target = this.targets.get(key);
+
+    if (!target) {
+      return;
+    }
+
+    if (this.rafIds.has(key)) {
+      return;
+    }
+
+    this.targets.set(key, { ...target, stopped: false });
+    this.scheduleFrame(key);
+  }
+
+  stop(key: object) {
+    const target = this.targets.get(key);
+    if (target && !target.stopped) {
+      this.targets.set(key, { ...target, stopped: true });
+    }
+
+    this.cancelFrame(key);
+  }
+
+  private constructFrame(key: object): FrameRequestCallback {
+    return (timestamp: number) => {
+      const target = this.targets.get(key);
+
+      if (!target) {
+        return;
+      }
+
+      const shouldAbort = this.onFrame(target, timestamp);
+
+      if (!target.stopped && !shouldAbort) {
+        this.scheduleFrame(key);
+      } else {
+        this.cancelFrame(key);
+      }
+    };
+  }
+
+  private scheduleFrame(key: object) {
+    const rafId = requestAnimationFrame(this.constructFrame(key));
+
+    this.rafIds.set(key, rafId);
+  }
+
+  private cancelFrame(key: object) {
+    if (this.rafIds.has(key)) {
+      const rafId = this.rafIds.get(key)!;
+
+      cancelAnimationFrame(rafId);
+    }
+
+    this.rafIds.delete(key);
+  }
+
+  private onFrame(target: AnimationTarget, timestamp: number): boolean {
+    const shouldAbort = target.callback(timestamp);
+
+    return shouldAbort ?? false;
+  }
+}

+ 51 - 8
packages/excalidraw/components/App.tsx

@@ -384,8 +384,7 @@ 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";
+import { SVGLayer } from "./SVGLayer";
 import {
   setEraserCursor,
   setCursor,
@@ -401,6 +400,10 @@ import { ElementCanvasButton } from "./MagicButton";
 import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
 import { EditorLocalStorage } from "../data/EditorLocalStorage";
 import FollowMode from "./FollowMode/FollowMode";
+
+import { AnimationFrameHandler } from "../animation-frame-handler";
+import { AnimatedTrail } from "../animated-trail";
+import { LaserTrails } from "../laser-trails";
 import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
 import { getRenderOpacity } from "../renderer/renderElement";
 
@@ -537,7 +540,29 @@ class App extends React.Component<AppProps, AppState> {
   lastPointerMoveEvent: PointerEvent | null = null;
   lastViewportPosition = { x: 0, y: 0 };
 
-  laserPathManager: LaserPathManager = new LaserPathManager(this);
+  animationFrameHandler = new AnimationFrameHandler();
+
+  laserTrails = new LaserTrails(this.animationFrameHandler, this);
+  eraserTrail = new AnimatedTrail(this.animationFrameHandler, this, {
+    streamline: 0.2,
+    size: 5,
+    keepHead: true,
+    sizeMapping: (c) => {
+      const DECAY_TIME = 200;
+      const DECAY_LENGTH = 10;
+      const t = Math.max(0, 1 - (performance.now() - c.pressure) / DECAY_TIME);
+      const l =
+        (DECAY_LENGTH -
+          Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
+        DECAY_LENGTH;
+
+      return Math.min(easeOut(l), easeOut(t));
+    },
+    fill: () =>
+      this.state.theme === THEME.LIGHT
+        ? "rgba(0, 0, 0, 0.2)"
+        : "rgba(255, 255, 255, 0.2)",
+  });
 
   onChangeEmitter = new Emitter<
     [
@@ -1471,7 +1496,9 @@ class App extends React.Component<AppProps, AppState> {
                         <div className="excalidraw-textEditorContainer" />
                         <div className="excalidraw-contextMenuContainer" />
                         <div className="excalidraw-eye-dropper-container" />
-                        <LaserToolOverlay manager={this.laserPathManager} />
+                        <SVGLayer
+                          trails={[this.laserTrails, this.eraserTrail]}
+                        />
                         {selectedElements.length === 1 &&
                           this.state.showHyperlinkPopup && (
                             <Hyperlink
@@ -2394,7 +2421,8 @@ class App extends React.Component<AppProps, AppState> {
     this.removeEventListeners();
     this.scene.destroy();
     this.library.destroy();
-    this.laserPathManager.destroy();
+    this.laserTrails.stop();
+    this.eraserTrail.stop();
     this.onChangeEmitter.clear();
     ShapeCache.destroy();
     SnapCache.destroy();
@@ -2619,6 +2647,10 @@ class App extends React.Component<AppProps, AppState> {
       this.updateLanguage();
     }
 
+    if (isEraserActive(prevState) && !isEraserActive(this.state)) {
+      this.eraserTrail.endPath();
+    }
+
     if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
       this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
     }
@@ -5070,6 +5102,8 @@ class App extends React.Component<AppProps, AppState> {
     pointerDownState: PointerDownState,
     scenePointer: { x: number; y: number },
   ) => {
+    this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y);
+
     let didChange = false;
 
     const processElements = (elements: ExcalidrawElement[]) => {
@@ -5500,7 +5534,7 @@ class App extends React.Component<AppProps, AppState> {
         this.state.activeTool.type,
       );
     } else if (this.state.activeTool.type === "laser") {
-      this.laserPathManager.startPath(
+      this.laserTrails.startPath(
         pointerDownState.lastCoords.x,
         pointerDownState.lastCoords.y,
       );
@@ -5521,6 +5555,13 @@ class App extends React.Component<AppProps, AppState> {
       event,
     );
 
+    if (this.state.activeTool.type === "eraser") {
+      this.eraserTrail.startPath(
+        pointerDownState.lastCoords.x,
+        pointerDownState.lastCoords.y,
+      );
+    }
+
     const onPointerMove =
       this.onPointerMoveFromPointerDownHandler(pointerDownState);
 
@@ -6784,7 +6825,7 @@ class App extends React.Component<AppProps, AppState> {
       }
 
       if (this.state.activeTool.type === "laser") {
-        this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y);
+        this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y);
       }
 
       const [gridX, gridY] = getGridPoint(
@@ -7793,6 +7834,8 @@ class App extends React.Component<AppProps, AppState> {
       const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
 
       if (isEraserActive(this.state) && pointerStart && pointerEnd) {
+        this.eraserTrail.endPath();
+
         const draggedDistance = distance2d(
           pointerStart.clientX,
           pointerStart.clientY,
@@ -8041,7 +8084,7 @@ class App extends React.Component<AppProps, AppState> {
       }
 
       if (activeTool.type === "laser") {
-        this.laserPathManager.endPath();
+        this.laserTrails.endPath();
         return;
       }
 

+ 3 - 3
packages/excalidraw/components/LaserTool/LaserPointerButton.tsx → packages/excalidraw/components/LaserPointerButton.tsx

@@ -1,8 +1,8 @@
-import "../ToolIcon.scss";
+import "./ToolIcon.scss";
 
 import clsx from "clsx";
-import { ToolButtonSize } from "../ToolButton";
-import { laserPointerToolIcon } from "../icons";
+import { ToolButtonSize } from "./ToolButton";
+import { laserPointerToolIcon } from "./icons";
 
 type LaserPointerIconProps = {
   title?: string;

+ 0 - 310
packages/excalidraw/components/LaserTool/LaserPathManager.ts

@@ -1,310 +0,0 @@
-import { LaserPointer } from "@excalidraw/laser-pointer";
-
-import { sceneCoordsToViewportCoords } from "../../utils";
-import App from "../App";
-import { getClientColor } from "../../clients";
-import { SocketId } from "../../types";
-
-// 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<SocketId, CollabolatorState> = new Map();
-
-  private rafId: number | undefined;
-  private isDrawing = false;
-  private container: SVGSVGElement | undefined;
-
-  constructor(private app: App) {
-    this.ownState = instantiateCollabolatorState();
-  }
-
-  destroy() {
-    this.stop();
-    this.isDrawing = false;
-    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.isDrawing = true;
-
-    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 (this.isDrawing) {
-      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;
-    }
-
-    let somePathsExist = false;
-
-    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)}`;
-      }
-
-      if (paths.trim()) {
-        somePathsExist = true;
-      }
-
-      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)}`;
-    }
-
-    paths = paths.trim();
-
-    if (paths) {
-      somePathsExist = true;
-    }
-
-    this.ownState.svg.setAttribute("d", paths);
-    this.ownState.svg.setAttribute("fill", "red");
-
-    if (!somePathsExist) {
-      this.isDrawing = false;
-    }
-  }
-}

+ 0 - 27
packages/excalidraw/components/LaserTool/LaserTool.tsx

@@ -1,27 +0,0 @@
-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>
-  );
-};

+ 1 - 1
packages/excalidraw/components/LayerUI.tsx

@@ -60,7 +60,7 @@ import "./Toolbar.scss";
 import { mutateElement } from "../element/mutateElement";
 import { ShapeCache } from "../scene/ShapeCache";
 import Scene from "../scene/Scene";
-import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
+import { LaserPointerButton } from "./LaserPointerButton";
 import { MagicSettings } from "./MagicSettings";
 import { TTDDialog } from "./TTDDialog/TTDDialog";
 

+ 4 - 2
packages/excalidraw/components/LaserTool/LaserToolOverlay.scss → packages/excalidraw/components/SVGLayer.scss

@@ -1,5 +1,5 @@
 .excalidraw {
-  .LaserToolOverlay {
+  .SVGLayer {
     pointer-events: none;
     width: 100vw;
     height: 100vh;
@@ -9,10 +9,12 @@
 
     z-index: 2;
 
-    .LaserToolOverlayCanvas {
+    & svg {
       image-rendering: auto;
       overflow: visible;
       position: absolute;
+      width: 100%;
+      height: 100%;
       top: 0;
       left: 0;
     }

+ 33 - 0
packages/excalidraw/components/SVGLayer.tsx

@@ -0,0 +1,33 @@
+import { useEffect, useRef } from "react";
+import { Trail } from "../animated-trail";
+
+import "./SVGLayer.scss";
+
+type SVGLayerProps = {
+  trails: Trail[];
+};
+
+export const SVGLayer = ({ trails }: SVGLayerProps) => {
+  const svgRef = useRef<SVGSVGElement | null>(null);
+
+  useEffect(() => {
+    if (svgRef.current) {
+      for (const trail of trails) {
+        trail.start(svgRef.current);
+      }
+    }
+
+    return () => {
+      for (const trail of trails) {
+        trail.stop();
+      }
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, trails);
+
+  return (
+    <div className="SVGLayer">
+      <svg ref={svgRef} />
+    </div>
+  );
+};

+ 124 - 0
packages/excalidraw/laser-trails.ts

@@ -0,0 +1,124 @@
+import { LaserPointerOptions } from "@excalidraw/laser-pointer";
+import { AnimatedTrail, Trail } from "./animated-trail";
+import { AnimationFrameHandler } from "./animation-frame-handler";
+import type App from "./components/App";
+import { SocketId } from "./types";
+import { easeOut } from "./utils";
+import { getClientColor } from "./clients";
+
+export class LaserTrails implements Trail {
+  public localTrail: AnimatedTrail;
+  private collabTrails = new Map<SocketId, AnimatedTrail>();
+
+  private container?: SVGSVGElement;
+
+  constructor(
+    private animationFrameHandler: AnimationFrameHandler,
+    private app: App,
+  ) {
+    this.animationFrameHandler.register(this, this.onFrame.bind(this));
+
+    this.localTrail = new AnimatedTrail(animationFrameHandler, app, {
+      ...this.getTrailOptions(),
+      fill: () => "red",
+    });
+  }
+
+  private getTrailOptions() {
+    return {
+      simplify: 0,
+      streamline: 0.4,
+      sizeMapping: (c) => {
+        const DECAY_TIME = 1000;
+        const DECAY_LENGTH = 50;
+        const t = Math.max(
+          0,
+          1 - (performance.now() - c.pressure) / DECAY_TIME,
+        );
+        const l =
+          (DECAY_LENGTH -
+            Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
+          DECAY_LENGTH;
+
+        return Math.min(easeOut(l), easeOut(t));
+      },
+    } as Partial<LaserPointerOptions>;
+  }
+
+  startPath(x: number, y: number): void {
+    this.localTrail.startPath(x, y);
+  }
+
+  addPointToPath(x: number, y: number): void {
+    this.localTrail.addPointToPath(x, y);
+  }
+
+  endPath(): void {
+    this.localTrail.endPath();
+  }
+
+  start(container: SVGSVGElement) {
+    this.container = container;
+
+    this.animationFrameHandler.start(this);
+    this.localTrail.start(container);
+  }
+
+  stop() {
+    this.animationFrameHandler.stop(this);
+    this.localTrail.stop();
+  }
+
+  onFrame() {
+    this.updateCollabTrails();
+  }
+
+  private updateCollabTrails() {
+    if (!this.container || this.app.state.collaborators.size === 0) {
+      return;
+    }
+
+    for (const [key, collabolator] of this.app.state.collaborators.entries()) {
+      let trail!: AnimatedTrail;
+
+      if (!this.collabTrails.has(key)) {
+        trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
+          ...this.getTrailOptions(),
+          fill: () => getClientColor(key),
+        });
+        trail.start(this.container);
+
+        this.collabTrails.set(key, trail);
+      } else {
+        trail = this.collabTrails.get(key)!;
+      }
+
+      if (collabolator.pointer && collabolator.pointer.tool === "laser") {
+        if (collabolator.button === "down" && !trail.hasCurrentTrail) {
+          trail.startPath(collabolator.pointer.x, collabolator.pointer.y);
+        }
+
+        if (
+          collabolator.button === "down" &&
+          trail.hasCurrentTrail &&
+          !trail.hasLastPoint(collabolator.pointer.x, collabolator.pointer.y)
+        ) {
+          trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y);
+        }
+
+        if (collabolator.button === "up" && trail.hasCurrentTrail) {
+          trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y);
+          trail.endPath();
+        }
+      }
+    }
+
+    for (const key of this.collabTrails.keys()) {
+      if (!this.app.state.collaborators.has(key)) {
+        const trail = this.collabTrails.get(key)!;
+        trail.stop();
+        this.collabTrails.delete(key);
+      }
+    }
+  }
+}

+ 1 - 1
packages/excalidraw/package.json

@@ -57,7 +57,7 @@
   },
   "dependencies": {
     "@braintree/sanitize-url": "6.0.2",
-    "@excalidraw/laser-pointer": "1.2.0",
+    "@excalidraw/laser-pointer": "1.3.1",
     "@excalidraw/mermaid-to-excalidraw": "0.2.0",
     "@excalidraw/random-username": "1.1.0",
     "@radix-ui/react-popover": "1.0.3",

+ 34 - 0
packages/excalidraw/utils.ts

@@ -1013,6 +1013,40 @@ export function addEventListener(
   };
 }
 
+const average = (a: number, b: number) => (a + b) / 2;
+export 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;
+}
+
 export const normalizeEOL = (str: string) => {
   return str.replace(/\r?\n|\r/g, "\n");
 };

+ 4 - 4
yarn.lock

@@ -2247,10 +2247,10 @@
   resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.3.tgz#2122ef7413ae77874ae9848ce0f1c6b3f0d8bbbd"
   integrity sha512-GemHNF5Z6ga0BWBSX7GJaNBUchLu6RwTcAB84eX1MeckRNhNasAsPCdelDlFalz27iS4RuYEQh0bPE8SRxJgbQ==
 
-"@excalidraw/laser-pointer@1.2.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.2.0.tgz#cd34ea7d24b11743c726488cc1fcb28c161cacba"
-  integrity sha512-WjFFwLk9ahmKRKku7U0jqYpeM3fe9ZS1K43pfwPREHk4/FYU3iKDKVeS8m4tEAASnRlBt3hhLCBQLBF2uvgOnw==
+"@excalidraw/laser-pointer@1.3.1":
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz#7c40836598e8e6ad91f01057883ed8b88fb9266c"
+  integrity sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==
 
 "@excalidraw/[email protected]":
   version "0.1.2"