gethue
Version:
Hue is an Open source SQL Query Editor for Databases/Warehouses
524 lines (473 loc) • 18 kB
JavaScript
// Licensed to Cloudera, Inc. under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. Cloudera, Inc. licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import $ from 'jquery';
import * as ko from 'knockout';
import huePubSub from '../../utils/huePubSub';
/**
* This binding limits the rendered items based on what is visible within a scrollable container. It supports
* multiple any amount of nested children with foreachVisible bindings
*
* The minHeight parameter is the initial expected rendered height of each entry, once rendered the real
* height is used. It keeps a number of elements above and below the visible elements to make slow scrolling
* smooth.
*
* The height of the container element has to be less than or equal to the inner height of the window.
*
* Example:
*
* <div class="container" style="overflow-y: scroll; height: 100px">
* <ul data-bind="foreachVisible: { data: items, minHeight: 20, container: '.container' }">
* <li>...</li>
* </ul>
* </div>
*
* For tables the binding has to be attached to the tbody element:
*
* <div class="container" style="overflow-y: scroll; height: 100px">
* <table>
* <thead>...</thead>
* <tbody data-bind="foreachVisible: { data: items, minHeight: 20, container: '.container' }>
* <tr>...</tr>
* </tbody>
* </ul>
* </div>
*
* Currently the binding only supports one element inside the bound element otherwise the height
* calculations will be off. In other words this will make it go bonkers:
*
* <div class="container" style="overflow-y: scroll; height: 100px">
* <ul data-bind="foreachVisible: { data: items, minHeight: 20, container: '.container' }">
* <li>...</li>
* <li style="display: none;">...</li>
* </ul>
* </div>
*
*/
ko.bindingHandlers.foreachVisible = {
init: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
return ko.bindingHandlers.template.init(
element,
() => {
return {
foreach: [],
templateEngine: ko.nativeTemplateEngine.instance
};
},
allBindings,
viewModel,
bindingContext
);
},
update: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
const options = valueAccessor();
let $element = $(element);
let isTable = false;
if ($element.parent().is('table')) {
$element = $element.parent();
isTable = true;
}
const $container = $element.closest(options.container);
const id = Math.random();
// This is possibly a parent element that has the foreachVisible binding
const $parentFVElement = bindingContext.$parentForeachVisible || null;
const parentId = bindingContext.$parentForeachVisibleId || -1;
// This is the element from the parent foreachVisible rendered element that contains
// this one or container for root
let $parentFVOwnerElement = $container;
$element.data('parentForeachVisible', $parentFVElement);
const depth = bindingContext.$depth || 0;
// Locate the owning element if within another foreach visible binding
if ($parentFVElement) {
const myOffset = $element.offset().top;
$parentFVElement.children().each((idx, child) => {
const $child = $(child);
if (myOffset > $child.offset().top) {
$parentFVOwnerElement = $child;
} else {
return false;
}
});
}
if ($parentFVOwnerElement.data('disposalFunction')) {
$parentFVOwnerElement.data('disposalFunction')();
$parentFVOwnerElement.data('lastKnownHeights', null);
}
let entryMinHeight = options.minHeight;
const allEntries = ko.utils.unwrapObservable(options.data);
let visibleEntryCount = 0;
let incrementLimit = 0; // The diff required to re-render, set to visibleCount below
let elementIncrement = 0; // Elements to add on either side of the visible elements, set to 3x visibleCount
let endIndex = 0;
const updateVisibleEntryCount = function () {
// TODO: Drop the window innerHeight limitation.
// Sometimes after resizeWrapper() is called the reported innerHeight of the $container is the same as
// the wrapper causing the binding to render all the items. This limits the visibleEntryCount to the
// window height.
const newEntryCount = Math.ceil(
Math.min($(window).innerHeight(), $container.innerHeight()) / entryMinHeight
);
if (newEntryCount !== visibleEntryCount) {
const diff = newEntryCount - visibleEntryCount;
elementIncrement = options.elementIncrement || 25;
incrementLimit = options.incrementLimit || 5;
visibleEntryCount = newEntryCount;
endIndex += diff;
huePubSub.publish('foreach.visible.update', id);
}
};
// TODO: Move intervals to webworker
const updateCountInterval = setInterval(updateVisibleEntryCount, 300);
updateVisibleEntryCount();
// In case this element was rendered before use the last known indices
let startIndex = Math.max($parentFVOwnerElement.data('startIndex') || 0, 0);
endIndex = Math.min(
$parentFVOwnerElement.data('endIndex') || visibleEntryCount + elementIncrement,
allEntries.length - 1
);
const huePubSubs = [];
const scrollToIndex = function (idx, offset, instant, callback) {
const lastKnownHeights = $parentFVOwnerElement.data('lastKnownHeights');
if (!lastKnownHeights || lastKnownHeights.length <= idx) {
return;
}
let top = 0;
for (let i = 0; i < idx; i++) {
top += lastKnownHeights[i];
}
const bottom = top + lastKnownHeights[idx];
window.setTimeout(() => {
const newScrollTop = top + offset;
if (instant) {
if (newScrollTop >= $container.height() + $container.scrollTop()) {
$container.scrollTop(bottom - $container.height());
} else if (newScrollTop <= $container.scrollTop()) {
$container.scrollTop(newScrollTop);
}
} else {
$container.stop().animate({ scrollTop: newScrollTop }, '500', 'swing', () => {
if (callback) {
callback();
}
});
}
}, 0);
};
if (!options.skipScrollEvent) {
huePubSubs.push(
huePubSub.subscribe('assist.db.scrollTo', targetEntry => {
let foundIndex = -1;
$.each(allEntries, (idx, entry) => {
if (targetEntry === entry) {
foundIndex = idx;
return false;
}
});
if (foundIndex !== -1) {
const offset = depth > 0 ? $parentFVOwnerElement.position().top : 0;
scrollToIndex(foundIndex, offset, false, () => {
huePubSub.publish('assist.db.scrollToComplete', targetEntry);
});
}
})
);
}
if (ko.isObservable(viewModel.foreachVisible)) {
viewModel.foreachVisible({
scrollToIndex: function (index) {
const offset = depth > 0 ? $parentFVOwnerElement.position().top : 0;
scrollToIndex(index, offset, true);
}
});
}
const childBindingContext = bindingContext.createChildContext(
bindingContext.$rawData,
null,
context => {
ko.utils.extend(context, {
$parentForeachVisible: $element,
$parentForeachVisibleId: id,
$depth: depth + 1,
$indexOffset: function () {
return startIndex;
}
});
}
);
let $wrapper = $element.parent();
if (!$wrapper.hasClass('foreach-wrapper')) {
$wrapper = $('<div>')
.css({
position: 'relative',
width: '100%'
})
.addClass('foreach-wrapper')
.insertBefore($element);
if (options.usePreloadBackground) {
$wrapper.addClass('assist-preloader-wrapper');
$element.addClass('assist-preloader');
}
$element
.css({
position: 'absolute',
top: 0,
width: '100%'
})
.appendTo($wrapper);
}
// This is kept up to date with the currently rendered elements, it's used to keep track of any
// height changes of the elements.
let renderedElements = [];
if (
!$parentFVOwnerElement.data('lastKnownHeights') ||
$parentFVOwnerElement.data('lastKnownHeights').length !== allEntries.length
) {
const lastKnownHeights = [];
$.each(allEntries, () => {
lastKnownHeights.push(entryMinHeight);
});
$parentFVOwnerElement.data('lastKnownHeights', lastKnownHeights);
}
const resizeWrapper = function () {
let totalHeight = 0;
const lastKnownHeights = $parentFVOwnerElement.data('lastKnownHeights');
if (!lastKnownHeights) {
return;
}
$.each(lastKnownHeights, (idx, height) => {
totalHeight += height;
});
$wrapper.height(totalHeight + 'px');
};
resizeWrapper();
const updateLastKnownHeights = function () {
if ($container.data('busyRendering')) {
return;
}
const lastKnownHeights = $parentFVOwnerElement.data('lastKnownHeights');
// Happens when closing first level and the third level is open, disposal tells the parents
// to update their heights...
if (!lastKnownHeights) {
return;
}
let diff = false;
let updateEntryCount = false;
$.each(renderedElements, (idx, renderedElement) => {
// TODO: Figure out why it goes over index at the end scroll position
if (startIndex + idx < lastKnownHeights.length) {
const renderedHeight = $(renderedElement).outerHeight(true);
if (renderedHeight > 5 && lastKnownHeights[startIndex + idx] !== renderedHeight) {
if (renderedHeight < entryMinHeight) {
entryMinHeight = renderedHeight;
updateEntryCount = true;
}
lastKnownHeights[startIndex + idx] = renderedHeight;
diff = true;
}
}
});
if (updateEntryCount) {
updateVisibleEntryCount();
}
// Only resize if a difference in height was noticed.
if (diff) {
$parentFVOwnerElement.data('lastKnownHeights', lastKnownHeights);
resizeWrapper();
}
};
let updateHeightsInterval = window.setInterval(updateLastKnownHeights, 600);
huePubSubs.push(
huePubSub.subscribe('foreach.visible.update.heights', targetId => {
if (targetId === id) {
clearInterval(updateHeightsInterval);
updateLastKnownHeights();
huePubSub.publish('foreach.visible.update.heights', parentId);
updateHeightsInterval = window.setInterval(updateLastKnownHeights, 600);
}
})
);
updateLastKnownHeights();
const positionList = function () {
const lastKnownHeights = $parentFVOwnerElement.data('lastKnownHeights');
if (!lastKnownHeights) {
return;
}
let top = 0;
for (let i = 0; i < startIndex; i++) {
top += lastKnownHeights[i];
}
$element.css('top', top + 'px');
};
const afterRender = function () {
renderedElements = isTable ? $element.children('tbody').children() : $element.children();
$container.data('busyRendering', false);
if (typeof options.fetchMore !== 'undefined' && endIndex === allEntries.length - 1) {
options.fetchMore();
}
huePubSub.publish('foreach.visible.update.heights', id);
};
const render = function () {
if ((endIndex === 0 && allEntries.length > 1) || endIndex < 0) {
ko.bindingHandlers.template.update(
element,
() => {
return {
foreach: [],
templateEngine: ko.nativeTemplateEngine.instance,
afterRender: function () {
// This is called once for each added element (not when elements are removed)
clearTimeout(throttle);
throttle = setTimeout(afterRender, 0);
}
};
},
allBindings,
viewModel,
childBindingContext
);
return;
}
$container.data('busyRendering', true);
// Save the start and end index for when the list is removed and is shown again.
$parentFVOwnerElement.data('startIndex', startIndex);
$parentFVOwnerElement.data('endIndex', endIndex);
positionList();
// This is to ensure that our afterRender is called (the afterRender of KO below isn't called
// when only elements are removed)
let throttle = setTimeout(afterRender, 0);
ko.bindingHandlers.template.update(
element,
() => {
return {
foreach: allEntries.slice(startIndex, endIndex + 1),
templateEngine: ko.nativeTemplateEngine.instance,
afterRender: function () {
// This is called once for each added element (not when elements are removed)
clearTimeout(throttle);
throttle = setTimeout(afterRender, 0);
}
};
},
allBindings,
viewModel,
childBindingContext
);
};
const setStartAndEndFromScrollTop = function () {
const lastKnownHeights = $parentFVOwnerElement.data('lastKnownHeights');
if (!lastKnownHeights) {
return;
}
let parentSpace = 0;
let $lastParent = $parentFVElement;
let $lastRef = $element;
while ($lastParent) {
// Include the header, parent() is .foreach-wrapper, parent().parent() is the container (ul or div)
const lastRefOffset = $lastRef.parent().parent().offset().top;
let lastAddedSpace = 0;
$lastParent.children().each((idx, child) => {
const $child = $(child);
if (lastRefOffset > $child.offset().top) {
lastAddedSpace = $child.outerHeight(true);
parentSpace += lastAddedSpace;
} else {
// Remove the height of the child which is the parent of this
parentSpace -= lastAddedSpace;
return false;
}
});
parentSpace += $lastParent.position().top;
$lastRef = $lastParent;
$lastParent = $lastParent.data('parentForeachVisible');
}
let position = Math.min($container.scrollTop() - parentSpace, $wrapper.height());
for (let i = 0; i < lastKnownHeights.length; i++) {
position -= lastKnownHeights[i];
if (position <= 0) {
startIndex = Math.max(i - elementIncrement, 0);
endIndex = Math.min(allEntries.length - 1, i + elementIncrement + visibleEntryCount);
break;
}
}
};
let renderThrottle = -1;
let preloadGhostThrottle = -1;
let lastScrollTop = -1;
const onScroll = function () {
if (
startIndex > incrementLimit &&
Math.abs(lastScrollTop - $container.scrollTop()) < incrementLimit * options.minHeight
) {
return;
}
lastScrollTop = $container.scrollTop();
setStartAndEndFromScrollTop();
// adds a preload ghost image just on scroll and removes it 200ms after the scroll stops
if (options.usePreloadBackground) {
$wrapper.addClass('assist-preloader-ghost');
clearTimeout(preloadGhostThrottle);
preloadGhostThrottle = setTimeout(() => {
$wrapper.removeClass('assist-preloader-ghost');
}, 200);
}
clearTimeout(renderThrottle);
const startDiff = Math.abs($parentFVOwnerElement.data('startIndex') - startIndex);
const endDiff = Math.abs($parentFVOwnerElement.data('endIndex') - endIndex);
if (
startDiff > incrementLimit ||
endDiff > incrementLimit ||
(startDiff !== 0 && startIndex === 0) ||
(endDiff !== 0 && endIndex === allEntries.length - 1)
) {
renderThrottle = setTimeout(render, 0);
}
};
huePubSubs.push(
huePubSub.subscribe('foreach.visible.update', callerId => {
if (callerId === id && endIndex > 0) {
setStartAndEndFromScrollTop();
clearTimeout(renderThrottle);
renderThrottle = setTimeout(render, 0);
}
})
);
$container.bind('scroll', onScroll);
$parentFVOwnerElement.data('disposalFunction', () => {
setTimeout(() => {
huePubSub.publish('foreach.visible.update.heights', parentId);
}, 0);
huePubSubs.forEach(pubSub => {
pubSub.remove();
});
$container.unbind('scroll', onScroll);
clearInterval(updateCountInterval);
clearInterval(updateHeightsInterval);
$parentFVOwnerElement.data('disposalFunction', null);
});
if (typeof options.pubSubDispose !== 'undefined') {
huePubSubs.push(
huePubSub.subscribe(options.pubSubDispose, $parentFVOwnerElement.data('disposalFunction'))
);
}
ko.utils.domNodeDisposal.addDisposeCallback(
$wrapper[0],
$parentFVOwnerElement.data('disposalFunction')
);
setStartAndEndFromScrollTop();
render();
}
};
ko.expressionRewriting.bindingRewriteValidators['foreachVisible'] = false;
ko.virtualElements.allowedBindings['foreachVisible'] = true;