From 2688bf2ebd1f338bf7e320b4fbcb646bc6ab9c60 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 24 Jul 2023 09:49:49 +0200 Subject: [PATCH] [Editor] Add some resizers all around an editor (bug 1843302) - it'll improve the way to resize images: diagonally (in keeping ratio between dimensions) or horizontally/vertically. - the resizer was almost invisible in HCM. - make a resize undoable/redoable. --- src/display/editor/editor.js | 313 +++++++++++++++++++++++- src/display/editor/ink.js | 12 + src/display/editor/stamp.js | 18 +- src/display/editor/tools.js | 10 + src/shared/util.js | 2 + web/annotation_editor_layer_builder.css | 138 +++++++++-- 6 files changed, 452 insertions(+), 41 deletions(-) diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 2c2aae175..d41d66c1f 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -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; } } diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index efda7cecb..471fd04a8 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -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) { diff --git a/src/display/editor/stamp.js b/src/display/editor/stamp.js index 950bf8071..8c26d0ce6 100644 --- a/src/display/editor/stamp.js +++ b/src/display/editor/stamp.js @@ -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); diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 641c93db4..5c4fd9982 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -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. */ diff --git a/src/shared/util.js b/src/shared/util.js index d8759c579..3b5ea5614 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -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. diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 312a190b6..eaae3c7a8 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -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); } @@ -139,14 +142,6 @@ outline: none; } -.annotationEditorLayer .inkEditor.disabled { - resize: none; -} - -.annotationEditorLayer .inkEditor.disabled.selectedEditor { - resize: horizontal; -} - .annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor):hover:not(.selectedEditor) { outline: var(--hover-outline); @@ -158,7 +153,6 @@ } .annotationEditorLayer .inkEditor.editing { - resize: none; cursor: inherit; } @@ -189,11 +183,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; +}