mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: add eraser tool trail (#7511)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
872973f145
commit
86cfeb714c
13 changed files with 482 additions and 356 deletions
148
packages/excalidraw/animated-trail.ts
Normal file
148
packages/excalidraw/animated-trail.ts
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
79
packages/excalidraw/animation-frame-handler.ts
Normal file
79
packages/excalidraw/animation-frame-handler.ts
Normal file
|
@ -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;
|
||||||
}
|
}
|
33
packages/excalidraw/components/SVGLayer.tsx
Normal file
33
packages/excalidraw/components/SVGLayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
124
packages/excalidraw/laser-trails.ts
Normal file
124
packages/excalidraw/laser-trails.ts
Normal file
|
@ -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…
Add table
Add a link
Reference in a new issue