Merge pull request #16732 from calixteman/editor_resize

[Editor] Add some resizers all around an editor (bug 1843302)
This commit is contained in:
calixteman 2023-07-25 14:17:09 +02:00 committed by GitHub
commit e00629966d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 452 additions and 41 deletions

View File

@ -21,11 +21,6 @@
import { bindEvents, ColorManager } from "./tools.js";
import { FeatureTest, shadow, unreachable } from "../../shared/util.js";
// The dimensions of the resizer is 15x15:
// https://searchfox.org/mozilla-central/rev/1ce190047b9556c3c10ab4de70a0e61d893e2954/toolkit/content/minimal-xul.css#136-137
// so each dimension must be greater than RESIZER_SIZE.
const RESIZER_SIZE = 16;
/**
* @typedef {Object} AnnotationEditorParameters
* @property {AnnotationEditorUIManager} uiManager - the global manager
@ -41,6 +36,10 @@ const RESIZER_SIZE = 16;
class AnnotationEditor {
#keepAspectRatio = false;
#resizersDiv = null;
#resizePosition = null;
#boundFocusin = this.focusin.bind(this);
#boundFocusout = this.focusout.bind(this);
@ -75,6 +74,7 @@ class AnnotationEditor {
this.div = null;
this._uiManager = parameters.uiManager;
this.annotationElementId = null;
this._willKeepAspectRatio = false;
const {
rotation,
@ -401,6 +401,274 @@ class AnnotationEditor {
return [0, 0];
}
#createResizers() {
if (this.#resizersDiv) {
return;
}
this.#resizersDiv = document.createElement("div");
this.#resizersDiv.classList.add("resizers");
const classes = ["topLeft", "topRight", "bottomRight", "bottomLeft"];
if (!this._willKeepAspectRatio) {
classes.push("topMiddle", "middleRight", "bottomMiddle", "middleLeft");
}
for (const name of classes) {
const div = document.createElement("div");
this.#resizersDiv.append(div);
div.classList.add("resizer", name);
div.addEventListener(
"pointerdown",
this.#resizerPointerdown.bind(this, name)
);
}
this.div.prepend(this.#resizersDiv);
}
#resizerPointerdown(name, event) {
event.preventDefault();
this.#resizePosition = [event.clientX, event.clientY];
const boundResizerPointermove = this.#resizerPointermove.bind(this, name);
const savedDraggable = this.div.draggable;
this.div.draggable = false;
const resizingClassName = `resizing${name
.charAt(0)
.toUpperCase()}${name.slice(1)}`;
this.parent.div.classList.add(resizingClassName);
const pointerMoveOptions = { passive: true, capture: true };
window.addEventListener(
"pointermove",
boundResizerPointermove,
pointerMoveOptions
);
const pointerUpCallback = () => {
// Stop the undo accumulation in order to have an undo action for each
// resize session.
this._uiManager.stopUndoAccumulation();
this.div.draggable = savedDraggable;
this.parent.div.classList.remove(resizingClassName);
window.removeEventListener(
"pointermove",
boundResizerPointermove,
pointerMoveOptions
);
};
window.addEventListener("pointerup", pointerUpCallback, {
once: true,
});
}
#resizerPointermove(name, event) {
const { clientX, clientY } = event;
const deltaX = clientX - this.#resizePosition[0];
const deltaY = clientY - this.#resizePosition[1];
this.#resizePosition[0] = clientX;
this.#resizePosition[1] = clientY;
const [parentWidth, parentHeight] = this.parentDimensions;
const savedX = this.x;
const savedY = this.y;
const savedWidth = this.width;
const savedHeight = this.height;
const minWidth = AnnotationEditor.MIN_SIZE / parentWidth;
const minHeight = AnnotationEditor.MIN_SIZE / parentHeight;
let cmd;
// 10000 because we multiply by 100 and use toFixed(2) in fixAndSetPosition.
// Without rounding, the positions of the corners other than the top left
// one can be slightly wrong.
const round = x => Math.round(x * 10000) / 10000;
const updatePosition = (width, height) => {
// We must take the parent dimensions as they are when undo/redo.
const [pWidth, pHeight] = this.parentDimensions;
this.setDims(pWidth * width, pHeight * height);
this.fixAndSetPosition();
};
const undo = () => {
this.width = savedWidth;
this.height = savedHeight;
this.x = savedX;
this.y = savedY;
updatePosition(savedWidth, savedHeight);
};
switch (name) {
case "topLeft": {
if (Math.sign(deltaX) * Math.sign(deltaY) < 0) {
return;
}
const dist = Math.hypot(deltaX, deltaY);
const oldDiag = Math.hypot(
savedWidth * parentWidth,
savedHeight * parentHeight
);
const brX = round(savedX + savedWidth);
const brY = round(savedY + savedHeight);
const ratio = Math.max(
Math.min(
1 - Math.sign(deltaX) * (dist / oldDiag),
// Avoid the editor to be larger than the page.
1 / savedWidth,
1 / savedHeight
),
// Avoid the editor to be smaller than the minimum size.
minWidth / savedWidth,
minHeight / savedHeight
);
const newWidth = round(savedWidth * ratio);
const newHeight = round(savedHeight * ratio);
const newX = brX - newWidth;
const newY = brY - newHeight;
cmd = () => {
this.width = newWidth;
this.height = newHeight;
this.x = newX;
this.y = newY;
updatePosition(newWidth, newHeight);
};
break;
}
case "topMiddle": {
const bmY = round(this.y + savedHeight);
const newHeight = round(
Math.max(minHeight, Math.min(1, savedHeight - deltaY / parentHeight))
);
const newY = bmY - newHeight;
cmd = () => {
this.height = newHeight;
this.y = newY;
updatePosition(savedWidth, newHeight);
};
break;
}
case "topRight": {
if (Math.sign(deltaX) * Math.sign(deltaY) > 0) {
return;
}
const dist = Math.hypot(deltaX, deltaY);
const oldDiag = Math.hypot(
this.width * parentWidth,
this.height * parentHeight
);
const blY = round(savedY + this.height);
const ratio = Math.max(
Math.min(
1 + Math.sign(deltaX) * (dist / oldDiag),
1 / savedWidth,
1 / savedHeight
),
minWidth / savedWidth,
minHeight / savedHeight
);
const newWidth = round(savedWidth * ratio);
const newHeight = round(savedHeight * ratio);
const newY = blY - newHeight;
cmd = () => {
this.width = newWidth;
this.height = newHeight;
this.y = newY;
updatePosition(newWidth, newHeight);
};
break;
}
case "middleRight": {
const newWidth = round(
Math.max(minWidth, Math.min(1, savedWidth + deltaX / parentWidth))
);
cmd = () => {
this.width = newWidth;
updatePosition(newWidth, savedHeight);
};
break;
}
case "bottomRight": {
if (Math.sign(deltaX) * Math.sign(deltaY) < 0) {
return;
}
const dist = Math.hypot(deltaX, deltaY);
const oldDiag = Math.hypot(
this.width * parentWidth,
this.height * parentHeight
);
const ratio = Math.max(
Math.min(
1 + Math.sign(deltaX) * (dist / oldDiag),
1 / savedWidth,
1 / savedHeight
),
minWidth / savedWidth,
minHeight / savedHeight
);
const newWidth = round(savedWidth * ratio);
const newHeight = round(savedHeight * ratio);
cmd = () => {
this.width = newWidth;
this.height = newHeight;
updatePosition(newWidth, newHeight);
};
break;
}
case "bottomMiddle": {
const newHeight = round(
Math.max(minHeight, Math.min(1, savedHeight + deltaY / parentHeight))
);
cmd = () => {
this.height = newHeight;
updatePosition(savedWidth, newHeight);
};
break;
}
case "bottomLeft": {
if (Math.sign(deltaX) * Math.sign(deltaY) > 0) {
return;
}
const dist = Math.hypot(deltaX, deltaY);
const oldDiag = Math.hypot(
this.width * parentWidth,
this.height * parentHeight
);
const trX = round(savedX + this.width);
const ratio = Math.max(
Math.min(
1 - Math.sign(deltaX) * (dist / oldDiag),
1 / savedWidth,
1 / savedHeight
),
minWidth / savedWidth,
minHeight / savedHeight
);
const newWidth = round(savedWidth * ratio);
const newHeight = round(savedHeight * ratio);
const newX = trX - newWidth;
cmd = () => {
this.width = newWidth;
this.height = newHeight;
this.x = newX;
updatePosition(newWidth, newHeight);
};
break;
}
case "middleLeft": {
const mrX = round(savedX + savedWidth);
const newWidth = round(
Math.max(minWidth, Math.min(1, savedWidth - deltaX / parentWidth))
);
const newX = mrX - newWidth;
cmd = () => {
this.width = newWidth;
this.x = newX;
updatePosition(newWidth, savedHeight);
};
break;
}
}
this.addCommands({
cmd,
undo,
mustExec: true,
type: this.resizeType,
overwriteIfSameType: true,
keepUndo: true,
});
}
/**
* Render this editor in a div.
* @returns {HTMLDivElement}
@ -654,10 +922,35 @@ class AnnotationEditor {
}
}
/**
* @returns {number} the type to use in the undo/redo stack when resizing.
*/
get resizeType() {
return -1;
}
/**
* @returns {boolean} true if this editor can be resized.
*/
get isResizable() {
return false;
}
/**
* Add the resizers to this editor.
*/
makeResizable() {
if (this.isResizable) {
this.#createResizers();
this.#resizersDiv.classList.remove("hidden");
}
}
/**
* Select this editor.
*/
select() {
this.makeResizable();
this.div?.classList.add("selectedEditor");
}
@ -665,6 +958,7 @@ class AnnotationEditor {
* Unselect this editor.
*/
unselect() {
this.#resizersDiv?.classList.add("hidden");
this.div?.classList.remove("selectedEditor");
}
@ -735,17 +1029,10 @@ class AnnotationEditor {
const { style } = this.div;
style.aspectRatio = aspectRatio;
style.height = "auto";
if (aspectRatio >= 1) {
style.minHeight = `${RESIZER_SIZE}px`;
style.minWidth = `${Math.round(aspectRatio * RESIZER_SIZE)}px`;
} else {
style.minWidth = `${RESIZER_SIZE}px`;
style.minHeight = `${Math.round(RESIZER_SIZE / aspectRatio)}px`;
}
}
static get MIN_SIZE() {
return RESIZER_SIZE;
return 16;
}
}

View File

@ -79,6 +79,7 @@ class InkEditor extends AnnotationEditor {
this.translationX = this.translationY = 0;
this.x = 0;
this.y = 0;
this._willKeepAspectRatio = true;
}
/** @inheritdoc */
@ -156,6 +157,11 @@ class InkEditor extends AnnotationEditor {
];
}
/** @inheritdoc */
get resizeType() {
return AnnotationEditorParamsType.INK_DIMS;
}
/**
* Update the thickness and make this action undoable.
* @param {number} thickness
@ -619,6 +625,7 @@ class InkEditor extends AnnotationEditor {
this.div.classList.add("disabled");
this.#fitToContent(/* firstTime = */ true);
this.makeResizable();
this.parent.addInkEditorIfNeeded(/* isCommitting = */ true);
@ -754,6 +761,11 @@ class InkEditor extends AnnotationEditor {
this.#observer.observe(this.div);
}
/** @inheritdoc */
get isResizable() {
return !this.isEmpty() && this.#disableEditing;
}
/** @inheritdoc */
render() {
if (this.div) {

View File

@ -13,8 +13,11 @@
* limitations under the License.
*/
import {
AnnotationEditorParamsType,
AnnotationEditorType,
} from "../../shared/util.js";
import { AnnotationEditor } from "./editor.js";
import { AnnotationEditorType } from "../../shared/util.js";
import { PixelsPerInch } from "../display_utils.js";
import { StampAnnotationElement } from "../annotation_layer.js";
@ -123,6 +126,11 @@ class StampEditor extends AnnotationEditor {
}
}
/** @inheritdoc */
get resizeType() {
return AnnotationEditorParamsType.STAMP_DIMS;
}
/** @inheritdoc */
remove() {
if (this.#bitmapId) {
@ -170,6 +178,11 @@ class StampEditor extends AnnotationEditor {
);
}
/** @inheritdoc */
get isResizable() {
return true;
}
/** @inheritdoc */
render() {
if (this.div) {
@ -194,7 +207,6 @@ class StampEditor extends AnnotationEditor {
if (this.width) {
// This editor was created in using copy (ctrl+c).
const [parentWidth, parentHeight] = this.parentDimensions;
this.setAspectRatio(this.width * parentWidth, this.height * parentHeight);
this.setAt(
baseX * parentWidth,
baseY * parentHeight,
@ -233,8 +245,6 @@ class StampEditor extends AnnotationEditor {
(height * parentHeight) / pageHeight
);
this.setAspectRatio(width, height);
const canvas = (this.#canvas = document.createElement("canvas"));
div.append(canvas);
this.#drawBitmap(width, height);

View File

@ -280,6 +280,12 @@ class CommandManager {
this.#commands.push(save);
}
stopUndoAccumulation() {
if (this.#position !== -1) {
this.#commands[this.#position].type = NaN;
}
}
/**
* Undo the last command.
*/
@ -1168,6 +1174,10 @@ class AnnotationEditorUIManager {
return this.#selectedEditors.size !== 0;
}
stopUndoAccumulation() {
this.#commandManager.stopUndoAccumulation();
}
/**
* Undo the last command.
*/

View File

@ -83,6 +83,8 @@ const AnnotationEditorParamsType = {
INK_COLOR: 11,
INK_THICKNESS: 12,
INK_OPACITY: 13,
INK_DIMS: 14,
STAMP_DIMS: 21,
};
// Permission flags from Table 22, Section 7.6.3.2 of the PDF specification.

View File

@ -14,10 +14,18 @@
*/
:root {
--focus-outline: solid 2px blue;
--hover-outline: dashed 2px blue;
--outline-width: 2px;
--outline-color: blue;
--focus-outline: solid var(--outline-width) var(--outline-color);
--hover-outline: dashed var(--outline-width) var(--outline-color);
--freetext-line-height: 1.35;
--freetext-padding: 2px;
--resizer-size: 8px;
--resizer-shift: calc(
0px - var(--outline-width) - var(--resizer-size) / 2 - var(--outline-width) /
2
);
--resizer-color: white;
--editorFreeText-editing-cursor: text;
/*#if COMPONENTS*/
--editorInk-editing-cursor: pointer;
@ -37,8 +45,10 @@
@media screen and (forced-colors: active) {
:root {
--focus-outline: solid 3px ButtonText;
--hover-outline: dashed 3px ButtonText;
--outline-width: 3px;
--outline-color: ButtonText;
--resizer-size: 12px;
--resizer-color: ButtonFace;
}
}
@ -78,7 +88,6 @@
.annotationEditorLayer .selectedEditor {
outline: var(--focus-outline);
resize: none;
}
.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor) {
@ -92,13 +101,8 @@
max-height: 100%;
}
.annotationEditorLayer :is(.inkEditor, .stampEditor) {
overflow: auto;
}
.annotationEditorLayer .freeTextEditor {
padding: calc(var(--freetext-padding) * var(--scale-factor));
resize: none;
width: auto;
height: auto;
touch-action: none;
@ -111,7 +115,6 @@
left: 0;
overflow: visible;
white-space: nowrap;
resize: none;
font: 10px sans-serif;
line-height: var(--freetext-line-height);
user-select: none;
@ -141,14 +144,6 @@
user-select: auto;
}
.annotationEditorLayer .inkEditor.disabled {
resize: none;
}
.annotationEditorLayer .inkEditor.disabled.selectedEditor {
resize: horizontal;
}
.annotationEditorLayer
:is(.freeTextEditor, .inkEditor, .stampEditor):hover:not(.selectedEditor) {
outline: var(--hover-outline);
@ -160,7 +155,6 @@
}
.annotationEditorLayer .inkEditor.editing {
resize: none;
cursor: inherit;
}
@ -191,11 +185,107 @@
transition-delay: var(--loading-icon-delay);
}
.annotationEditorLayer .stampEditor.selectedEditor {
resize: horizontal;
}
.annotationEditorLayer .stampEditor canvas {
width: 100%;
height: 100%;
}
.annotationEditorLayer .resizers {
width: 100%;
height: 100%;
position: absolute;
inset: 0;
}
.annotationEditorLayer .resizers.hidden {
display: none;
}
.annotationEditorLayer .resizer {
width: var(--resizer-size);
height: var(--resizer-size);
border-radius: 50%;
background: var(--resizer-color);
border: var(--focus-outline);
position: absolute;
}
.annotationEditorLayer .resizer.topLeft {
cursor: nw-resize;
top: var(--resizer-shift);
left: var(--resizer-shift);
}
.annotationEditorLayer .resizer.topMiddle {
cursor: n-resize;
top: var(--resizer-shift);
left: calc(50% + var(--resizer-shift));
}
.annotationEditorLayer .resizer.topRight {
cursor: ne-resize;
top: var(--resizer-shift);
right: var(--resizer-shift);
}
.annotationEditorLayer .resizer.middleRight {
cursor: e-resize;
top: calc(50% + var(--resizer-shift));
right: var(--resizer-shift);
}
.annotationEditorLayer .resizer.bottomRight {
cursor: se-resize;
bottom: var(--resizer-shift);
right: var(--resizer-shift);
}
.annotationEditorLayer .resizer.bottomMiddle {
cursor: s-resize;
bottom: var(--resizer-shift);
left: calc(50% + var(--resizer-shift));
}
.annotationEditorLayer .resizer.bottomLeft {
cursor: sw-resize;
bottom: var(--resizer-shift);
left: var(--resizer-shift);
}
.annotationEditorLayer .resizer.middleLeft {
cursor: w-resize;
top: calc(50% + var(--resizer-shift));
left: var(--resizer-shift);
}
.annotationEditorLayer.resizingTopLeft {
cursor: nw-resize;
}
.annotationEditorLayer.resizingTopMiddle {
cursor: n-resize;
}
.annotationEditorLayer.resizingTopRight {
cursor: ne-resize;
}
.annotationEditorLayer.resizingMiddleRight {
cursor: e-resize;
}
.annotationEditorLayer.resizingBottomRight {
cursor: se-resize;
}
.annotationEditorLayer.resizingBottomMiddle {
cursor: s-resize;
}
.annotationEditorLayer.resizingBottomLeft {
cursor: sw-resize;
}
.annotationEditorLayer.resizingMiddleLeft {
cursor: w-resize;
}