// Creates new InfiniteTree object. constructor(el, options) { super(); if (isDOM(el)) { options = { ...options, el }; } else { options = el; } // Assign options this.options = { ...this.options, ...options }; if (!this.options.el) { console.error('Failed to initialize infinite-tree: el is not specified.', options); return; } this.create(); // Load tree data if it's provided if (options.data) { this.loadData(options.data); } }
exports.render = function(container, props){ if (!HTMLRenderer) throw new Error('You can only render a DOM tree in the browser. Use renderString instead.'); if (!isDom(container)) throw new Error(container + ' is not a valid render target.'); var renderer = new HTMLRenderer(container); var entity = new Entity(this, props); var scene = new Scene(renderer, entity); return scene; };
module.exports = (element,cb) => { if (!(isDOM(element))) { throw new Error('Expected a DOM element.'); } if (!(isFunction(cb))) { throw new Error('Expected a callback.'); } element.addEventListener('scroll',() => { let currentScrollTop = element.scrollTop; let dir = currentScrollTop > prevScrollTop ? 0 : 1; prevScrollTop = currentScrollTop; return cb(dir); }, false); };
function render (app, container, opts) { var frameId var isRendering var rootId = 'root' var currentElement var currentNativeElement var connections = {} var entities = {} var pools = {} var handlers = {} var children = {} children[rootId] = {} if (!isDom(container)) { throw new Error('Container element must be a DOM element') } /** * Rendering options. Batching is only ever really disabled * when running tests, and pooling can be disabled if the user * is doing something stupid with the DOM in their components. */ var options = defaults(assign({}, app.options || {}, opts || {}), { pooling: true, batching: true, validateProps: false }) /** * Listen to DOM events */ addNativeEventListeners() /** * Watch for changes to the app so that we can update * the DOM as needed. */ app.on('unmount', onunmount) app.on('mount', onmount) app.on('source', onupdate) /** * If the app has already mounted an element, we can just * render that straight away. */ if (app.element) render() /** * Teardown the DOM rendering so that it stops * rendering and everything can be garbage collected. */ function teardown () { removeNativeEventListeners() removeNativeElement() app.off('unmount', onunmount) app.off('mount', onmount) app.off('source', onupdate) } /** * Swap the current rendered node with a new one that is rendered * from the new virtual element mounted on the app. * * @param {VirtualElement} element */ function onmount () { invalidate() } /** * If the app unmounts an element, we should clear out the current * rendered element. This will remove all the entities. */ function onunmount () { removeNativeElement() currentElement = null } /** * Update all components that are bound to the source * * @param {String} name * @param {*} data */ function onupdate (name, data) { connections[name](data) } /** * Render and mount a component to the native dom. * * @param {Entity} entity * @return {HTMLElement} */ function mountEntity (entity) { register(entity) setSources(entity) children[entity.id] = {} entities[entity.id] = entity // commit initial state and props. commit(entity) // callback before mounting. trigger('beforeMount', entity, [entity.context]) trigger('beforeRender', entity, [entity.context]) // render virtual element. var virtualElement = renderEntity(entity) // create native element. var nativeElement = toNative(entity.id, '0', virtualElement) entity.virtualElement = virtualElement entity.nativeElement = nativeElement // callback after mounting. trigger('afterRender', entity, [entity.context, nativeElement]) trigger('afterMount', entity, [entity.context, nativeElement, setState(entity)]) return nativeElement } /** * Remove a component from the native dom. * * @param {Entity} entity */ function unmountEntity (entityId) { var entity = entities[entityId] if (!entity) return trigger('beforeUnmount', entity, [entity.context, entity.nativeElement]) unmountChildren(entityId) removeAllEvents(entityId) delete entities[entityId] delete children[entityId] } /** * Render the entity and make sure it returns a node * * @param {Entity} entity * * @return {VirtualTree} */ function renderEntity (entity) { var component = entity.component if (!component.render) throw new Error('Component needs a render function') var result = component.render(entity.context, setState(entity)) if (!result) throw new Error('Render function must return an element.') return result } /** * Whenever setState or setProps is called, we mark the entity * as dirty in the renderer. This lets us optimize the re-rendering * and skip components that definitely haven't changed. * * @param {Entity} entity * * @return {Function} A curried function for updating the state of an entity */ function setState (entity) { return function (nextState) { updateEntityState(entity, nextState) } } /** * Tell the app it's dirty and needs to re-render. If batching is disabled * we can just trigger a render immediately, otherwise we'll wait until * the next available frame. */ function invalidate () { if (!options.batching) { if (!isRendering) render() } else { if (!frameId) frameId = raf(render) } } /** * Update the DOM. If the update fails we stop the loop * so we don't get errors on every frame. * * @api public */ function render () { // If this is called synchronously we need to // cancel any pending future updates clearFrame() // If the rendering from the previous frame is still going, // we'll just wait until the next frame. Ideally renders should // not take over 16ms to stay within a single frame, but this should // catch it if it does. if (isRendering) { frameId = raf(render) return } else { isRendering = true } // 1. If there isn't a native element rendered for the current mounted element // then we need to create it from scratch. // 2. If a new element has been mounted, we should diff them. // 3. We should update check all child components for changes. if (!currentNativeElement) { currentElement = app.element currentNativeElement = toNative(rootId, '0', currentElement) container.appendChild(currentNativeElement) } else if (currentElement !== app.element) { currentNativeElement = patch(rootId, currentElement, app.element, currentNativeElement) currentElement = app.element updateChildren(rootId) } else { updateChildren(rootId) } // Allow rendering again. isRendering = false } /** * Clear the current scheduled frame */ function clearFrame () { if (!frameId) return raf.cancel(frameId) frameId = 0 } /** * Update a component. * * The entity is just the data object for a component instance. * * @param {String} id Component instance id. */ function updateEntity (entityId) { var entity = entities[entityId] setSources(entity) if (!shouldUpdate(entity)) return updateChildren(entityId) var currentTree = entity.virtualElement var nextProps = entity.pendingProps var nextState = entity.pendingState var previousState = entity.context.state var previousProps = entity.context.props // hook before rendering. could modify state just before the render occurs. trigger('beforeUpdate', entity, [entity.context, nextProps, nextState]) trigger('beforeRender', entity, [entity.context]) // commit state and props. commit(entity) // re-render. var nextTree = renderEntity(entity) // if the tree is the same we can just skip this component // but we should still check the children to see if they're dirty. // This allows us to memoize the render function of components. if (nextTree === currentTree) return updateChildren(entityId) // apply new virtual tree to native dom. entity.nativeElement = patch(entityId, currentTree, nextTree, entity.nativeElement) entity.virtualElement = nextTree updateChildren(entityId) // trigger render hook trigger('afterRender', entity, [entity.context, entity.nativeElement]) // trigger afterUpdate after all children have updated. trigger('afterUpdate', entity, [entity.context, previousProps, previousState, setState(entity)]) } /** * Update all the children of an entity. * * @param {String} id Component instance id. */ function updateChildren (entityId) { forEach(children[entityId], function (childId) { updateEntity(childId) }) } /** * Remove all of the child entities of an entity * * @param {Entity} entity */ function unmountChildren (entityId) { forEach(children[entityId], function (childId) { unmountEntity(childId) }) } /** * Remove the root element. If this is called synchronously we need to * cancel any pending future updates. */ function removeNativeElement () { clearFrame() removeElement(rootId, '0', currentNativeElement) currentNativeElement = null } /** * Create a native element from a virtual element. * * @param {String} entityId * @param {String} path * @param {Object} vnode * * @return {HTMLDocumentFragment} */ function toNative (entityId, path, vnode) { switch (vnode.type) { case 'text': return toNativeText(vnode) case 'element': return toNativeElement(entityId, path, vnode) case 'component': return toNativeComponent(entityId, path, vnode) } } /** * Create a native text element from a virtual element. * * @param {Object} vnode */ function toNativeText (vnode) { return document.createTextNode(vnode.data) } /** * Create a native element from a virtual element. */ function toNativeElement (entityId, path, vnode) { var attributes = vnode.attributes var children = vnode.children var tagName = vnode.tagName var el // create element either from pool or fresh. if (!options.pooling || !canPool(tagName)) { if (svg.isElement(tagName)) { el = document.createElementNS(svg.namespace, tagName) } else { el = document.createElement(tagName) } } else { var pool = getPool(tagName) el = cleanup(pool.pop()) if (el.parentNode) el.parentNode.removeChild(el) } // set attributes. forEach(attributes, function (value, name) { setAttribute(entityId, path, el, name, value) }) // store keys on the native element for fast event handling. el.__entity__ = entityId el.__path__ = path // add children. forEach(children, function (child, i) { var childEl = toNative(entityId, path + '.' + i, child) if (!childEl.parentNode) el.appendChild(childEl) }) return el } /** * Create a native element from a component. */ function toNativeComponent (entityId, path, vnode) { var child = new Entity(vnode.component, vnode.props) children[entityId][path] = child.id return mountEntity(child) } /** * Patch an element with the diff from two trees. */ function patch (entityId, prev, next, el) { return diffNode('0', entityId, prev, next, el) } /** * Create a diff between two tress of nodes. */ function diffNode (path, entityId, prev, next, el) { // Type changed. This could be from element->text, text->ComponentA, // ComponentA->ComponentB etc. But NOT div->span. These are the same type // (ElementNode) but different tag name. if (prev.type !== next.type) return replaceElement(entityId, path, el, next) switch (next.type) { case 'text': return diffText(prev, next, el) case 'element': return diffElement(path, entityId, prev, next, el) case 'component': return diffComponent(path, entityId, prev, next, el) } } /** * Diff two text nodes and update the element. */ function diffText (previous, current, el) { if (current.data !== previous.data) el.data = current.data return el } /** * Diff the children of an ElementNode. */ function diffChildren (path, entityId, prev, next, el) { var positions = [] var hasKeys = false var childNodes = Array.prototype.slice.apply(el.childNodes) var leftKeys = reduce(prev.children, keyMapReducer, {}) var rightKeys = reduce(next.children, keyMapReducer, {}) var currentChildren = assign({}, children[entityId]) function keyMapReducer (acc, child) { if (child.key != null) { acc[child.key] = child hasKeys = true } return acc } // Diff all of the nodes that have keys. This lets us re-used elements // instead of overriding them and lets us move them around. if (hasKeys) { // Removals forEach(leftKeys, function (leftNode, key) { if (rightKeys[key] == null) { var leftPath = path + '.' + leftNode.index removeElement( entityId, leftPath, childNodes[leftNode.index] ) } }) // Update nodes forEach(rightKeys, function (rightNode, key) { var leftNode = leftKeys[key] // We only want updates for now if (leftNode == null) return var leftPath = path + '.' + leftNode.index // Updated positions[rightNode.index] = diffNode( leftPath, entityId, leftNode, rightNode, childNodes[leftNode.index] ) }) // Update the positions of all child components and event handlers forEach(rightKeys, function (rightNode, key) { var leftNode = leftKeys[key] // We just want elements that have moved around if (leftNode == null || leftNode.index === rightNode.index) return var rightPath = path + '.' + rightNode.index var leftPath = path + '.' + leftNode.index // Update all the child component path positions to match // the latest positions if they've changed. This is a bit hacky. forEach(currentChildren, function (childId, childPath) { if (leftPath === childPath) { delete children[entityId][childPath] children[entityId][rightPath] = childId } }) }) // Now add all of the new nodes last in case their path // would have conflicted with one of the previous paths. forEach(rightKeys, function (rightNode, key) { var rightPath = path + '.' + rightNode.index if (leftKeys[key] == null) { positions[rightNode.index] = toNative( entityId, rightPath, rightNode ) } }) } else { var maxLength = Math.max(prev.children.length, next.children.length) // Now diff all of the nodes that don't have keys for (var i = 0; i < maxLength; i++) { var leftNode = prev.children[i] var rightNode = next.children[i] // Removals if (rightNode == null) { removeElement( entityId, path + '.' + leftNode.index, childNodes[leftNode.index] ) } // New Node if (leftNode == null) { positions[rightNode.index] = toNative( entityId, path + '.' + rightNode.index, rightNode ) } // Updated if (leftNode && rightNode) { positions[leftNode.index] = diffNode( path + '.' + leftNode.index, entityId, leftNode, rightNode, childNodes[leftNode.index] ) } } } // Reposition all the elements forEach(positions, function (childEl, newPosition) { var target = el.childNodes[newPosition] if (childEl !== target) { if (target) { el.insertBefore(childEl, target) } else { el.appendChild(childEl) } } }) } /** * Diff the attributes and add/remove them. */ function diffAttributes (prev, next, el, entityId, path) { var nextAttrs = next.attributes var prevAttrs = prev.attributes // add new attrs forEach(nextAttrs, function (value, name) { if (events[name] || !(name in prevAttrs) || prevAttrs[name] !== value) { setAttribute(entityId, path, el, name, value) } }) // remove old attrs forEach(prevAttrs, function (value, name) { if (!(name in nextAttrs)) { removeAttribute(entityId, path, el, name) } }) } /** * Update a component with the props from the next node. If * the component type has changed, we'll just remove the old one * and replace it with the new component. */ function diffComponent (path, entityId, prev, next, el) { if (next.component !== prev.component) { return replaceElement(entityId, path, el, next) } else { var targetId = children[entityId][path] // This is a hack for now if (targetId) { updateEntityProps(targetId, next.props) } return el } } /** * Diff two element nodes. */ function diffElement (path, entityId, prev, next, el) { if (next.tagName !== prev.tagName) return replaceElement(entityId, path, el, next) diffAttributes(prev, next, el, entityId, path) diffChildren(path, entityId, prev, next, el) return el } /** * Removes an element from the DOM and unmounts and components * that are within that branch * * side effects: * - removes element from the DOM * - removes internal references * * @param {String} entityId * @param {String} path * @param {HTMLElement} el */ function removeElement (entityId, path, el) { var childrenByPath = children[entityId] var childId = childrenByPath[path] var entityHandlers = handlers[entityId] || {} var removals = [] // If the path points to a component we should use that // components element instead, because it might have moved it. if (childId) { var child = entities[childId] el = child.nativeElement unmountEntity(childId) removals.push(path) } else { // Just remove the text node if (!isElement(el)) return el.parentNode.removeChild(el) // Then we need to find any components within this // branch and unmount them. forEach(childrenByPath, function (childId, childPath) { if (childPath === path || isWithinPath(path, childPath)) { unmountEntity(childId) removals.push(childPath) } }) // Remove all events at this path or below it forEach(entityHandlers, function (fn, handlerPath) { if (handlerPath === path || isWithinPath(path, handlerPath)) { removeEvent(entityId, handlerPath) } }) } // Remove the paths from the object without touching the // old object. This keeps the object using fast properties. forEach(removals, function (path) { delete children[entityId][path] }) // Remove it from the DOM el.parentNode.removeChild(el) // Return all of the elements in this node tree to the pool // so that the elements can be re-used. if (options.pooling) { walk(el, function (node) { if (!isElement(node) || !canPool(node.tagName)) return getPool(node.tagName.toLowerCase()).push(node) }) } } /** * Replace an element in the DOM. Removing all components * within that element and re-rendering the new virtual node. * * @param {Entity} entity * @param {String} path * @param {HTMLElement} el * @param {Object} vnode * * @return {void} */ function replaceElement (entityId, path, el, vnode) { var parent = el.parentNode var index = Array.prototype.indexOf.call(parent.childNodes, el) // remove the previous element and all nested components. This // needs to happen before we create the new element so we don't // get clashes on the component paths. removeElement(entityId, path, el) // then add the new element in there var newEl = toNative(entityId, path, vnode) var target = parent.childNodes[index] if (target) { parent.insertBefore(newEl, target) } else { parent.appendChild(newEl) } // update all `entity.nativeElement` references. forEach(entities, function (entity) { if (entity.nativeElement === el) { entity.nativeElement = newEl } }) return newEl } /** * Set the attribute of an element, performing additional transformations * dependning on the attribute name * * @param {HTMLElement} el * @param {String} name * @param {String} value */ function setAttribute (entityId, path, el, name, value) { if (events[name]) { addEvent(entityId, path, events[name], value) return } switch (name) { case 'value': el.value = value break case 'innerHTML': el.innerHTML = value break case svg.isAttribute(name): el.setAttributeNS(svg.namespace, name, value) break default: el.setAttribute(name, value) break } } /** * Remove an attribute, performing additional transformations * dependning on the attribute name * * @param {HTMLElement} el * @param {String} name */ function removeAttribute (entityId, path, el, name) { if (events[name]) { removeEvent(entityId, path, events[name]) return } el.removeAttribute(name) } /** * Checks to see if one tree path is within * another tree path. Example: * * 0.1 vs 0.1.1 = true * 0.2 vs 0.3.5 = false * * @param {String} target * @param {String} path * * @return {Boolean} */ function isWithinPath (target, path) { return path.indexOf(target + '.') === 0 } /** * Is the DOM node an element node * * @param {HTMLElement} el * * @return {Boolean} */ function isElement (el) { return !!el.tagName } /** * Get the pool for a tagName, creating it if it * doesn't exist. * * @param {String} tagName * * @return {Pool} */ function getPool (tagName) { var pool = pools[tagName] if (!pool) { var poolOpts = svg.isElement(tagName) ? { namespace: svg.namespace, tagName: tagName } : { tagName: tagName } pool = pools[tagName] = new Pool(poolOpts) } return pool } /** * Clean up previously used native element for reuse. * * @param {HTMLElement} el */ function cleanup (el) { removeAllChildren(el) removeAllAttributes(el) return el } /** * Remove all the attributes from a node * * @param {HTMLElement} el */ function removeAllAttributes (el) { for (var i = el.attributes.length - 1; i >= 0; i--) { var name = el.attributes[i].name el.removeAttribute(name) } } /** * Remove all the child nodes from an element * * @param {HTMLElement} el */ function removeAllChildren (el) { while (el.firstChild) el.removeChild(el.firstChild) } /** * Trigger a hook on a component. * * @param {String} name Name of hook. * @param {Entity} entity The component instance. * @param {Array} args To pass along to hook. */ function trigger (name, entity, args) { if (typeof entity.component[name] !== 'function') return entity.component[name].apply(null, args) } /** * Update an entity to match the latest rendered vode. We always * replace the props on the component when composing them. This * will trigger a re-render on all children below this point. * * @param {Entity} entity * @param {String} path * @param {Object} vnode * * @return {void} */ function updateEntityProps (entityId, nextProps) { var entity = entities[entityId] entity.pendingProps = nextProps entity.dirty = true invalidate() } /** * Update component instance state. */ function updateEntityState (entity, nextState) { entity.pendingState = assign(entity.pendingState, nextState) entity.dirty = true invalidate() } /** * Commit props and state changes to an entity. */ function commit (entity) { entity.context = { state: entity.pendingState, props: entity.pendingProps, id: entity.id } entity.pendingState = assign({}, entity.context.state) entity.pendingProps = assign({}, entity.context.props) validateProps(entity.context.props, entity.propTypes) entity.dirty = false } /** * Try to avoid creating new virtual dom if possible. * * Later we may expose this so you can override, but not there yet. */ function shouldUpdate (entity) { if (!entity.dirty) return false if (!entity.component.shouldUpdate) return true var nextProps = entity.pendingProps var nextState = entity.pendingState var bool = entity.component.shouldUpdate(entity.context, nextProps, nextState) return bool } /** * Register an entity. * * This is mostly to pre-preprocess component properties and values chains. * * The end result is for every component that gets mounted, * you create a set of IO nodes in the network from the `value` definitions. * * @param {Component} component */ function register (entity) { var component = entity.component // all entities for this component type. var entities = component.entities = component.entities || {} // add entity to component list entities[entity.id] = entity // get 'class-level' sources. var sources = component.sources if (sources) return var map = component.sourceToPropertyName = {} component.sources = sources = [] var propTypes = component.propTypes for (var name in propTypes) { var data = propTypes[name] if (!data) continue if (!data.source) continue sources.push(data.source) map[data.source] = name } // send value updates to all component instances. sources.forEach(function (source) { connections[source] = update function update (data) { var prop = map[source] for (var entityId in entities) { var entity = entities[entityId] var changes = {} changes[prop] = data updateEntityProps(entityId, assign(entity.pendingProps, changes)) } } }) } /** * Set the initial source value on the entity * * @param {Entity} entity */ function setSources (entity) { var component = entity.component var map = component.sourceToPropertyName var sources = component.sources sources.forEach(function (source) { var name = map[source] if (entity.pendingProps[name] != null) return entity.pendingProps[name] = app.sources[source] // get latest value plugged into global store }) } /** * Add all of the DOM event listeners */ function addNativeEventListeners () { forEach(events, function (eventType) { document.body.addEventListener(eventType, handleEvent, true) }) } /** * Add all of the DOM event listeners */ function removeNativeEventListeners () { forEach(events, function (eventType) { document.body.removeEventListener(eventType, handleEvent, true) }) } /** * Handle an event that has occured within the container * * @param {Event} event */ function handleEvent (event) { var target = event.target var entityId = target.__entity__ var eventType = event.type // Walk up the DOM tree and see if there is a handler // for this event type higher up. while (target && target.__entity__ === entityId) { var fn = keypath.get(handlers, [entityId, target.__path__, eventType]) if (fn) { event.delegateTarget = target fn(event) break } target = target.parentNode } } /** * Bind events for an element, and all it's rendered child elements. * * @param {String} path * @param {String} event * @param {Function} fn */ function addEvent (entityId, path, eventType, fn) { keypath.set(handlers, [entityId, path, eventType], throttle(function (e) { var entity = entities[entityId] if (entity) { fn.call(null, e, entity.context, setState(entity)) } else { fn.call(null, e) } })) } /** * Unbind events for a entityId * * @param {String} entityId */ function removeEvent (entityId, path, eventType) { var args = [entityId] if (path) args.push(path) if (eventType) args.push(eventType) keypath.del(handlers, args) } /** * Unbind all events from an entity * * @param {Entity} entity */ function removeAllEvents (entityId) { keypath.del(handlers, [entityId]) } /** * Validate the current properties. These simple validations * make it easier to ensure the correct props are passed in. * * Available rules include: * * type: string | array | object | boolean | number | date | function * expects: [] An array of values this prop could equal * optional: Boolean */ function validateProps (props, rules) { if (!options.validateProps) return // TODO: Only validate in dev mode forEach(rules, function (options, name) { if (name === 'children') return var value = props[name] var optional = (options.optional === true) if (optional && value == null) { return } if (!optional && value == null) { throw new Error('Missing prop named: ' + name) } if (options.type && type(value) !== options.type) { throw new Error('Invalid type for prop named: ' + name) } if (options.expects && options.expects.indexOf(value) < 0) { throw new Error('Invalid value for prop named: ' + name + '. Must be one of ' + options.expects.toString()) } }) // Now check for props that haven't been defined forEach(props, function (value, key) { if (key === 'children') return if (!rules[key]) throw new Error('Unexpected prop named: ' + key) }) } /** * Used for debugging to inspect the current state without * us needing to explicitly manage storing/updating references. * * @return {Object} */ function inspect () { return { entities: entities, pools: pools, handlers: handlers, connections: connections, currentElement: currentElement, options: options, app: app, container: container, children: children } } /** * Return an object that lets us completely remove the automatic * DOM rendering and export debugging tools. */ return { remove: teardown, inspect: inspect } }