animated-trail.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { LaserPointer } from "@excalidraw/laser-pointer";
  2. import {
  3. SVG_NS,
  4. getSvgPathFromStroke,
  5. sceneCoordsToViewportCoords,
  6. } from "@excalidraw/common";
  7. import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
  8. import type { AnimationFrameHandler } from "./animation-frame-handler";
  9. import type App from "./components/App";
  10. import type { AppState } from "./types";
  11. export interface Trail {
  12. start(container: SVGSVGElement): void;
  13. stop(): void;
  14. startPath(x: number, y: number): void;
  15. addPointToPath(x: number, y: number): void;
  16. endPath(): void;
  17. }
  18. export interface AnimatedTrailOptions {
  19. fill: (trail: AnimatedTrail) => string;
  20. stroke?: (trail: AnimatedTrail) => string;
  21. animateTrail?: boolean;
  22. }
  23. export class AnimatedTrail implements Trail {
  24. private currentTrail?: LaserPointer;
  25. private pastTrails: LaserPointer[] = [];
  26. private container?: SVGSVGElement;
  27. private trailElement: SVGPathElement;
  28. private trailAnimation?: SVGAnimateElement;
  29. constructor(
  30. private animationFrameHandler: AnimationFrameHandler,
  31. protected app: App,
  32. private options: Partial<LaserPointerOptions> &
  33. Partial<AnimatedTrailOptions>,
  34. ) {
  35. this.animationFrameHandler.register(this, this.onFrame.bind(this));
  36. this.trailElement = document.createElementNS(SVG_NS, "path");
  37. if (this.options.animateTrail) {
  38. this.trailAnimation = document.createElementNS(SVG_NS, "animate");
  39. // TODO: make this configurable
  40. this.trailAnimation.setAttribute("attributeName", "stroke-dashoffset");
  41. this.trailElement.setAttribute("stroke-dasharray", "7 7");
  42. this.trailElement.setAttribute("stroke-dashoffset", "10");
  43. this.trailAnimation.setAttribute("from", "0");
  44. this.trailAnimation.setAttribute("to", `-14`);
  45. this.trailAnimation.setAttribute("dur", "0.3s");
  46. this.trailElement.appendChild(this.trailAnimation);
  47. }
  48. }
  49. get hasCurrentTrail() {
  50. return !!this.currentTrail;
  51. }
  52. hasLastPoint(x: number, y: number) {
  53. if (this.currentTrail) {
  54. const len = this.currentTrail.originalPoints.length;
  55. return (
  56. this.currentTrail.originalPoints[len - 1][0] === x &&
  57. this.currentTrail.originalPoints[len - 1][1] === y
  58. );
  59. }
  60. return false;
  61. }
  62. start(container?: SVGSVGElement) {
  63. if (container) {
  64. this.container = container;
  65. }
  66. if (this.trailElement.parentNode !== this.container && this.container) {
  67. this.container.appendChild(this.trailElement);
  68. }
  69. this.animationFrameHandler.start(this);
  70. }
  71. stop() {
  72. this.animationFrameHandler.stop(this);
  73. if (this.trailElement.parentNode === this.container) {
  74. this.container?.removeChild(this.trailElement);
  75. }
  76. }
  77. startPath(x: number, y: number) {
  78. this.currentTrail = new LaserPointer(this.options);
  79. this.currentTrail.addPoint([x, y, performance.now()]);
  80. this.update();
  81. }
  82. addPointToPath(x: number, y: number) {
  83. if (this.currentTrail) {
  84. this.currentTrail.addPoint([x, y, performance.now()]);
  85. this.update();
  86. }
  87. }
  88. endPath() {
  89. if (this.currentTrail) {
  90. this.currentTrail.close();
  91. this.currentTrail.options.keepHead = false;
  92. this.pastTrails.push(this.currentTrail);
  93. this.currentTrail = undefined;
  94. this.update();
  95. }
  96. }
  97. getCurrentTrail() {
  98. return this.currentTrail;
  99. }
  100. clearTrails() {
  101. this.pastTrails = [];
  102. this.currentTrail = undefined;
  103. this.update();
  104. }
  105. private update() {
  106. this.start();
  107. if (this.trailAnimation) {
  108. this.trailAnimation.setAttribute("begin", "indefinite");
  109. this.trailAnimation.setAttribute("repeatCount", "indefinite");
  110. }
  111. }
  112. private onFrame() {
  113. const paths: string[] = [];
  114. for (const trail of this.pastTrails) {
  115. paths.push(this.drawTrail(trail, this.app.state));
  116. }
  117. if (this.currentTrail) {
  118. const currentPath = this.drawTrail(this.currentTrail, this.app.state);
  119. paths.push(currentPath);
  120. }
  121. this.pastTrails = this.pastTrails.filter((trail) => {
  122. return trail.getStrokeOutline().length !== 0;
  123. });
  124. if (paths.length === 0) {
  125. this.stop();
  126. }
  127. const svgPaths = paths.join(" ").trim();
  128. this.trailElement.setAttribute("d", svgPaths);
  129. if (this.trailAnimation) {
  130. this.trailElement.setAttribute(
  131. "fill",
  132. (this.options.fill ?? (() => "black"))(this),
  133. );
  134. this.trailElement.setAttribute(
  135. "stroke",
  136. (this.options.stroke ?? (() => "black"))(this),
  137. );
  138. } else {
  139. this.trailElement.setAttribute(
  140. "fill",
  141. (this.options.fill ?? (() => "black"))(this),
  142. );
  143. }
  144. }
  145. private drawTrail(trail: LaserPointer, state: AppState): string {
  146. const _stroke = trail
  147. .getStrokeOutline(trail.options.size / state.zoom.value)
  148. .map(([x, y]) => {
  149. const result = sceneCoordsToViewportCoords(
  150. { sceneX: x, sceneY: y },
  151. state,
  152. );
  153. return [result.x, result.y];
  154. });
  155. const stroke = this.trailAnimation
  156. ? _stroke.slice(0, _stroke.length / 2)
  157. : _stroke;
  158. return getSvgPathFromStroke(stroke, true);
  159. }
  160. }