/** @module UI controls */ requirejs('controls/control'); /** Scroller - a generic scrolling control, that's mainly supposed to host 1 or more listviews, that are virtualized (i.e. can handle even millions of items). @class Scroller @constructor @extends Control */ inheritClass('Scroller', Control, { initialize: function () { Scroller.$super.initialize.apply(this, arguments); this.enableDragNDrop(); this.contextMenu = []; // LS: for the context menu forwarding below to work (#15884) this.reloadSettings(); // set smooth/animated scroll this.smoothScrollTimeLimit = 1000 * animTools.animationTime * 1.02 /* a reserve in order to finish scrolling after e.g. pop-up animation starts */ ; // ms this.scrolled = false; this.lassoParentElementScroller = true; this.container.classList.add('scrollable'); this.registerEventHandler('wheel'); this.registerEventHandler('keydown'); this.registerEventHandler('mousedown'); this.registerEventHandler('mouseup'); this.localListen(app, 'settingschange', function () { this.reloadSettings(); }.bind(this)); this.localListen(document.body, 'keydown', (e) => { var key = friendlyKeyName(e); if (key == 'Space') { // ignore scrolling on space (#15917) this._ignoreScroll = true; this.requestTimeout(() => { this._ignoreScroll = false; }, 500); var focusedElement = document.activeElement; if (focusedElement && ((focusedElement.nodeName == 'INPUT') || (focusedElement.hasAttribute('contenteditable')))) { // editing - we cannot prevent default for spacebar (#16185) } else { // not editing - prevent default to ignore scrolling on space (#15917) e.preventDefault(); } } }); this.localListen(this.container, 'scroll', function (evt) { if (evt.target == this.container && !this._ignoreScroll) { this.scrolled = true; this.notifyChildren(true /* to make deferred draw, otherwise freeze can happen - #16794 */ ); } }.bind(this)); }, reloadSettings: function () { var sett = settings.get('Appearance'); this.smoothScroll = sett.Appearance.SmoothScroll; }, handle_mouseup: function (e) { if (this._nearestLV && this._nearestLV.controlClass && this._nearestLV.controlClass.lassoSelectionEnabled) { this._nearestLV.controlClass._cleanUpLasso(); } this._nearestLV = null; }, handle_mousedown: function (e) { this._nearestLV = this.getFirstUp(e.offsetY, this.container.offsetHeight, true /* allow empty LVs */ ); if (this._nearestLV && this._nearestLV.controlClass && this._nearestLV.controlClass.lassoSelectionEnabled) { this._nearestLV.controlClass.handleLassoStart(null, e); } }, handle_wheel: function (e) { if (e.deltaY !== 0 && !e.shiftKey /* #16406 - item 4 */ ) { if (e.stopPropagation) e.stopPropagation(); e.preventDefault(); var delta = e.deltaY; if (e.deltaMode === DOM_DELTA_LINE) { delta *= fontLineSizePx(); } else if (e.deltaMode === DOM_DELTA_PAGE) { delta *= this.container.clientHeight; } this.setSmoothScrollOffset(this.getScrollOffset() + delta); } }, cleanUp: function () { Scroller.$super.cleanUp.apply(this, arguments); }, getScrollOffset: function () { if (!this.scrolled) return 0; if (this.smoothScrollTarget >= 0) return this.smoothScrollTarget; else return this.container.scrollTop; }, getSmoothScrollOffset: function () { var scrollTop = this.getScrollOffset(); if (this.smoothScrollOrigin >= 0) { var newTime = window.performance.now(); if (newTime - this.smoothScrollTime >= this.smoothScrollTimeLimit) { var res = this.smoothScrollTarget; this.setScrollOffset(res); this.smoothScrollOrigin = -1; this.smoothScrollTarget = -1; return res; } else return this.smoothScrollOrigin + Math.round((scrollTop - this.smoothScrollOrigin) * Math.pow((newTime - this.smoothScrollTime) / this.smoothScrollTimeLimit, 0.6)); } else return scrollTop; }, notifyChildren: function (deferred) { var ctrls = qes(this.container, '[data-control-class]'); for (var i = 0; i < ctrls.length; i++) { var el = ctrls[i]; if (el.controlClass && el.controlClass.parentScrollFrame) el.controlClass.parentScrollFrame(deferred); } }, handleSmoothScroll: function () { var scrollFn = function () { this.container.scrollTop = this.getSmoothScrollOffset(); if (this.smoothScrollOrigin >= 0) this.requestFrame(scrollFn.bind(this), 'scrollFn'); else this.frameQueued = false; this.notifyChildren(); // to prevent incorrect listview header movement during scrolling } if (!this.frameQueued) { this.frameQueued = true; this.requestFrame(scrollFn.bind(this), 'scrollFn'); } }, setSmoothScrollOffset: function (newValue, canScrollBeyond /*To allow scrolling lower than is the current viewport height*/ ) { if (isNaN(newValue)) return; newvalue = Math.max(canScrollBeyond ? newValue : Math.min(newValue, this.container.scrollHeight - this.container.clientHeight), 0); if (this.smoothScroll) { this.scrolled = true; this.smoothScrollOrigin = this.getSmoothScrollOffset(); this.smoothScrollTime = window.performance.now(); this.smoothScrollTarget = newvalue; this.handleSmoothScroll(); } else { this.setScrollOffset(newValue); } }, setScrollOffset: function (newValue) { if (isNaN(newValue)) return; this.container.scrollTop = newValue; this.scrolled = true; }, focusFirstDown: function (fromPoint, maxLen, allowEmpty) { var ctrls = qes(this.container, '[data-control-class]'); var bestctrl = undefined; var bestmatch = Number.MAX_SAFE_INTEGER; forEach(ctrls, function (ctrl) { if (!ctrl.controlClass || !ctrl.controlClass.visible || !(ctrl.tabIndex >= 0) || (ctrl.controlClass.itemCount == 0 && !allowEmpty) /*empty listview*/ ) return; if (fromPoint <= ctrl.offsetTop && ctrl.offsetTop < bestmatch) { bestmatch = ctrl.offsetTop; bestctrl = ctrl; } }); if (bestctrl && bestctrl.offsetTop < fromPoint + maxLen) { // De-focus the old element var el = document.activeElement; if (el) { var ctrl = elementControl(el); if (ctrl && ctrl.setFocusedAndSelectedIndex) ctrl.setFocusedAndSelectedIndex(-1); } // Focus the newly found control bestctrl.focus({ preventScroll: true }); if (bestctrl.controlClass.setFocusedAndSelectedIndex) bestctrl.controlClass.setFocusedAndSelectedIndex(0); return true; } return false; }, getFirstUp: function (fromPoint, maxLen, allowEmpty) { var ctrls = qes(this.container, '[data-control-class]'); var bestctrl = undefined; var bestmatch = -1; forEach(ctrls, function (ctrl) { if (!ctrl.controlClass || !ctrl.controlClass.visible || !(ctrl.tabIndex >= 0) || (ctrl.controlClass.itemCount == 0 && !allowEmpty) /*empty listview*/ ) return; var bottom = ctrl.offsetTop + ctrl.offsetHeight; if (bottom <= fromPoint && bottom > bestmatch) { bestmatch = bottom; bestctrl = ctrl; } }); if (bestctrl && bestctrl.offsetTop + bestctrl.offsetHeight >= fromPoint - maxLen) { return bestctrl; } return null; }, focusFirstUp: function (fromPoint, maxLen, allowEmpty) { var bestctrl = this.getFirstUp(fromPoint, maxLen, allowEmpty); if (bestctrl) { // De-focus the old element var el = document.activeElement; if (el) { var ctrl = elementControl(el); if (ctrl && ctrl.setFocusedAndSelectedIndex) ctrl.setFocusedAndSelectedIndex(-1); } // Focus the newly found control bestctrl.focus({ preventScroll: true }); if (bestctrl.controlClass.setFocusedAndSelectedIndex) bestctrl.controlClass.setFocusedAndSelectedIndex(bestctrl.controlClass.itemCount - 1); return true; } return false; }, handle_keydown: function (e) { var handled = false; switch (friendlyKeyName(e)) { case 'Home': this.focusFirstDown(0, this.container.offsetHeight); this.setSmoothScrollOffset(0); handled = true; break; case 'End': this.focusFirstUp(this.container.scrollHeight, this.container.offsetHeight); this.setSmoothScrollOffset(this.container.scrollHeight); handled = true; break; case 'Up': var el = document.activeElement; if (!el) break; var bottom = el.offsetTop + el.offsetHeight; handled = this.focusFirstUp(el.offsetTop, el.offsetTop - this.container.scrollTop); break; case 'Down': var el = document.activeElement; if (!el) break; var bottom = el.offsetTop + el.offsetHeight; handled = this.focusFirstDown(bottom, this.container.scrollTop + this.container.offsetHeight - bottom); break; } if (handled) { e.stopPropagation(); e.preventDefault(); } }, handle_layoutchange: function (evt) { if (this._initialScrollTop !== undefined) { if (this.container.scrollTop > 0) { // was already manually scrolled, do not change this._initialScrollTop = undefined; } else if (this._initialScrollTop <= (this.container.scrollHeight - this.container.clientHeight)) { // we already have sufficient height of the main control, scroll to initial position this.setScrollOffset(this._initialScrollTop); this._initialScrollTop = undefined; this.notifyChildren(); } if (this._initialScrollTop === undefined) { // unregister layoutchange event, no longer needed this.unregisterEventHandler('layoutchange'); } } Scroller.$super.handle_layoutchange.call(this, evt); }, _forward: function (e, action, forContextMenu) { var _this = this; var getParentClass = function (ctrl) { while (ctrl.parentNode) { ctrl = ctrl.parentNode; if (ctrl.controlClass && ctrl.parentNode == _this.container) { return ctrl; } } } var ctrl = getParentClass(e.target); if (!ctrl) { // it is drop to the "empty" area of this scroller // find the last usable control for the drop var ctrl = this.container.firstChild; var lastCtrl; while (ctrl) { if (ctrl.controlClass && ctrl.controlClass.dndEventsRegistered) lastCtrl = ctrl; ctrl = ctrl.nextSibling; } ctrl = lastCtrl; } if (ctrl) { var cls = ctrl.controlClass; if (cls.dndEventsRegistered && cls[action]) { if (forContextMenu) { if (cls.cancelSelection) cls.cancelSelection(); ctrl.focus(); } e._target = ctrl; dnd.setDropTargetControl(e, ctrl); // define correct drop target (so isSameControl can be enumerated correctly) return cls[action].call(cls, e); } } }, getDropMode: function (e) { return this._forward(e, 'getDropMode'); }, canDrop: function (e) { return this._forward(e, 'canDrop'); }, drop: function (e) { this._forward(e, 'drop'); }, getDraggedObject: function (e) { return this._forward(e, 'getDraggedObject'); }, contextMenuHandler: function (e) { e.stopPropagation(); this._forward(e, 'contextMenuHandler', true); }, storeState: function () { var state = Scroller.$super.storeState.call(this); state.scrollTop = this.container.scrollTop; return state; }, restoreState: function (state, isJustViewModeChange) { Scroller.$super.restoreState.apply(this, arguments); this._initialScrollTop = state.scrollTop || 0; this.container.scrollTop = 0; // reset to original null value, to be able to detect scroll change if (this._initialScrollTop > 0) { // register layoutchange event, so we can check, if we already can scroll to desired position this.registerEventHandler('layoutchange'); } else this._initialScrollTop = undefined; } }, { });