diff --git a/README.md b/README.md index b255731434c76..fe24fa56fbbdb 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ PDF.js is community-driven and supported by Mozilla. Our goal is to create a general-purpose, web standards-based platform for parsing and rendering PDFs. +## One difference from Mozilla repo +This fork of pdf.js has one single extra feature and that is to go back and forth (Use the Key B) to any link inside the pdf and come back. And we can create marked positions (Mark aline using M) and move between those positions in the pdf using a shortcut key "C" + ## Contributing PDF.js is an open source project and always looking for more contributors. To diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index 1d0e9ed02a4bc..a3dc7ec559c4b 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -574,6 +574,37 @@ pdfjs-editor-undo-bar-close-button = .title = Close pdfjs-editor-undo-bar-close-button-label = Close +## Jump history (marks, and returning from links) + +# Variables: +# $page (Number) - the page number of the position to return to +pdfjs-jump-history-back-button = + .title = Return to page { $page } (B) + .aria-label = Return to page { $page } +pdfjs-jump-history-back-button-empty = + .title = Nothing to return to yet; follow a link, or use the outline, first (B) + .aria-label = Nothing to return to yet +pdfjs-jump-history-list-button = + .title = Show marks (C / Shift+C to cycle) + .aria-label = Show marks +pdfjs-jump-history-panel-title = Marks +pdfjs-jump-history-save-button = Save to file +pdfjs-jump-history-max-entries-label = Marks to remember +# Variables: +# $page (Number) - the page number of the mark +pdfjs-jump-history-entry-page = Page { $page } +pdfjs-jump-history-entry-empty = No marks yet. Press M, or select text and use the mark button. +pdfjs-jump-history-clear-button = Clear all +pdfjs-jump-history-clear-button-confirm = Confirm clear +pdfjs-jump-history-mark-floating-button = + .title = Mark this line (M) +pdfjs-jump-history-mark-floating-button-label = Mark this line +pdfjs-jump-history-list-floating-button = + .title = Show marks +pdfjs-jump-history-list-floating-button-label = Show marks +pdfjs-jump-history-entry-loading = Loading… +pdfjs-jump-history-entry-no-text = (no text on this line) + ## Add a signature dialog pdfjs-editor-add-signature-dialog-label = This modal allows the user to create a signature to add to a PDF document. The user can edit the name (which also serves as the alt text), and optionally save the signature for repeated use. diff --git a/src/core/document.js b/src/core/document.js index 79fb31c47dbdd..37956e53d9b20 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -2275,17 +2275,18 @@ class PDFDocument { } get calculationOrderIds() { - const co = this.catalog.acroForm?.get("CO"); - let ids = null; + const calculationOrder = this.catalog.acroForm?.get("CO"); + if (!Array.isArray(calculationOrder) || calculationOrder.length === 0) { + return shadow(this, "calculationOrderIds", null); + } - if (Array.isArray(co)) { - for (const id of co) { - if (id instanceof Ref) { - (ids ??= []).push(id.toString()); - } + const ids = []; + for (const id of calculationOrder) { + if (id instanceof Ref) { + ids.push(id.toString()); } } - return shadow(this, "calculationOrderIds", ids); + return shadow(this, "calculationOrderIds", ids.length ? ids : null); } get annotationGlobals() { diff --git a/src/core/evaluator.js b/src/core/evaluator.js index 1e2200fafb111..6da7dbf8f2691 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -3020,7 +3020,7 @@ class PartialEvaluator { for (let i = 0, ii = glyphs.length; i < ii; i++) { const glyph = glyphs[i]; - const { category } = glyph; + const { category, originalCharCode } = glyph; if (category.isInvisibleFormatMark) { continue; @@ -3030,7 +3030,7 @@ class PartialEvaluator { const glyphWidth = font.vertical ? glyph.vmetric[0] : glyph.width; let scaledDim = glyphWidth * scale; - if (glyph.isSpace) { + if (originalCharCode === 0x20) { charSpacing += textState.wordSpacing; } diff --git a/src/core/fonts.js b/src/core/fonts.js index 8f3f327ac5034..4f16cbf0f477f 100644 --- a/src/core/fonts.js +++ b/src/core/fonts.js @@ -242,7 +242,6 @@ function amendFallbackToUnicode(properties) { class Glyph { constructor( - fontExtraProperties, originalCharCode, fontChar, unicode, @@ -254,9 +253,7 @@ class Glyph { isInFont, isRotated ) { - if (fontExtraProperties) { - this.originalCharCode = originalCharCode; - } + this.originalCharCode = originalCharCode; this.fontChar = fontChar; this.unicode = unicode; this.accent = accent; @@ -3675,7 +3672,6 @@ class Font { } glyph = new Glyph( - this.fontExtraProperties, charcode, fontChar, unicode, diff --git a/src/display/editor/toolbar.js b/src/display/editor/toolbar.js index 5aa71f2168f4b..9714d66ed6c01 100644 --- a/src/display/editor/toolbar.js +++ b/src/display/editor/toolbar.js @@ -316,6 +316,11 @@ class FloatingToolbar { } ); + for (const { className, l10nId, labelL10nId, onClick } of this.#uiManager + .floatingToolbarButtons) { + this.#makeButton(className, l10nId, labelL10nId, onClick); + } + return editToolbar; } diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 27cd6af4ce28e..927f693906aa3 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -779,6 +779,8 @@ class AnnotationEditorUIManager { #floatingToolbar = null; + #floatingToolbarButtons = []; + #idManager = new IdManager(); #isEnabled = false; @@ -1218,6 +1220,26 @@ class AnnotationEditorUIManager { return !!this.#commentManager; } + /** + * Add a button to the floating toolbar shown when selecting text, after the + * built-in ones; the selection is still available when it's clicked. + * @param {object} button + * @param {string} button.className - Used to style the button, e.g. its icon. + * @param {string} button.l10nId - For the title of the button. + * @param {string} button.labelL10nId - For the label of the button. + * @param {Function} button.onClick + */ + addFloatingToolbarButton(button) { + this.#floatingToolbarButtons.push(button); + // Re-create the toolbar, if already used, to include the new button. + this.#floatingToolbar?.hide(); + this.#floatingToolbar = null; + } + + get floatingToolbarButtons() { + return this.#floatingToolbarButtons; + } + editComment(editor, posX, posY, options) { this.#commentManager?.showDialog(this, editor, posX, posY, options); } diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 6745401903d81..91961a8eebdcf 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -268,6 +268,14 @@ &.deleteButton::before { mask-image: var(--editor-toolbar-delete-image); } + + &.jumpHistoryMarkButton::before { + mask-image: url(images/toolbarButton-bookmark.svg); + } + + &.jumpHistoryListButton::before { + mask-image: url(images/toolbarButton-viewOutline.svg); + } } > * { diff --git a/web/app.js b/web/app.js index ed900ce309435..1266357c3644e 100644 --- a/web/app.js +++ b/web/app.js @@ -84,6 +84,7 @@ import { PDFDocumentProperties } from "web-pdf_document_properties"; import { PDFFindBar } from "web-pdf_find_bar"; import { PDFFindController } from "./pdf_find_controller.js"; import { PDFHistory } from "./pdf_history.js"; +import { PDFJumpHistory } from "./pdf_jump_history.js"; import { PDFLayerViewer } from "web-pdf_layer_viewer"; import { PDFOutlineViewer } from "web-pdf_outline_viewer"; import { PDFPresentationMode } from "web-pdf_presentation_mode"; @@ -138,6 +139,8 @@ const PDFViewerApplication = { pdfTextExtractor: null, /** @type {PDFHistory} */ pdfHistory: null, + /** @type {PDFJumpHistory} */ + pdfJumpHistory: null, /** @type {ViewsManager} */ viewsManager: null, /** @type {PDFOutlineViewer} */ @@ -767,6 +770,14 @@ const PDFViewerApplication = { linkService.setHistory(this.pdfHistory); } + if (appConfig.jumpHistory) { + this.pdfJumpHistory = new PDFJumpHistory( + appConfig.jumpHistory, + eventBus, + pdfViewer + ); + } + if (!this.supportsIntegratedFind && appConfig.findBar) { this.findBar = new PDFFindBar( appConfig.findBar, @@ -1379,6 +1390,7 @@ const PDFViewerApplication = { this.pdfLayerViewer?.reset(); this.pdfHistory?.reset(); + this.pdfJumpHistory?.reset(); this.findBar?.reset(); this.toolbar?.reset(); this.secondaryToolbar?.reset(); @@ -2259,6 +2271,8 @@ const PDFViewerApplication = { // Scale was not initialized: invalid bookmark or scale was not specified. // Setting the default one. this.pdfViewer.currentScaleValue ||= DEFAULT_SCALE_VALUE; + + this.pdfJumpHistory?.initialize(this.store, this._docFilename); }, /** @@ -3530,6 +3544,25 @@ function onKeyDown(evt) { this.rotatePages(90); break; + case 66: // 'b' + if (this.pdfJumpHistory?.canReturn) { + this.pdfJumpHistory.toggleReturn(); + handled = true; + } + break; + case 77: // 'm' + if (this.pdfJumpHistory) { + this.pdfJumpHistory.markCurrentPosition(); + handled = true; + } + break; + case 67: // 'c' + if (this.pdfJumpHistory?.hasMarks) { + this.pdfJumpHistory.cycleBack(); + handled = true; + } + break; + case 115: // F4 this.viewsManager?.toggle(); break; @@ -3574,6 +3607,12 @@ function onKeyDown(evt) { case 82: // 'r' this.rotatePages(-90); break; + case 67: // 'c' + if (this.pdfJumpHistory?.hasMarks) { + this.pdfJumpHistory.cycleForward(); + handled = true; + } + break; } } diff --git a/web/pdf_jump_history.js b/web/pdf_jump_history.js new file mode 100644 index 0000000000000..1611a4da19693 --- /dev/null +++ b/web/pdf_jump_history.js @@ -0,0 +1,915 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { internalOpt } from "./internal_evt.js"; +import { MathClamp } from "pdfjs-lib"; +import { RenderingStates } from "./renderable_view.js"; + +/** @typedef {import("./event_utils").EventBus} EventBus */ +/** @typedef {import("./pdf_viewer").PDFViewer} PDFViewer */ +/** @typedef {import("./view_history").ViewHistory} ViewHistory */ + +// Default, and allowed, number of marks to remember; the user's choice is +// kept in `localStorage`. +const DEFAULT_MAX_ENTRIES = 5; +const MAX_ENTRIES_CHOICES = [1, 2, 3, 4, 5]; +const MAX_ENTRIES_STORAGE_KEY = "pdfjs.jumpHistoryMaxEntries"; +// When marking the selected line, keep this much space (in PDF units) above it +// once returning there. +const MARK_MARGIN = 24; +// Marks on the same page whose tops are this close (in PDF units) are the same +// line, i.e. marking it again doesn't add another mark. +const SAME_LINE_DISTANCE = 2; +// Maximum length of the text shown for a mark in the list. +const MAX_TEXT_LENGTH = 80; +// How long (in milliseconds) the line is flashed when going to a position. +const FLASH_DURATION = 1600; + +/** + * Two independent ways of getting back to a reading position: + * + * - Marks, set explicitly with `M` or from the floating toolbar shown when + * selecting text. Marked lines are highlighted on their page, and can be + * listed (to jump directly to any of them, or save them to a JSON file) and + * cycled through with `C` and `Shift+C`, wrapping around at either end. + * Nothing else ever adds to, or changes, the marks. + * + * - A single return point, remembered when following a link, an outline + * item, a thumbnail or typing a page number. `B` (or the floating button) + * goes back to where that navigation started; pressing it again goes back + * to where it was pressed, i.e. it toggles between both places. + * + * The line of the position being gone to is briefly flashed. + * + * Unlike `PDFHistory` this is independent of the browser history. + */ +class PDFJumpHistory { + // The marks, oldest first. + #marks = []; + + // The index of the mark last gone to, or added; -1 if none. + #markIndex = -1; + + // Where to go back to with `B`. + #returnPosition = null; + + #currentLocation = null; + + #enabled = false; + + #maxEntries = DEFAULT_MAX_ENTRIES; + + #store = null; + + #filename = "document.pdf"; + + #panelAbortController = null; + + #clearTimeout = null; + + /** + * @param {object} options - The DOM elements of the floating buttons. + * @param {HTMLDivElement} options.container + * @param {HTMLButtonElement} options.backButton + * @param {HTMLButtonElement} options.listButton + * @param {HTMLDivElement} options.panel + * @param {HTMLOListElement} options.entries + * @param {HTMLButtonElement} options.saveButton + * @param {HTMLSelectElement} options.maxEntriesSelect + * @param {HTMLButtonElement} options.clearButton + * @param {EventBus} eventBus - The application event bus. + * @param {PDFViewer} pdfViewer - The PDF viewer. + */ + constructor( + { + container, + backButton, + listButton, + panel, + entries, + saveButton, + maxEntriesSelect, + clearButton, + }, + eventBus, + pdfViewer + ) { + this.container = container; + this.backButton = backButton; + this.listButton = listButton; + this.panel = panel; + this.entries = entries; + this.eventBus = eventBus; + this.pdfViewer = pdfViewer; + + backButton.addEventListener("click", () => this.toggleReturn()); + listButton.addEventListener("click", () => this.togglePanel()); + saveButton.addEventListener("click", () => this.saveToFile()); + + // Clearing needs a second click, to confirm it. + clearButton.addEventListener("click", () => { + if (this.#clearTimeout) { + this.#resetClearButton(clearButton); + this.clearAll(); + return; + } + clearButton.classList.add("confirm"); + clearButton.setAttribute( + "data-l10n-id", + "pdfjs-jump-history-clear-button-confirm" + ); + this.#clearTimeout = setTimeout( + () => this.#resetClearButton(clearButton), + 3000 + ); + }); + this.clearButton = clearButton; + + // Add the buttons to the toolbar shown when selecting text, next to the + // highlight and comment ones. + eventBus.on( + "annotationeditoruimanager", + ({ uiManager }) => { + uiManager.addFloatingToolbarButton({ + className: "jumpHistoryMarkButton", + l10nId: "pdfjs-jump-history-mark-floating-button", + labelL10nId: "pdfjs-jump-history-mark-floating-button-label", + onClick: () => this.markSelection(), + }); + uiManager.addFloatingToolbarButton({ + className: "jumpHistoryListButton", + l10nId: "pdfjs-jump-history-list-floating-button", + labelL10nId: "pdfjs-jump-history-list-floating-button-label", + onClick: () => { + document.getSelection()?.removeAllRanges(); + this.openPanel(); + }, + }); + }, + internalOpt + ); + + try { + const stored = parseInt( + localStorage.getItem(MAX_ENTRIES_STORAGE_KEY), + 10 + ); + if (MAX_ENTRIES_CHOICES.includes(stored)) { + this.#maxEntries = stored; + } + } catch { + // Unable to read from storage; ignoring errors. + } + maxEntriesSelect.replaceChildren( + ...MAX_ENTRIES_CHOICES.map(n => new Option(n, n)) + ); + maxEntriesSelect.value = this.#maxEntries; + maxEntriesSelect.addEventListener("change", () => + this.setMaxEntries(parseInt(maxEntriesSelect.value, 10)) + ); + + eventBus.on( + "updateviewarea", + ({ location }) => { + this.#currentLocation = location; + }, + internalOpt + ); + eventBus.on( + "beforenavigate", + this.#onBeforeNavigate.bind(this), + internalOpt + ); + // Rendering a page (e.g. after zooming or rotating) removes the + // highlights from it. + eventBus.on( + "pagerendered", + ({ pageNumber }) => this.#renderHighlights(pageNumber), + internalOpt + ); + + this.#render(); + } + + /** + * Start tracking positions, once the initial view of the document is set. + * @param {ViewHistory|null} store - Used to persist the marks per file. + * @param {string} filename - Used to name the file the marks are saved to. + */ + async initialize(store, filename) { + this.#enabled = true; + this.#store = store; + this.#filename = filename || "document.pdf"; + this.#render(); + if (!store) { + return; + } + try { + const { + jumpHistoryMarks, + jumpHistory, + jumpHistoryForward, + jumpHistoryMark, + } = await store.getMultiple({ + jumpHistoryMarks: null, + // Previously, the marks were stored along with the other positions. + jumpHistory: null, + jumpHistoryForward: null, + jumpHistoryMark: null, + }); + if (this.#store !== store) { + return; + } + const stored = Array.isArray(jumpHistoryMarks) + ? jumpHistoryMarks + : [ + ...(Array.isArray(jumpHistory) ? jumpHistory : []), + ...(Array.isArray(jumpHistoryForward) + ? jumpHistoryForward.toReversed() + : []), + jumpHistoryMark, + ].filter(pos => pos?.marked); + + const pagesCount = this.pdfViewer.pagesCount; + const marks = stored + .filter( + pos => + Number.isInteger(pos?.pageNumber) && + pos.pageNumber >= 1 && + pos.pageNumber <= pagesCount + ) + .map(pos => this.#toPosition(pos)); + // Marks added before the stored ones were loaded are the newest. + this.#marks.unshift(...marks); + this.#markIndex = this.#marks.length ? this.#marks.length - 1 : -1; + this.#trim(); + this.#render(); + } catch { + // Unable to read from storage; ignoring errors. + } + } + + reset() { + this.#marks.length = 0; + this.#markIndex = -1; + this.#returnPosition = null; + this.#currentLocation = null; + this.#enabled = false; + this.#store = null; + this.closePanel(); + this.#resetClearButton(this.clearButton); + this.#render(); + } + + get canReturn() { + return !!this.#returnPosition; + } + + get hasMarks() { + return this.#marks.length > 0; + } + + get maxEntries() { + return this.#maxEntries; + } + + /** + * Change how many marks are remembered, dropping the oldest ones when there + * are now too many. + * @param {number} value + */ + setMaxEntries(value) { + if (!MAX_ENTRIES_CHOICES.includes(value)) { + return; + } + this.#maxEntries = value; + try { + localStorage.setItem(MAX_ENTRIES_STORAGE_KEY, value); + } catch { + // Unable to write to storage. + } + this.#trim(); + this.#render(); + this.#save(); + } + + get isPanelOpen() { + return !this.panel.hidden; + } + + /** + * Go back to where the last navigation (link, outline, ...) started; doing + * it again returns to where this was done. + */ + toggleReturn() { + const target = this.#returnPosition; + if (!target) { + return; + } + const location = this.#currentLocation; + this.#returnPosition = location ? this.#toPosition(location) : null; + + this.#goTo(target); + this.#render(); + this.#flash(target); + } + + /** + * Go to the previous (older) mark, or wrap around to the newest one. + */ + cycleBack() { + const count = this.#marks.length; + if (count) { + this.#goToMark( + this.#markIndex < 0 ? count - 1 : (this.#markIndex - 1 + count) % count + ); + } + } + + /** + * Go to the next (newer) mark, or wrap around to the oldest one. + */ + cycleForward() { + const count = this.#marks.length; + if (count) { + this.#goToMark((this.#markIndex + 1) % count); + } + } + + #goToMark(index) { + const mark = this.#marks[index]; + if (!mark) { + return; + } + this.#markIndex = index; + this.#goTo(mark); + this.#render(); + this.#flash(mark); + } + + #toPosition({ pageNumber, left, top, text, textTop, textLeft, line }) { + const position = { + pageNumber, + left: Math.round(left), + top: Math.round(top), + text: text ?? null, + // The bounds of the line, in PDF units: [x1, y1, x2, y2]. + line: line ?? null, + }; + if (position.text === null || position.line === null) { + const ready = this.#getLine( + pageNumber, + textTop ?? top, + textLeft ?? null + ).then(({ text: lineText, line: lineRect }) => { + position.text ??= lineText; + position.line = lineRect; + this.#render(); + this.#save(); + }); + // Not enumerable, such that it's not saved along with the position. + Object.defineProperty(position, "ready", { value: ready }); + } + return position; + } + + /** + * Get the text, and bounds, of the first line at, or below, `top` (in PDF + * units). + * @returns {Promise<{text: string, line: Array|undefined}>} + */ + async #getLine(pageNumber, top, x = null) { + try { + const page = await this.pdfViewer.pdfDocument.getPage(pageNumber); + const { items } = await page.getTextContent(); + // When `x` is known, only consider the text at that horizontal position, + // since the lines of different columns are often at similar heights. + const isAtX = ({ transform, width }) => + x === null || (transform[4] <= x + 1 && transform[4] + width >= x - 1); + + // Find the line: the nearest baseline at, or below, `top`. When guessing + // (i.e. without `x`), ignore lone characters such as footnote markers; + // otherwise a single selected character (e.g. in an equation) is fine. + let lineY = -Infinity, + lineHeight = 10; + for (const item of items) { + const y = item.transform[5]; + const length = item.str.trim().length; + if ( + length > (x === null ? 1 : 0) && + y <= top && + y > lineY && + isAtX(item) + ) { + lineY = y; + lineHeight = item.height || 10; + } + } + // Find the column at `x` (or at the start of the line), such that the + // lines of neighbouring columns aren't merged. Count, for each position, + // how many nearby text items cover it: a gutter between columns is + // covered much less than the column itself (only by e.g. a title over + // all of them), whereas the gaps within e.g. an equation are covered by + // the lines of the paragraphs around it. + const nearbyItems = items.filter( + ({ transform, width }) => + width > 0 && Math.abs(transform[5] - lineY) < 8 * lineHeight + ); + const minX = Math.floor( + Math.min(...nearbyItems.map(({ transform }) => transform[4])) + ); + const maxX = Math.ceil( + Math.max(...nearbyItems.map(i => i.transform[4] + i.width)) + ); + const coverage = new Uint16Array(Math.max(maxX - minX + 1, 1)); + for (const { transform, width } of nearbyItems) { + const from = Math.floor(transform[4]) - minX; + const to = Math.ceil(transform[4] + width) - minX; + for (let i = from; i < to; i++) { + coverage[i]++; + } + } + const startX = + x ?? + Math.min( + ...items + .filter(({ transform }) => Math.abs(transform[5] - lineY) < 2) + .map(({ transform }) => transform[4]) + ); + const at = MathClamp(Math.round(startX) - minX, 0, maxX - minX); + const threshold = 0.4 * coverage[at]; + let colStart = at, + colEnd = at; + while (colStart > 0 && coverage[colStart - 1] > threshold) { + colStart--; + } + while (colEnd < maxX - minX && coverage[colEnd + 1] > threshold) { + colEnd++; + } + colStart += minX; + colEnd += minX; + + // Besides the text on the same baseline, include what's slightly above + // or below it: super/subscripts, fractions, operators of equations... + const lineRun = items + .filter(({ str, transform, width }) => { + const dy = transform[5] - lineY; + const middle = transform[4] + width / 2; + return ( + middle >= colStart - 1 && + middle <= colEnd + 1 && + (Math.abs(dy) < 2 || + (str.trim() && dy > -0.6 * lineHeight && dy < 0.8 * lineHeight)) + ); + }) + .sort((a, b) => a.transform[4] - b.transform[4]); + + const parts = []; + let x1 = Infinity, + y1 = Infinity, + x2 = -Infinity, + y2 = -Infinity; + for (const { str, transform, width, height } of lineRun) { + parts.push(str); + // Cover the descenders below, and the ascenders above, the baseline. + const h = height || lineHeight; + x1 = Math.min(x1, transform[4]); + x2 = Math.max(x2, transform[4] + width); + y1 = Math.min(y1, transform[5] - 0.3 * h); + y2 = Math.max(y2, transform[5] + 0.95 * h); + } + const text = parts.join(" ").replaceAll(/\s+/g, " ").trim(); + const line = parts.length + ? [x1 - 2, y1, x2 + 2, y2].map(Math.round) + : undefined; + return { + text: + text.length > MAX_TEXT_LENGTH + ? `${text.slice(0, MAX_TEXT_LENGTH - 1)}…` + : text, + line, + }; + } catch { + return { text: "", line: undefined }; + } + } + + /** + * Draw `position`'s line on its page, if that page is currently rendered. + * @returns {HTMLDivElement|null} + */ + #drawLine(position, className) { + const pageView = this.pdfViewer.getPageView(position.pageNumber - 1); + if (!position.line || !pageView?.div) { + return null; + } + const { viewport } = pageView; + const [px1, py1, px2, py2] = position.line; + const [x1, y1] = viewport.convertToViewportPoint(px1, py1); + const [x2, y2] = viewport.convertToViewportPoint(px2, py2); + const div = document.createElement("div"); + div.className = className; + div.setAttribute("aria-hidden", "true"); + // Percentages keep it in place while zooming, before the page re-renders. + const { width, height } = viewport; + Object.assign(div.style, { + left: `${(100 * Math.min(x1, x2)) / width}%`, + top: `${(100 * Math.min(y1, y2)) / height}%`, + width: `${(100 * Math.abs(x2 - x1)) / width}%`, + height: `${(100 * Math.abs(y2 - y1)) / height}%`, + }); + pageView.div.append(div); + return div; + } + + /** + * Highlight the marked lines; on the given page only, or on all pages. + * @param {number} [pageNumber] + */ + #renderHighlights(pageNumber = 0) { + const selector = pageNumber + ? `.page[data-page-number="${pageNumber}"] .jumpHistoryHighlight` + : ".jumpHistoryHighlight"; + for (const div of this.pdfViewer.viewer.querySelectorAll(selector)) { + div.remove(); + } + if (!this.#enabled) { + return; + } + for (const mark of this.#marks) { + if (!pageNumber || mark.pageNumber === pageNumber) { + this.#drawLine(mark, "jumpHistoryHighlight"); + } + } + } + + /** + * Briefly flash the line of `position`, to make it easy to spot. + */ + async #flash(position) { + await position.ready; + // Wait for the page to be scrolled into view, and rendered if it wasn't + // already since that removes anything drawn on it. + await new Promise(resolve => { + setTimeout(resolve, 50); + }); + const pageView = this.pdfViewer.getPageView(position.pageNumber - 1); + if (pageView && pageView.renderingState !== RenderingStates.FINISHED) { + await new Promise(resolve => { + const ac = new AbortController(); + const done = () => { + ac.abort(); + resolve(); + }; + this.eventBus.on( + "pagerendered", + evt => evt.pageNumber === position.pageNumber && done(), + { signal: ac.signal, ...internalOpt } + ); + setTimeout(done, 2000); + }); + } + const div = this.#drawLine(position, "jumpHistoryFlash"); + if (div) { + setTimeout(() => div.remove(), FLASH_DURATION); + } + } + + togglePanel() { + if (this.isPanelOpen) { + this.closePanel(); + } else { + this.openPanel(); + } + } + + openPanel() { + if (this.isPanelOpen || this.container.hidden) { + return; + } + this.panel.hidden = false; + this.listButton.setAttribute("aria-expanded", "true"); + this.#renderPanel(); + + this.#panelAbortController = new AbortController(); + const { signal } = this.#panelAbortController; + window.addEventListener( + "pointerdown", + evt => { + if ( + !this.panel.contains(evt.target) && + !this.listButton.contains(evt.target) + ) { + this.closePanel(); + } + }, + { signal } + ); + window.addEventListener( + "keydown", + evt => { + if (evt.key === "Escape") { + this.closePanel(); + this.listButton.focus(); + evt.preventDefault(); + evt.stopPropagation(); + } + }, + { signal, capture: true } + ); + this.panel.querySelector("[aria-current] button, button")?.focus(); + } + + #resetClearButton(button) { + clearTimeout(this.#clearTimeout); + this.#clearTimeout = null; + button.classList.remove("confirm"); + button.setAttribute("data-l10n-id", "pdfjs-jump-history-clear-button"); + } + + closePanel() { + this.#panelAbortController?.abort(); + this.#panelAbortController = null; + this.panel.hidden = true; + this.listButton.setAttribute("aria-expanded", "false"); + } + + #renderPanel() { + if (!this.isPanelOpen) { + return; + } + if (!this.#marks.length) { + const li = document.createElement("li"); + li.className = "empty"; + li.setAttribute("data-l10n-id", "pdfjs-jump-history-entry-empty"); + this.entries.replaceChildren(li); + return; + } + const items = this.#marks.map((mark, index) => { + const li = document.createElement("li"); + if (index === this.#markIndex) { + li.setAttribute("aria-current", "true"); + } + + const button = document.createElement("button"); + button.type = "button"; + + const page = document.createElement("span"); + page.className = "jumpHistoryEntryPage"; + page.setAttribute("data-l10n-id", "pdfjs-jump-history-entry-page"); + page.setAttribute( + "data-l10n-args", + JSON.stringify({ page: mark.pageNumber }) + ); + + const text = document.createElement("span"); + text.className = "jumpHistoryEntryText"; + if (mark.text) { + text.textContent = mark.text; + } else { + text.setAttribute( + "data-l10n-id", + mark.text === null + ? "pdfjs-jump-history-entry-loading" + : "pdfjs-jump-history-entry-no-text" + ); + } + button.append(page, text); + button.addEventListener("click", () => { + this.closePanel(); + this.#goToMark(index); + }); + li.append(button); + return li; + }); + this.entries.replaceChildren(...items); + } + + /** + * Download all the marks, oldest first, as a JSON file. + */ + saveToFile() { + const marks = this.#marks.map((mark, i) => ({ + order: i + 1, + page: mark.pageNumber, + left: mark.left, + top: mark.top, + text: mark.text || "", + })); + const data = { + document: this.#filename, + fingerprint: this.pdfViewer.pdfDocument?.fingerprints[0] ?? null, + savedAt: new Date().toISOString(), + marks, + }; + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${this.#filename.replace(/\.pdf$/i, "")}-marks.json`; + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); + } + + #goTo({ pageNumber, left, top }) { + // Not using the link service, hence this isn't a navigation to return + // from. + this.pdfViewer.scrollPageIntoView({ + pageNumber, + destArray: [null, { name: "XYZ" }, left, top, null], + allowNegativeOffset: true, + ignoreDestinationZoom: true, + }); + this.pdfViewer.focus(); + } + + /** + * Mark the position at the top of the view. + */ + markCurrentPosition() { + const location = this.#currentLocation; + if (location) { + this.#addMark({ ...location, text: null }); + } + } + + /** + * Mark the line at the given height of a page. + * @param {number} pageNumber + * @param {number} top - The top of the line, in PDF units. + * @param {number} textTop - Somewhere within the line (e.g. its middle), + * which must be above its baseline, in PDF units. + * @param {number} [textLeft] - A horizontal position within the line, in + * PDF units; used to find the right column in multi-column layouts. + */ + markAt(pageNumber, top, textTop, textLeft = null) { + const location = this.#currentLocation; + if (!location || !Number.isInteger(pageNumber)) { + return; + } + // Don't go above the page, otherwise the end of the previous one would be + // shown when returning to the mark. + const pageTop = + this.pdfViewer.getPageView(pageNumber - 1)?.pdfPage?.view[3] ?? Infinity; + this.#addMark({ + pageNumber, + left: location.left, + top: Math.min(top + MARK_MARGIN, pageTop), + textTop, + textLeft, + text: null, + }); + } + + #addMark(location) { + const mark = this.#toPosition(location); + // Marking the same line again moves it to the end, rather than adding it + // twice. + this.#marks = this.#marks.filter( + ({ pageNumber, top }) => + pageNumber !== mark.pageNumber || + Math.abs(top - mark.top) > SAME_LINE_DISTANCE + ); + this.#marks.push(mark); + this.#markIndex = this.#marks.length - 1; + this.#trim(); + this.#render(); + this.#save(); + this.#flash(mark); + } + + /** + * Mark the line of the selected text, from the floating toolbar shown when + * selecting text. + */ + markSelection() { + const selection = document.getSelection(); + if (!selection || selection.isCollapsed) { + return; + } + const range = selection.getRangeAt(0); + const node = range.startContainer; + const pageDiv = ( + node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement + )?.closest(".page"); + const rect = [...range.getClientRects()].find(r => r.width && r.height); + if (!pageDiv || !rect) { + return; + } + const pageNumber = parseInt(pageDiv.getAttribute("data-page-number"), 10); + const pageView = this.pdfViewer.getPageView(pageNumber - 1); + if (!pageView) { + return; + } + // Relative to the content of the page, i.e. without its border. + const { div } = pageView; + const pageRect = div.getBoundingClientRect(); + const originX = pageRect.left + div.clientLeft, + originY = pageRect.top + div.clientTop; + const [, top] = pageView.getPagePoint( + rect.left - originX, + rect.top - originY + ); + const [left, middle] = pageView.getPagePoint( + rect.left + 2 - originX, + rect.top + rect.height / 2 - originY + ); + // Remove the selection, which hides the floating toolbar. + selection.removeAllRanges(); + this.markAt(pageNumber, top, middle, left); + } + + /** + * Remove all the marks of the document. + */ + clearAll() { + this.#marks.length = 0; + this.#markIndex = -1; + this.#render(); + this.#save(); + } + + #onBeforeNavigate() { + if (!this.#enabled) { + return; + } + // Also when staying on the same page, e.g. a link to a section further + // down, since that's a jump as well. + const location = this.#currentLocation; + if (location) { + this.#returnPosition = this.#toPosition(location); + this.#render(); + } + } + + /** + * Drop the oldest marks when there are more than the user wants. + */ + #trim() { + const excess = this.#marks.length - this.#maxEntries; + if (excess > 0) { + this.#marks.splice(0, excess); + this.#markIndex = Math.max(this.#markIndex - excess, -1); + } + } + + #save() { + this.#store + ?.setMultiple({ + jumpHistoryMarks: this.#marks, + }) + .catch(() => { + // Unable to write to storage. + }); + } + + #render() { + const target = this.#returnPosition; + + // Always shown once a document is loaded, even before any navigation. + this.container.hidden = !this.#enabled; + if (this.container.hidden) { + this.closePanel(); + } + this.#renderPanel(); + this.#renderHighlights(); + + this.backButton.disabled = !target; + this.backButton.querySelector(".jumpHistoryPage").textContent = + target?.pageNumber ?? ""; + if (target) { + this.backButton.setAttribute( + "data-l10n-id", + "pdfjs-jump-history-back-button" + ); + this.backButton.setAttribute( + "data-l10n-args", + JSON.stringify({ page: target.pageNumber }) + ); + } else { + this.backButton.setAttribute( + "data-l10n-id", + "pdfjs-jump-history-back-button-empty" + ); + this.backButton.removeAttribute("data-l10n-args"); + } + } +} + +export { PDFJumpHistory }; diff --git a/web/pdf_link_service.js b/web/pdf_link_service.js index 1e091aa78a2fc..17292679cec4a 100644 --- a/web/pdf_link_service.js +++ b/web/pdf_link_service.js @@ -188,6 +188,7 @@ class PDFLinkService { this.pdfHistory.pushCurrentPosition(); this.pdfHistory.push({ namedDest, explicitDest, pageNumber }); } + this.eventBus?.dispatch("beforenavigate", { source: this, pageNumber }); this.pdfViewer.scrollPageIntoView({ pageNumber, @@ -234,6 +235,7 @@ class PDFLinkService { this.pdfHistory.pushCurrentPosition(); this.pdfHistory.pushPage(pageNumber); } + this.eventBus?.dispatch("beforenavigate", { source: this, pageNumber }); this.pdfViewer.scrollPageIntoView({ pageNumber }); } diff --git a/web/viewer.css b/web/viewer.css index f18d4b8435b59..3e4d664dda070 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -377,6 +377,233 @@ body { transition-timing-function: var(--sidebar-transition-timing-function); } +#jumpHistory { + position: absolute; + inset-block-end: 24px; + inset-inline-end: 32px; + z-index: 1; + display: flex; + gap: 1px; + border: 1px solid var(--doorhanger-border-color); + border-radius: 18px; + background-color: var(--doorhanger-bg-color); + box-shadow: 0 2px 8px rgb(0 0 0 / 0.2); + overflow: hidden; + + &[hidden] { + display: none; + } + + > .jumpHistoryButton { + display: flex; + align-items: center; + gap: 6px; + height: 36px; + min-width: 36px; + padding-inline: 10px; + border: none; + background: none; + color: var(--main-color); + font: message-box; + font-size: 13px; + font-variant-numeric: tabular-nums; + cursor: pointer; + + &[hidden] { + display: none; + } + + &:hover:not(:disabled) { + background-color: var(--button-hover-color); + } + + &:disabled { + opacity: 0.4; + cursor: default; + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color, Highlight); + outline-offset: -2px; + } + + > svg { + flex-shrink: 0; + } + } +} + +#jumpHistory > #jumpHistoryList { + border-inline-start: 1px solid var(--doorhanger-border-color); +} + +#jumpHistoryPanel { + position: absolute; + inset-block-end: 70px; + inset-inline-end: 32px; + z-index: 1; + display: flex; + flex-direction: column; + width: min(380px, calc(100% - 64px)); + max-height: min(420px, calc(100% - var(--toolbar-height) - 100px)); + border: 1px solid var(--doorhanger-border-color); + border-radius: 8px; + background-color: var(--doorhanger-bg-color); + box-shadow: 0 4px 16px rgb(0 0 0 / 0.25); + color: var(--main-color); + font: message-box; + font-size: 13px; + + &[hidden] { + display: none; + } + + .jumpHistoryPanelActions { + display: flex; + flex-shrink: 0; + gap: 4px; + + > button { + white-space: nowrap; + } + } + + #jumpHistoryClear.confirm { + color: light-dark(rgb(197 0 66), rgb(255 132 128)); + } + + > .jumpHistoryPanelHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 8px 8px 12px; + border-bottom: 1px solid var(--doorhanger-border-color); + font-weight: 600; + } + + > .jumpHistoryPanelFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 8px 8px 12px; + border-top: 1px solid var(--doorhanger-border-color); + + > select { + padding: 2px 4px; + border: 1px solid var(--doorhanger-border-color); + border-radius: 4px; + background-color: var(--field-bg-color); + color: inherit; + font: inherit; + } + } + + > #jumpHistoryEntries { + margin: 0; + padding: 4px; + overflow-y: auto; + list-style: none; + + > li > button { + display: grid; + grid-template-columns: auto 1fr; + column-gap: 8px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: 4px; + background: none; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; + + &:hover { + background-color: var(--button-hover-color); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color, Highlight); + outline-offset: -2px; + } + } + + .jumpHistoryEntryPage { + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .jumpHistoryEntryText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.8; + } + + > li.empty { + padding: 6px 8px; + opacity: 0.7; + } + + > li[aria-current] > button { + background-color: var(--button-hover-color); + } + } +} + +.pdfViewer .page > :is(.jumpHistoryHighlight, .jumpHistoryFlash) { + --jump-history-highlight-color: rgb(255 212 0 / 0.4); + --jump-history-highlight-accent: rgb(234 128 0); + + position: absolute; + border-radius: 2px; + background-color: var(--jump-history-highlight-color); + /* Keep the text readable, and selectable, below the highlight. */ + mix-blend-mode: multiply; + pointer-events: none; +} + +.pdfViewer .page > .jumpHistoryHighlight { + box-shadow: -3px 0 0 var(--jump-history-highlight-accent); +} + +.pdfViewer .page > .jumpHistoryFlash { + --jump-history-highlight-color: rgb(255 170 0 / 0.55); + + animation: jumpHistoryFlash 1.6s ease-out forwards; +} + +@keyframes jumpHistoryFlash { + 0%, + 40% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .pdfViewer .page > .jumpHistoryFlash { + animation: none; + } +} + +@media (forced-colors: active) { + .pdfViewer .page > :is(.jumpHistoryHighlight, .jumpHistoryFlash) { + mix-blend-mode: normal; + background-color: transparent; + outline: 2px solid Highlight; + } +} + +#viewerContainer.pdfPresentationMode ~ :is(#jumpHistory, #jumpHistoryPanel) { + display: none; +} + /*#if MOZCENTRAL*/ #viewerContainer:not(.pdfPresentationMode) { inset-block-start: calc(var(--toolbar-height) + var(--pfn-bar-height, 0px)); diff --git a/web/viewer.html b/web/viewer.html index 012ff1a5d90b7..42cad0e6d7bcf 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -889,6 +889,31 @@
+ + + + diff --git a/web/viewer.js b/web/viewer.js index 5534294bb691c..ed58d40a1af89 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -346,6 +346,16 @@ function getViewerConfiguration() { editorHighlightShowAll: document.getElementById("editorHighlightShowAll"), }, printContainer: document.getElementById("printContainer"), + jumpHistory: { + container: document.getElementById("jumpHistory"), + backButton: document.getElementById("jumpHistoryBack"), + listButton: document.getElementById("jumpHistoryList"), + panel: document.getElementById("jumpHistoryPanel"), + entries: document.getElementById("jumpHistoryEntries"), + saveButton: document.getElementById("jumpHistorySave"), + maxEntriesSelect: document.getElementById("jumpHistoryMaxEntries"), + clearButton: document.getElementById("jumpHistoryClear"), + }, editorUndoBar: { container: document.getElementById("editorUndoBar"), message: document.getElementById("editorUndoBarMessage"),