obsidian-visualiser/shared/dom.util.ts

365 lines
14 KiB
TypeScript

import { buildIcon, getIcon, iconLoaded, loadIcon, type IconifyIcon } from 'iconify-icon';
import { loading } from './components.util';
export type RedrawableHTML = HTMLElement & { update?: (recursive: boolean) => void }
export type Node = HTMLElement & { update?: (recursive: boolean) => void } | SVGElement | Text | undefined;
export type NodeChildren = Array<Reactive<Node>> | undefined;
export type Class = Reactive<string | Array<Class> | Record<string, boolean> | undefined>;
type Listener<K extends keyof HTMLElementEventMap> = | ((this: RedrawableHTML, ev: HTMLElementEventMap[K]) => any) | {
options?: boolean | AddEventListenerOptions;
listener: (this: RedrawableHTML, 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>
};
}
let defered = false, _set = new Set<() => void>();
const _defer = (fn: () => void) => {
if(!defered)
{
defered = true;
queueMicrotask(() => {
_set.forEach(e => e());
_set.clear();
defered = false;
});
}
_set.add(fn);
}
export const cancelPropagation = (e: Event) => e.stopImmediatePropagation();
export function dom<T extends keyof HTMLElementTagNameMap>(tag: T, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[T] & { update?: (recursive: boolean) => void };
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): HTMLElementTagNameMap[T] & { array?: DOMList<U>, update?: (recursive: boolean) => void };
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 }): HTMLElementTagNameMap[T] & { array?: DOMList<U>, update?: (recursive: boolean) => void }
{
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))
{
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<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) _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'))
{
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 && setup)
{
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(cls?: Class, children?: NodeChildren): HTMLElementTagNameMap['div'] & { update?: (recursive: boolean) => void }
export function div<U extends any>(cls?: Class, children?: { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): HTMLElementTagNameMap['div'] & { array: DOMList<U>, update?: (recursive: boolean) => void }
export function div<U extends any>(cls?: Class, children?: NodeChildren | { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): HTMLElementTagNameMap['div'] & { array?: DOMList<U>, update?: (recursive: boolean) => void }
{
//@ts-expect-error
return dom("div", { class: cls }, children);
}
export function span(cls?: Class, text?: Reactive<string | Text>): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void }
{
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, properties: {
class?: Class;
style?: Reactive<Record<string, string | undefined | boolean | number> | 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<Record<string, string | undefined> | string>;
class?: Class;
}
const iconLoadingRegistry: Map<string, Promise<Required<IconifyIcon>> | 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 '';
}
}