Skip to content

Instantly share code, notes, and snippets.

@laughinghan
Last active October 5, 2020 23:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save laughinghan/4c0a773266ff885e4a559d4dd4b89612 to your computer and use it in GitHub Desktop.
Save laughinghan/4c0a773266ff885e4a559d4dd4b89612 to your computer and use it in GitHub Desktop.
/*
https://jsbin.com/licibaj/edit?js,output
// The app consists of two input boxes, one representing
// Celsius, and the other Fahrenheit.
%View "#app":
<div>
<input @celsius value="0" />ºC
=
<input @fahrenheit value="32" />ºF
</div>
// When either input box is modified, and it’s value is a
// valid number, the other input box is updated to reflect
// the corresponding temperature in the other units.
When celsius.Edit emits {value}:
Let degC = value.as_number()
If degC isnt #error _:
Let degF = (degC * 9) / 5 + 32
Change fahrenheit.value to degF.as_string()
When fahrenheit.Edit emits {value}:
Let degF = value.as_number()
If degF isnt #error _:
Let degC = (degF - 32) / 1.8
Change celsius.value to degC.as_string()
=== After expansion of %View macro/compiler plugin ===
Get celsius_field = Do create_element("input", { value: "0" })
Get fahrenheit_field = Do create_element("input", { value: "32" })
Do render_view(
'#app',
create_element("div", attr: %map{}, content:
celsius_field,
"ºC =",
fahrenheit_field,
"ºF"))
Init celsius with celsius_field
Init fahrenheit with fahrenheit_field
Module celsius:
Export Edit, <getter, setter> as value
Preinit: celsius_field
Edit = Do edit_stream(celsius_field)
getter = Do get_form_field_value(celsius_field)
setter = value => cmd: Do set_form_field_value(celsius_field, value)
Module fahrenheit:
Export Edit, <getter, setter> as value
Preinit: fahrenheit_field
Edit = Do edit_stream(fahrenheit_field)
getter = Do get_form_field_value(fahrenheit_field)
setter = value => cmd: Do set_form_field_value(fahrenheit_field, value)
// View library functions:
Import render_view, create_element from "libview.js"
Declare create_element as
(String, attr?: Map<String, String>,
content?: ...List<*Element>) => Command<*Element>
Declare render_view as
(String, *Element) => Command<Void>
//*/
// The app consists of two input boxes, one representing
// Celsius, and the other Fahrenheit.
const celsius_field = create_element("input", { value: "0" })
const fahrenheit_field = create_element("input", { value: "32" })
render_view(
'#app',
create_element("div", {},
celsius_field,
"ºC =",
fahrenheit_field,
"ºF"))
namespace celsius {
export const Edit = edit_stream(celsius_field)
export const value = {
get: get_form_field_value(celsius_field),
set: (value: string) => set_form_field_value(celsius_field, value),
}
}
namespace fahrenheit {
export const Edit = edit_stream(fahrenheit_field)
export const value = {
get: get_form_field_value(fahrenheit_field),
set: (value: string) => set_form_field_value(fahrenheit_field, value),
}
}
// When either input box is modified, and it’s value is a
// valid number, the other input box is updated to reflect
// the corresponding temperature in the other units.
celsius.Edit.subscribe(({value}) => {
const degC = str_as_num(value)
if (!(degC instanceof Error)) {
const degF = (degC * 9) / 5 + 32
fahrenheit.value.set(String(degF))
}
})
fahrenheit.Edit.subscribe(({value}) => {
const degF = str_as_num(value)
if (!(degF instanceof Error)) {
const degC = (degF - 32) / 1.8
celsius.value.set(String(degC))
}
})
// view core
function create_element(tagName: string, attrs: {[k: string]: string}, ...children: Array<string | Element>): Element {
const el = document.createElement(tagName)
for (const name in attrs) {
el.setAttribute(name, attrs[name])
}
for (const child of children) {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child))
} else {
el.appendChild(child)
}
}
return el
}
function render_view(selector: string, el: Element) {
const mountEl = getElement(selector)
mountEl.innerHTML = ''
mountEl.appendChild(el)
}
function getElement(selector: string): Element {
if (selector.charAt(0) === '#') {
const matching = document.getElementById(selector.slice(1))
if (matching === null) {
throw `Found no element with id: '${selector.slice(1)}'`
}
return matching
} else if (selector.charAt(0) === '.') {
const matching = document.getElementsByClassName(selector.slice(1))
if (matching.length === 0) {
throw `Found no element with class name: '${selector.slice(1)}'`
}
if (matching.length > 1) {
throw `Found ${matching.length} elements with class name '${selector}', need exactly one only`
}
return matching[0]
}
throw `Selector for mount element to render at must start with either '#' or '.', got: '${selector.charAt(0)}'`
}
function edit_stream(el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement): Stream<{ value: string }> {
let event_name: string
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
event_name = 'input'
} else if (el.tagName === 'SELECT') {
event_name = 'change'
} else {
throw `'Edit' handlers only allowed on <input/>, <select/>, and <textarea/> elements, but got a <${el.tagName.toLowerCase()}/>`
}
return {
subscribe(handler: (ev: { value: string }) => void) {
let listener: (ev: Event) => void
el.addEventListener(event_name, listener = ev => {
handler({ value: (ev.target as HTMLInputElement).value })
})
return {
unsubscribe() {
el.removeEventListener(event_name, listener)
}
}
}
}
}
function get_form_field_value(..._: any) {
return () => { throw 'Not Implemented' }
}
function set_form_field_value(el: Element, value: string) {
if (!(el instanceof HTMLInputElement)) {
throw `set_form_field_value() only allowed on <input/>s, but got a <${el.tagName.toLowerCase()}/>`
}
if (el.value !== value) {
el.value = value
}
}
function str_as_num(str: string) {
const num = +str
return isNaN(num)
? new Error(`String cannot be converted to number: ${str}`)
: num
}
/*
https://jsbin.com/xafelaj/edit?js,output
// A list of counters.
State counters = [0]
%View '#app':
<div>
<ol @list>
{ For Each item In counters:
<li>
<button @dec title="Decrement">-</button>
<input @counter value={item.as_string()} />
<button @inc title="Increment">+</button>
<button @dupe title="Duplicate">⎘</button>
<button @delete title="Delete">x</button>
</li>
}
</ol>
<button @add_counter>Add Counter</button>
</div>
// There's a button to add more counters.
When add_counter.Click:
Change counters To [...counters, 0]
// Counters can be edited, but only to an integer.
When list.counter.Edit Emits {value, context: i}:
Let new_count = value.as_number()
If new_count isnt #error _:
Change counters[i] To new_count.round()
// Each counter has buttons that can decrement, increment, duplicate, or delete it.
When list.inc.Click Emits {context: i}:
Change counters[i] By _ + 1
When list.dec.Click Emits {context: i}:
Change counters[i] By _ - 1
When list.dupe.Click Emits {context: i}:
Change counters By _.insert(at: i, value: counters[i])
When list.delete.Click Emits {context: i}:
Change counters By _.remove(from: i, to: i+1)
=== Browser Prelude ===
Bootstrap create_element From "viewlib.js"
Init _viewlib(create_element)
=== After expansion of %View macro/compiler plugin ===
make_list_items = counters => cmd:
Get looped_elements = counters.map(item => ({
dec: Do create_element("button", attr: %map{ title: "Decrement" }, content: "-"),
counter: Do create_element("input", attr: %map{ value: item.as_string() }),
inc: Do create_element("button", attr: %map{ title: "Increment" }, content: "+"),
dupe: Do create_element("button", attr: %map{ title: "Duplicate" }, content: "⎘"),
delete: Do create_element("button", attr: %map{ title: "Delete" }, content: "x"),
}))
Get list_items = looped_elements.map(els =>
Do create_element("li", attr: #none, content:
els.dec,
els.counter,
els.inc,
els.dupe,
els.delete))
Get events = looped_elements.map(els => ({
dec: Do event_stream(els.dec, 'click'),
counter: Do edit_stream(els.counter),
inc: Do event_stream(els.inc, 'click'),
dupe: Do event_stream(els.dupe, 'click'),
delete: Do event_stream(els.delete, 'click'),
}))
Let updaters = looped_elements.map(els => ({
counter_value: updated_item => cmd:
Let item_as_string = updated_item.as_string()
If item_as_string !== els.counter.get_value():
Do els.set_value(item_as_string)
}))
Return { list_items, events, updaters }
Get { list_items, events: list_events, updaters: list_updaters } =
Do make_list_items(Initial counters)
Get list_el = Do create_element("ol", attr: #none, content: ...list_items)
Get add_counter_button = Do create_element("button", %map{}, "Add Counter")
Do render_view(
'#app',
Do create_element("div", attr: #none, content:
list_el,
add_counter_button))
Init list(list_el, events: list_events, updaters: list_updaters)
Init add_counter(button: add_counter_button)
Module list:
Export make_list, dec, counter, inc, dupe, delete
Init Params = (list_el, events: initial_events, updaters: initial_updaters)
Derive State view From counters:
Initial: {
events: initial_events,
updaters: initial_updaters,
}
Update(counters: ops):
Do ops.reduce(from: 0, update: (soFar: i, next: op) =>
match op:
#skip n ->
i + n
#remove n -> Do cmd:
Do list_el.remove_looped_elements(at: i, n)
Change view.events By _.remove(at: i, n)
Change view.updaters By _.remove(at: i, n)
Return i
#insert counters_items -> Do cmd:
Let n = counters_items.length()
Get { list_items, events: new_events, updaters: new_updaters } =
Do make_list_items(counters_items)
Do list_el.insert_looped_elements(at: i, items: list_items)
Change view.events By _.insert(at: i, items: new_events)
Change view.updaters By _.insert(at: i, items: new_updaters)
Return i + n
#replace counters_items -> Do cmd:
Let n = counters_items.length()
Do counters_items.map_i((item, j) =>
Do updaters[i + j].counter_value(item))
Return i + n
)
Otherwise When counter.Edit Emits { context: i }:
// if `counters` *wasn't* updated, make sure the corresponding
// @counter <input/> value is still properly synced
Do updaters[i].counter_value(counters[i])
contextualized_events = view.events.map_i((r, i) =>
r.map_values(_.map(_ & { context: i })))
Module dec:
Export Click
Import contextualized_events
Click = contextualized_events.map(.dec).any()
Module counter:
Export Edit
Import contextualized_events
Edit = contextualized_events.map(.counter).any()
Module inc:
Export Click
Import contextualized_events
Click = contextualized_events.map(.inc).any()
Module dupe:
Export Click
Import contextualized_events
Click = contextualized_events.map(.dupe).any()
Module delete:
Export Click
Import contextualized_events
Click = contextualized_events.map(.delete).any()
Module add_counter:
Export Click
Init Params = (button)
Click = Do event_stream(button, 'click')
// View library functions:
Import render_view, create_element from "libview.js"
Declare create_element as
(String, attr?: Map<String, String>,
content?: ...List<*Element>) => Command<*Element>
Declare render_view as
(String, *Element) => Command<Void>
*/
// A list of counters.
const counters = [0]
const make_list_items = (counters: number[]) => {
const looped_elements = counters.map(item => ({
dec: create_element("button", { title: "Decrement" }, "-"),
counter: create_element("input", { value: String(item) }),
inc: create_element("button", { title: "Increment" }, "+"),
dupe: create_element("button", { title: "Duplicate" }, "⎘"),
delete_: create_element("button", { title: "Delete" }, "x"),
}))
const list_items = looped_elements.map(els =>
create_element("li", {},
els.dec,
els.counter,
els.inc,
els.dupe,
els.delete_))
const events = looped_elements.map(els => ({
dec: event_stream(els.dec, 'click'),
counter: edit_stream(els.counter),
inc: event_stream(els.inc, 'click'),
dupe: event_stream(els.dupe, 'click'),
delete_: event_stream(els.delete_, 'click'),
}))
const updaters = looped_elements.map(els => ({
counter_value: (updated_item: number) => {
const item_as_string = String(updated_item)
if (item_as_string !== get_form_field_value(els.counter)) {
set_form_field_value(els.counter, item_as_string)
}
},
}))
return { list_items, events, updaters }
}
const { list_items, events: list_events, updaters: list_updaters } =
make_list_items(counters)
const list_el = create_element("ol", {}, ...list_items)
const add_counter_button = create_element('button', {}, 'Add Counter')
render_view(
'#app',
create_element('div', {},
list_el,
add_counter_button))
namespace list {
const initial_events = list_events
const initial_updaters = list_updaters
const view = {
events: initial_events,
updaters: initial_updaters,
}
export const update_view = (ops: ArrayDelta<typeof counters[number]>) => {
ops.reduce((i, op) => {
switch(op.$type) {
case 'skip': {
const n = op.$value
return i + n
}
case 'remove': {
const n = op.$value
remove_looped_elements(list_el, i, n)
view.events .splice(i, n)
view.updaters.splice(i, n)
dec .Click.remove(i, n)
counter.Edit .remove(i, n)
inc .Click.remove(i, n)
dupe .Click.remove(i, n)
delete_.Click.remove(i, n)
return i
}
case 'insert': {
const counters_items = op.$value
const n = counters_items.length
const { list_items, events: new_events, updaters: new_updaters } = make_list_items(counters_items)
insert_looped_elements(list_el, i, list_items)
view.events.splice(i, 0, ...new_events)
view.updaters.splice(i, 0, ...new_updaters)
const new_ctxd_evts = new_events.map(obj => {
const new_obj: any = {}
for (const key in obj) {
const stream: Stream<{ value: Event | string }> = obj[key as keyof typeof obj]
new_obj[key] = map_stream(stream, e =>
({ ...e, context: view.events.indexOf(obj) }))
}
return new_obj as {
[K in keyof typeof obj]: typeof obj[K] extends Stream<infer V>
? Stream<V & { context: number }>
: never
}
})
dec .Click.insert(i, new_ctxd_evts.map(e => e.dec))
counter.Edit .insert(i, new_ctxd_evts.map(e => e.counter))
inc .Click.insert(i, new_ctxd_evts.map(e => e.inc))
dupe .Click.insert(i, new_ctxd_evts.map(e => e.dupe))
delete_.Click.insert(i, new_ctxd_evts.map(e => e.delete_))
return i + n
}
case 'replace': {
const counters_items = op.$value
const n = counters_items.length
for (let j = 0; j < n; j += 1) {
view.updaters[i+j].counter_value(counters_items[j])
}
return i + n
}
}
}, 0)
}
type ListenerFor<S> = S extends { subscribe: (l: infer L) => any } ? L : never
export const sync_view: ListenerFor<typeof counter.Edit> = ({ context: i }) => {
view.updaters[i].counter_value(counters[i])
}
const contextualized_events = view.events.map(obj => {
const new_obj: any = {}
for (const key in obj) {
const stream: Stream<{ value: Event | string }> = obj[key as keyof typeof obj]
new_obj[key] = map_stream(stream, e =>
({ ...e, context: view.events.indexOf(obj) }))
}
return new_obj as {
[K in keyof typeof obj]: typeof obj[K] extends Stream<infer V>
? Stream<V & { context: number }>
: never
}
})
export namespace dec {
export const Click = any_stream(contextualized_events.map(e => e.dec))
}
export namespace counter {
export const Edit = any_stream(contextualized_events.map(e => e.counter))
}
export namespace inc {
export const Click = any_stream(contextualized_events.map(e => e.inc))
}
export namespace dupe {
export const Click = any_stream(contextualized_events.map(e => e.dupe))
}
export namespace delete_ {
export const Click = any_stream(contextualized_events.map(e => e.delete_))
}
}
namespace add_counter {
export const Click = event_stream(add_counter_button, 'click')
}
// There's a button to add more counters.
add_counter.Click.subscribe(() => {
list.update_view([
{ $type: 'skip', $value: counters.length },
{ $type: 'insert', $value: [0] },
])
counters.push(0)
})
// Counters can be edited, but only to an integer.
list.counter.Edit.subscribe(e => {
const {value, context: i} = e
const new_count = str_as_num(value)
if (!(new_count instanceof Error)) {
list.update_view([
{ $type: 'skip', $value: i },
{ $type: 'replace', $value: [new_count] },
])
counters[i] = new_count
}
list.sync_view(e)
})
// Each counter has buttons that can increment, decrement, or delete it.
list.inc.Click.subscribe(({context: i}) => {
list.update_view([
{ $type: 'skip', $value: i },
{ $type: 'replace', $value: [counters[i] + 1] },
])
counters[i] += 1
})
list.dec.Click.subscribe(({context: i}) => {
list.update_view([
{ $type: 'skip', $value: i },
{ $type: 'replace', $value: [counters[i] - 1] },
])
counters[i] -= 1
})
list.dupe.Click.subscribe(({context: i}) => {
list.update_view([
{ $type: 'skip', $value: i },
{ $type: 'insert', $value: [counters[i]] },
])
counters.splice(i, 0, counters[i])
})
list.delete_.Click.subscribe(({context: i}) => {
list.update_view([
{ $type: 'skip', $value: i },
{ $type: 'remove', $value: 1 },
])
counters.splice(i, 1)
})
// utils for built-in types
type ArrayDelta<T> = ArrayDeltaItem<T>[]
type ArrayDeltaItem<T> =
{ $type: 'skip', $value: number }
| { $type: 'remove', $value: number }
| { $type: 'replace', $value: T[] }
| { $type: 'insert', $value: T[] }
function str_as_num(str: string) {
const num = +str
return isNaN(num)
? new Error(`String cannot be converted to number: ${str}`)
: num
}
type TaggedUnion<T = string, V = any> = { $type: T, $value: V }
type TagOf<U extends TaggedUnion> =
U extends TaggedUnion<infer T, any> ? T : never
type ValOf<U extends TaggedUnion> =
U extends TaggedUnion<string, infer V> ? V : never
function map_inner<U extends TaggedUnion, V>(x: U, fn: (v: ValOf<U>) => V): TaggedUnion<TagOf<U>, V> {
return {
$type: x.$type as TagOf<U>,
$value: fn(x.$value),
}
}
function unwrap<U extends TaggedUnion>(x: U): ValOf<U> {
return x.$value
}
// view core
type ElementFromTag<T> =
T extends 'div' ? HTMLDivElement :
T extends 'span' ? HTMLSpanElement :
T extends 'ol' ? HTMLOListElement :
T extends 'li' ? HTMLLIElement :
T extends 'button' ? HTMLButtonElement :
T extends 'input' ? HTMLInputElement :
T extends 'textarea' ? HTMLTextAreaElement :
T extends 'select' ? HTMLSelectElement :
Element
function create_element<T extends string>(
tagName: T, attrs: {[k: string]: string}, ...children: Array<string | Element>
) {
const el = document.createElement(tagName) as ElementFromTag<T>
for (const name in attrs) {
el.setAttribute(name, attrs[name])
}
for (const child of children) {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child))
} else {
el.appendChild(child)
}
}
return el
}
function render_view(selector: string, el: Element) {
const mountEl = getElement(selector)
mountEl.innerHTML = ''
mountEl.appendChild(el)
}
function getElement(selector: string): Element {
if (selector.charAt(0) === '#') {
const matching = document.getElementById(selector.slice(1))
if (matching === null) {
throw `Found no element with id: '${selector.slice(1)}'`
}
return matching
} else if (selector.charAt(0) === '.') {
const matching = document.getElementsByClassName(selector.slice(1))
if (matching.length === 0) {
throw `Found no element with class name: '${selector.slice(1)}'`
}
if (matching.length > 1) {
throw `Found ${matching.length} elements with class name '${selector}', need exactly one only`
}
return matching[0]
}
throw `Selector for mount element to render at must start with either '#' or '.', got: '${selector.charAt(0)}'`
}
// view form field utils
function get_form_field_value(el: HTMLInputElement): string {
return el.value
}
function set_form_field_value(el: HTMLInputElement, value: string): void {
if (el.value !== value) {
el.value = value
}
}
// view events
function event_stream(el: Element, event_name: string): Stream<{ value: Event }> {
return {
subscribe(handler: (ev: { value: Event }) => void) {
let listener: (ev: Event) => void
el.addEventListener(event_name, listener = ev => {
handler({ value: ev })
})
return {
unsubscribe() {
el.removeEventListener(event_name, listener)
}
}
}
}
}
function edit_stream(el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement): Stream<{ value: string }> {
let event_name: string
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
event_name = 'input'
} else if (el.tagName === 'SELECT') {
event_name = 'change'
} else {
throw `'Edit' handlers only allowed on <input/>, <select/>, and <textarea/> elements, but got a <${el.tagName.toLowerCase()}/>`
}
return map_stream(event_stream(el, event_name), ({ value: ev }) => {
return { value: (ev.target as HTMLInputElement).value }
})
}
// view for-loop updates
function insert_looped_elements(el: Element, i: number, items: Element[]) {
const child = el.children[i] // in IE 5.5-8, includes comment nodes https://www.quirksmode.org/dom/core/#t81
for (const item of items) {
el.insertBefore(item, child || null) // IE 5.5+
}
}
function remove_looped_elements(el: Element, i: number, n: number) {
for (let j = 0; j < n; j += 1) {
el.removeChild(el.children[i]) // removeChild is IE 5.5+
}
}
function replace_looped_elements(el: Element, i: number, items: Element[]) {
for (let j = 0; j < items.length; j += 1) {
el.replaceChild(items[j], el.children[i+j]) // replaceChild is IE 5.5+
}
}
// stream utils
interface Stream<T> {
subscribe: (listener: (x: T) => void) => {
unsubscribe: () => void
}
}
function map_stream<T, U>(stream: Stream<T>, fn: (x: T) => U): Stream<U> {
return {
subscribe(listener) {
return stream.subscribe(x => listener(fn(x)))
}
}
}
function any_stream<T>(streams: Stream<T>[]) {
// array of subscriptions per stream
const subs: { unsubscribe: () => void }[][] = streams.map(_ => [])
const listeners: Array<(x: T) => void> = []
return {
subscribe(listener: typeof listeners[number]) {
listeners.push(listener)
for (let i = 0; i < streams.length; i += 1) {
subs[i].push(streams[i].subscribe(listener))
}
return {
unsubscribe() {
const i = listeners.indexOf(listener)
listeners.splice(i, 1)
for (const subsForStream of subs) {
subsForStream.splice(i, 1)
}
}
}
},
insert(i: number, new_streams: Stream<T>[]) {
streams.splice(i, 0, ...new_streams)
subs.splice(i, 0, ...new_streams.map(
s => listeners.map(l => s.subscribe(l))))
},
remove(i: number, n: number) {
streams.splice(i, n)
const removed = subs.splice(i, n)
for (const subsForStream of removed) {
for (const sub of subsForStream) sub.unsubscribe()
}
},
}
}
function filter_stream<T, U extends T>(stream: Stream<T>, predicate: (x: T) => x is U): Stream<U> {
return {
subscribe(listener) {
return stream.subscribe(x => {
if (predicate(x)) {
listener(x)
}
})
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment