Skip to content

Commit

Permalink
first pass at Propagator class
Browse files Browse the repository at this point in the history
  • Loading branch information
OrionReed committed Dec 11, 2024
1 parent e557f83 commit 967bb7c
Show file tree
Hide file tree
Showing 3 changed files with 263 additions and 136 deletions.
2 changes: 1 addition & 1 deletion demo/event-propagators.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
target="#box4"
trigger="transform"
expression="y: from.x,
rotation: from.x"
rotation: from.x/10"
></folk-event-propagator>

<script type="module">
Expand Down
243 changes: 243 additions & 0 deletions src/common/Propagator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/**
* A function that processes the event and updates the target element.
* @param {Element} source - The source element that emitted the event
* @param {Element} target - The target element that receives propagated changes
* @param {Event} event - The event that triggered the propagation
* @returns {any} - The result of the propagation
*/
export type PropagatorFunction = (source: Element, target: Element, 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?: Element | null;
target?: Element | null;
event?: string | null;
handler?: PropagatorFunction | string;
parser?: PropagatorParser;
onParseSuccess?: (body: string) => void;
onParseError?: (error: Error) => void;
};

/**
* A propagator takes a source and target element and listens for events on the source.
* When an event is detected, it will execute a handler and update the target element.
*/
export class Propagator {
#source: Element | null = null;
#target: Element | 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 {Element} [options.source] - Source element that emits events
* @param {Element} [options.target] - Target element that receives propagated changes
* @param {string} [options.event] - Event name to listen for on the source element
* @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 element that emits events.
* Setting a new source will automatically update event listeners.
*/
get source(): Element | null {
return this.#source;
}

set source(element: Element | null) {
// Remove listener from old source
if (this.#source && this.#eventName) {
this.#source.removeEventListener(this.#eventName, this.#handleEvent);
}

this.#source = element;

// Add listener to new source
if (this.#source && this.#eventName) {
this.#source.addEventListener(this.#eventName, this.#handleEvent);
}
}

/**
* The target element that receives propagated changes.
*/
get target(): Element | null {
return this.#target;
}

set target(element: Element | null) {
this.#target = element;
}

/**
* The name of the event to listen for on the source element.
* 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 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;
}
};
}
Loading

0 comments on commit 967bb7c

Please sign in to comment.