From 86cfeb714c63bf7efccf5c26bf1f30522395f5db Mon Sep 17 00:00:00 2001 From: Are Date: Thu, 11 Jan 2024 17:10:15 +0100 Subject: [PATCH] feat: add eraser tool trail (#7511) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/animated-trail.ts | 148 +++++++++ .../excalidraw/animation-frame-handler.ts | 79 +++++ packages/excalidraw/components/App.tsx | 59 +++- .../{LaserTool => }/LaserPointerButton.tsx | 6 +- .../components/LaserTool/LaserPathManager.ts | 310 ------------------ .../components/LaserTool/LaserTool.tsx | 27 -- packages/excalidraw/components/LayerUI.tsx | 2 +- .../LaserToolOverlay.scss => SVGLayer.scss} | 6 +- packages/excalidraw/components/SVGLayer.tsx | 33 ++ packages/excalidraw/laser-trails.ts | 124 +++++++ packages/excalidraw/package.json | 2 +- packages/excalidraw/utils.ts | 34 ++ yarn.lock | 8 +- 13 files changed, 482 insertions(+), 356 deletions(-) create mode 100644 packages/excalidraw/animated-trail.ts create mode 100644 packages/excalidraw/animation-frame-handler.ts rename packages/excalidraw/components/{LaserTool => }/LaserPointerButton.tsx (87%) delete mode 100644 packages/excalidraw/components/LaserTool/LaserPathManager.ts delete mode 100644 packages/excalidraw/components/LaserTool/LaserTool.tsx rename packages/excalidraw/components/{LaserTool/LaserToolOverlay.scss => SVGLayer.scss} (80%) create mode 100644 packages/excalidraw/components/SVGLayer.tsx create mode 100644 packages/excalidraw/laser-trails.ts diff --git a/packages/excalidraw/animated-trail.ts b/packages/excalidraw/animated-trail.ts new file mode 100644 index 000000000..de5fd08fd --- /dev/null +++ b/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 & + Partial, + ) { + 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); + } +} diff --git a/packages/excalidraw/animation-frame-handler.ts b/packages/excalidraw/animation-frame-handler.ts new file mode 100644 index 000000000..b1a984466 --- /dev/null +++ b/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(); + private rafIds = new WeakMap(); + + 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; + } +} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 2d8967a4c..2d88d3a63 100644 --- a/packages/excalidraw/components/App.tsx +++ b/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 { 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 {
- + {selectedElements.length === 1 && this.state.showHyperlinkPopup && ( { 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 { 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 { 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 { 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 { 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 { } 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 { 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 { } if (activeTool.type === "laser") { - this.laserPathManager.endPath(); + this.laserTrails.endPath(); return; } diff --git a/packages/excalidraw/components/LaserTool/LaserPointerButton.tsx b/packages/excalidraw/components/LaserPointerButton.tsx similarity index 87% rename from packages/excalidraw/components/LaserTool/LaserPointerButton.tsx rename to packages/excalidraw/components/LaserPointerButton.tsx index dbb843293..ae3cfb31a 100644 --- a/packages/excalidraw/components/LaserTool/LaserPointerButton.tsx +++ b/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; diff --git a/packages/excalidraw/components/LaserTool/LaserPathManager.ts b/packages/excalidraw/components/LaserTool/LaserPathManager.ts deleted file mode 100644 index b6e462aa3..000000000 --- a/packages/excalidraw/components/LaserTool/LaserPathManager.ts +++ /dev/null @@ -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 = 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; - } - } -} diff --git a/packages/excalidraw/components/LaserTool/LaserTool.tsx b/packages/excalidraw/components/LaserTool/LaserTool.tsx deleted file mode 100644 index e93d72dfc..000000000 --- a/packages/excalidraw/components/LaserTool/LaserTool.tsx +++ /dev/null @@ -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(null); - - useEffect(() => { - if (svgRef.current) { - manager.start(svgRef.current); - } - - return () => { - manager.stop(); - }; - }, [manager]); - - return ( -
- -
- ); -}; diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 7dd362063..8cedf689d 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/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"; diff --git a/packages/excalidraw/components/LaserTool/LaserToolOverlay.scss b/packages/excalidraw/components/SVGLayer.scss similarity index 80% rename from packages/excalidraw/components/LaserTool/LaserToolOverlay.scss rename to packages/excalidraw/components/SVGLayer.scss index da874b452..5eb0353aa 100644 --- a/packages/excalidraw/components/LaserTool/LaserToolOverlay.scss +++ b/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; } diff --git a/packages/excalidraw/components/SVGLayer.tsx b/packages/excalidraw/components/SVGLayer.tsx new file mode 100644 index 000000000..feaebaf94 --- /dev/null +++ b/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(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 ( +
+ +
+ ); +}; diff --git a/packages/excalidraw/laser-trails.ts b/packages/excalidraw/laser-trails.ts new file mode 100644 index 000000000..49a0de5be --- /dev/null +++ b/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(); + + 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; + } + + 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); + } + } + } +} diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 1cd837fdd..7ec828cc1 100644 --- a/packages/excalidraw/package.json +++ b/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", diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index c2afedb32..4630c5bce 100644 --- a/packages/excalidraw/utils.ts +++ b/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"); }; diff --git a/yarn.lock b/yarn.lock index 87493423e..f857f7fb4 100644 --- a/yarn.lock +++ b/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/markdown-to-text@0.1.2": version "0.1.2"