UNPKG

@joshuafcole/fluorine

Version:

A highly reactive, optionally compiled, and strongly typed view layer for TypeScript.

794 lines (716 loc) 21.6 kB
import { now, inline_chunk, EMPTY, pad_lines, pad_chunk } from "efreet/utils"; import { RawValue, EventBinder } from "./catalyst"; export enum PropType { Property, Attribute, Event, Lifecycle } export interface FElement extends FElement.Basic, FElement.Content, FElement.Hooks, FElement.Lifecycle { [attr: string]: any; } export interface FSVGElement extends FElement.Basic, FElement.SVGPresentation, FElement.Hooks, FElement.Lifecycle { svg: true; [attr: string]: any; } export namespace FElement { export type EventHandler = ReturnType<EventBinder["compile"]>; export interface Hooks { // Mouse Hooks click?: EventBinder | EventHandler; dblclick?: EventBinder | EventHandler; contextmenu?: EventBinder | EventHandler; mousedown?: EventBinder | EventHandler; mouseup?: EventBinder | EventHandler; mousemove?: EventBinder | EventHandler; mouseover?: EventBinder | EventHandler; mouseout?: EventBinder | EventHandler; mouseenter?: EventBinder | EventHandler; mouseleave?: EventBinder | EventHandler; wheel?: EventBinder | EventHandler; // Touch Hooks touchstart?: EventBinder | EventHandler; touchend?: EventBinder | EventHandler; touchcancel?: EventBinder | EventHandler; touchmove?: EventBinder | EventHandler; // Drag-n-Drop Hooks dragstart?: EventBinder | EventHandler; dragover?: EventBinder | EventHandler; drag?: EventBinder | EventHandler; dragend?: EventBinder | EventHandler; drop?: EventBinder | EventHandler; // Keyboard Hooks keyup?: EventBinder | EventHandler; keydown?: EventBinder | EventHandler; // Misc. Hooks focus?: EventBinder | EventHandler; blur?: EventBinder | EventHandler; focusin?: EventBinder | EventHandler; focusout?: EventBinder | EventHandler; scroll?: EventBinder | EventHandler; change?: EventBinder | EventHandler; input?: EventBinder | EventHandler; cut?: EventBinder | EventHandler; copy?: EventBinder | EventHandler; paste?: EventBinder | EventHandler; // Animation Hooks transitionstart?: EventBinder | EventHandler; transitionrun?: EventBinder | EventHandler; transitioncancel?: EventBinder | EventHandler; transitionend?: EventBinder | EventHandler; animationstart?: EventBinder | EventHandler; animationiteration?: EventBinder | EventHandler; animationcancel?: EventBinder | EventHandler; animationend?: EventBinder | EventHandler; } export interface Lifecycle { postrender?: (elem: FElement, node: Element) => void; } export interface Basic { class?: string; id?: string; tabindex?: number; kind?: string; svg?: boolean; key?: string; ix?: number; dirty?: boolean; focused?: boolean; style?: string; } export interface Content { selected?: boolean; contentEditable?: boolean; checked?: boolean; disabled?: boolean; draggable?: boolean; spellcheck?: boolean; allowfullscreen?: boolean; title?: string; placeholder?: string; href?: string; src?: string; download?: string; type?: string; min?: number; max?: number; step?: number; value?: any; for?: string; name?: string; target?: any; } type CursorValues = | "auto" | "crosshair" | "default" | "pointer" | "move" | "e-resize" | "ne-resize" | "nw-resize" | "n-resize" | "se-resize" | "sw-resize" | "s-resize" | "w-resize" | "text" | "wait" | "help" | "inherit" | string; type DisplayValues = | "inline" | "block" | "list-item" | "run-in" | "compact" | "marker" | "table" | "inline-table" | "table-row-group" | "table-header-group" | "table-footer-group" | "table-row" | "table-column-group" | "table-column" | "table-cell" | "table-caption" | "none" | "inherit" | "flex" | "grid"; type SVGDrawRule = "nonzero" | "evenodd"; type SVGPointerEvents = | "bounding-box" | "visiblePainted" | "visibleFill" | "visibleStroke" | "visible" | "painted" | "fill" | "stroke" | "all" | "none"; export interface SVGPresentation { "clip-path"?: string; "clip-rule"?: SVGDrawRule | "inherit"; color?: string; "color-interpolation"?: "auto" | "sRGB" | "linearRGB" | "inherit"; "color-rendering"?: | "auto" | "optimizeSpeed" | "optimizeQuality" | "inherit"; cursor?: CursorValues; display?: DisplayValues; fill?: string; "fill-opacity"?: number | string; "fill-rule"?: SVGDrawRule; filter?: string; mask?: string; opacity?: number | string; "pointer-events"?: SVGPointerEvents; "shape-rendering"?: | "auto" | "optimizeSpeed" | "crispEdges" | "geometricPrecision" | "inherit"; stroke?: string; "stroke-dasharray"?: string; "stroke-dashoffset"?: number | string; "stroke-linecap"?: "butt" | "round" | "square"; "stroke-linejoin"?: "arcs" | "bevel" | "miter" | "miter-clip" | "round"; "stroke-miterlimit"?: number | string; "stroke-opacity"?: number | string; "stroke-width"?: number | string; transform?: string; "vector-effect"?: "default" | "non-scaling-stroke" | "inherit" | string; visibility?: "visible" | "hidden" | "collapse" | "inherit"; } } export type FChild = FElem | string | undefined; export interface FElem extends FElement { _id: string; kind: string; tagname?: string; parent?: string; ix?: number; children?: (FChild | FChild[])[]; [attr: string]: any; } export interface FNode extends Element, HTMLOrSVGElement { _id: string; } export const EMPTY_ELEM = {} as Readonly<FElem>; const namespace_urls = { xlink: "http://www.w3.org/1999/xlink" }; function set_attribute(node: Element, property: string, value: any) { let [ns, prop] = property.split(":"); if (prop === undefined) [prop, ns] = [ns, prop]; if (ns) console.log(ns, prop, value); if (value === false || value === undefined) ns ? node.removeAttributeNS(namespace_urls[ns], prop) : node.removeAttribute(property); else if (value === true) ns ? node.setAttributeNS(namespace_urls[ns], prop, "") : node.setAttribute(property, ""); else ns ? node.setAttributeNS(namespace_urls[ns], prop, value) : node.setAttribute(property, value); } export class Renderer { prop_map: { [prop: string]: PropType } = { // DEBUG _id: PropType.Attribute, _ix: PropType.Attribute, // Content class: PropType.Attribute, type: PropType.Attribute, disabled: PropType.Attribute, spellcheck: PropType.Attribute, placeholder: PropType.Attribute, tabindex: PropType.Attribute, title: PropType.Attribute, href: PropType.Attribute, src: PropType.Attribute, target: PropType.Attribute, download: PropType.Attribute, allowfullscreen: PropType.Attribute, for: PropType.Attribute, name: PropType.Attribute, id: PropType.Attribute, selected: PropType.Attribute, checked: PropType.Attribute, // style: PropType.Property, // Mouse Hooks click: PropType.Event, dblclick: PropType.Event, contextmenu: PropType.Event, mousedown: PropType.Event, mouseup: PropType.Event, mousemove: PropType.Event, mouseover: PropType.Event, mouseout: PropType.Event, mouseenter: PropType.Event, mouseleave: PropType.Event, wheel: PropType.Event, // Touch Hooks touchstart: PropType.Event, touchend: PropType.Event, touchcancel: PropType.Event, touchmove: PropType.Event, // Drag-n-Drop Hooks dragstart: PropType.Event, dragover: PropType.Event, drag: PropType.Event, dragend: PropType.Event, drop: PropType.Event, // Keyboard Hooks keyup: PropType.Event, keydown: PropType.Event, // Misc. Hooks focus: PropType.Event, blur: PropType.Event, focusin: PropType.Event, focusout: PropType.Event, scroll: PropType.Event, change: PropType.Event, input: PropType.Event, cut: PropType.Event, copy: PropType.Event, paste: PropType.Event, // Animation Hooks transitionstart: PropType.Event, transitionrun: PropType.Event, transitioncancel: PropType.Event, transitionend: PropType.Event, animationstart: PropType.Event, animationiteration: PropType.Event, animationcancel: PropType.Event, animationend: PropType.Event, // Lifecycle postrender: PropType.Lifecycle }; differs: { [kind: string]: (cur: FElem, old: FElem | undefined) => boolean; } = { static: this.static_diff, text: this.text_diff, dynamic: this.dynamic_diff }; updaters: { [kind: string]: ( cur: FElem, old: FElem | undefined, node: Element, lib: Renderer ) => void; } = { static: this.static_update, text: this.text_update, dynamic: this.dynamic_update }; container: FNode = document.createElement("div") as any; node_cache: { [id: string]: FNode } = { __root: this.container, head: document.head as any }; prev_tree: { [id: string]: FElem }; tree: { [id: string]: FElem } = {}; postrenders: string[]; to_focus?: string; render_roots: () => FElement[] = () => []; debug = { tree: false, perf: false, events: false }; queued: boolean; constructor() { this.container.className = "__root"; this.container._id = "__root"; } handle_event = (event: Event) => { let id = ((event.currentTarget || event.target) as FNode)._id; let elem = this.tree[id]; if (!elem) return; let handler = elem[event.type] as FElement.EventHandler | EventBinder; if (handler) { if (typeof handler === "function") handler(event, elem); else handler.compile()(event, elem); } }; reset() { this.prev_tree = this.tree; this.tree = {}; this.postrenders = []; } prepare(root: FElement) { let start = now(); let elem_count = 1; let { tree } = this; let elements = [root]; for (let elem_ix = 0; elem_ix < elem_count; elem_ix += 1) { let cur: FElement = elements[elem_ix]; if (cur.parent === undefined) cur.parent = "__root"; if (cur._id === undefined) cur._id = "__root__" + elem_ix; tree[cur._id] = cur as FElem; let { children = EMPTY } = cur; let child_count = 0; for (let maybe_child of children) { if (maybe_child === undefined) continue; if ( typeof maybe_child === "string" || typeof maybe_child === "number" ) { elements.push( this.prepare_child( cur._id, { kind: "text", textContent: "" + maybe_child }, child_count++ ) ); } else if (!(maybe_child instanceof Array)) { elements.push( this.prepare_child(cur._id, maybe_child, child_count++) ); } else { for (let sub_child of maybe_child) { if (sub_child === undefined) continue; if ( typeof sub_child === "string" || typeof sub_child === "number" ) { elements.push( this.prepare_child( cur._id, { kind: "text", textContent: "" + sub_child }, child_count++ ) ); } else if (!(sub_child instanceof Array)) { elements.push( this.prepare_child(cur._id, sub_child, child_count++) ); } else { throw new Error( "Fluorine allows one one layer of array-based child nesting (make sure to flatten any nested maps)." ); } } } } elem_count += child_count; } if (this.debug.perf) console.log(`Prepared (${elem_count}) in ${now() - start}`); return tree; } prepare_child(parent_id: string, child: FElement, child_ix: number) { if (child._id === undefined) child._id = `${parent_id}__${child_ix}`; if (child.ix === undefined) child.ix = child_ix; if (child.parent === undefined) child.parent = parent_id; return child as FElem; } domify() { let { differs, prev_tree, tree } = this; for (let id of Object.keys(prev_tree)) { if (!tree[id]) { // Remove let node = this.node_cache[id]; if (node.parentNode) node.parentNode.removeChild(node); } } for (let id of Object.keys(tree)) { let cur = tree[id]; let prev = prev_tree[id]; if (!prev) { // Add let node: FNode; if (cur.svg) node = document.createElementNS( "http://www.w3.org/2000/svg", cur.tagname || "div" ) as any; else if (cur.kind === "text") node = document.createTextNode(cur.textContent) as any; else node = document.createElement(cur.tagname || "div") as any; this.node_cache[id] = node; node._id = id; prev = EMPTY_ELEM; } else if (cur.tagname !== prev.tagname) { // Replace let old_node = this.node_cache[id]; if (old_node.parentNode) old_node.parentNode.removeChild(old_node); let new_node: FNode; if (cur.svg) new_node = document.createElementNS( "http://www.w3.org/2000/svg", cur.tagname || "div" ) as any; else if (cur.kind === "text") new_node = document.createTextNode(cur.textContent) as any; else new_node = document.createElement(cur.tagname || "div") as any; this.node_cache[id] = new_node; new_node._id = id; // @FIXME: If it isn't correct to always omit old children, we need to topo sort the inputs and go top down. // while (old_node.firstChild) new_node.appendChild(old_node.firstChild); let old_kids = prev.children; prev = EMPTY_ELEM; prev.children = old_kids; let child_ix = 0; let child_count = old_kids ? old_kids.length : 0; while (old_node.firstChild) { let child = old_node.firstChild as FNode | (Text & { _id?: string }); let cur_kid = (old_kids || EMPTY)[child_ix]; if (child_ix >= child_count || !cur_kid) { old_node.removeChild(child); } else if ( (child instanceof Text && child.textContent === cur_kid) || child._id === (cur_kid as FElem)._id ) { new_node.appendChild(child); } child_ix += 1; } } if (cur.ix !== prev.ix || cur.parent !== prev.parent) { // Move let node = this.node_cache[id]; this._move_elem(id, cur, node); if (this.debug.tree && node.nodeName !== "#text") { node.setAttribute("_id", cur._id); node.setAttribute("ix", "" + cur.ix); } } if (differs[cur.kind](cur, prev)) { // Update let node = this.node_cache[id]; this.updaters[cur.kind](cur, prev, node, this); if (cur.postrender) this.postrenders.push(id); } if (cur.focused && cur.focused !== prev.focused) { // Focus this.to_focus = id; } } } _move_elem(id: string, cur: FElem, node: Element) { let parent_node = this.node_cache[cur.parent!]; if (parent_node) { if (cur.ix! >= parent_node.children.length) { parent_node.appendChild(node); } else { parent_node.insertBefore(node, parent_node.children[cur.ix!]); } } if (cur.postrender) this.postrenders.push(id); } postdomify() { for (let id of this.postrenders) { let node = this.node_cache[id]; let cur = this.tree[id]; cur.postrender!(cur, node); } if (this.to_focus) { let id = this.to_focus; this.to_focus = undefined; let node = this.node_cache[id]; if (node.focus) { setTimeout(() => { node.focus(); if ("value" in node) { let input: HTMLInputElement = node as any; input.selectionStart = input.selectionEnd = ( input.value || "" ).length; } // if (typeof window.getSelection != "undefined" // && typeof document.createRange != "undefined") { // let range = document.createRange(); // range.selectNodeContents(node); // range.collapse(false); // let sel = window.getSelection(); // sel.removeAllRanges(); // sel.addRange(range); // } }, 0); } } } redraw() { if (!this.queued) { this.queued = true; requestAnimationFrame(this.force_draw); } } force_draw = () => { let start = now(); let roots = this.render_roots(); let root_time = now(); this.render(roots); if (this.debug.perf) { console.info( "Render time", now() - root_time, "Gen roots time", root_time - start ); } this.queued = false; }; render(elems: FElement[]) { this.reset(); // We sort elements by depth to allow them to be self referential. elems.sort( (a, b) => (a.parent ? a.parent.split("__").length : 0) - (b.parent ? b.parent.split("__").length : 0) ); let start = now(); for (let elem of elems) this.prepare(elem); let prepare = now(); this.domify(); let domify = now(); this.postdomify(); let postDomify = now(); let time = now() - start; if (time > 15) { console.info("slow render (> 15ms): ", time, { prepare: prepare - start, domify: domify - prepare, postDomify: postDomify - domify }); } } static_diff(cur: FElem, old: FElem) { return !!cur.dirty; } static_update(this: never, cur: FElem, old: FElem, node: Element) {} text_diff(cur: FElem, old: FElem) { return cur.textContent !== old.textContent; } text_update(this: never, cur: FElem, old: FElem, node: Element) { if (cur.textContent !== old.textContent) node.textContent = cur.textContent; } dynamic_diff(cur: FElem, old: FElem) { for (let prop in cur) { if (cur[prop] !== old[prop]) return true; } for (let prop in old) { if (cur[prop] !== old[prop]) return true; } return false; } dynamic_update( this: never, cur: FElem, old: FElem, node: Element, lib: this ) { for (let prop in cur) { if ( prop === "children" || prop === "ix" || prop === "parent" || prop === "tagname" ) continue; let type = lib.prop_map[prop]; if (cur[prop] !== old[prop]) { if (type === PropType.Attribute || (cur.svg && type === undefined)) { set_attribute(node, prop, cur[prop]); } else if (type === PropType.Event) { // node["on" + prop] = lib.handle_event; node.addEventListener(prop, lib.handle_event); } else { node[prop] = cur[prop]; } } } for (let prop in old) { if (cur[prop] !== undefined) continue; let type = lib.prop_map[prop]; if (type === PropType.Attribute) { node.removeAttribute(prop); } else if (type === PropType.Event) { // node["on" + prop] = undefined; node.removeEventListener(prop, old[prop]); } else if (prop !== "children") { node[prop] = undefined; } } } specialize(kind: string, properties: string[]) { if (this.differs[kind]) return; let start = now(); if (properties.length === 0) { this.differs[kind] = this.static_diff; this.updaters[kind] = this.static_update; return; } let is_svg = !!properties.filter(prop => prop === "svg").length; let prop_checks = properties.map( prop => `cur["${prop}"] !== old["${prop}"]` ); let diff = new Function( "cur", "old", pad_chunk(2)` if(cur.dirty || ${prop_checks.join(" ||\n")}) { return true; } return false; ` ) as (cur: FElem, old: FElem | undefined) => boolean; let prop_updaters = properties.map(prop => { let update: string = `node["${prop}"] = cur["${prop}"];`; let type = this.prop_map[prop]; if (type === PropType.Attribute || (type === undefined && is_svg)) { update = inline_chunk` if(cur["${prop}"] === undefined || cur["${prop}"] === false) node.removeAttribute("${prop}"); else node.setAttribute("${prop}", cur["${prop}"] === true ? "" : cur["${prop}"]); `; } else if (type === PropType.Event) { update = inline_chunk` node["${"on" + prop}"] = cur["${prop}"] ? lib.handle_event : undefined; `; } else if (type === PropType.Lifecycle) { // This is a no-op, lifecycle events are managed using the element, not the node. } return inline_chunk` if(cur["${prop}"] !== old["${prop}"]) { ${update} } `; }); let update = new Function( "cur", "old", "node", "lib", pad_chunk(2)` ${prop_updaters.join("\n")} ` ) as ( cur: FElem, old: FElem | undefined, node: Element | undefined ) => void; this.differs[kind] = diff; this.updaters[kind] = update; } }