diff --git a/lib/folk-event-propagator.ts b/lib/folk-event-propagator.ts index 095c07d..4056a81 100644 --- a/lib/folk-event-propagator.ts +++ b/lib/folk-event-propagator.ts @@ -1,7 +1,7 @@ import { css, PropertyValues } from '@lit/reactive-element'; import { FolkRope } from './folk-rope.ts'; import { property } from '@lit/reactive-element/decorators.js'; -import { Propagator } from './common/Propagator.ts'; +import { Propagator } from '@propagators/index.ts'; export class FolkEventPropagator extends FolkRope { static override tagName = 'folk-event-propagator'; diff --git a/propagators/index.ts b/propagators/index.ts new file mode 100644 index 0000000..7ee0539 --- /dev/null +++ b/propagators/index.ts @@ -0,0 +1 @@ +export * from './propagator.ts'; diff --git a/propagators/propagator.ts b/propagators/propagator.ts new file mode 100644 index 0000000..b2f56aa --- /dev/null +++ b/propagators/propagator.ts @@ -0,0 +1,243 @@ +/** + * 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; + } + }; +} diff --git a/tsconfig.json b/tsconfig.json index a5479f3..5bf7e0f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "@propagators/*": ["propagators/*"] } }, - "include": ["lib/**/*.ts", "website/**/*.ts", "vite.config.ts"] + "include": ["**/*.ts", "vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 8e79bb9..544b65f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -69,7 +69,7 @@ export default defineConfig({ alias: { '@lib': resolve(__dirname, './lib'), '@labs': resolve(__dirname, './labs'), - '@propagators': resolve(__dirname, './labs'), + '@propagators': resolve(__dirname, './propagators'), }, }, plugins: [linkGenerator()],