diff options
-rw-r--r-- | src/lustre.ffi.mjs | 85 | ||||
-rw-r--r-- | src/runtime.ffi.mjs | 166 |
2 files changed, 189 insertions, 62 deletions
diff --git a/src/lustre.ffi.mjs b/src/lustre.ffi.mjs index fa022d1..3a6556e 100644 --- a/src/lustre.ffi.mjs +++ b/src/lustre.ffi.mjs @@ -1,10 +1,6 @@ -import { innerHTML, createTree } from "./runtime.ffi.mjs"; -import { Ok, Error, List } from "./gleam.mjs"; -import { - Some, - None, - map as option_map, -} from "../gleam_stdlib/gleam/option.mjs"; +import { morphdom } from "./runtime.ffi.mjs"; +import { Ok, Error } from "./gleam.mjs"; +import { map } from "./lustre/element.mjs"; // RUNTIME --------------------------------------------------------------------- @@ -18,33 +14,30 @@ export class App { #willUpdate = false; #didUpdate = false; - // These are the three functions that the user provides to the runtime. - #__init; - #__update; - #__render; - constructor(init, update, render) { - this.#__init = init; - this.#__update = update; - this.#__render = render; + this.#init = init; + this.#update = update; + this.#view = render; } start(selector = "body") { if (this.#root) return this; try { - this.#root = document.querySelector(selector); + const el = document.querySelector(selector); + const [next, cmds] = this.#init(); + + this.#root = el; + this.#state = next; + this.#commands = cmds[0].toArray(); + this.#didUpdate = true; + + window.requestAnimationFrame(() => this.#tick()); + + return new Ok((msg) => this.dispatch(msg)); } catch (_) { return new Error(undefined); } - - const [next, cmds] = this.#__init(); - this.#state = next; - this.#commands = cmds[0].toArray(); - this.#didUpdate = true; - - window.requestAnimationFrame(() => this.#tick()); - return new Ok((msg) => this.dispatch(msg)); } dispatch(msg) { @@ -55,22 +48,23 @@ export class App { } #render() { - const node = this.#__render(this.#state); - const tree = createTree(map(node, (msg) => this.dispatch(msg))); + const node = this.#view(this.#state); + const vdom = map(node, (msg) => this.dispatch(msg)); - innerHTML(this.#root, tree); + morphdom(this.#root.firstChild, vdom); } #tick() { this.#flush(); this.#didUpdate && this.#render(); + this.#didUpdate = false; this.#willUpdate = false; } #flush(times = 0) { if (this.#queue.length) { while (this.#queue.length) { - const [next, cmds] = this.#__update(this.#state, this.#queue.shift()); + const [next, cmds] = this.#update(this.#state, this.#queue.shift()); this.#state = next; this.#commands.concat(cmds[0].toArray()); @@ -92,37 +86,4 @@ export class App { } export const setup = (init, update, render) => new App(init, update, render); -export const start = (app, selector = "body") => app.start(selector); - -// VDOM ------------------------------------------------------------------------ - -export const node = (tag, attrs, children) => - createTree(tag, Object.fromEntries(attrs.toArray()), children.toArray()); -export const text = (content) => content; -export const attr = (key, value) => { - if (value instanceof List) return [key, value.toArray()]; - if (value instanceof Some) return [key, value[0]]; - if (value instanceof None) return [key, undefined]; - - return [key, value]; -}; -export const on = (event, handler) => [`on${event}`, handler]; -export const map = (node, f) => ({ - ...node, - attributes: Object.entries(node.attributes).reduce((attrs, [key, value]) => { - // It's safe to mutate the `attrs` object here because we created it at - // the start of the reduce: it's not shared with any other code. - - // If the attribute is an event handler, wrap it in a function that - // transforms - if (key.startsWith("on") && typeof value === "function") { - attrs[key] = (e) => option_map(value(e), f); - } else { - attrs[key] = value; - } - - return attrs; - }, {}), - childNodes: node.childNodes.map((child) => map(child, f)), -}); -export const styles = (list) => Object.fromEntries(list.toArray()); +export const start = (app, selector) => app.start(selector); diff --git a/src/runtime.ffi.mjs b/src/runtime.ffi.mjs index 28066fe..c349a70 100644 --- a/src/runtime.ffi.mjs +++ b/src/runtime.ffi.mjs @@ -1,3 +1,5 @@ +import { h, t } from "./lustre/element.mjs"; + // This file is vendored from https://github.com/patrick-steele-idem/morphdom/ // and is licensed under the MIT license. For a copy of the original license // head on over to: @@ -780,3 +782,167 @@ function morphdomFactory(morphAttrs) { export const morphdom = morphdomFactory(morphAttrs); export default morphdom; + +// VDOM HACKS ------------------------------------------------------------------ + +// Whew this is some Naughty Stuff™. Our `Element` Gleam type is opaque so the +// class constructors are not exposed and we can't import them. We need to +// massage these classes into a shape that morphdom knows how to deal with, and +// because we can't get at the constructors directly we're going to be sneaky and +// get in by constructing some dummy elements and stealing their prototypes. +// +// Morphdom expects the following properties/methods to exist on a valid VDOM +// node: +// +// - firstChild; +// - nextSibling; +// - nodeType; +// - nodeName; +// - namespaceURI; +// - nodeValue; +// - attributes; +// - value; +// - selected; +// - disabled; +// - hasAttributeNS(namespaceURI, name); +// - actualize(document); +// +// See more here: https://github.com/patrick-steele-idem/morphdom/blob/master/docs/virtual-dom.md +// + +Object.defineProperties(h("").constructor.prototype, { + firstChild: { + get() { + // If this is the first time `firstChild` is being accessed, we need to + // create the children array first. + if (!this.children) this.children = this[2].toArray(); + + const child = this.children[0]; + + if (child) { + child.parentElement = this; + child.index = 0; + } + + return child; + }, + }, + + nextSibling: { + get() { + const sibling = this.parentElement?.children[this.index + 1]; + + if (sibling) { + sibling.parentElement = this.parentElement; + sibling.index = this.index + 1; + } + + return sibling; + }, + }, + + nodeType: { + value: ELEMENT_NODE, + }, + + nodeName: { + get() { + return this[0].toUpperCase(); + }, + }, + + namespaceURI: { + value: undefined, + }, + + nodeValue: { + value: null, + }, + + attributes: { + get() { + if (!this._attributes) { + this._attributes = Object.fromEntries(this[1].toArray()); + } + + return this._attributes; + }, + }, + + value: { + get() { + return this.attributes.value; + }, + }, + + selected: { + get() { + return !!this.attributes.selected; + }, + }, + + disabled: { + get() { + return !!this.attributes.disabled; + }, + }, + + hasAttributeNS: { + value: function (_, name) { + return name in this.attributes; + }, + }, + + actualize: { + value: function (document) { + const el = document.createElement(this[0]); + + for (const key in this.attributes) { + el[key] = this.attributes[key]; + } + + for (let child = this.firstChild; !!child; child = child.nextSibling) { + el.appendChild(child.actualize(document)); + } + + return el; + }, + }, +}); + +Object.defineProperties(t("").constructor.prototype, { + nextSibling: { + get() { + const sibling = this.parentElement?.children[this.index + 1]; + + if (sibling) { + sibling.parentElement = this.parentElement; + sibling.index = this.index + 1; + } + + return sibling; + }, + }, + + nodeType: { + get() { + return TEXT_NODE; + }, + }, + + nodeName: { + value: "#text", + }, + + nodeValue: { + get() { + return this[0]; + }, + }, + + actualize: { + value: function (document) { + return document.createTextNode(this[0]); + }, + }, +}); |