diff options
Diffstat (limited to 'packages/excalidraw/animated-trail.ts')
| -rw-r--r-- | packages/excalidraw/animated-trail.ts | 149 |
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); + } +} |
