1
0
Fork 0
mirror of https://github.com/mozilla/pdf.js.git synced 2025-04-22 16:18:08 +02:00

[Editor] Improve curve smoothing for Ink tool (bug 1789443)

- Remove the dependency on fit-curve;
- Improve the way to draw the current line in using a Path2D and
  in clearing only the last part of the curve instead of clearing
  all the canvas;
- Smooth the curve when drawing to avoid to have some changes after
  the drawing ends;
- Make the smoothing a bit less agressive.
This commit is contained in:
Calixte Denizet 2023-02-09 11:16:10 +01:00
parent 094fb3c783
commit d2b4ed3cea
10 changed files with 225 additions and 216 deletions

View file

@ -1,20 +0,0 @@
/* Copyright 2022 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.
*/
const fitCurve = require(PDFJSDev.test("LIB")
? "fit-curve"
: "fit-curve/src/fit-curve.js");
export { fitCurve };

View file

@ -19,7 +19,6 @@ import {
Util,
} from "../../shared/util.js";
import { AnnotationEditor } from "./editor.js";
import { fitCurve } from "pdfjs-fitCurve";
import { opacityToHex } from "./tools.js";
// The dimensions of the resizer is 15x15:
@ -37,6 +36,8 @@ class InkEditor extends AnnotationEditor {
#baseWidth = 0;
#boundCanvasContextMenu = this.canvasContextMenu.bind(this);
#boundCanvasPointermove = this.canvasPointermove.bind(this);
#boundCanvasPointerleave = this.canvasPointerleave.bind(this);
@ -45,11 +46,13 @@ class InkEditor extends AnnotationEditor {
#boundCanvasPointerdown = this.canvasPointerdown.bind(this);
#currentPath2D = new Path2D();
#disableEditing = false;
#isCanvasInitialized = false;
#hasSomethingToDraw = false;
#lastPoint = null;
#isCanvasInitialized = false;
#observer = null;
@ -76,6 +79,7 @@ class InkEditor extends AnnotationEditor {
this.opacity = params.opacity || null;
this.paths = [];
this.bezierPath2D = [];
this.allRawPaths = [];
this.currentPath = [];
this.scaleFactor = 1;
this.translationX = this.translationY = 0;
@ -294,7 +298,6 @@ class InkEditor extends AnnotationEditor {
super.enableEditMode();
this.div.draggable = false;
this.canvas.addEventListener("pointerdown", this.#boundCanvasPointerdown);
this.canvas.addEventListener("pointerup", this.#boundCanvasPointerup);
}
/** @inheritdoc */
@ -311,7 +314,6 @@ class InkEditor extends AnnotationEditor {
"pointerdown",
this.#boundCanvasPointerdown
);
this.canvas.removeEventListener("pointerup", this.#boundCanvasPointerup);
}
/** @inheritdoc */
@ -362,6 +364,15 @@ class InkEditor extends AnnotationEditor {
* @param {number} y
*/
#startDrawing(x, y) {
this.canvas.addEventListener("contextmenu", this.#boundCanvasContextMenu);
this.canvas.addEventListener("pointerleave", this.#boundCanvasPointerleave);
this.canvas.addEventListener("pointermove", this.#boundCanvasPointermove);
this.canvas.addEventListener("pointerup", this.#boundCanvasPointerup);
this.canvas.removeEventListener(
"pointerdown",
this.#boundCanvasPointerdown
);
this.isEditing = true;
if (!this.#isCanvasInitialized) {
this.#isCanvasInitialized = true;
@ -372,30 +383,14 @@ class InkEditor extends AnnotationEditor {
this.opacity ??= InkEditor._defaultOpacity;
}
this.currentPath.push([x, y]);
this.#lastPoint = null;
this.#hasSomethingToDraw = false;
this.#setStroke();
this.ctx.beginPath();
this.ctx.moveTo(x, y);
this.#requestFrameCallback = () => {
if (!this.#requestFrameCallback) {
return;
this.#drawPoints();
if (this.#requestFrameCallback) {
window.requestAnimationFrame(this.#requestFrameCallback);
}
if (this.#lastPoint) {
if (this.isEmpty()) {
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
} else {
this.#redraw();
}
this.ctx.lineTo(...this.#lastPoint);
this.#lastPoint = null;
this.ctx.stroke();
}
window.requestAnimationFrame(this.#requestFrameCallback);
};
window.requestAnimationFrame(this.#requestFrameCallback);
}
@ -407,11 +402,40 @@ class InkEditor extends AnnotationEditor {
*/
#draw(x, y) {
const [lastX, lastY] = this.currentPath.at(-1);
if (x === lastX && y === lastY) {
if (this.currentPath.length > 1 && x === lastX && y === lastY) {
return;
}
this.currentPath.push([x, y]);
this.#lastPoint = [x, y];
const currentPath = this.currentPath;
let path2D = this.#currentPath2D;
currentPath.push([x, y]);
this.#hasSomethingToDraw = true;
if (currentPath.length <= 2) {
path2D.moveTo(...currentPath[0]);
path2D.lineTo(x, y);
return;
}
if (currentPath.length === 3) {
this.#currentPath2D = path2D = new Path2D();
path2D.moveTo(...currentPath[0]);
}
this.#makeBezierCurve(
path2D,
...currentPath.at(-3),
...currentPath.at(-2),
x,
y
);
}
#endPath() {
if (this.currentPath.length === 0) {
return;
}
const lastPoint = this.currentPath.at(-1);
this.#currentPath2D.lineTo(...lastPoint);
}
/**
@ -420,38 +444,39 @@ class InkEditor extends AnnotationEditor {
* @param {number} y
*/
#stopDrawing(x, y) {
this.ctx.closePath();
this.#requestFrameCallback = null;
x = Math.min(Math.max(x, 0), this.canvas.width);
y = Math.min(Math.max(y, 0), this.canvas.height);
const [lastX, lastY] = this.currentPath.at(-1);
if (x !== lastX || y !== lastY) {
this.currentPath.push([x, y]);
}
this.#draw(x, y);
this.#endPath();
// Interpolate the path entered by the user with some
// Bezier's curves in order to have a smoother path and
// to reduce the data size used to draw it in the PDF.
let bezier;
if (this.currentPath.length !== 1) {
bezier = fitCurve(this.currentPath, 30, null);
bezier = this.#generateBezierPoints();
} else {
// We have only one point finally.
const xy = [x, y];
bezier = [[xy, xy.slice(), xy.slice(), xy]];
}
const path2D = InkEditor.#buildPath2D(bezier);
this.currentPath.length = 0;
const path2D = this.#currentPath2D;
const currentPath = this.currentPath;
this.currentPath = [];
this.#currentPath2D = new Path2D();
const cmd = () => {
this.allRawPaths.push(currentPath);
this.paths.push(bezier);
this.bezierPath2D.push(path2D);
this.rebuild();
};
const undo = () => {
this.allRawPaths.pop();
this.paths.pop();
this.bezierPath2D.pop();
if (this.paths.length === 0) {
@ -468,6 +493,95 @@ class InkEditor extends AnnotationEditor {
this.addCommands({ cmd, undo, mustExec: true });
}
#drawPoints() {
if (!this.#hasSomethingToDraw) {
return;
}
this.#hasSomethingToDraw = false;
const thickness = Math.ceil(this.thickness * this.parentScale);
const lastPoints = this.currentPath.slice(-3);
const x = lastPoints.map(xy => xy[0]);
const y = lastPoints.map(xy => xy[1]);
const xMin = Math.min(...x) - thickness;
const xMax = Math.max(...x) + thickness;
const yMin = Math.min(...y) - thickness;
const yMax = Math.max(...y) + thickness;
const { ctx } = this;
ctx.save();
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
// In Chrome, the clip() method doesn't work as expected.
ctx.clearRect(xMin, yMin, xMax - xMin, yMax - yMin);
ctx.beginPath();
ctx.rect(xMin, yMin, xMax - xMin, yMax - yMin);
ctx.clip();
} else {
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
for (const path of this.bezierPath2D) {
ctx.stroke(path);
}
ctx.stroke(this.#currentPath2D);
ctx.restore();
}
#makeBezierCurve(path2D, x0, y0, x1, y1, x2, y2) {
const prevX = (x0 + x1) / 2;
const prevY = (y0 + y1) / 2;
const x3 = (x1 + x2) / 2;
const y3 = (y1 + y2) / 2;
path2D.bezierCurveTo(
prevX + (2 * (x1 - prevX)) / 3,
prevY + (2 * (y1 - prevY)) / 3,
x3 + (2 * (x1 - x3)) / 3,
y3 + (2 * (y1 - y3)) / 3,
x3,
y3
);
}
#generateBezierPoints() {
const path = this.currentPath;
if (path.length <= 2) {
return [[path[0], path[0], path.at(-1), path.at(-1)]];
}
const bezierPoints = [];
let i;
let [x0, y0] = path[0];
for (i = 1; i < path.length - 2; i++) {
const [x1, y1] = path[i];
const [x2, y2] = path[i + 1];
const x3 = (x1 + x2) / 2;
const y3 = (y1 + y2) / 2;
// The quadratic is: [[x0, y0], [x1, y1], [x3, y3]].
// Convert the quadratic to a cubic
// (see https://fontforge.org/docs/techref/bezier.html#converting-truetype-to-postscript)
const control1 = [x0 + (2 * (x1 - x0)) / 3, y0 + (2 * (y1 - y0)) / 3];
const control2 = [x3 + (2 * (x1 - x3)) / 3, y3 + (2 * (y1 - y3)) / 3];
bezierPoints.push([[x0, y0], control1, control2, [x3, y3]]);
[x0, y0] = [x3, y3];
}
const [x1, y1] = path[i];
const [x2, y2] = path[i + 1];
// The quadratic is: [[x0, y0], [x1, y1], [x2, y2]].
const control1 = [x0 + (2 * (x1 - x0)) / 3, y0 + (2 * (y1 - y0)) / 3];
const control2 = [x2 + (2 * (x1 - x2)) / 3, y2 + (2 * (y1 - y2)) / 3];
bezierPoints.push([[x0, y0], control1, control2, [x2, y2]]);
return bezierPoints;
}
/**
* Redraw all the paths.
*/
@ -482,6 +596,7 @@ class InkEditor extends AnnotationEditor {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.#updateTransform();
for (const path of this.bezierPath2D) {
ctx.stroke(path);
}
@ -537,24 +652,29 @@ class InkEditor extends AnnotationEditor {
// Since it's the last child, there's no need to give it a higher z-index.
this.setInForeground();
event.preventDefault();
if (event.type !== "mouse") {
this.div.focus();
}
event.stopPropagation();
this.canvas.addEventListener("pointerleave", this.#boundCanvasPointerleave);
this.canvas.addEventListener("pointermove", this.#boundCanvasPointermove);
this.#startDrawing(event.offsetX, event.offsetY);
}
/**
* oncontextmenu callback for the canvas we're drawing on.
* @param {PointerEvent} event
*/
canvasContextMenu(event) {
event.preventDefault();
}
/**
* onpointermove callback for the canvas we're drawing on.
* @param {PointerEvent} event
*/
canvasPointermove(event) {
event.stopPropagation();
event.preventDefault();
this.#draw(event.offsetX, event.offsetY);
}
@ -563,17 +683,8 @@ class InkEditor extends AnnotationEditor {
* @param {PointerEvent} event
*/
canvasPointerup(event) {
if (event.button !== 0) {
return;
}
if (this.isInEditMode() && this.currentPath.length !== 0) {
event.stopPropagation();
this.#endDrawing(event);
// Since the ink editor covers all of the page and we want to be able
// to select another editor, we just put this one in the background.
this.setInBackground();
}
event.preventDefault();
this.#endDrawing(event);
}
/**
@ -582,7 +693,6 @@ class InkEditor extends AnnotationEditor {
*/
canvasPointerleave(event) {
this.#endDrawing(event);
this.setInBackground();
}
/**
@ -590,8 +700,6 @@ class InkEditor extends AnnotationEditor {
* @param {PointerEvent} event
*/
#endDrawing(event) {
this.#stopDrawing(event.offsetX, event.offsetY);
this.canvas.removeEventListener(
"pointerleave",
this.#boundCanvasPointerleave
@ -600,8 +708,25 @@ class InkEditor extends AnnotationEditor {
"pointermove",
this.#boundCanvasPointermove
);
this.canvas.removeEventListener("pointerup", this.#boundCanvasPointerup);
this.canvas.addEventListener("pointerdown", this.#boundCanvasPointerdown);
// Slight delay to avoid the context menu to appear (it can happen on a long
// tap with a pen).
setTimeout(() => {
this.canvas.removeEventListener(
"contextmenu",
this.#boundCanvasContextMenu
);
}, 10);
this.#stopDrawing(event.offsetX, event.offsetY);
this.addToAnnotationStorage();
// Since the ink editor covers all of the page and we want to be able
// to select another editor, we just put this one in the background.
this.setInBackground();
}
/**
@ -762,7 +887,7 @@ class InkEditor extends AnnotationEditor {
}
/**
* Convert the output of fitCurve in some Path2D.
* Convert into a Path2D.
* @param {Arra<Array<number>} bezier
* @returns {Path2D}
*/
@ -1099,4 +1224,4 @@ class InkEditor extends AnnotationEditor {
}
}
export { fitCurve, InkEditor };
export { InkEditor };