diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index d31103c00..5d1429f75 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/nl/viewer.properties b/l10n/nl/viewer.properties index ebf56e2d8..0491255d8 100644 --- a/l10n/nl/viewer.properties +++ b/l10n/nl/viewer.properties @@ -148,6 +148,8 @@ layers.title=Lagen tonen (dubbelklik om alle lagen naar de standaardstatus terug layers_label=Lagen thumbs.title=Miniaturen tonen thumbs_label=Miniaturen +current_outline_item.title=Huidige positie in documentoverzicht selecteren +current_outline_item_label=Huidige positie in documentoverzicht findbar.title=Zoeken in document findbar_label=Zoeken diff --git a/l10n/sv-SE/viewer.properties b/l10n/sv-SE/viewer.properties index f6cfc7c9b..974af193e 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 f2c049f37..bc28beda9 100644 --- a/web/app.js +++ b/web/app.js @@ -32,6 +32,7 @@ import { ProgressBar, RendererType, ScrollMode, + SidebarView, SpreadMode, TextLayerMode, } from "./ui_utils.js"; @@ -56,7 +57,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"; @@ -68,6 +68,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"; @@ -1419,7 +1420,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 2ba85aa0c..6e203aea8 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) { @@ -27,7 +30,9 @@ class BaseTreeViewer { } reset() { + this._pdfDocument = null; this._lastToggleIsShow = true; + this._currentTreeItem = null; // Remove the tree from the DOM. this.container.textContent = ""; @@ -120,6 +125,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/pdf_layer_viewer.js b/web/pdf_layer_viewer.js index e1612cb87..8ab073874 100644 --- a/web/pdf_layer_viewer.js +++ b/web/pdf_layer_viewer.js @@ -41,7 +41,6 @@ class PDFLayerViewer extends BaseTreeViewer { reset() { super.reset(); this._optionalContentConfig = null; - this._pdfDocument = null; } /** diff --git a/web/pdf_outline_viewer.js b/web/pdf_outline_viewer.js index 25420c4e2..83a142dd6 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,29 @@ 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._pageNumberToDestHashCapability = null; + this._currentPageNumber = 1; + this._isPagesLoaded = false; } /** @@ -51,6 +75,8 @@ class PDFOutlineViewer extends BaseTreeViewer { this.eventBus.dispatch("outlineloaded", { source: this, outlineCount, + enableCurrentOutlineItemButton: + outlineCount > 0 && !this._pdfDocument?.loadingParams.disableAutoFetch, }); } @@ -71,7 +97,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 +156,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); @@ -174,6 +203,120 @@ class PDFOutlineViewer extends BaseTreeViewer { this._finishRendering(fragment, outlineCount, hasAnyNesting); } + + /** + * Find/highlight the current outline item, corresponding to the active page. + * @private + */ + async _currentOutlineItem() { + if (!this._isPagesLoaded) { + throw new Error("_currentOutlineItem: All pages have not been loaded."); + } + 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(); + + const pageNumberToDestHash = new Map(), + pageNumberNesting = new Map(); + const queue = [{ nesting: 0, items: this._outline }]; + while (queue.length > 0) { + const levelData = queue.shift(), + currentNesting = levelData.nesting; + for (const { dest, items } of levelData.items) { + let explicitDest, pageNumber; + if (typeof dest === "string") { + explicitDest = await pdfDocument.getDestination(dest); + + if (pdfDocument !== this._pdfDocument) { + return null; // The document was closed while the data resolved. + } + } else { + explicitDest = dest; + } + if (Array.isArray(explicitDest)) { + const [destRef] = explicitDest; + + if (typeof destRef === "object") { + pageNumber = this.linkService._cachedPageNumber(destRef); + + if (!pageNumber) { + try { + 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); + } catch (ex) { + // Invalid page reference, ignore it and continue parsing. + } + } + } else if (Number.isInteger(destRef)) { + pageNumber = destRef + 1; + } + + if ( + Number.isInteger(pageNumber) && + (!pageNumberToDestHash.has(pageNumber) || + currentNesting > pageNumberNesting.get(pageNumber)) + ) { + const destHash = this.linkService.getDestinationHash(dest); + pageNumberToDestHash.set(pageNumber, destHash); + pageNumberNesting.set(pageNumber, currentNesting); + } + } + + if (items.length > 0) { + queue.push({ nesting: currentNesting + 1, items }); + } + } + } + + this._pageNumberToDestHashCapability.resolve( + pageNumberToDestHash.size > 0 ? pageNumberToDestHash : null + ); + return this._pageNumberToDestHashCapability.promise; + } } export { PDFOutlineViewer }; diff --git a/web/pdf_sidebar.js b/web/pdf_sidebar.js index 10737cdf0..37242c6e5 100644 --- a/web/pdf_sidebar.js +++ b/web/pdf_sidebar.js @@ -13,20 +13,11 @@ * limitations under the License. */ -import { NullL10n, PresentationModeState } from "./ui_utils.js"; +import { NullL10n, PresentationModeState, 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} outlineOptionsContainer - 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 b44b55205..4749af811 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", @@ -1037,6 +1046,7 @@ export { isValidSpreadMode, isPortraitOrientation, PresentationModeState, + SidebarView, RendererType, TextLayerMode, ScrollMode, diff --git a/web/viewer.css b/web/viewer.css index cc44c7c78..04f3888bd 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -48,8 +48,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); @@ -77,6 +77,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); @@ -121,8 +122,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); @@ -338,6 +339,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; @@ -694,16 +702,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; @@ -711,6 +725,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; @@ -1073,6 +1089,14 @@ 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); +} +html[dir="rtl"] #currentOutlineItem.toolbarButton::before { + transform: scaleX(-1); +} + #viewFind.toolbarButton::before { -webkit-mask-image: var(--toolbarButton-search-icon); mask-image: var(--toolbarButton-search-icon); @@ -1448,6 +1472,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, @@ -1458,12 +1487,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); -} - /* TODO: file FF bug to support ::-moz-selection:window-inactive so we can override the opaque grey background when the window is inactive; see https://bugzilla.mozilla.org/show_bug.cgi?id=706209 */ diff --git a/web/viewer.html b/web/viewer.html index e9cf7e3b6..013896ab2 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -76,19 +76,31 @@ See https://github.com/adobe-type-tools/cmap-resources