Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@
"types": "./dist/keyframeDefaults.d.ts",
"environments": ["browser", "bun", "node"]
},
"./html-in-canvas": {
"source": "./src/htmlInCanvas.ts",
"runtime": "./dist/htmlInCanvas.js",
"types": "./dist/htmlInCanvas.d.ts",
"environments": ["browser", "bun", "node"]
},
"./gsap-cdn": {
"source": "./src/gsapCdn.ts",
"runtime": "./dist/gsapCdn.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@
"import": "./src/keyframeDefaults.ts",
"types": "./src/keyframeDefaults.ts"
},
"./html-in-canvas": {
"bun": "./src/htmlInCanvas.ts",
"node": "./dist/htmlInCanvas.js",
"import": "./src/htmlInCanvas.ts",
"types": "./src/htmlInCanvas.ts"
},
"./gsap-cdn": {
"bun": "./src/gsapCdn.ts",
"node": "./dist/gsapCdn.js",
Expand Down Expand Up @@ -614,6 +620,10 @@
"import": "./dist/keyframeDefaults.js",
"types": "./dist/keyframeDefaults.d.ts"
},
"./html-in-canvas": {
"import": "./dist/htmlInCanvas.js",
"types": "./dist/htmlInCanvas.d.ts"
},
"./gsap-cdn": {
"import": "./dist/gsapCdn.js",
"types": "./dist/gsapCdn.d.ts"
Expand Down
107 changes: 107 additions & 0 deletions packages/core/src/htmlInCanvas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import {
htmlInCanvasForm,
markCaptureCanvas,
markCaptured,
unmarkCaptured,
CAPTURE_CANVAS_SELECTOR,
} from "./htmlInCanvas";
import { supportsCaptureMotionBlur } from "./pageMotionBlur";

const canvasProto = HTMLCanvasElement.prototype as unknown as Record<string, unknown>;
const CANVAS_MEMBERS = ["requestPaint", "layoutSubtree", "updateElementGeometry"];

function install(canvasMembers: string[], drawElementImage = true): void {
vi.stubGlobal(
"CanvasRenderingContext2D",
drawElementImage
? class {
drawElementImage(): void {}
}
: class {},
);
for (const name of canvasMembers) canvasProto[name] = () => {};
}

afterEach(() => {
vi.unstubAllGlobals();
for (const name of CANVAS_MEMBERS) delete canvasProto[name];
});

describe("htmlInCanvasForm", () => {
it("is layoutsubtree where the canvas has layoutSubtree, even if it also has updateElementGeometry", () => {
install(["requestPaint", "layoutSubtree", "updateElementGeometry"]);
expect(htmlInCanvasForm()).toBe("layoutsubtree");
});

it("is drawable where layoutSubtree is gone and updateElementGeometry is there", () => {
install(["requestPaint", "updateElementGeometry"]);
expect(htmlInCanvasForm()).toBe("drawable");
});

it("is null when neither form is there", () => {
install(["requestPaint"]);
expect(htmlInCanvasForm()).toBeNull();
});

it("is null without drawElementImage or without requestPaint", () => {
install(["requestPaint", "layoutSubtree"], false);
expect(htmlInCanvasForm()).toBeNull();
vi.unstubAllGlobals();
delete canvasProto.requestPaint;
install(["layoutSubtree"]);
expect(htmlInCanvasForm()).toBeNull();
});
});

describe("supportsCaptureMotionBlur", () => {
it.each([
["old form", ["requestPaint", "layoutSubtree"], true],
["new form", ["requestPaint", "updateElementGeometry"], true],
["API present but no known form", ["requestPaint"], false],
])("%s", (_name, members, expected) => {
install(members);
expect(supportsCaptureMotionBlur()).toBe(expected);
});
});

describe("marking", () => {
it("marks the old form with layoutsubtree and leaves children alone", () => {
const canvas = document.createElement("canvas");
const child = document.createElement("div");
markCaptureCanvas(canvas, "layoutsubtree");
markCaptured(child, "layoutsubtree");
expect(canvas.hasAttribute("layoutsubtree")).toBe(true);
expect(canvas.hasAttribute("content")).toBe(false);
expect(child.hasAttribute("drawable")).toBe(false);
});

it("marks the new form with content=drawable and drawable children, and unmarks them", () => {
const canvas = document.createElement("canvas");
const child = document.createElement("div");
markCaptureCanvas(canvas, "drawable");
markCaptured(child, "drawable");
expect(canvas.getAttribute("content")).toBe("drawable");
expect(canvas.hasAttribute("layoutsubtree")).toBe(false);
expect(child.hasAttribute("drawable")).toBe(true);
unmarkCaptured(child, "drawable");
expect(child.hasAttribute("drawable")).toBe(false);
});

it("finds a nested capture canvas of either form", () => {
for (const mark of [
(c: HTMLElement) => c.setAttribute("layoutsubtree", ""),
(c: HTMLElement) => c.setAttribute("content", "drawable"),
]) {
const root = document.createElement("div");
const canvas = document.createElement("canvas");
mark(canvas);
root.append(canvas);
expect(root.querySelector(CAPTURE_CANVAS_SELECTOR)).toBe(canvas);
}
const plain = document.createElement("div");
plain.append(document.createElement("canvas"));
expect(plain.querySelector(CAPTURE_CANVAS_SELECTOR)).toBeNull();
});
});
29 changes: 29 additions & 0 deletions packages/core/src/htmlInCanvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/** Chrome <=155 marks a capture canvas with `layoutsubtree`; 157+ with `content="drawable"` and `drawable` on each captured child. */
export type HtmlInCanvasForm = "layoutsubtree" | "drawable";

export const CAPTURE_CANVAS_SELECTOR = 'canvas[layoutsubtree], canvas[content="drawable"]';

/** The form this browser's html-in-canvas takes, or null when it has no usable one. */
export function htmlInCanvasForm(win: Window = window): HtmlInCanvasForm | null {
const ctx = (win as Window & { CanvasRenderingContext2D?: { prototype: object } })
.CanvasRenderingContext2D?.prototype;
if (!ctx || !("drawElementImage" in ctx)) return null;
const canvas = win.document.createElement("canvas");
if (!("requestPaint" in canvas)) return null;
// 155 already has updateElementGeometry but still wants layoutsubtree, so this order matters.
if ("layoutSubtree" in canvas) return "layoutsubtree";
return "updateElementGeometry" in canvas ? "drawable" : null;
}

export function markCaptureCanvas(canvas: HTMLCanvasElement, form: HtmlInCanvasForm): void {
if (form === "layoutsubtree") canvas.setAttribute("layoutsubtree", "");
else canvas.setAttribute("content", "drawable");
}

export function markCaptured(el: Element, form: HtmlInCanvasForm): void {
if (form === "drawable") el.setAttribute("drawable", "");
}

export function unmarkCaptured(el: Element, form: HtmlInCanvasForm): void {
if (form === "drawable") el.removeAttribute("drawable");
}
Loading
Loading