canoejs
Version:
A lightweight, widget-based UI framework
215 lines (177 loc) • 6.63 kB
text/typescript
import EventLinker from "./EventLinker";
import Widget from "./Widget";
import { Canoe } from "../canoe";
export default class Render {
rootId: string = "";
rootWidget: Widget;
private static renderCache = new Map<string, HTMLElement>();
private static lastWidgetHash: string = "";
constructor(rootId: string = "", rootWidget: Widget) {
this.rootId = rootId;
this.rootWidget = rootWidget;
}
private getFocus = (): string | null => {
let el: Element | null = document.activeElement;
if (!el) return null;
const stack: string[] = [];
while (el && el.parentNode) {
let sibCount = 0;
let sibIndex = 0;
if (el.parentNode instanceof HTMLElement) {
const siblings = el.parentNode.children;
for (let i = 0; i < siblings.length; i++) {
if (siblings[i].nodeName === el.nodeName) {
if (siblings[i] === el) sibIndex = sibCount;
sibCount++;
}
}
}
if (el instanceof HTMLElement && el.id) {
// Escape the ID for CSS selector
const escapedId = CSS.escape(el.id);
stack.unshift(`${el.nodeName.toLowerCase()}#${escapedId}`);
} else if (sibCount > 1) {
stack.unshift(`${el.nodeName.toLowerCase()}:nth-of-type(${sibIndex + 1})`);
} else {
stack.unshift(el.nodeName.toLowerCase());
}
el = el.parentNode as Element;
}
return stack.length > 1 ? stack.slice(1).join(' > ') : null;
};
private setFocus = (selector: string | null) => {
if (!selector) return;
const element = document.querySelector(selector) as HTMLElement | null;
element?.focus();
};
private updateAttributes = (oldEl: HTMLElement, newEl: HTMLElement) => {
const oldAttrs = new Set(oldEl.getAttributeNames());
const newAttrs = new Set(newEl.getAttributeNames());
// Remove old attributes
oldAttrs.forEach(attr => {
if (!newAttrs.has(attr)) {
oldEl.removeAttribute(attr);
}
});
// Set new and changed attributes
newAttrs.forEach(attr => {
const newVal = newEl.getAttribute(attr);
if (oldEl.getAttribute(attr) !== newVal) {
oldEl.setAttribute(attr, newVal || "");
}
});
};
private updateStyles = (oldEl: HTMLElement, newEl: HTMLElement) => {
const oldStyles = oldEl.style;
const newStyles = newEl.style;
// Remove old styles
for (let i = oldStyles.length - 1; i >= 0; i--) {
const property = oldStyles[i];
if (!newStyles.getPropertyValue(property)) {
oldStyles.removeProperty(property);
}
}
// Set new styles
for (let i = 0; i < newStyles.length; i++) {
const property = newStyles[i];
const value = newStyles.getPropertyValue(property);
if (oldStyles.getPropertyValue(property) !== value) {
oldStyles.setProperty(property, value);
}
}
};
private updateClasses = (oldEl: HTMLElement, newEl: HTMLElement) => {
const oldClasses = Array.from(oldEl.classList);
const newClasses = Array.from(newEl.classList);
// Remove old classes
oldClasses.forEach(cls => {
if (!newClasses.includes(cls)) {
oldEl.classList.remove(cls);
}
});
// Add new classes
newClasses.forEach(cls => {
if (!oldClasses.includes(cls)) {
oldEl.classList.add(cls);
}
});
};
private updateChildren = (oldEl: HTMLElement, newEl: HTMLElement) => {
const oldChildren = Array.from(oldEl.childNodes);
const newChildren = Array.from(newEl.childNodes);
const max = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < max; i++) {
const oldChild = oldChildren[i];
const newChild = newChildren[i];
if (!oldChild && newChild) {
oldEl.appendChild(newChild.cloneNode(true));
} else if (oldChild && !newChild) {
oldEl.removeChild(oldChild);
} else if (
oldChild?.nodeType === Node.TEXT_NODE &&
newChild?.nodeType === Node.TEXT_NODE
) {
if (oldChild.textContent !== newChild.textContent) {
oldChild.textContent = newChild.textContent;
}
} else if (
oldChild instanceof HTMLElement &&
newChild instanceof HTMLElement
) {
this.updateElement(oldChild, newChild);
} else if (oldChild && newChild) {
oldEl.replaceChild(newChild.cloneNode(true), oldChild);
}
}
};
private updateElement = (oldEl: HTMLElement, newEl: HTMLElement) => {
if (oldEl.tagName !== newEl.tagName) {
oldEl.replaceWith(newEl);
return;
}
this.updateAttributes(oldEl, newEl);
this.updateStyles(oldEl, newEl);
this.updateClasses(oldEl, newEl);
this.updateChildren(oldEl, newEl);
};
private getWidgetHash = (): string => {
// Crear un hash simple del widget basado en su estructura
const widgetStr = JSON.stringify(this.rootWidget, (key, value) => {
if (key === 'id') return undefined; // Ignorar IDs para el hash
return value;
});
return widgetStr;
};
render(): HTMLElement | null {
const widgetHash = this.getWidgetHash();
// Cache check - solo evitar re-renderizado si el widget es exactamente el mismo
if (widgetHash === Render.lastWidgetHash) {
return document.getElementById(this.rootId);
}
Render.lastWidgetHash = widgetHash;
// Limpiar TODOS los eventos antes de cualquier render
EventLinker.clearEvents();
const focusSelector = this.getFocus();
const rootElement = document.getElementById(this.rootId);
const newContent = this.rootWidget.render();
if (!rootElement) {
if (Canoe.debug) console.warn(`Render error: element with id '${this.rootId}' not found.`);
return null;
}
// Reemplazar completamente el contenido para asegurar limpieza total de eventos
// Esto garantiza que no queden eventos residuales de renders anteriores
rootElement.innerHTML = '';
rootElement.appendChild(newContent);
this.setFocus(focusSelector);
// Reconstruir todos los eventos después del render
EventLinker.linkEvents();
if (Canoe.debug) {
console.log('🔄 Render completado - Eventos limpiados y reconstruidos');
}
return rootElement;
}
static clearCache(): void {
Render.renderCache.clear();
Render.lastWidgetHash = "";
}
}