summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/animated-trail.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/animated-trail.ts')
-rw-r--r--packages/excalidraw/animated-trail.ts149
1 files changed, 149 insertions, 0 deletions
diff --git a/packages/excalidraw/animated-trail.ts b/packages/excalidraw/animated-trail.ts
new file mode 100644
index 0000000..97a0054
--- /dev/null
+++ b/packages/excalidraw/animated-trail.ts
@@ -0,0 +1,149 @@
+import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
+import { LaserPointer } from "@excalidraw/laser-pointer";
+import type { AnimationFrameHandler } from "./animation-frame-handler";
+import type { 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);
+ }
+}