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.
This commit is contained in:
Calixte Denizet 2022-12-12 14:24:27 +01:00
parent 31da2fad51
commit 663007a5c7
7 changed files with 190 additions and 78 deletions

View File

@ -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.",

View File

@ -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;
},

View File

@ -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: "",

View File

@ -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,

View File

@ -143,6 +143,11 @@
display: none;
}
.pdfViewer .page canvas[zooming] {
width: 100%;
height: 100%;
}
.pdfViewer .page .loadingIcon {
position: absolute;
display: block;

View File

@ -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();
}
}
}

View File

@ -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();
}
}
/**