diff --git a/test/unit/ui_utils_spec.js b/test/unit/ui_utils_spec.js index 0ead190432d90..41af41e542030 100644 --- a/test/unit/ui_utils_spec.js +++ b/test/unit/ui_utils_spec.js @@ -279,12 +279,11 @@ describe("ui_utils", function () { viewTop < scrollBottom && viewBottom > scrollTop ) { - const hiddenHeight = - Math.max(0, scrollTop - viewTop) + - Math.max(0, viewBottom - scrollBottom); - const hiddenWidth = - Math.max(0, scrollLeft - viewLeft) + - Math.max(0, viewRight - scrollRight); + const minY = Math.max(0, scrollTop - viewTop); + const minX = Math.max(0, scrollLeft - viewLeft); + + const hiddenHeight = minY + Math.max(0, viewBottom - scrollBottom); + const hiddenWidth = minX + Math.max(0, viewRight - scrollRight); const fractionHeight = (div.clientHeight - hiddenHeight) / div.clientHeight; @@ -292,12 +291,23 @@ describe("ui_utils", function () { (div.clientWidth - hiddenWidth) / div.clientWidth; const percent = (fractionHeight * fractionWidth * 100) | 0; + let visibleArea = null; + if (percent < 100) { + visibleArea = { + minX, + minY, + maxX: Math.min(viewRight, scrollRight) - viewLeft, + maxY: Math.min(viewBottom, scrollBottom) - viewTop, + }; + } + views.push({ id: view.id, x: viewLeft, y: viewTop, view, percent, + visibleArea, widthPercent: (fractionWidth * 100) | 0, }); ids.add(view.id); diff --git a/web/app.js b/web/app.js index 5ea10fcfde157..e8ecf094b44b1 100644 --- a/web/app.js +++ b/web/app.js @@ -2280,18 +2280,18 @@ if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { }; } -function onPageRender({ pageNumber }) { +function onPageRender({ pageNumber, source }) { // If the page is (the most) visible when it starts rendering, // ensure that the page number input loading indicator is displayed. - if (pageNumber === this.page) { + if (pageNumber === this.page && !source.detailView) { this.toolbar?.updateLoadingIndicatorState(true); } } -function onPageRendered({ pageNumber, error }) { +function onPageRendered({ pageNumber, source, error }) { // If the page is still visible when it has finished rendering, // ensure that the page number input loading indicator is hidden. - if (pageNumber === this.page) { + if (pageNumber === this.page && !source.detailView) { this.toolbar?.updateLoadingIndicatorState(false); } diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 13b18e433c26f..fc12f7da00b2f 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -52,6 +52,10 @@ import { TextHighlighter } from "./text_highlighter.js"; import { TextLayerBuilder } from "./text_layer_builder.js"; import { XfaLayerBuilder } from "./xfa_layer_builder.js"; +// TODO: Should we make this an app option, or just always +// enable the feature? +const ENABLE_ZOOM_DETAIL = true; + /** * @typedef {Object} PDFPageViewOptions * @property {HTMLDivElement} [container] - The viewer element. @@ -104,31 +108,256 @@ const DEFAULT_LAYER_PROPERTIES = const LAYERS_ORDER = new Map([ ["canvasWrapper", 0], - ["textLayer", 1], - ["annotationLayer", 2], - ["annotationEditorLayer", 3], - ["xfaLayer", 3], + ["detailLayer", 1], + ["textLayer", 2], + ["annotationLayer", 3], + ["annotationEditorLayer", 4], + ["xfaLayer", 4], ]); +class PDFPageViewBase { + #enableHWA = false; + + #loadingId = null; + + #renderError = null; + + #renderingState = RenderingStates.INITIAL; + + #showCanvas = null; + + canvas = null; + + canvasWrapper = null; + + /** @type {null | HTMLDivElement} */ + div = null; + + eventBus = null; + + id = null; + + pageColors = null; + + renderingQueue = null; + + renderTask = null; + + resume = null; + + constructor(options) { + this.#enableHWA = + #enableHWA in options ? options.#enableHWA : options.enableHWA || false; + this.eventBus = options.eventBus; + this.id = options.id; + this.pageColors = options.pageColors || null; + this.renderingQueue = options.renderingQueue; + } + + get renderingState() { + return this.#renderingState; + } + + set renderingState(state) { + if (state === this.#renderingState) { + return; + } + this.#renderingState = state; + + if (this.#loadingId) { + clearTimeout(this.#loadingId); + this.#loadingId = null; + } + + switch (state) { + case RenderingStates.PAUSED: + this.div.classList.remove("loading"); + break; + case RenderingStates.RUNNING: + this.div.classList.add("loadingIcon"); + this.#loadingId = setTimeout(() => { + // Adding the loading class is slightly postponed in order to not have + // it with loadingIcon. + // If we don't do that the visibility of the background is changed but + // the transition isn't triggered. + this.div.classList.add("loading"); + this.#loadingId = null; + }, 0); + break; + case RenderingStates.INITIAL: + case RenderingStates.FINISHED: + this.div.classList.remove("loadingIcon", "loading"); + break; + } + } + + get _renderError() { + return this.#renderError; + } + + _ensureCanvasWrapper(onCreate) { + // Wrap the canvas so that if it has a CSS transform for high DPI the + // overflow will be hidden in Firefox. + let canvasWrapper = this.canvasWrapper; + if (!canvasWrapper) { + canvasWrapper = this.canvasWrapper = document.createElement("div"); + canvasWrapper.classList.add("canvasWrapper"); + onCreate(canvasWrapper); + } + return canvasWrapper; + } + + _createCanvas(hideUntilComplete = false) { + const { canvasWrapper, pageColors } = this; + + const hasHCM = !!(pageColors?.background && pageColors?.foreground); + const prevCanvas = this.canvas; + const updateOnFirstShow = !prevCanvas && !hasHCM && !hideUntilComplete; + + const canvas = document.createElement("canvas"); + canvas.setAttribute("role", "presentation"); + this.canvas = canvas; + + this.#showCanvas = isLastShow => { + if (updateOnFirstShow) { + // Don't add the canvas until the first draw callback, or until + // drawing is complete when `!this.renderingQueue`, to prevent black + // flickering. + canvasWrapper.append(canvas); + this.#showCanvas = null; + return; + } + if (!isLastShow) { + return; + } + + if (prevCanvas) { + prevCanvas.replaceWith(canvas); + prevCanvas.width = prevCanvas.height = 0; + } else { + // In HCM, a final filter is applied on the canvas which means that + // before it's applied we've normal colors. Consequently, to avoid to + // have a final flash we just display it once all the drawing is done. + canvasWrapper.append(canvas); + } + }; + + const ctx = canvas.getContext("2d", { + alpha: false, + willReadFrequently: !this.#enableHWA, + }); + + return { canvas, prevCanvas, ctx }; + } + + #renderContinueCallback = cont => { + this.#showCanvas?.(false); + if (this.renderingQueue && !this.renderingQueue.isHighestPriority(this)) { + this.renderingState = RenderingStates.PAUSED; + this.resume = () => { + this.renderingState = RenderingStates.RUNNING; + cont(); + }; + return; + } + cont(); + }; + + _resetCanvas() { + const { canvas } = this; + if (!canvas) { + return; + } + canvas.remove(); + canvas.width = canvas.height = 0; + this.canvas = null; + } + + async _drawCanvas(options, prevCanvas, onFinish) { + const renderTask = (this.renderTask = this.pdfPage.render(options)); + renderTask.onContinue = this.#renderContinueCallback; + + try { + await renderTask.promise; + this.#showCanvas?.(true); + this.#finishRenderTask(renderTask, null, onFinish); + } catch (error) { + // When zooming with a `drawingDelay` set, avoid temporarily showing + // a black canvas if rendering was cancelled before the `onContinue`- + // callback had been invoked at least once. + if (!(error instanceof RenderingCancelledException)) { + this.#showCanvas?.(true); + } else { + prevCanvas?.remove(); + this._resetCanvas(); + } + this.#finishRenderTask(renderTask, error, onFinish); + } + } + + async #finishRenderTask(renderTask, error, onFinish) { + // The renderTask may have been replaced by a new one, so only remove + // the reference to the renderTask if it matches the one that is + // triggering this callback. + if (renderTask === this.renderTask) { + this.renderTask = null; + } + + if (error instanceof RenderingCancelledException) { + this.#renderError = null; + return; + } + this.#renderError = error; + + this.renderingState = RenderingStates.FINISHED; + + onFinish?.(renderTask); + + this.eventBus.dispatch("pagerendered", { + source: this, + pageNumber: this.id, + cssTransform: false, + timestamp: performance.now(), + error: this.#renderError, + }); + + if (error) { + throw error; + } + } + + cancelRendering({ cancelExtraDelay = 0 } = {}) { + if (this.renderTask) { + this.renderTask.cancel(cancelExtraDelay); + this.renderTask = null; + } + this.resume = null; + } +} + +/** + * @typedef {Object} PDFPageViewUpdateParameters + * @property {number} [scale] The new scale, if specified. + * @property {number} [rotation] The new rotation, if specified. + * @property {Promise} [optionalContentConfigPromise] + * A promise that is resolved with an {@link OptionalContentConfig} + * instance. The default value is `null`. + * @property {number} [drawingDelay] + */ + /** * @implements {IRenderableView} */ -class PDFPageView { +class PDFPageView extends PDFPageViewBase { #annotationMode = AnnotationMode.ENABLE_FORMS; - #canvasWrapper = null; - - #enableHWA = false; - #hasRestrictedScaling = false; #isEditing = false; #layerProperties = null; - #loadingId = null; - - #originalViewport = null; + _originalViewport = null; #previousRotation = null; @@ -136,10 +365,6 @@ class PDFPageView { #scaleRoundY = 1; - #renderError = null; - - #renderingState = RenderingStates.INITIAL; - #textLayerMode = TextLayerMode.ENABLE; #useThumbnailCanvas = { @@ -148,16 +373,19 @@ class PDFPageView { regularAnnotations: true, }; - #layers = [null, null, null, null]; + #originalViewport = null; + + #layers = [null, null, null, null, null]; /** * @param {PDFPageViewOptions} options */ constructor(options) { + super(options); + const container = options.container; const defaultViewport = options.defaultViewport; - this.id = options.id; this.renderingId = "page" + this.id; this.#layerProperties = options.layerProperties || DEFAULT_LAYER_PROPERTIES; @@ -175,18 +403,12 @@ class PDFPageView { this.imageResourcesPath = options.imageResourcesPath || ""; this.maxCanvasPixels = options.maxCanvasPixels ?? AppOptions.get("maxCanvasPixels"); - this.pageColors = options.pageColors || null; - this.#enableHWA = options.enableHWA || false; - this.eventBus = options.eventBus; - this.renderingQueue = options.renderingQueue; this.l10n = options.l10n; if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { this.l10n ||= new GenericL10n(); } - this.renderTask = null; - this.resume = null; if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { this._isStandalone = !this.renderingQueue?.hasViewer(); this._container = container; @@ -201,6 +423,8 @@ class PDFPageView { this.structTreeLayer = null; this.drawLayer = null; + this.detailView = null; + const div = document.createElement("div"); div.className = "page"; div.setAttribute("data-page-number", this.id); @@ -252,7 +476,7 @@ class PDFPageView { } } - #addLayer(div, name) { + _addLayer(div, name) { const pos = LAYERS_ORDER.get(name); const oldDiv = this.#layers[pos]; this.#layers[pos] = div; @@ -270,41 +494,8 @@ class PDFPageView { this.div.prepend(div); } - get renderingState() { - return this.#renderingState; - } - - set renderingState(state) { - if (state === this.#renderingState) { - return; - } - this.#renderingState = state; - - if (this.#loadingId) { - clearTimeout(this.#loadingId); - this.#loadingId = null; - } - - switch (state) { - case RenderingStates.PAUSED: - this.div.classList.remove("loading"); - break; - case RenderingStates.RUNNING: - this.div.classList.add("loadingIcon"); - this.#loadingId = setTimeout(() => { - // Adding the loading class is slightly postponed in order to not have - // it with loadingIcon. - // If we don't do that the visibility of the background is changed but - // the transition isn't triggered. - this.div.classList.add("loading"); - this.#loadingId = null; - }, 0); - break; - case RenderingStates.INITIAL: - case RenderingStates.FINISHED: - this.div.classList.remove("loadingIcon", "loading"); - break; - } + _forgetLayer(name) { + this.#layers[LAYERS_ORDER.get(name)] = null; } #setDimensions() { @@ -447,7 +638,7 @@ class PDFPageView { if (this.xfaLayer?.div) { // Pause translation when inserting the xfaLayer in the DOM. this.l10n.pause(); - this.#addLayer(this.xfaLayer.div, "xfaLayer"); + this._addLayer(this.xfaLayer.div, "xfaLayer"); this.l10n.resume(); } this.#dispatchLayerRendered("xfalayerrendered", error); @@ -509,14 +700,8 @@ class PDFPageView { this._textHighlighter.enable(); } - #resetCanvas() { - const { canvas } = this; - if (!canvas) { - return; - } - canvas.remove(); - canvas.width = canvas.height = 0; - this.canvas = null; + _resetCanvas() { + super._resetCanvas(); this.#originalViewport = null; } @@ -544,7 +729,9 @@ class PDFPageView { (keepAnnotationEditorLayer && this.annotationEditorLayer?.div) || null, xfaLayerNode = (keepXfaLayer && this.xfaLayer?.div) || null, textLayerNode = (keepTextLayer && this.textLayer?.div) || null, - canvasWrapperNode = (keepCanvasWrapper && this.#canvasWrapper) || null; + canvasWrapperNode = (keepCanvasWrapper && this.canvasWrapper) || null, + detailLayerNode = + (keepCanvasWrapper && this.detailView?.canvasWrapper) || null; for (let i = childNodes.length - 1; i >= 0; i--) { const node = childNodes[i]; switch (node) { @@ -553,6 +740,7 @@ class PDFPageView { case xfaLayerNode: case textLayerNode: case canvasWrapperNode: + case detailLayerNode: continue; } node.remove(); @@ -581,9 +769,13 @@ class PDFPageView { } this.structTreeLayer?.hide(); - if (!keepCanvasWrapper && this.#canvasWrapper) { - this.#canvasWrapper = null; - this.#resetCanvas(); + if (!keepCanvasWrapper && this.canvasWrapper) { + this.canvasWrapper = null; + this._resetCanvas(); + } + if (!keepCanvasWrapper && this.detailView?.canvasWrapper) { + this.detailView.canvasWrapper = null; + this.detailView._resetCanvas(); } } @@ -601,15 +793,15 @@ class PDFPageView { }); } - /** - * @typedef {Object} PDFPageViewUpdateParameters - * @property {number} [scale] The new scale, if specified. - * @property {number} [rotation] The new rotation, if specified. - * @property {Promise} [optionalContentConfigPromise] - * A promise that is resolved with an {@link OptionalContentConfig} - * instance. The default value is `null`. - * @property {number} [drawingDelay] - */ + updateVisibleArea(visibleArea) { + if (this.#hasRestrictedScaling && this.maxCanvasPixels > 0 && visibleArea) { + this.detailView ??= new PDFPageDetailView({ pageView: this }); + this.detailView.update({ visibleArea }); + } else if (this.detailView) { + this.detailView.reset(); + this.detailView = null; + } + } /** * Update e.g. the scale and/or rotation of the page. @@ -664,7 +856,7 @@ class PDFPageView { this.maxCanvasPixels === 0 ) { onlyCssZoom = true; - } else if (this.maxCanvasPixels > 0) { + } else if (!ENABLE_ZOOM_DETAIL && this.maxCanvasPixels > 0) { const { width, height } = this.viewport; const { sx, sy } = this.outputScale; onlyCssZoom = @@ -710,12 +902,15 @@ class PDFPageView { // rendering is done, hence don't dispatch it here as well. return; } + + this.detailView?.update({ underlyingViewUpdated: true }); + this.eventBus.dispatch("pagerendered", { source: this, pageNumber: this.id, cssTransform: true, timestamp: performance.now(), - error: this.#renderError, + error: this._renderError, }); return; } @@ -728,6 +923,8 @@ class PDFPageView { keepTextLayer: true, keepCanvasWrapper: true, }); + + this.detailView?.update({ underlyingViewUpdated: true }); } /** @@ -741,11 +938,7 @@ class PDFPageView { keepTextLayer = false, cancelExtraDelay = 0, } = {}) { - if (this.renderTask) { - this.renderTask.cancel(cancelExtraDelay); - this.renderTask = null; - } - this.resume = null; + super.cancelRendering({ cancelExtraDelay }); if (this.textLayer && (!keepTextLayer || !this.textLayer.div)) { this.textLayer.cancel(); @@ -844,39 +1037,6 @@ class PDFPageView { return this.viewport.convertToPdfPoint(x, y); } - async #finishRenderTask(renderTask, error = null) { - // The renderTask may have been replaced by a new one, so only remove - // the reference to the renderTask if it matches the one that is - // triggering this callback. - if (renderTask === this.renderTask) { - this.renderTask = null; - } - - if (error instanceof RenderingCancelledException) { - this.#renderError = null; - return; - } - this.#renderError = error; - - this.renderingState = RenderingStates.FINISHED; - - // Ensure that the thumbnails won't become partially (or fully) blank, - // for documents that contain interactive form elements. - this.#useThumbnailCanvas.regularAnnotations = !renderTask.separateAnnots; - - this.eventBus.dispatch("pagerendered", { - source: this, - pageNumber: this.id, - cssTransform: false, - timestamp: performance.now(), - error: this.#renderError, - }); - - if (error) { - throw error; - } - } - async draw() { if (this.renderingState !== RenderingStates.INITIAL) { console.error("Must be in new state before drawing"); @@ -891,14 +1051,9 @@ class PDFPageView { this.renderingState = RenderingStates.RUNNING; - // Wrap the canvas so that if it has a CSS transform for high DPI the - // overflow will be hidden in Firefox. - let canvasWrapper = this.#canvasWrapper; - if (!canvasWrapper) { - canvasWrapper = this.#canvasWrapper = document.createElement("div"); - canvasWrapper.classList.add("canvasWrapper"); - this.#addLayer(canvasWrapper, "canvasWrapper"); - } + const canvasWrapper = this._ensureCanvasWrapper(cw => { + this._addLayer(cw, "canvasWrapper"); + }); if ( !this.textLayer && @@ -916,7 +1071,7 @@ class PDFPageView { onAppend: textLayerDiv => { // Pause translation when inserting the textLayer in the DOM. this.l10n.pause(); - this.#addLayer(textLayerDiv, "textLayer"); + this._addLayer(textLayerDiv, "textLayer"); this.l10n.resume(); }, }); @@ -951,64 +1106,16 @@ class PDFPageView { accessibilityManager: this._accessibilityManager, annotationEditorUIManager, onAppend: annotationLayerDiv => { - this.#addLayer(annotationLayerDiv, "annotationLayer"); + this._addLayer(annotationLayerDiv, "annotationLayer"); }, }); } - const renderContinueCallback = cont => { - showCanvas?.(false); - if (this.renderingQueue && !this.renderingQueue.isHighestPriority(this)) { - this.renderingState = RenderingStates.PAUSED; - this.resume = () => { - this.renderingState = RenderingStates.RUNNING; - cont(); - }; - return; - } - cont(); - }; - const { width, height } = viewport; - const canvas = document.createElement("canvas"); - canvas.setAttribute("role", "presentation"); - - const hasHCM = !!(pageColors?.background && pageColors?.foreground); - const prevCanvas = this.canvas; - const updateOnFirstShow = !prevCanvas && !hasHCM; - this.canvas = canvas; this.#originalViewport = viewport; - let showCanvas = isLastShow => { - if (updateOnFirstShow) { - // Don't add the canvas until the first draw callback, or until - // drawing is complete when `!this.renderingQueue`, to prevent black - // flickering. - canvasWrapper.append(canvas); - showCanvas = null; - return; - } - if (!isLastShow) { - return; - } - - if (prevCanvas) { - prevCanvas.replaceWith(canvas); - prevCanvas.width = prevCanvas.height = 0; - } else { - // In HCM, a final filter is applied on the canvas which means that - // before it's applied we've normal colors. Consequently, to avoid to - // have a final flash we just display it once all the drawing is done. - canvasWrapper.append(canvas); - } - - showCanvas = null; - }; + const { canvas, prevCanvas, ctx } = this._createCanvas(); - const ctx = canvas.getContext("2d", { - alpha: false, - willReadFrequently: !this.#enableHWA, - }); const outputScale = (this.outputScale = new OutputScale()); if ( @@ -1071,14 +1178,17 @@ class PDFPageView { pageColors, isEditing: this.#isEditing, }; - const renderTask = (this.renderTask = pdfPage.render(renderContext)); - renderTask.onContinue = renderContinueCallback; - - const resultPromise = renderTask.promise.then( + const resultPromise = this._drawCanvas( + renderContext, + prevCanvas, + renderTask => { + // Ensure that the thumbnails won't become partially (or fully) blank, + // for documents that contain interactive form elements. + this.#useThumbnailCanvas.regularAnnotations = + !renderTask.separateAnnots; + } + ).then( async () => { - showCanvas?.(true); - await this.#finishRenderTask(renderTask); - this.structTreeLayer ||= new StructTreeLayerBuilder( pdfPage, viewport.rawDims @@ -1111,22 +1221,14 @@ class PDFPageView { textLayer: this.textLayer, drawLayer: this.drawLayer.getDrawLayer(), onAppend: annotationEditorLayerDiv => { - this.#addLayer(annotationEditorLayerDiv, "annotationEditorLayer"); + this._addLayer(annotationEditorLayerDiv, "annotationEditorLayer"); }, }); this.#renderAnnotationEditorLayer(); }, - error => { - // When zooming with a `drawingDelay` set, avoid temporarily showing - // a black canvas if rendering was cancelled before the `onContinue`- - // callback had been invoked at least once. - if (!(error instanceof RenderingCancelledException)) { - showCanvas?.(true); - } else { - prevCanvas?.remove(); - this.#resetCanvas(); - } - return this.#finishRenderTask(renderTask, error); + // TODO: To minimize diff due to autoformatting. Remove before merging. + e => { + throw e; } ); @@ -1183,4 +1285,184 @@ class PDFPageView { } } -export { PDFPageView }; +/** + * @implements {IRenderableView} + */ +class PDFPageDetailView extends PDFPageViewBase { + constructor({ pageView }) { + super(pageView); + + this.pageView = pageView; + this.renderingId = "detail" + this.id; + + this.detailArea = null; + + this.div = pageView.div; + } + + setPdfPage(pdfPage) { + this.pageView.setPdfPage(pdfPage); + } + + get pdfPage() { + return this.pageView.pdfPage; + } + + reset({ keepCanvasWrapper = false } = {}) { + this.cancelRendering(); + this.renderingState = RenderingStates.INITIAL; + + if (!keepCanvasWrapper && this.canvasWrapper) { + this.canvasWrapper.remove(); + this.canvasWrapper = null; + this._resetCanvas(); + } + } + + #covers(visibleArea) { + if (!this.detailArea) { + return false; + } + + const { minX, minY, width, height } = this.detailArea; + return ( + visibleArea.minX >= minX && + visibleArea.minY >= minY && + visibleArea.maxX <= minX + width && + visibleArea.maxY <= minY + height + ); + } + + update({ visibleArea = null, underlyingViewUpdated = false } = {}) { + if (underlyingViewUpdated) { + this.cancelRendering(); + this.renderingState = RenderingStates.INITIAL; + return; + } + + if (this.#covers(visibleArea)) { + return; + } + + const { viewport, maxCanvasPixels } = this.pageView; + + const visibleWidth = visibleArea.maxX - visibleArea.minX; + const visibleHeight = visibleArea.maxY - visibleArea.minY; + + // "overflowScale" represents which percentage of the width and of the + // height the detail area extends outside of the visible area. We want to + // draw a larger area so that we don't have to constantly re-draw while + // scrolling. The detail area's dimensions thus become + // visibleLength * (2 * overflowScale + 1). + // We default to adding a whole height/length of detail area on each side, + // but we can reduce it to make sure that we stay within the maxCanvasPixels + // limit. + const visiblePixels = + visibleWidth * visibleHeight * (window.devicePixelRatio || 1) ** 2; + const maxDetailToVisibleLinearRatio = Math.sqrt( + maxCanvasPixels / visiblePixels + ); + const maxOverflowScale = (maxDetailToVisibleLinearRatio - 1) / 2; + let overflowScale = Math.min(1, maxOverflowScale); + if (overflowScale < 0) { + overflowScale = 0; + // In this case, we render a detail view that is exactly as big as the + // visible area, but we ignore the .maxCanvasPixels limit. + // TODO: We should probably instead give up and not render the detail view + // in this case. It's quite rate to hit it though, because usually + // .maxCanvasPixels will at least have enough pixels to cover the visible + // screen. + } + + const overflowWidth = visibleWidth * overflowScale; + const overflowHeight = visibleHeight * overflowScale; + + const minX = Math.max(0, visibleArea.minX - overflowWidth); + const maxX = Math.min(viewport.width, visibleArea.maxX + overflowWidth); + const minY = Math.max(0, visibleArea.minY - overflowHeight); + const maxY = Math.min(viewport.height, visibleArea.maxY + overflowHeight); + const width = maxX - minX; + const height = maxY - minY; + + this.detailArea = { minX, minY, width, height }; + + this.reset({ keepCanvasWrapper: true }); + } + + async draw() { + const initialRenderingState = this.renderingState; + if (initialRenderingState !== RenderingStates.INITIAL) { + console.error("Must be in new state before drawing"); + this.reset(); // Ensure that we reset all state to prevent issues. + } + const { div, pdfPage, viewport } = this.pageView; + + if (!pdfPage) { + this.renderingState = RenderingStates.FINISHED; + throw new Error("pdfPage is not loaded"); + } + + this.renderingState = RenderingStates.RUNNING; + + this._ensureCanvasWrapper(canvasWrapper => { + canvasWrapper.classList.add("detailLayer"); + this.pageView._addLayer(canvasWrapper, "detailLayer"); + }); + + const { canvas, prevCanvas, ctx } = this._createCanvas( + // If there is already the lower resolution canvas behind, + // we don't show the new one until when it's fully ready. + this.pageView.renderingState === RenderingStates.FINISHED || + initialRenderingState === RenderingStates.FINISHED + ); + const { width, height } = viewport; + + const area = this.detailArea; + + const { devicePixelRatio = 1 } = window; + const transform = + devicePixelRatio !== 1 + ? [ + devicePixelRatio, + 0, + 0, + devicePixelRatio, + -area.minX * devicePixelRatio, + -area.minY * devicePixelRatio, + ] + : null; + + canvas.width = area.width * devicePixelRatio; + canvas.height = area.height * devicePixelRatio; + canvas.style.position = "absolute"; + canvas.style.display = "block"; + canvas.style.width = `${(area.width * 100) / width}%`; + canvas.style.height = `${(area.height * 100) / height}%`; + canvas.style.top = `${(area.minY * 100) / height}%`; + canvas.style.left = `${(area.minX * 100) / width}%`; + + const renderingPromise = this._drawCanvas( + { + canvasContext: ctx, + transform, + viewport, + pageColors: this.pageColors, + }, + prevCanvas + ); + + div.setAttribute("data-loaded", true); + + this.eventBus.dispatch("pagerender", { + source: this, + pageNumber: this.id, + }); + + return renderingPromise; + } +} + +/** + * @implements {IRenderableView} + */ +export { PDFPageDetailView, PDFPageView }; diff --git a/web/pdf_rendering_queue.js b/web/pdf_rendering_queue.js index 620eae6c104da..d314e410d8c19 100644 --- a/web/pdf_rendering_queue.js +++ b/web/pdf_rendering_queue.js @@ -109,8 +109,9 @@ class PDFRenderingQueue { * render next (if any). * * Priority: - * 1. visible pages - * 2. if last scrolled down, the page after the visible pages, or + * 1. zoomed-in partial views of visible pages + * 2. visible pages + * 3. if last scrolled down, the page after the visible pages, or * if last scrolled up, the page before the visible pages */ const visibleViews = visible.views, @@ -119,6 +120,12 @@ class PDFRenderingQueue { if (numVisible === 0) { return null; } + for (let i = 0; i < numVisible; i++) { + const { detailView } = visibleViews[i].view; + if (detailView && !this.isViewFinished(detailView)) { + return detailView; + } + } for (let i = 0; i < numVisible; i++) { const view = visibleViews[i].view; if (!this.isViewFinished(view)) { diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index e968db3f14810..34a5e891f0377 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -1220,7 +1220,13 @@ class PDFViewer { if (this.pagesCount === 0) { return; } - this.update(); + + const visible = this._getVisiblePages(); + for (const { view, visibleArea } of visible.views) { + view.updateVisibleArea(visibleArea); + } + + this.update(visible); } #scrollIntoView(pageView, pageSpot = null) { @@ -1647,8 +1653,8 @@ class PDFViewer { }; } - update() { - const visible = this._getVisiblePages(); + update(currentlyVisible) { + const visible = currentlyVisible || this._getVisiblePages(); const visiblePages = visible.views, numVisiblePages = visiblePages.length; diff --git a/web/ui_utils.js b/web/ui_utils.js index 07dc55c7a7221..dd2f250eae2f1 100644 --- a/web/ui_utils.js +++ b/web/ui_utils.js @@ -554,10 +554,11 @@ function getVisibleElements({ continue; } - const hiddenHeight = - Math.max(0, top - currentHeight) + Math.max(0, viewBottom - bottom); - const hiddenWidth = - Math.max(0, left - currentWidth) + Math.max(0, viewRight - right); + const minY = Math.max(0, top - currentHeight); + const minX = Math.max(0, left - currentWidth); + + const hiddenHeight = minY + Math.max(0, viewBottom - bottom); + const hiddenWidth = minX + Math.max(0, viewRight - right); const fractionHeight = (viewHeight - hiddenHeight) / viewHeight, fractionWidth = (viewWidth - hiddenWidth) / viewWidth; @@ -567,6 +568,18 @@ function getVisibleElements({ id: view.id, x: currentWidth, y: currentHeight, + visibleArea: + // We only specify which part of the page is visible when it's not + // the full page, as there is no point in handling a partial page + // rendering otherwise. + percent === 100 + ? null + : { + minX, + minY, + maxX: Math.min(viewRight, right) - currentWidth, + maxY: Math.min(viewBottom, bottom) - currentHeight, + }, view, percent, widthPercent: (fractionWidth * 100) | 0,