folk-canvas/propagators/bidirectional-propagator.ts

294 lines
8.9 KiB
TypeScript

import type { PropagatorFunction, PropagatorParser } from './types.ts';
interface PropagatorOptions {
source?: EventTarget | null;
target?: EventTarget | null;
sourceEvent?: string | null;
targetEvent?: string | null;
sourceHandler?: PropagatorFunction | string | null;
targetHandler?: PropagatorFunction | string | null;
onParseSuccess?: ((body: string) => void) | null;
onParseError?: ((error: Error) => void) | null;
parser?: PropagatorParser | null;
}
/**
* A propagator takes a source and target element and listens for events on both.
* When an event is detected on one, it will execute a handler and update the other element.
*/
export class BidirectionalPropagator {
#source: EventTarget | null = null;
#target: EventTarget | null = null;
#sourceEventName: string | null = null;
#targetEventName: string | null = null;
#sourceHandler: PropagatorFunction | null = null;
#targetHandler: PropagatorFunction | null = null;
#parser: PropagatorParser | null = null;
#onParse: ((body: string) => void) | null = null;
#onError: ((error: Error) => void) | null = null;
#isPropagationLocked = false;
/**
* Creates a new BiPropagator instance.
* @param {PropagatorOptions} options - Configuration options for the propagator
*/
constructor(options: PropagatorOptions = {}) {
const {
source = null,
target = null,
sourceEvent = null,
targetEvent = null,
sourceHandler = null,
targetHandler = 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 (sourceEvent) this.sourceEvent = sourceEvent;
if (targetEvent) this.targetEvent = targetEvent;
if (sourceHandler) this.sourceHandler = sourceHandler;
if (targetHandler) this.targetHandler = targetHandler;
}
/**
* The source element that emits events.
* Setting a new source will automatically update event listeners.
*/
get source() {
return this.#source;
}
set source(element) {
// Remove listener from old source
if (this.#source && this.#sourceEventName) {
this.#source.removeEventListener(this.#sourceEventName, this.#handleSourceEvent);
}
this.#source = element;
// Add listener to new source
if (this.#source && this.#sourceEventName) {
this.#source.addEventListener(this.#sourceEventName, this.#handleSourceEvent);
}
}
/**
* The target element that receives propagated changes.
* Setting a new target will automatically update event listeners.
*/
get target() {
return this.#target;
}
set target(element) {
// Remove listener from old target
if (this.#target && this.#targetEventName) {
this.#target.removeEventListener(this.#targetEventName, this.#handleTargetEvent);
}
this.#target = element;
// Add listener to new target
if (this.#target && this.#targetEventName) {
this.#target.addEventListener(this.#targetEventName, this.#handleTargetEvent);
}
}
/**
* The name of the event to listen for on both the source and target elements.
*/
get sourceEvent(): string | null {
return this.#sourceEventName;
}
set sourceEvent(name: string | null) {
if (this.#source && this.#sourceEventName) {
this.#source.removeEventListener(this.#sourceEventName, this.#handleSourceEvent);
}
this.#sourceEventName = name;
if (this.#source && this.#sourceEventName) {
this.#source.addEventListener(this.#sourceEventName, this.#handleSourceEvent);
}
}
get targetEvent(): string | null {
return this.#targetEventName;
}
set targetEvent(name: string | null) {
if (this.#target && this.#targetEventName) {
this.#target.removeEventListener(this.#targetEventName, this.#handleTargetEvent);
}
this.#targetEventName = name;
if (this.#target && this.#targetEventName) {
this.#target.addEventListener(this.#targetEventName, this.#handleTargetEvent);
}
}
/**
* The handler function that processes the event and updates the other element.
* Can be set using either a function or a string expression.
*/
get sourceHandler(): PropagatorFunction | null {
return this.#sourceHandler;
}
set sourceHandler(value: PropagatorFunction | string | null) {
if (typeof value === 'string') {
try {
this.#sourceHandler = this.#parser ? this.#parser(value) : this.#defaultParser(value);
} catch (error) {
this.#sourceHandler = null;
}
} else {
this.#sourceHandler = value;
}
}
get targetHandler(): PropagatorFunction | null {
return this.#targetHandler;
}
set targetHandler(value: PropagatorFunction | string | null) {
if (typeof value === 'string') {
try {
this.#targetHandler = this.#parser ? this.#parser(value) : this.#defaultParser(value);
} catch (error) {
this.#targetHandler = null;
}
} else {
this.#targetHandler = value;
}
}
/**
* Manually triggers the propagation from the source 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.#sourceEventName) {
event = new Event(this.#sourceEventName);
}
if (!event) return;
this.#handleSourceEvent(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.#sourceEventName) {
this.#source.removeEventListener(this.#sourceEventName, this.#handleSourceEvent);
}
if (this.#target && this.#targetEventName) {
this.#target.removeEventListener(this.#targetEventName, this.#handleTargetEvent);
}
this.#source = null;
this.#target = null;
this.#sourceHandler = null;
this.#targetHandler = null;
}
#handleSourceEvent = async (event: Event) => {
if (this.#isPropagationLocked) return;
if (!this.#source || !this.#target || !this.#sourceHandler) return;
this.#isPropagationLocked = true;
try {
this.#sourceHandler(this.#source, this.#target, event);
await Promise.resolve();
} finally {
this.#isPropagationLocked = false;
}
};
#handleTargetEvent = async (event: Event) => {
if (this.#isPropagationLocked) return;
if (!this.#source || !this.#target || !this.#targetHandler) return;
this.#isPropagationLocked = true;
try {
this.#targetHandler(this.#target, this.#source, event);
await Promise.resolve();
} finally {
this.#isPropagationLocked = false;
}
};
// 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 target element.\`);
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 target element.\`);
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;
}
};
}