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; _bindings = new Set>(); // init constructor(initialValue: T) { this._value = initialValue; } // value get value(): T { return this._value; } set value(newValue: T) { 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); } subscribeSilent(fn: (newValue: T) => void): void { this._bindings.add(fn); } // stringify toString(): string { return JSON.stringify(this._value); } } export class ListState extends State> { private additionHandlers = new Set>(); private removalHandlers = new Map>>(); private genericRemovalHandlers = new Set>(); // init constructor(initialItems?: T[]) { super(new Set(initialItems)); } // list add(...items: T[]): void { items.forEach((item) => { if (this.value.has(item)) return; this.value.add(item); this.additionHandlers.forEach((handler) => handler(item)); }); this.callSubscriptions(); } remove(...items: T[]): void { items.forEach((item) => { this.value.delete(item); this.genericRemovalHandlers.forEach((handler) => handler(item)); if (!this.removalHandlers.has(item)) return; this.removalHandlers.get(item)!.forEach((handler) => handler(item)); this.removalHandlers.delete(item); }); 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 { if (!this.removalHandlers.has(item)) this.removalHandlers.set(item, new Set()); this.removalHandlers.get(item)!.add(handler); } handleRemovals(handler: RemovalSubscription): void { this.genericRemovalHandlers.add(handler); } // stringification toString(): string { const array = [...this.value.values()]; const json = JSON.stringify(array); return json; } } export class MapState extends State> { private additionHandlers = new Set>(); private removalHandlers = new Map>>(); private genericRemovalHandlers = new Set>(); // init constructor(initialItems?: [string, T][]) { super(new Map(initialItems)); } // list set(key: string, item: T): void { this.remove(key); this.value.set(key, item); this.additionHandlers.forEach((handler) => handler(item)); this.callSubscriptions(); } remove(key: string): void { const item = this.value.get(key); if (!item) return; this.value.delete(key); this.callSubscriptions(); this.genericRemovalHandlers.forEach((handler) => handler(item)); if (!this.removalHandlers.has(item)) return; this.removalHandlers.get(item)!.forEach((handler) => handler(item)); this.removalHandlers.delete(item); } clear() { [...this.value.keys()].forEach((key) => this.remove(key)); } // handlers handleAddition(handler: AdditionSubscription): void { this.additionHandlers.add(handler); [...this.value.values()].forEach(handler); } handleRemoval(item: T, handler: RemovalSubscription): void { if (!this.removalHandlers.has(item)) this.removalHandlers.set(item, new Set()); this.removalHandlers.get(item)!.add(handler); } handleRemovals(handler: RemovalSubscription): void { this.genericRemovalHandlers.add(handler); } // stringification toString(): string { const array = [...this.value.entries()]; 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.subscribeSilent(() => (proxyState.value = fn())), ); return proxyState; } export function bulkSubscribe( statesToSubscibe: State[], fn: () => void, ): void { statesToSubscibe.forEach((state) => state.subscribeSilent(fn)); } 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, initialItems: any[] = [], ): ListState { const storedString = localStorage.getItem(localStorageKey) ?? ""; 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 function restoreMapState( localStorageKey: string, initialItems: [string, any][] = [], ): MapState { const storedString = localStorage.getItem(localStorageKey) ?? ""; try { const array = JSON.parse(storedString); if (!Array.isArray(array)) throw ""; initialItems = array; } catch {} const state = new MapState(initialItems); persistState(localStorageKey, state); return state; } export type StateItemConverter = (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: Event) => { if (!(e instanceof KeyboardEvent)) return; if (e.key != "Enter") return; value(e); }); break; } default: { element.addEventListener(directiveValue, value); } } break; } case "keystroke": { element.addEventListener("keydown", (e: Event) => { if (!(e instanceof KeyboardEvent)) return; if (e.metaKey == false && e.ctrlKey == false) return; if (e.key != directiveValue) return; value(e); }); break; } case "subscribe": { 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; } case "children": { switch (directiveValue) { case "set": { const state = value as State; state.subscribe((newValue) => { element.innerHTML = ""; element.append(...[newValue].flat()); }); break; } case "append": case "prepend": { try { const [listState, toElement] = value as [ listState: ListState, StateItemConverter, ]; listState.handleAddition((newItem) => { const child = toElement(newItem); listState.handleRemoval(newItem, () => child.remove(), ); if (directiveValue == "append") { element.append(child); } else if (directiveValue == "prepend") { element.prepend(child); } }); } catch (error) { console.error(error); throw `error: cannot process subscribe:children directive. \n Usage: "children:append={[list, converter]}"; you can find a more detailed example in the documentation.`; } } } break; } default: element.setAttribute(attributename, value); } }); children.filter((x) => x).forEach((child) => element.append(child)); return element; }