Add scrolling modes to web viewer

In addition to the default scrolling mode (vertical), this commit adds
horizontal and wrapped scrolling, implemented primarily with CSS.
This commit is contained in:
Ryan Hendrickson 2018-05-14 23:10:32 -04:00
parent 65c8549759
commit 91cbc185da
17 changed files with 665 additions and 27 deletions

View File

@ -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…

View File

@ -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]);
});
});
});

View File

@ -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();
}

View File

@ -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,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

View File

@ -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;
}

View File

@ -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);

View File

@ -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;

View File

@ -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,
};

View File

@ -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);
}

View File

@ -168,7 +168,19 @@ See https://github.com/adobe-type-tools/cmap-resources
<div class="horizontalToolbarSeparator"></div>
<button id="documentProperties" class="secondaryToolbarButton documentProperties" title="Document Properties…" tabindex="62" data-l10n-id="document_properties">
<button id="scrollVertical" class="secondaryToolbarButton scrollVertical toggled" title="Use Vertical Scrolling" tabindex="62" data-l10n-id="scroll_vertical">
<span data-l10n-id="scroll_vertical_label">Vertical Scrolling</span>
</button>
<button id="scrollHorizontal" class="secondaryToolbarButton scrollHorizontal" title="Use Horizontal Scrolling" tabindex="63" data-l10n-id="scroll_horizontal">
<span data-l10n-id="scroll_horizontal_label">Horizontal Scrolling</span>
</button>
<button id="scrollWrapped" class="secondaryToolbarButton scrollWrapped" title="Use Wrapped Scrolling" tabindex="64" data-l10n-id="scroll_wrapped">
<span data-l10n-id="scroll_wrapped_label">Wrapped Scrolling</span>
</button>
<div class="horizontalToolbarSeparator"></div>
<button id="documentProperties" class="secondaryToolbarButton documentProperties" title="Document Properties…" tabindex="65" data-l10n-id="document_properties">
<span data-l10n-id="document_properties_label">Document Properties…</span>
</button>
</div>

View File

@ -96,6 +96,9 @@ function getViewerConfiguration() {
pageRotateCcwButton: document.getElementById('pageRotateCcw'),
cursorSelectToolButton: document.getElementById('cursorSelectTool'),
cursorHandToolButton: document.getElementById('cursorHandTool'),
scrollVerticalButton: document.getElementById('scrollVertical'),
scrollHorizontalButton: document.getElementById('scrollHorizontal'),
scrollWrappedButton: document.getElementById('scrollWrapped'),
documentPropertiesButton: document.getElementById('documentProperties'),
},
fullscreen: {