gristlabs_grist-core/app/client/lib/koDom.js

491 lines
19 KiB
JavaScript
Raw Permalink Normal View History

/**
* koDom.js is an analog to Knockout.js bindings that works with our dom.js library.
* koDom provides a suite of bindings between the DOM and knockout observables.
*
* For example, here's how we can create som DOM with some bindings, given a view-model object vm:
* dom(
* 'div',
* kd.toggleClass('active', vm.isActive),
* kd.text(function() {
* return vm.data()[vm.selectedRow()][part.value.index()];
* })
* );
*/
/**
* Use the browser globals in a way that allows replacing them with mocks in tests.
*/
var G = require('./browserGlobals').get('document', 'Node');
var ko = require('knockout');
var dom = require('./dom');
var koArray = require('./koArray');
/**
* Creates a binding between a DOM element and an observable value, making sure that
* updaterFunc(elem, value) is called whenever the observable changes. It also registers disposal
* callbacks on the element so that the binding is cleared when the element is disposed with
* ko.cleanNode() or ko.removeNode().
*
* @param {Node} elem: DOM element.
* @param {Object} valueOrFunc: Either an observable, a function (to create ko.computed() with),
* or a constant value.
* @param {Function} updaterFunc: Called both initially and whenever the value changes as
* updaterFunc(elem, value). The value is already unwrapped (so is not an observable).
*/
function setBinding(elem, valueOrFunc, updaterFunc) {
var subscription;
if (ko.isObservable(valueOrFunc)) {
subscription = valueOrFunc.subscribe(function(v) { updaterFunc(elem, v); });
ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {
subscription.dispose();
});
updaterFunc(elem, valueOrFunc.peek());
} else if (typeof valueOrFunc === 'function') {
valueOrFunc = ko.computed(valueOrFunc);
subscription = valueOrFunc.subscribe(function(v) { updaterFunc(elem, v); });
ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {
subscription.dispose();
valueOrFunc.dispose();
});
updaterFunc(elem, valueOrFunc.peek());
} else {
updaterFunc(elem, valueOrFunc);
}
}
exports.setBinding = setBinding;
/**
* Internal helper to create a binding. Used by most simple bindings.
* @param {Object} valueOrFunc: Either an observable, a function (to create ko.computed() with),
* or a constant value.
* @param {Function} updaterFunc: Called both initially and whenever the value changes as
* updaterFunc(elem, value). The value is already unwrapped (so is not an observable).
* @returns {Function} Function suitable to pass as an argument to dom(); i.e. one that takes an
* DOM element, and adds the bindings to it. It also registers disposal callbacks on the
* element, so that bindings are cleaned up when the element is disposed with ko.cleanNode()
* or ko.removeNode().
*/
function makeBinding(valueOrFunc, updaterFunc) {
return function(elem) {
setBinding(elem, valueOrFunc, updaterFunc);
};
}
exports.makeBinding = makeBinding;
/**
* Keeps the text content of a DOM element in sync with an observable value.
* Just like knockout's `text` binding.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
*/
function text(valueOrFunc) {
return function(elem) {
// Since setting textContent property of an element removes all its other children, we insert
// a new text node, and change the content of that. However, we tie the binding to the parent
// elem, i.e. make it disposed along with elem, because text nodes don't get cleaned by
// ko.removeNode / ko.cleanNode.
var textNode = G.document.createTextNode("");
setBinding(elem, valueOrFunc, function(elem, value) {
textNode.nodeValue = value;
});
elem.appendChild(textNode);
};
}
exports.text = text;
// Used for replacing the static token span created by bootstrap tokenfield with the the same token
// but with its text content tied to an observable.
// To use bootstrapToken:
// 1) Get the token to make a clone of and the observable desired.
// 2) Create the new token by calling this function.
// 3) Replace the original token with this newly created token in the DOM by doing
// Ex: var newToken = bootstrapToken(originalToken, observable);
// parentElement.replaceChild(originalToken, newToken);
// TODO: Make templateToken optional. If not given, bind the observable to a manually created token.
function bootstrapToken(templateToken, valueOrFunc) {
var clone = templateToken.cloneNode();
setBinding(clone, valueOrFunc, function(e, value) {
clone.textContent = value;
});
return clone;
}
exports.bootstrapToken = bootstrapToken;
/**
* Keeps the attribute `attrName` of a DOM element in sync with an observable value.
* Just like knockout's `attr` binding. Removes the attribute when the value is null or undefined.
* @param {String} attrName The name of the attribute to bind, e.g. 'href'.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
*/
function attr(attrName, valueOrFunc) {
return makeBinding(valueOrFunc, function(elem, value) {
if (value === null || value === undefined) {
elem.removeAttribute(attrName);
} else {
elem.setAttribute(attrName, value);
}
});
}
exports.attr = attr;
/**
* Sets or removes a boolean attribute of a DOM element. According to the spec, empty string is a
* valid true value for the attribute, and the false value is indicated by the attribute's absence.
* @param {String} attrName The name of the attribute to bind, e.g. 'href'.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
*/
function boolAttr(attrName, valueOrFunc) {
return makeBinding(valueOrFunc, function(elem, value) {
if (!value) {
elem.removeAttribute(attrName);
} else {
elem.setAttribute(attrName, '');
}
});
}
exports.boolAttr = boolAttr;
/**
* Keeps the style property `property` of a DOM element in sync with an observable value.
* Just like knockout's `style` binding.
* @param {String} property The name of the style property to bind, e.g. 'fontWeight'.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
*/
function style(property, valueOrFunc) {
return makeBinding(valueOrFunc, function(elem, value) {
// `style.setProperty` must be use to set custom property (ie: properties starting with '--').
// However since it does not support camelCase property, we still need to use the other form
// `elem.style[prop] = val;` for other properties.
if (property.startsWith('--')) {
elem.style.setProperty(property, value);
} else {
elem.style[property] = value;
}
});
}
exports.style = style;
/**
* Shows or hides the element depending on a boolean value. Note that the element must be visible
* initially (i.e. unsetting style.display should show it).
* @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed
* observable. The value is treated as a boolean.
*/
function show(boolValueOrFunc) {
return makeBinding(boolValueOrFunc, function(elem, value) {
elem.style.display = value ? '' : 'none';
});
}
exports.show = show;
/**
* The opposite of show, equivalent to show(function() { return !value(); }).
* @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed
* observable. The value is treated as a boolean.
*/
function hide(boolValueOrFunc) {
return makeBinding(boolValueOrFunc, function(elem, value) {
elem.style.display = value ? 'none' : '';
});
}
exports.hide = hide;
/**
* Associates some data with the DOM element, using ko.utils.domData.
*/
function domData(key, valueOrFunc) {
return makeBinding(valueOrFunc, function(elem, value) {
ko.utils.domData.set(elem, key, value);
});
}
exports.domData = domData;
/**
* Keeps the value of the given DOM form element in sync with an observable value.
* Just like knockout's `value` binding, except that it is one-directional (for now).
*/
function value(valueOrFunc) {
return makeBinding(valueOrFunc, function(elem, value) {
// This conditional shouldn't be necessary, but on Electron 1.7,
// setting unchanged value cause cursor to jump
if (elem.value !== value) { elem.value = value; }
});
}
exports.value = value;
/**
* Toggles a css class `className` according to the truthiness of an observable value.
* Similar to knockout's `css` binding with a static class.
* @param {String} className The name of the class to toggle.
* @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed
* observable. The value is treated as a boolean.
*/
function toggleClass(className, boolValueOrFunc) {
return makeBinding(boolValueOrFunc, function(elem, value) {
elem.classList.toggle(className, !!value);
});
}
exports.toggleClass = toggleClass;
/**
* Toggles the `disabled` attribute on when boolValueOrFunc evaluates true. When
* it evaluates false, the attribute is removed.
* @param {[type]} boolValueOrFunc boolValueOrFunc An observable, a constant, or a function for a computed
* observable. The value is treated as a boolean.
*/
function toggleDisabled(boolValueOrFunc) {
return makeBinding(boolValueOrFunc, function(elem, disabled) {
if (disabled) {
elem.setAttribute('disabled', 'disabled');
} else {
elem.removeAttribute('disabled');
}
});
}
exports.toggleDisabled = toggleDisabled;
/**
* Adds a css class named by an observable value. If the value changes, the previous class will be
* removed and the new one added. The value may be empty to avoid adding any class.
* Similar to knockout's `css` binding with a dynamic class.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
*/
function cssClass(valueOrFunc) {
var prevClass;
return makeBinding(valueOrFunc, function(elem, value) {
if (prevClass) {
elem.classList.remove(prevClass);
}
prevClass = value;
if (value) {
elem.classList.add(value);
}
});
}
exports.cssClass = cssClass;
/**
* Scrolls a child element into view. The value should be the index of the child element to
* consider. This function supports scrolly, and is mainly useful for scrollable container
* elements, with a `foreach` or a `scrolly` inside.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable
* whose value is the index of the child element to keep scrolled into view.
*/
function scrollChildIntoView(valueOrFunc) {
return makeBinding(valueOrFunc, doScrollChildIntoView);
}
// Key at which we will store the index to scroll for async scrolling.
const indexKey = Symbol();
function doScrollChildIntoView(elem, index, sync) {
if (index === null) {
return Promise.resolve();
}
const scrolly = ko.utils.domData.get(elem, "scrolly");
if (scrolly) {
if (sync) {
scrolly.scrollRowIntoView(index);
// Clear async index for scrolling.
elem[indexKey] = null;
return Promise.resolve();
} else {
// Delay this in case it's triggered while other changes are processed (e.g. splices).
// Scrolling is asynchronous, so in case there is already
// active scroll queued, we will change the target index.
// For example:
// doScrollChildIntoView(el, 10, false) # sets the index to 10 and queues a Promise1
// doScrollChildIntoView(el, 20, false) # updates index to 20 and queues a Promise2
// ....
// Promise1 moves to 20, and clears the index.
// Promise2 checks the index is null and just returns.
elem[indexKey] = index;
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
// If scroll was cancelled (there was another call after, that finished
// and cleared the index) return.
if (elem[indexKey] === null) {
resolve();
return;
}
if (!scrolly.isDisposed()) {
scrolly.scrollRowIntoView(elem[indexKey]);
}
resolve();
} catch(err) {
reject(err);
} finally {
// Clear the index, any subsequent async scrolls will be cancelled (on the if test above).
elem[indexKey] = null;
}
}, 0);
});
}
} else {
const child = elem.children[index];
if (child) {
if (index === 0) {
// Scroll the container all the way if showing the first child.
elem.scrollTop = 0;
}
const childRect = child.getBoundingClientRect();
const parentRect = elem.getBoundingClientRect();
if (childRect.top < parentRect.top) {
child.scrollIntoView(true); // Align with top if scrolling up..
} else if (childRect.bottom > parentRect.bottom) {
child.scrollIntoView(false); // ..bottom if scrolling down.
}
}
}
return Promise.resolve();
}
exports.scrollChildIntoView = scrollChildIntoView;
exports.doScrollChildIntoView = doScrollChildIntoView;
/**
* Adds to a DOM element the content returned by `contentFunc` called with the value of the given
* observable. The content may be a Node, an array of Nodes, text, null or undefined.
* Similar to knockout's `with` binding.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
* @param {Function} contentFunc Called with the value of `valueOrFunc` whenever that value
* changes. The returned content will replace previous content among the children of the bound
* DOM element in the place where the scope() call was present among arguments to dom().
*/
function scope(valueOrFunc, contentFunc) {
var marker, contentNodes = [];
return makeBinding(valueOrFunc, function(elem, value) {
// We keep a comment marker, so that we know where to insert the content, and numChildren, so
// that we know how many children are part of that content.
if (!marker) {
marker = elem.appendChild(G.document.createComment(""));
}
// Create the new content before destroying the old, so that it is OK for the new content to
// include the old (by reattaching inside the new content). If we did it after, the old
// content would get destroyed before it gets moved. (Note that "destroyed" here means
// clearing associated bindings and event handlers, so it's not easily visible.)
var content = dom.frag(contentFunc(value));
// Remove any children added last time, cleaning associated data.
for (var i = 0; i < contentNodes.length; i++) {
if (contentNodes[i].parentNode === elem) {
ko.removeNode(contentNodes[i]);
}
}
contentNodes.length = 0;
var next = marker.nextSibling;
elem.insertBefore(content, next);
// Any number of children may have gotten added if content was a DocumentFragment.
for (var n = marker.nextSibling; n !== next; n = n.nextSibling) {
contentNodes.push(n);
}
});
}
exports.scope = scope;
/**
* Conditionally adds to a DOM element the content returned by `contentFunc()` depending on the
* boolean value of the given observable. The content may be a Node, an array of Nodes, text, null
* or undefined.
* Similar to knockout's `if` binding.
* @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed
* observable. The value is checked as a boolean, and passed to the content function.
* @param {Function} contentFunc A function called with the value of `boolValueOrFunc` whenever
* the observable changes from false to true. The returned content is added to the bound DOM
* element in the place where the maybe() call was present among arguments to dom().
*/
function maybe(boolValueOrFunc, contentFunc) {
return scope(boolValueOrFunc, function(yesNo) {
return yesNo ? contentFunc(yesNo) : null;
});
}
exports.maybe = maybe;
/**
* Observes an observable array (koArray), and creates and adds as many children to the bound DOM
* element as there are items in it. As the array is changed, children are added or removed. Also
* works for a plain data array, creating a static list of children.
*
* Elements are typically added and removed by splicing data into or out of the data koArray. When
* an item is removed, the corresponding node is removed using ko.removeNode (which also runs any
* disposal tied to the node). If the caller retains a reference to a Node item, and removes it
* from its parent, foreach will cope with it fine, but will not call ko.removeNode on that node
* when the item from which it came is spliced out.
*
* @param {koArray} data An koArray instance.
* @param {Function} itemCreateFunc A function called as `itemCreateFunc(item)` for each
* array element. Note: `index` is not passed to itemCreateFunc as it is only correct at the
* time the item is created, and does not reflect further changes to the array.
*/
function foreach(data, itemCreateFunc) {
var marker;
var children = [];
return function(elem) {
if (!marker) {
marker = elem.appendChild(G.document.createComment(""));
}
var spliceFunc = function(splice) {
var i, start = splice.start;
// Remove the elements that are gone.
var deletedElems = children.splice(start, splice.deleted.length);
for (i = 0; i < deletedElems.length; i++) {
// Some nodes may be null, or may have been removed elsewhere in the program. The latter
// are no longer our responsibility, and we should not clean them up.
if (deletedElems[i] && deletedElems[i].parentNode === elem) {
ko.removeNode(deletedElems[i]);
}
}
if (splice.added > 0) {
// Create and insert new elements.
var frag = G.document.createDocumentFragment();
var spliceArgs = [start, 0];
for (i = 0; i < splice.added; i++) {
var itemModel = splice.array[start + i];
var insertEl = itemCreateFunc(itemModel);
if (insertEl) {
ko.utils.domData.set(insertEl, "itemModel", itemModel);
frag.appendChild(insertEl);
}
spliceArgs.push(insertEl);
}
// Add new elements to the children array we maintain.
Array.prototype.splice.apply(children, spliceArgs);
// Find a valid child immediately preceding the start of the splice, for DOM insertion.
var baseElem = marker;
for (i = start - 1; i >= 0; i--) {
if (children[i] && children[i].parentNode === elem) {
baseElem = children[i];
break;
}
}
elem.insertBefore(frag, baseElem.nextSibling);
}
};
var array = data;
if (koArray.isKoArray(data)) {
var subscription = data.subscribe(spliceFunc, null, 'spliceChange');
ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {
subscription.dispose();
});
array = data.all();
} else if (!Array.isArray(data)) {
throw new Error("koDom.foreach applied to non-array: " + data);
}
spliceFunc({ array: array, start: 0, added: array.length, deleted: [] });
};
}
exports.foreach = foreach;