diff --git a/.changeset/element-animate-papi.md b/.changeset/element-animate-papi.md new file mode 100644 index 0000000000..948ce48a0a --- /dev/null +++ b/.changeset/element-animate-papi.md @@ -0,0 +1,6 @@ +--- +"@lynx-js/web-constants": patch +"@lynx-js/testing-environment": patch +--- + +Implement `__ElementAnimate` PAPI for web platform animation lifecycle diff --git a/packages/repl/src/examples/interactivity-element-animate/main-thread.js b/packages/repl/src/examples/interactivity-element-animate/main-thread.js new file mode 100644 index 0000000000..c13e1a98bd --- /dev/null +++ b/packages/repl/src/examples/interactivity-element-animate/main-thread.js @@ -0,0 +1,142 @@ +// Demonstrates: __ElementAnimate — Web Animations API bridge +// +// __ElementAnimate(element, [operation, name, keyframes?, options?]) +// operation: 0=START, 1=PLAY, 2=PAUSE, 3=CANCEL, 4=FINISH +// +// Tap the buttons to control the animation. + +globalThis.renderPage = function renderPage() { + const page = __CreatePage('page', 0); + const container = __CreateView(0); + __AppendElement(page, container); + __SetInlineStyles( + container, + 'padding:40px; align-items:center; gap:20px;', + ); + + const title = __CreateText(0); + __AppendElement(container, title); + __AppendElement(title, __CreateRawText('__ElementAnimate')); + __SetInlineStyles( + title, + 'font-size:18px; font-weight:700; margin-bottom:4px;', + ); + + const subtitle = __CreateText(0); + __AppendElement(container, subtitle); + __AppendElement( + subtitle, + __CreateRawText('Controls the Web Animations API from the main thread'), + ); + __SetInlineStyles( + subtitle, + 'font-size:13px; color:#888; margin-bottom:16px;', + ); + + // Animated box + const box = __CreateView(0); + __AppendElement(container, box); + __SetInlineStyles( + box, + 'width:120px; height:120px; background-color:#3b82f6; border-radius:16px; margin-bottom:16px;', + ); + + // Start a looping pulse animation + const animName = 'demo-pulse'; + __ElementAnimate(box, [ + 0, // START + animName, + [ + { opacity: 1, transform: 'scale(1)' }, + { opacity: 0.5, transform: 'scale(0.8)' }, + { opacity: 1, transform: 'scale(1)' }, + ], + { + duration: 1500, + iterationCount: 'infinite', + timingFunction: 'ease-in-out', + }, + ]); + + // Status text + const statusRaw = __CreateRawText('Playing'); + const statusLabel = __CreateText(0); + __AppendElement(container, statusLabel); + __AppendElement(statusLabel, statusRaw); + __SetInlineStyles( + statusLabel, + 'font-size:14px; color:#666; margin-bottom:16px;', + ); + + // Worklet handler router + const handlers = {}; + globalThis.runWorklet = function(handlerId, args) { + if (handlers[handlerId]) handlers[handlerId](...args); + }; + + // Button row + const btnRow = __CreateView(0); + __AppendElement(container, btnRow); + __SetInlineStyles(btnRow, 'flex-direction:row; gap:10px;'); + + function makeButton(label, handlerId) { + const btn = __CreateView(0); + __AppendElement(btnRow, btn); + __SetInlineStyles( + btn, + 'padding:8px 18px; background-color:#1e293b; border-radius:6px; align-items:center; justify-content:center;', + ); + const txt = __CreateText(0); + __AppendElement(btn, txt); + __AppendElement(txt, __CreateRawText(label)); + __SetInlineStyles(txt, 'color:#fff; font-size:13px;'); + __AddEvent(btn, 'bindEvent', 'tap', { + type: 'worklet', + value: handlerId, + }); + } + + handlers['onPause'] = function() { + __ElementAnimate(box, [2, /* PAUSE */ animName]); + __SetAttribute(statusRaw, 'text', 'Paused'); + __FlushElementTree(); + }; + + handlers['onPlay'] = function() { + __ElementAnimate(box, [1, /* PLAY */ animName]); + __SetAttribute(statusRaw, 'text', 'Playing'); + __FlushElementTree(); + }; + + handlers['onCancel'] = function() { + __ElementAnimate(box, [3, /* CANCEL */ animName]); + __SetAttribute(statusRaw, 'text', 'Cancelled'); + __FlushElementTree(); + }; + + handlers['onRestart'] = function() { + __ElementAnimate(box, [ + 0, /* START */ + animName, + [ + { opacity: 1, transform: 'scale(1)' }, + { opacity: 0.5, transform: 'scale(0.8)' }, + { opacity: 1, transform: 'scale(1)' }, + ], + { + duration: 1500, + iterationCount: 'infinite', + timingFunction: 'ease-in-out', + }, + ]); + __SetAttribute(statusRaw, 'text', 'Playing'); + __FlushElementTree(); + }; + + makeButton('Pause', 'onPause'); + makeButton('Play', 'onPlay'); + makeButton('Cancel', 'onCancel'); + makeButton('Restart', 'onRestart'); + + __FlushElementTree(); +}; diff --git a/packages/repl/src/samples.ts b/packages/repl/src/samples.ts index ad8dbe16e7..7560e16928 100644 --- a/packages/repl/src/samples.ts +++ b/packages/repl/src/samples.ts @@ -21,6 +21,7 @@ import interactivityEventMtMain from './examples/interactivity-event-main-thread import interactivityRefsBgMain from './examples/interactivity-refs-bg/main-thread.js?raw'; import interactivityRefsBgBg from './examples/interactivity-refs-bg/background.js?raw'; import interactivityRefsMtMain from './examples/interactivity-refs-main-thread/main-thread.js?raw'; +import interactivityElementAnimateMain from './examples/interactivity-element-animate/main-thread.js?raw'; // Attributes & Data import attributesSetAndGet from './examples/attributes-set-and-get/main-thread.js?raw'; @@ -163,6 +164,13 @@ export const samples: Sample[] = [ background: '', css: '', }, + { + name: 'Element Animate', + category: 'Interactivity', + mainThread: interactivityElementAnimateMain, + background: '', + css: '', + }, // ── Attributes & Data ────────────────────────────────────────────────── { diff --git a/packages/testing-library/testing-environment/etc/testing-environment.api.md b/packages/testing-library/testing-environment/etc/testing-environment.api.md index 1b47bf4d0e..5e78499041 100644 --- a/packages/testing-library/testing-environment/etc/testing-environment.api.md +++ b/packages/testing-library/testing-environment/etc/testing-environment.api.md @@ -58,6 +58,13 @@ export const initElementTree: () => { __CreateList(parentComponentUniqueId: number, componentAtIndex: any, enqueueComponent: any): LynxElement; __GetTag(ele: LynxElement): string; __GetAttributeByName(ele: LynxElement, name: string): string | null; + animationMap: Map; + __ElementAnimate(element: LynxElement, args: [number, string, ...any[]]): void; clear(): void; toTree(): LynxElement | undefined; enterListItemAtIndex(e: LynxElement, index: number, ...args: any[]): number; diff --git a/packages/testing-library/testing-environment/src/__tests__/element-papi.test.js b/packages/testing-library/testing-environment/src/__tests__/element-papi.test.js index 3552053b7b..58cb0b3731 100644 --- a/packages/testing-library/testing-environment/src/__tests__/element-papi.test.js +++ b/packages/testing-library/testing-environment/src/__tests__/element-papi.test.js @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; beforeEach(() => { lynxTestingEnv.reset(); @@ -58,4 +58,54 @@ describe('element PAPI', () => { `); }); + + it('__ElementAnimate START should create animation', () => { + const view = __CreateView(0); + __ElementAnimate(view, [0, /* START */ 'anim-1', [{ opacity: 0 }, { + opacity: 1, + }], { duration: 1000 }]); + expect(elementTree.animationMap.get('anim-1')).toEqual({ + element: view, + state: 'running', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + options: { duration: 1000 }, + }); + }); + + it('__ElementAnimate PAUSE should pause animation', () => { + const view = __CreateView(0); + __ElementAnimate(view, [0, /* START */ 'anim-2', [{ opacity: 0 }, { + opacity: 1, + }], { duration: 500 }]); + __ElementAnimate(view, [2, /* PAUSE */ 'anim-2']); + expect(elementTree.animationMap.get('anim-2').state).toBe('paused'); + }); + + it('__ElementAnimate PLAY should resume animation', () => { + const view = __CreateView(0); + __ElementAnimate(view, [0, /* START */ 'anim-3', [{ opacity: 0 }, { + opacity: 1, + }], { duration: 500 }]); + __ElementAnimate(view, [2, /* PAUSE */ 'anim-3']); + __ElementAnimate(view, [1, /* PLAY */ 'anim-3']); + expect(elementTree.animationMap.get('anim-3').state).toBe('running'); + }); + + it('__ElementAnimate CANCEL should remove animation', () => { + const view = __CreateView(0); + __ElementAnimate(view, [0, /* START */ 'anim-4', [{ opacity: 0 }, { + opacity: 1, + }], { duration: 500 }]); + __ElementAnimate(view, [3, /* CANCEL */ 'anim-4']); + expect(elementTree.animationMap.has('anim-4')).toBe(false); + }); + + it('__ElementAnimate FINISH should mark animation finished', () => { + const view = __CreateView(0); + __ElementAnimate(view, [0, /* START */ 'anim-5', [{ opacity: 0 }, { + opacity: 1, + }], { duration: 500 }]); + __ElementAnimate(view, [4, /* FINISH */ 'anim-5']); + expect(elementTree.animationMap.get('anim-5').state).toBe('finished'); + }); }); diff --git a/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts b/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts index c7f87af276..5503194804 100644 --- a/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts +++ b/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts @@ -418,6 +418,51 @@ export const initElementTree = () => { return ele.getAttribute(name); } + /** @internal */ + animationMap = new Map< + string, + { element: LynxElement; state: string; keyframes?: any[]; options?: any } + >(); + + __ElementAnimate( + element: LynxElement, + args: [number, string, ...any[]], + ) { + const [operation, name] = args; + switch (operation) { + case 0 /* START */: { + const keyframes = args[2]; + const options = args[3]; + this.animationMap.set(name, { + element, + state: 'running', + keyframes, + options, + }); + break; + } + case 1 /* PLAY */: { + const anim = this.animationMap.get(name); + if (anim) anim.state = 'running'; + break; + } + case 2 /* PAUSE */: { + const anim = this.animationMap.get(name); + if (anim) anim.state = 'paused'; + break; + } + case 3 /* CANCEL */: { + this.animationMap.delete(name); + break; + } + case 4 /* FINISH */: { + const anim = this.animationMap.get(name); + if (anim) anim.state = 'finished'; + break; + } + } + } + clear() { this.root = undefined; } diff --git a/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts b/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts index c839f9f4a0..579f3eef89 100644 --- a/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts +++ b/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts @@ -328,6 +328,18 @@ export type QuerySelectorPAPI = ( selector: string, ) => unknown; +export type ElementAnimatePAPI = ( + element: HTMLElement, + args: + | [ + operation: 0, + name: string, + keyframes: Record[], + options?: Record, + ] + | [operation: 1 | 2 | 3 | 4, name: string], +) => void; + export interface ElementPAPIs { __ElementFromBinary: ElementFromBinaryPAPI; @@ -394,6 +406,7 @@ export interface ElementPAPIs { ) => void; __InvokeUIMethod: InvokeUIMethodPAPI; __QuerySelector: QuerySelectorPAPI; + __ElementAnimate: ElementAnimatePAPI; } export interface MainThreadGlobalThis extends ElementPAPIs { diff --git a/packages/web-platform/web-core-wasm-e2e/tests/web-core.test.ts b/packages/web-platform/web-core-wasm-e2e/tests/web-core.test.ts index edaa15c4ee..7e95a6c1d9 100644 --- a/packages/web-platform/web-core-wasm-e2e/tests/web-core.test.ts +++ b/packages/web-platform/web-core-wasm-e2e/tests/web-core.test.ts @@ -595,6 +595,194 @@ test.describe('web core tests', () => { await wait(500); expect(success).toBeTruthy(); }); + test('__ElementAnimate START creates animation', async ({ page, browserName }) => { + // firefox not support + test.skip(browserName === 'firefox'); + await goto(page); + await page.evaluate(() => { + const root = globalThis.runtime.__CreatePage('0', '0', {}); + const element = globalThis.runtime.__CreateElement('view', '0', {}); + globalThis.runtime.__AppendElement(root, element); + globalThis.runtime.__FlushElementTree(); + globalThis.runtime.__ElementAnimate(element, [ + 0, /* START */ + 'test-anim-1', + [{ opacity: '0' }, { opacity: '1' }], + { duration: 1000, fillMode: 'forwards' }, + ]); + }); + await wait(100); + const animations = await page.evaluate(() => { + const lynxView = document.querySelector('lynx-view') as any; + const root = lynxView?.shadowRoot ?? lynxView; + const el = root?.querySelector('x-view'); + return el?.getAnimations().length ?? 0; + }); + expect(animations).toBe(1); + }); + + test('__ElementAnimate PAUSE pauses animation', async ({ page, browserName }) => { + // firefox not support + test.skip(browserName === 'firefox'); + await goto(page); + await page.evaluate(() => { + const root = globalThis.runtime.__CreatePage('0', '0', {}); + const element = globalThis.runtime.__CreateElement('view', '0', {}); + globalThis.runtime.__AppendElement(root, element); + globalThis.runtime.__FlushElementTree(); + globalThis.runtime.__ElementAnimate(element, [ + 0, /* START */ + 'test-anim-pause', + [{ opacity: '0' }, { opacity: '1' }], + { duration: 5000 }, + ]); + globalThis.runtime.__ElementAnimate(element, [ + 2, + /* PAUSE */ 'test-anim-pause', + ]); + }); + await wait(100); + const playState = await page.evaluate(() => { + const lynxView = document.querySelector('lynx-view') as any; + const root = lynxView?.shadowRoot ?? lynxView; + const el = root?.querySelector('x-view'); + const anims = el?.getAnimations() ?? []; + return anims[0]?.playState; + }); + expect(playState).toBe('paused'); + }); + + test('__ElementAnimate PLAY resumes paused animation', async ({ page, browserName }) => { + // firefox not support + test.skip(browserName === 'firefox'); + await goto(page); + await page.evaluate(() => { + const root = globalThis.runtime.__CreatePage('0', '0', {}); + const element = globalThis.runtime.__CreateElement('view', '0', {}); + globalThis.runtime.__AppendElement(root, element); + globalThis.runtime.__FlushElementTree(); + globalThis.runtime.__ElementAnimate(element, [ + 0, /* START */ + 'test-anim-play', + [{ opacity: '0' }, { opacity: '1' }], + { duration: 5000 }, + ]); + globalThis.runtime.__ElementAnimate(element, [ + 2, + /* PAUSE */ 'test-anim-play', + ]); + globalThis.runtime.__ElementAnimate(element, [ + 1, + /* PLAY */ 'test-anim-play', + ]); + }); + await wait(100); + const playState = await page.evaluate(() => { + const lynxView = document.querySelector('lynx-view') as any; + const root = lynxView?.shadowRoot ?? lynxView; + const el = root?.querySelector('x-view'); + const anims = el?.getAnimations() ?? []; + return anims[0]?.playState; + }); + expect(playState).toBe('running'); + }); + + test('__ElementAnimate CANCEL removes animation', async ({ page, browserName }) => { + // firefox not support + test.skip(browserName === 'firefox'); + await goto(page); + await page.evaluate(() => { + const root = globalThis.runtime.__CreatePage('0', '0', {}); + const element = globalThis.runtime.__CreateElement('view', '0', {}); + globalThis.runtime.__AppendElement(root, element); + globalThis.runtime.__FlushElementTree(); + globalThis.runtime.__ElementAnimate(element, [ + 0, /* START */ + 'test-anim-cancel', + [{ opacity: '0' }, { opacity: '1' }], + { duration: 5000, fillMode: 'forwards' }, + ]); + globalThis.runtime.__ElementAnimate(element, [ + 3, + /* CANCEL */ 'test-anim-cancel', + ]); + }); + await wait(100); + const animations = await page.evaluate(() => { + const lynxView = document.querySelector('lynx-view') as any; + const root = lynxView?.shadowRoot ?? lynxView; + const el = root?.querySelector('x-view'); + return el?.getAnimations().length ?? 0; + }); + expect(animations).toBe(0); + }); + + test('__ElementAnimate FINISH finishes animation', async ({ page, browserName }) => { + // firefox not support + test.skip(browserName === 'firefox'); + await goto(page); + await page.evaluate(() => { + const root = globalThis.runtime.__CreatePage('0', '0', {}); + const element = globalThis.runtime.__CreateElement('view', '0', {}); + globalThis.runtime.__AppendElement(root, element); + globalThis.runtime.__FlushElementTree(); + globalThis.runtime.__ElementAnimate(element, [ + 0, /* START */ + 'test-anim-finish', + [{ opacity: '0' }, { opacity: '1' }], + { duration: 5000, fillMode: 'forwards' }, + ]); + globalThis.runtime.__ElementAnimate(element, [ + 4, + /* FINISH */ 'test-anim-finish', + ]); + }); + await wait(100); + const playState = await page.evaluate(() => { + const lynxView = document.querySelector('lynx-view') as any; + const root = lynxView?.shadowRoot ?? lynxView; + const el = root?.querySelector('x-view'); + const anims = el?.getAnimations() ?? []; + return anims[0]?.playState; + }); + expect(playState).toBe('finished'); + }); + + test('__ElementAnimate START replaces existing animation with same name', async ({ page, browserName }) => { + // firefox not support + test.skip(browserName === 'firefox'); + await goto(page); + await page.evaluate(() => { + const root = globalThis.runtime.__CreatePage('0', '0', {}); + const element = globalThis.runtime.__CreateElement('view', '0', {}); + globalThis.runtime.__AppendElement(root, element); + globalThis.runtime.__FlushElementTree(); + // Start first animation + globalThis.runtime.__ElementAnimate(element, [ + 0, /* START */ + 'test-anim-replace', + [{ opacity: '0' }, { opacity: '1' }], + { duration: 5000, fillMode: 'forwards' }, + ]); + // Start second animation with same name — should cancel the first + globalThis.runtime.__ElementAnimate(element, [ + 0, /* START */ + 'test-anim-replace', + [{ transform: 'translateX(0px)' }, { transform: 'translateX(100px)' }], + { duration: 5000, fillMode: 'forwards' }, + ]); + }); + await wait(100); + const animations = await page.evaluate(() => { + const lynxView = document.querySelector('lynx-view') as any; + const root = lynxView?.shadowRoot ?? lynxView; + const el = root?.querySelector('x-view'); + return el?.getAnimations().length ?? 0; + }); + // Only 1 animation should remain (the replaced one was cancelled) + expect(animations).toBe(1); + }); + test('source-map-release', async ({ page, browserName }) => { // firefox not support test.skip(browserName === 'firefox'); diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/elementAPIs/createElementAPI.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/elementAPIs/createElementAPI.ts index c73c3fa3f3..c16929c5ed 100644 --- a/packages/web-platform/web-core-wasm/ts/client/mainthread/elementAPIs/createElementAPI.ts +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/elementAPIs/createElementAPI.ts @@ -1,6 +1,7 @@ import { wasmInstance } from '../../wasm.js'; import { + AnimationOperation, LYNX_TAG_TO_HTML_TAG_MAP, LYNX_TIMING_FLAG_ATTRIBUTE, lynxDefaultDisplayLinearAttribute, @@ -462,6 +463,68 @@ export function createElementAPI( __GetElementUniqueID, __UpdateListCallbacks, __SwapElement, + __ElementAnimate: (() => { + const animationMap = new Map(); + const mapTimingOptions = ( + options?: Record, + ): KeyframeAnimationOptions | undefined => { + if (!options) return undefined; + const result: KeyframeAnimationOptions = {}; + if ('duration' in options) { + result.duration = Number(options['duration']); + } + if ('delay' in options) result.delay = Number(options['delay']); + if ('direction' in options) { + result.direction = options['direction'] as PlaybackDirection; + } + if ('iterationCount' in options) { + result.iterations = options['iterationCount'] === 'infinite' + ? Infinity + : Number(options['iterationCount']); + } + if ('fillMode' in options) { + result.fill = options['fillMode'] as FillMode; + } + if ('timingFunction' in options) { + result.easing = options['timingFunction'] as string; + } + return result; + }; + return (element: HTMLElement, args: any) => { + const [operation, name] = args; + switch (operation) { + case AnimationOperation.START: { + const keyframes = args[2]; + const options = args[3]; + animationMap.get(name)?.cancel(); + const animation = element.animate( + keyframes as Keyframe[], + mapTimingOptions(options), + ); + animation.oncancel = animation.onfinish = () => { + if (animationMap.get(name) === animation) { + animationMap.delete(name); + } + }; + animationMap.set(name, animation); + break; + } + case AnimationOperation.PLAY: + animationMap.get(name)?.play(); + break; + case AnimationOperation.PAUSE: + animationMap.get(name)?.pause(); + break; + case AnimationOperation.CANCEL: + animationMap.get(name)?.cancel(); + animationMap.delete(name); + break; + case AnimationOperation.FINISH: + animationMap.get(name)?.finish(); + break; + } + }; + })(), __FlushElementTree: (_, options) => { const pipelineId = options?.pipelineOptions?.pipelineID; const backgroundThread = mtsBinding.lynxViewInstance.backgroundThread; diff --git a/packages/web-platform/web-core-wasm/ts/server/elementAPIs/createElementAPI.ts b/packages/web-platform/web-core-wasm/ts/server/elementAPIs/createElementAPI.ts index cfd57eb167..8d17c01ac8 100644 --- a/packages/web-platform/web-core-wasm/ts/server/elementAPIs/createElementAPI.ts +++ b/packages/web-platform/web-core-wasm/ts/server/elementAPIs/createElementAPI.ts @@ -410,6 +410,8 @@ export function createElementAPI( } }) as AddInlineStylePAPI, + __ElementAnimate: () => {}, + __FlushElementTree: (() => { if (pageElementId !== undefined) { mtsBinding.ssrResult = wasmContext.generate_html(pageElementId); diff --git a/packages/web-platform/web-core-wasm/ts/types/IElementPAPI.ts b/packages/web-platform/web-core-wasm/ts/types/IElementPAPI.ts index 2322e2c727..e3619ea9dd 100644 --- a/packages/web-platform/web-core-wasm/ts/types/IElementPAPI.ts +++ b/packages/web-platform/web-core-wasm/ts/types/IElementPAPI.ts @@ -283,6 +283,18 @@ export type SetCSSIdPAPI = ( entryName: string | undefined, ) => void; +export type ElementAnimatePAPI = ( + element: HTMLElement, + args: + | [ + operation: 0, + name: string, + keyframes: Record[], + options?: Record, + ] + | [operation: 1 | 2 | 3 | 4, name: string], +) => void; + export type GetPageElementPAPI = () => HTMLElement | undefined; export type MarkTemplateElementPAPI = ( @@ -375,6 +387,7 @@ export interface ElementPAPIs { __SetCSSId: SetCSSIdPAPI; __GetPageElement: GetPageElementPAPI; __GetAttributeByName: GetAttributeByNamePAPI; + __ElementAnimate: ElementAnimatePAPI; __FlushElementTree: ( _subTree?: unknown, options?: FlushElementTreeOptions, diff --git a/packages/web-platform/web-mainthread-apis/ts/createMainThreadGlobalThis.ts b/packages/web-platform/web-mainthread-apis/ts/createMainThreadGlobalThis.ts index 204b86e90f..bbf4f1dd2f 100644 --- a/packages/web-platform/web-mainthread-apis/ts/createMainThreadGlobalThis.ts +++ b/packages/web-platform/web-mainthread-apis/ts/createMainThreadGlobalThis.ts @@ -64,6 +64,8 @@ import { ErrorCode, type QuerySelectorPAPI, type InvokeUIMethodPAPI, + type ElementAnimatePAPI, + AnimationOperation, } from '@lynx-js/web-constants'; import { createMainThreadLynx } from './createMainThreadLynx.js'; import { @@ -781,6 +783,65 @@ export function createMainThreadGlobalThis( return el; }; + const animationMap = new Map(); + + const mapTimingOptions = ( + options?: Record, + ): KeyframeAnimationOptions | undefined => { + if (!options) return undefined; + const result: KeyframeAnimationOptions = {}; + if ('duration' in options) result.duration = Number(options['duration']); + if ('delay' in options) result.delay = Number(options['delay']); + if ('direction' in options) { + result.direction = options['direction'] as PlaybackDirection; + } + if ('iterationCount' in options) { + result.iterations = options['iterationCount'] === 'infinite' + ? Infinity + : Number(options['iterationCount']); + } + if ('fillMode' in options) result.fill = options['fillMode'] as FillMode; + if ('timingFunction' in options) { + result.easing = options['timingFunction'] as string; + } + return result; + }; + + const __ElementAnimate: ElementAnimatePAPI = (element, args) => { + const [operation, name] = args; + switch (operation) { + case AnimationOperation.START: { + const keyframes = args[2]; + const options = args[3]; + animationMap.get(name)?.cancel(); + const animation = element.animate( + keyframes as Keyframe[], + mapTimingOptions(options), + ); + animation.oncancel = animation.onfinish = () => { + if (animationMap.get(name) === animation) { + animationMap.delete(name); + } + }; + animationMap.set(name, animation); + break; + } + case AnimationOperation.PLAY: + animationMap.get(name)?.play(); + break; + case AnimationOperation.PAUSE: + animationMap.get(name)?.pause(); + break; + case AnimationOperation.CANCEL: + animationMap.get(name)?.cancel(); + animationMap.delete(name); + break; + case AnimationOperation.FINISH: + animationMap.get(name)?.finish(); + break; + } + }; + const __GetPageElement: GetPageElementPAPI = () => { return pageElement; }; @@ -951,6 +1012,7 @@ export function createMainThreadGlobalThis( renderPage: undefined, __InvokeUIMethod, __QuerySelector, + __ElementAnimate, }; Object.assign(mtsRealm.globalWindow, mtsGlobalThis); Object.defineProperty(mtsRealm.globalWindow, 'renderPage', {