diff --git a/packages/core/src/runtime/adapters/motion.test.ts b/packages/core/src/runtime/adapters/motion.test.ts new file mode 100644 index 000000000..ecd632d8c --- /dev/null +++ b/packages/core/src/runtime/adapters/motion.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createMotionAdapter } from "./motion"; + +const motionWindow = window as Window & { + __hfMotion?: unknown[]; +}; + +function createMotionInstance(opts?: { duration?: number }) { + let currentTime = 0; + return { + get time() { + return currentTime; + }, + set time(t: number) { + currentTime = t; + }, + _setTimeSpy: vi.fn((t: number) => { + currentTime = t; + }), + pause: vi.fn(), + play: vi.fn(), + stop: vi.fn(), + duration: opts?.duration ?? 2, + }; +} + +function createSeekTracker() { + const instance = createMotionInstance(); + const proxy = new Proxy(instance, { + set(target, prop, value) { + if (prop === "time") { + target._setTimeSpy(value); + target.time = value; + return true; + } + return Reflect.set(target, prop, value); + }, + }); + return { instance, proxy }; +} + +describe("motion adapter", () => { + beforeEach(() => { + delete motionWindow.__hfMotion; + }); + + afterEach(() => { + delete motionWindow.__hfMotion; + }); + + it("has correct name", () => { + expect(createMotionAdapter().name).toBe("motion"); + }); + + describe("discover", () => { + it("does not throw", () => { + const adapter = createMotionAdapter(); + expect(() => adapter.discover()).not.toThrow(); + }); + }); + + describe("seek", () => { + it("sets .time in seconds", () => { + const { instance, proxy } = createSeekTracker(); + motionWindow.__hfMotion = [proxy]; + const adapter = createMotionAdapter(); + adapter.seek({ time: 1.5 }); + expect(instance._setTimeSpy).toHaveBeenCalledWith(1.5); + }); + + it("clamps negative time to 0", () => { + const { instance, proxy } = createSeekTracker(); + motionWindow.__hfMotion = [proxy]; + const adapter = createMotionAdapter(); + adapter.seek({ time: -3 }); + expect(instance._setTimeSpy).toHaveBeenCalledWith(0); + }); + + it("does nothing with no instances", () => { + const adapter = createMotionAdapter(); + expect(() => adapter.seek({ time: 1 })).not.toThrow(); + }); + + it("seeks multiple instances", () => { + const a = createSeekTracker(); + const b = createSeekTracker(); + motionWindow.__hfMotion = [a.proxy, b.proxy]; + const adapter = createMotionAdapter(); + adapter.seek({ time: 2.5 }); + expect(a.instance._setTimeSpy).toHaveBeenCalledWith(2.5); + expect(b.instance._setTimeSpy).toHaveBeenCalledWith(2.5); + }); + + it("continues if one instance throws", () => { + const bad = { + get time() { + return 0; + }, + set time(_: number) { + throw new Error("boom"); + }, + pause: vi.fn(), + play: vi.fn(), + }; + const good = createSeekTracker(); + motionWindow.__hfMotion = [bad, good.proxy]; + const adapter = createMotionAdapter(); + adapter.seek({ time: 1 }); + expect(good.instance._setTimeSpy).toHaveBeenCalledWith(1); + }); + }); + + describe("pause", () => { + it("pauses all instances", () => { + const a = createMotionInstance(); + const b = createMotionInstance(); + motionWindow.__hfMotion = [a, b]; + const adapter = createMotionAdapter(); + adapter.pause(); + expect(a.pause).toHaveBeenCalled(); + expect(b.pause).toHaveBeenCalled(); + }); + + it("does nothing with no instances", () => { + const adapter = createMotionAdapter(); + expect(() => adapter.pause()).not.toThrow(); + }); + }); + + describe("play", () => { + it("plays all instances", () => { + const a = createMotionInstance(); + motionWindow.__hfMotion = [a]; + const adapter = createMotionAdapter(); + adapter.play!(); + expect(a.play).toHaveBeenCalled(); + }); + }); + + describe("revert", () => { + it("does not throw", () => { + const adapter = createMotionAdapter(); + expect(() => adapter.revert!()).not.toThrow(); + }); + }); +}); diff --git a/packages/core/src/runtime/adapters/motion.ts b/packages/core/src/runtime/adapters/motion.ts new file mode 100644 index 000000000..3b91d1c9d --- /dev/null +++ b/packages/core/src/runtime/adapters/motion.ts @@ -0,0 +1,116 @@ +import type { RuntimeDeterministicAdapter } from "../types"; + +/** + * Motion adapter for HyperFrames + * + * Supports Motion (motion.dev) — the framework-agnostic library from + * the creators of Framer Motion. Uses the `.time` setter (seconds) + * for frame-accurate seeking. + * + * ## Usage in a composition + * + * ```html + * + * + * ``` + * + * Sequenced animations work the same way: + * + * ```html + * + * ``` + * + * Multiple instances are supported — all are seeked in sync. + */ +export function createMotionAdapter(): RuntimeDeterministicAdapter { + return { + name: "motion", + + discover: () => { + // Motion has no global registry — instances must be manually + // registered on window.__hfMotion by the composition. + }, + + seek: (ctx) => { + const timeSec = Math.max(0, Number(ctx.time) || 0); + const instances = (window as MotionWindow).__hfMotion; + if (!instances || instances.length === 0) return; + + for (const instance of instances) { + try { + if ("time" in instance) { + (instance as MotionAnimationInstance).time = timeSec; + } + } catch { + // ignore per-instance failures + } + } + }, + + pause: () => { + const instances = (window as MotionWindow).__hfMotion; + if (!instances || instances.length === 0) return; + + for (const instance of instances) { + try { + if (typeof instance.pause === "function") { + instance.pause(); + } + } catch { + // ignore + } + } + }, + + play: () => { + const instances = (window as MotionWindow).__hfMotion; + if (!instances || instances.length === 0) return; + + for (const instance of instances) { + try { + if (typeof instance.play === "function") { + instance.play(); + } + } catch { + // ignore + } + } + }, + + revert: () => { + // Don't clear __hfMotion — instances are owned by the composition. + }, + }; +} + +// ── Minimal type shapes (no motion package dependency) ──────────────────────── + +interface MotionAnimationInstance { + time: number; + pause: () => void; + play: () => void; + stop?: () => void; + duration?: number; +} + +interface MotionWindow extends Window { + Motion?: Record; + /** Motion animation instances registered by compositions for the adapter to seek. */ + __hfMotion?: MotionAnimationInstance[]; +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 5c5d8a84d..b21dd266e 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4,6 +4,7 @@ import { createCssAdapter } from "./adapters/css"; import { createGsapAdapter } from "./adapters/gsap"; import { createAnimeJsAdapter } from "./adapters/animejs"; import { createLottieAdapter } from "./adapters/lottie"; +import { createMotionAdapter } from "./adapters/motion"; import { createThreeAdapter } from "./adapters/three"; import { createWaapiAdapter } from "./adapters/waapi"; import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media"; @@ -1592,6 +1593,7 @@ export function initSandboxRuntimeModular(): void { } state.deterministicAdapters = [ + createMotionAdapter(), createWaapiAdapter(), createCssAdapter({ resolveStartSeconds: (element) => resolveStartForElement(element, 0), diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index ceb2ece0e..77948fea4 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -43,6 +43,19 @@ declare global { }; }; THREE?: ThreeLike; + /** + * Global Motion instance (set by including the motion.min.js UMD bundle). + */ + Motion?: Record; + /** + * Motion animation instances registered by compositions. + * The adapter seeks all instances via the .time setter (seconds). + * + * Push your animation instance here: + * window.__hfMotion = window.__hfMotion || []; + * window.__hfMotion.push(anim); + */ + __hfMotion?: unknown[]; /** * Global anime.js instance (set by including the anime.iife.min.js script). * The adapter uses `anime.running` for auto-discovery.