diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index d31103c008f26b..5d1429f75b4234 100644 --- a/l10n/en-US/viewer.properties +++ b/l10n/en-US/viewer.properties @@ -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 diff --git a/l10n/sv-SE/viewer.properties b/l10n/sv-SE/viewer.properties index f6cfc7c9baf6e8..974af193e0de12 100644 --- a/l10n/sv-SE/viewer.properties +++ b/l10n/sv-SE/viewer.properties @@ -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 diff --git a/web/app.js b/web/app.js index 59743aa720bcd0..280d876016eada 100644 --- a/web/app.js +++ b/web/app.js @@ -33,6 +33,7 @@ import { ProgressBar, RendererType, ScrollMode, + SidebarView, SpreadMode, TextLayerMode, } from "./ui_utils.js"; @@ -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"; @@ -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"; @@ -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 }); diff --git a/web/base_tree_viewer.js b/web/base_tree_viewer.js index 579cd014790f9a..3d7926f17e0219 100644 --- a/web/base_tree_viewer.js +++ b/web/base_tree_viewer.js @@ -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) { @@ -28,6 +31,7 @@ class BaseTreeViewer { reset() { this._lastToggleIsShow = true; + this._currentTreeItem = null; // Remove the tree from the DOM. this.container.textContent = ""; @@ -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 }; diff --git a/web/images/toolbarButton-currentOutlineItem.svg b/web/images/toolbarButton-currentOutlineItem.svg new file mode 100644 index 00000000000000..f6a46e7caa0485 --- /dev/null +++ b/web/images/toolbarButton-currentOutlineItem.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/pdf_outline_viewer.js b/web/pdf_outline_viewer.js index 7d584564c3c348..f151e1c17c2a23 100644 --- a/web/pdf_outline_viewer.js +++ b/web/pdf_outline_viewer.js @@ -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 @@ -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 { @@ -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; } /** @@ -51,6 +76,8 @@ class PDFOutlineViewer extends BaseTreeViewer { this.eventBus.dispatch("outlineloaded", { source: this, outlineCount, + enableCurrentOutlineItemButton: + outlineCount > 0 && !this._pdfDocument?.loadingParams.disableAutoFetch, }); } @@ -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); } @@ -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); @@ -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 }; diff --git a/web/pdf_sidebar.js b/web/pdf_sidebar.js index 49ccb39f606891..fcac0a2a1d3ee4 100644 --- a/web/pdf_sidebar.js +++ b/web/pdf_sidebar.js @@ -13,20 +13,11 @@ * limitations under the License. */ -import { NullL10n } from "./ui_utils.js"; +import { NullL10n, SidebarView } from "./ui_utils.js"; import { RenderingStates } from "./pdf_rendering_queue.js"; const UI_NOTIFICATION_CLASS = "pdfSidebarNotification"; -const SidebarView = { - UNKNOWN: -1, - NONE: 0, - THUMBS: 1, // Default value. - OUTLINE: 2, - ATTACHMENTS: 3, - LAYERS: 4, -}; - /** * @typedef {Object} PDFSidebarOptions * @property {PDFSidebarElements} elements - The DOM elements. @@ -62,6 +53,10 @@ const SidebarView = { * the attachments are placed. * @property {HTMLDivElement} layersView - The container in which * the layers are placed. + * @property {HTMLDivElement} outlineOptionsContiner - The container in which + * the outline view-specific option button(s) are placed. + * @property {HTMLButtonElement} currentOutlineItemButton - The button used to + * find the current outline item. */ class PDFSidebar { @@ -103,6 +98,9 @@ class PDFSidebar { this.attachmentsView = elements.attachmentsView; this.layersView = elements.layersView; + this._outlineOptionsContainer = elements.outlineOptionsContainer; + this._currentOutlineItemButton = elements.currentOutlineItemButton; + this.eventBus = eventBus; this.l10n = l10n; this._disableNotification = disableNotification; @@ -119,6 +117,7 @@ class PDFSidebar { this.outlineButton.disabled = false; this.attachmentsButton.disabled = false; this.layersButton.disabled = false; + this._currentOutlineItemButton.disabled = true; } /** @@ -243,6 +242,12 @@ class PDFSidebar { ); this.layersView.classList.toggle("hidden", view !== SidebarView.LAYERS); + // Finally, update view-specific CSS classes. + this._outlineOptionsContainer.classList.toggle( + "hidden", + view !== SidebarView.OUTLINE + ); + if (forceOpen && !this.isOpen) { this.open(); return true; // Opening will trigger rendering and dispatch the event. @@ -460,6 +465,11 @@ class PDFSidebar { this.eventBus.dispatch("resetlayers", { source: this }); }); + // Buttons for view-specific options. + this._currentOutlineItemButton.addEventListener("click", () => { + this.eventBus.dispatch("currentoutlineitem", { source: this }); + }); + // Disable/enable views. const onTreeLoaded = (count, button, view) => { button.disabled = !count; @@ -475,6 +485,12 @@ class PDFSidebar { this.eventBus._on("outlineloaded", evt => { onTreeLoaded(evt.outlineCount, this.outlineButton, SidebarView.OUTLINE); + + if (evt.enableCurrentOutlineItemButton) { + this.pdfViewer.pagesPromise.then(() => { + this._currentOutlineItemButton.disabled = !this.isInitialViewSet; + }); + } }); this.eventBus._on("attachmentsloaded", evt => { diff --git a/web/ui_utils.js b/web/ui_utils.js index 70f54f5e6e0850..f711308e2e8740 100644 --- a/web/ui_utils.js +++ b/web/ui_utils.js @@ -32,6 +32,15 @@ const PresentationModeState = { FULLSCREEN: 3, }; +const SidebarView = { + UNKNOWN: -1, + NONE: 0, + THUMBS: 1, // Default value. + OUTLINE: 2, + ATTACHMENTS: 3, + LAYERS: 4, +}; + const RendererType = { CANVAS: "canvas", SVG: "svg", @@ -1031,6 +1040,7 @@ export { isValidSpreadMode, isPortraitOrientation, PresentationModeState, + SidebarView, RendererType, TextLayerMode, ScrollMode, diff --git a/web/viewer.css b/web/viewer.css index 216b6c45da9196..a6b10f0e4b9520 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -47,8 +47,8 @@ --findbar-nextprevious-btn-bg-color: rgba(227, 228, 230, 1); --treeitem-color: rgba(0, 0, 0, 0.8); --treeitem-hover-color: rgba(0, 0, 0, 0.9); - --treeitem-active-color: rgba(0, 0, 0, 0.08); - --treeitem-active-bg-color: rgba(0, 0, 0, 1); + --treeitem-selected-color: rgba(0, 0, 0, 0.9); + --treeitem-selected-bg-color: rgba(0, 0, 0, 0.25); --sidebaritem-bg-color: rgba(0, 0, 0, 0.15); --doorhanger-bg-color: rgba(255, 255, 255, 1); --doorhanger-border-color: rgba(12, 12, 13, 0.2); @@ -76,6 +76,7 @@ --toolbarButton-viewOutline-icon: url(images/toolbarButton-viewOutline.svg); --toolbarButton-viewAttachments-icon: url(images/toolbarButton-viewAttachments.svg); --toolbarButton-viewLayers-icon: url(images/toolbarButton-viewLayers.svg); + --toolbarButton-currentOutlineItem-icon: url(images/toolbarButton-currentOutlineItem.svg); --toolbarButton-search-icon: url(images/toolbarButton-search.svg); --findbarButton-previous-icon: url(images/findbarButton-previous.svg); --findbarButton-next-icon: url(images/findbarButton-next.svg); @@ -119,8 +120,8 @@ --findbar-nextprevious-btn-bg-color: rgba(89, 89, 89, 1); --treeitem-color: rgba(255, 255, 255, 0.8); --treeitem-hover-color: rgba(255, 255, 255, 0.9); - --treeitem-active-color: rgba(255, 255, 255, 0.08); - --treeitem-active-bg-color: rgba(255, 255, 255, 1); + --treeitem-selected-color: rgba(255, 255, 255, 0.9); + --treeitem-selected-bg-color: rgba(255, 255, 255, 0.25); --sidebaritem-bg-color: rgba(255, 255, 255, 0.15); --doorhanger-bg-color: rgba(74, 74, 79, 1); --doorhanger-border-color: rgba(39, 39, 43, 1); @@ -336,6 +337,13 @@ html[dir="rtl"] #toolbarSidebar .toolbarButton { margin-left: 2px !important; } +html[dir="ltr"] #toolbarSidebarRight .toolbarButton { + margin-right: 3px !important; +} +html[dir="rtl"] #toolbarSidebarRight .toolbarButton { + margin-left: 3px !important; +} + #sidebarResizer { position: absolute; top: 0; @@ -692,16 +700,22 @@ html[dir="ltr"] .doorHangerRight:before { } html[dir="ltr"] #toolbarViewerLeft, -html[dir="rtl"] #toolbarViewerRight { +html[dir="rtl"] #toolbarViewerRight, +html[dir="ltr"] #toolbarSidebarLeft, +html[dir="rtl"] #toolbarSidebarRight { float: left; } html[dir="ltr"] #toolbarViewerRight, -html[dir="rtl"] #toolbarViewerLeft { +html[dir="rtl"] #toolbarViewerLeft, +html[dir="ltr"] #toolbarSidebarRight, +html[dir="rtl"] #toolbarSidebarLeft { float: right; } html[dir="ltr"] #toolbarViewerLeft > *, html[dir="ltr"] #toolbarViewerMiddle > *, html[dir="ltr"] #toolbarViewerRight > *, +html[dir="ltr"] #toolbarSidebarLeft *, +html[dir="ltr"] #toolbarSidebarRight *, html[dir="ltr"] .findbar * { position: relative; float: left; @@ -709,6 +723,8 @@ html[dir="ltr"] .findbar * { html[dir="rtl"] #toolbarViewerLeft > *, html[dir="rtl"] #toolbarViewerMiddle > *, html[dir="rtl"] #toolbarViewerRight > *, +html[dir="rtl"] #toolbarSidebarLeft *, +html[dir="rtl"] #toolbarSidebarRight *, html[dir="rtl"] .findbar * { position: relative; float: right; @@ -1101,6 +1117,11 @@ html[dir="rtl"] #viewOutline.toolbarButton::before { mask-image: var(--toolbarButton-viewLayers-icon); } +#currentOutlineItem.toolbarButton::before { + -webkit-mask-image: var(--toolbarButton-currentOutlineItem-icon); + mask-image: var(--toolbarButton-currentOutlineItem-icon); +} + #viewFind.toolbarButton::before { -webkit-mask-image: var(--toolbarButton-search-icon); mask-image: var(--toolbarButton-search-icon); @@ -1476,6 +1497,11 @@ html[dir="rtl"] .treeItemToggler::before { left: 4px; } +.treeItem.selected > a { + background-color: var(--treeitem-selected-bg-color); + color: var(--treeitem-selected-color); +} + .treeItemToggler:hover, .treeItemToggler:hover + a, .treeItemToggler:hover ~ .treeItems, @@ -1486,12 +1512,6 @@ html[dir="rtl"] .treeItemToggler::before { color: var(--treeitem-hover-color); } -.treeItem.selected { - background-color: var(--treeitem-active-bg-color); - background-clip: padding-box; - color: var(--treeitem-active-color); -} - .noResults { font-size: 12px; color: rgba(255, 255, 255, 0.8); diff --git a/web/viewer.html b/web/viewer.html index e9cf7e3b6f71c4..d955c616995500 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -76,19 +76,31 @@
-
- - - - +
+
+ + + + +
+
+ +
+
diff --git a/web/viewer.js b/web/viewer.js index d447c962a6ace5..e4165fcdd07f72 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -132,6 +132,11 @@ function getViewerConfiguration() { outlineView: document.getElementById("outlineView"), attachmentsView: document.getElementById("attachmentsView"), layersView: document.getElementById("layersView"), + // View-specific options + outlineOptionsContainer: document.getElementById( + "outlineOptionsContainer" + ), + currentOutlineItemButton: document.getElementById("currentOutlineItem"), }, sidebarResizer: { outerContainer: document.getElementById("outerContainer"),