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 = 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; } } }