import { buildIcon, getIcon, iconLoaded, loadIcon, type IconifyIcon } from 'iconify-icon'; import { loading } from './components.util'; import { deepEquals } from './general.util'; export type RedrawableHTML = HTMLElement & { update?: (recursive: boolean) => void } export type Node = HTMLElement & { update?: (recursive: boolean) => void } | SVGElement | Text | undefined; export type NodeChildren = Array> | undefined; export type Class = Reactive | Record | undefined>; type Listener = | ((this: RedrawableHTML, ev: HTMLElementEventMap[K]) => any) | { options?: boolean | AddEventListenerOptions; listener: (this: RedrawableHTML, ev: HTMLElementEventMap[K]) => any; } | undefined; export type Reactive = T | (() => T); export interface DOMList extends Array{ render(redraw?: boolean): void; }; export interface NodeProperties { attributes?: Record>; text?: Reactive; class?: Class; style?: Reactive | string>; listeners?: { [K in keyof HTMLElementEventMap]?: Listener }; } let defered = false, _deferSet = new Set<() => void>(); const _defer = (fn: () => void) => { if(!defered) { defered = true; queueMicrotask(() => { _deferSet.forEach(e => e()); _deferSet.clear(); defered = false; }); } _deferSet.add(fn); } let reactiveEffect: (() => void) | null = null; const _reactiveCache = new WeakMap(); // Store a Weak map of all the tracked object. // For each object, we have a map of its properties, allowing us to effectively listen to absolutely everything on the object // For a given property, we have a set of "effect" (function called on value update) const _tracker = new WeakMap<{}, Map void>>>(); function trigger(target: T, key: string | symbol) { const dependencies = _tracker.get(target) if(!dependencies) return; const set = dependencies.get(key); set?.forEach(e => e()); } function track(target: T, key: string | symbol) { if(!reactiveEffect) return; let dependencies = _tracker.get(target); if(!dependencies) { dependencies = new Map(); _tracker.set(target, dependencies); } let set = dependencies.get(key); if(!set) { set = new Set(); dependencies.set(key, set); } set.add(reactiveEffect); } export function reactive(obj: T): T { if(_reactiveCache.has(obj)) return _reactiveCache.get(obj)!; const proxy = new Proxy(obj, { get: (target, key, receiver) => { track(target, key); const value = Reflect.get(target, key, receiver); if(value && typeof value === 'object') return reactive(value); return value; }, set: (target, key, value, receiver) => { const old = Reflect.get(target, key, receiver); const result = Reflect.set(target, key, value, receiver); if(old !== value) _defer(() => trigger(target, key)); return result; }, }); _reactiveCache.set(obj, proxy); return proxy; } function requireReactive(reactiveProperty: Reactive, effect: (processed: T) => void) { if(typeof reactiveProperty !== 'function') return effect(reactiveProperty); else { // Function wrapping to keep the context safe and secured. // Also useful to retrigger the tracking system if the reactive property provides new properties (via conditions for example) const secureEffect = () => effect((reactiveProperty as () => T)()); const secureContext = () => { reactiveEffect = secureContext; try { secureEffect(); } finally { reactiveEffect = null; } }; secureContext(); } } export const cancelPropagation = (e: Event) => e.stopImmediatePropagation(); export function dom(tag: T, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[T] & { update?: (recursive: boolean) => void }; export function dom(tag: T, properties?: NodeProperties, children?: { render: (data: U) => Node, list?: Array, redraw?: boolean }): HTMLElementTagNameMap[T] & { array?: DOMList, update?: (recursive: boolean) => void }; export function dom(tag: T, properties?: NodeProperties, children?: NodeChildren | { render: (data: U) => Node, list?: Array, redraw?: boolean }): HTMLElementTagNameMap[T] & { array?: DOMList, update?: (recursive: boolean) => void } { const element = document.createElement(tag) as HTMLElementTagNameMap[T] & { array?: DOMList, update: (recursive: boolean) => void }; let setup = true, updating = false; const _cache = new Map(); const update = (recursive: boolean) => { updating = true; if(children !== undefined && (setup || recursive)) { if(Array.isArray(children)) { for(const c of children) { const child = typeof c === 'function' ? c() : c; if(child !== undefined) { element.appendChild(child); recursive && 'update' in child && _defer(() => child.update!(true)); } } } else if(children.list !== undefined) { if(setup) { children.list.forEach(e => _cache.set(e, children.render(e))); const _push = children.list.push; children.list.push = (...items: U[]) => { items.forEach(e => { if(!_cache.has(e)) { const dom = children.render(e); _cache.set(e, dom); dom && element.appendChild(dom); } else { const dom = _cache.get(e); dom && element.appendChild(dom); } }); if(children.redraw) _defer(() => update(false)); return _push.bind(children.list)(...items); }; const _splice = children.list.splice; children.list.splice = (start: number, deleteCount: number, ...items: U[]) => { const list = _splice.bind(children.list)(start, deleteCount, ...items); list.forEach(e => { if(!children.list!.find(_e => _e === e)) _cache.delete(e); }); element.array!.render(); return list; }; } else if(recursive) _cache.forEach((v, k) => v && 'update' in v && v.update!(true)); element.array = children.list as DOMList; element.array.render = (redraw?: boolean) => { element.replaceChildren(...children.list?.map(e => _cache.get(e)).filter(e => !!e) ?? []); if((redraw !== undefined || children.redraw !== undefined) && !updating) _defer(() => update(redraw ?? children.redraw!)); } element.array.render(); } } if(properties?.attributes) { for(const [k, v] of Object.entries(properties.attributes)) { if(!setup && typeof v !== 'function') continue; const value = typeof v === 'function' ? v() : v; if(typeof value === 'string' || typeof value === 'number') element.setAttribute(k, value.toString(10)); else if(typeof value === 'boolean') element.toggleAttribute(k, value); } } if(properties?.text && (setup || typeof properties.text === 'function')) { requireReactive(properties.text, (text) => { if(typeof text === 'string') element.textContent = text; else if(typeof text === 'number') element.textContent = text.toString(); else element.appendChild(text as Text); }) } if(properties?.listeners && setup) { for(let [k, v] of Object.entries(properties.listeners)) { const key = k as keyof HTMLElementEventMap, value = v as Listener; if(typeof value === 'function') element.addEventListener(key, value.bind(element)); else if(value) element.addEventListener(key, value.listener.bind(element), value.options); } } styling(element, properties ?? {}); updating = false; }; update(false); setup = false; element.update = update; return element; } export function div(cls?: Class, children?: NodeChildren): HTMLElementTagNameMap['div'] & { update?: (recursive: boolean) => void } export function div(cls?: Class, children?: { render: (data: U) => Node, list?: Array, redraw?: boolean }): HTMLElementTagNameMap['div'] & { array: DOMList, update?: (recursive: boolean) => void } export function div(cls?: Class, children?: NodeChildren | { render: (data: U) => Node, list?: Array, redraw?: boolean }): HTMLElementTagNameMap['div'] & { array?: DOMList, update?: (recursive: boolean) => void } { //@ts-expect-error return dom("div", { class: cls }, children); } export function span(cls?: Class, text?: Reactive): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void } { return dom("span", { class: cls, text: text }); } export function svg(tag: K, properties?: NodeProperties, children?: SVGElement[]): SVGElementTagNameMap[K] { const element = document.createElementNS("http://www.w3.org/2000/svg", tag); if(children && children.length > 0) for(const c of children) if(c !== undefined) element.appendChild(c); if(properties?.attributes) for(const [k, v] of Object.entries(properties.attributes)) if(typeof v === 'string') element.setAttribute(k, v); else if(typeof v === 'boolean') element.toggleAttribute(k, v); if(properties?.text && typeof properties.text === 'string') element.textContent = properties.text; styling(element, properties ?? {}); return element; } export function text(data: Reactive): Text { const text = document.createTextNode(''); requireReactive(data, (txt) => text.textContent = txt.toString()); return text; } export function styling(element: SVGElement | RedrawableHTML, properties: { class?: Class; style?: Reactive | string>; }): SVGElement | RedrawableHTML { if(properties?.class) { element.setAttribute('class', mergeClasses(properties.class)); } if(properties?.style) { if(typeof properties.style === 'string') { element.setAttribute('style', properties.style); } else for(const [k, v] of Object.entries(properties.style)) if(v !== undefined && v !== false) element.attributeStyleMap.set(k, v); } return element; } export interface IconProperties { mode?: 'svg' | 'style' | 'bg' | 'mask'; inline?: boolean; width?: string | number; height?: string | number; hFlip?: boolean; vFlip?: boolean; rotate?: number; style?: Reactive | string>; class?: Class; } const iconLoadingRegistry: Map> | null | undefined> = new Map(); export function icon(name: string, properties?: IconProperties) { const element = dom('div', { class: properties?.class, style: properties?.style }); const build = (icon: IconifyIcon | null | undefined) => { if(!icon) return element.replaceChildren(); const built = buildIcon(icon, properties); const dom = svg('svg', { attributes: built.attributes }); dom.innerHTML = built.body; element.replaceChildren(dom); } if(!iconLoaded(name)) { element.appendChild(loading('small')); if(!iconLoadingRegistry.has(name)) iconLoadingRegistry.set(name, loadIcon(name)); iconLoadingRegistry.get(name)?.then(build); } else build(getIcon(name)); return element; } export function mergeClasses(cls: Class): string { const classes = typeof cls === 'function' ? cls() : cls; if(typeof classes === 'string') { return classes.trim(); } else if(Array.isArray(classes)) { return classes.map(e => mergeClasses(e)).join(' '); } else if(classes) { return Object.entries(classes).filter(e => e[1]).map(e => e[0].trim()).join(' '); } else { return ''; } }