gristlabs_grist-core/app/client/lib/koDom.js
Jarosław Sadziński c1de16aee7 (core) Scrolling to the active record on search
Summary:
Two bugs fixed:
1. On search, when the first result is in the active record, GridView wasn't scrolling to the active record.
2. When an active record was not visible, GridView wasn't scrolling to the active record when the column index was changed.

The problem was that the scrolling behavior was based only on rowIndex which isn't changed (and doesn't notify subscribers) when a column index changes or when the search highlights a cell.
This diff makes the computed depend also on the fieldIndex, and is introducing a new method that can scroll to the active record on demand (which is used by the search).

Test Plan: Updated tests.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3191
2021-12-21 09:57:21 +01:00

463 lines
18 KiB
JavaScript

/**
* 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);
}
function doScrollChildIntoView(elem, index) {
if (index === null) {
return Promise.resolve();
}
const scrolly = ko.utils.domData.get(elem, "scrolly");
if (scrolly) {
// Delay this in case it's triggered while other changes are processed (e.g. splices).
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
if (!scrolly.isDisposed()) {
scrolly.scrollRowIntoView(index);
}
resolve();
} catch(err) {
reject(err);
}
}, 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;