/** * A function that processes the event and updates the target. * @param {EventTarget} source - The source that emitted the event * @param {EventTarget} target - The target that receives propagated changes * @param {Event} event - The event that triggered the propagation * @returns {any} - The result of the propagation */ export type PropagatorFunction = (source: EventTarget, target: EventTarget, event: Event) => any; /** * A parser function that converts a string expression into a PropagatorFunction. * @param {string} body - The string expression to parse * @param {Propagator} [propagator] - The Propagator instance (optional) * @returns {PropagatorFunction | null} - The parsed PropagatorFunction or null if parsing fails */ export type PropagatorParser = (body: string, propagator?: Propagator) => PropagatorFunction | null; export type PropagatorOptions = { source?: EventTarget | null; target?: EventTarget | null; event?: string | null; handler?: PropagatorFunction | string; parser?: PropagatorParser; onParseSuccess?: (body: string) => void; onParseError?: (error: Error) => void; }; /** * A propagator takes in a source and target and listens for events on the source. * When an event is detected, it will execute a handler and update the target. */ export class Propagator { #source: EventTarget | null = null; #target: EventTarget | null = null; #eventName: string | null = null; #handler: PropagatorFunction | null = null; #parser: PropagatorParser | null = null; #onParse: ((body: string) => void) | null = null; #onError: ((error: Error) => void) | null = null; /** * Creates a new Propagator instance. * @param {PropagatorOptions} options - Configuration options for the propagator * @param {EventTarget} [options.source] - Source that emits events * @param {EventTarget} [options.target] - Target that receives propagated changes * @param {string} [options.event] - Event name to listen for on the source * @param {PropagatorFunction|string} [options.handler] - Event handler function or string expression * @param {PropagatorParser} [options.parser] - Custom parser for string handlers * @param {Function} [options.onParse] - Callback fired when a string handler is parsed * @param {Function} [options.onError] - Callback fired when an error occurs during parsing */ constructor(options: PropagatorOptions = {}) { const { source = null, target = null, event = null, handler = null, onParseSuccess: onParse = null, onParseError: onError = null, parser = null, } = options; this.#onParse = onParse; this.#onError = onError; this.#parser = parser; this.source = source; this.target = target; if (event) this.event = event; if (handler) this.handler = handler; } /** * The source that emits events. * Setting a new source will automatically update event listeners. */ get source(): EventTarget | null { return this.#source; } set source(eventTarget: EventTarget | null) { // Remove listener from old source if (this.#source && this.#eventName) { this.#source.removeEventListener(this.#eventName, this.#handleEvent); } this.#source = eventTarget; // Add listener to new source if (this.#source && this.#eventName) { this.#source.addEventListener(this.#eventName, this.#handleEvent); } } /** * The target that receives propagated changes. */ get target(): EventTarget | null { return this.#target; } set target(eventTarget: EventTarget | null) { this.#target = eventTarget; } /** * The name of the event to listen for on the source. * Setting a new event name will automatically update event listeners. */ get event(): string | null { return this.#eventName; } set event(name: string) { // Remove old listener if (this.#source && this.#eventName) { this.#source.removeEventListener(this.#eventName, this.#handleEvent); } this.#eventName = name; // Add new listener if (this.#source && this.#eventName) { this.#source.addEventListener(this.#eventName, this.#handleEvent); } } /** * The handler function that processes the event and updates the target. * Can be set using either a function or a string expression. */ get handler(): PropagatorFunction | null { return this.#handler; } set handler(value: PropagatorFunction | string | null) { if (typeof value === 'string') { try { this.#handler = this.#parser ? this.#parser(value, this) : this.#defaultParser(value); } catch (error) { this.#handler = null; } } else { this.#handler = value; } } /** * Manually triggers the propagation with an optional event. * If no event is provided and an event name is set, creates a new event. * @param {Event} [event] - Optional event to propagate */ propagate(event?: Event): void { if (!event && this.#eventName) { event = new Event(this.#eventName); } if (!event) return; this.#handleEvent(event); } /** * Cleans up the propagator by removing event listeners and clearing references. * Should be called when the propagator is no longer needed. */ dispose(): void { if (this.#source && this.#eventName) { this.#source.removeEventListener(this.#eventName, this.#handleEvent); } this.#source = null; this.#target = null; this.#handler = null; } #handleEvent = (event: Event) => { if (!this.#source || !this.#target || !this.#handler) return; try { this.#handler(this.#source, this.#target, event); } catch (error) { console.error('Error in propagator handler:', error); } }; // This approach turns object syntax into imperative code. // We could alternatively use a parser (i.e. Babel) to statically analyse the code // and check on keystrokes if the code is valid. // We should try a few different things to figure out how this class could work best. #defaultParser = (body: string): PropagatorFunction | null => { const processedExp = body.trim(); const codeLines: string[] = []; // Split the expression into lines, handling different line endings const lines = processedExp.split(/\r?\n/); for (const line of lines) { let line_trimmed = line.trim(); if (!line_trimmed) continue; // Remove trailing comma if it exists (only if it's at the very end of the line) if (line_trimmed.endsWith(',')) { line_trimmed = line_trimmed.slice(0, -1).trim(); } // Find the first colon index, which separates the key and value. // Colons can still be used in ternary operators or other expressions, const colonIndex = line_trimmed.indexOf(':'); if (colonIndex === -1) { continue; } const key = line_trimmed.slice(0, colonIndex).trim(); const value = line_trimmed.slice(colonIndex + 1).trim(); if (key === '()') { // Anonymous function: directly evaluate the value codeLines.push(`${value};`); } else if (key.endsWith('()')) { // If the key is a method, execute it if the condition is true const methodName = key.slice(0, -2); codeLines.push(` if (typeof to.${methodName} !== 'function') throw new Error(\`Method '${methodName}' does not exist on the target.\`); else if (${value}) to.${methodName}();`); } else { // For property assignments, assign the value directly codeLines.push(` if (!('${key}' in to)) throw new Error(\`Property '${key}' does not exist on the target.\`); to.${key} = ${value};`); } } const functionBody = codeLines.join('\n'); try { const handler = new Function('from', 'to', 'event', functionBody) as PropagatorFunction; this.#onParse?.(functionBody); return handler; } catch (error) { this.#onError?.(error as Error); return null; } }; }