refactor: replace Jinja templates with static frontend pages
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Hoover — vdom.js
|
||||
*
|
||||
* Virtual DOM: h() factory, vnode creation, diffing, patching.
|
||||
* Maintains _vnodeDom WeakMap for vnode ↔ DOM element resolution.
|
||||
*
|
||||
* Critical fixes vs. reactive-dom.js:
|
||||
* - _vnodeDom updated after EVERY vnode→dom assignment
|
||||
* - Keyed diff with proper element reordering
|
||||
* - Unkeyed diff with anchor tracking
|
||||
* - Proper unmountTree for cleanup (fires registered onUnmount hooks)
|
||||
*/
|
||||
|
||||
// Exported so render.js can access it
|
||||
export const _vnodeDom = new WeakMap();
|
||||
|
||||
// Lifecycle hooks registry (component.js populates this)
|
||||
export const _mountFn = { fn: null };
|
||||
export const _unmountFn = { fn: null };
|
||||
|
||||
export function setMountFn(fn) { _mountFn.fn = fn; }
|
||||
export function setUnmountFn(fn) { _unmountFn.fn = fn; }
|
||||
|
||||
/**
|
||||
* Build a VNode. Three forms:
|
||||
* h('div', { class: 'x' }, h('span', null, 'hi')) — element
|
||||
* h(ComponentFn, { prop: 1 }, child1, child2) — component (fn called)
|
||||
* h('#text', 'some text') — text node
|
||||
*/
|
||||
export function h(tag, props, ...children) {
|
||||
if (typeof tag === 'function') {
|
||||
const base = typeof props === 'object' && props !== null ? props : {};
|
||||
if (!base.children && children.length)
|
||||
base.children = flatten(children);
|
||||
return tag(base);
|
||||
}
|
||||
if (tag === '#text')
|
||||
return { tag: '#text', text: String(props) };
|
||||
if (tag === '#comp') {
|
||||
return { tag: '#comp', props: props || {}, ch: flatten(children) };
|
||||
}
|
||||
return { tag, props: props || {}, ch: flatten(children) };
|
||||
}
|
||||
|
||||
/** Flatten nested arrays / primitives → VNode array. */
|
||||
function flatten(arr) {
|
||||
const out = [];
|
||||
for (const c of arr.flat(Infinity)) {
|
||||
if (c == null || typeof c === 'boolean') continue;
|
||||
out.push(
|
||||
typeof c === 'string' || typeof c === 'number'
|
||||
? { tag: '#text', text: String(c) }
|
||||
: c,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the real DOM element for a VNode via _vnodeDom.
|
||||
*/
|
||||
export function getDom(vnode) {
|
||||
return vnode ? _vnodeDom.get(vnode) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a real DOM element (or subtree) from a VNode.
|
||||
* Also registers _vnodeDom mapping for the created element and all descendants.
|
||||
*/
|
||||
export function createDom(vnode) {
|
||||
if (!vnode) return document.createTextNode('');
|
||||
if (vnode.tag === '#text') {
|
||||
const tn = document.createTextNode(vnode.text || '');
|
||||
_vnodeDom.set(vnode, tn);
|
||||
return tn;
|
||||
}
|
||||
const el = document.createElement(vnode.tag);
|
||||
applyProps(el, vnode.props);
|
||||
_vnodeDom.set(vnode, el);
|
||||
for (const c of vnode.ch || []) {
|
||||
el.appendChild(createDom(c));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Apply every prop on an element (initial mount). */
|
||||
export function applyProps(el, props) {
|
||||
for (const [k, v] of Object.entries(props)) setProp(el, k, v);
|
||||
}
|
||||
|
||||
/** Set a single prop (or event) on an element. */
|
||||
export function setProp(el, key, value) {
|
||||
if (key === 'key' || key === 'ref') return;
|
||||
if (key === 'html') { el.innerHTML = String(value); return; }
|
||||
if (key === 'innerHTML') { el.innerHTML = String(value); return; }
|
||||
if (key === 'textContent') { el.textContent = String(value); return; }
|
||||
|
||||
if (key.startsWith('on:')) {
|
||||
const ev = key.slice(3);
|
||||
const map = el._evMap || {};
|
||||
if (map[ev]) el.removeEventListener(ev, map[ev]);
|
||||
if (typeof value === 'function') {
|
||||
el.addEventListener(ev, value);
|
||||
map[ev] = value;
|
||||
} else delete map[ev];
|
||||
el._evMap = map;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'class' && typeof value === 'object' && value !== null) {
|
||||
el.className = Object.keys(value).filter(k => value[k]).join(' ');
|
||||
return;
|
||||
}
|
||||
if (key === 'style' && typeof value === 'object' && value !== null) {
|
||||
for (const [sk, sv] of Object.entries(value)) el.style[sk] = sv;
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) {
|
||||
el.value = value == null ? '' : String(value); return;
|
||||
}
|
||||
if (key === 'checked' && tag === 'input') { el.checked = !!value; return; }
|
||||
if (key === 'disabled') { el.disabled = !!value; return; }
|
||||
if (key === 'selected' && tag === 'option') { el.selected = !!value; return; }
|
||||
|
||||
if (value == null || value === false || value === undefined)
|
||||
el.removeAttribute(key);
|
||||
else
|
||||
el.setAttribute(key, value === true ? '' : String(value));
|
||||
}
|
||||
|
||||
/** Remove a single prop from an element. */
|
||||
export function unsetProp(el, key) {
|
||||
if (key === 'key' || key === 'ref') return;
|
||||
if (key.startsWith('on:')) {
|
||||
const ev = key.slice(3);
|
||||
const map = el._evMap || {};
|
||||
if (map[ev]) { el.removeEventListener(ev, map[ev]); delete map[ev]; }
|
||||
el._evMap = map;
|
||||
return;
|
||||
}
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) return;
|
||||
if (key === 'checked' && tag === 'input') { el.checked = false; return; }
|
||||
if (key === 'disabled') { el.disabled = false; return; }
|
||||
if (key === 'selected' && tag === 'option') { el.selected = false; return; }
|
||||
el.removeAttribute(key);
|
||||
}
|
||||
|
||||
/** Diff two props objects and patch the element in place. */
|
||||
export function patchProps(el, oldP = {}, newP = {}) {
|
||||
for (const k of new Set([...Object.keys(oldP), ...Object.keys(newP)])) {
|
||||
const hasOld = k in oldP, hasNew = k in newP;
|
||||
if (hasOld && hasNew && Object.is(oldP[k], newP[k])) continue;
|
||||
if (hasNew) setProp(el, k, newP[k]);
|
||||
else unsetProp(el, k);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively clean up event listeners and child nodes. */
|
||||
export function sweepDom(el) {
|
||||
if (_unmountFn.fn) _unmountFn.fn(el);
|
||||
for (const ev of Object.keys(el._evMap || {})) el.removeEventListener(ev, el._evMap[ev]);
|
||||
while (el.firstChild) {
|
||||
const child = el.firstChild;
|
||||
if (child.nodeType === Node.ELEMENT_NODE) sweepDom(child);
|
||||
el.removeChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch children of a parent element.
|
||||
* Dispatches to keyed or unkeyed patching based on whether any vnode has a key.
|
||||
*/
|
||||
export function patchChildren(parent, oldCh, newCh) {
|
||||
const hasKeys = (ch) => ch.some(v => v?.props?.key != null);
|
||||
if (hasKeys(newCh) && hasKeys(oldCh))
|
||||
patchKeyed(parent, oldCh, newCh);
|
||||
else
|
||||
patchUnkeyed(parent, oldCh, newCh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unkeyed (index-based) children diff.
|
||||
*
|
||||
* Fix: _vnodeDom updated after EVERY vnode→dom assignment.
|
||||
*/
|
||||
export function patchUnkeyed(parent, oldCh, newCh) {
|
||||
const maxLen = Math.max(oldCh.length, newCh.length);
|
||||
let lastDom = null;
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const oldV = oldCh[i], newV = newCh[i];
|
||||
|
||||
if (!newV && oldV) {
|
||||
const d = getDom(oldV);
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (newV && !oldV) {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = d;
|
||||
continue;
|
||||
}
|
||||
|
||||
patchNode(parent, oldV, newV, null);
|
||||
lastDom = getDom(newV);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed children diff — preserves order, reuses DOM by key.
|
||||
*
|
||||
* Fix: proper element reordering using lastDom anchor tracking.
|
||||
*/
|
||||
export function patchKeyed(parent, oldCh, newCh) {
|
||||
const oldMap = new Map(
|
||||
oldCh.filter(v => v?.props?.key != null).map(v => [v.props.key, v])
|
||||
);
|
||||
const toRemove = new Set(oldMap.keys());
|
||||
let lastDom = null;
|
||||
|
||||
for (const newV of newCh) {
|
||||
const key = newV.props?.key;
|
||||
toRemove.delete(key);
|
||||
const oldV = oldMap.get(key);
|
||||
|
||||
if (oldV) {
|
||||
patchNode(parent, oldV, newV, null);
|
||||
const d = getDom(newV);
|
||||
if (d) {
|
||||
if (lastDom && d !== lastDom.nextSibling) {
|
||||
parent.insertBefore(d, lastDom.nextSibling || null);
|
||||
}
|
||||
lastDom = d;
|
||||
}
|
||||
} else {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, lastDom ? lastDom.nextSibling : null);
|
||||
lastDom = d;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of toRemove) {
|
||||
const oldV = oldMap.get(key);
|
||||
const d = getDom(oldV);
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch one VNode against another inside parent.
|
||||
*
|
||||
* - no old → create + insert
|
||||
* - no new → sweep + remove
|
||||
* - tag match → patchProps + patchChildren
|
||||
* - tag mismatch → replace
|
||||
*
|
||||
* Fix: _vnodeDom always set to the correct dom after patch.
|
||||
*/
|
||||
export function patchNode(parent, oldV, newV, anchor) {
|
||||
if (!oldV && !newV) return;
|
||||
|
||||
if (!oldV) {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, anchor || null);
|
||||
return;
|
||||
}
|
||||
if (!newV) {
|
||||
const d = getDom(oldV);
|
||||
if (d?.parentNode) {
|
||||
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
|
||||
d.parentNode.removeChild(d);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dom = getDom(oldV);
|
||||
if (!dom || !dom.parentNode) {
|
||||
const d = createDom(newV);
|
||||
_vnodeDom.set(newV, d);
|
||||
parent.insertBefore(d, anchor || null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tag changed → full replace
|
||||
if (oldV.tag !== newV.tag) {
|
||||
if (dom.nodeType === Node.ELEMENT_NODE) sweepDom(dom);
|
||||
const nd = createDom(newV);
|
||||
_vnodeDom.set(newV, nd);
|
||||
dom.parentNode.replaceChild(nd, dom);
|
||||
return;
|
||||
}
|
||||
|
||||
// Text node — fast path
|
||||
if (oldV.tag === '#text') {
|
||||
if (oldV.text !== newV.text) dom.nodeValue = newV.text;
|
||||
_vnodeDom.set(newV, dom);
|
||||
return;
|
||||
}
|
||||
|
||||
// Element: patch in place
|
||||
patchProps(dom, oldV.props || {}, newV.props || {});
|
||||
patchChildren(dom, oldV.ch || [], newV.ch || []);
|
||||
_vnodeDom.set(newV, dom);
|
||||
}
|
||||
Reference in New Issue
Block a user