diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index 3f4723eb2..dc3bcf06e 100644 --- a/l10n/en-US/viewer.properties +++ b/l10n/en-US/viewer.properties @@ -65,6 +65,13 @@ cursor_text_select_tool_label=Text Selection Tool cursor_hand_tool.title=Enable Hand Tool cursor_hand_tool_label=Hand Tool +scroll_vertical.title=Use Vertical Scrolling +scroll_vertical_label=Vertical Scrolling +scroll_horizontal.title=Use Horizontal Scrolling +scroll_horizontal_label=Horizontal Scrolling +scroll_wrapped.title=Use Wrapped Scrolling +scroll_wrapped_label=Wrapped Scrolling + # Document properties dialog box document_properties.title=Document Properties… document_properties_label=Document Properties… diff --git a/test/unit/ui_utils_spec.js b/test/unit/ui_utils_spec.js index 64426c966..0e93bd8ac 100644 --- a/test/unit/ui_utils_spec.js +++ b/test/unit/ui_utils_spec.js @@ -14,8 +14,10 @@ */ import { - binarySearchFirstItem, EventBus, getPageSizeInches, getPDFFileNameFromURL, - isPortraitOrientation, isValidRotation, waitOnEventOrTimeout, WaitOnType + backtrackBeforeAllVisibleElements, binarySearchFirstItem, EventBus, + getPageSizeInches, getPDFFileNameFromURL, getVisibleElements, + isPortraitOrientation, isValidRotation, moveToEndOfArray, + waitOnEventOrTimeout, WaitOnType } from '../../web/ui_utils'; import { createObjectURL } from '../../src/shared/util'; import isNodeJS from '../../src/shared/is_node'; @@ -447,4 +449,267 @@ describe('ui_utils', function() { expect(height2).toEqual(8.5); }); }); + + describe('getVisibleElements', function() { + // These values are based on margin/border values in the CSS, but there + // isn't any real need for them to be; they just need to take *some* value. + const BORDER_WIDTH = 9; + const SPACING = 2 * BORDER_WIDTH - 7; + + // This is a helper function for assembling an array of view stubs from an + // array of arrays of [width, height] pairs, which represents wrapped lines + // of pages. It uses the above constants to add realistic spacing between + // the pages and the lines. + // + // If you're reading a test that calls makePages, you should think of the + // inputs to makePages as boxes with no borders, being laid out in a + // container that has no margins, so that the top of the tallest page in + // the first row will be at y = 0, and the left of the first page in + // the first row will be at x = 0. The spacing between pages in a row, and + // the spacing between rows, is SPACING. If you wanted to construct an + // actual HTML document with the same layout, you should give each page + // element a margin-right and margin-bottom of SPACING, and add no other + // margins, borders, or padding. + // + // If you're reading makePages itself, you'll see a somewhat more + // complicated picture because this suite of tests is exercising + // getVisibleElements' ability to account for the borders that real page + // elements have. makePages tests this by subtracting a BORDER_WIDTH from + // offsetLeft/Top and adding it to clientLeft/Top. So the element stubs that + // getVisibleElements sees may, for example, actually have an offsetTop of + // -9. If everything is working correctly, this detail won't leak out into + // the tests themselves, and so the tests shouldn't use the value of + // BORDER_WIDTH at all. + function makePages(lines) { + const result = []; + let lineTop = 0, id = 0; + for (const line of lines) { + const lineHeight = line.reduce(function(maxHeight, pair) { + return Math.max(maxHeight, pair[1]); + }, 0); + let offsetLeft = -BORDER_WIDTH; + for (const [clientWidth, clientHeight] of line) { + const offsetTop = + lineTop + (lineHeight - clientHeight) / 2 - BORDER_WIDTH; + const div = { + offsetLeft, offsetTop, clientWidth, clientHeight, + clientLeft: BORDER_WIDTH, clientTop: BORDER_WIDTH, + }; + result.push({ id, div, }); + ++id; + offsetLeft += clientWidth + SPACING; + } + lineTop += lineHeight + SPACING; + } + return result; + } + + // This is a reimplementation of getVisibleElements without the + // optimizations. + function slowGetVisibleElements(scroll, pages) { + const views = []; + const { scrollLeft, scrollTop, } = scroll; + const scrollRight = scrollLeft + scroll.clientWidth; + const scrollBottom = scrollTop + scroll.clientHeight; + for (const view of pages) { + const { div, } = view; + const viewLeft = div.offsetLeft + div.clientLeft; + const viewRight = viewLeft + div.clientWidth; + const viewTop = div.offsetTop + div.clientTop; + const viewBottom = viewTop + div.clientHeight; + + if (viewLeft < scrollRight && viewRight > scrollLeft && + viewTop < scrollBottom && viewBottom > scrollTop) { + const hiddenHeight = Math.max(0, scrollTop - viewTop) + + Math.max(0, viewBottom - scrollBottom); + const hiddenWidth = Math.max(0, scrollLeft - viewLeft) + + Math.max(0, viewRight - scrollRight); + const visibleArea = (div.clientHeight - hiddenHeight) * + (div.clientWidth - hiddenWidth); + const percent = + (visibleArea * 100 / div.clientHeight / div.clientWidth) | 0; + views.push({ id: view.id, x: viewLeft, y: viewTop, view, percent, }); + } + } + return { first: views[0], last: views[views.length - 1], views, }; + } + + // This function takes a fixed layout of pages and compares the system under + // test to the slower implementation above, for a range of scroll viewport + // sizes and positions. + function scrollOverDocument(pages, horizontally = false) { + const size = pages.reduce(function(max, { div, }) { + return Math.max( + max, + horizontally ? + div.offsetLeft + div.clientLeft + div.clientWidth : + div.offsetTop + div.clientTop + div.clientHeight); + }, 0); + // The numbers (7 and 5) are mostly arbitrary, not magic: increase them to + // make scrollOverDocument tests faster, decrease them to make the tests + // more scrupulous, and keep them coprime to reduce the chance of missing + // weird edge case bugs. + for (let i = 0; i < size; i += 7) { + // The screen height (or width) here (j - i) doubles on each inner loop + // iteration; again, this is just to test an interesting range of cases + // without slowing the tests down to check every possible case. + for (let j = i + 5; j < size; j += (j - i)) { + const scroll = horizontally ? { + scrollTop: 0, + scrollLeft: i, + clientHeight: 10000, + clientWidth: j - i, + } : { + scrollTop: i, + scrollLeft: 0, + clientHeight: j - i, + clientWidth: 10000, + }; + expect(getVisibleElements(scroll, pages, false, horizontally)) + .toEqual(slowGetVisibleElements(scroll, pages)); + } + } + } + + it('with pages of varying height', function() { + const pages = makePages([ + [[50, 20], [20, 50]], + [[30, 12], [12, 30]], + [[20, 50], [50, 20]], + [[50, 20], [20, 50]], + ]); + scrollOverDocument(pages); + }); + + it('widescreen challenge', function() { + const pages = makePages([ + [[10, 50], [10, 60], [10, 70], [10, 80], [10, 90]], + [[10, 90], [10, 80], [10, 70], [10, 60], [10, 50]], + [[10, 50], [10, 60], [10, 70], [10, 80], [10, 90]], + ]); + scrollOverDocument(pages); + }); + + it('works with horizontal scrolling', function() { + const pages = makePages([ + [[10, 50], [20, 20], [30, 10]], + ]); + scrollOverDocument(pages, true); + }); + + // This sub-suite is for a notionally internal helper function for + // getVisibleElements. + describe('backtrackBeforeAllVisibleElements', function() { + // Layout elements common to all tests + const tallPage = [10, 50]; + const shortPage = [10, 10]; + + // A scroll position that ensures that only the tall pages in the second + // row are visible + const top1 = + 20 + SPACING + // height of the first row + 40; // a value between 30 (so the short pages on the second row are + // hidden) and 50 (so the tall pages are visible) + + // A scroll position that ensures that all of the pages in the second row + // are visible, but the tall ones are a tiny bit cut off + const top2 = 20 + SPACING + // height of the first row + 10; // a value greater than 0 but less than 30 + + // These tests refer to cases enumerated in the comments of + // backtrackBeforeAllVisibleElements. + it('handles case 1', function() { + const pages = makePages([ + [[10, 20], [10, 20], [10, 20], [10, 20]], + [tallPage, shortPage, tallPage, shortPage], + [[10, 50], [10, 50], [10, 50], [10, 50]], + [[10, 20], [10, 20], [10, 20], [10, 20]], + [[10, 20]], + ]); + // binary search would land on the second row, first page + const bsResult = 4; + expect(backtrackBeforeAllVisibleElements(bsResult, pages, top1)) + .toEqual(4); + }); + + it('handles case 2', function() { + const pages = makePages([ + [[10, 20], [10, 20], [10, 20], [10, 20]], + [tallPage, shortPage, tallPage, tallPage], + [[10, 50], [10, 50], [10, 50], [10, 50]], + [[10, 20], [10, 20], [10, 20], [10, 20]], + ]); + // binary search would land on the second row, third page + const bsResult = 6; + expect(backtrackBeforeAllVisibleElements(bsResult, pages, top1)) + .toEqual(4); + }); + + it('handles case 3', function() { + const pages = makePages([ + [[10, 20], [10, 20], [10, 20], [10, 20]], + [tallPage, shortPage, tallPage, shortPage], + [[10, 50], [10, 50], [10, 50], [10, 50]], + [[10, 20], [10, 20], [10, 20], [10, 20]], + ]); + // binary search would land on the third row, first page + const bsResult = 8; + expect(backtrackBeforeAllVisibleElements(bsResult, pages, top1)) + .toEqual(4); + }); + + it('handles case 4', function() { + const pages = makePages([ + [[10, 20], [10, 20], [10, 20], [10, 20]], + [tallPage, shortPage, tallPage, shortPage], + [[10, 50], [10, 50], [10, 50], [10, 50]], + [[10, 20], [10, 20], [10, 20], [10, 20]], + ]); + // binary search would land on the second row, first page + const bsResult = 4; + expect(backtrackBeforeAllVisibleElements(bsResult, pages, top2)) + .toEqual(4); + }); + }); + }); + + describe('moveToEndOfArray', function() { + it('works on empty arrays', function() { + const data = []; + moveToEndOfArray(data, function() {}); + expect(data).toEqual([]); + }); + + it('works when moving everything', function() { + const data = [1, 2, 3, 4, 5]; + moveToEndOfArray(data, function() { + return true; + }); + expect(data).toEqual([1, 2, 3, 4, 5]); + }); + + it('works when moving some things', function() { + const data = [1, 2, 3, 4, 5]; + moveToEndOfArray(data, function(x) { + return x % 2 === 0; + }); + expect(data).toEqual([1, 3, 5, 2, 4]); + }); + + it('works when moving one thing', function() { + const data = [1, 2, 3, 4, 5]; + moveToEndOfArray(data, function(x) { + return x === 1; + }); + expect(data).toEqual([2, 3, 4, 5, 1]); + }); + + it('works when moving nothing', function() { + const data = [1, 2, 3, 4, 5]; + moveToEndOfArray(data, function(x) { + return x === 0; + }); + expect(data).toEqual([1, 2, 3, 4, 5]); + }); + }); }); diff --git a/web/app.js b/web/app.js index 4cb950899..705c1a523 100644 --- a/web/app.js +++ b/web/app.js @@ -1385,6 +1385,7 @@ let PDFViewerApplication = { eventBus.on('scalechanged', webViewerScaleChanged); eventBus.on('rotatecw', webViewerRotateCw); eventBus.on('rotateccw', webViewerRotateCcw); + eventBus.on('switchscrollmode', webViewerSwitchScrollMode); eventBus.on('documentproperties', webViewerDocumentProperties); eventBus.on('find', webViewerFind); eventBus.on('findfromurlhash', webViewerFindFromUrlHash); @@ -1451,6 +1452,7 @@ let PDFViewerApplication = { eventBus.off('scalechanged', webViewerScaleChanged); eventBus.off('rotatecw', webViewerRotateCw); eventBus.off('rotateccw', webViewerRotateCcw); + eventBus.off('switchscrollmode', webViewerSwitchScrollMode); eventBus.off('documentproperties', webViewerDocumentProperties); eventBus.off('find', webViewerFind); eventBus.off('findfromurlhash', webViewerFindFromUrlHash); @@ -1960,6 +1962,9 @@ function webViewerRotateCw() { function webViewerRotateCcw() { PDFViewerApplication.rotatePages(-90); } +function webViewerSwitchScrollMode(evt) { + PDFViewerApplication.pdfViewer.setScrollMode(evt.mode); +} function webViewerDocumentProperties() { PDFViewerApplication.pdfDocumentProperties.open(); } diff --git a/web/base_viewer.js b/web/base_viewer.js index daefce3cc..94de1779c 100644 --- a/web/base_viewer.js +++ b/web/base_viewer.js @@ -15,9 +15,9 @@ import { CSS_UNITS, DEFAULT_SCALE, DEFAULT_SCALE_VALUE, isPortraitOrientation, - isValidRotation, MAX_AUTO_SCALE, NullL10n, PresentationModeState, - RendererType, SCROLLBAR_PADDING, TextLayerMode, UNKNOWN_SCALE, - VERTICAL_PADDING, watchScroll + isValidRotation, MAX_AUTO_SCALE, moveToEndOfArray, NullL10n, + PresentationModeState, RendererType, SCROLLBAR_PADDING, TextLayerMode, + UNKNOWN_SCALE, VERTICAL_PADDING, watchScroll } from './ui_utils'; import { PDFRenderingQueue, RenderingStates } from './pdf_rendering_queue'; import { AnnotationLayerBuilder } from './annotation_layer_builder'; @@ -29,6 +29,12 @@ import { TextLayerBuilder } from './text_layer_builder'; const DEFAULT_CACHE_SIZE = 10; +const ScrollMode = { + VERTICAL: 0, // The default value. + HORIZONTAL: 1, + WRAPPED: 2, +}; + /** * @typedef {Object} PDFViewerOptions * @property {HTMLDivElement} container - The container for the viewer element. @@ -61,6 +67,10 @@ const DEFAULT_CACHE_SIZE = 10; * size in total pixels, i.e. width * height. Use -1 for no limit. * The default value is 4096 * 4096 (16 mega-pixels). * @property {IL10n} l10n - Localization service. + * @property {number} scrollMode - (optional) The direction in which the + * document pages should be laid out within the scrolling container. The + * constants from {ScrollMode} should be used. The default value is + * `ScrollMode.VERTICAL`. */ function PDFPageViewBuffer(size) { @@ -75,8 +85,24 @@ function PDFPageViewBuffer(size) { data.shift().destroy(); } }; - this.resize = function(newSize) { + /** + * After calling resize, the size of the buffer will be newSize. The optional + * parameter pagesToKeep is, if present, an array of pages to push to the back + * of the buffer, delaying their destruction. The size of pagesToKeep has no + * impact on the final size of the buffer; if pagesToKeep has length larger + * than newSize, some of those pages will be destroyed anyway. + */ + this.resize = function(newSize, pagesToKeep) { size = newSize; + if (pagesToKeep) { + const pageIdsToKeep = new Set(); + for (let i = 0, iMax = pagesToKeep.length; i < iMax; ++i) { + pageIdsToKeep.add(pagesToKeep[i].id); + } + moveToEndOfArray(data, function(page) { + return pageIdsToKeep.has(page.id); + }); + } while (data.length > size) { data.shift().destroy(); } @@ -126,6 +152,7 @@ class BaseViewer { this.useOnlyCssZoom = options.useOnlyCssZoom || false; this.maxCanvasPixels = options.maxCanvasPixels; this.l10n = options.l10n || NullL10n; + this.scrollMode = options.scrollMode || ScrollMode.VERTICAL; this.defaultRenderingQueue = !options.renderingQueue; if (this.defaultRenderingQueue) { @@ -143,6 +170,7 @@ class BaseViewer { if (this.removePageBorders) { this.viewer.classList.add('removePageBorders'); } + this._updateScrollModeClasses(); } get pagesCount() { @@ -557,6 +585,11 @@ class BaseViewer { 0 : SCROLLBAR_PADDING; let vPadding = (this.isInPresentationMode || this.removePageBorders) ? 0 : VERTICAL_PADDING; + if (this.scrollMode === ScrollMode.HORIZONTAL) { + const temp = hPadding; + hPadding = vPadding; + vPadding = temp; + } let pageWidthScale = (this.container.clientWidth - hPadding) / currentPage.width * currentPage.scale; let pageHeightScale = (this.container.clientHeight - vPadding) / @@ -733,10 +766,15 @@ class BaseViewer { }); } - _resizeBuffer(numVisiblePages) { + /** + * visiblePages is optional; if present, it should be an array of pages and in + * practice its length is going to be numVisiblePages, but this is not + * required. The new size of the buffer depends only on numVisiblePages. + */ + _resizeBuffer(numVisiblePages, visiblePages) { let suggestedCacheSize = Math.max(DEFAULT_CACHE_SIZE, 2 * numVisiblePages + 1); - this._buffer.resize(suggestedCacheSize); + this._buffer.resize(suggestedCacheSize, visiblePages); } _updateLocation(firstPage) { @@ -847,9 +885,11 @@ class BaseViewer { forceRendering(currentlyVisiblePages) { let visiblePages = currentlyVisiblePages || this._getVisiblePages(); + let scrollAhead = this.scrollMode === ScrollMode.HORIZONTAL ? + this.scroll.right : this.scroll.down; let pageView = this.renderingQueue.getHighestPriority(visiblePages, this._pages, - this.scroll.down); + scrollAhead); if (pageView) { this._ensurePdfPageLoaded(pageView).then(() => { this.renderingQueue.renderView(pageView); @@ -957,8 +997,32 @@ class BaseViewer { }; }); } + + setScrollMode(mode) { + if (mode !== this.scrollMode) { + this.scrollMode = mode; + this._updateScrollModeClasses(); + this.eventBus.dispatch('scrollmodechanged', { mode, }); + const pageNumber = this._currentPageNumber; + // Non-numeric scale modes can be sensitive to the scroll orientation. + // Call this before re-scrolling to the current page, to ensure that any + // changes in scale don't move the current page. + if (isNaN(this._currentScaleValue)) { + this._setScale(this._currentScaleValue, this.isInPresentationMode); + } + this.scrollPageIntoView({ pageNumber, }); + this.update(); + } + } + + _updateScrollModeClasses() { + const mode = this.scrollMode, { classList, } = this.viewer; + classList.toggle('scrollHorizontal', mode === ScrollMode.HORIZONTAL); + classList.toggle('scrollWrapped', mode === ScrollMode.WRAPPED); + } } export { BaseViewer, + ScrollMode, }; diff --git a/web/images/secondaryToolbarButton-scrollHorizontal.png b/web/images/secondaryToolbarButton-scrollHorizontal.png new file mode 100644 index 000000000..cb702fc4d Binary files /dev/null and b/web/images/secondaryToolbarButton-scrollHorizontal.png differ diff --git a/web/images/secondaryToolbarButton-scrollHorizontal@2x.png b/web/images/secondaryToolbarButton-scrollHorizontal@2x.png new file mode 100644 index 000000000..7f05289bb Binary files /dev/null and b/web/images/secondaryToolbarButton-scrollHorizontal@2x.png differ diff --git a/web/images/secondaryToolbarButton-scrollVertical.png b/web/images/secondaryToolbarButton-scrollVertical.png new file mode 100644 index 000000000..0b8427a16 Binary files /dev/null and b/web/images/secondaryToolbarButton-scrollVertical.png differ diff --git a/web/images/secondaryToolbarButton-scrollVertical@2x.png b/web/images/secondaryToolbarButton-scrollVertical@2x.png new file mode 100644 index 000000000..72ab55ebf Binary files /dev/null and b/web/images/secondaryToolbarButton-scrollVertical@2x.png differ diff --git a/web/images/secondaryToolbarButton-scrollWrapped.png b/web/images/secondaryToolbarButton-scrollWrapped.png new file mode 100644 index 000000000..165fc8bc0 Binary files /dev/null and b/web/images/secondaryToolbarButton-scrollWrapped.png differ diff --git a/web/images/secondaryToolbarButton-scrollWrapped@2x.png b/web/images/secondaryToolbarButton-scrollWrapped@2x.png new file mode 100644 index 000000000..424614119 Binary files /dev/null and b/web/images/secondaryToolbarButton-scrollWrapped@2x.png differ diff --git a/web/pdf_viewer.css b/web/pdf_viewer.css index cc0afa1fb..a0adbde3f 100644 --- a/web/pdf_viewer.css +++ b/web/pdf_viewer.css @@ -46,6 +46,35 @@ border: none; } +.pdfViewer.scrollHorizontal, .pdfViewer.scrollWrapped { + margin-left: 3.5px; + margin-right: 3.5px; + text-align: center; +} + +.pdfViewer.scrollHorizontal { + white-space: nowrap; +} + +.pdfViewer.removePageBorders { + margin-left: 0; + margin-right: 0; +} + +.pdfViewer.scrollHorizontal .page, +.pdfViewer.scrollWrapped .page { + display: inline-block; + margin-left: -3.5px; + margin-right: -3.5px; + vertical-align: middle; +} + +.pdfViewer.removePageBorders.scrollHorizontal .page, +.pdfViewer.removePageBorders.scrollWrapped .page { + margin-left: 5px; + margin-right: 5px; +} + .pdfViewer .page canvas { margin: 0; display: block; @@ -65,6 +94,21 @@ background: url('images/loading-icon.gif') center no-repeat; } +.pdfPresentationMode .pdfViewer { + margin-left: 0; + margin-right: 0; +} + +.pdfPresentationMode .pdfViewer .page { + display: block; +} + +.pdfPresentationMode .pdfViewer .page, +.pdfPresentationMode .pdfViewer.removePageBorders .page { + margin-left: auto; + margin-right: auto; +} + .pdfPresentationMode:-ms-fullscreen .pdfViewer .page { margin-bottom: 100% !important; } diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index c32065ff6..e6e520f5f 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -13,8 +13,8 @@ * limitations under the License. */ +import { BaseViewer, ScrollMode } from './base_viewer'; import { getVisibleElements, scrollIntoView } from './ui_utils'; -import { BaseViewer } from './base_viewer'; import { shadow } from 'pdfjs-lib'; class PDFViewer extends BaseViewer { @@ -23,12 +23,16 @@ class PDFViewer extends BaseViewer { } _scrollIntoView({ pageDiv, pageSpot = null, }) { + if (!pageSpot && this.scrollMode === ScrollMode.HORIZONTAL) { + pageSpot = { left: 0, top: 0, }; + } scrollIntoView(pageDiv, pageSpot); } _getVisiblePages() { if (!this.isInPresentationMode) { - return getVisibleElements(this.container, this._pages, true); + return getVisibleElements(this.container, this._pages, true, + this.scrollMode === ScrollMode.HORIZONTAL); } // The algorithm in getVisibleElements doesn't work in all browsers and // configurations when presentation mode is active. @@ -44,7 +48,7 @@ class PDFViewer extends BaseViewer { if (numVisiblePages === 0) { return; } - this._resizeBuffer(numVisiblePages); + this._resizeBuffer(numVisiblePages, visiblePages); this.renderingQueue.renderHighestPriority(visible); diff --git a/web/secondary_toolbar.js b/web/secondary_toolbar.js index 6495fc5ef..a332f668b 100644 --- a/web/secondary_toolbar.js +++ b/web/secondary_toolbar.js @@ -15,6 +15,7 @@ import { CursorTool } from './pdf_cursor_tools'; import { SCROLLBAR_PADDING } from './ui_utils'; +import { ScrollMode } from './base_viewer'; /** * @typedef {Object} SecondaryToolbarOptions @@ -76,6 +77,12 @@ class SecondaryToolbar { eventDetails: { tool: CursorTool.SELECT, }, close: true, }, { element: options.cursorHandToolButton, eventName: 'switchcursortool', eventDetails: { tool: CursorTool.HAND, }, close: true, }, + { element: options.scrollVerticalButton, eventName: 'switchscrollmode', + eventDetails: { mode: ScrollMode.VERTICAL, }, close: true, }, + { element: options.scrollHorizontalButton, eventName: 'switchscrollmode', + eventDetails: { mode: ScrollMode.HORIZONTAL, }, close: true, }, + { element: options.scrollWrappedButton, eventName: 'switchscrollmode', + eventDetails: { mode: ScrollMode.WRAPPED, }, close: true, }, { element: options.documentPropertiesButton, eventName: 'documentproperties', close: true, }, ]; @@ -95,9 +102,10 @@ class SecondaryToolbar { this.reset(); - // Bind the event listeners for click and cursor tool actions. + // Bind the event listeners for click, cursor tool, and scroll mode actions. this._bindClickListeners(); this._bindCursorToolsListener(options); + this._bindScrollModeListener(options); // Bind the event listener for adjusting the 'max-height' of the toolbar. this.eventBus.on('resize', this._setMaxHeight.bind(this)); @@ -172,6 +180,17 @@ class SecondaryToolbar { }); } + _bindScrollModeListener(buttons) { + this.eventBus.on('scrollmodechanged', function(evt) { + buttons.scrollVerticalButton.classList.toggle('toggled', + evt.mode === ScrollMode.VERTICAL); + buttons.scrollHorizontalButton.classList.toggle('toggled', + evt.mode === ScrollMode.HORIZONTAL); + buttons.scrollWrappedButton.classList.toggle('toggled', + evt.mode === ScrollMode.WRAPPED); + }); + } + open() { if (this.opened) { return; diff --git a/web/ui_utils.js b/web/ui_utils.js index f59acab2b..b38489341 100644 --- a/web/ui_utils.js +++ b/web/ui_utils.js @@ -155,6 +155,12 @@ function watchScroll(viewAreaElement, callback) { rAF = window.requestAnimationFrame(function viewAreaElementScrolled() { rAF = null; + let currentX = viewAreaElement.scrollLeft; + let lastX = state.lastX; + if (currentX !== lastX) { + state.right = currentX > lastX; + } + state.lastX = currentX; let currentY = viewAreaElement.scrollTop; let lastY = state.lastY; if (currentY !== lastY) { @@ -166,7 +172,9 @@ function watchScroll(viewAreaElement, callback) { }; let state = { + right: true, down: true, + lastX: viewAreaElement.scrollLeft, lastY: viewAreaElement.scrollTop, _eventHandler: debounceScroll, }; @@ -296,50 +304,211 @@ function getPageSizeInches({ view, userUnit, rotate, }) { } /** - * Generic helper to find out what elements are visible within a scroll pane. + * Helper function for getVisibleElements. + * + * @param {number} index - initial guess at the first visible element + * @param {Array} views - array of pages, into which `index` is an index + * @param {number} top - the top of the scroll pane + * @returns {number} less than or equal to `index` that is definitely at or + * before the first visible element in `views`, but not by too much. (Usually, + * this will be the first element in the first partially visible row in + * `views`, although sometimes it goes back one row further.) */ -function getVisibleElements(scrollEl, views, sortByVisibility = false) { +function backtrackBeforeAllVisibleElements(index, views, top) { + // binarySearchFirstItem's assumption is that the input is ordered, with only + // one index where the conditions flips from false to true: + // [false ..., true...]. With wrapped scrolling, it is possible to have + // [false ..., true, false, true ...]. + // + // So there is no guarantee that the binary search yields the index of the + // first visible element. It could have been any of the other visible elements + // that were preceded by a hidden element. + + // Of course, if either this element or the previous (hidden) element is also + // the first element, there's nothing to worry about. + if (index < 2) { + return index; + } + + // That aside, the possible cases are represented below. + // + // **** = fully hidden + // A*B* = mix of partially visible and/or hidden pages + // CDEF = fully visible + // + // (1) Binary search could have returned A, in which case we can stop. + // (2) Binary search could also have returned B, in which case we need to + // check the whole row. + // (3) Binary search could also have returned C, in which case we need to + // check the whole previous row. + // + // There's one other possibility: + // + // **** = fully hidden + // ABCD = mix of fully and/or partially visible pages + // + // (4) Binary search could only have returned A. + + // Initially assume that we need to find the beginning of the current row + // (case 1, 2, or 4), which means finding a page that is above the current + // page's top. If the found page is partially visible, we're definitely not in + // case 3, and this assumption is correct. + let elt = views[index].div; + let pageTop = elt.offsetTop + elt.clientTop; + + if (pageTop >= top) { + // The found page is fully visible, so we're actually either in case 3 or 4, + // and unfortunately we can't tell the difference between them without + // scanning the entire previous row, so we just conservatively assume that + // we do need to backtrack to that row. In both cases, the previous page is + // in the previous row, so use its top instead. + elt = views[index - 1].div; + pageTop = elt.offsetTop + elt.clientTop; + } + + // Now we backtrack to the first page that still has its bottom below + // `pageTop`, which is the top of a page in the first visible row (unless + // we're in case 4, in which case it's the row before that). + // `index` is found by binary search, so the page at `index - 1` is + // invisible and we can start looking for potentially visible pages from + // `index - 2`. (However, if this loop terminates on its first iteration, + // which is the case when pages are stacked vertically, `index` should remain + // unchanged, so we use a distinct loop variable.) + for (let i = index - 2; i >= 0; --i) { + elt = views[i].div; + if (elt.offsetTop + elt.clientTop + elt.clientHeight <= pageTop) { + // We have reached the previous row, so stop now. + // This loop is expected to terminate relatively quickly because the + // number of pages per row is expected to be small. + break; + } + index = i; + } + return index; +} + +/** + * Generic helper to find out what elements are visible within a scroll pane. + * + * Well, pretty generic. There are some assumptions placed on the elements + * referenced by `views`: + * - If `horizontal`, no left of any earlier element is to the right of the + * left of any later element. + * - Otherwise, `views` can be split into contiguous rows where, within a row, + * no top of any element is below the bottom of any other element, and + * between rows, no bottom of any element in an earlier row is below the + * top of any element in a later row. + * + * (Here, top, left, etc. all refer to the padding edge of the element in + * question. For pages, that ends up being equivalent to the bounding box of the + * rendering canvas. Earlier and later refer to index in `views`, not page + * layout.) + * + * @param scrollEl {HTMLElement} - a container that can possibly scroll + * @param views {Array} - objects with a `div` property that contains an + * HTMLElement, which should all be descendents of `scrollEl` satisfying the + * above layout assumptions + * @param sortByVisibility {boolean} - if true, the returned elements are sorted + * in descending order of the percent of their padding box that is visible + * @param horizontal {boolean} - if true, the elements are assumed to be laid + * out horizontally instead of vertically + * @returns {Object} `{ first, last, views: [{ id, x, y, view, percent }] }` + */ +function getVisibleElements(scrollEl, views, sortByVisibility = false, + horizontal = false) { let top = scrollEl.scrollTop, bottom = top + scrollEl.clientHeight; let left = scrollEl.scrollLeft, right = left + scrollEl.clientWidth; - function isElementBottomBelowViewTop(view) { + // Throughout this "generic" function, comments will assume we're working with + // PDF document pages, which is the most important and complex case. In this + // case, the visible elements we're actually interested is the page canvas, + // which is contained in a wrapper which adds no padding/border/margin, which + // is itself contained in `view.div` which adds no padding (but does add a + // border). So, as specified in this function's doc comment, this function + // does all of its work on the padding edge of the provided views, starting at + // offsetLeft/Top (which includes margin) and adding clientLeft/Top (which is + // the border). Adding clientWidth/Height gets us the bottom-right corner of + // the padding edge. + function isElementBottomAfterViewTop(view) { let element = view.div; let elementBottom = element.offsetTop + element.clientTop + element.clientHeight; return elementBottom > top; } + function isElementRightAfterViewLeft(view) { + let element = view.div; + let elementRight = + element.offsetLeft + element.clientLeft + element.clientWidth; + return elementRight > left; + } let visible = [], view, element; - let currentHeight, viewHeight, hiddenHeight, percentHeight; - let currentWidth, viewWidth; + let currentHeight, viewHeight, viewBottom, hiddenHeight; + let currentWidth, viewWidth, viewRight, hiddenWidth; + let percentVisible; let firstVisibleElementInd = views.length === 0 ? 0 : - binarySearchFirstItem(views, isElementBottomBelowViewTop); + binarySearchFirstItem(views, horizontal ? isElementRightAfterViewLeft : + isElementBottomAfterViewTop); + + if (views.length > 0 && !horizontal) { + // In wrapped scrolling, with some page sizes, isElementBottomAfterViewTop + // doesn't satisfy the binary search condition: there can be pages with + // bottoms above the view top between pages with bottoms below. This + // function detects and corrects that error; see it for more comments. + firstVisibleElementInd = + backtrackBeforeAllVisibleElements(firstVisibleElementInd, views, top); + } + + // lastEdge acts as a cutoff for us to stop looping, because we know all + // subsequent pages will be hidden. + // + // When using wrapped scrolling, we can't simply stop the first time we reach + // a page below the bottom of the view; the tops of subsequent pages on the + // same row could still be visible. In horizontal scrolling, we don't have + // that issue, so we can stop as soon as we pass `right`, without needing the + // code below that handles the -1 case. + let lastEdge = horizontal ? right : -1; for (let i = firstVisibleElementInd, ii = views.length; i < ii; i++) { view = views[i]; element = view.div; + currentWidth = element.offsetLeft + element.clientLeft; currentHeight = element.offsetTop + element.clientTop; + viewWidth = element.clientWidth; viewHeight = element.clientHeight; + viewRight = currentWidth + viewWidth; + viewBottom = currentHeight + viewHeight; - if (currentHeight > bottom) { + if (lastEdge === -1) { + // As commented above, this is only needed in non-horizontal cases. + // Setting lastEdge to the bottom of the first page that is partially + // visible ensures that the next page fully below lastEdge is on the + // next row, which has to be fully hidden along with all subsequent rows. + if (viewBottom >= bottom) { + lastEdge = viewBottom; + } + } else if ((horizontal ? currentWidth : currentHeight) > lastEdge) { break; } - currentWidth = element.offsetLeft + element.clientLeft; - viewWidth = element.clientWidth; - if (currentWidth + viewWidth < left || currentWidth > right) { + if (viewBottom <= top || currentHeight >= bottom || + viewRight <= left || currentWidth >= right) { continue; } + hiddenHeight = Math.max(0, top - currentHeight) + - Math.max(0, currentHeight + viewHeight - bottom); - percentHeight = ((viewHeight - hiddenHeight) * 100 / viewHeight) | 0; + Math.max(0, viewBottom - bottom); + hiddenWidth = Math.max(0, left - currentWidth) + + Math.max(0, viewRight - right); + percentVisible = ((viewHeight - hiddenHeight) * (viewWidth - hiddenWidth) * + 100 / viewHeight / viewWidth) | 0; visible.push({ id: view.id, x: currentWidth, y: currentHeight, view, - percent: percentHeight, + percent: percentVisible, }); } @@ -640,6 +809,26 @@ class ProgressBar { } } +/** + * Moves all elements of an array that satisfy condition to the end of the + * array, preserving the order of the rest. + */ +function moveToEndOfArray(arr, condition) { + const moved = [], len = arr.length; + let write = 0; + for (let read = 0; read < len; ++read) { + if (condition(arr[read])) { + moved.push(arr[read]); + } else { + arr[write] = arr[read]; + ++write; + } + } + for (let read = 0; write < len; ++read, ++write) { + arr[write] = moved[read]; + } +} + export { CSS_UNITS, DEFAULT_SCALE_VALUE, @@ -663,6 +852,7 @@ export { getPDFFileNameFromURL, noContextMenuHandler, parseQueryString, + backtrackBeforeAllVisibleElements, // only exported for testing getVisibleElements, roundToDivide, getPageSizeInches, @@ -675,4 +865,5 @@ export { animationStarted, WaitOnType, waitOnEventOrTimeout, + moveToEndOfArray, }; diff --git a/web/viewer.css b/web/viewer.css index 0b008d92b..838ba81a6 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -966,6 +966,18 @@ html[dir="rtl"] .secondaryToolbarButton > span { content: url(images/secondaryToolbarButton-handTool.png); } +.secondaryToolbarButton.scrollVertical::before { + content: url(images/secondaryToolbarButton-scrollVertical.png); +} + +.secondaryToolbarButton.scrollHorizontal::before { + content: url(images/secondaryToolbarButton-scrollHorizontal.png); +} + +.secondaryToolbarButton.scrollWrapped::before { + content: url(images/secondaryToolbarButton-scrollWrapped.png); +} + .secondaryToolbarButton.documentProperties::before { content: url(images/secondaryToolbarButton-documentProperties.png); } @@ -1689,6 +1701,18 @@ html[dir='rtl'] #documentPropertiesOverlay .row > * { content: url(images/secondaryToolbarButton-handTool@2x.png); } + .secondaryToolbarButton.scrollVertical::before { + content: url(images/secondaryToolbarButton-scrollVertical@2x.png); + } + + .secondaryToolbarButton.scrollHorizontal::before { + content: url(images/secondaryToolbarButton-scrollHorizontal@2x.png); + } + + .secondaryToolbarButton.scrollWrapped::before { + content: url(images/secondaryToolbarButton-scrollWrapped@2x.png); + } + .secondaryToolbarButton.documentProperties::before { content: url(images/secondaryToolbarButton-documentProperties@2x.png); } diff --git a/web/viewer.html b/web/viewer.html index 2d460456c..e92592e1a 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -168,7 +168,19 @@ See https://github.com/adobe-type-tools/cmap-resources
-