vector-im-hydrogen-web/src/ui/web/general/TemplateView.js

308 lines
9.0 KiB
JavaScript
Raw Normal View History

import { setAttribute, text, isChildren, classNames, TAG_NAMES } from "./html.js";
2020-04-29 10:00:51 +02:00
import {errorToDOM} from "./error.js";
2019-06-14 23:08:41 +02:00
function objHasFns(obj) {
for(const value of Object.values(obj)) {
if (typeof value === "function") {
return true;
2019-06-14 23:08:41 +02:00
}
}
return false;
2019-06-14 23:08:41 +02:00
}
2019-06-14 23:46:31 +02:00
/**
Bindable template. Renders once, and allows bindings for given nodes. If you need
to change the structure on a condition, use a subtemplate (if)
supports
- event handlers (attribute fn value with name that starts with on)
- one way binding of attributes (other attribute fn value)
- one way binding of text values (child fn value)
- refs to get dom nodes
- className binding returning object with className => enabled map
2020-04-29 10:10:33 +02:00
- add subviews inside the template
*/
export class TemplateView {
2020-04-29 10:00:51 +02:00
constructor(value, render = undefined) {
this._value = value;
2020-04-29 10:00:51 +02:00
this._render = render;
this._eventListeners = null;
this._bindings = null;
2020-04-29 10:00:51 +02:00
// this should become _subViews and also include templates.
// How do we know which ones we should update though?
// Wrapper class?
this._subViews = null;
this._root = null;
this._boundUpdateFromValue = null;
}
2020-04-30 18:27:21 +02:00
get value() {
return this._value;
}
2020-04-29 10:00:51 +02:00
_subscribe() {
this._boundUpdateFromValue = this._updateFromValue.bind(this);
if (typeof this._value.on === "function") {
this._value.on("change", this._boundUpdateFromValue);
}
else if (typeof this._value.subscribe === "function") {
this._value.subscribe(this._boundUpdateFromValue);
}
}
2020-04-29 10:00:51 +02:00
_unsubscribe() {
if (this._boundUpdateFromValue) {
if (typeof this._value.off === "function") {
this._value.off("change", this._boundUpdateFromValue);
}
else if (typeof this._value.unsubscribe === "function") {
this._value.unsubscribe(this._boundUpdateFromValue);
}
2020-04-29 10:00:51 +02:00
this._boundUpdateFromValue = null;
}
2020-04-29 10:00:51 +02:00
}
_attach() {
if (this._eventListeners) {
for (let {node, name, fn} of this._eventListeners) {
node.addEventListener(name, fn);
}
}
}
2020-04-29 10:00:51 +02:00
_detach() {
if (this._eventListeners) {
for (let {node, name, fn} of this._eventListeners) {
node.removeEventListener(name, fn);
}
}
2020-04-29 10:00:51 +02:00
}
mount(options) {
2020-04-29 19:12:12 +02:00
const builder = new TemplateBuilder(this);
2020-04-29 10:00:51 +02:00
if (this._render) {
2020-04-29 19:12:12 +02:00
this._root = this._render(builder, this._value);
2020-04-29 10:00:51 +02:00
} else if (this.render) { // overriden in subclass
2020-04-29 19:12:12 +02:00
this._root = this.render(builder, this._value);
} else {
throw new Error("no render function passed in, or overriden in subclass");
}
2020-04-29 10:00:51 +02:00
const parentProvidesUpdates = options && options.parentProvidesUpdates;
if (!parentProvidesUpdates) {
this._subscribe();
}
this._attach();
return this._root;
}
2020-04-29 10:00:51 +02:00
unmount() {
this._detach();
this._unsubscribe();
2020-04-30 18:27:21 +02:00
if (this._subViews) {
for (const v of this._subViews) {
v.unmount();
}
2020-04-29 10:00:51 +02:00
}
}
root() {
return this._root;
}
_updateFromValue() {
this.update(this._value);
}
update(value) {
this._value = value;
if (this._bindings) {
for (const binding of this._bindings) {
binding();
}
}
}
_addEventListener(node, name, fn) {
if (!this._eventListeners) {
this._eventListeners = [];
}
this._eventListeners.push({node, name, fn});
}
_addBinding(bindingFn) {
if (!this._bindings) {
this._bindings = [];
}
this._bindings.push(bindingFn);
}
2020-04-29 10:00:51 +02:00
_addSubView(view) {
if (!this._subViews) {
this._subViews = [];
}
2020-04-29 10:00:51 +02:00
this._subViews.push(view);
}
2020-04-29 19:12:12 +02:00
}
// what is passed to render
class TemplateBuilder {
constructor(templateView) {
this._templateView = templateView;
}
get _value() {
return this._templateView._value;
}
_addAttributeBinding(node, name, fn) {
let prevValue = undefined;
const binding = () => {
const newValue = fn(this._value);
if (prevValue !== newValue) {
prevValue = newValue;
setAttribute(node, name, newValue);
}
};
2020-04-29 19:12:12 +02:00
this._templateView._addBinding(binding);
binding();
}
2019-06-14 23:08:41 +02:00
_addClassNamesBinding(node, obj) {
this._addAttributeBinding(node, "className", value => classNames(obj, value));
}
_addTextBinding(fn) {
const initialValue = fn(this._value);
const node = text(initialValue);
let prevValue = initialValue;
const binding = () => {
const newValue = fn(this._value);
if (prevValue !== newValue) {
prevValue = newValue;
node.textContent = newValue+"";
}
};
2020-04-29 19:12:12 +02:00
this._templateView._addBinding(binding);
return node;
}
2019-06-14 23:46:31 +02:00
_setNodeAttributes(node, attributes) {
for(let [key, value] of Object.entries(attributes)) {
const isFn = typeof value === "function";
// binding for className as object of className => enabled
if (key === "className" && typeof value === "object" && value !== null) {
if (objHasFns(value)) {
this._addClassNamesBinding(node, value);
} else {
setAttribute(node, key, classNames(value));
}
2019-06-14 23:46:31 +02:00
} else if (key.startsWith("on") && key.length > 2 && isFn) {
const eventName = key.substr(2, 1).toLowerCase() + key.substr(3);
const handler = value;
2020-04-29 19:12:12 +02:00
this._templateView._addEventListener(node, eventName, handler);
2019-06-14 23:46:31 +02:00
} else if (isFn) {
this._addAttributeBinding(node, key, value);
} else {
setAttribute(node, key, value);
}
}
}
_setNodeChildren(node, children) {
if (!Array.isArray(children)) {
children = [children];
}
for (let child of children) {
if (typeof child === "function") {
child = this._addTextBinding(child);
} else if (!child.nodeType) {
// not a DOM node, turn into text
child = text(child);
}
node.appendChild(child);
}
}
_addReplaceNodeBinding(fn, renderNode) {
let prevValue = fn(this._value);
let node = renderNode(null);
const binding = () => {
const newValue = fn(this._value);
if (prevValue !== newValue) {
prevValue = newValue;
const newNode = renderNode(node);
if (node.parentElement) {
node.parentElement.replaceChild(newNode, node);
}
node = newNode;
}
};
2020-04-29 19:12:12 +02:00
this._templateView._addBinding(binding);
return node;
}
2020-04-29 19:12:48 +02:00
el(name, attributes, children) {
if (attributes && isChildren(attributes)) {
children = attributes;
attributes = null;
}
const node = document.createElement(name);
if (attributes) {
this._setNodeAttributes(node, attributes);
}
if (children) {
this._setNodeChildren(node, children);
}
return node;
}
2020-04-29 10:00:51 +02:00
// this insert a view, and is not a view factory for `if`, so returns the root element to insert in the template
// you should not call t.view() and not use the result (e.g. attach the result to the template DOM tree).
view(view) {
let root;
try {
root = view.mount();
} catch (err) {
return errorToDOM(err);
}
2020-04-29 19:12:12 +02:00
this._templateView._addSubView(view);
2020-04-29 10:00:51 +02:00
return root;
}
// sugar
createTemplate(render) {
return vm => new TemplateView(vm, render);
2020-04-29 10:00:51 +02:00
}
// creates a conditional subtemplate
2020-04-29 10:00:51 +02:00
if(fn, viewCreator) {
const boolFn = value => !!fn(value);
return this._addReplaceNodeBinding(boolFn, (prevNode) => {
if (prevNode && prevNode.nodeType !== Node.COMMENT_NODE) {
2020-04-29 10:00:51 +02:00
const viewIdx = this._subViews.findIndex(v => v.root() === prevNode);
if (viewIdx !== -1) {
const [view] = this._subViews.splice(viewIdx, 1);
view.unmount();
}
}
if (boolFn(this._value)) {
2020-04-29 10:00:51 +02:00
const view = viewCreator(this._value);
return this.view(view);
} else {
return document.createComment("if placeholder");
}
});
}
}
for (const tag of TAG_NAMES) {
2020-04-29 19:12:12 +02:00
TemplateBuilder.prototype[tag] = function(attributes, children) {
2019-06-14 23:46:31 +02:00
return this.el(tag, attributes, children);
};
}