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

[Editor] Support resizing editors with the keyboard (bug 1854340)

This commit is contained in:
Calixte Denizet 2023-10-03 15:02:54 +02:00
parent 426209c6e6
commit 05ca3fd99b
5 changed files with 469 additions and 88 deletions

View file

@ -15,10 +15,13 @@
// eslint-disable-next-line max-len
/** @typedef {import("./annotation_editor_layer.js").AnnotationEditorLayer} AnnotationEditorLayer */
// eslint-disable-next-line max-len
/** @typedef {import("./tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
import { bindEvents, ColorManager } from "./tools.js";
import {
AnnotationEditorUIManager,
bindEvents,
ColorManager,
KeyboardManager,
} from "./tools.js";
import { FeatureTest, shadow, unreachable } from "../../shared/util.js";
import { noContextMenu } from "../display_utils.js";
@ -35,6 +38,8 @@ import { noContextMenu } from "../display_utils.js";
* Base class for editors.
*/
class AnnotationEditor {
#allResizerDivs = null;
#altText = "";
#altTextDecorative = false;
@ -51,16 +56,22 @@ class AnnotationEditor {
#resizersDiv = null;
#savedDimensions = null;
#boundFocusin = this.focusin.bind(this);
#boundFocusout = this.focusout.bind(this);
#focusedResizerName = "";
#hasBeenClicked = false;
#isEditing = false;
#isInEditMode = false;
#isResizerEnabledForKeyboard = false;
#moveInDOMTimeout = null;
_initialOptions = Object.create(null);
@ -85,6 +96,39 @@ class AnnotationEditor {
// button to edit the alt text is visually moved outside of the editor.
static SMALL_EDITOR_SIZE = 0;
static get _resizerKeyboardManager() {
const resize = AnnotationEditor.prototype._resizeWithKeyboard;
const small = AnnotationEditorUIManager.TRANSLATE_SMALL;
const big = AnnotationEditorUIManager.TRANSLATE_BIG;
return shadow(
this,
"_resizerKeyboardManager",
new KeyboardManager([
[["ArrowLeft", "mac+ArrowLeft"], resize, { args: [-small, 0] }],
[
["ctrl+ArrowLeft", "mac+shift+ArrowLeft"],
resize,
{ args: [-big, 0] },
],
[["ArrowRight", "mac+ArrowRight"], resize, { args: [small, 0] }],
[
["ctrl+ArrowRight", "mac+shift+ArrowRight"],
resize,
{ args: [big, 0] },
],
[["ArrowUp", "mac+ArrowUp"], resize, { args: [0, -small] }],
[["ctrl+ArrowUp", "mac+shift+ArrowUp"], resize, { args: [0, -big] }],
[["ArrowDown", "mac+ArrowDown"], resize, { args: [0, small] }],
[["ctrl+ArrowDown", "mac+shift+ArrowDown"], resize, { args: [0, big] }],
[
["Escape", "mac+Escape"],
AnnotationEditor.prototype._stopResizingWithKeyboard,
],
])
);
}
/**
* @param {AnnotationEditorParameters} parameters
*/
@ -157,6 +201,14 @@ class AnnotationEditor {
"editor_alt_text_button_label",
"editor_alt_text_edit_button_label",
"editor_alt_text_decorative_tooltip",
"editor_resizer_label_topLeft",
"editor_resizer_label_topMiddle",
"editor_resizer_label_topRight",
"editor_resizer_label_middleRight",
"editor_resizer_label_bottomRight",
"editor_resizer_label_bottomMiddle",
"editor_resizer_label_bottomLeft",
"editor_resizer_label_middleLeft",
].map(str => [str, l10n.get(str)])
);
if (options?.strings) {
@ -277,6 +329,9 @@ class AnnotationEditor {
if (parent !== null) {
this.pageIndex = parent.pageIndex;
this.pageDimensions = parent.pageDimensions;
} else {
// The editor is being removed from the DOM, so we need to stop resizing.
this.#stopResizing();
}
this.parent = parent;
}
@ -600,19 +655,32 @@ class AnnotationEditor {
}
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");
}
// When the resizers are used with the keyboard, they're focusable, hence
// we want to have them in this order (top left, top middle, top right, ...)
// in the DOM to have the focus order correct.
const classes = this._willKeepAspectRatio
? ["topLeft", "topRight", "bottomRight", "bottomLeft"]
: [
"topLeft",
"topMiddle",
"topRight",
"middleRight",
"bottomRight",
"bottomMiddle",
"bottomLeft",
"middleLeft",
];
for (const name of classes) {
const div = document.createElement("div");
this.#resizersDiv.append(div);
div.classList.add("resizer", name);
div.setAttribute("data-resizer-name", name);
div.addEventListener(
"pointerdown",
this.#resizerPointerdown.bind(this, name)
);
div.addEventListener("contextmenu", noContextMenu);
div.tabIndex = -1;
}
this.div.prepend(this.#resizersDiv);
}
@ -659,40 +727,7 @@ class AnnotationEditor {
this.parent.div.style.cursor = savedParentCursor;
this.div.style.cursor = savedCursor;
const newX = this.x;
const newY = this.y;
const newWidth = this.width;
const newHeight = this.height;
if (
newX === savedX &&
newY === savedY &&
newWidth === savedWidth &&
newHeight === savedHeight
) {
return;
}
this.addCommands({
cmd: () => {
this.width = newWidth;
this.height = newHeight;
this.x = newX;
this.y = newY;
const [parentWidth, parentHeight] = this.parentDimensions;
this.setDims(parentWidth * newWidth, parentHeight * newHeight);
this.fixAndSetPosition();
},
undo: () => {
this.width = savedWidth;
this.height = savedHeight;
this.x = savedX;
this.y = savedY;
const [parentWidth, parentHeight] = this.parentDimensions;
this.setDims(parentWidth * savedWidth, parentHeight * savedHeight);
this.fixAndSetPosition();
},
mustExec: true,
});
this.#addResizeToUndoStack(savedX, savedY, savedWidth, savedHeight);
};
window.addEventListener("pointerup", pointerUpCallback);
// If the user switches to another window (with alt+tab), then we end the
@ -700,6 +735,43 @@ class AnnotationEditor {
window.addEventListener("blur", pointerUpCallback);
}
#addResizeToUndoStack(savedX, savedY, savedWidth, savedHeight) {
const newX = this.x;
const newY = this.y;
const newWidth = this.width;
const newHeight = this.height;
if (
newX === savedX &&
newY === savedY &&
newWidth === savedWidth &&
newHeight === savedHeight
) {
return;
}
this.addCommands({
cmd: () => {
this.width = newWidth;
this.height = newHeight;
this.x = newX;
this.y = newY;
const [parentWidth, parentHeight] = this.parentDimensions;
this.setDims(parentWidth * newWidth, parentHeight * newHeight);
this.fixAndSetPosition();
},
undo: () => {
this.width = savedWidth;
this.height = savedHeight;
this.x = savedX;
this.y = savedY;
const [parentWidth, parentHeight] = this.parentDimensions;
this.setDims(parentWidth * savedWidth, parentHeight * savedHeight);
this.fixAndSetPosition();
},
mustExec: true,
});
}
#resizerPointermove(name, event) {
const [parentWidth, parentHeight] = this.parentDimensions;
const savedX = this.x;
@ -1205,12 +1277,12 @@ class AnnotationEditor {
}
/**
* If it returns true, then this editor handle the keyboard
* If it returns true, then this editor handles the keyboard
* events itself.
* @returns {boolean}
*/
shouldGetKeyboardEvents() {
return false;
return this.#isResizerEnabledForKeyboard;
}
/**
@ -1303,6 +1375,7 @@ class AnnotationEditor {
clearTimeout(this.#moveInDOMTimeout);
this.#moveInDOMTimeout = null;
}
this.#stopResizing();
}
/**
@ -1319,9 +1392,140 @@ class AnnotationEditor {
if (this.isResizable) {
this.#createResizers();
this.#resizersDiv.classList.remove("hidden");
bindEvents(this, this.div, ["keydown"]);
}
}
/**
* onkeydown callback.
* @param {KeyboardEvent} event
*/
keydown(event) {
if (
!this.isResizable ||
event.target !== this.div ||
event.key !== "Enter"
) {
return;
}
this._uiManager.setSelected(this);
this.#savedDimensions = {
savedX: this.x,
savedY: this.y,
savedWidth: this.width,
savedHeight: this.height,
};
const children = this.#resizersDiv.children;
if (!this.#allResizerDivs) {
this.#allResizerDivs = Array.from(children);
const boundResizerKeydown = this.#resizerKeydown.bind(this);
const boundResizerBlur = this.#resizerBlur.bind(this);
for (const div of this.#allResizerDivs) {
const name = div.getAttribute("data-resizer-name");
div.addEventListener("keydown", boundResizerKeydown);
div.addEventListener("blur", boundResizerBlur);
div.addEventListener("focus", this.#resizerFocus.bind(this, name));
AnnotationEditor._l10nPromise
.get(`editor_resizer_label_${name}`)
.then(msg => div.setAttribute("aria-label", msg));
}
}
// We want to have the resizers in the visual order, so we move the first
// (top-left) to the right place.
const first = this.#allResizerDivs[0];
let firstPosition = 0;
for (const div of children) {
if (div === first) {
break;
}
firstPosition++;
}
const nextFirstPosition =
(((360 - this.rotation + this.parentRotation) % 360) / 90) *
(this.#allResizerDivs.length / 4);
if (nextFirstPosition !== firstPosition) {
// We need to reorder the resizers in the DOM in order to have the focus
// on the top-left one.
if (nextFirstPosition < firstPosition) {
for (let i = 0; i < firstPosition - nextFirstPosition; i++) {
this.#resizersDiv.append(this.#resizersDiv.firstChild);
}
} else if (nextFirstPosition > firstPosition) {
for (let i = 0; i < nextFirstPosition - firstPosition; i++) {
this.#resizersDiv.firstChild.before(this.#resizersDiv.lastChild);
}
}
let i = 0;
for (const child of children) {
const div = this.#allResizerDivs[i++];
const name = div.getAttribute("data-resizer-name");
AnnotationEditor._l10nPromise
.get(`editor_resizer_label_${name}`)
.then(msg => child.setAttribute("aria-label", msg));
}
}
this.#setResizerTabIndex(0);
this.#isResizerEnabledForKeyboard = true;
this.#resizersDiv.firstChild.focus({ focusVisible: true });
event.preventDefault();
event.stopImmediatePropagation();
}
#resizerKeydown(event) {
AnnotationEditor._resizerKeyboardManager.exec(this, event);
}
#resizerBlur(event) {
if (
this.#isResizerEnabledForKeyboard &&
event.relatedTarget?.parentNode !== this.#resizersDiv
) {
this.#stopResizing();
}
}
#resizerFocus(name) {
this.#focusedResizerName = this.#isResizerEnabledForKeyboard ? name : "";
}
#setResizerTabIndex(value) {
if (!this.#allResizerDivs) {
return;
}
for (const div of this.#allResizerDivs) {
div.tabIndex = value;
}
}
_resizeWithKeyboard(x, y) {
if (!this.#isResizerEnabledForKeyboard) {
return;
}
this.#resizerPointermove(this.#focusedResizerName, {
movementX: x,
movementY: y,
});
}
#stopResizing() {
this.#isResizerEnabledForKeyboard = false;
this.#setResizerTabIndex(-1);
if (this.#savedDimensions) {
const { savedX, savedY, savedWidth, savedHeight } = this.#savedDimensions;
this.#addResizeToUndoStack(savedX, savedY, savedWidth, savedHeight);
this.#savedDimensions = null;
}
}
_stopResizingWithKeyboard() {
this.#stopResizing();
this.div.focus();
}
/**
* Select this editor.
*/

View file

@ -1021,7 +1021,7 @@ class AnnotationEditorUIManager {
* @param {KeyboardEvent} event
*/
keydown(event) {
if (!this.getActive()?.shouldGetKeyboardEvents()) {
if (!this.isEditorHandlingKeyboard) {
AnnotationEditorUIManager._keyboardManager.exec(this, event);
}
}
@ -1732,6 +1732,14 @@ class AnnotationEditorUIManager {
}
}
get isEditorHandlingKeyboard() {
return (
this.getActive()?.shouldGetKeyboardEvents() ||
(this.#selectedEditors.size === 1 &&
this.#selectedEditors.values().next().value.shouldGetKeyboardEvents())
);
}
/**
* Is the current editor the one passed as argument?
* @param {AnnotationEditor} editor