first pass at Propagator class
This commit is contained in:
parent
e557f839da
commit
967bb7c85c
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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 * as parser from '@babel/parser';
|
||||
import { Propagator } from './common/Propagator.ts';
|
||||
|
||||
export class FolkEventPropagator extends FolkRope {
|
||||
static override tagName = 'folk-event-propagator';
|
||||
|
|
@ -28,12 +28,11 @@ export class FolkEventPropagator extends FolkRope {
|
|||
];
|
||||
|
||||
@property({ type: String, reflect: true }) trigger = '';
|
||||
|
||||
@property({ type: String, reflect: true }) expression = '';
|
||||
|
||||
#function: Function | null = null;
|
||||
#triggerTextarea = document.createElement('textarea');
|
||||
#expressionTextarea = document.createElement('textarea');
|
||||
#propagator: Propagator | null = null;
|
||||
|
||||
override firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
|
|
@ -47,166 +46,51 @@ export class FolkEventPropagator extends FolkRope {
|
|||
});
|
||||
|
||||
this.#triggerTextarea.value = this.trigger;
|
||||
|
||||
this.#expressionTextarea.value = this.expression;
|
||||
|
||||
this.renderRoot.append(this.#triggerTextarea, this.#expressionTextarea);
|
||||
|
||||
this.#initializePropagator();
|
||||
}
|
||||
|
||||
override updated(changedProperties: PropertyValues<this>): void {
|
||||
super.update(changedProperties);
|
||||
|
||||
if (changedProperties.has('trigger')) {
|
||||
this.sourceElement?.removeEventListener(this.trigger, this.#evaluateExpression);
|
||||
this.sourceElement?.addEventListener(this.trigger, this.#evaluateExpression);
|
||||
}
|
||||
|
||||
if (changedProperties.has('expression')) {
|
||||
this.#parseExpression();
|
||||
}
|
||||
|
||||
const previousSourceElement = changedProperties.get('sourceElement');
|
||||
if (previousSourceElement) {
|
||||
const trigger = changedProperties.get('trigger') || this.trigger;
|
||||
previousSourceElement.removeEventListener(trigger, this.#evaluateExpression);
|
||||
this.sourceElement?.addEventListener(this.trigger, this.#evaluateExpression);
|
||||
if (changedProperties.has('trigger') || changedProperties.has('expression')) {
|
||||
this.#initializePropagator();
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.sourceElement?.removeEventListener(this.trigger, this.#evaluateExpression);
|
||||
this.#propagator?.dispose();
|
||||
}
|
||||
|
||||
#initializePropagator() {
|
||||
this.#propagator?.dispose();
|
||||
this.#propagator = new Propagator({
|
||||
source: this.sourceElement,
|
||||
target: this.targetElement,
|
||||
event: this.trigger,
|
||||
handler: this.expression,
|
||||
onParseError: () => this.cut(),
|
||||
onParseSuccess: () => this.mend(),
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
super.render();
|
||||
|
||||
const triggerPoint = this.points[Math.floor(this.points.length / 5)];
|
||||
|
||||
if (triggerPoint) {
|
||||
this.#triggerTextarea.style.left = `${triggerPoint.pos.x}px`;
|
||||
this.#triggerTextarea.style.top = `${triggerPoint.pos.y}px`;
|
||||
}
|
||||
|
||||
const expressionPoint = this.points[Math.floor(this.points.length / 2)];
|
||||
|
||||
if (expressionPoint) {
|
||||
this.#expressionTextarea.style.left = `${expressionPoint.pos.x}px`;
|
||||
this.#expressionTextarea.style.top = `${expressionPoint.pos.y}px`;
|
||||
}
|
||||
}
|
||||
|
||||
#parseExpression() {
|
||||
const processedExp = this.expression.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) {
|
||||
// Line without a colon, skip or handle error
|
||||
console.warn(`Skipping line without colon: "${line_trimmed}"`);
|
||||
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 {
|
||||
// parseAst(functionBody);
|
||||
this.#function = new Function('from', 'to', 'event', functionBody);
|
||||
this.mend();
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse expression:', error, functionBody);
|
||||
this.cut();
|
||||
this.#function = null;
|
||||
}
|
||||
}
|
||||
|
||||
#evaluateExpression = (event?: Event) => {
|
||||
if (this.sourceElement === null || this.targetElement === null) return;
|
||||
|
||||
if (!this.#function) return;
|
||||
|
||||
try {
|
||||
this.#function(this.sourceElement, this.targetElement, event);
|
||||
this.style.setProperty('--folk-rope-color', '');
|
||||
} catch (error) {
|
||||
console.warn('Failed to evaluate expression:', error);
|
||||
this.style.setProperty('--folk-rope-color', 'red');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* function parseAst(functionBody: string) {
|
||||
const ast = parser.parse(functionBody, {
|
||||
sourceType: 'script',
|
||||
});
|
||||
|
||||
const toProps = new Set<string>();
|
||||
const fromProps = new Set<string>();
|
||||
|
||||
function walkAst(node: Node) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
if (node.type === 'MemberExpression' && node.object?.type === 'Identifier') {
|
||||
const objName = node.object.name;
|
||||
if (objName !== 'to' && objName !== 'from') return;
|
||||
|
||||
const propSet = objName === 'to' ? toProps : fromProps;
|
||||
|
||||
if (node.property?.type === 'Identifier') {
|
||||
propSet.add(node.property.name);
|
||||
} else if (node.property?.type === 'StringLiteral') {
|
||||
propSet.add(node.property.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively walk through all properties
|
||||
for (const key of Object.keys(node)) {
|
||||
const value = (node as any)[key];
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walkAst);
|
||||
} else if (value && typeof value === 'object') {
|
||||
walkAst(value as Node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkAst(ast);
|
||||
|
||||
console.log('Properties accessed on to:', Array.from(toProps));
|
||||
console.log('Properties accessed on from:', Array.from(fromProps));
|
||||
} */
|
||||
|
|
|
|||
Loading…
Reference in New Issue