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 .changeset/element-animate-papi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@lynx-js/web-constants": patch
"@lynx-js/testing-environment": patch
---

Implement `__ElementAnimate` PAPI for web platform animation lifecycle
Original file line number Diff line number Diff line change
@@ -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();
};
8 changes: 8 additions & 0 deletions packages/repl/src/samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -163,6 +164,13 @@ export const samples: Sample[] = [
background: '',
css: '',
},
{
name: 'Element Animate',
category: 'Interactivity',
mainThread: interactivityElementAnimateMain,
background: '',
css: '',
},

// ── Attributes & Data ──────────────────────────────────────────────────
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {
element: LynxElement;
state: string;
keyframes?: any[];
options?: any;
}>;
__ElementAnimate(element: LynxElement, args: [number, string, ...any[]]): void;
clear(): void;
toTree(): LynxElement | undefined;
enterListItemAtIndex(e: LynxElement, index: number, ...args: any[]): number;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';

beforeEach(() => {
lynxTestingEnv.reset();
Expand Down Expand Up @@ -58,4 +58,54 @@ describe('element PAPI', () => {
</view>
`);
});

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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Comment on lines +432 to +464
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The testing environment hard-codes operation numbers (0..4) inside __ElementAnimate. Since the web platform uses the AnimationOperation enum, importing and using the same enum here will prevent drift when operations are added/renumbered and make the tests more self-documenting.

Copilot uses AI. Check for mistakes.

clear() {
this.root = undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,18 @@ export type QuerySelectorPAPI = (
selector: string,
) => unknown;

export type ElementAnimatePAPI = (
element: HTMLElement,
args:
| [
operation: 0,
name: string,
keyframes: Record<string, string | number>[],
options?: Record<string, string | number>,
]
| [operation: 1 | 2 | 3 | 4, name: string],
) => void;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export interface ElementPAPIs {
__ElementFromBinary: ElementFromBinaryPAPI;

Expand Down Expand Up @@ -394,6 +406,7 @@ export interface ElementPAPIs {
) => void;
__InvokeUIMethod: InvokeUIMethodPAPI;
__QuerySelector: QuerySelectorPAPI;
__ElementAnimate: ElementAnimatePAPI;
}

export interface MainThreadGlobalThis extends ElementPAPIs {
Expand Down
Loading
Loading