You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
311 lines
7.7 KiB
TypeScript
311 lines
7.7 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|