diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/example.ts | 171 | ||||
| -rw-r--r-- | src/rx.ts | 398 | 
2 files changed, 569 insertions, 0 deletions
diff --git a/src/example.ts b/src/example.ts new file mode 100644 index 0000000..c23a4eb --- /dev/null +++ b/src/example.ts @@ -0,0 +1,171 @@ +import { h, withVar, mount, RxAble } from 'rx' + +const imbricatedMaps = +  withVar(1, (counter, updateCounter) => [ +    counterComponent({ +      value: counter, +      onSub: () => updateCounter(n => n - 1), +      onAdd: () => updateCounter(n => n + 1) +    }), +    counter.map(c1 => { +      console.log('c1') +      return [ +        h('div', 'Inside first count'), +        counter.map(c2 => { +          console.log('c2') +          return h('div', `Inside second count ${c2}`) +        }) +      ] +    }) +  ]) + +const flatMap = +  withVar(1, (counter, updateCounter) => [ +    counterComponent({ +      value: counter, +      onSub: () => updateCounter(n => n - 1), +      onAdd: () => updateCounter(n => n + 1) +    }), +    counter.flatMap(c1 => { +      console.log('c1') +      return counter.map(c2 => { +        console.log('c2') +        return h('div', `Inside second count ${c2}`) +      }) +    }) +  ]) + +const checkbox = +  withVar(false, (checked, update) => [ +    checkboxComponent({ +      label: 'Checkbox', +      isChecked: checked, +      onCheck: (isChecked: boolean) => update(_ => isChecked), +    }), +    checked.map(isChecked => isChecked ? 'C’est coché!' : 'Ça n’est pas coché') +  ]) + +const rxChildren = +  withVar(3, (count, update) => [ +    h('input', { +      type: 'number', +      value: count, +      onchange: (event: Event) => update(_ => parseInt((event.target as HTMLInputElement).value)) +    }), +    h('div', 'FOO'), +    count.map(n => Array(n).fill(null).map((_, i) => h('div', `A ${i}!`))), +    h('div', 'BAR'), +    count.map(n => Array(n).fill(null).map((_, i) => h('div', `B ${i}!`))), +    h('div', 'BAZ') +  ]) + +const nodesCancel = +  withVar(false, (checked, update) => [ +    checkboxComponent({ +      label: 'Checkbox', +      isChecked: checked, +      onCheck: (isChecked: boolean) => update(_ => isChecked), +    }), +    checked.map(isChecked => isChecked ? rxChildren : undefined) +  ]) + +const counters = +  withVar<Array<number>>([], (counters, update) => { +    return [ +      counterComponent({ +        value: counters.map(cs => cs.length), +        onSub: () => update(cs => cs.slice(0, -1)), +        onAdd: () => update(cs => cs.concat(0)) +      }), +      h('hr'), +      h('div', { style: 'margin-top: 1rem' }, counters.map(cs => +        cs.map((c, i) => +          counterComponent({ +            value: c, +            onSub: () => update(cs => { +              cs[i] = c - 1 +              return cs +            }), +            onAdd: () => update(cs => { +              cs[i] = c + 1 +              return cs +            }) +          }) +        ) +      )) +    ] +  }) + +const rows = +  withVar(1, (count, updateCount) => [ +    h('input', { +      type: 'number', +      value: count, +      onchange: (event: Event) => updateCount(_ => parseInt((event.target as HTMLInputElement).value)) +    }), +    count.map(n => Array(n).fill(null).map((_, i) => h('div', i))) +  ]) + +const chrono = +  withVar(false, (isChecked, updateCheck) => [ +    checkboxComponent({ +      label: 'Show counter', +      isChecked, +      onCheck: b => updateCheck(_ => b) +    }), +    isChecked.map(b => b && withVar(0, (elapsed, updateElapsed) => { +      const interval = window.setInterval( +        () => updateElapsed(n => n + 1), +        1000 +      ) +      return h( +        'div', +        { onunmount: () => clearInterval(interval) }, +        elapsed +      ) +    })) +  ]) + +const view = h('main', +  h('h1', 'Rx'), +  chrono +) + +mount(view) + +// Checkbox + +interface CheckboxParams { +  label: RxAble<string> +  isChecked: RxAble<boolean> +  onCheck: (isChecked: boolean) => void +} + +function checkboxComponent({ label, isChecked, onCheck }: CheckboxParams) { +  return h('label', +    h('input', +      { type: 'checkbox', +        onchange: (event: Event) => onCheck((event.target as HTMLInputElement).checked), +        checked: isChecked, +      } +    ), +    label +  ) +} + +// Counter + +interface CounterParams { +  value: RxAble<number> +  onAdd: () => void +  onSub: () => void +} + +function counterComponent({ value, onAdd, onSub}: CounterParams) { +  return h('div', +    [ h('span', { style: 'margin-right: 5px' }, value), +      h('button', { onclick: () => onSub() }, '-'), +      h('button', { onclick: () => onAdd() }, '+'), +    ] +  ) +} diff --git a/src/rx.ts b/src/rx.ts new file mode 100644 index 0000000..dbdd3ad --- /dev/null +++ b/src/rx.ts @@ -0,0 +1,398 @@ +// Html + +export interface Html { +  type: 'Tag' | 'WithVar' +} + +export interface Tag extends Html { +  type: 'Tag' +  tagName: string +  attributes: Attributes +  children?: Array<Child> +  onmount?: (element: Element) => void +  onunmount?: (element: Element) => void +} + +export interface WithVar<A> extends Html { +  type: 'WithVar' +  init: A +  getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Child +} + +interface Attributes { +  [key: string]: Rx<AttributeValue> | AttributeValue +} + +export type AttributeValue +  = string +  | number +  | boolean +  | ((event: Event) => void) +  | ((element: Element) => void) + +export type Child = false | undefined | string | number | Html | Rx<Child> | Array<Child> + +function isChild(x: any): x is Child { +  return (typeof x === 'string' +    || typeof x === 'number' +    || isHtml(x) +    || isRx(x) +    || Array.isArray(x)) +} + +type ValueOrArray<T> = T | Array<ValueOrArray<T>> + +export function h( +  tagName: string, +  x?: Attributes | Child, +  ...children: Array<Child> +): 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<A>(init: A, getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Child): 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' +  } +} + +export 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 +  } +} + +export 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(child: Child): Cancelable { +  const state = new State() +  let appendRes = appendChild(state, document.body, child) +  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: Child, 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("") +    element.append(rxBase) +    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, +      lastAdded: appendRes.lastAdded, +    } +  } else if (child === undefined || child === false) { +    return { +      cancel: voidCancel, +      remove: voidRemove, +      lastAdded +    } +  } else { +    throw new Error(`Unrecognized child: ${child}`) +  } +} + +function isHtml<A>(x: any): x is Html { +  return isTag<A>(x) || isWithVar<A>(x) +} + +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 (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) +  } +}  | 
