145 lines
5.3 KiB
JavaScript
145 lines
5.3 KiB
JavaScript
/**
|
|
* renderer.js — DOM rendering and patching for el-ui.
|
|
*
|
|
* v0.1 strategy: full string re-render with event rebinding.
|
|
* The architecture is designed for future targeted DOM patching:
|
|
* `patch()` will accept the activated node set and only update
|
|
* DOM subtrees whose data-el-tag corresponds to an activated node.
|
|
*
|
|
* Event binding uses data-el-* attributes set by the compiler,
|
|
* which avoids innerHTML XSS risks for handler references while
|
|
* keeping the runtime small (no virtual DOM, no event delegation table).
|
|
*/
|
|
|
|
export class Renderer {
|
|
/**
|
|
* @param {HTMLElement} root the mount point
|
|
* @param {import('./index.js').Component} component component instance
|
|
*/
|
|
constructor(root, component) {
|
|
this.root = root;
|
|
this.component = component;
|
|
this._currentHtml = '';
|
|
this._boundHandlers = new WeakMap();
|
|
}
|
|
|
|
/**
|
|
* Initial mount — render and bind events.
|
|
*/
|
|
mount() {
|
|
this._currentHtml = this.component.render();
|
|
this.root.innerHTML = this._currentHtml;
|
|
this._bindEvents();
|
|
if (typeof this.component.onMount === 'function') {
|
|
this.component.onMount();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Patch the DOM after a state change.
|
|
* Called by the component when spreading activation completes.
|
|
*
|
|
* @param {Set<string>} [_activatedNodes] future: targeted patching by node set
|
|
*/
|
|
patch(_activatedNodes) {
|
|
const newHtml = this.component.render();
|
|
if (newHtml !== this._currentHtml) {
|
|
// Preserve scroll position and focused element identity
|
|
const focused = document.activeElement;
|
|
const focusedId = focused && focused !== document.body ? focused.id : null;
|
|
|
|
this.root.innerHTML = newHtml;
|
|
this._bindEvents();
|
|
this._currentHtml = newHtml;
|
|
|
|
// Restore focus if element still exists
|
|
if (focusedId) {
|
|
const el = this.root.querySelector(`#${CSS.escape(focusedId)}`);
|
|
if (el) el.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bind all data-el-* event handlers declared by the compiler.
|
|
*
|
|
* The compiler emits attributes like:
|
|
* data-el-click="__self.handleClick()"
|
|
* data-el-input="(e) => __self.setState('value', e.target.value)"
|
|
*
|
|
* We eval these in the context of the component instance.
|
|
* (For production use, a full CSP-compatible binding would generate
|
|
* handler functions at compile time.)
|
|
*
|
|
* @private
|
|
*/
|
|
_bindEvents() {
|
|
const comp = this.component;
|
|
const eventNames = ['click', 'input', 'change', 'mouseenter', 'mouseleave',
|
|
'keydown', 'keyup', 'keypress', 'focus', 'blur',
|
|
'submit', 'mousedown', 'mouseup', 'dblclick', 'contextmenu'];
|
|
|
|
for (const eventName of eventNames) {
|
|
const attr = `data-el-${eventName}`;
|
|
this.root.querySelectorAll(`[${attr}]`).forEach(el => {
|
|
const handlerExpr = el.getAttribute(attr);
|
|
if (!handlerExpr) return;
|
|
|
|
// Build a handler function in the component's scope
|
|
const handler = this._compileHandler(handlerExpr, comp);
|
|
if (handler) {
|
|
// Remove previous binding if any
|
|
const prev = this._boundHandlers.get(el);
|
|
if (prev) el.removeEventListener(eventName, prev[eventName]);
|
|
|
|
el.addEventListener(eventName, handler);
|
|
if (!this._boundHandlers.has(el)) this._boundHandlers.set(el, {});
|
|
this._boundHandlers.get(el)[eventName] = handler;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compile a handler expression string into a callable function.
|
|
* The expression is evaluated with `__self` bound to the component.
|
|
*
|
|
* @private
|
|
* @param {string} expr
|
|
* @param {object} comp
|
|
* @returns {function|null}
|
|
*/
|
|
_compileHandler(expr, comp) {
|
|
try {
|
|
// Decode HTML entities (attrs may have " etc.)
|
|
const decoded = expr
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
// If it looks like an arrow function or function reference, wrap it
|
|
const isArrow = decoded.includes('=>');
|
|
const isCall = decoded.includes('(');
|
|
|
|
if (isArrow) {
|
|
// Arrow function: `(e) => __self.setState(...)` or `() => ...`
|
|
// eslint-disable-next-line no-new-func
|
|
const fn = new Function('__self', `return ${decoded}`)(comp);
|
|
return typeof fn === 'function' ? fn : null;
|
|
} else if (isCall) {
|
|
// Method call: `__self.handleClick()`
|
|
// eslint-disable-next-line no-new-func
|
|
return new Function('__self', 'event', `${decoded}`).bind(null, comp);
|
|
} else {
|
|
// Bare identifier: method name on the component
|
|
return comp[decoded] ? comp[decoded].bind(comp) : null;
|
|
}
|
|
} catch (e) {
|
|
console.warn(`el-ui: failed to compile handler: ${expr}`, e);
|
|
return null;
|
|
}
|
|
}
|
|
}
|