@purtuga/dom-data-bind
Version:
DOM Data Bind utility. Bind data to DOM
388 lines (331 loc) • 13.8 kB
JavaScript
import {Map} from "@purtuga/common/src/jsutils/Map.js"
import {domInsertBefore} from "@purtuga/common/src/domutils/domInsertBefore.js"
import {
arraySplice,
isArray,
objectKeys
} from "@purtuga/common/src/jsutils/runtime-aliases.js"
import Directive from "./Directive.js"
import {
arrayForEach,
createComment,
createValueGetter,
DOM_DATA_BIND_PROP,
getAttribute,
hasAttribute,
isPureObject,
PRIVATE,
removeAttribute,
removeChild
} from "../utils"
import {render} from "../render.js";
import {view} from "../view.js";
import {NodeHandler} from "./NodeHandler.js";
//============================================
const EACH = Symbol("directive.each.setup");
const DIRECTIVE = "_each";
const KEY_DIRECTIVE = "_key";
const destroyBinder = binder => binder && binder._destroy();
const defaultRowKey = data => data;
const isEmptyList = list => (isArray(list) && !list.length) || (isPureObject(list) && !objectKeys(list).length);
class EachDirectiveNodeHandler extends NodeHandler {
binders = [];
bindersByKey = new Map();
listIterator = () => this._directive.iterateOverList(this, PRIVATE.get(this).value);
init(directive, node, directives) {
super.init(directive, node, directives);
this._placeholderEle = createComment("directive.each");
this._isSoleChild = hasDedicatedParent(this._node);
// create the template for the row content, which is stored in the Comment node data
this._viewTemplate = view(node.data, directives);
if (!this._viewTemplate[EACH]) {
setupViewTemplate(this._viewTemplate);
}
domInsertBefore(this._placeholderEle, node);
removeChild(node.parentNode, node);
}
update(newList) {
const state = PRIVATE.get(this);
if (newList !== state.value) {
state.value = null;
if (this.listIterator.stopWatchingAll) {
this.listIterator.stopWatchingAll();
}
}
if (!newList) {
this._directive.destroyChildBinders(this.binders, this);
return;
}
state.value = newList;
if (isEmptyList(newList) && this.binders) {
this._directive.destroyChildBinders(this.binders, this);
}
else {
this.listIterator();
}
}
destroy() {
// Support for Observables
if (this.listIterator.stopWatchingAll) {
this.listIterator.stopWatchingAll();
}
this.bindersByKey.clear();
this._directive.destroyChildBinders(this.binders, this);
super.destroy();
}
}
/**
* Directive to loop through an array or object. In addition, it also support an
* internal binding directive called `b:key`
*
* @class EachDirective
* @extends Directive
*
* @example
*
* // Use with array:
* _each="item of arrayList"
* _each="(item, index) of arrayList"
*
* // Use with Object
* _each="value of objectList"
* _each="(value, key) of objectList"
*/
export class EachDirective extends Directive {
static NodeHandlerConstructor = EachDirectiveNodeHandler;
static has(ele) {
return hasAttribute(ele, DIRECTIVE) ? DIRECTIVE : "";
}
static manages() { return true; }
init(attr, attrValue) {
const [ iteratorArgs, listVar ] = parseDirectiveValue((attrValue || "").trim());
this._attr = attr;
this._iteratorArgs = iteratorArgs;
this._tokenValueGetter = createValueGetter((listVar || ""), "each");
}
/**
* Destroy the binder instances and remove Elements from DOM.
*
* @param binders
* @param handler
*/
destroyChildBinders(binders, handler) {
if (!binders || !binders.length) {
return;
}
binders = binders.splice(0);
if (handler._isSoleChild) { // Supper fast way to just clear the UI
const parentEle = handler._placeholderEle.parentNode;
parentEle.textContent = "";
parentEle.appendChild(handler._placeholderEle);
setTimeout(() => {
arrayForEach(binders, binder => binder._destroy());
});
}
else {
arrayForEach(binders, binder => binder._destroy());
}
}
/**
* Returns an object (`dataObj` if provided on input) with additional keys - each
* one being the argNames that the user defined in their HTML `_each` template.
*
* It essentially matches up two array by using the keys from one array and mapping to
* values from the second array at exactly the same location.
* Example:
*
* _each="item in arrayList"
* arrayList = [ "value 1" ]
*
* // Array Keys // Array values // result: object
* // Defined in the // Data in actual // Matches the key
* // template // Array // to the data
* //------------------- //----------------- //---------------------
* [ [ === {
* "item" "value 1" === item: "value1"
* ] ] === }
*
* @param {Array} values
* @param {Object} [dataObj]
*
* @returns {Object}
*/
getDataForIteration(values, dataObj) {
return this._iteratorArgs.reduce(
(rowData, argName) => {
rowData[argName] = values.shift();
return rowData;
},
dataObj || {}
);
}
/**
* Iterates over a new set (list) and eitehr updates or builds out new elements for each item
* in that list.
*
* @param handler
* @param newData
*/
iterateOverList(handler, newData) {
/** @type NodeHandlerState */
const state = PRIVATE.get(handler);
let isDataArray = isArray(newData);
let iterationDataList;
if (isDataArray) {
isDataArray = true;
iterationDataList = newData;
}
else if (isPureObject(newData)) {
iterationDataList = objectKeys(newData);
} else {
return;
}
const currentBinders = handler.binders;
const binderToBeDestroyed = new Map(); // Will be recycled
const totalItems = iterationDataList.length;
const { usesKey, getKey } = handler._viewTemplate[EACH];
// Loop through each piece of data and build a DOM binder for it.
// The data should be in sync with `currentBinders`
for (let i = 0; i < totalItems; i++) {
let rowData = { // FIXME: can this object creation be avoided? For Arrays - it should be possible. Objects - not sure.
$root: state.data.$root || state.data,
$parent: state.data,
$data: state.data.$data || state.data
};
// Adjust the rowData to have the `key` and/or `value` and `index` as top level items
// These are added to the rowData object just created above.
if (isDataArray) {
this.getDataForIteration([ iterationDataList[i], i ], rowData);
}
else {
this.getDataForIteration([ newData[ iterationDataList[i] ], iterationDataList[i], i ], rowData);
}
const rowKey = getKey(
usesKey
? rowData // => Use rowData created above - getKey() will run a value getter on it.
: isDataArray
? iterationDataList[i] // => Use the object from the newData
: newData[ iterationDataList[i] ] // => Use the Object key
);
// If a binder currently exists, then see if it is the one previously
// created for this row's data
if (currentBinders[i] && currentBinders[i]._loop.rowKey === rowKey) {
currentBinders[i][DOM_DATA_BIND_PROP].setData(rowData);
continue;
}
// If there is a binder at the current position, then its not the one need.
// move it to the `to be destroyed` list.
if (currentBinders[i]) {
currentBinders[i][DOM_DATA_BIND_PROP].recover();
binderToBeDestroyed.set(
currentBinders[i]._loop.rowKey,
currentBinders[i]
);
currentBinders[i] = null;
}
// Do we have a rowBinder for this data item in the existing list,
// but perhaps at a different location? Get it and move it to the new position.
// Old position in the existing array is set to null (avoids mutating array)
let binder = handler.bindersByKey.get(rowKey);
if (binder) {
if (binder._loop.pos !== null && currentBinders[binder._loop.pos] === binder) {
currentBinders[binder._loop.pos] = null;
}
} else {
binder = binderToBeDestroyed.get(rowKey);
if (binder) {
binderToBeDestroyed.delete(rowKey);
}
}
if (binder) {
currentBinders[i] = binder;
binder._loop.pos = i;
currentBinders[i][DOM_DATA_BIND_PROP].recover();
positionRowInDom(currentBinders, i, handler._placeholderEle);
currentBinders[i][DOM_DATA_BIND_PROP].setData(rowData);
continue;
}
// Create new binder
// First check if we can recycle one that is tagged to be destroyed.
// if not, then create a new one.
if (binderToBeDestroyed.size) {
const [recycleBinderKey, recycleBinder] = binderToBeDestroyed.entries().next().value;
binder = recycleBinder;
binder[DOM_DATA_BIND_PROP].setData(rowData);
binderToBeDestroyed.delete(recycleBinderKey);
binder._loop.rowKey = rowKey;
binder._loop.pos = i;
} else {
binder = render(handler._viewTemplate, rowData, handler._directives);
binder._destroy = destroyRowElement;
binder._handler = handler; // needed by destroyRowElement()
binder._loop = { rowKey, pos: i };
}
currentBinders[i] = binder;
handler.bindersByKey.set(rowKey, binder);
positionRowInDom(currentBinders, i, handler._placeholderEle);
}
// Destroy binders that were not used
if (binderToBeDestroyed.size) {
arrayForEach(binderToBeDestroyed.values(), destroyBinder);
binderToBeDestroyed.clear();
}
// remove any left over items in currentBinders where is no longer part of newData
if (totalItems < currentBinders.length) {
arrayForEach(arraySplice(currentBinders, totalItems), destroyBinder);
}
}
}
function setupViewTemplate (viewTemplate) {
if (!viewTemplate[EACH]) {
viewTemplate[EACH] = {
usesKey: false,
getKey: defaultRowKey
};
const firstChildNode = viewTemplate.ele.content.firstChild;
if (
firstChildNode &&
firstChildNode.hasAttribute &&
hasAttribute(firstChildNode, KEY_DIRECTIVE)
) {
viewTemplate[EACH].usesKey = true;
viewTemplate[EACH].getKey = createValueGetter(getAttribute(firstChildNode, KEY_DIRECTIVE), "each.key");
removeAttribute(firstChildNode, KEY_DIRECTIVE);
}
}
}
function positionRowInDom(currentBinders, binderIndex, defaultInsertMarkerElement) {
const binder = currentBinders[binderIndex];
// Get all original nodes from binder back to the DocumentFragment
binder[DOM_DATA_BIND_PROP].recover();
// If we have a binder after this one, then do an insertBefore using the first node of the nextSibling
if (currentBinders[binderIndex + 1]) {
domInsertBefore(binder, currentBinders[binderIndex + 1][DOM_DATA_BIND_PROP]._childNodes[0]);
} else {
// Just place the binder before the marker
domInsertBefore(binder, defaultInsertMarkerElement);
}
}
function destroyRowElement () {
// remove all elements/nodes of this row from DOM
this[DOM_DATA_BIND_PROP].recover();
if (this._loop.rowKey) {
this._handler.bindersByKey.delete(this._loop.rowKey);
}
this[DOM_DATA_BIND_PROP].destroy();
}
function parseDirectiveValue(attrValue) {
let matches = /\(?(.+?)\)?\W?(?:of|in)\W(.*)/.exec(attrValue);
if (matches) {
matches = matches.slice(1);
matches[0] = matches[0].split(/,/).map(argName => String(argName).trim());
return matches;
}
return [];
}
function hasDedicatedParent(node) {
return Array.prototype.every.call(node.parentNode.childNodes, childNode => {
return childNode === node || (childNode.nodeType === 3 && !childNode.textContent.trim());
});
}
export default EachDirective;