247 lines
6.9 KiB
TypeScript
247 lines
6.9 KiB
TypeScript
import { css } from './common/tags.ts';
|
|
import { FolkRope } from './folk-rope.ts';
|
|
import * as parser from '@babel/parser';
|
|
import type { Node } from '@babel/types';
|
|
|
|
const styles = new CSSStyleSheet();
|
|
styles.replaceSync(css`
|
|
:host {
|
|
display: block;
|
|
position: absolute;
|
|
inset: 0 0 0 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
textarea {
|
|
position: absolute;
|
|
width: auto;
|
|
min-width: 3ch;
|
|
height: auto;
|
|
resize: none;
|
|
background: rgba(256, 256, 256, 0.8);
|
|
border: 1px solid #ccc;
|
|
padding: 4px;
|
|
pointer-events: auto;
|
|
overflow: hidden;
|
|
field-sizing: content;
|
|
translate: -50% -50%;
|
|
border-radius: 5px;
|
|
}
|
|
`);
|
|
|
|
export class FolkEventPropagator extends FolkRope {
|
|
static override tagName = 'folk-event-propagator';
|
|
|
|
#triggers: string[] = [];
|
|
get triggers() {
|
|
return this.#triggers;
|
|
}
|
|
set triggers(triggers: string | string[]) {
|
|
if (typeof triggers === 'string') {
|
|
triggers = triggers.split(',');
|
|
}
|
|
this.#removeEventListenersToSource();
|
|
|
|
this.#triggers = triggers;
|
|
|
|
this.#addEventListenersToSource();
|
|
}
|
|
|
|
#expression = '';
|
|
#function: Function | null = null;
|
|
get expression() {
|
|
return this.#expression;
|
|
}
|
|
set expression(expression) {
|
|
this.mend();
|
|
this.#expression = expression;
|
|
const processedExp = 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);
|
|
} catch (error) {
|
|
console.warn('Failed to parse expression:', error, functionBody);
|
|
this.cut();
|
|
this.#function = null;
|
|
}
|
|
}
|
|
|
|
#triggerTextarea = document.createElement('textarea');
|
|
#expressionTextarea = document.createElement('textarea');
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
this.shadowRoot?.adoptedStyleSheets.push(styles);
|
|
|
|
this.#triggerTextarea.addEventListener('change', () => {
|
|
this.triggers = this.#triggerTextarea.value;
|
|
});
|
|
this.triggers = this.#triggerTextarea.value = this.getAttribute('triggers') || '';
|
|
|
|
this.shadowRoot?.appendChild(this.#triggerTextarea);
|
|
|
|
this.#expressionTextarea.addEventListener('input', () => {
|
|
this.expression = this.#expressionTextarea.value;
|
|
});
|
|
|
|
this.shadowRoot?.appendChild(this.#expressionTextarea);
|
|
|
|
this.expression = this.#expressionTextarea.value = this.getAttribute('expression') || '';
|
|
}
|
|
|
|
override render(sourceRect: DOMRectReadOnly, targetRect: DOMRectReadOnly) {
|
|
super.render(sourceRect, targetRect);
|
|
}
|
|
|
|
override draw() {
|
|
super.draw();
|
|
|
|
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`;
|
|
}
|
|
}
|
|
|
|
override observeSource() {
|
|
super.observeSource();
|
|
|
|
this.#addEventListenersToSource();
|
|
}
|
|
|
|
#addEventListenersToSource() {
|
|
for (const trigger of this.#triggers) {
|
|
// TODO: add special triggers for intersection, rAF, etc.
|
|
this.sourceElement?.addEventListener(trigger, this.evaluateExpression);
|
|
}
|
|
}
|
|
|
|
override unobserveSource() {
|
|
super.unobserveSource();
|
|
this.#removeEventListenersToSource();
|
|
}
|
|
|
|
#removeEventListenersToSource() {
|
|
for (const trigger of this.#triggers) {
|
|
this.sourceElement?.removeEventListener(trigger, this.evaluateExpression);
|
|
}
|
|
}
|
|
|
|
override observeTarget() {
|
|
super.observeTarget();
|
|
}
|
|
|
|
override unobserveTarget() {
|
|
super.unobserveTarget();
|
|
}
|
|
|
|
evaluateExpression = (event?: Event) => {
|
|
if (this.sourceElement === null || this.targetElement === null) return;
|
|
this.stroke = 'black';
|
|
if (!this.#function) return;
|
|
|
|
try {
|
|
this.#function(this.sourceElement, this.targetElement, event);
|
|
} catch (error) {
|
|
console.warn('Failed to evaluate expression:', error);
|
|
this.stroke = '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));
|
|
}
|