From 663007a5c727242d42916723c3e2b3eb9f3022f4 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 12 Dec 2022 14:24:27 +0100 Subject: [PATCH] Only redraw after zooming is finished (bug 1661253) Right now, the visible pages are redrawn for each scale change. Consequently, zooming with mouse wheel or in pinching can be pretty janky (even on a desktop machine but with a hdpi screen). So the main idea in this patch is to draw the visible pages only once zooming is finished. --- extensions/chromium/preferences_schema.json | 6 + web/app.js | 12 +- web/app_options.js | 6 + web/pdf_page_view.js | 133 ++++++++++++++------ web/pdf_viewer.css | 5 + web/pdf_viewer.js | 85 ++++++++----- web/text_layer_builder.js | 21 ++-- 7 files changed, 190 insertions(+), 78 deletions(-) diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index 6716b4cbd..88411cbc0 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -28,6 +28,12 @@ ], "default": 0 }, + "defaultZoomDelay": { + "title": "Default zoom delay", + "description": "Delay (in ms) to wait before redrawing the canvas.", + "type": "integer", + "default": 400 + }, "defaultZoomValue": { "title": "Default zoom level", "description": "Default zoom level of the viewer. Accepted values: 'auto', 'page-actual', 'page-width', 'page-height', 'page-fit', or a zoom level in percents.", diff --git a/web/app.js b/web/app.js index a30170ba1..493458bc2 100644 --- a/web/app.js +++ b/web/app.js @@ -655,14 +655,18 @@ const PDFViewerApplication = { if (this.pdfViewer.isInPresentationMode) { return; } - this.pdfViewer.increaseScale(steps); + this.pdfViewer.increaseScale(steps, { + delay: AppOptions.get("defaultZoomDelay"), + }); }, zoomOut(steps) { if (this.pdfViewer.isInPresentationMode) { return; } - this.pdfViewer.decreaseScale(steps); + this.pdfViewer.decreaseScale(steps, { + delay: AppOptions.get("defaultZoomDelay"), + }); }, zoomReset() { @@ -2019,9 +2023,7 @@ const PDFViewerApplication = { this._wheelUnusedTicks = 0; } this._wheelUnusedTicks += ticks; - const wholeTicks = - Math.sign(this._wheelUnusedTicks) * - Math.floor(Math.abs(this._wheelUnusedTicks)); + const wholeTicks = Math.trunc(this._wheelUnusedTicks); this._wheelUnusedTicks -= wholeTicks; return wholeTicks; }, diff --git a/web/app_options.js b/web/app_options.js index de96674a2..4d0a479d1 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -68,6 +68,12 @@ const defaultOptions = { value: 0, kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + defaultZoomDelay: { + /** @type {number} */ + value: + typeof PDFJSDev === "undefined" || !PDFJSDev.test("GENERIC") ? 400 : -1, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, defaultZoomValue: { /** @type {string} */ value: "", diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 393e2c8ce..2286fe99c 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -118,6 +118,8 @@ class PDFPageView { #layerProperties = null; + #previousRotation = null; + #useThumbnailCanvas = { initialOptionalContent: true, regularAnnotations: true, @@ -227,9 +229,14 @@ class PDFPageView { } #setDimensions() { - const { div, viewport } = this; + const { viewport } = this; + if (this.#previousRotation === viewport.rotation) { + return; + } + this.#previousRotation = viewport.rotation; + setLayerDimensions( - div, + this.div, viewport, /* mustFlip = */ true, /* mustRotate = */ false @@ -245,6 +252,7 @@ class PDFPageView { scale: this.scale * PixelsPerInch.PDF_TO_CSS_UNITS, rotation: totalRotation, }); + this.#setDimensions(); this.reset(); } @@ -417,7 +425,6 @@ class PDFPageView { }); this.renderingState = RenderingStates.INITIAL; - this.#setDimensions(); const div = this.div; const childNodes = div.childNodes, @@ -502,7 +509,12 @@ class PDFPageView { } } - update({ scale = 0, rotation = null, optionalContentConfigPromise = null }) { + update({ + scale = 0, + rotation = null, + optionalContentConfigPromise = null, + drawingDelay = -1, + }) { this.scale = scale || this.scale; if (typeof rotation === "number") { this.rotation = rotation; // The rotation may be zero. @@ -528,6 +540,7 @@ class PDFPageView { scale: this.scale * PixelsPerInch.PDF_TO_CSS_UNITS, rotation: totalRotation, }); + this.#setDimensions(); if ( (typeof PDFJSDev === "undefined" || @@ -572,17 +585,40 @@ class PDFPageView { } } + const postponeDrawing = drawingDelay >= 0 && drawingDelay < 1000; + if (this.canvas) { if ( + postponeDrawing || this.useOnlyCssZoom || (this.hasRestrictedScaling && isScalingRestricted) ) { + if ( + postponeDrawing && + this.renderingState !== RenderingStates.FINISHED + ) { + this.cancelRendering({ + keepZoomLayer: true, + keepAnnotationLayer: true, + keepAnnotationEditorLayer: true, + keepXfaLayer: true, + keepTextLayer: true, + cancelExtraDelay: drawingDelay, + }); + // It isn't really finished, but once we have finished + // to postpone, we'll call this.reset(...) which will set + // the rendering state to INITIAL, hence the next call to + // PDFViewer.update() will trigger a redraw (if it's mandatory). + this.renderingState = RenderingStates.FINISHED; + } + this.cssTransform({ target: this.canvas, redrawAnnotationLayer: true, redrawAnnotationEditorLayer: true, redrawXfaLayer: true, - redrawTextLayer: true, + redrawTextLayer: !postponeDrawing, + hideTextLayer: postponeDrawing, }); this.eventBus.dispatch("pagerendered", { @@ -620,9 +656,10 @@ class PDFPageView { keepAnnotationEditorLayer = false, keepXfaLayer = false, keepTextLayer = false, + cancelExtraDelay = 0, } = {}) { if (this.paintTask) { - this.paintTask.cancel(); + this.paintTask.cancel(cancelExtraDelay); this.paintTask = null; } this.resume = null; @@ -662,31 +699,49 @@ class PDFPageView { redrawAnnotationEditorLayer = false, redrawXfaLayer = false, redrawTextLayer = false, + hideTextLayer = false, }) { // Scale target (canvas or svg), its wrapper and page container. - const width = this.viewport.width; - const height = this.viewport.height; - const div = this.div; - target.style.width = - target.parentNode.style.width = - div.style.width = - Math.floor(width) + "px"; - target.style.height = - target.parentNode.style.height = - div.style.height = - Math.floor(height) + "px"; - // The canvas may have been originally rotated; rotate relative to that. - const relativeRotation = - this.viewport.rotation - this.paintedViewportMap.get(target).rotation; - const absRotation = Math.abs(relativeRotation); - let scaleX = 1, - scaleY = 1; - if (absRotation === 90 || absRotation === 270) { - // Scale x and y because of the rotation. - scaleX = height / width; - scaleY = width / height; + + if (target instanceof HTMLCanvasElement) { + if (!target.hasAttribute("zooming")) { + target.setAttribute("zooming", true); + const { style } = target; + style.width = style.height = ""; + } + } else { + const div = this.div; + const { width, height } = this.viewport; + + target.style.width = + target.parentNode.style.width = + div.style.width = + Math.floor(width) + "px"; + target.style.height = + target.parentNode.style.height = + div.style.height = + Math.floor(height) + "px"; + } + + const originalViewport = this.paintedViewportMap.get(target); + if (this.viewport !== originalViewport) { + // The canvas may have been originally rotated; rotate relative to that. + const relativeRotation = + this.viewport.rotation - originalViewport.rotation; + const absRotation = Math.abs(relativeRotation); + let scaleX = 1, + scaleY = 1; + if (absRotation === 90 || absRotation === 270) { + const { width, height } = this.viewport; + // Scale x and y because of the rotation. + scaleX = height / width; + scaleY = width / height; + } + + if (absRotation !== 0) { + target.style.transform = `rotate(${relativeRotation}deg) scale(${scaleX}, ${scaleY})`; + } } - target.style.transform = `rotate(${relativeRotation}deg) scale(${scaleX}, ${scaleY})`; if (redrawAnnotationLayer && this.annotationLayer) { this.#renderAnnotationLayer(); @@ -697,8 +752,13 @@ class PDFPageView { if (redrawXfaLayer && this.xfaLayer) { this.#renderXfaLayer(); } - if (redrawTextLayer && this.textLayer) { - this.#renderTextLayer(); + + if (this.textLayer) { + if (hideTextLayer) { + this.textLayer.hide(); + } else if (redrawTextLayer) { + this.#renderTextLayer(); + } } } @@ -933,8 +993,8 @@ class PDFPageView { onRenderContinue(cont) { cont(); }, - cancel() { - renderTask.cancel(); + cancel(extraDelay = 0) { + renderTask.cancel(extraDelay); }, get separateAnnots() { return renderTask.separateAnnots; @@ -942,6 +1002,7 @@ class PDFPageView { }; const viewport = this.viewport; + const { width, height } = viewport; const canvas = document.createElement("canvas"); canvas.setAttribute("role", "presentation"); @@ -968,12 +1029,12 @@ class PDFPageView { }); // Use a scale that makes the canvas have the originally intended size // of the page. - outputScale.sx *= actualSizeViewport.width / viewport.width; - outputScale.sy *= actualSizeViewport.height / viewport.height; + outputScale.sx *= actualSizeViewport.width / width; + outputScale.sy *= actualSizeViewport.height / height; } if (this.maxCanvasPixels > 0) { - const pixelsInViewport = viewport.width * viewport.height; + const pixelsInViewport = width * height; const maxScale = Math.sqrt(this.maxCanvasPixels / pixelsInViewport); if (outputScale.sx > maxScale || outputScale.sy > maxScale) { outputScale.sx = maxScale; @@ -1003,7 +1064,7 @@ class PDFPageView { const renderContext = { canvasContext: ctx, transform, - viewport: this.viewport, + viewport, annotationMode: this.#annotationMode, optionalContentConfigPromise: this._optionalContentConfigPromise, annotationCanvasMap: this._annotationCanvasMap, diff --git a/web/pdf_viewer.css b/web/pdf_viewer.css index 2a6055adf..a050d4b63 100644 --- a/web/pdf_viewer.css +++ b/web/pdf_viewer.css @@ -143,6 +143,11 @@ display: none; } +.pdfViewer .page canvas[zooming] { + width: 100%; + height: 100%; +} + .pdfViewer .page .loadingIcon { position: absolute; display: block; diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 4de3dd382..995b15d83 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -216,6 +216,8 @@ class PDFViewer { #onVisibilityChange = null; + #scaleTimeoutId = null; + /** * @param {PDFViewerOptions} options */ @@ -454,7 +456,7 @@ class PDFViewer { if (!this.pdfDocument) { return; } - this._setScale(val, false); + this._setScale(val, { noScroll: false }); } /** @@ -471,7 +473,7 @@ class PDFViewer { if (!this.pdfDocument) { return; } - this._setScale(val, false); + this._setScale(val, { noScroll: false }); } /** @@ -503,14 +505,12 @@ class PDFViewer { const pageNumber = this._currentPageNumber; - const updateArgs = { rotation }; - for (const pageView of this._pages) { - pageView.update(updateArgs); - } + this.refresh(true, { rotation }); + // Prevent errors in case the rotation changes *before* the scale has been // set to a non-default value. if (this._currentScaleValue) { - this._setScale(this._currentScaleValue, true); + this._setScale(this._currentScaleValue, { noScroll: true }); } this.eventBus.dispatch("rotationchanging", { @@ -1080,7 +1080,11 @@ class PDFViewer { ); } - _setScaleUpdatePages(newScale, newValue, noScroll = false, preset = false) { + _setScaleUpdatePages( + newScale, + newValue, + { noScroll = false, preset = false, delay: drawingDelay = -1 } + ) { this._currentScaleValue = newValue.toString(); if (this.#isSameScale(newScale)) { @@ -1099,10 +1103,22 @@ class PDFViewer { newScale * PixelsPerInch.PDF_TO_CSS_UNITS ); - const updateArgs = { scale: newScale }; - for (const pageView of this._pages) { - pageView.update(updateArgs); + const mustPostponeDrawing = drawingDelay >= 0 && drawingDelay < 1000; + const updateArgs = { + scale: newScale, + }; + if (mustPostponeDrawing) { + updateArgs.drawingDelay = drawingDelay; } + this.refresh(true, updateArgs); + + if (mustPostponeDrawing) { + this.#scaleTimeoutId = setTimeout(() => { + this.#scaleTimeoutId = null; + this.refresh(); + }, drawingDelay); + } + this._currentScale = newScale; if (!noScroll) { @@ -1152,11 +1168,12 @@ class PDFViewer { return 1; } - _setScale(value, noScroll = false) { + _setScale(value, options) { let scale = parseFloat(value); if (scale > 0) { - this._setScaleUpdatePages(scale, value, noScroll, /* preset = */ false); + options.preset = false; + this._setScaleUpdatePages(scale, value, options); } else { const currentPage = this._pages[this._currentPageNumber - 1]; if (!currentPage) { @@ -1211,7 +1228,8 @@ class PDFViewer { console.error(`_setScale: "${value}" is an unknown zoom value.`); return; } - this._setScaleUpdatePages(scale, value, noScroll, /* preset = */ true); + options.preset = true; + this._setScaleUpdatePages(scale, value, options); } } @@ -1223,7 +1241,7 @@ class PDFViewer { if (this.isInPresentationMode) { // Fixes the case when PDF has different page sizes. - this._setScale(this._currentScaleValue, true); + this._setScale(this._currentScaleValue, { noScroll: true }); } this.#scrollIntoView(pageView); } @@ -1725,11 +1743,7 @@ class PDFViewer { } this._optionalContentConfigPromise = promise; - const updateArgs = { optionalContentConfigPromise: promise }; - for (const pageView of this._pages) { - pageView.update(updateArgs); - } - this.update(); + this.refresh(false, { optionalContentConfigPromise: promise }); this.eventBus.dispatch("optionalcontentconfigchanged", { source: this, @@ -1792,7 +1806,7 @@ class PDFViewer { // Call this before re-scrolling to the current page, to ensure that any // changes in scale don't move the current page. if (this._currentScaleValue && isNaN(this._currentScaleValue)) { - this._setScale(this._currentScaleValue, true); + this._setScale(this._currentScaleValue, { noScroll: true }); } this._setCurrentPageNumber(pageNumber, /* resetCurrentPageView = */ true); this.update(); @@ -1864,7 +1878,7 @@ class PDFViewer { // Call this before re-scrolling to the current page, to ensure that any // changes in scale don't move the current page. if (this._currentScaleValue && isNaN(this._currentScaleValue)) { - this._setScale(this._currentScaleValue, true); + this._setScale(this._currentScaleValue, { noScroll: true }); } this._setCurrentPageNumber(pageNumber, /* resetCurrentPageView = */ true); this.update(); @@ -2005,29 +2019,37 @@ class PDFViewer { /** * Increase the current zoom level one, or more, times. * @param {number} [steps] - Defaults to zooming once. + * @param {Object|null} [options] */ - increaseScale(steps = 1) { + increaseScale(steps = 1, options = null) { let newScale = this._currentScale; do { newScale = (newScale * DEFAULT_SCALE_DELTA).toFixed(2); newScale = Math.ceil(newScale * 10) / 10; newScale = Math.min(MAX_SCALE, newScale); } while (--steps > 0 && newScale < MAX_SCALE); - this.currentScaleValue = newScale; + + options ||= Object.create(null); + options.noScroll = false; + this._setScale(newScale, options); } /** * Decrease the current zoom level one, or more, times. * @param {number} [steps] - Defaults to zooming once. + * @param {Object|null} [options] */ - decreaseScale(steps = 1) { + decreaseScale(steps = 1, options = null) { let newScale = this._currentScale; do { newScale = (newScale / DEFAULT_SCALE_DELTA).toFixed(2); newScale = Math.floor(newScale * 10) / 10; newScale = Math.max(MIN_SCALE, newScale); } while (--steps > 0 && newScale > MIN_SCALE); - this.currentScaleValue = newScale; + + options ||= Object.create(null); + options.noScroll = false; + this._setScale(newScale, options); } #updateContainerHeightCss(height = this.container.clientHeight) { @@ -2098,15 +2120,20 @@ class PDFViewer { this.#annotationEditorUIManager.updateParams(type, value); } - refresh() { + refresh(noUpdate = false, updateArgs = Object.create(null)) { if (!this.pdfDocument) { return; } - const updateArgs = {}; for (const pageView of this._pages) { pageView.update(updateArgs); } - this.update(); + if (this.#scaleTimeoutId !== null) { + clearTimeout(this.#scaleTimeoutId); + this.#scaleTimeoutId = null; + } + if (!noUpdate) { + this.update(); + } } } diff --git a/web/text_layer_builder.js b/web/text_layer_builder.js index b6cb63846..20fc7f67d 100644 --- a/web/text_layer_builder.js +++ b/web/text_layer_builder.js @@ -86,8 +86,8 @@ class TextLayerBuilder { } const scale = viewport.scale * (globalThis.devicePixelRatio || 1); + const { rotation } = viewport; if (this.renderingDone) { - const { rotation } = viewport; const mustRotate = rotation !== this.#rotation; const mustRescale = scale !== this.#scale; if (mustRotate || mustRescale) { @@ -101,10 +101,10 @@ class TextLayerBuilder { mustRescale, mustRotate, }); - this.show(); this.#scale = scale; this.#rotation = rotation; } + this.show(); return; } @@ -125,20 +125,25 @@ class TextLayerBuilder { await this.textLayerRenderTask.promise; this.#finishRendering(); this.#scale = scale; + this.#rotation = rotation; this.show(); this.accessibilityManager?.enable(); } hide() { - // We turn off the highlighter in order to avoid to scroll into view an - // element of the text layer which could be hidden. - this.highlighter?.disable(); - this.div.hidden = true; + if (!this.div.hidden) { + // We turn off the highlighter in order to avoid to scroll into view an + // element of the text layer which could be hidden. + this.highlighter?.disable(); + this.div.hidden = true; + } } show() { - this.div.hidden = false; - this.highlighter?.enable(); + if (this.div.hidden && this.renderingDone) { + this.div.hidden = false; + this.highlighter?.enable(); + } } /**