// Html
export interface Html {
type: 'Tag' | 'WithVar'
}
export interface Tag extends Html {
type: 'Tag'
tagName: string
attributes: Attributes
children?: Array
onmount?: (element: Element) => void
onunmount?: (element: Element) => void
}
export interface WithVar extends Html {
type: 'WithVar'
init: A
getChildren: (v: Var, update: (f: (value: A) => A) => void) => Child
}
interface Attributes {
[key: string]: Rx | AttributeValue
}
export type AttributeValue
= string
| number
| boolean
| ((event: Event) => void)
| ((element: Element) => void)
export type Child = false | undefined | string | number | Html | Rx | Array
function isChild(x: any): x is Child {
return (typeof x === 'string'
|| typeof x === 'number'
|| isHtml(x)
|| isRx(x)
|| Array.isArray(x))
}
type ValueOrArray = T | Array>
export function h(
tagName: string,
x?: Attributes | Child,
...children: Array
): Tag {
if (x === undefined || x === false) {
return {
type: 'Tag',
tagName,
attributes: {}
}
} else if (isChild(x)) {
return {
type: 'Tag',
tagName,
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,
attributes,
children,
onmount,
onunmount
}
}
}
export function withVar(init: A, getChildren: (v: Var, update: (f: (value: A) => A) => void) => Child): WithVar {
return {
type: 'WithVar',
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)
}
}
class Var extends Rx {
readonly type: 'Var'
readonly id: string
constructor(id: string) {
super()
this.id = id
this.type = 'Var'
}
}
export 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
}
}
export 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
}
}
// Mount
export function mount(child: Child): Cancelable {
const state = new State()
let appendRes = appendChild(state, document.body, child)
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())
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) {
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 (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 {
throw new Error(`Unrecognized rx: ${rx}`)
}
}
function isRx(x: any): x is Rx {
return x !== undefined && x.type !== undefined && (x.type === "Var" || x.type === "Map" || x.type === "FlatMap")
}
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"
}
// Append
interface AppendResult {
cancel: Cancelable
remove: Removable
lastAdded?: Node
}
function appendChild(state: State, element: Element, child: Child, 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, attributes, children, onmount, onunmount } = child
const childElement = document.createElement(tagName)
const cancelAttributes = Object.entries(attributes).map(([key, value]) => {
if (isRx(value)) {
return rxRun(state, value, newValue => setAttribute(state, childElement, key, newValue))
} else {
setAttribute(state, childElement, key, value)
}
})
const appendChildrenRes = appendChild(state, childElement, children)
appendNode(element, childElement, lastAdded)
if (onmount !== undefined) {
onmount(childElement)
}
return {
cancel: () => {
cancelAttributes.forEach(cancel => cancel !== undefined ? cancel() : {})
appendChildrenRes.cancel()
if (onunmount !== undefined) {
onunmount(childElement)
}
},
remove: () => element.removeChild(childElement),
lastAdded: childElement,
}
} else if (isWithVar(child)) {
const { init, getChildren } = child
const v = state.register(init)
const children = getChildren(v, f => state.update(v, f))
const appendRes = appendChild(state, element, children)
return {
cancel: () => {
appendRes.cancel()
state.unregister(v)
},
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: Child) => {
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 === undefined || child === false) {
return {
cancel: voidCancel,
remove: voidRemove,
lastAdded
}
} else {
throw new Error(`Unrecognized child: ${child}`)
}
}
function isHtml(x: any): x is Html {
return isTag(x) || isWithVar(x)
}
function isTag(x: any): x is Tag {
return x !== undefined && x.type === "Tag"
}
function isWithVar(x: any): x is WithVar {
return x !== undefined && x.type === "WithVar"
}
function appendNode(base: Element, node: Node, lastAdded?: Node) {
if (lastAdded !== undefined) {
base.insertBefore(node, lastAdded.nextSibling)
} else {
base.append(node)
}
}
function setAttribute(state: State, element: Element, key: string, attribute: AttributeValue) {
if (typeof attribute == "boolean") {
if (attribute) {
// @ts-ignore
element[key] = "true"
}
} else if (typeof attribute == "number") {
// @ts-ignore
element[key] = attribute.toString()
} else if (typeof attribute == "string") {
// @ts-ignore
element[key] = attribute
} else {
// @ts-ignore
element[key] = (event: Event) => attribute(event)
}
}