diff --git a/src/elements/visual/c2dBase.ts b/src/elements/visual/c2dBase.ts index ce0c47b..091c61c 100644 --- a/src/elements/visual/c2dBase.ts +++ b/src/elements/visual/c2dBase.ts @@ -61,6 +61,10 @@ export class C2DBase extends CustomHTMLElement { return this.#eventProxy; } + remove() { + this.canvas.queueRemoval(this); + } + /** * Scales a vector by the device's pixel ratio. */ diff --git a/src/elements/visual/canvas.ts b/src/elements/visual/canvas.ts index e9696fb..b684a69 100644 --- a/src/elements/visual/canvas.ts +++ b/src/elements/visual/canvas.ts @@ -31,6 +31,7 @@ export class Canvas2DCanvasElement extends c2dStandaloneChildren(C2DBase) { #keyboardTracker = new KeyboardTracker(); #lastFrameTime = -1; #mouseTracker: MouseTracker; + #removalQueue = new Set(); #renderEvents = new Set(); #renderQueued = false; #setAlpha: number | null = null; @@ -250,6 +251,12 @@ export class Canvas2DCanvasElement extends c2dStandaloneChildren(C2DBase) { return this.#mouseTracker; } + queueRemoval(child: HTMLElement) { + this.#removalQueue.add(child); + + this.queueRender(); + } + queueRender() { if (this.#renderQueued || this.#waitFor.size) return; @@ -323,6 +330,19 @@ export class Canvas2DCanvasElement extends c2dStandaloneChildren(C2DBase) { return; } + while (this.#removalQueue.size) { + const next = this.#removalQueue.values().next(); + + if (next.value === undefined) + throw new Error("Found undefined value in canvas's removal queue."); + + const child = next.value; + + this.removeChild(child); + + this.#removalQueue.delete(child); + } + const context = this.#context; context.save(); diff --git a/tests/c2d-bezier.test.ts b/tests/c2d-bezier.test.ts index f687c72..389f460 100644 --- a/tests/c2d-bezier.test.ts +++ b/tests/c2d-bezier.test.ts @@ -12,6 +12,7 @@ import { Canvas2DBezier, Canvas2DShapeBezier, } from "../dist/types/elements/visual/bezier"; +import { testRemoval } from "./testRemoval"; function testControlPoints( setup: ElementTestSetup<{ controlA: Vector2D; controlB: Vector2D }> @@ -169,6 +170,8 @@ describe("c2d-bezier", () => { testFill(setup, "bezierCurveTo"); testShadow(setup, "bezierCurveTo"); + + testRemoval(setup); }); describe("c2d-shape-bezier", () => { @@ -189,4 +192,6 @@ describe("c2d-shape-bezier", () => { testControlPoints(setup); testTransform(setup, 1); + + testRemoval(setup); }); diff --git a/tests/c2d-ellipse.test.ts b/tests/c2d-ellipse.test.ts index 85f7101..a2c18c9 100644 --- a/tests/c2d-ellipse.test.ts +++ b/tests/c2d-ellipse.test.ts @@ -14,6 +14,7 @@ import { Canvas2DEllipse, Canvas2DShapeEllipse, } from "../dist/types/elements/visual/ellipse"; +import { testRemoval } from "./testRemoval"; function testStartEndAngles( setup: ElementTestSetup<{ startAngle: Angle; endAngle: Angle }> @@ -91,6 +92,8 @@ describe("c2d-ellipse", () => { testFill(setup, "ellipse"); testShadow(setup, "ellipse"); + + testRemoval(setup); }); describe("c2d-shape-ellipse", () => { @@ -111,4 +114,6 @@ describe("c2d-shape-ellipse", () => { testTransform(setup, 1); testRectangleBounds(setup, "ellipse", 0.5); + + testRemoval(setup); }); diff --git a/tests/c2d-image.test.ts b/tests/c2d-image.test.ts index c567e84..18abc01 100644 --- a/tests/c2d-image.test.ts +++ b/tests/c2d-image.test.ts @@ -7,6 +7,7 @@ import { testOffset } from "./testOffset"; import { testShadow } from "./testShadow"; import { ElementTestSetup } from "./types"; import { Canvas2DImage } from "../dist/types/elements/visual/image"; +import { testRemoval } from "./testRemoval"; describe("c2d-image", () => { mockMatchMedia(); @@ -91,4 +92,6 @@ describe("c2d-image", () => { testRectangleBounds(setup, "drawImage", 1, 3, 4); testShadow(setup, "drawImage"); + + testRemoval(setup); }); diff --git a/tests/c2d-line.test.ts b/tests/c2d-line.test.ts index 4465aaa..fb3a684 100644 --- a/tests/c2d-line.test.ts +++ b/tests/c2d-line.test.ts @@ -11,6 +11,7 @@ import { Canvas2DLine, Canvas2DShapeLine, } from "../dist/types/elements/visual/line"; +import { testRemoval } from "./testRemoval"; function testTo(setup: ElementTestSetup<{ to: Vector2D }>) { describe("to", () => { @@ -108,6 +109,8 @@ describe("c2d-line", () => { testStroke(setup, "lineTo"); testShadow(setup, "lineTo"); + + testRemoval(setup); }); describe("c2d-shape-line", () => { @@ -126,4 +129,6 @@ describe("c2d-shape-line", () => { testTo(setup); testTransform(setup, 1); + + testRemoval(setup); }); diff --git a/tests/c2d-rectangle.test.ts b/tests/c2d-rectangle.test.ts index 8739aad..c4ea5c9 100644 --- a/tests/c2d-rectangle.test.ts +++ b/tests/c2d-rectangle.test.ts @@ -14,6 +14,7 @@ import { Canvas2DRectangle, Canvas2DShapeRectangle, } from "../dist/types/elements/visual/rectangle"; +import { testRemoval } from "./testRemoval"; function testBorderRadius( setup: ElementTestSetup<{ @@ -209,6 +210,8 @@ describe("c2d-rectangle", () => { testFill(setup, "rect"); testShadow(setup, "rect"); + + testRemoval(setup); }); describe("c2d-shape-rectangle", () => { @@ -233,4 +236,6 @@ describe("c2d-shape-rectangle", () => { testBorderRadius(setup); testRectangleBounds(setup, "rect"); + + testRemoval(setup); }); diff --git a/tests/c2d-shape.test.ts b/tests/c2d-shape.test.ts index 650703e..7239391 100644 --- a/tests/c2d-shape.test.ts +++ b/tests/c2d-shape.test.ts @@ -10,6 +10,7 @@ import { waitFor } from "@testing-library/dom"; import { testShadow } from "./testShadow"; import { ElementTestSetup } from "./types"; import { Canvas2DShape } from "../dist/types/elements/visual/shape"; +import { testRemoval } from "./testRemoval"; describe("c2d-shape", () => { setupJestCanvasMock(); @@ -73,4 +74,6 @@ describe("c2d-shape", () => { testStroke(setup, "moveTo"); testShadow(setup, "moveTo"); + + testRemoval(setup); }); diff --git a/tests/c2d-text.test.ts b/tests/c2d-text.test.ts index 8418a55..d85bce4 100644 --- a/tests/c2d-text.test.ts +++ b/tests/c2d-text.test.ts @@ -8,6 +8,7 @@ import { waitFor } from "@testing-library/dom"; import { testShadow } from "./testShadow"; import { ElementTestSetup } from "./types"; import { Canvas2DText } from "../dist/types/elements/visual/text"; +import { testRemoval } from "./testRemoval"; describe("c2d-text", () => { setupJestCanvasMock(); @@ -130,4 +131,6 @@ describe("c2d-text", () => { testFill(setup, "fillText", false); testShadow(setup, "fillText"); + + testRemoval(setup); }); diff --git a/tests/c2d-video.test.ts b/tests/c2d-video.test.ts index d30c9cb..239338b 100644 --- a/tests/c2d-video.test.ts +++ b/tests/c2d-video.test.ts @@ -9,6 +9,7 @@ import { waitFor } from "@testing-library/dom"; import { testShadow } from "./testShadow"; import { ElementTestSetup } from "./types"; import { Canvas2DVideo } from "../dist/types/elements/visual/video"; +import { testRemoval } from "./testRemoval"; describe("c2d-video", () => { mockMatchMedia(); @@ -145,4 +146,6 @@ describe("c2d-video", () => { }); testShadow(setup, "drawImage"); + + testRemoval(setup); }); diff --git a/tests/testRemoval.ts b/tests/testRemoval.ts new file mode 100644 index 0000000..697f7c2 --- /dev/null +++ b/tests/testRemoval.ts @@ -0,0 +1,29 @@ +import { jest } from "@jest/globals"; +import { waitFor } from "@testing-library/dom"; +import { ElementTestSetup } from "./types"; + +export function testRemoval(setup: ElementTestSetup<{}>) { + test("Queues canvas render when removed", async () => { + const { canvas, element, teardown } = setup(); + + const queueRemoval = jest.spyOn(canvas, "queueRemoval"); + + const queueRender = jest.spyOn(canvas, "queueRender"); + + expect(queueRemoval).not.toHaveBeenCalled(); + + expect(queueRender).not.toHaveBeenCalled(); + + expect(canvas.contains(element)).toBe(true); + + element.remove(); + + expect(queueRemoval).toHaveBeenCalled(); + + expect(queueRender).toHaveBeenCalled(); + + await waitFor(() => !canvas.contains(element)); + + teardown(); + }); +}