364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
import { iconLoaded, loadIcon } from 'iconify-icon';
|
|
|
|
export type RedrawableHTML<T extends keyof HTMLElementTagNameMap> = HTMLElementTagNameMap[T] & { update: (recursive: boolean) => void }
|
|
export type Node = RedrawableHTML<any> & { update: (recursive: boolean) => void } | SVGElement | Text | undefined;
|
|
export type NodeChildren = Array<Node> | undefined;
|
|
|
|
export type Class = Reactive<string | Array<Class> | Record<string, boolean> | undefined>;
|
|
type Listener<K extends keyof HTMLElementEventMap> = | ((this: HTMLElement, ev: HTMLElementEventMap[K]) => any) | {
|
|
options?: boolean | AddEventListenerOptions;
|
|
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any;
|
|
} | undefined;
|
|
|
|
export type Reactive<T> = T | (() => T);
|
|
export interface DOMList<T> extends Array<T>{
|
|
render(redraw?: boolean): void;
|
|
};
|
|
|
|
export interface NodeProperties
|
|
{
|
|
attributes?: Record<string, Reactive<string | undefined | boolean | number>>;
|
|
text?: Reactive<string | Text>;
|
|
class?: Class;
|
|
style?: Reactive<Record<string, string | undefined | boolean | number> | string>;
|
|
listeners?: {
|
|
[K in keyof HTMLElementEventMap]?: Listener<K>
|
|
};
|
|
}
|
|
|
|
export const cancelPropagation = (e: Event) => e.stopImmediatePropagation();
|
|
export function dom<T extends keyof HTMLElementTagNameMap>(tag: T, properties?: NodeProperties, children?: NodeChildren): RedrawableHTML<T>;
|
|
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): RedrawableHTML<T> & { array?: DOMList<U> };
|
|
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: NodeChildren | { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): RedrawableHTML<T> & { array?: DOMList<U> }
|
|
{
|
|
const element = document.createElement(tag) as HTMLElementTagNameMap[T] & { array?: DOMList<U>, update: (recursive: boolean) => void };
|
|
let setup = true, updating = false;
|
|
|
|
const _cache = new Map<U, Node>();
|
|
|
|
const update = (recursive: boolean) => {
|
|
updating = true;
|
|
|
|
if(children !== undefined && (setup || recursive))
|
|
{
|
|
element.replaceChildren();
|
|
if(Array.isArray(children))
|
|
{
|
|
for(const c of children)
|
|
{
|
|
if(c !== undefined)
|
|
{
|
|
element.appendChild(c);
|
|
recursive && 'update' in c && c.update(true);
|
|
}
|
|
}
|
|
}
|
|
else if(children.list !== undefined)
|
|
{
|
|
if(setup || recursive)
|
|
{
|
|
_cache.clear();
|
|
children.list.forEach(e => _cache.set(e, children.render(e)));
|
|
}
|
|
|
|
if(setup)
|
|
{
|
|
const _push = children.list.push;
|
|
children.list.push = (...items: U[]) => {
|
|
items.forEach(e => {
|
|
const dom = children.render(e);
|
|
_cache.set(e, dom);
|
|
dom && element.appendChild(dom);
|
|
});
|
|
if(children.redraw) 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 => _cache.get(e)?.remove() || _cache.delete(e));
|
|
if(children.redraw) update(false);
|
|
return list;
|
|
};
|
|
}
|
|
|
|
element.array = children.list as DOMList<U>;
|
|
element.array.render = (redraw?: boolean) => {
|
|
element.replaceChildren(...children.list?.map(e => _cache.get(e)).filter(e => !!e) ?? []);
|
|
if((redraw !== undefined || children.redraw !== undefined) && !updating) 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'))
|
|
{
|
|
const text = typeof properties.text === 'function' ? properties.text() : properties.text;
|
|
if(typeof text === 'string')
|
|
element.textContent = text;
|
|
else
|
|
element.appendChild(text as Text);
|
|
}
|
|
|
|
if(properties?.listeners)
|
|
{
|
|
for(let [k, v] of Object.entries(properties.listeners))
|
|
{
|
|
const key = k as keyof HTMLElementEventMap, value = v as Listener<typeof key>;
|
|
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<U extends any>(cls?: Class, children?: NodeChildren): RedrawableHTML<'div'>
|
|
export function div<U extends any>(cls?: Class, children?: { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): RedrawableHTML<'div'> & { array: DOMList<U> }
|
|
export function div<U extends any>(cls?: Class, children?: NodeChildren | { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): RedrawableHTML<'div'> & { array?: DOMList<U> }
|
|
{
|
|
//@ts-expect-error
|
|
return dom("div", { class: cls }, children);
|
|
}
|
|
export function span(cls?: Class, text?: Reactive<string | Text>): RedrawableHTML<'span'>
|
|
{
|
|
return dom("span", { class: cls, text: text });
|
|
}
|
|
export function svg<K extends keyof SVGElementTagNameMap>(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: string): Text;
|
|
export function text(data: {}, _txt: Reactive<string>): Text;
|
|
export function text(data: any, _txt?: Reactive<string>): Text
|
|
{
|
|
if(typeof data === 'string')
|
|
return document.createTextNode(data);
|
|
else if(_txt)
|
|
{
|
|
const cache = new Map<string, number>();
|
|
let txtCache = (typeof _txt === 'function' ? _txt() : _txt);
|
|
const setup = (property: string) => {
|
|
const prop = property.split('.');
|
|
let obj = data;
|
|
|
|
for(let i = 0; i < prop.length - 1; i++)
|
|
{
|
|
if(prop[i]! in obj) obj = obj[prop[i]!];
|
|
else return 0;
|
|
}
|
|
|
|
const last = prop.slice(-1)[0]!;
|
|
if(last in obj)
|
|
{
|
|
const prototype = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(obj), last);
|
|
let clone = obj[last];
|
|
delete obj[last];
|
|
Object.defineProperty(obj, last, { ...prototype, get: () => prototype?.get ? prototype.get() : clone, set: (v) => { if(prototype?.set) { prototype.set(v); clone = obj[last]; } else if(!prototype?.get) { clone = v; } cache.set(property, v); replace(); }, enumerable: true, configurable: true, });
|
|
cache.set(property, clone);
|
|
|
|
return obj[last];
|
|
}
|
|
else return 0;
|
|
}
|
|
const apply = (_setup: boolean) => txtCache.replace(/\{\{(.+?)\}\}/g, (_, txt: string) => {
|
|
let i = 0, current = 0, property = '', nextOp = '';
|
|
const _compute = () => {
|
|
if(property.length > 0)
|
|
{
|
|
let value = 0;
|
|
if(_setup)
|
|
value = setup(property);
|
|
else
|
|
value = cache.get(property)!;
|
|
|
|
if(nextOp === '+')
|
|
current += value;
|
|
else if(nextOp === '-')
|
|
current -= value;
|
|
else if(nextOp === '*')
|
|
current *= value;
|
|
else if(nextOp === '/')
|
|
current /= value;
|
|
else if(nextOp === '%')
|
|
current /= value;
|
|
else if(nextOp === '')
|
|
current = value;
|
|
|
|
nextOp = '';
|
|
property = '';
|
|
}
|
|
}
|
|
while(i < txt.length)
|
|
{
|
|
switch(txt.charAt(i))
|
|
{
|
|
case '+':
|
|
case '-':
|
|
case '*':
|
|
case '/':
|
|
case '%':
|
|
_compute();
|
|
|
|
nextOp = txt.charAt(i).trim();
|
|
break;
|
|
case ' ':
|
|
break;
|
|
default:
|
|
property += txt.charAt(i);
|
|
break;
|
|
}
|
|
i++;
|
|
}
|
|
_compute();
|
|
return current.toString();
|
|
});
|
|
const replace = () => {
|
|
const txt = (typeof _txt === 'function' ? _txt() : _txt);
|
|
if(txt !== txtCache)
|
|
{
|
|
txtCache = txt;
|
|
node.textContent = apply(true);
|
|
}
|
|
else node.textContent = apply(false);
|
|
};
|
|
const node = document.createTextNode(apply(true));
|
|
return node;
|
|
}
|
|
else return document.createTextNode('');
|
|
}
|
|
export function styling(element: SVGElement | RedrawableHTML<any>, properties: {
|
|
class?: Class;
|
|
style?: Reactive<Record<string, string | undefined | boolean | number> | string>;
|
|
}): SVGElement | HTMLElement
|
|
{
|
|
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?: string;
|
|
inline?: boolean;
|
|
noobserver?: boolean;
|
|
width?: string | number;
|
|
height?: string | number;
|
|
flip?: string;
|
|
rotate?: number|string;
|
|
style?: Record<string, string | undefined> | string;
|
|
class?: Class;
|
|
}
|
|
const iconCache: Map<string, HTMLElement> = new Map();
|
|
export function icon(name: string, properties?: IconProperties): HTMLElement
|
|
{
|
|
let element;
|
|
|
|
if(iconCache.has(name))
|
|
element = iconCache.get(name)!.cloneNode() as HTMLElement;
|
|
else
|
|
{
|
|
element = document.createElement('iconify-icon');
|
|
|
|
if(!iconLoaded(name))
|
|
loadIcon(name);
|
|
|
|
element.setAttribute('icon', name);
|
|
|
|
iconCache.set(name, element.cloneNode() as HTMLElement);
|
|
}
|
|
|
|
properties?.mode && element.setAttribute('mode', properties?.mode.toString());
|
|
properties?.inline && element.toggleAttribute('inline', properties?.inline);
|
|
element.toggleAttribute('noobserver', properties?.noobserver ?? true);
|
|
properties?.width && element.setAttribute('width', properties?.width.toString());
|
|
properties?.height && element.setAttribute('height', properties?.height.toString());
|
|
properties?.flip && element.setAttribute('flip', properties?.flip.toString());
|
|
properties?.rotate && element.setAttribute('rotate', properties?.rotate.toString());
|
|
|
|
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) element.attributeStyleMap.set(k, v);
|
|
}
|
|
|
|
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 '';
|
|
}
|
|
} |