13 changed files with 482 additions and 356 deletions
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -1,8 +1,8 @@ |
|||||||
import "../ToolIcon.scss"; |
import "./ToolIcon.scss"; |
||||||
|
|
||||||
import clsx from "clsx"; |
import clsx from "clsx"; |
||||||
import { ToolButtonSize } from "../ToolButton"; |
import { ToolButtonSize } from "./ToolButton"; |
||||||
import { laserPointerToolIcon } from "../icons"; |
import { laserPointerToolIcon } from "./icons"; |
||||||
|
|
||||||
type LaserPointerIconProps = { |
type LaserPointerIconProps = { |
||||||
title?: string; |
title?: string; |
||||||
@ -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; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -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> |
|
||||||
); |
|
||||||
}; |
|
||||||
@ -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> |
||||||
|
); |
||||||
|
}; |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue