@joshuafcole/fluorine
Version:
A highly reactive, optionally compiled, and strongly typed view layer for TypeScript.
794 lines (716 loc) • 21.6 kB
text/typescript
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;
}
}