You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/client/lib/dom.js

454 lines
15 KiB

// Builds a DOM tree or document fragment, easily.
//
// Usage:
// dom('a#link.c1.c2', {href:url}, 'Hello ', dom('span', 'world'));
// creates Node <a id="link" class="c1 c2" href={{url}}>Hello <span>world</span></a>.
// dom.frag(dom('span', 'Hello'), ['blah', dom('div', 'world')])
// creates document fragment with <span>Hello</span>blah<div>world</div>.
//
// Arrays among child arguments get flattened. Objects are turned into attributes.
//
// If an argument is a function it will be called with elem as the argument,
// which may be a convenient way to modify elements, set styles, or attach events.
var ko = require('knockout');
/**
* Use the browser globals in a way that allows replacing them with mocks in tests.
*/
var G = require('./browserGlobals').get('document', 'Node', '$', 'window');
/**
* dom('tag#id.class1.class2' | Node, other args)
* The first argument is typically a string consisting of a tag name, with optional #foo suffix
* to add the ID 'foo', and zero or more .bar suffixes to add a css class 'bar'. If the first
* argument is a Node, that node is used for subsequent changes without creating a new one.
*
* The rest of the arguments are optional and may be:
*
* Nodes - which become children of the created element;
* strings - which become text node children;
* objects - of the form {attr: val} to set additional attributes on the element;
* Arrays of Nodes - which are flattened out and become children of the created element;
* functions - which are called with elem as the argument, for a chance to modify the
* element as it's being created. When functions return values (other than undefined),
* these return values get applied to the containing element recursively.
*/
function dom(firstArg, ...args) {
let elem;
if (firstArg instanceof G.Node) {
elem = firstArg;
} else {
elem = createElemFromString(firstArg, createDOMElement);
}
return handleChildren(elem, arguments, 1);
}
/**
* dom.svg('tag#id.class1.class2', other args) behaves much like `dom`, but does not accept Node
* as a first argument--only a tag string. Because SVG elements are created in a different
* namespace, `dom.svg` should be used for creating SVG elements such as `polygon`.
*/
dom.svg = function (firstArg, ...args) {
let elem = createElemFromString(firstArg, createSVGElement);
return handleChildren(elem, arguments, 1);
};
/**
* Given a tag string of the form 'tag#id.class1.class2' and an element creator function, returns
* a new tag element with the id and classes properly set.
*/
function createElemFromString(tagString, elemCreator) {
// We do careful hand-written parsing rather than use a regexp for speed. Using a regexp is
// significantly more expensive.
let tag, id, classes;
let dotPos = tagString.indexOf(".");
let hashPos = tagString.indexOf('#');
if (dotPos === -1) {
dotPos = tagString.length;
} else {
classes = tagString.substring(dotPos + 1).replace(/\./g, ' ');
}
if (hashPos === -1) {
tag = tagString.substring(0, dotPos);
} else if (hashPos > dotPos) {
throw new Error('ID must come before classes in dom("' + tagString + '")');
} else {
tag = tagString.substring(0, hashPos);
id = tagString.substring(hashPos + 1, dotPos);
}
let elem = elemCreator(tag);
if (id) { elem.setAttribute('id', id); }
if (classes) { elem.setAttribute('class', classes); }
return elem;
}
function createDOMElement(tagName) {
return G.document.createElement(tagName);
}
function createSVGElement(tagName) {
return G.document.createElementNS('http://www.w3.org/2000/svg', tagName);
}
// Append the rest of the arguments as children, flattening arrays
function handleChildren(elem, children, index) {
for (var i = index, len = children.length; i < len; i++) {
var child = children[i];
if (Array.isArray(child)) {
child = handleChildren(elem, child, 0);
} else if (typeof child == 'function') {
child = child(elem);
if (typeof child !== 'undefined') {
handleChildren(elem, [child], 0);
}
} else if (child === null || child === void 0) {
// nothing
} else if (child instanceof G.Node) {
elem.appendChild(child);
} else if (typeof child === 'object') {
for (var key in child) {
elem.setAttribute(key, child[key]);
}
} else {
elem.appendChild(G.document.createTextNode(child));
}
}
return elem;
}
/**
* Creates a DocumentFragment consisting of all arguments, flattening any arguments that are
* arrays. If any arguments or array elements are strings, those are turned into text nodes.
* All argument types supported by the dom() function are supported by dom.frag() as well.
*/
dom.frag = function(varArgNodes) {
var elem = G.document.createDocumentFragment();
return handleChildren(elem, arguments, 0);
};
/**
* Forward all or some arguments to the dom() call. E.g.
*
* dom(a, b, c, dom.fwdArgs(arguments, 2));
*
* is equivalent to:
*
* dom(a, b, c, arguments[2], arguments[3], arguments[4], ...)
*
* It is very convenient to use in other functions which want to accept arbitrary arguments for
* dom() and forward them. See koForm.js for many examples.
*
* @param {Array|Arguments} args: Array or Arguments object containing arguments to forward.
* @param {Number} startIndex: The index of the first element to forward.
*/
dom.fwdArgs = function(args, startIndex) {
return function(elem) {
handleChildren(elem, args, startIndex);
};
};
/**
* Wraps the given function to make it easy to use as an argument to dom(). The passed-in function
* must take a DOM Node as the first argument, and the returned wrapped function may be called
* without this argument when used as an argument to dom(), in which case the original function
* will be called with the element being constructed.
*
* For example, if we define:
* foo.method = dom.inlinable(function(elem, a, b) { ... });
* then the call
* dom('div', foo.method(1, 2))
* translates to
* dom('div', function(elem) { foo.method(elem, 1, 2); })
* which causes foo.method(elem, 1, 2) to be called with elem set to the DIV being constructed.
*
* When the first argument is a DOM Node, calls to the wrapped function proceed as usual. In both
* cases, `this` context is passed along to the wrapped function as expected.
*/
dom.inlinable = dom.inlineable = function inlinable(func) {
return function(optElem) {
if (optElem instanceof G.Node) {
return func.apply(this, arguments);
} else {
return wrapInlinable(func, this, arguments);
}
};
};
function wrapInlinable(func, context, args) {
// The switch is an optimization which speeds things up substantially.
switch (args.length) {
case 0: return function(elem) { return func.call(context, elem); };
case 1: return function(elem) { return func.call(context, elem, args[0]); };
case 2: return function(elem) { return func.call(context, elem, args[0], args[1]); };
case 3: return function(elem) { return func.call(context, elem, args[0], args[1], args[2]); };
}
return function(elem) {
Array.prototype.unshift.call(args, elem);
return func.apply(context, args);
};
}
/**
* Shortcut for document.getElementById.
*/
dom.id = function(id) {
return G.document.getElementById(id);
};
/**
* Hides the given element. Can be passed into dom(), e.g. dom('div', dom.hide, ...).
*/
dom.hide = function(elem) {
elem.style.display = 'none';
};
/**
* Shows the given element, assuming that it's not hidden by a class.
*/
dom.show = function(elem) {
elem.style.display = '';
};
/**
* Toggles the given element, assuming that it's not hidden by a class. The second argument is
* optional, and if provided will make toggle() behave as either show() or hide().
* @returns {Boolean} Whether the element is visible after toggle.
*/
dom.toggle = function(elem, optYesNo) {
if (optYesNo === undefined)
optYesNo = (elem.style.display === 'none');
elem.style.display = optYesNo ? '' : 'none';
return elem.style.display !== 'none';
};
/**
* Set the given className on the element while it is being dragged over.
* Can be used inlined as in `dom(..., dom.dragOverClass('foo'))`.
* @param {String} className: Class name to set while a drag-over is in progress.
*/
dom.dragOverClass = dom.inlinable(function(elem, className) {
// Note: This is hard to get correct on both FF and Chrome because of dragenter/dragleave events that
// occur for contained elements. See
// http://stackoverflow.com/questions/7110353/html5-dragleave-fired-when-hovering-a-child-element.
// Here we use a reference count, and filter out duplicate dragenter events on the same target.
let counter = 0;
let lastTarget = null;
dom.on(elem, 'dragenter', ev => {
if (Array.from(ev.originalEvent.dataTransfer.types).includes('text/html')) {
// This would not be present when dragging in an actual file. We return undefined, to avoid
// suppressing normal behavior (which is suppressed below when we return false).
return;
}
if (ev.target !== lastTarget) {
lastTarget = ev.target;
ev.originalEvent.dataTransfer.dropEffect = 'copy';
if (!counter) { elem.classList.add(className); }
counter++;
}
return false;
});
dom.on(elem, 'dragleave', () => {
lastTarget = null;
counter = Math.max(0, counter - 1);
if (!counter) { elem.classList.remove(className); }
});
dom.on(elem, 'drop', () => {
lastTarget = null;
counter = 0;
elem.classList.remove(className);
});
});
/**
* Change a Node's childNodes similarly to Array splice. This allows removing and adding nodes.
* It translates to calls to replaceChild, insertBefore, removeChild, and appendChild, as
* appropriate.
* @param {Number} index Index at which to start changing the array.
* @param {Number} howMany Number of old array elements to remove or replace.
* @param {Node} optNewChildren This is an optional parameter specifying a new node to insert,
* and may be repeated to insert multiple nodes. Null values are ignored.
* @returns {Array[Node]} array of removed nodes.
* TODO: this desperately needs a unittest.
*/
dom.splice = function(node, index, howMany, optNewChildren) {
var end = Math.min(index + howMany, node.childNodes.length);
for (var i = 3; i < arguments.length; i++) {
if (arguments[i] !== null) {
if (index < end) {
node.replaceChild(arguments[i], node.childNodes[index]);
index++;
} else if (index < node.childNodes.length) {
node.insertBefore(arguments[i], node.childNodes[index]);
} else {
node.appendChild(arguments[i]);
}
}
}
var ret = Array.prototype.slice.call(node.childNodes, index, end);
while (end > index) {
node.removeChild(node.childNodes[--end]);
}
return ret;
};
/**
* Returns the index of the given node among its parent's children (i.e. its siblings).
*/
dom.childIndex = function(node) {
return Array.prototype.indexOf.call(node.parentNode.childNodes, node);
};
function makeFilterFunc(selectorOrFunc) {
if (typeof selectorOrFunc === 'string') {
return function(elem) { return elem.matches && elem.matches(selectorOrFunc); };
}
return selectorOrFunc;
}
/**
* Iterates backwards through the children of `parent`, returning the first one matching the given
* selector or filter function. Returns null if no matching node is found.
*/
dom.findLastChild = function(parent, selectorOrFunc) {
var filterFunc = makeFilterFunc(selectorOrFunc);
for (var c = parent.lastChild; c; c = c.previousSibling) {
if (filterFunc(c)) {
return c;
}
}
return null;
};
/**
* Iterates up the DOM tree from `child` to `container`, returning the first Node matching the
* given selector or filter function. Returns null for no match.
* If `container` is given, the returned node will be non-null only if contained in it.
* If `container` is omitted, the search will go all the way up.
*/
dom.findAncestor = function(child, optContainer, selectorOrFunc) {
if (arguments.length === 2) {
selectorOrFunc = optContainer;
optContainer = null;
}
var filterFunc = makeFilterFunc(selectorOrFunc);
var match = null;
while (child) {
if (!match && filterFunc(child)) {
match = child;
if (!optContainer) {
return match;
}
}
if (child === optContainer) {
return match;
}
child = child.parentNode;
}
return null;
};
/**
* Detaches a Node from its parent, and returns the Node passed in.
*/
dom.detachNode = function(node) {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
return node;
};
/**
* Use JQuery to attach an event handler to the given element. For documentation, see
* http://api.jquery.com/on/. You may use this inline while building dom, as:
* dom(..., dom.on(events, args...))
* E.g.
* dom('div',
* dom.on('click', function(ev) {
* console.log(ev);
* })
* );
*/
dom.on = dom.inlinable(function(elem, events, optSelector, optData, handler) {
G.$(elem).on(events, optSelector, optData, handler);
});
dom.once = dom.inlinable(function(elem, events, optSelector, optData, handler) {
G.$(elem).one(events, optSelector, optData, handler);
});
/**
* Helper to do some processing on a DOM element after the current call stack has cleared. E.g.
* dom('input',
* dom.defer(function(elem) {
* elem.focus();
* })
* );
* will cause elem.focus() to be called for the INPUT element after a setTimeout of 0.
*
* This is often useful for dealing with focusing and selection.
*/
dom.defer = function(func, optContext) {
return function(elem) {
setTimeout(func.bind(optContext, elem), 0);
};
};
/**
* Call the given function with the given context when the element is cleaned up using
* ko.removeNode or ko.cleanNode. This may be used inline as an argument to dom(), without the
* first argument, to apply to the element being constructed. The function called will receive the
* element as the sole argument.
* @param {Node} elem Element whose destruction should trigger a call to func. It
* should be omitted when used as an argument to dom().
* @param {Function} func Function to call, with elem as an argument, when elem is cleaned up.
* @param {Object} optContext Optionally `this` context to call the function with.
*/
dom.onDispose = dom.inlinable(function(elem, func, optContext) {
ko.utils.domNodeDisposal.addDisposeCallback(elem, func.bind(optContext));
});
/**
* Tie the disposal of the given value to the given element, so that value.dispose() gets called
* when the element is cleaned up using ko.removeNode or ko.cleanNode. This may be used inline as
* an argument to dom(), without the first argument, to apply to the element being constructed.
* @param {Node} elem Element whose destruction should trigger the disposal of the value. It
* should be omitted when used as an argument to dom().
* @param {Object} disposableValue A value with a dispose() method, such as a computed observable.
*/
dom.autoDispose = dom.inlinable(function(elem, disposableValue) {
ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {
disposableValue.dispose();
});
});
/**
* Set an identifier for the given element for identifying the element in automated browser tests.
* @param {String} ident: Arbitrary string; convention is to name it as "ModuleName.nameInModule".
*/
dom.testId = dom.inlinable(function(elem, ident) {
elem.setAttribute('data-test-id', ident);
});
module.exports = dom;