From ffca3dfb15f37999d2b751c5b62a90ead65201a3 Mon Sep 17 00:00:00 2001 From: Joris Date: Mon, 13 Feb 2023 15:25:56 +0100 Subject: Use rx to update the view --- src/chord.ts | 4 +- src/lib/dom.ts | 15 -- src/lib/h.ts | 34 ----- src/lib/rx.ts | 405 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 33 ++++- src/view/form.ts | 149 +++++++++++++------ src/view/layout.ts | 13 -- src/view/options.ts | 22 +-- src/view/play.ts | 89 ++++++------ 9 files changed, 598 insertions(+), 166 deletions(-) delete mode 100644 src/lib/dom.ts delete mode 100644 src/lib/h.ts create mode 100644 src/lib/rx.ts delete mode 100644 src/view/layout.ts (limited to 'src') diff --git a/src/chord.ts b/src/chord.ts index 0584727..118f146 100644 --- a/src/chord.ts +++ b/src/chord.ts @@ -7,11 +7,9 @@ export type Options = { } export function generate(o: Options): [string, string] { - let base = all[Math.floor(Math.random() * all.length)] - - let alterations: string[] = [] + if (o.major) alterations = alterations.concat('') if (o.minor) alterations = alterations.concat('-') if (o.seventh) alterations = alterations.concat('7') 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 + | Array + | Rx + +interface Tag { + type: 'Tag' + tagName: string + attributes: Attributes + children?: Array + onmount?: (element: Element) => void + onunmount?: (element: Element) => void +} + +interface WithVar { + type: 'WithVar' + init: A + getChildren: (v: Var, update: (f: (value: A) => A) => void) => Html +} + +interface Attributes { + [key: string]: Rx | 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 | Array> + +export function h( + tagName: string, + x?: Attributes | Html, + ...children: Array +): 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(init: A, getChildren: (v: Var, update: (f: (value: A) => A) => void) => Html): 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' + } +} + +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 + } +} + +// Mount + +export function mount(html: Html): Cancelable { + const state = new State() + let appendRes = appendChild(state, document.body, 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()) + 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: 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, 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: 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(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 (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) + } +} diff --git a/src/main.ts b/src/main.ts index aecb754..bba799e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,31 @@ -import * as form from 'view/form' -import * as dom from 'lib/dom' +import { h, withVar, mount } from 'lib/rx' +import * as Form from 'view/form' +import * as Play from 'view/play' +import * as Options from 'view/options' -dom.show(form.view()) +enum Page { + Form, + Play +} + +mount( + withVar(Page.Form, (page, updatePage) => [ + h('header', + { onclick: () => updatePage(_ => Page.Form) }, + 'Chords' + ), + page.map(p => + p === Page.Form + ? Form.view({ + options: Options.load(), + onSubmit: (options: Options.Model) => { + Options.save(options) + updatePage(_ => Page.Play) + } + }) + : Play.view({ + options: Options.load() + }) + ) + ]) +) diff --git a/src/view/form.ts b/src/view/form.ts index 77a8cb7..5547e0c 100644 --- a/src/view/form.ts +++ b/src/view/form.ts @@ -1,66 +1,121 @@ -import h, { classNames } from 'lib/h' -import * as dom from 'lib/dom' -import * as play from 'view/play' -import * as layout from 'view/layout' -import * as chord from 'chord' -import * as options from 'view/options' +import { h, withVar, Html, Rx } from 'lib/rx' +import * as Options from 'view/options' -// View - -export function view(): Element[] { - let opts = options.load() +interface Params { + options: Options.Model + onSubmit: (options: Options.Model) => void +} - return layout.view( +export function view({ options, onSubmit }: Params): Html { + return withVar(options, (opts, updateOptions) => h('form', - { - className: 'g-Form', - onsubmit + { className: 'g-Form', + onsubmit: opts.map(o => + (event: Event) => { + event.preventDefault() + onSubmit(o) + } + ) }, - chordCheckbox({ name: 'major', label: '', checked: opts.major }), - chordCheckbox({ name: 'minor', label: '-', checked: opts.minor }), - chordCheckbox({ name: 'seventh', label: '7', checked: opts.seventh }), - chordCheckbox({ name: 'minorSeventh', label: '-7', checked: opts.minorSeventh }), - chordCheckbox({ name: 'majorSeventh', label: '7', checked: opts.majorSeventh }), - labelInput({ type: 'number', name: 'bpm', label: 'BPM', value: opts.bpm.toString() }), - labelInput({ type: 'number', name: 'beatsPerChord', label: 'Beats per Chord', value: opts.beatsPerChord.toString() }), + chordCheckbox({ + label: '', + checked: opts.map(o => o.major), + onCheck: (checked => updateOptions(o => { + o.major = checked + return o + })) + }), + chordCheckbox({ + label: '-', + checked: opts.map(o => o.minor), + onCheck: (checked => updateOptions(o => { + o.minor = checked + return o + })) + }), + chordCheckbox({ + label: '7', + checked: opts.map(o => o.seventh), + onCheck: (checked => updateOptions(o => { + o.seventh = checked + return o + })) + }), + chordCheckbox({ + label: '-7', + checked: opts.map(o => o.minorSeventh), + onCheck: (checked => updateOptions(o => { + o.minorSeventh = checked + return o + })) + }), + chordCheckbox({ + label: '7', + checked: opts.map(o => o.majorSeventh), + onCheck: (checked => updateOptions(o => { + o.majorSeventh = checked + return o + })) + }), + numberInput({ + label: 'BPM', + value: opts.map(o => o.bpm.toString()), + onChange: (n => updateOptions(o => { + o.bpm = n + return o + })) + }), + numberInput({ + label: 'Beats per Chord', + value: opts.map(o => o.beatsPerChord.toString()), + onChange: (n => updateOptions(o => { + o.beatsPerChord = n + return o + })) + }), h('input', { type: 'submit', value: 'Play' }) ) ) } -function chordCheckbox({ name, label, checked }: any): Element { - return labelInput({ className: 'g-ChordLabel', type: 'checkbox', name, label, checked }) +interface ChordCheckboxParams { + label: string + checked: Rx + onCheck: (checked: boolean) => void } -function onsubmit(event: Event): void { - event.preventDefault() - let input = (name: String) => document.querySelector(`input[name="${name}"]`) as HTMLInputElement - let opts = { - major: input('major').checked, - minor: input('minor').checked, - seventh: input('seventh').checked, - minorSeventh: input('minorSeventh').checked, - majorSeventh: input('majorSeventh').checked, - bpm: parseInt(input('bpm').value), - beatsPerChord: parseInt(input('beatsPerChord').value) - } - options.save(opts) - dom.show(play.view(opts)) +function chordCheckbox({ label, checked, onCheck }: ChordCheckboxParams): Html { + return h('label', + { className: 'g-ChordLabel' }, + h('input', + { type: 'checkbox', + checked, + onchange: (event: Event) => onCheck((event.target as HTMLInputElement).checked) + } + ), + label + ) } -type LabelInputParams = { - className?: string, - type: string, - name: string, - label: string, - checked?: boolean, - value?: string +interface NumberInputParams { + label: string + value: Rx + onChange: (n: number) => void } -function labelInput({ className, type, name, label, checked, value }: LabelInputParams) { +function numberInput({ label, value, onChange }: NumberInputParams): Html { return h('label', - className !== undefined ? { className } : {}, - h('input', { type, name, checked, value }), + h('input', + { type: 'number', + value, + onchange: (event: Event) => { + const n = parseInt((event.target as HTMLInputElement).value) + if (typeof n === 'number') { + onChange(n) + } + } + } + ), label ) } diff --git a/src/view/layout.ts b/src/view/layout.ts deleted file mode 100644 index ac62290..0000000 --- a/src/view/layout.ts +++ /dev/null @@ -1,13 +0,0 @@ -import h from 'lib/h' -import * as dom from 'lib/dom' -import * as form from 'view/form' - -export function view(main: Element): Element[] { - return [ - h('header', - { onclick: () => dom.show(form.view()) }, - 'Chords' - ), - main - ] -} diff --git a/src/view/options.ts b/src/view/options.ts index 4a57f97..31fd631 100644 --- a/src/view/options.ts +++ b/src/view/options.ts @@ -1,14 +1,14 @@ -export type Options = { - major: boolean, - minor: boolean, - seventh: boolean, - minorSeventh: boolean, - majorSeventh: boolean, - bpm: number, +export interface Model { + major: boolean + minor: boolean + seventh: boolean + minorSeventh: boolean + majorSeventh: boolean + bpm: number beatsPerChord: number } -let defaultOptions: Options = { +let init: Model = { major: true, minor: false, seventh: false, @@ -20,11 +20,11 @@ let defaultOptions: Options = { let key: string = 'options' -export function load(): Options { +export function load(): Model { let str = localStorage[key] - return str && JSON.parse(str) || defaultOptions + return str && JSON.parse(str) || init } -export function save(options: Options): void { +export function save(options: Model): void { localStorage[key] = JSON.stringify(options) } diff --git a/src/view/play.ts b/src/view/play.ts index f0340f7..b85e505 100644 --- a/src/view/play.ts +++ b/src/view/play.ts @@ -1,49 +1,58 @@ -import h, { classNames } from 'lib/h' -import * as dom from 'lib/dom' -import { Options } from 'view/options' -import * as chord from 'chord' -import * as layout from 'view/layout' +import { h, withVar, Html } from 'lib/rx' +import * as Options from 'view/options' +import * as Chord from 'chord' -export function view(options: Options): Element[] { - let chords = h('div', - { className: 'g-Play' }, - chordNode(), - chordNode(options), - chordNode(options) - ) - - let chordBeat = 1 +interface ViewParams { + options: Options.Model +} - dom.triggerAnimation(chords.children[1] as HTMLElement, 'g-Chord--Beat') - setInterval(() => { - if (chordBeat == options.beatsPerChord) { - shiftChords(chords, options) - chords.children[0].classList.remove('g-Chord--Beat') - dom.triggerAnimation(chords as HTMLElement, 'g-Play--Shift') - dom.triggerAnimation(chords.children[1] as HTMLElement, 'g-Chord--Beat') - chordBeat = 1 - } else { - dom.triggerAnimation(chords.children[1] as HTMLElement, 'g-Chord--Beat') - chordBeat += 1 - } - }, 60 / options.bpm * 1000) +export function view({ options }: ViewParams): Html { + const initChords: Array<[string, string]> = [['', ''], Chord.generate(options), Chord.generate(options)] - return layout.view(chords) -} + return withVar(initChords, (chords, updateChords) => + withVar(undefined, (beat, updateBeat) => { + let chordBeat = 1 + const interval = setInterval(() => { + if (chordBeat === options.beatsPerChord) { + updateChords(xs => { + xs.shift() + xs.push(Chord.generate(options)) + return xs + }) + chordBeat = 1 + } else { + updateBeat(_ => undefined) + chordBeat += 1 + } + }, 60 / options.bpm * 1000) -/* Shift chords and generate a new random one. - */ -function shiftChords(chords: Element, options: Options) { - chords.removeChild(chords.children[0]) - chords.appendChild(chordNode(options)) + return h('div', + { className: 'g-Play', + onunmount: () => clearInterval(interval) + }, + chords.map(xs => + h('div', + { className: 'g-Chords' }, + xs.map((chord, i) => { + if (i == 0) { + return viewChord(chord, 'g-Chord--Disappear') + } else if (i == 1) { + return beat.map(_ => viewChord(chord)) + } else { + return viewChord(chord) + } + }) + ) + ) + ) + }) + ) } -function chordNode(options?: Options): Element { - let [base, alteration] = options ? chord.generate(options) : ['', ''] - - return h('div', - { className: 'g-Chord' }, +function viewChord([base, alteration]: [string, string], className: string = ''): Html { + return h('div', + { className: `g-Chord ${className}` }, base, - h('sup', {}, alteration) + h('sup', alteration) ) } -- cgit v1.2.3