diff options
| author | Joris | 2023-02-13 15:25:56 +0100 | 
|---|---|---|
| committer | Joris | 2023-02-13 21:34:38 +0100 | 
| commit | ffca3dfb15f37999d2b751c5b62a90ead65201a3 (patch) | |
| tree | 62f2b0429ea6f8aaa328d41da7b623439142e75b /src/lib | |
| parent | a1960c7e10691e460b835446bb358c27e971fba8 (diff) | |
Use rx to update the view
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/dom.ts | 15 | ||||
| -rw-r--r-- | src/lib/h.ts | 34 | ||||
| -rw-r--r-- | src/lib/rx.ts | 405 | 
3 files changed, 405 insertions, 49 deletions
diff --git a/src/lib/dom.ts b/src/lib/dom.ts deleted file mode 100644 index 0b6a0ab..0000000 --- a/src/lib/dom.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function show(elements: Element[]): void { -  document.body.innerHTML = '' -  elements.forEach(element => document.body.appendChild(element)) -} - -/* Trigger animation in any case. - * - * Trigger reflow between removing and adding the classname. - * See https://css-tricks.com/restart-css-animation/ - */ -export function triggerAnimation(element: HTMLElement, animation: string) { -  element.classList.remove(animation) -  void element.offsetWidth -  element.classList.add(animation) -} diff --git a/src/lib/h.ts b/src/lib/h.ts deleted file mode 100644 index 8b1abf3..0000000 --- a/src/lib/h.ts +++ /dev/null @@ -1,34 +0,0 @@ -type Child = Element | Text | string | number - -export default function h( -  tagName: string, -  attrs: object, -  ...children: Child[] -): Element { -  const isSvg = tagName === 'svg' || tagName === 'path' - -  let elem = isSvg -    ? document.createElementNS('http://www.w3.org/2000/svg', tagName) -    : document.createElement(tagName) - -  if (isSvg) { -    Object.entries(attrs).forEach(([key, value]) => { -      elem.setAttribute(key, value) -    }) -  } else { -    elem = Object.assign(elem, attrs) -  } - -  for (const child of children) { -    if (typeof child === 'number') -      elem.append(child.toString()) -    else -      elem.append(child) -  } - -  return elem -} - -export function classNames(obj: {[key: string]: boolean }): string { -  return Object.keys(obj).filter(k => obj[k]).join(' ') -} diff --git a/src/lib/rx.ts b/src/lib/rx.ts new file mode 100644 index 0000000..3f3b8d9 --- /dev/null +++ b/src/lib/rx.ts @@ -0,0 +1,405 @@ +// [1.1.0] 2023-02-13 + +// Html + +export type Html +  = false  +  | undefined  +  | string  +  | number  +  | Tag  +  | WithVar<any>  +  | Array<Html> +  | Rx<Html>  + +interface Tag { +  type: 'Tag' +  tagName: string +  attributes: Attributes +  children?: Array<Html> +  onmount?: (element: Element) => void +  onunmount?: (element: Element) => void +} + +interface WithVar<A> { +  type: 'WithVar' +  init: A +  getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Html +} + +interface Attributes { +  [key: string]: Rx<AttributeValue> | AttributeValue +} + +type AttributeValue +  = undefined +  | 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)  +    || isWithVar(x) +    || isRx(x) +    || Array.isArray(x)) +} + +type ValueOrArray<T> = T | Array<ValueOrArray<T>> + +export function h( +  tagName: string, +  x?: Attributes | Html, +  ...children: Array<Html> +): Tag { +  if (x === undefined || x === false) { +    return { +      type: 'Tag', +      tagName, +      attributes: {} +    } +  } else if (isHtml(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<A>(init: A, getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Html): WithVar<A> { +  return { +    type: 'WithVar', +    init, +    getChildren +  } +} + +// Rx + +export type RxAble<A> = Rx<A> | A + +export class Rx<A> { +  map<B>(f: (value: A) => B): Rx<B> { +    return new Map<A, B>(this, f) +  } + +  flatMap<B>(f: (value: A) => Rx<B>): Rx<B> { +    return new FlatMap<A, B>(this, f) +  } +} + +class Var<A> extends Rx<A> { +  readonly type: 'Var' +  readonly id: string + +  constructor(id: string) { +    super() +    this.id = id +    this.type = 'Var' +  } +} + +class Map<A, B> extends Rx<B> { +  readonly type: 'Map' +  readonly rx: Rx<A> +  readonly f: (value: A) => B + +  constructor(rx: Rx<A>, f: (value: A) => B) { +    super() +    this.type = 'Map' +    this.rx = rx +    this.f = f +  } +} + +class FlatMap<A, B> extends Rx<B> { +  readonly type: 'FlatMap' +  readonly rx: Rx<A> +  readonly f: (value: A) => Rx<B> + +  constructor(rx: Rx<A>, f: (value: A) => Rx<B>) { +    super() +    this.type = 'FlatMap' +    this.rx = rx +    this.f = f +  } +} + +// Mount + +export function mount(html: Html): Cancelable { +  const state = new State() +  let appendRes = appendChild(state, document.body, html) +  return appendRes.cancel +} + +interface StateEntry<A> { +  value: A +  subscribers: Array<(value: A) => void> +} + +class State { +  readonly state: {[key: string]: StateEntry<any>} +  varCounter: bigint + +  constructor() { +    this.state = {} +    this.varCounter = BigInt(0) +  } + +  register<A>(initValue: A) { +    const v = new Var(this.varCounter.toString()) +    this.varCounter += BigInt(1) +    this.state[v.id] = { +      value: initValue, +      subscribers: [] +    } +    return v +  } + +  unregister<A>(v: Var<A>) { +    delete this.state[v.id] +  } + +  get<A>(v: Var<A>) { +    return this.state[v.id].value +  } + +  update<A>(v: Var<A>, 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<A>(v: Var<A>, 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<A>(state: State, rx: Rx<A>, effect: (value: A) => void): Cancelable { +  if (isVar(rx)) { +    const cancel = state.subscribe(rx, effect) +    effect(state.get(rx)) +    return cancel +  } else if (isMap<A, any>(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<A>(x: any): x is Rx<A> { +  return x !== undefined && x.type !== undefined && (x.type === "Var" || x.type === "Map" || x.type === "FlatMap") +} + +function isVar<A>(x: any): x is Var<A> { +  return x.type === "Var" +} + +function isMap<A, B>(x: any): x is Map<A, B> { +    return x.type === "Map" +} + +function isFlatMap<A, B>(x: any): x is FlatMap<A, B> { +    return x.type === "FlatMap" +} + +// 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<Cancelable> = [] +    let removes: Array<Removable> = [] +    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<AttributeValue>(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: 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 === undefined || child === false) { +    return { +      cancel: voidCancel, +      remove: voidRemove, +      lastAdded +    } +  } else { +    throw new Error(`Unrecognized child: ${child}`) +  } +} + +function isTag<A>(x: any): x is Tag { +  return x !== undefined && x.type === "Tag" +} + +function isWithVar<A>(x: any): x is WithVar<A> { +  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 (attribute === undefined || attribute === false) { +    // Do nothing +  } else if (attribute === true) { +    // @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) +  } +}  | 
