Skip to content

Commit

Permalink
Add support for finding/highlighting the outlineItem, corresponding t…
Browse files Browse the repository at this point in the history
…o the currently visible page, in the sidebar (issue 7557, bug 1253820, bug 1499050)

This implementation is inspired by the behaviour in (recent versions of) Adobe Reader, since it leads to reasonably simple and straightforward code as far as I'm concerned.
*Specifically:* We'll only consider *one* destination per page when finding/highlighting the current outline item, which is similar to e.g. Adobe Reader, and we choose the *first* outline item at the *lowest* level of the outline tree.

Given that this functionality requires not only parsing of the `outline`, but fetching *all* of the `destinations`, this feature will when initialized have a non-trivial performance overhead for larger PDF documents.
In an attempt to reduce the performance impact, the following steps are taken here:

 - The "find current outline item"-functionality will only be enabled once *one* page has rendered and *all* the pages have been loaded[1], to prevent it interfering with data regular fetching/parsing early on during document loading and viewer initialization.

 - With the exception of a couple of small and simple `eventBus`-listeners, in `PDFOutlineViewer`, this new functionality is initialized *lazily* the first time that the user clicks on the `currentOutlineItem`-button.

 - The entire "find current outline item"-functionality is disabled when `disableAutoFetch = true` is set, since it can easily lead to the setting becoming essentially pointless[2] by triggering *a lot* of data fetching from a relatively minor viewer-feature.

 - Use `PDFDocumentProxy.getDestinations` to fetch *all* `destinations` at once, rather than having *potentially* thousands of repeated `PDFDocumentProxy.getDestination` calls. This way we first of all reduce the *overall* asynchronicity of the code in question, and secondly there's also a lot less main/worker-thread message passing.

Finally, we'll now always highlight an outline item that the user manually clicked on, since only highlighting when the new "find current outline item"-functionality is used seemed inconsistent.

---
[1] Keep in mind that the `outline` itself already isn't fetched/parsed until at least *one* page has been rendered in the viewer.

[2] And also quite slow, since it can take a fair amount of time to fetch all of the necessary `destinations` data when `disableAutoFetch = true` is set.
  • Loading branch information
Snuffleupagus committed Dec 26, 2020
1 parent df53e78 commit 57f1dad
Show file tree
Hide file tree
Showing 11 changed files with 303 additions and 40 deletions.
2 changes: 2 additions & 0 deletions l10n/en-US/viewer.properties
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ layers.title=Show Layers (double-click to reset all layers to the default state)
layers_label=Layers
thumbs.title=Show Thumbnails
thumbs_label=Thumbnails
current_outline_item.title=Find Current Outline Item
current_outline_item_label=Current Outline Item
findbar.title=Find in Document
findbar_label=Find

Expand Down
2 changes: 2 additions & 0 deletions l10n/sv-SE/viewer.properties
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ layers.title=Visa lager (dubbelklicka för att återställa alla lager till stan
layers_label=Lager
thumbs.title=Visa miniatyrer
thumbs_label=Miniatyrer
current_outline_item.title=Hitta aktuell position i dokumentdispositionen
current_outline_item_label=Aktuell position i dokumentdisposition
findbar.title=Sök i dokument
findbar_label=Sök

Expand Down
5 changes: 3 additions & 2 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
ProgressBar,
RendererType,
ScrollMode,
SidebarView,
SpreadMode,
TextLayerMode,
} from "./ui_utils.js";
Expand All @@ -57,7 +58,6 @@ import {
} from "pdfjs-lib";
import { CursorTool, PDFCursorTools } from "./pdf_cursor_tools.js";
import { PDFRenderingQueue, RenderingStates } from "./pdf_rendering_queue.js";
import { PDFSidebar, SidebarView } from "./pdf_sidebar.js";
import { OverlayManager } from "./overlay_manager.js";
import { PasswordPrompt } from "./password_prompt.js";
import { PDFAttachmentViewer } from "./pdf_attachment_viewer.js";
Expand All @@ -69,6 +69,7 @@ import { PDFLayerViewer } from "./pdf_layer_viewer.js";
import { PDFLinkService } from "./pdf_link_service.js";
import { PDFOutlineViewer } from "./pdf_outline_viewer.js";
import { PDFPresentationMode } from "./pdf_presentation_mode.js";
import { PDFSidebar } from "./pdf_sidebar.js";
import { PDFSidebarResizer } from "./pdf_sidebar_resizer.js";
import { PDFThumbnailViewer } from "./pdf_thumbnail_viewer.js";
import { PDFViewer } from "./pdf_viewer.js";
Expand Down Expand Up @@ -1430,7 +1431,7 @@ const PDFViewerApplication = {

onePageRendered.then(() => {
pdfDocument.getOutline().then(outline => {
this.pdfOutlineViewer.render({ outline });
this.pdfOutlineViewer.render({ outline, pdfDocument });
});
pdfDocument.getAttachments().then(attachments => {
this.pdfAttachmentViewer.render({ attachments });
Expand Down
44 changes: 44 additions & 0 deletions web/base_tree_viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@

import { removeNullCharacters } from "pdfjs-lib";

const TREEITEM_OFFSET_TOP = -100; // px
const TREEITEM_SELECTED_CLASS = "selected";

class BaseTreeViewer {
constructor(options) {
if (this.constructor === BaseTreeViewer) {
Expand All @@ -28,6 +31,7 @@ class BaseTreeViewer {

reset() {
this._lastToggleIsShow = true;
this._currentTreeItem = null;

// Remove the tree from the DOM.
this.container.textContent = "";
Expand Down Expand Up @@ -106,6 +110,46 @@ class BaseTreeViewer {
render(params) {
throw new Error("Not implemented: render");
}

/**
* @private
*/
_updateCurrentTreeItem(treeItem = null) {
if (this._currentTreeItem) {
// Ensure that the current treeItem-selection is always removed.
this._currentTreeItem.classList.remove(TREEITEM_SELECTED_CLASS);
this._currentTreeItem = null;
}
if (treeItem) {
treeItem.classList.add(TREEITEM_SELECTED_CLASS);
this._currentTreeItem = treeItem;
}
}

/**
* @private
*/
_scrollToCurrentTreeItem(treeItem) {
if (!treeItem) {
return;
}
// Ensure that the treeItem is *fully* expanded, such that it will first of
// all be visible and secondly that scrolling it into view works correctly.
let currentNode = treeItem.parentNode;
while (currentNode && currentNode !== this.container) {
if (currentNode.classList.contains("treeItem")) {
const toggler = currentNode.firstElementChild;
toggler?.classList.remove("treeItemsHidden");
}
currentNode = currentNode.parentNode;
}
this._updateCurrentTreeItem(treeItem);

this.container.scrollTo(
treeItem.offsetLeft,
treeItem.offsetTop + TREEITEM_OFFSET_TOP
);
}
}

export { BaseTreeViewer };
1 change: 1 addition & 0 deletions web/images/toolbarButton-currentOutlineItem.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
156 changes: 153 additions & 3 deletions web/pdf_outline_viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@
* limitations under the License.
*/

import { addLinkAttributes, LinkTarget } from "pdfjs-lib";
import {
addLinkAttributes,
createPromiseCapability,
LinkTarget,
} from "pdfjs-lib";
import { BaseTreeViewer } from "./base_tree_viewer.js";
import { SidebarView } from "./ui_utils.js";

/**
* @typedef {Object} PDFOutlineViewerOptions
Expand All @@ -26,6 +31,7 @@ import { BaseTreeViewer } from "./base_tree_viewer.js";
/**
* @typedef {Object} PDFOutlineViewerRenderParameters
* @property {Array|null} outline - An array of outline objects.
* @property {PDFDocument} pdfDocument - A {PDFDocument} instance.
*/

class PDFOutlineViewer extends BaseTreeViewer {
Expand All @@ -37,11 +43,30 @@ class PDFOutlineViewer extends BaseTreeViewer {
this.linkService = options.linkService;

this.eventBus._on("toggleoutlinetree", this._toggleAllTreeItems.bind(this));
this.eventBus._on(
"currentoutlineitem",
this._currentOutlineItem.bind(this)
);

this.eventBus._on("pagechanging", evt => {
this._currentPageNumber = evt.pageNumber;
});
this.eventBus._on("pagesloaded", evt => {
this._isPagesLoaded = !!evt.pagesCount;
});
this.eventBus._on("sidebarviewchanged", evt => {
this._sidebarView = evt.view;
});
}

reset() {
super.reset();
this._outline = null;
this._pdfDocument = null;

this._pageNumberToDestHashCapability = null;
this._currentPageNumber = 1;
this._isPagesLoaded = false;
}

/**
Expand All @@ -51,6 +76,8 @@ class PDFOutlineViewer extends BaseTreeViewer {
this.eventBus.dispatch("outlineloaded", {
source: this,
outlineCount,
enableCurrentOutlineItemButton:
outlineCount > 0 && !this._pdfDocument?.loadingParams.disableAutoFetch,
});
}

Expand All @@ -71,7 +98,9 @@ class PDFOutlineViewer extends BaseTreeViewer {
}

element.href = linkService.getDestinationHash(dest);
element.onclick = () => {
element.onclick = evt => {
this._updateCurrentTreeItem(evt.target.parentNode);

if (dest) {
linkService.goToDestination(dest);
}
Expand Down Expand Up @@ -128,11 +157,12 @@ class PDFOutlineViewer extends BaseTreeViewer {
/**
* @param {PDFOutlineViewerRenderParameters} params
*/
render({ outline }) {
render({ outline, pdfDocument }) {
if (this._outline) {
this.reset();
}
this._outline = outline || null;
this._pdfDocument = pdfDocument || null;

if (!outline) {
this._dispatchEvent(/* outlineCount = */ 0);
Expand Down Expand Up @@ -182,6 +212,126 @@ class PDFOutlineViewer extends BaseTreeViewer {

this._dispatchEvent(outlineCount);
}

/**
* Find/highlight the current outline item, corresponding to the active page.
* @private
*/
async _currentOutlineItem() {
if (!this._isPagesLoaded) {
// Wait until all pages are loaded, since parsing is resource intensive.
console.error("_currentOutlineItem: All pages are not loaded yet.");
return;
}
if (!this._outline || !this._pdfDocument) {
return;
}

const pageNumberToDestHash = await this._getPageNumberToDestHash(
this._pdfDocument
);
if (!pageNumberToDestHash) {
return;
}
this._updateCurrentTreeItem(/* treeItem = */ null);

if (this._sidebarView !== SidebarView.OUTLINE) {
return; // The outline view is no longer visible, hence do nothing.
}
// When there is no destination on the current page, always check the
// previous ones in (reverse) order.
for (let i = this._currentPageNumber; i > 0; i--) {
const destHash = pageNumberToDestHash.get(i);
if (!destHash) {
continue;
}
const linkElement = this.container.querySelector(`a[href="${destHash}"]`);
if (!linkElement) {
continue;
}
this._scrollToCurrentTreeItem(linkElement.parentNode);
break;
}
}

/**
* To (significantly) simplify the overall implementation, we will only
* consider *one* destination per page when finding/highlighting the current
* outline item (similar to e.g. Adobe Reader); more specifically, we choose
* the *first* outline item at the *lowest* level of the outline tree.
* @private
*/
async _getPageNumberToDestHash(pdfDocument) {
if (this._pageNumberToDestHashCapability) {
return this._pageNumberToDestHashCapability.promise;
}
this._pageNumberToDestHashCapability = createPromiseCapability();
// NOTE: Despite `getDestinations` being slow, for larger documents,
// we need to use it here to reduce the *overall* parsing time.
const destinations = await pdfDocument.getDestinations();

if (pdfDocument !== this._pdfDocument) {
return null; // The document was closed while the data resolved.
}
const destCandidates = new Map();

const queue = [{ nesting: 0, items: this._outline }];
while (queue.length > 0) {
const levelData = queue.shift();
for (const { dest, items } of levelData.items) {
const explicitDest =
typeof dest === "string" ? destinations[dest] : dest;

if (Array.isArray(explicitDest)) {
const [destRef] = explicitDest;
let pageNumber;

if (typeof destRef === "object") {
pageNumber = this.linkService._cachedPageNumber(destRef);

if (!pageNumber) {
pageNumber = (await pdfDocument.getPageIndex(destRef)) + 1;

if (pdfDocument !== this._pdfDocument) {
return null; // The document was closed while the data resolved.
}
this.linkService.cachePageRef(pageNumber, destRef);
}
} else if (Number.isInteger(destRef)) {
pageNumber = destRef + 1;
}
if (
Number.isInteger(pageNumber) &&
pageNumber > 0 &&
pageNumber <= this.linkService.pagesCount
) {
const candidate = destCandidates.get(pageNumber),
currentNesting = levelData.nesting;
if (!candidate || currentNesting > candidate.nesting) {
destCandidates.set(pageNumber, { nesting: currentNesting, dest });
}
}
}

if (items.length > 0) {
queue.push({ nesting: levelData.nesting + 1, items });
}
}
}

if (destCandidates.size > 0) {
const pageNumberToDestHash = new Map();

for (const [pageNumber, candidate] of destCandidates) {
const destHash = this.linkService.getDestinationHash(candidate.dest);
pageNumberToDestHash.set(pageNumber, destHash);
}
this._pageNumberToDestHashCapability.resolve(pageNumberToDestHash);
} else {
this._pageNumberToDestHashCapability.resolve(null);
}
return this._pageNumberToDestHashCapability.promise;
}
}

export { PDFOutlineViewer };
Loading

0 comments on commit 57f1dad

Please sign in to comment.