feat: add eraser tool trail (#7511)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
pull/7539/head^2
Are 1 year ago committed by GitHub
parent 872973f145
commit 86cfeb714c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -384,8 +384,7 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { StaticCanvas, InteractiveCanvas } from "./canvases";
import { Renderer } from "../scene/Renderer"; import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { LaserToolOverlay } from "./LaserTool/LaserTool"; import { SVGLayer } from "./SVGLayer";
import { LaserPathManager } from "./LaserTool/LaserPathManager";
import { import {
setEraserCursor, setEraserCursor,
setCursor, setCursor,
@ -401,6 +400,10 @@ import { ElementCanvasButton } from "./MagicButton";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { EditorLocalStorage } from "../data/EditorLocalStorage"; import { EditorLocalStorage } from "../data/EditorLocalStorage";
import FollowMode from "./FollowMode/FollowMode"; 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 { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { getRenderOpacity } from "../renderer/renderElement"; import { getRenderOpacity } from "../renderer/renderElement";
@ -537,7 +540,29 @@ class App extends React.Component<AppProps, AppState> {
lastPointerMoveEvent: PointerEvent | null = null; lastPointerMoveEvent: PointerEvent | null = null;
lastViewportPosition = { x: 0, y: 0 }; 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< onChangeEmitter = new Emitter<
[ [
@ -1471,7 +1496,9 @@ class App extends React.Component<AppProps, AppState> {
<div className="excalidraw-textEditorContainer" /> <div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" /> <div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" /> <div className="excalidraw-eye-dropper-container" />
<LaserToolOverlay manager={this.laserPathManager} /> <SVGLayer
trails={[this.laserTrails, this.eraserTrail]}
/>
{selectedElements.length === 1 && {selectedElements.length === 1 &&
this.state.showHyperlinkPopup && ( this.state.showHyperlinkPopup && (
<Hyperlink <Hyperlink
@ -2394,7 +2421,8 @@ class App extends React.Component<AppProps, AppState> {
this.removeEventListeners(); this.removeEventListeners();
this.scene.destroy(); this.scene.destroy();
this.library.destroy(); this.library.destroy();
this.laserPathManager.destroy(); this.laserTrails.stop();
this.eraserTrail.stop();
this.onChangeEmitter.clear(); this.onChangeEmitter.clear();
ShapeCache.destroy(); ShapeCache.destroy();
SnapCache.destroy(); SnapCache.destroy();
@ -2619,6 +2647,10 @@ class App extends React.Component<AppProps, AppState> {
this.updateLanguage(); this.updateLanguage();
} }
if (isEraserActive(prevState) && !isEraserActive(this.state)) {
this.eraserTrail.endPath();
}
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) { if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
this.setState({ viewModeEnabled: !!this.props.viewModeEnabled }); this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
} }
@ -5070,6 +5102,8 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },
) => { ) => {
this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y);
let didChange = false; let didChange = false;
const processElements = (elements: ExcalidrawElement[]) => { const processElements = (elements: ExcalidrawElement[]) => {
@ -5500,7 +5534,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type, this.state.activeTool.type,
); );
} else if (this.state.activeTool.type === "laser") { } else if (this.state.activeTool.type === "laser") {
this.laserPathManager.startPath( this.laserTrails.startPath(
pointerDownState.lastCoords.x, pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y, pointerDownState.lastCoords.y,
); );
@ -5521,6 +5555,13 @@ class App extends React.Component<AppProps, AppState> {
event, event,
); );
if (this.state.activeTool.type === "eraser") {
this.eraserTrail.startPath(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
);
}
const onPointerMove = const onPointerMove =
this.onPointerMoveFromPointerDownHandler(pointerDownState); this.onPointerMoveFromPointerDownHandler(pointerDownState);
@ -6784,7 +6825,7 @@ class App extends React.Component<AppProps, AppState> {
} }
if (this.state.activeTool.type === "laser") { if (this.state.activeTool.type === "laser") {
this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y); this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y);
} }
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
@ -7793,6 +7834,8 @@ class App extends React.Component<AppProps, AppState> {
const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent; const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
if (isEraserActive(this.state) && pointerStart && pointerEnd) { if (isEraserActive(this.state) && pointerStart && pointerEnd) {
this.eraserTrail.endPath();
const draggedDistance = distance2d( const draggedDistance = distance2d(
pointerStart.clientX, pointerStart.clientX,
pointerStart.clientY, pointerStart.clientY,
@ -8041,7 +8084,7 @@ class App extends React.Component<AppProps, AppState> {
} }
if (activeTool.type === "laser") { if (activeTool.type === "laser") {
this.laserPathManager.endPath(); this.laserTrails.endPath();
return; return;
} }

@ -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>
);
};

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

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

@ -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);
}
}
}
}

@ -57,7 +57,7 @@
}, },
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "6.0.2", "@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/mermaid-to-excalidraw": "0.2.0",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.0.3", "@radix-ui/react-popover": "1.0.3",

@ -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) => { export const normalizeEOL = (str: string) => {
return str.replace(/\r?\n|\r/g, "\n"); return str.replace(/\r?\n|\r/g, "\n");
}; };

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

Loading…
Cancel
Save