From eb0cd43ad96b6b157fbbaeaccbbfc0b727fb2ba8 Mon Sep 17 00:00:00 2001 From: Evan Shapiro Date: Mon, 1 Jul 2013 15:05:41 -0700 Subject: [PATCH] Bug865750: visibility_monitor rewrite using getElementsByTagName --- .../test/unit/tag_visibility_monitor_test.js | 387 +++++++++++++ shared/js/tag_visibility_monitor.js | 532 ++++++++++++++++++ 2 files changed, 919 insertions(+) create mode 100644 apps/gallery/test/unit/tag_visibility_monitor_test.js create mode 100644 shared/js/tag_visibility_monitor.js diff --git a/apps/gallery/test/unit/tag_visibility_monitor_test.js b/apps/gallery/test/unit/tag_visibility_monitor_test.js new file mode 100644 index 000000000000..993b7f0a4346 --- /dev/null +++ b/apps/gallery/test/unit/tag_visibility_monitor_test.js @@ -0,0 +1,387 @@ +/*==================================== + MonitorTagVisibility Tests + + - implements and runs a DSL, monitoring changes that took place between + instructions + + - DSL + - instructions are either a string, or an array of strings + - instructions are posted to the event loop and run consecutively + - if instruction is an array, subinstructions are run consecutively, + not posted to the event loop consecutively + - instructions access elements with identifiers composed of numbers and + commas + - %d will be used to represent a number + - %i and %j will be used to represent an identifier + - instructions: + scroll %d + sets container's scrolltop to %d + rm %i + removes element %i + addafter %i %j + add %i after %j (so %j's next sibling is %i) + addbefore %i %j + add %i before %j (so %j's prev sibling is %i) + add %i + append %i to container + +====================================*/ + +'use strict'; + +require('/shared/js/tag_visibility_monitor.js'); + +suite('tag_visibility_monitor', function() { + + function run() { + + //=================== + // simpleContainerScroll test + //=================== + + //having a border makes it easier to test visually + var style = document.createElement('style'); + style.innerHTML = + 'div > div {' + + ' border: 1px solid black;' + + ' -moz-box-sizing: border-box;' + + ' padding-left: 10px;' + + ' background: white;' + + '}'; + document.head.appendChild(style); + + //=================== + // testing basic scrolling, rm, add + //=================== + + test('basics', function(done) { + + var childHeight = 10; + var containerHeight = 100; + var numChildren = 20; + + var instance = setup(numChildren, childHeight, containerHeight); + + instance.testInStages( + [ + 'scroll ' + childHeight * 3, + 'scroll ' + 0, + 'scroll ' + 10000, + 'scroll ' + 0, + 'rm 0', + 'rm 1', + 'rm 2', + 'rm 3', + 'rm 4', + 'rm 5', + 'addbefore 5 6', + 'addafter 20 19', + 'rm 14' + ], + function doneTesting(logger) { + var o = {}; + for (var i = 0; i < 10; i++) { o[i] = 'on'; } + assert.deepEqual(logger.data[0], o); + + assert.deepEqual(logger.data[1], { + 0: 'off', + 1: 'off', + 2: 'off', + 10: 'on', + 11: 'on', + 12: 'on' + }); + + assert.deepEqual(logger.data[2], { + 0: 'on', + 1: 'on', + 2: 'on', + 10: 'off', + 11: 'off', + 12: 'off' + }); + + var o = {}; + for (var i = 0; i < 10; i++) { o[i] = 'off'; } + for (var i = 10; i < 20; i++) { o[i] = 'on'; } + + assert.deepEqual(logger.data[3], o); + + var o = {}; + for (var i = 0; i < 10; i++) { o[i] = 'on'; } + for (var i = 10; i < 20; i++) { o[i] = 'off'; } + assert.deepEqual(logger.data[4], o); + + assert.deepEqual(logger.data[5], { 10: 'on' }); + assert.deepEqual(logger.data[6], { 11: 'on' }); + assert.deepEqual(logger.data[7], { 12: 'on' }); + assert.deepEqual(logger.data[8], { 13: 'on' }); + assert.deepEqual(logger.data[9], { 14: 'on' }); + assert.deepEqual(logger.data[10], { 15: 'on' }); + + assert.deepEqual(logger.data[11], { 5: 'on', 15: 'off' }); + + assert.deepEqual(logger.data[12], undefined); + + assert.deepEqual(logger.data[13], { 15: 'on' }); + + for (var i = 14; i < logger.data.length; i++) { + console.log(logger.data[i]); + } + instance.container.parentNode.removeChild(instance.container); + done(); + }); + }); + + test('multiop', function(done) { + + var childHeight = 10; + var containerHeight = 100; + var numChildren = 20; + + var instance = setup(numChildren, childHeight, containerHeight); + + instance.testInStages( + [ + 'scroll 50', + [ + 'rm 0', + 'rm 1', + 'rm 2', + 'rm 3', + 'rm 4', + 'rm 5', + 'rm 6', + 'rm 7', + 'rm 8', + 'rm 9', + 'rm 10', + 'rm 11', + 'rm 12', + 'rm 13', + 'rm 14' + ] + ], + function doneTesting(logger) { + + var o = {}; + for (var i = 0; i < 10; i++) { o[i] = 'on'; } + assert.deepEqual(logger.data[0], o); + + var o = {}; + for (var i = 0; i < 5; i++) { o[i] = 'off'; } + for (var i = 10; i < 15; i++) { o[i] = 'on'; } + assert.deepEqual(logger.data[1], o); + + var o = {}; + for (var i = 15; i < 20; i++) { o[i] = 'on'; } + assert.deepEqual(logger.data[2], o); + + for (var i = 3; i < logger.data.length; i++) { + console.log(logger.data[i]); + } + instance.container.parentNode.removeChild(instance.container); + done(); + }); + }); + + test('addRemoveContainer', function(done) { + + var childHeight = 10; + var containerHeight = 100; + + var instance = setup(0, childHeight, containerHeight); + + var testStages = []; + testStages.push('add 0', 'rm 0'); + for (var i = 0; i < 50; i++) { + testStages.push('add ' + i); + } + for (var i = 0; i < 50; i++) { + testStages.push('rm ' + i); + } + + instance.testInStages( + testStages, + function doneTesting(logger) { + + var c = 4; + for (var i = 1; i < 10; i++, c++) { + var o = {}; + o[i] = 'on'; + assert.deepEqual(logger.data[c], o); + } + for (var i = 0; i < 50 - 10; i++, c++) { + assert.deepEqual(logger.data[c], undefined); + } + for (var i = 10; i < 50; i++, c++) { + var o = {}; + o[i] = 'on'; + assert.deepEqual(logger.data[c], o); + } + + for (c; c < logger.data.length; c++) { + console.log(logger.data[c]); + } + + instance.container.parentNode.removeChild(instance.container); + done(); + }); + }); + } + + //=================== + // helpers + //=================== + + var nextId = 0; + + function setup(numChildren, childHeight, containerHeight) { + + var container = createTestContainer(numChildren, + childHeight, + containerHeight); + container.className = 'simpleContainer' + nextId; + + nextId += 1; + + var logger = new VisibilityLogger(); + var scrollDelta = 1; + var scrollMargin = 0; + var monitor = monitorTagVisibility( + container, + 'div', + scrollMargin, + scrollDelta, + function onscreen(child) { + child.style.background = 'blue'; + logger.log(child.index, 'on'); + }, + function offscreen(child) { + child.style.background = 'red'; + logger.log(child.index, 'off'); + }); + + function testInStages(tests, done) { + var i = 0; + + var TimeForScrollAndVisibilityMonitorEvents = 0; + + function runTest(test) { + var scrollMatch = test.match(/scroll (\d+)/); + var rmMatch = test.match(/rm ([0-9,]+)/); + var addAfterMatch = test.match(/addafter ([0-9,]+) ([0-9,]+)/); + var addBeforeMatch = test.match(/addbefore ([0-9,]+) ([0-9,]+)/); + var addMatch = test.match(/add ([0-9,]+)/); + if (scrollMatch) { + container.scrollTop = scrollMatch[1]; + } + else if (rmMatch) { + var index = test.substr(3); + var div = getChildByIndexProp(container, index); + div.parentNode.removeChild(div); + } + else if (addAfterMatch) { + var prev = getChildByIndexProp(container, '' + addAfterMatch[2]); + var child = document.createElement('div'); + child.style.height = childHeight + 'px'; + child.index = '' + addAfterMatch[1]; + prev.parentNode.insertBefore(child, prev.nextSibling); + } + else if (addBeforeMatch) { + var prev = getChildByIndexProp(container, '' + addBeforeMatch[2]); + var child = document.createElement('div'); + child.style.height = childHeight + 'px'; + child.index = '' + addBeforeMatch[1]; + prev.parentNode.insertBefore(child, prev); + } + else if (addMatch) { + var child = document.createElement('div'); + child.style.height = childHeight + 'px'; + child.index = '' + addMatch[1]; + container.appendChild(child); + } + } + + function runNext() { + if (i < tests.length) { + var test = tests[i]; + if (test.push) { + for (var j = 0; j < test.length; j++) + runTest(test[j]); + } + else { + runTest(test); + } + logger.nextStep(); + i++; + setTimeout(runNext, TimeForScrollAndVisibilityMonitorEvents); + } + else { + done(logger); + monitor.stop(); + } + } + + runNext(); + } + return { 'testInStages': testInStages, 'container': container }; + } + + function createTestContainer(numChildren, childHeight, containerHeight) { + var container = document.createElement('div'); + container.style.height = containerHeight + 'px'; + container.style.position = 'relative'; + container.style.overflowY = 'scroll'; + document.body.appendChild(container); + + for (var i = 0; i < numChildren; i++) { + var child = document.createElement('div'); + child.style.height = childHeight + 'px'; + child.index = '' + i; + container.appendChild(child); + } + + return container; + } + + function getChildByIndexProp(parent, index) { + var child = parent.firstChild; + while (child !== null) { + if (child.index === index) { + return child; + } + var childChild = getChildByIndexProp(child, index); + if (childChild !== null) { + return childChild; + } + child = child.nextElementSibling; + } + return null; + } + + function putOnEventQueue(fn) { + setTimeout(fn, 0); + } + + function VisibilityLogger() { + this.currentStep = 0; + this.data = []; + } + + VisibilityLogger.prototype = { + log: function(index, type) { + if (this.data[this.currentStep] === undefined) + this.data[this.currentStep] = {}; + this.data[this.currentStep][index] = type; + }, + nextStep: function() { + this.currentStep += 1; + } + }; + + run(); + +}); + + diff --git a/shared/js/tag_visibility_monitor.js b/shared/js/tag_visibility_monitor.js new file mode 100644 index 000000000000..5291b8451cfa --- /dev/null +++ b/shared/js/tag_visibility_monitor.js @@ -0,0 +1,532 @@ + +/*==================================== + monitorTagVisibility + generalized function to watch children of a container + + ------------------------------------ + + terminology + - child: any descendent of a node + + ------------------------------------ + + arguments + container + - the scrollable element we want to monitor the children of + tag + - the tag to monitor + scrollMargin + - how close an element needs to be to the container edge to be + considered onscreen + scrollDelta, + - how much the container needs to be scrolled before onscreen + and offscreen are recalculated + - higher value means callbacks fired less frequently, but there + are more of them when they are fired + onscreenCallback, + - called with the element that is now onscreen + offscreenCallback + - called with the element that is now offscreen + + ------------------------------------ + + returns an object with the following properties: + pauseMonitoringMutations + - a function, that when called, disables mutation monitoring + resumeMonitoringMutations + - a function, that when called, enables mutation monitoring + stop + - a funtion, that when called, stops and cleans up the module instance + - performs full visibility update if first argument is true + + ------------------------------------ + + assumptions + children flow top to bottom + children are not absolutely positioned + children do not move on their own + children don't change size or become hidden + the container is not statically positioned + the container element only changes size on a resize event + + ------------------------------------ + + performance + - if you can, when adding a node to the dom, add children to the node + before adding the node to the dom. + - if you're certain a change won't change visibility, surround it with + calls to pause and resume monitoring mutations. + + ------------------------------------ + + high level overview of implementation + more details can be found in comments closer to the implementation itself + + the central algorithm contains 2 stages + 1) visibility computation (recomputeFirstAndLastOnscreen) + - computing the first and last visible element + 2) notification of offscreen / onscreen (callCallbacks) + - notifying elements whose visibility has changed + + - the central algorithm is triggered whenever an event is received that + suggests the visibility may have changed + - onscroll + - onresize + - mutation of any child (mutation observer) + +====================================*/ + +function monitorTagVisibility( + container, + tag, + scrollMargin, + scrollDelta, + onscreenCallback, + offscreenCallback +) { + + // we need offsetTop to work properly, which will only happen if the container + // is not position: static + if (container.style.position === 'static') { + console.warn( + 'MonitorTagVisibility:' + + '"position: static" containers not supported,' + + ' maybe use "position: relative"?'); + } + + tag = tag.toUpperCase(); + + var state; + + var FORWARDS = 1; + var BACKWARDS = -1; + + var BEFORE = -1; + var ON = 0; + var AFTER = 1; + + //==================================== + // init + //==================================== + + function init() { + state = { + scrollTop: -1, + pendingCallCallbacksTimeoutId: null, + prev: {} + }; + + window.addEventListener('resize', resizeHandler); + container.addEventListener('scroll', scrollHandler); + state.observer = new MutationObserver(mutationHandler); + state.observer.observe(container, { childList: true, subtree: true }); + + // we use this to keep track of the relevent children of the container + // - because its a live node list, the indices may change, but the + // variable will always contain everything + state.children = container.getElementsByTagName(tag); + reset(); + + updateVisibility(true); + } + + function reset() { + state.firstChildIndex = null; + state.lastChildIndex = null; + state.firstChild = null; + state.lastChild = null; + state.prev.firstChildIndex = null; + state.prev.lastChildIndex = null; + } + + //==================================== + // event handlers + //==================================== + + function resizeHandler() { + updateVisibility(); + } + + function scrollHandler() { + var scrollTop = container.scrollTop; + if (Math.abs(scrollTop - state.prev.scrollTop) < scrollDelta) { + return; + } + state.prev.scrollTop = scrollTop; + + updateVisibility(); + } + + function updateVisibility(callImmediately) { + if (container.clientHeight === 0) { + return; + } + recomputeFirstAndLastOnscreen(); + + if (callImmediately || scrollDelta >= 1) { + callCallbacks(); + } + else { + // put it on the back of the event queue, we'll call it later + // otherwise, as scrollDelta = 0, there's going to be a lot of callbacks + deferCallingCallbacks(); + } + } + + //==================================== + // mutation + //==================================== + + function mutationHandler(mutations) { + // some of the removals may have messed with our first/last child on screen + fixRange(mutations); + + // check if anything we add needs to be notified as onscreen + for (var i = 0; i < mutations.length; i++) { + var mutation = mutations[i]; + if (mutation.addedNodes) { + for (var j = 0; j < mutation.addedNodes.length; j++) { + var child = mutation.addedNodes[j]; + if (child.nodeType === Node.ELEMENT_NODE && + child.tagName === tag) { + if (child === state.firstChild || + child === state.lastChild || + (after(child, state.firstChild) && + before(child, state.lastChild)) + ) { + safeOnscreenCallback(child); + } + } + } + } + } + } + + // ------------------------------- + // fixRange + // fixes state if it doesn't match the new dom state + // - as we keep track of indices, adding and deleting indices changes what + // element we meant to point to + function fixRange(mutations) { + var numNodesAdded = 0; + for (var i = 0; i < mutations.length; i++) { + var mutation = mutations[i]; + if (mutation.addedNodes) { + for (var j = 0; j < mutation.addedNodes.length; j++) { + var child = mutation.addedNodes[j]; + if (child.nodeType === Node.ELEMENT_NODE && + child.tagName === tag); + numNodesAdded += 1; + } + } + } + + // if a node was deleted, we'll have to use its sibling, but what if its + // sibling is deleted? Then we need the mutation event for that sibling + // so we can get its sibling. Using WeakMaps allow us to use the deleted + // node as a key to get the mutation for that deleted node + var removedNodes = new WeakMap(); + for (var i = 0; i < mutations.length; i++) { + var mutation = mutations[i]; + if (mutation.removedNodes) { + for (var j = 0; j < mutation.removedNodes.length; j++) { + var child = mutation.removedNodes[j]; + removedNodes.set(child, mutation); + } + } + } + + // ------------------------------- + // fixIndex + // takes a possibly invalid index, and returns the next valid index + // - if the node at our index (target) was deleted, we have to find + // the next valid element, and get the index for that element + // - to ensure that the elements are calculated correctly we have to + // search in a different direction for the last and first onscreen + // chidlren + function fixIndex(index, target, dir) { + var sibling; + if (dir == FORWARDS) + sibling = 'nextSibling'; + else + sibling = 'previousSibling'; + while (target !== null && + removedNodes.has(target) + ) { + target = removedNodes.get(target)[sibling]; + } + var limit = getLimit(dir); + if (dir == FORWARDS && index > limit) + index = limit; + while (state.children[index] !== target && index !== limit) + index += dir; + return index; + } + + // Why +/- numNodesAdded? + // if going backwards + nodes were added after target node, we add + // to the start index so we don't miss the desired node + // if going forwards + nodes were added before target node, we sub + // from the start so we don't miss the desired node + state.firstChildIndex = + fixIndex(state.firstChildIndex - numNodesAdded, + state.firstChild, FORWARDS); + state.lastChildIndex = + fixIndex(state.lastChildIndex + numNodesAdded, + state.lastChild, BACKWARDS); + state.prev.firstChildIndex = + fixIndex(state.prev.firstChildIndex - numNodesAdded, + state.prev.firstChild, FORWARDS); + state.prev.lastChildIndex = + fixIndex(state.prev.lastChildIndex + numNodesAdded, + state.prev.lastChild, BACKWARDS); + + recomputeFirstAndLastOnscreen(); + callCallbacks(); + } + + //==================================== + // visibility computation + //==================================== + + //--------------------------------------- + // recomputeFirstAndLastOnscreen + // find the elements that are the first/last child on screen + // - to make this faster, we use the last first/last child on screen as a + // guess for where to start looking + function recomputeFirstAndLastOnscreen() { + + if (state.children.length === 0) { + state.firstChildIndex = null; + state.lastChildIndex = null; + return; + } + + var firstChildIndexGuess = state.prev.firstChildIndex; + var lastChildIndexGuess = state.prev.lastChildIndex; + if (state.prev.firstChildIndex === null) { + firstChildIndexGuess = 0; + } + if (state.prev.lastChildIndex === null) { + lastChildIndexGuess = state.children.length - 1; + } + + state.firstChildIndex = + computeBorderVisibilityIndex(firstChildIndexGuess, BEFORE, BACKWARDS); + state.lastChildIndex = + computeBorderVisibilityIndex(lastChildIndexGuess, AFTER, FORWARDS); + + state.firstChild = state.children[state.firstChildIndex]; + state.lastChild = state.children[state.lastChildIndex]; + } + + function computeBorderVisibilityIndex(guess, visibilityPosition, dir) { + // move in direction past container + var limit = getLimit(dir); + while (relativeVisibilityPosition(container, state.children[guess]) !== + visibilityPosition) { + if (guess === limit) + break; + guess += dir; + } + // move back towards container + limit = getLimit(-dir); + while (relativeVisibilityPosition(container, state.children[guess]) === + visibilityPosition) { + if (guess === limit) + break; + guess += -dir; + } + return guess; + } + + function deferCallingCallbacks() { + if (state.pendingCallCallbacksTimeoutId !== null) { + // take it off before putting it back on, we want to ensure its on the + // back of the event queue + window.clearTimeout(state.pendingCallCallbacksTimeoutId); + } + state.pendingCallCallbacksTimeoutId = setTimeout(callCallbacks, 0); + } + + //--------------------------------------- + // callCallbacks + // - notifies elements between the new [first/last] onscreen and the old + // [first/last] onscreen as either onscreen or offscreen + // + // 3 possibilities + // 1) there is no old [first/last] onscreen + // - notify between new firstOnscreen and new lastOnscreen as + // onscreen + // 2) the new first->last onscreen and old first->last onscreen are + // disjoint + // - notify the old first->last onscreen as offscreen + // - notify the new first->last onscreen as onscreen + // 3) the new first->last onscreen and the old first->last onscreen + // overlap + // - consider the two ranges (new first->old first), (new last->old + // last) separately + // - for each of the two ranges, compare the relative positions of + // the old and new + function callCallbacks() { + + if (state.pendingCallCallbacksTimeoutId !== null) { + window.clearTimeout(state.pendingCallCallbacksTimeoutId); + state.pendingCallCallbacksTimeoutId = null; + } + + if (state.firstChildIndex === null || state.lastChildIndex === null) { + // nothing on screen, nothing to do + } + else if ( + state.prev.firstChildIndex === null || + state.prev.lastChildIndex === null + ) { + notifyOnscreenInRange(state.firstChildIndex, state.lastChildIndex); + } + else if ( + state.lastChildIndex < state.prev.firstChildIndex || + state.firstChildIndex > state.prev.lastChildIndex + ) { // disjoint + notifyOnscreenInRange(state.firstChildIndex, + state.lastChildIndex); + notifyOffscreenInRange(state.prev.firstChildIndex, + state.prev.lastChildIndex); + } + else { // overlapping + // onscreen at top + if (state.firstChildIndex < state.prev.firstChildIndex) + notifyOnscreenInRange(state.firstChildIndex, + state.prev.firstChildIndex - 1); + // onscreen at bottom + if (state.lastChildIndex > state.prev.lastChildIndex) + notifyOnscreenInRange(state.prev.lastChildIndex + 1, + state.lastChildIndex); + // offscreen at top + if (state.firstChildIndex > state.prev.firstChildIndex) + notifyOffscreenInRange(state.prev.firstChildIndex, + state.firstChildIndex - 1); + // offscreen at bottom + if (state.lastChildIndex < state.prev.lastChildIndex) + notifyOffscreenInRange(state.lastChildIndex + 1, + state.prev.lastChildIndex); + } + + state.prev.firstChildIndex = state.firstChildIndex; + state.prev.lastChildIndex = state.lastChildIndex; + state.prev.firstChild = state.firstChild; + state.prev.lastChild = state.lastChild; + } + + //==================================== + // dom helpers + //==================================== + + function relativeVisibilityPosition(container, child) { + + var scrollTop = container.scrollTop; + var screenTop = scrollTop - scrollMargin; + var screenBottom = scrollTop + container.clientHeight + scrollMargin; + + var childTop = child.offsetTop; + var childBottom = childTop + child.offsetHeight; + if (childBottom <= screenTop) + return BEFORE; + if (childTop >= screenBottom) + return AFTER; + return ON; + } + + function before(a, b) { + return !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + } + + function after(a, b) { + return !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_PRECEDING); + } + + //==================================== + // helpers + //==================================== + + function getLimit(dir) { + if (dir === 1) + return Math.max(0, state.children.length - 1); + else + return 0; + } + + function notifyOnscreenInRange(start, stop) { + notifyInRange(start, stop, safeOnscreenCallback); + } + + function notifyOffscreenInRange(start, stop) { + notifyInRange(start, stop, safeOffscreenCallback); + } + + function notifyInRange(start, stop, fn) { + for (var i = start; i <= stop; i++) { + fn(state.children[i]); + } + } + + function safeOnscreenCallback(child) { + try { + onscreenCallback(child); + } + catch (e) { + console.warn('monitorTagVisiblity: ' + + 'Exception in onscreenCallback:', + e, e.stack); + } + } + + function safeOffscreenCallback(child) { + try { + offscreenCallback(child); + } + catch (e) { + console.warn('monitorTagVisiblity: ' + + 'Exception in offscreenCallback:', + e, e.stack); + } + } + + //==================================== + // API + //==================================== + + function pauseMonitoringMutations() { + state.observer.disconnect(); + } + + function resumeMonitoringMutations(forceVisibilityUpdate) { + if (state.stopped) + return; + state.observer.observe(container, { childList: true, subtree: true }); + + if (forceVisibilityUpdate) { + reset(); + updateVisibility(true); + } + } + + function stopMonitoring() { + container.removeEventListener('scroll', scrollHandler); + window.removeEventListener('resize', onresize); + state.observer.disconnect(); + state.stopped = true; + } + + //==================================== + // initialization + return + //==================================== + + init(); + + return { + pauseMonitoringMutations: pauseMonitoringMutations, + resumeMonitoringMutations: resumeMonitoringMutations, + stop: stopMonitoring + }; +}