import { v4 } from "uuid"; /* DATA MODEL */ /* Types */ export interface Identifiable { id: string; } export interface Stringifiable { toString: () => string; } /* Utility */ export function UUID(): string { return v4(); } /* State */ export type StateSubscription = (newValue: T) => void; export type AdditionSubscription = (newItem: T) => void; export type RemovalSubscription = (removedItem: T) => void; export class State { private _value: T; private _bindings = new Set>(); // init constructor(initialValue: T) { this._value = initialValue; } // value get value(): T { return this._value; } set value(newValue: T) { if (this._value == newValue) return; this._value = newValue; this.callSubscriptions(); } // subscriptions callSubscriptions(): void { this._bindings.forEach((fn) => fn(this._value)); } subscribe(fn: (newValue: T) => void): void { this._bindings.add(fn); fn(this._value); } // stringify toString(): string { return JSON.stringify(this._value); } } export class ListState extends State> { private additionHandlers = new Set>(); private removalHandlers = new Map>(); // init constructor(initialItems?: T[]) { super(new Set(initialItems)); } // list add(...items: T[]): void { items.forEach((item) => { this.value.add(item); this.additionHandlers.forEach((handler) => handler(item)); }); this.callSubscriptions(); } remove(...items: T[]): void { items.forEach((item) => { this.value.delete(item); const id = item.id; if (!this.removalHandlers.has(id)) return; this.removalHandlers.get(id)!(item); this.removalHandlers.delete(id); }); this.callSubscriptions(); } clear() { this.remove(...this.value.values()); } // handlers handleAddition(handler: AdditionSubscription): void { this.additionHandlers.add(handler); [...this.value.values()].forEach(handler); } handleRemoval(item: T, handler: RemovalSubscription): void { this.removalHandlers.set(item.id, handler); } // stringification toString(): string { const array = [...this.value]; const json = JSON.stringify(array); return json; } } // UTILITY export function createProxyState( statesToSubscibe: State[], fn: () => T ): State { const proxyState = new State(fn()); statesToSubscibe.forEach((state) => state.subscribe(() => (proxyState.value = fn())) ); return proxyState; } function persistState(localStorageKey: string, state: State) { state.subscribe(() => { const stringifiedValue = state.toString(); localStorage.setItem(localStorageKey, stringifiedValue); }); } export function restoreState( localStorageKey: string, initialStateValue: T ): State { const storedString = localStorage.getItem(localStorageKey) ?? JSON.stringify(initialStateValue); const convertedValue = JSON.parse(storedString); const state = new State(convertedValue); persistState(localStorageKey, state); return state; } export function restoreListState( localStorageKey: string ): ListState { const storedString = localStorage.getItem(localStorageKey) ?? ""; let initialItems: any[] = []; try { const array = JSON.parse(storedString); if (!Array.isArray(array)) throw ""; initialItems = array; } catch {} const state = new ListState(initialItems); persistState(localStorageKey, state); return state; } export type ListItemConverter = ( item: T ) => HTMLElement; /* JSX */ export function createElement( tagName: keyof HTMLElementTagNameMap, attributes: { [key: string]: any } | null = {}, ...children: (HTMLElement | string)[] ) { const element = document.createElement(tagName); if (attributes != null) Object.entries(attributes).forEach((entry) => { const [attributename, value] = entry; const [directiveKey, directiveValue] = attributename.split(":"); switch (directiveKey) { case "on": { switch (directiveValue) { case "enter": { element.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key != "Enter") return; value(); }); break; } default: { element.addEventListener(directiveValue, value); } } break; } case "subscribe": { if (directiveValue == "children") { element.style.scrollBehavior = "smooth"; try { const [listState, toElement] = value as [ listState: ListState, ListItemConverter ]; listState.handleAddition((newItem) => { const child = toElement(newItem); listState.handleRemoval(newItem, () => child.remove() ); element.append(child); element.scrollTop = element.scrollHeight; }); } catch { throw `error: cannot process subscribe:children directive because ListItemConverter is not defined. Usage: "subscribe:children={[list, converter]}"; you can find a more detailed example in the documentation`; } } else { const state = value as State; state.subscribe( (newValue) => (element[directiveValue] = newValue) ); } break; } case "bind": { const state = value as State; state.subscribe( (newValue) => (element[directiveValue] = newValue) ); element.addEventListener( "input", () => (state.value = (element as any)[directiveValue]) ); break; } case "toggle": { if (value.subscribe) { const state = value as State; state.subscribe((newValue) => element.toggleAttribute(directiveValue, newValue) ); } else { element.toggleAttribute(directiveValue, value); } break; } case "set": { const state = value as State; state.subscribe((newValue) => element.setAttribute(directiveValue, newValue) ); break; } default: element.setAttribute(attributename, value); } }); children.filter((x) => x).forEach((child) => element.append(child)); return element; }