UNPKG

gethue

Version:

Hue is an Open source SQL Query Editor for Databases/Warehouses

524 lines (473 loc) 18 kB
// 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;