Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
@@ -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,50 @@ export const initElementTree = () => {
return ele.getAttribute(name);
}

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: number,
name: string,
keyframes: Record<string, string | number>[],
options?: Record<string, string | number>,
]
| [operation: number, name: string],
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.

ElementAnimatePAPI currently uses operation: number for both tuple variants, which prevents TypeScript from narrowing args based on the operation and makes invalid operation values type-check. Consider using the shared AnimationOperation enum (and a discriminated union keyed on specific enum members) so callers and implementations get proper type safety.

Suggested change
export type ElementAnimatePAPI = (
element: HTMLElement,
args:
| [
operation: number,
name: string,
keyframes: Record<string, string | number>[],
options?: Record<string, string | number>,
]
| [operation: number, name: string],
export enum AnimationOperation {
Start = 0,
Cancel = 1,
}
export type ElementAnimatePAPI = (
element: HTMLElement,
args:
| [
operation: AnimationOperation.Start,
name: string,
keyframes: Record<string, string | number>[],
options?: Record<string, string | number>,
]
| [operation: AnimationOperation.Cancel, name: string],

Copilot uses AI. Check for mistakes.
) => 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
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import {
ErrorCode,
type QuerySelectorPAPI,
type InvokeUIMethodPAPI,
type ElementAnimatePAPI,
AnimationOperation,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} from '@lynx-js/web-constants';
import { createMainThreadLynx } from './createMainThreadLynx.js';
import {
Expand Down Expand Up @@ -781,6 +783,60 @@ export function createMainThreadGlobalThis(
return el;
};

const animationMap = new Map<string, globalThis.Animation | undefined>();
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.

animationMap is typed as Map<string, globalThis.Animation | undefined>, but globalThis.Animation refers to the global constructor type (typeof Animation) rather than the Animation instance returned by element.animate(). This will either fail type-checking or make the map type incorrect. Use the DOM Animation instance type (e.g. Map<string, Animation>), or ReturnType<HTMLElement['animate']> if you want to derive it from the API.

Suggested change
const animationMap = new Map<string, globalThis.Animation | undefined>();
const animationMap = new Map<string, Animation | undefined>();

Copilot uses AI. Check for mistakes.

const mapTimingOptions = (
options?: Record<string, string | number>,
): 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.set(
name,
element.animate(
keyframes as Keyframe[],
mapTimingOptions(options),
),
);
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.

On START, an existing animation with the same name is overwritten in animationMap without being cancelled first. This means repeated START calls (e.g. the REPL sample's Restart button) will leave the previous animation running and create multiple concurrent animations for the same logical id. Cancel any existing animation for name before starting the new one (and consider replacing the entry only after that).

Suggested change
animationMap.set(
name,
element.animate(
keyframes as Keyframe[],
mapTimingOptions(options),
),
);
const existingAnimation = animationMap.get(name);
if (existingAnimation) {
existingAnimation.cancel();
}
const newAnimation = element.animate(
keyframes as Keyframe[],
mapTimingOptions(options),
);
animationMap.set(name, newAnimation);

Copilot uses AI. Check for mistakes.
break;
}
case AnimationOperation.PLAY:
animationMap.get(name)?.play();
break;
case AnimationOperation.PAUSE:
animationMap.get(name)?.pause();
break;
case AnimationOperation.CANCEL:
animationMap.get(name)?.cancel();
break;
case AnimationOperation.FINISH:
animationMap.get(name)?.finish();
break;
Comment on lines +835 to +841
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.

CANCEL calls animation.cancel() but does not remove the entry from animationMap, leaving a stale reference and causing the map to grow unbounded over time. Delete the map entry after cancelling (and consider also removing entries on finish/onfinish to avoid leaks when animations end naturally).

Copilot uses AI. Check for mistakes.
}
};

const __GetPageElement: GetPageElementPAPI = () => {
return pageElement;
};
Expand Down Expand Up @@ -951,6 +1007,7 @@ export function createMainThreadGlobalThis(
renderPage: undefined,
__InvokeUIMethod,
__QuerySelector,
__ElementAnimate,
};
Object.assign(mtsRealm.globalWindow, mtsGlobalThis);
Object.defineProperty(mtsRealm.globalWindow, 'renderPage', {
Expand Down
Loading
Loading