fix(editor): excessive battery usage (#11377)

* fix: Excessive battery usage

* chore: Refactor Eraser, Lasso and Laser pointer to use AnimationController

* fix: Last laser trail element is not removed from SVG
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács
2026-05-24 16:12:17 +02:00
committed by GitHub
co-authored by dwelle
parent f6d85bc80f
commit b42b1a193d
12 changed files with 283 additions and 172 deletions
@@ -1,5 +1,4 @@
import { LaserPointer } from "@excalidraw/laser-pointer"; import { LaserPointer } from "@excalidraw/laser-pointer";
import { import {
SVG_NS, SVG_NS,
getSvgPathFromStroke, getSvgPathFromStroke,
@@ -8,7 +7,8 @@ import {
import type { LaserPointerOptions } from "@excalidraw/laser-pointer"; import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import type { AnimationFrameHandler } from "./animation-frame-handler"; import { AnimationController } from "./renderer/animation";
import type App from "./components/App"; import type App from "./components/App";
import type { AppState } from "./types"; import type { AppState } from "./types";
@@ -34,15 +34,16 @@ export class AnimatedTrail implements Trail {
private container?: SVGSVGElement; private container?: SVGSVGElement;
private trailElement: SVGPathElement; private trailElement: SVGPathElement;
private trailAnimation?: SVGAnimateElement; private trailAnimation?: SVGAnimateElement;
private key: string;
private static counter = 0;
constructor( constructor(
private animationFrameHandler: AnimationFrameHandler,
protected app: App, protected app: App,
private options: Partial<LaserPointerOptions> & private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>, Partial<AnimatedTrailOptions>,
) { ) {
this.animationFrameHandler.register(this, this.onFrame.bind(this)); this.key = `animated-trail-${AnimatedTrail.counter++}`;
this.trailElement = document.createElementNS(SVG_NS, "path"); this.trailElement = document.createElementNS(SVG_NS, "path");
if (this.options.animateTrail) { if (this.options.animateTrail) {
this.trailAnimation = document.createElementNS(SVG_NS, "animate"); this.trailAnimation = document.createElementNS(SVG_NS, "animate");
@@ -73,6 +74,15 @@ export class AnimatedTrail implements Trail {
return false; return false;
} }
private cleanup() {
this.pastTrails = [];
this.currentTrail = undefined;
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
}
start(container?: SVGSVGElement) { start(container?: SVGSVGElement) {
if (container) { if (container) {
this.container = container; this.container = container;
@@ -82,15 +92,23 @@ export class AnimatedTrail implements Trail {
this.container.appendChild(this.trailElement); this.container.appendChild(this.trailElement);
} }
this.animationFrameHandler.start(this); if (!AnimationController.running(this.key)) {
AnimationController.start(this.key, () => {
const needsNext = this.onFrame();
if (needsNext) {
return { keep: true };
}
this.cleanup();
return null;
});
}
} }
stop() { stop() {
this.animationFrameHandler.stop(this); AnimationController.cancel(this.key);
this.cleanup();
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
} }
startPath(x: number, y: number) { startPath(x: number, y: number) {
@@ -145,21 +163,25 @@ export class AnimatedTrail implements Trail {
if (this.currentTrail) { if (this.currentTrail) {
const currentPath = this.drawTrail(this.currentTrail, this.app.state); const currentPath = this.drawTrail(this.currentTrail, this.app.state);
paths.push(currentPath); paths.push(currentPath);
} }
this.pastTrails = this.pastTrails.filter((trail) => { this.pastTrails = this.pastTrails.filter(
return trail.getStrokeOutline().length !== 0; (t) =>
}); t.getStrokeOutline(t.options.size / this.app.state.zoom.value)
.length !== 0,
);
if (paths.length === 0) { if (paths.length === 0) {
this.stop(); // Clean up the SVG path if there are no trails to render
this.trailElement.setAttribute("d", "");
return false;
} }
const svgPaths = paths.join(" ").trim(); const svgPaths = paths.join(" ").trim();
this.trailElement.setAttribute("d", svgPaths); this.trailElement.setAttribute("d", svgPaths);
if (this.trailAnimation) { if (this.trailAnimation) {
this.trailElement.setAttribute( this.trailElement.setAttribute(
"fill", "fill",
@@ -175,6 +197,8 @@ export class AnimatedTrail implements Trail {
(this.options.fill ?? (() => "black"))(this), (this.options.fill ?? (() => "black"))(this),
); );
} }
return true;
} }
private drawTrail(trail: LaserPointer, state: AppState): string { private drawTrail(trail: LaserPointer, state: AppState): string {
@@ -1,79 +0,0 @@
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;
}
}
+5 -7
View File
@@ -343,7 +343,6 @@ import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register"; import { actions } from "../actions/register";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { AnimationFrameHandler } from "../animation-frame-handler";
import { import {
getDefaultAppState, getDefaultAppState,
isEraserActive, isEraserActive,
@@ -417,7 +416,7 @@ import {
setCursorForShape, setCursorForShape,
} from "../cursor"; } from "../cursor";
import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
import { LaserTrails } from "../laser-trails"; import { LaserTrails } from "../laserTrails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle"; import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
import { textWysiwyg } from "../wysiwyg/textWysiwyg"; import { textWysiwyg } from "../wysiwyg/textWysiwyg";
@@ -703,11 +702,9 @@ class App extends React.Component<AppProps, AppState> {
previousPointerMoveCoords: { x: number; y: number } | null = null; previousPointerMoveCoords: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 }; lastViewportPosition = { x: 0, y: 0 };
animationFrameHandler = new AnimationFrameHandler(); laserTrails = new LaserTrails(this);
eraserTrail = new EraserTrail(this);
laserTrails = new LaserTrails(this.animationFrameHandler, this); lassoTrail = new LassoTrail(this);
eraserTrail = new EraserTrail(this.animationFrameHandler, this);
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
onChangeEmitter = new Emitter< onChangeEmitter = new Emitter<
[ [
@@ -4615,6 +4612,7 @@ class App extends React.Component<AppProps, AppState> {
} }
if (collaborators) { if (collaborators) {
this.laserTrails.updateCollabTrails(collaborators);
this.setState({ collaborators }); this.setState({ collaborators });
} }
}, },
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import "./SVGLayer.scss"; import "./SVGLayer.scss";
import type { Trail } from "../animated-trail"; import type { Trail } from "../animatedTrail";
type SVGLayerProps = { type SVGLayerProps = {
trails: Trail[]; trails: Trail[];
+3 -5
View File
@@ -33,9 +33,7 @@ import type { Bounds } from "@excalidraw/common";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { AnimatedTrail } from "../animated-trail"; import { AnimatedTrail } from "../animatedTrail";
import type { AnimationFrameHandler } from "../animation-frame-handler";
import type App from "../components/App"; import type App from "../components/App";
@@ -43,8 +41,8 @@ export class EraserTrail extends AnimatedTrail {
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set(); private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
private groupsToErase: Set<ExcalidrawElement["id"]> = new Set(); private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
constructor(animationFrameHandler: AnimationFrameHandler, app: App) { constructor(app: App) {
super(animationFrameHandler, app, { super(app, {
streamline: 0.2, streamline: 0.2,
size: 5, size: 5,
keepHead: true, keepHead: true,
@@ -2,27 +2,20 @@ import { DEFAULT_LASER_COLOR, easeOut } from "@excalidraw/common";
import type { LaserPointerOptions } from "@excalidraw/laser-pointer"; import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import { AnimatedTrail } from "./animated-trail"; import { AnimatedTrail } from "./animatedTrail";
import { getClientColor } from "./clients"; import { getClientColor } from "./clients";
import type { Trail } from "./animated-trail"; import type { Trail } from "./animatedTrail";
import type { AnimationFrameHandler } from "./animation-frame-handler";
import type App from "./components/App"; import type App from "./components/App";
import type { SocketId } from "./types"; import type { SocketId } from "./types";
export class LaserTrails implements Trail { export class LaserTrails implements Trail {
public localTrail: AnimatedTrail; public localTrail: AnimatedTrail;
private collabTrails = new Map<SocketId, AnimatedTrail>(); private collabTrails = new Map<SocketId, AnimatedTrail>();
private container?: SVGSVGElement; private container?: SVGSVGElement;
constructor( constructor(private app: App) {
private animationFrameHandler: AnimationFrameHandler, this.localTrail = new AnimatedTrail(app, {
private app: App,
) {
this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.localTrail = new AnimatedTrail(animationFrameHandler, app, {
...this.getTrailOptions(), ...this.getTrailOptions(),
fill: () => DEFAULT_LASER_COLOR, fill: () => DEFAULT_LASER_COLOR,
}); });
@@ -63,30 +56,45 @@ export class LaserTrails implements Trail {
start(container: SVGSVGElement) { start(container: SVGSVGElement) {
this.container = container; this.container = container;
this.animationFrameHandler.start(this);
this.localTrail.start(container); this.localTrail.start(container);
} }
stop() { stop() {
this.animationFrameHandler.stop(this);
this.localTrail.stop(); this.localTrail.stop();
this.stopCollabTrails();
this.container = undefined;
} }
onFrame() { private stopCollabTrails(collaborators?: App["state"]["collaborators"]) {
this.updateCollabTrails(); for (const [key, trail] of this.collabTrails) {
const collaborator = collaborators?.get(key);
if (!collaborator) {
trail.stop();
this.collabTrails.delete(key);
}
}
} }
private updateCollabTrails() { updateCollabTrails(collaborators: App["state"]["collaborators"]) {
if (!this.container || this.app.state.collaborators.size === 0) { this.stopCollabTrails(collaborators);
if (!this.container || collaborators.size === 0) {
return; return;
} }
for (const [key, collaborator] of this.app.state.collaborators.entries()) { for (const [key, collaborator] of collaborators.entries()) {
let trail!: AnimatedTrail; // Current user has their own trail drawn via localTrail
if (collaborator.isCurrentUser) {
continue;
}
if (!this.collabTrails.has(key)) { // IDEA: Use the collaborator pointer coordinates to trace out the
trail = new AnimatedTrail(this.animationFrameHandler, this.app, { // laser pointer trail when 1) the selected collab tool is the laser
// pointer and 2) the collab pointer button is in the "down" state.
let trail = this.collabTrails.get(key);
if (!trail) {
trail = new AnimatedTrail(this.app, {
...this.getTrailOptions(), ...this.getTrailOptions(),
fill: () => fill: () =>
collaborator.pointer?.laserColor || collaborator.pointer?.laserColor ||
@@ -95,36 +103,33 @@ export class LaserTrails implements Trail {
trail.start(this.container); trail.start(this.container);
this.collabTrails.set(key, trail); this.collabTrails.set(key, trail);
} else {
trail = this.collabTrails.get(key)!;
} }
if (collaborator.pointer && collaborator.pointer.tool === "laser") { if (collaborator.pointer && collaborator.pointer.tool === "laser") {
if (collaborator.button === "down" && !trail.hasCurrentTrail) { const buttonDown = collaborator.button === "down";
const buttonUp = collaborator.button === "up";
const hasTrail = trail.hasCurrentTrail;
// Initialize a new trail
if (buttonDown && !hasTrail) {
trail.startPath(collaborator.pointer.x, collaborator.pointer.y); trail.startPath(collaborator.pointer.x, collaborator.pointer.y);
} }
if ( // Add only original points
collaborator.button === "down" && const lastPointOriginal = !trail.hasLastPoint(
trail.hasCurrentTrail && collaborator.pointer.x,
!trail.hasLastPoint(collaborator.pointer.x, collaborator.pointer.y) collaborator.pointer.y,
) { );
if (buttonDown && lastPointOriginal) {
trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y);
} }
if (collaborator.button === "up" && trail.hasCurrentTrail) { // End the trail on button up
if (buttonUp && hasTrail) {
trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y);
trail.endPath(); 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);
}
}
} }
} }
+3 -5
View File
@@ -25,9 +25,7 @@ import type {
NonDeleted, NonDeleted,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { type AnimationFrameHandler } from "../animation-frame-handler"; import { AnimatedTrail } from "../animatedTrail";
import { AnimatedTrail } from "../animated-trail";
import { getLassoSelectedElementIds } from "./utils"; import { getLassoSelectedElementIds } from "./utils";
@@ -47,8 +45,8 @@ export class LassoTrail extends AnimatedTrail {
private canvasTranslate: CanvasTranslate | null = null; private canvasTranslate: CanvasTranslate | null = null;
private keepPreviousSelection: boolean = false; private keepPreviousSelection: boolean = false;
constructor(animationFrameHandler: AnimationFrameHandler, app: App) { constructor(app: App) {
super(animationFrameHandler, app, { super(app, {
animateTrail: true, animateTrail: true,
streamline: 0.4, streamline: 0.4,
sizeMapping: (c) => { sizeMapping: (c) => {
+58 -16
View File
@@ -6,7 +6,10 @@ export type Animation<R extends object> = (params: {
}) => R | null | undefined; }) => R | null | undefined;
export class AnimationController { export class AnimationController {
private static isRunning = false; private static scheduledFrame:
| { id: ReturnType<typeof requestAnimationFrame>; type: "raf" }
| { id: ReturnType<typeof setTimeout>; type: "timeout" }
| null = null;
private static animations = new Map< private static animations = new Map<
string, string,
{ {
@@ -17,6 +20,10 @@ export class AnimationController {
>(); >();
static start<R extends object>(key: string, animation: Animation<R>) { static start<R extends object>(key: string, animation: Animation<R>) {
if (AnimationController.animations.has(key)) {
return;
}
const initialState = animation({ const initialState = animation({
deltaTime: 0, deltaTime: 0,
state: undefined, state: undefined,
@@ -29,19 +36,54 @@ export class AnimationController {
state: initialState, state: initialState,
}); });
if (!AnimationController.isRunning) { AnimationController.scheduleNextFrame();
AnimationController.isRunning = true;
if (isRenderThrottlingEnabled()) {
requestAnimationFrame(AnimationController.tick);
} else {
setTimeout(AnimationController.tick, 0);
}
}
} }
} }
private static scheduleNextFrame() {
if (AnimationController.scheduledFrame) {
return;
}
if (isRenderThrottlingEnabled()) {
AnimationController.scheduledFrame = {
id: requestAnimationFrame(AnimationController.tick),
type: "raf",
};
} else {
AnimationController.scheduledFrame = {
id: setTimeout(AnimationController.tick, 0),
type: "timeout",
};
}
}
private static cancelScheduledFrame() {
if (!AnimationController.scheduledFrame) {
return;
}
if (AnimationController.scheduledFrame.type === "raf") {
cancelAnimationFrame(AnimationController.scheduledFrame.id);
} else {
clearTimeout(AnimationController.scheduledFrame.id);
}
AnimationController.scheduledFrame = null;
}
private static cancelScheduledFrameIfIdle() {
if (AnimationController.animations.size > 0) {
return false;
}
AnimationController.cancelScheduledFrame();
return true;
}
private static tick() { private static tick() {
AnimationController.scheduledFrame = null;
if (AnimationController.animations.size > 0) { if (AnimationController.animations.size > 0) {
for (const [key, animation] of AnimationController.animations) { for (const [key, animation] of AnimationController.animations) {
const now = performance.now(); const now = performance.now();
@@ -56,8 +98,7 @@ export class AnimationController {
if (!state) { if (!state) {
AnimationController.animations.delete(key); AnimationController.animations.delete(key);
if (AnimationController.animations.size === 0) { if (AnimationController.cancelScheduledFrameIfIdle()) {
AnimationController.isRunning = false;
return; return;
} }
} else { } else {
@@ -66,11 +107,11 @@ export class AnimationController {
} }
} }
if (isRenderThrottlingEnabled()) { if (AnimationController.cancelScheduledFrameIfIdle()) {
requestAnimationFrame(AnimationController.tick); return;
} else {
setTimeout(AnimationController.tick, 0);
} }
AnimationController.scheduleNextFrame();
} }
} }
@@ -80,5 +121,6 @@ export class AnimationController {
static cancel(key: string) { static cancel(key: string) {
AnimationController.animations.delete(key); AnimationController.animations.delete(key);
AnimationController.cancelScheduledFrameIfIdle();
} }
} }
@@ -83,6 +83,26 @@ mockMermaidToExcalidraw({
}, },
}); });
const normalizeDialogSnapshot = (dialog: Element) => {
const dialogClone = dialog.cloneNode(true) as HTMLElement;
dialogClone
.querySelectorAll<HTMLElement>(".ttd-dialog-content")
.forEach((element) => {
// Radix Tabs injects this during initial mount animation prevention.
// Its presence depends on render timing and is unrelated to this test.
if (element.style.animationDuration === "0s") {
element.style.removeProperty("animation-duration");
}
if (!element.getAttribute("style")) {
element.removeAttribute("style");
}
});
return dialogClone.outerHTML;
};
describe("Test <MermaidToExcalidraw/>", () => { describe("Test <MermaidToExcalidraw/>", () => {
beforeEach(async () => { beforeEach(async () => {
await render( await render(
@@ -99,7 +119,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
it("should open mermaid popup when active tool is mermaid", async () => { it("should open mermaid popup when active tool is mermaid", async () => {
const dialog = document.querySelector(".ttd-dialog")!; const dialog = document.querySelector(".ttd-dialog")!;
await waitFor(() => expect(dialog.querySelector("canvas")).not.toBeNull()); await waitFor(() => expect(dialog.querySelector("canvas")).not.toBeNull());
expect(dialog.outerHTML).toMatchSnapshot(); expect(normalizeDialogSnapshot(dialog)).toMatchSnapshot();
}); });
it("should show error in preview when mermaid library throws error", async () => { it("should show error in preview when mermaid library throws error", async () => {
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`; exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content"><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = ` exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
"flowchart TD "flowchart TD
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AnimationController } from "../renderer/animation";
const FIRST_KEY = "animation-test-first";
const SECOND_KEY = "animation-test-second";
describe("AnimationController", () => {
beforeEach(() => {
vi.useFakeTimers();
window.EXCALIDRAW_THROTTLE_RENDER = false;
});
afterEach(() => {
AnimationController.cancel(FIRST_KEY);
AnimationController.cancel(SECOND_KEY);
window.EXCALIDRAW_THROTTLE_RENDER = undefined;
vi.useRealTimers();
});
it("starts a new animation after the previous last animation was cancelled", async () => {
let firstFrames = 0;
AnimationController.start(FIRST_KEY, () => {
firstFrames++;
return { keep: true };
});
expect(firstFrames).toBe(1);
AnimationController.cancel(FIRST_KEY);
await vi.runOnlyPendingTimersAsync();
let secondFrames = 0;
AnimationController.start(SECOND_KEY, () => {
secondFrames++;
return secondFrames === 1 ? { keep: true } : null;
});
expect(secondFrames).toBe(1);
await vi.runOnlyPendingTimersAsync();
expect(secondFrames).toBe(2);
expect(AnimationController.running(SECOND_KEY)).toBe(false);
});
it("cancels a frame scheduled during a tick if no animations remain", async () => {
let firstFrames = 0;
let secondFrames = 0;
AnimationController.start(FIRST_KEY, ({ state }) => {
if (!state) {
return { keep: true };
}
firstFrames++;
AnimationController.start(SECOND_KEY, () => {
secondFrames++;
return { keep: true };
});
AnimationController.cancel(SECOND_KEY);
return null;
});
await vi.runOnlyPendingTimersAsync();
expect(firstFrames).toBe(1);
expect(secondFrames).toBe(1);
expect(vi.getTimerCount()).toBe(0);
});
});
+33 -1
View File
@@ -10,7 +10,7 @@ import { API } from "./helpers/api";
import { Pointer } from "./helpers/ui"; import { Pointer } from "./helpers/ui";
import { act, GlobalTestState, render, waitFor } from "./test-utils"; import { act, GlobalTestState, render, waitFor } from "./test-utils";
import type { ExcalidrawProps } from "../types"; import type { Collaborator, ExcalidrawProps, SocketId } from "../types";
describe("laser tool interactions", () => { describe("laser tool interactions", () => {
const h = window.h; const h = window.h;
@@ -128,4 +128,36 @@ describe("laser tool interactions", () => {
expect(h.state.scrollY).toBe(initialScrollY); expect(h.state.scrollY).toBe(initialScrollY);
expect(GlobalTestState.interactiveCanvas.style.cursor).toContain(""); expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("");
}); });
it("cleans up remote laser trails when the last collaborator leaves", async () => {
await render(<Excalidraw />);
const socketId = "socket-id" as SocketId;
const collaborators = new Map<SocketId, Collaborator>([
[
socketId,
{
pointer: {
x: 10,
y: 10,
tool: "laser",
},
button: "down",
},
],
]);
const svgLayer = document.querySelector(".SVGLayer svg")!;
act(() => {
h.app.updateScene({ collaborators });
});
expect(svgLayer.querySelectorAll("path")).toHaveLength(1);
act(() => {
h.app.updateScene({ collaborators: new Map() });
});
expect(svgLayer.querySelectorAll("path")).toHaveLength(0);
});
}); });