import { componentTypeIds } from './IdManager.js'; export const COMPONENT_CHANGE_HANDLE = Symbol('Component change handle'); export type BaseShape = Record; export type ComponentHandle< Shape extends BaseShape = any, Ext extends Extensions = any, > = { id: number; name: string; defaults: () => Shape; reset: (instance: Shape) => void; initialize: ( pooled: ComponentInstanceInternal, initial: Partial, id: number, ) => void; serialize?: Serializer; deserialize?: Deserializer; extensions?: Ext; create: () => ComponentInstance; isInstance: (instance: any) => instance is ComponentInstance; }; type Serializer = (shape: Shape) => string; // TODO: should additionalproperties always be handled by the system not user-configurable? type Deserializer = ( str: string, additionalProperties: PropertyDescriptorMap, ) => Shape; type Extensions = | Record) => any> | undefined; // & { // // ban defining overlapping keys with Shape // [U in keyof Shape]?: never; // }; type AppliedExtensions> = Ex extends undefined ? {} : { [K in keyof Ex]: Ex[K] extends (...args: any[]) => any ? ReturnType : never; }; export type ComponentInstance$< Shape extends BaseShape = any, Ext extends Extensions = Extensions, > = { /** A unique ID for this component instance */ id: number; /** A reference to the component definition */ type: ComponentHandle; /** INTERNAL USE ONLY */ [COMPONENT_CHANGE_HANDLE]?: (instance: ComponentInstance) => void; /** Set changed = true to trigger changed() filters for this component */ changed: boolean; /** * Call $() with a callback to automatically update the changed state for any * alterations made within the callback. */ (callback: () => void): void; }; export type ComponentInstance< Shape extends BaseShape = BaseShape, Ext extends Extensions = Extensions, > = { $: ComponentInstance$; } & Shape & AppliedExtensions; // component just as seen by internal systems - no typing of // data or extensions export type ComponentInstanceInternal = { $: ComponentInstance$; }; export type InstanceFor = Handle extends ComponentHandle ? ComponentInstance : never; export type AnyComponent = ComponentInstanceInternal; export type ComponentOptions< Shape extends BaseShape, Ext extends Extensions, > = { serialize?: Serializer; deserialize?: Deserializer; extensions?: Ext; }; const defaultSerialize: Serializer = (instance) => { const gettersAndSetters: PropertyDescriptorMap = {}; const data: Record = {}; const descriptors = Object.getOwnPropertyDescriptors(instance); Object.keys(descriptors).forEach((key) => { const descriptor = descriptors[key]; if ( typeof descriptor?.get === 'function' || typeof descriptor?.set === 'function' ) { gettersAndSetters[key] = descriptor; } else if ( descriptor.enumerable && descriptor.value && !(typeof descriptor.value === 'function') ) { data[key] = descriptor.value; } }); return JSON.stringify(data); }; const defaultDeserialize: Deserializer = ( serialized: string, additionalProperties: PropertyDescriptorMap, ) => { const data = JSON.parse(serialized); Object.defineProperties(data, additionalProperties); return data as any; }; export const componentTypeMap = new Map(); const componentNameSet = new Set(); function createComponentDefinition< Shape extends BaseShape, Ext extends Extensions, >( name: string, init: () => Shape, options?: ComponentOptions, ): ComponentHandle { if (componentNameSet.has(name)) { throw new Error( `Component name "${name}" already exists. Names must be unique. Use "namespace" to avoid conflicts.`, ); } const handle = { id: componentTypeIds.get(), name, defaults: init, } as ComponentHandle; componentTypeMap.set(handle.id, handle); function reset(instance: Shape) { Object.assign(instance, init()); } function initialize( pooled: ComponentInstanceInternal, initial: Partial, id: number, ) { Object.assign(pooled, init(), initial); const $ = ((callback: () => void) => { callback(); pooled.$.changed = true; }) as ComponentInstance$; Object.defineProperties($, { id: { value: id, writable: false }, type: { value: handle, writable: false }, changed: { get() { console.warn('changed never returns true'); return false; }, set(_) { $[COMPONENT_CHANGE_HANDLE]?.(pooled); }, }, }); pooled.$ = $; } function create(): ComponentInstance { const instance: any = init(); const extensions = options?.extensions; if (extensions) { Object.keys(extensions).forEach((key) => { Object.defineProperty(instance, key, { get: () => (extensions[key] as any)(instance), }); }); } initialize(instance, {}, 0); return instance; } function isInstance( instance: any, ): instance is ComponentInstance { if (!('$' in instance)) return false; return instance.$.type.id === handle.id; } Object.assign(handle, { reset, create, initialize, isInstance, ...options, }); return handle; } export function component< Shape extends BaseShape, Ext extends Extensions, >(name: string, init: () => Shape, options?: ComponentOptions) { return createComponentDefinition(name, init, { serialize: defaultSerialize, deserialize: defaultDeserialize, ...options, }); } export function state>( name: string, init: () => Shape, options?: Omit, 'serialize' | 'deserialize'>, ) { return createComponentDefinition(name, init, options); } export function namespace(ns: string) { function namespacedComponent< Shape extends BaseShape, Ext extends Extensions, >(name: string, init: () => Shape, options?: ComponentOptions) { return component(`${ns}:${name}`, init, options); } function namespacedState< Shape extends BaseShape, Ext extends Extensions, >( name: string, init: () => Shape, options?: Omit, 'serialize' | 'deserialize'>, ) { return state(`${ns}:${name}`, init, options); } return { component: namespacedComponent, state: namespacedState, }; }