psytask
Version:
JavaScript Framework for Psychology task
346 lines (329 loc) • 11.9 kB
text/typescript
import type { DeepReadonly, LooseObject, Merge } from '../types';
import type { App } from './app';
import { reactive } from './reactive';
import { EventEmitter, h, on, promiseWithResolvers } from './util';
const createShowInfo = () => ({ start_time: 0, frame_times: [] as number[] });
type SceneShowInfo = ReturnType<typeof createShowInfo>;
type ForbiddenSceneData = { [K in keyof SceneShowInfo]?: never };
/**
* @param props - The reactive props to control the scene display
* @param ctx - The scene instance, can be used to manage lifecycle and use
* other setups
* @see {@link Scene}
*/
type SceneSetup<
P extends LooseObject = any,
D extends LooseObject = LooseObject & ForbiddenSceneData,
> = (
props: P,
ctx: Scene<never>,
) => {
/** The node(s) appended to the root element of scene */
node: string | Node | (string | Node)[];
/** Data getter to get data from elements */
data?: () => D;
};
export type { SceneSetup as Component };
type SceneShow<
P extends LooseObject = any,
D extends LooseObject = LooseObject & ForbiddenSceneData,
> = (patchProps?: Partial<P>) => Promise<Merge<D, SceneShowInfo>>;
/** @ignore */
export type SceneFunction = SceneSetup | SceneShow;
/**
* Scene lifecycle and root element event name-value pairs.
*
* | name | trigger timing |
* | --------------------- | -------------------------------------------------------------------------------------------------------- |
* | scene:show | the scene is shown |
* | scene:frame | on each frame when the scene is shown |
* | scene:close | the scene is closed |
* | mouse:left | the left mouse button is pressed |
* | mouse:middle | the middle mouse button is pressed |
* | mouse:right | the right mouse button is pressed |
* | mouse:unknown | an unknown mouse button is pressed |
* | key:\<key\> | a {@link https://developer.mozilla.org/docs/Web/API/UI_Events/Keyboard_event_key_values key} is pressed |
* | \<HTMLElement-event\> | an {@link https://developer.mozilla.org/docs/Web/API/HTMLElement#events html element event} is triggered |
*/
export type SceneEventMap = HTMLElementEventMap & {
'scene:show': null;
'scene:frame': { lastFrameTime: number };
'scene:close': null;
} & {
[K in `mouse:${'left' | 'middle' | 'right' | 'unknown'}`]: MouseEvent;
} & {
[K in `key:${string}`]: KeyboardEvent;
};
type SceneEventType = keyof SceneEventMap;
/** Scene options (readonly) */
export type SceneOptions<T extends SceneFunction> = {
/** Default props used to call setup */
readonly defaultProps: DeepReadonly<
T extends SceneSetup<infer P> ? P : LooseObject
>;
/** Scene duration in milliseconds */
readonly duration?: number;
/** Close the scene on specific {@link SceneEventMap | events} */
readonly close_on?: SceneEventType | DeepReadonly<SceneEventType[]>;
/** Whether to log frame times */
readonly frame_times?: boolean;
};
const buttonTypeMap = ['mouse:left', 'mouse:middle', 'mouse:right'] as const;
/**
* Provide type infer for generic setup function, do nothing in runtime.
*
* @example
*
* ```ts
* const genericSetup = <T>(props: T) => ({
* node: h('div'),
* data: () => props,
* });
* using scene = app.scene(generic(genericSetup), { defaultProps: {} });
* const data = await scene.show({ text: '' });
* data.text; // is string
* ```
*/
const setup2show: {
<P extends LooseObject, D extends LooseObject & ForbiddenSceneData = {}>(
f: SceneSetup<P, D>,
): SceneShow<P, D>;
} = (f) => f as any;
export { setup2show as generic };
export class Scene<
T extends SceneFunction,
> extends EventEmitter<SceneEventMap> {
/** Root element */
readonly root = h('div', {
className: 'psytask-scene',
tabIndex: -1, // support keyboard events
oncontextmenu: (e) => e.preventDefault(), // prevent context menu
style: { transform: 'scale(0)' },
});
/** Reactive props @see {@link reactive} */
readonly props: SceneOptions<T>['defaultProps'];
/** Data getter */
data?: T extends SceneSetup<infer P, infer D> ? () => D : () => LooseObject;
/**
* Show the scene and change props temporarily
*
* @example
*
* ```ts
* using scene = app.text('', { defaultProps: { children: 'default' } });
* await scene.show({ children: 'new' }); // will show `new`
* await scene.show(); // will show `default`
* ```
*
* @function
*/
//@ts-ignore
show: T extends SceneSetup<infer P, infer D> ? SceneShow<P, D> : T =
this.#show;
/** Current scene options */
options: SceneOptions<T>;
#showPromiseWithResolvers: ReturnType<
typeof promiseWithResolvers<null>
> | null = null;
/**
* @param app - The {@link App} instance
* @param setup - The {@link SceneSetup | scene setup function}
* @param defaultOptions - Default {@link SceneOptions | scene options}
*/
constructor(
public readonly app: App,
setup: T,
public readonly defaultOptions: SceneOptions<T>,
) {
super();
this.options = defaultOptions;
const { node, data, props } = this.use(setup as SceneSetup, {
...defaultOptions.defaultProps,
});
//@ts-ignore
this.data = data;
this.props = props;
Array.isArray(node) ? this.root.append(...node) : this.root.append(node);
app.root.appendChild(this.root);
this.on('cleanup', () => app.root.removeChild(this.root));
}
/**
* Use component
*
* @example
*
* ```ts
* using scene = app.scene(
* (props: { text: string }, ctx) => {
* const stim = ctx.use(TextStim); // use other component
* effect(() => {
* stim.props.children = 'Current text is: ' + props.text;
* });
* return {
* node: h('div', null, stim.node),
* data: () => ({
* ...stim.data(),
* length: props.text.length,
* }),
* };
* },
* { defaultProps: { text: 'default text' } },
* );
* ```
*
* @param setup Scene setup function
* @param defaultProps Default props for the scene
*/
use<T extends SceneSetup>(
setup: T,
defaultProps: T extends SceneSetup<infer P> ? P : never,
) {
const props = reactive(defaultProps);
return { ...(setup(props, this) as ReturnType<T>), props };
}
/**
* Override default options temporarily
*
* @example
*
* ```ts
* using scene = app.text('', { duration: 100 });
* await scene.config({ duration: 200 }).show(); // will show 200ms
* await scene.show(); // will show 100ms
* ```
*/
config(patchOptions: Partial<SceneOptions<T>>) {
this.options = { ...this.defaultOptions, ...patchOptions };
return this;
}
close() {
if (!this.#showPromiseWithResolvers) {
throw new Error("Scene hasn't been shown");
}
this.root.style.transform = 'scale(0)';
this.#showPromiseWithResolvers.resolve(null);
}
async #show(patchProps?: Partial<LooseObject>) {
if (this.#showPromiseWithResolvers) {
throw new Error('Scene has been shown');
}
this.root.focus();
this.root.style.transform = 'scale(1)';
this.#showPromiseWithResolvers = promiseWithResolvers();
const { defaultProps, duration, close_on, frame_times } = this.options;
Object.assign(this.props, defaultProps, patchProps);
if (process.env.NODE_ENV === 'development') {
//@ts-ignore
window['s'] = this;
}
this.emit('scene:show', null);
// add event listener
if (typeof close_on !== 'undefined') {
const close_ons = Array.isArray(close_on) ? close_on : [close_on];
const close = () => this.close();
for (const close_on of close_ons) {
this.on(close_on, close);
this.once('scene:close', () => this.off(close_on, close));
}
}
const eventTypes = Object.keys(this.listeners) as SceneEventType[];
const hasSpecialType: [mouse: boolean, key: boolean] = [false, false];
for (const type of eventTypes) {
if (!hasSpecialType[0] && type.startsWith('mouse:')) {
hasSpecialType[0] = true;
this.once(
'scene:close',
on(this.root, 'mousedown', (e) =>
this.emit(buttonTypeMap[e.button] ?? 'mouse:unknown', e),
),
);
continue;
}
if (!hasSpecialType[1] && type.startsWith('key:')) {
hasSpecialType[1] = true;
this.once(
'scene:close',
on(this.root, 'keydown', (e) => this.emit(`key:${e.key}`, e)),
);
continue;
}
if (!type.startsWith('scene:')) {
this.once(
'scene:close',
//@ts-ignore
on(this.root, type, (e) => this.emit(type, e)),
);
}
}
// check duration
const frame_ms = this.app.data.frame_ms;
if (
process.env.NODE_ENV === 'development' &&
typeof duration !== 'undefined'
) {
const theoreticalDuration = Math.round(duration / frame_ms) * frame_ms;
const error = theoreticalDuration - duration;
if (Math.abs(error) >= 1) {
console.warn(
`Scene duration is not a multiple of frame_ms, theoretical duration is ${theoreticalDuration} ms, but got ${duration} ms (error: ${error} ms)`,
);
}
}
// render
/**
* ## Render logic
*
* ```text
* scene_1.show ->
* call_rAF_cb -> render -> vsync -> scene_1.start_time -> ... ->
* call_rAF_cb(scene_2.show) -> render -> vsync -> scene_2.start_time -> ... ->
* call_rAF_cb(scene_3.show) -> render -> vsync -> scene_3.start_time -> ...
* ```
*
* ## Closing condition
*
* | symbol/expression | description |
* | :---------------------: | ------------------- |
* | t | current frame time |
* | t_0 | start frame time |
* | D | duration |
* | \delta | next frame duration |
* | e = t - t_0 - D | duration error |
* | \|e\| <= \|e + \delta\| | closing condition |
*
* Inference:
*
* ```text
* For |e| <= |e + \delta|, given that \delta > 0
* if e >= 0 then e <= e + \delta -> true
* if e < 0 then -e <= |e + \delta|
* if e + \delta >= 0 then -e <= e + \delta -> e >= -\delta / 2
* if e + \delta < 0 then -e <= -e - \delta -> false
* ```
*/
let rAFid: number;
const showInfo = createShowInfo();
const onFrame = (lastFrameTime: number) => {
frame_times && showInfo.frame_times.push(lastFrameTime);
if (
typeof duration !== 'undefined' &&
lastFrameTime - showInfo.start_time >= duration - frame_ms * 1.5
) {
this.close();
return;
}
this.emit('scene:frame', { lastFrameTime });
rAFid = window.requestAnimationFrame(onFrame);
};
rAFid = window.requestAnimationFrame((lastFrameTime) => {
showInfo.start_time = lastFrameTime;
onFrame(lastFrameTime);
});
await this.#showPromiseWithResolvers.promise;
this.emit('scene:close', null);
window.cancelAnimationFrame(rAFid);
this.options = this.defaultOptions;
this.#showPromiseWithResolvers = null;
return Object.assign(this.data?.() ?? {}, showInfo);
}
}