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:
@@ -1,5 +1,4 @@
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
|
||||
import {
|
||||
SVG_NS,
|
||||
getSvgPathFromStroke,
|
||||
@@ -8,7 +7,8 @@ import {
|
||||
|
||||
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 { AppState } from "./types";
|
||||
|
||||
@@ -34,15 +34,16 @@ export class AnimatedTrail implements Trail {
|
||||
private container?: SVGSVGElement;
|
||||
private trailElement: SVGPathElement;
|
||||
private trailAnimation?: SVGAnimateElement;
|
||||
private key: string;
|
||||
|
||||
private static counter = 0;
|
||||
|
||||
constructor(
|
||||
private animationFrameHandler: AnimationFrameHandler,
|
||||
protected app: App,
|
||||
private options: Partial<LaserPointerOptions> &
|
||||
Partial<AnimatedTrailOptions>,
|
||||
) {
|
||||
this.animationFrameHandler.register(this, this.onFrame.bind(this));
|
||||
|
||||
this.key = `animated-trail-${AnimatedTrail.counter++}`;
|
||||
this.trailElement = document.createElementNS(SVG_NS, "path");
|
||||
if (this.options.animateTrail) {
|
||||
this.trailAnimation = document.createElementNS(SVG_NS, "animate");
|
||||
@@ -73,6 +74,15 @@ export class AnimatedTrail implements Trail {
|
||||
return false;
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
this.pastTrails = [];
|
||||
this.currentTrail = undefined;
|
||||
|
||||
if (this.trailElement.parentNode === this.container) {
|
||||
this.container?.removeChild(this.trailElement);
|
||||
}
|
||||
}
|
||||
|
||||
start(container?: SVGSVGElement) {
|
||||
if (container) {
|
||||
this.container = container;
|
||||
@@ -82,15 +92,23 @@ export class AnimatedTrail implements Trail {
|
||||
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() {
|
||||
this.animationFrameHandler.stop(this);
|
||||
|
||||
if (this.trailElement.parentNode === this.container) {
|
||||
this.container?.removeChild(this.trailElement);
|
||||
}
|
||||
AnimationController.cancel(this.key);
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
startPath(x: number, y: number) {
|
||||
@@ -145,21 +163,25 @@ export class AnimatedTrail implements Trail {
|
||||
|
||||
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;
|
||||
});
|
||||
this.pastTrails = this.pastTrails.filter(
|
||||
(t) =>
|
||||
t.getStrokeOutline(t.options.size / this.app.state.zoom.value)
|
||||
.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();
|
||||
|
||||
this.trailElement.setAttribute("d", svgPaths);
|
||||
|
||||
if (this.trailAnimation) {
|
||||
this.trailElement.setAttribute(
|
||||
"fill",
|
||||
@@ -175,6 +197,8 @@ export class AnimatedTrail implements Trail {
|
||||
(this.options.fill ?? (() => "black"))(this),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -343,7 +343,6 @@ import { ActionManager } from "../actions/manager";
|
||||
import { actions } from "../actions/register";
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { AnimationFrameHandler } from "../animation-frame-handler";
|
||||
import {
|
||||
getDefaultAppState,
|
||||
isEraserActive,
|
||||
@@ -417,7 +416,7 @@ import {
|
||||
setCursorForShape,
|
||||
} from "../cursor";
|
||||
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
|
||||
import { LaserTrails } from "../laser-trails";
|
||||
import { LaserTrails } from "../laserTrails";
|
||||
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
|
||||
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
||||
@@ -703,11 +702,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
previousPointerMoveCoords: { x: number; y: number } | null = null;
|
||||
lastViewportPosition = { x: 0, y: 0 };
|
||||
|
||||
animationFrameHandler = new AnimationFrameHandler();
|
||||
|
||||
laserTrails = new LaserTrails(this.animationFrameHandler, this);
|
||||
eraserTrail = new EraserTrail(this.animationFrameHandler, this);
|
||||
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
|
||||
laserTrails = new LaserTrails(this);
|
||||
eraserTrail = new EraserTrail(this);
|
||||
lassoTrail = new LassoTrail(this);
|
||||
|
||||
onChangeEmitter = new Emitter<
|
||||
[
|
||||
@@ -4615,6 +4612,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (collaborators) {
|
||||
this.laserTrails.updateCollabTrails(collaborators);
|
||||
this.setState({ collaborators });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
|
||||
import "./SVGLayer.scss";
|
||||
|
||||
import type { Trail } from "../animated-trail";
|
||||
import type { Trail } from "../animatedTrail";
|
||||
|
||||
type SVGLayerProps = {
|
||||
trails: Trail[];
|
||||
|
||||
@@ -33,9 +33,7 @@ import type { Bounds } from "@excalidraw/common";
|
||||
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { AnimatedTrail } from "../animated-trail";
|
||||
|
||||
import type { AnimationFrameHandler } from "../animation-frame-handler";
|
||||
import { AnimatedTrail } from "../animatedTrail";
|
||||
|
||||
import type App from "../components/App";
|
||||
|
||||
@@ -43,8 +41,8 @@ export class EraserTrail extends AnimatedTrail {
|
||||
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
|
||||
private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
|
||||
|
||||
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
|
||||
super(animationFrameHandler, app, {
|
||||
constructor(app: App) {
|
||||
super(app, {
|
||||
streamline: 0.2,
|
||||
size: 5,
|
||||
keepHead: true,
|
||||
|
||||
@@ -2,27 +2,20 @@ import { DEFAULT_LASER_COLOR, easeOut } from "@excalidraw/common";
|
||||
|
||||
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||
|
||||
import { AnimatedTrail } from "./animated-trail";
|
||||
import { AnimatedTrail } from "./animatedTrail";
|
||||
import { getClientColor } from "./clients";
|
||||
|
||||
import type { Trail } from "./animated-trail";
|
||||
import type { AnimationFrameHandler } from "./animation-frame-handler";
|
||||
import type { Trail } from "./animatedTrail";
|
||||
import type App from "./components/App";
|
||||
import type { SocketId } from "./types";
|
||||
|
||||
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, {
|
||||
constructor(private app: App) {
|
||||
this.localTrail = new AnimatedTrail(app, {
|
||||
...this.getTrailOptions(),
|
||||
fill: () => DEFAULT_LASER_COLOR,
|
||||
});
|
||||
@@ -63,30 +56,45 @@ export class LaserTrails implements Trail {
|
||||
|
||||
start(container: SVGSVGElement) {
|
||||
this.container = container;
|
||||
|
||||
this.animationFrameHandler.start(this);
|
||||
this.localTrail.start(container);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.animationFrameHandler.stop(this);
|
||||
this.localTrail.stop();
|
||||
this.stopCollabTrails();
|
||||
this.container = undefined;
|
||||
}
|
||||
|
||||
onFrame() {
|
||||
this.updateCollabTrails();
|
||||
private stopCollabTrails(collaborators?: App["state"]["collaborators"]) {
|
||||
for (const [key, trail] of this.collabTrails) {
|
||||
const collaborator = collaborators?.get(key);
|
||||
|
||||
if (!collaborator) {
|
||||
trail.stop();
|
||||
this.collabTrails.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateCollabTrails() {
|
||||
if (!this.container || this.app.state.collaborators.size === 0) {
|
||||
updateCollabTrails(collaborators: App["state"]["collaborators"]) {
|
||||
this.stopCollabTrails(collaborators);
|
||||
|
||||
if (!this.container || collaborators.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, collaborator] of this.app.state.collaborators.entries()) {
|
||||
let trail!: AnimatedTrail;
|
||||
for (const [key, collaborator] of collaborators.entries()) {
|
||||
// Current user has their own trail drawn via localTrail
|
||||
if (collaborator.isCurrentUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.collabTrails.has(key)) {
|
||||
trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
|
||||
// IDEA: Use the collaborator pointer coordinates to trace out the
|
||||
// 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(),
|
||||
fill: () =>
|
||||
collaborator.pointer?.laserColor ||
|
||||
@@ -95,36 +103,33 @@ export class LaserTrails implements Trail {
|
||||
trail.start(this.container);
|
||||
|
||||
this.collabTrails.set(key, trail);
|
||||
} else {
|
||||
trail = this.collabTrails.get(key)!;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (
|
||||
collaborator.button === "down" &&
|
||||
trail.hasCurrentTrail &&
|
||||
!trail.hasLastPoint(collaborator.pointer.x, collaborator.pointer.y)
|
||||
) {
|
||||
// Add only original points
|
||||
const lastPointOriginal = !trail.hasLastPoint(
|
||||
collaborator.pointer.x,
|
||||
collaborator.pointer.y,
|
||||
);
|
||||
if (buttonDown && lastPointOriginal) {
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,7 @@ import type {
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { type AnimationFrameHandler } from "../animation-frame-handler";
|
||||
|
||||
import { AnimatedTrail } from "../animated-trail";
|
||||
import { AnimatedTrail } from "../animatedTrail";
|
||||
|
||||
import { getLassoSelectedElementIds } from "./utils";
|
||||
|
||||
@@ -47,8 +45,8 @@ export class LassoTrail extends AnimatedTrail {
|
||||
private canvasTranslate: CanvasTranslate | null = null;
|
||||
private keepPreviousSelection: boolean = false;
|
||||
|
||||
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
|
||||
super(animationFrameHandler, app, {
|
||||
constructor(app: App) {
|
||||
super(app, {
|
||||
animateTrail: true,
|
||||
streamline: 0.4,
|
||||
sizeMapping: (c) => {
|
||||
|
||||
@@ -6,7 +6,10 @@ export type Animation<R extends object> = (params: {
|
||||
}) => R | null | undefined;
|
||||
|
||||
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<
|
||||
string,
|
||||
{
|
||||
@@ -17,6 +20,10 @@ export class AnimationController {
|
||||
>();
|
||||
|
||||
static start<R extends object>(key: string, animation: Animation<R>) {
|
||||
if (AnimationController.animations.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialState = animation({
|
||||
deltaTime: 0,
|
||||
state: undefined,
|
||||
@@ -29,19 +36,54 @@ export class AnimationController {
|
||||
state: initialState,
|
||||
});
|
||||
|
||||
if (!AnimationController.isRunning) {
|
||||
AnimationController.isRunning = true;
|
||||
|
||||
if (isRenderThrottlingEnabled()) {
|
||||
requestAnimationFrame(AnimationController.tick);
|
||||
} else {
|
||||
setTimeout(AnimationController.tick, 0);
|
||||
}
|
||||
}
|
||||
AnimationController.scheduleNextFrame();
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
AnimationController.scheduledFrame = null;
|
||||
|
||||
if (AnimationController.animations.size > 0) {
|
||||
for (const [key, animation] of AnimationController.animations) {
|
||||
const now = performance.now();
|
||||
@@ -56,8 +98,7 @@ export class AnimationController {
|
||||
if (!state) {
|
||||
AnimationController.animations.delete(key);
|
||||
|
||||
if (AnimationController.animations.size === 0) {
|
||||
AnimationController.isRunning = false;
|
||||
if (AnimationController.cancelScheduledFrameIfIdle()) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -66,11 +107,11 @@ export class AnimationController {
|
||||
}
|
||||
}
|
||||
|
||||
if (isRenderThrottlingEnabled()) {
|
||||
requestAnimationFrame(AnimationController.tick);
|
||||
} else {
|
||||
setTimeout(AnimationController.tick, 0);
|
||||
if (AnimationController.cancelScheduledFrameIfIdle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
AnimationController.scheduleNextFrame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,5 +121,6 @@ export class AnimationController {
|
||||
|
||||
static cancel(key: string) {
|
||||
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/>", () => {
|
||||
beforeEach(async () => {
|
||||
await render(
|
||||
@@ -99,7 +119,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
|
||||
it("should open mermaid popup when active tool is mermaid", async () => {
|
||||
const dialog = document.querySelector(".ttd-dialog")!;
|
||||
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 () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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`] = `
|
||||
"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);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { API } from "./helpers/api";
|
||||
import { Pointer } from "./helpers/ui";
|
||||
import { act, GlobalTestState, render, waitFor } from "./test-utils";
|
||||
|
||||
import type { ExcalidrawProps } from "../types";
|
||||
import type { Collaborator, ExcalidrawProps, SocketId } from "../types";
|
||||
|
||||
describe("laser tool interactions", () => {
|
||||
const h = window.h;
|
||||
@@ -128,4 +128,36 @@ describe("laser tool interactions", () => {
|
||||
expect(h.state.scrollY).toBe(initialScrollY);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user