feat: add eraser tool trail (#7511)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Are 2024-01-11 17:10:15 +01:00 committed by GitHub
parent 872973f145
commit 86cfeb714c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 482 additions and 356 deletions

View file

@ -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<AppProps, AppState> {
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<AppProps, AppState> {
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
<LaserToolOverlay manager={this.laserPathManager} />
<SVGLayer
trails={[this.laserTrails, this.eraserTrail]}
/>
{selectedElements.length === 1 &&
this.state.showHyperlinkPopup && (
<Hyperlink
@ -2394,7 +2421,8 @@ class App extends React.Component<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
}
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<AppProps, AppState> {
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<AppProps, AppState> {
}
if (activeTool.type === "laser") {
this.laserPathManager.endPath();
this.laserTrails.endPath();
return;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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