From 632eef6424d8dc8d40c2906177892697679e7b85 Mon Sep 17 00:00:00 2001 From: Joris Date: Sat, 19 Apr 2025 12:36:38 +0200 Subject: Add ZIG server --- frontend/ts/src/lib/rx.ts | 781 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 781 insertions(+) create mode 100644 frontend/ts/src/lib/rx.ts (limited to 'frontend/ts/src/lib/rx.ts') diff --git a/frontend/ts/src/lib/rx.ts b/frontend/ts/src/lib/rx.ts new file mode 100644 index 0000000..5edd3c1 --- /dev/null +++ b/frontend/ts/src/lib/rx.ts @@ -0,0 +1,781 @@ +// Rx 3.0.0 + +// Html + +export type Html + = false + | undefined + | null + | string + | number + | Tag + | WithState + | WithState2 + | WithState3 + | WithState4 + | WithState5 + | WithState6 + | WithState7 + | Array + | Rx + +interface Tag { + type: 'Tag' + tagName: string + isSvg: boolean + attributes: Attributes + children?: Array + onmount?: (element: Element) => void + onunmount?: (element: Element) => void +} + +interface WithState { + type: 'WithState' + init: A + getChildren: (v: Var) => Html +} + +interface WithState2 { + type: 'WithState2' + init: [A, B] + getChildren: (v1: Var, v2: Var) => Html +} + +interface WithState3 { + type: 'WithState3' + init: [A, B, C] + getChildren: (v1: Var, v2: Var, v3: Var) => Html +} + +interface WithState4 { + type: 'WithState4' + init: [A, B, C, D] + getChildren: (v1: Var, v2: Var, v3: Var, v4: Var) => Html +} + +interface WithState5 { + type: 'WithState5' + init: [A, B, C, D, E] + getChildren: (v1: Var, v2: Var, v3: Var, v4: Var, v5: Var) => Html +} + +interface WithState6 { + type: 'WithState6' + init: [A, B, C, D, E, F] + getChildren: (v1: Var, v2: Var, v3: Var, v4: Var, v5: Var, v6: Var) => Html +} + +interface WithState7 { + type: 'WithState7' + init: [A, B, C, D, E, F, G] + getChildren: (v1: Var, v2: Var, v3: Var, v4: Var, v5: Var, v6: Var, v7: Var) => Html +} + +export interface Attributes { + [key: string]: Rx | AttributeValue +} + +type AttributeValue + = undefined + | null + | string + | number + | boolean + | ((event: Event) => void) + | ((element: Element) => void) + +function isHtml(x: any): x is Html { + return (typeof x === 'string' + || typeof x === 'number' + || isTag(x) + || isWithState(x) + || isWithState2(x) + || isWithState3(x) + || isWithState4(x) + || isWithState5(x) + || isWithState6(x) + || isWithState7(x) + || isRx(x) + || Array.isArray(x)) +} + +type ValueOrArray = T | Array> + +export function s( + tagName: string, + x?: Attributes | Html, + ...children: Array +): Tag { + return node(true, tagName, x, ...children) +} + +export function h( + tagName: string, + x?: Attributes | Html, + ...children: Array +): Tag { + return node(false, tagName, x, ...children) +} + +export function node( + isSvg: boolean, + tagName: string, + x?: Attributes | Html, + ...children: Array +): Tag { + if (x === undefined || x == null || x === false) { + return { + type: 'Tag', + tagName, + isSvg, + attributes: {} + } + } else if (isHtml(x)) { + return { + type: 'Tag', + tagName, + isSvg, + attributes: {}, + children: [x, ...children], + } + } else { + let attributes = x as Attributes + let onmount, onunmount + if ('onmount' in attributes) { + onmount = attributes['onmount'] as (element: Element) => void + delete attributes['onmount'] + } + if ('onunmount' in attributes) { + onunmount = attributes['onunmount'] as (element: Element) => void + delete attributes['onunmount'] + } + return { + type: 'Tag', + tagName, + isSvg, + attributes, + children, + onmount, + onunmount + } + } +} + +export function withState(init: A, getChildren: (v: Var) => Html): WithState { + return { + type: 'WithState', + init, + getChildren + } +} + +export function withState2(init: [A, B], getChildren: (v1: Var, v2: Var) => Html): WithState2 { + return { + type: 'WithState2', + init, + getChildren + } +} + +export function withState3(init: [A, B, C], getChildren: (v1: Var, v2: Var, v3: Var) => Html): WithState3 { + return { + type: 'WithState3', + init, + getChildren + } +} + +export function withState4(init: [A, B, C, D], getChildren: (v1: Var, v2: Var, v3: Var, v4: Var) => Html): WithState4 { + return { + type: 'WithState4', + init, + getChildren + } +} + +export function withState5(init: [A, B, C, D, E], getChildren: (v1: Var, v2: Var, v3: Var, v4: Var, v5: Var) => Html): WithState5 { + return { + type: 'WithState5', + init, + getChildren + } +} + +export function withState6(init: [A, B, C, D, E, F], getChildren: (v1: Var, v2: Var, v3: Var, v4: Var, v5: Var, v6: Var) => Html): WithState6 { + return { + type: 'WithState6', + init, + getChildren + } +} + +export function withState7(init: [A, B, C, D, E, F, G], getChildren: (v1: Var, v2: Var, v3: Var, v4: Var, v5: Var, v6: Var, v7: Var) => Html): WithState7 { + return { + type: 'WithState7', + init, + getChildren + } +} + +// Rx + +export type RxAble = Rx | A + +export class Rx { + map(f: (value: A) => B): Rx { + return new Map(this, f) + } + + flatMap(f: (value: A) => Rx): Rx { + return new FlatMap(this, f) + } +} + +export function map2(rx: [Rx, Rx], fn: (a: A, b: B) => C): Rx { + return rx[0].flatMap(a => rx[1].map(b => fn(a, b))) +} + +export function map3(rx: [Rx, Rx, Rx], fn: (a: A, b: B, c: C) => D): Rx { + return rx[0].flatMap(a => rx[1].flatMap(b => rx[2].map(c => fn(a, b, c)))) +} + +export function map4(rx: [Rx, Rx, Rx, Rx], fn: (a: A, b: B, c: C, d: D) => E): Rx { + return rx[0].flatMap(a => rx[1].flatMap(b => rx[2].flatMap(c => rx[3].map(d => fn(a, b, c, d))))) +} + +export function map5(rx: [Rx, Rx, Rx, Rx, Rx], fn: (a: A, b: B, c: C, d: D, e: E) => F): Rx { + return rx[0].flatMap(a => rx[1].flatMap(b => rx[2].flatMap(c => rx[3].flatMap(d => rx[4].map(e => fn(a, b, c, d, e)))))) +} + +export function map6(rx: [Rx, Rx, Rx, Rx, Rx, Rx], fn: (a: A, b: B, c: C, d: D, e: E, f: F) => G): Rx { + return rx[0].flatMap(a => rx[1].flatMap(b => rx[2].flatMap(c => rx[3].flatMap(d => rx[4].flatMap(e => rx[5].map(f => fn(a, b, c, d, e, f))))))) +} + +class Pure extends Rx { + readonly type: 'Pure' + readonly value: A + + constructor(value: A) { + super() + this.type = 'Pure' + this.value = value + } +} + +export function pure(value: A): Rx { + return new Pure(value) +} + +class Var extends Rx { + readonly type: 'Var' + readonly id: string + readonly update: (f: (value: A) => A) => void + + constructor(id: string, update: (v: Var) => ((f: ((value: A) => A)) => void)) { + super() + this.id = id + this.type = 'Var' + this.update = update(this) + } +} + +class Map extends Rx { + readonly type: 'Map' + readonly rx: Rx + readonly f: (value: A) => B + + constructor(rx: Rx, f: (value: A) => B) { + super() + this.type = 'Map' + this.rx = rx + this.f = f + } +} + +class FlatMap extends Rx { + readonly type: 'FlatMap' + readonly rx: Rx + readonly f: (value: A) => Rx + + constructor(rx: Rx, f: (value: A) => Rx) { + super() + this.type = 'FlatMap' + this.rx = rx + this.f = f + } +} + +export function sequence(xs: Array>): Sequence { + return new Sequence(xs) +} + +export function sequence2(xs: Array>): Rx> { + return xs.reduce( + (acc: Rx>, x: Rx) => acc.flatMap(ys => x.map(y => [y, ...ys])), + new Pure([]) + ) +} + +class Sequence extends Rx> { + readonly type: 'Sequence' + readonly xs: Array> + + constructor(xs: Array>) { + super() + this.type = 'Sequence' + this.xs = xs + } +} + +// Mount + +export function mount(element: HTMLElement, html: Html): Cancelable { + const state = new State() + let appendRes = appendChild(state, element, html) + return appendRes.cancel +} + +interface StateEntry { + value: A + subscribers: Array<(value: A) => void> +} + +class State { + readonly state: {[key: string]: StateEntry} + varCounter: bigint + + constructor() { + this.state = {} + this.varCounter = BigInt(0) + } + + register(initValue: A) { + const v = new Var(this.varCounter.toString(), v => (f => this.update(v, f))) + this.varCounter += BigInt(1) + this.state[v.id] = { + value: initValue, + subscribers: [] + } + return v + } + + unregister(v: Var) { + delete this.state[v.id] + } + + get(v: Var) { + return this.state[v.id].value + } + + update(v: Var, f: (value: A) => A) { + if (v.id in this.state) { + const value = f(this.state[v.id].value) + this.state[v.id].value = value + this.state[v.id].subscribers.forEach(notify => { + // Don’t notify if it has been removed from a precedent notifier + if (this.state[v.id].subscribers.indexOf(notify) !== -1) { + notify(value) + } + }) + } + } + + subscribe(v: Var, notify: (value: A) => void): Cancelable { + this.state[v.id].subscribers.push(notify) + return () => this.state[v.id].subscribers = this.state[v.id].subscribers.filter(n => n !== notify) + } +} + +// Cancelable + +type Cancelable = () => void + +const voidCancel = () => {} + +// Removable + +type Removable = () => void + +const voidRemove = () => {} + +// Rx run + +function rxRun( + state: State, + rx: Rx, + effect: (value: A) => void +): Cancelable { + if (isPure(rx)) { + effect(rx.value) + return voidCancel + } else if (isVar(rx)) { + const cancel = state.subscribe(rx, effect) + effect(state.get(rx)) + return cancel + } else if (isMap(rx)) { + return rxRun(state, rx.rx, value => effect(rx.f(value))) + } else if (isFlatMap(rx)) { + let cancel1 = voidCancel + const cancel2 = rxRun(state, rx.rx, (value: A) => { + cancel1() + cancel1 = rxRun(state, rx.f(value), effect) + }) + return () => { + cancel2() + cancel1() + } + } else if (isSequence(rx)) { + const cancels = Array(rx.xs.length).fill(voidCancel) + const xs = Array(rx.xs.length).fill(undefined) + let initEnded = false + + rx.xs.forEach((rxChild, i) => { + cancels[i] = rxRun( + state, + rxChild, + (value: A) => { + xs[i] = value + if (initEnded) { + // @ts-ignore + effect(xs) + } + } + ) + }) + // @ts-ignore + effect(xs) + initEnded = true + return () => cancels.forEach(cancel => cancel()) + } else { + throw new Error(`Unrecognized rx: ${rx}`) + } +} + +function isRx(x: any): x is Rx { + return x != null && x.type !== undefined && (x.type === 'Var' || x.type === 'Map' || x.type === 'FlatMap' || x.type === 'Sequence' || x.type === 'Pure') +} + +function isPure(x: any): x is Pure { + return x.type === 'Pure' +} + +function isVar(x: any): x is Var { + return x.type === 'Var' +} + +function isMap(x: any): x is Map { + return x.type === 'Map' +} + +function isFlatMap(x: any): x is FlatMap { + return x.type === 'FlatMap' +} + +function isSequence(x: any): x is Sequence { + return x.type === 'Sequence' +} + +// Append + +interface AppendResult { + cancel: Cancelable + remove: Removable + lastAdded?: Node +} + +function appendChild(state: State, element: Element, child: Html, lastAdded?: Node): AppendResult { + if (Array.isArray(child)) { + let cancels: Array = [] + let removes: Array = [] + child.forEach((o) => { + const appendResult = appendChild(state, element, o, lastAdded) + cancels.push(appendResult.cancel) + removes.push(appendResult.remove) + lastAdded = appendResult.lastAdded + }) + return { + cancel: () => cancels.forEach((o) => o()), + remove: () => removes.forEach((o) => o()), + lastAdded + } + } else if (typeof child == 'string') { + const node = document.createTextNode(child) + appendNode(element, node, lastAdded) + return { + cancel: voidCancel, + remove: () => element.removeChild(node), + lastAdded: node + } + } else if (typeof child == 'number') { + return appendChild(state, element, child.toString(), lastAdded) + } else if (isTag(child)) { + const { tagName, isSvg, attributes, children, onmount, onunmount } = child + + const childElement = isSvg + ? document.createElementNS('http://www.w3.org/2000/svg', tagName) + : document.createElement(tagName) + + const setAttr = isSvg + ? (key: any, value: any) => childElement.setAttribute(key, value) + // @ts-ignore + : (key: any, value: any) => childElement[key] = value + + const cancelAttributes = Object.entries(attributes).map(([key, value]) => { + if (isRx(value)) { + return rxRun(state, value, newValue => setAttribute(setAttr, childElement, key, newValue)) + } else { + setAttribute(setAttr, childElement, key, value) + } + }) + + const appendChildrenRes = appendChild(state, childElement, children) + + appendNode(element, childElement, lastAdded) + + if (onmount !== undefined) { + // Wait for the element to be on the page + window.setTimeout(() => onmount(childElement), 0) + } + + return { + cancel: () => { + cancelAttributes.forEach(cancel => cancel !== undefined ? cancel() : {}) + appendChildrenRes.cancel() + if (onunmount !== undefined) { + onunmount(childElement) + } + }, + remove: () => element.removeChild(childElement), + lastAdded: childElement, + } + } else if (isWithState(child)) { + const { init, getChildren } = child + const v = state.register(init) + const children = getChildren(v) + const appendRes = appendChild(state, element, children) + return { + cancel: () => { + appendRes.cancel() + state.unregister(v) + }, + remove: () => appendRes.remove(), + lastAdded: appendRes.lastAdded + } + } else if (isWithState2(child)) { + const { init, getChildren } = child + const [ init1, init2 ] = init + const v1 = state.register(init1) + const v2 = state.register(init2) + const children = getChildren(v1, v2) + const appendRes = appendChild(state, element, children) + return { + cancel: () => { + appendRes.cancel() + state.unregister(v1) + state.unregister(v2) + }, + remove: () => appendRes.remove(), + lastAdded: appendRes.lastAdded + } + } else if (isWithState3(child)) { + const { init, getChildren } = child + const [ init1, init2, init3 ] = init + const v1 = state.register(init1) + const v2 = state.register(init2) + const v3 = state.register(init3) + const children = getChildren(v1, v2, v3) + const appendRes = appendChild(state, element, children) + return { + cancel: () => { + appendRes.cancel() + state.unregister(v1) + state.unregister(v2) + state.unregister(v3) + }, + remove: () => appendRes.remove(), + lastAdded: appendRes.lastAdded + } + } else if (isWithState4(child)) { + const { init, getChildren } = child + const [ init1, init2, init3, init4 ] = init + const v1 = state.register(init1) + const v2 = state.register(init2) + const v3 = state.register(init3) + const v4 = state.register(init4) + const children = getChildren(v1, v2, v3, v4) + const appendRes = appendChild(state, element, children) + return { + cancel: () => { + appendRes.cancel() + state.unregister(v1) + state.unregister(v2) + state.unregister(v3) + state.unregister(v4) + }, + remove: () => appendRes.remove(), + lastAdded: appendRes.lastAdded + } + } else if (isWithState5(child)) { + const { init, getChildren } = child + const [ init1, init2, init3, init4, init5 ] = init + const v1 = state.register(init1) + const v2 = state.register(init2) + const v3 = state.register(init3) + const v4 = state.register(init4) + const v5 = state.register(init5) + const children = getChildren(v1, v2, v3, v4, v5) + const appendRes = appendChild(state, element, children) + return { + cancel: () => { + appendRes.cancel() + state.unregister(v1) + state.unregister(v2) + state.unregister(v3) + state.unregister(v4) + state.unregister(v5) + }, + remove: () => appendRes.remove(), + lastAdded: appendRes.lastAdded + } + } else if (isWithState6(child)) { + const { init, getChildren } = child + const [ init1, init2, init3, init4, init5, init6 ] = init + const v1 = state.register(init1) + const v2 = state.register(init2) + const v3 = state.register(init3) + const v4 = state.register(init4) + const v5 = state.register(init5) + const v6 = state.register(init6) + const children = getChildren(v1, v2, v3, v4, v5, v6) + const appendRes = appendChild(state, element, children) + return { + cancel: () => { + appendRes.cancel() + state.unregister(v1) + state.unregister(v2) + state.unregister(v3) + state.unregister(v4) + state.unregister(v5) + state.unregister(v6) + }, + remove: () => appendRes.remove(), + lastAdded: appendRes.lastAdded + } + } else if (isWithState7(child)) { + const { init, getChildren } = child + const [ init1, init2, init3, init4, init5, init6, init7 ] = init + const v1 = state.register(init1) + const v2 = state.register(init2) + const v3 = state.register(init3) + const v4 = state.register(init4) + const v5 = state.register(init5) + const v6 = state.register(init6) + const v7 = state.register(init7) + const children = getChildren(v1, v2, v3, v4, v5, v6, v7) + const appendRes = appendChild(state, element, children) + return { + cancel: () => { + appendRes.cancel() + state.unregister(v1) + state.unregister(v2) + state.unregister(v3) + state.unregister(v4) + state.unregister(v5) + state.unregister(v6) + state.unregister(v7) + }, + remove: () => appendRes.remove(), + lastAdded: appendRes.lastAdded + } + } else if (isRx(child)) { + const rxBase = document.createTextNode('') + appendNode(element, rxBase, lastAdded) + let appendRes: AppendResult = { + cancel: voidCancel, + remove: voidRemove, + lastAdded: rxBase + } + const cancelRx = rxRun(state, child, (value: Html) => { + appendRes.cancel() + appendRes.remove() + appendRes = appendChild(state, element, value, rxBase) + }) + return { + cancel: () => { + appendRes.cancel() + cancelRx() + }, + remove: () => { + appendRes.remove() + element.removeChild(rxBase) + }, + lastAdded: appendRes.lastAdded, + } + } else if (!child) { + return { + cancel: voidCancel, + remove: voidRemove, + lastAdded + } + } else { + throw new Error(`Unrecognized child: ${child}`) + } +} + +function isTag(x: any): x is Tag { + return x != null && x.type === 'Tag' +} + +function isWithState(x: any): x is WithState { + return x != null && x.type === 'WithState' +} + +function isWithState2(x: any): x is WithState2 { + return x != null && x.type === 'WithState2' +} + +function isWithState3(x: any): x is WithState3 { + return x != null && x.type === 'WithState3' +} + +function isWithState4(x: any): x is WithState4 { + return x != null && x.type === 'WithState4' +} + +function isWithState5(x: any): x is WithState5 { + return x != null && x.type === 'WithState5' +} + +function isWithState6(x: any): x is WithState6 { + return x != null && x.type === 'WithState6' +} + +function isWithState7(x: any): x is WithState7 { + return x != null && x.type === 'WithState7' +} + +function appendNode(base: Element, node: Node, lastAdded?: Node) { + if (lastAdded !== undefined) { + base.insertBefore(node, lastAdded.nextSibling) + } else { + base.append(node) + } +} + +function setAttribute(setAttr: (key: any, value: any) => void, element: Element, key: string, attribute: AttributeValue) { + if (attribute === undefined) { + // Do nothing + } else if (attribute === true) { + setAttr(key, 'true') + } else if (attribute === false) { + // @ts-ignore + if (key in element) setAttr(key, false) + } else if (typeof attribute === 'number') { + setAttr(key, attribute.toString()) + } else if (typeof attribute === 'string') { + setAttr(key, attribute) + } else { + // @ts-ignore + setAttr(key, (event: Event) => attribute(event)) + } +} -- cgit v1.2.3