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;
}
}
}

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;

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