Merge pull request #17062 from calixteman/resize_keyboard

[Editor] Support resizing editors with the keyboard (bug 1854340)
This commit is contained in:
calixteman 2023-10-04 13:55:04 +02:00 committed by GitHub
commit 40d6b0e1bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 469 additions and 88 deletions

View File

@ -280,3 +280,21 @@ editor_alt_text_save_button=Save
editor_alt_text_decorative_tooltip=Marked as decorative
# This is a placeholder for the alt text input area
editor_alt_text_textarea.placeholder=For example, “A young man sits down at a table to eat a meal”
# Editor resizers
# LOCALIZATION NOTE (editor_resizer_label_topLeft): This is used in an aria label to help to understand the role of the resizer.
editor_resizer_label_topLeft=Top left corner — resize
# LOCALIZATION NOTE (editor_resizer_label_topMiddle): This is used in an aria label to help to understand the role of the resizer.
editor_resizer_label_topMiddle=Top middle — resize
# LOCALIZATION NOTE (editor_resizer_label_topRight): This is used in an aria label to help to understand the role of the resizer.
editor_resizer_label_topRight=Top right corner — resize
# LOCALIZATION NOTE (editor_resizer_label_middleRight): This is used in an aria label to help to understand the role of the resizer.
editor_resizer_label_middleRight=Middle right — resize
# LOCALIZATION NOTE (editor_resizer_label_bottomRight): This is used in an aria label to help to understand the role of the resizer.
editor_resizer_label_bottomRight=Bottom right corner — resize
# LOCALIZATION NOTE (editor_resizer_label_bottomMiddle): This is used in an aria label to help to understand the role of the resizer.
editor_resizer_label_bottomMiddle=Bottom middle — resize
# LOCALIZATION NOTE (editor_resizer_label_bottomLeft): This is used in an aria label to help to understand the role of the resizer.
editor_resizer_label_bottomLeft=Bottom left corner — resize
# LOCALIZATION NOTE (editor_resizer_label_middleLeft): This is used in an aria label to help to understand the role of the resizer.
editor_resizer_label_middleLeft=Middle left — resize

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

View File

@ -17,6 +17,7 @@ const {
closePages,
getEditorDimensions,
getEditorSelector,
getFirstSerialized,
loadAndWait,
serializeBitmapDimensions,
waitForAnnotationEditorLayer,
@ -59,6 +60,53 @@ const waitForImage = async (page, selector) => {
await page.waitForSelector(`${selector} .altText`);
};
const copyImage = async (page, imagePath, number) => {
const data = fs
.readFileSync(path.join(__dirname, imagePath))
.toString("base64");
await page.evaluate(async imageData => {
const resp = await fetch(`data:image/png;base64,${imageData}`);
const blob = await resp.blob();
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
}, data);
let hasPasteEvent = false;
while (!hasPasteEvent) {
// We retry to paste if nothing has been pasted before 500ms.
const promise = Promise.race([
page.evaluate(
() =>
new Promise(resolve => {
document.addEventListener(
"paste",
e => resolve(e.clipboardData.items.length !== 0),
{
once: true,
}
);
})
),
page.evaluate(
() =>
new Promise(resolve => {
setTimeout(() => resolve(false), 500);
})
),
]);
await page.keyboard.down("Control");
await page.keyboard.press("v");
await page.keyboard.up("Control");
hasPasteEvent = await promise;
}
await waitForImage(page, getEditorSelector(number));
};
describe("Stamp Editor", () => {
describe("Basic operations", () => {
let pages;
@ -235,50 +283,7 @@ describe("Stamp Editor", () => {
pages.map(async ([browserName, page]) => {
await page.click("#editorStamp");
const data = fs
.readFileSync(path.join(__dirname, "../images/firefox_logo.png"))
.toString("base64");
await page.evaluate(async imageData => {
const resp = await fetch(`data:image/png;base64,${imageData}`);
const blob = await resp.blob();
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
}, data);
let hasPasteEvent = false;
while (!hasPasteEvent) {
// We retry to paste if nothing has been pasted before 500ms.
const promise = Promise.race([
page.evaluate(
() =>
new Promise(resolve => {
document.addEventListener(
"paste",
e => resolve(e.clipboardData.items.length !== 0),
{
once: true,
}
);
})
),
page.evaluate(
() =>
new Promise(resolve => {
setTimeout(() => resolve(false), 500);
})
),
]);
await page.keyboard.down("Control");
await page.keyboard.press("v");
await page.keyboard.up("Control");
hasPasteEvent = await promise;
}
await waitForImage(page, getEditorSelector(0));
await copyImage(page, "../images/firefox_logo.png", 0);
// Wait for the alt-text button to be visible.
const buttonSelector = `${getEditorSelector(0)} button.altText`;
@ -432,4 +437,142 @@ describe("Stamp Editor", () => {
);
});
});
describe("Resize an image with the keyboard", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait("empty.pdf", ".annotationEditorLayer", 50);
});
afterAll(async () => {
await closePages(pages);
});
it("must check that the dimensions change", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await page.click("#editorStamp");
await copyImage(page, "../images/firefox_logo.png", 0);
const editorSelector = getEditorSelector(0);
await page.click(editorSelector);
await waitForSelectedEditor(page, editorSelector);
await page.waitForSelector(
`${editorSelector} .resizer.topLeft[tabindex="-1"]`
);
const getDims = async () => {
const [blX, blY, trX, trY] = await getFirstSerialized(
page,
x => x.rect
);
return [trX - blX, trY - blY];
};
const [width, height] = await getDims();
// Press Enter to enter in resize-with-keyboard mode.
await page.keyboard.press("Enter");
// The resizer must become keyboard focusable.
await page.waitForSelector(
`${editorSelector} .resizer.topLeft[tabindex="0"]`
);
let prevWidth = width;
let prevHeight = height;
const waitForDimsChange = async (w, h) => {
await page.waitForFunction(
(prevW, prevH) => {
const [x1, y1, x2, y2] =
window.PDFViewerApplication.pdfDocument.annotationStorage.serializable.map
.values()
.next().value.rect;
const newWidth = x2 - x1;
const newHeight = y2 - y1;
return newWidth !== prevW || newHeight !== prevH;
},
{},
w,
h
);
};
for (let i = 0; i < 40; i++) {
await page.keyboard.press("ArrowLeft");
await waitForDimsChange(prevWidth, prevHeight);
[prevWidth, prevHeight] = await getDims();
}
let [newWidth, newHeight] = await getDims();
expect(newWidth > width + 30)
.withContext(`In ${browserName}`)
.toEqual(true);
expect(newHeight > height + 30)
.withContext(`In ${browserName}`)
.toEqual(true);
for (let i = 0; i < 4; i++) {
await page.keyboard.down("Control");
await page.keyboard.press("ArrowRight");
await page.keyboard.up("Control");
await waitForDimsChange(prevWidth, prevHeight);
[prevWidth, prevHeight] = await getDims();
}
[newWidth, newHeight] = await getDims();
expect(Math.abs(newWidth - width) < 2)
.withContext(`In ${browserName}`)
.toEqual(true);
expect(Math.abs(newHeight - height) < 2)
.withContext(`In ${browserName}`)
.toEqual(true);
// Move the focus to the next resizer.
await page.keyboard.press("Tab");
await page.waitForFunction(
() => !!document.activeElement?.classList.contains("topMiddle")
);
for (let i = 0; i < 40; i++) {
await page.keyboard.press("ArrowUp");
await waitForDimsChange(prevWidth, prevHeight);
[prevWidth, prevHeight] = await getDims();
}
[, newHeight] = await getDims();
expect(newHeight > height + 50)
.withContext(`In ${browserName}`)
.toEqual(true);
for (let i = 0; i < 4; i++) {
await page.keyboard.down("Control");
await page.keyboard.press("ArrowDown");
await page.keyboard.up("Control");
await waitForDimsChange(prevWidth, prevHeight);
[prevWidth, prevHeight] = await getDims();
}
[, newHeight] = await getDims();
expect(Math.abs(newHeight - height) < 2)
.withContext(`In ${browserName}`)
.toEqual(true);
// Escape should remove the focus from the resizer.
await page.keyboard.press("Escape");
await page.waitForSelector(
`${editorSelector} .resizer.topLeft[tabindex="-1"]`
);
await page.waitForFunction(
() => !document.activeElement?.classList.contains("resizer")
);
})
);
});
});
});

View File

@ -85,6 +85,14 @@ const DEFAULT_L10N_STRINGS = {
editor_alt_text_button_label: "Alt text",
editor_alt_text_edit_button_label: "Edit alt text",
editor_alt_text_decorative_tooltip: "Marked as decorative",
editor_resizer_label_topLeft: "Top left corner — resize",
editor_resizer_label_topMiddle: "Top middle — resize",
editor_resizer_label_topRight: "Top right corner — resize",
editor_resizer_label_middleRight: "Middle right — resize",
editor_resizer_label_bottomRight: "Bottom right corner — resize",
editor_resizer_label_bottomMiddle: "Bottom middle — resize",
editor_resizer_label_bottomLeft: "Bottom left corner — resize",
editor_resizer_label_middleLeft: "Middle left — resize",
};
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
DEFAULT_L10N_STRINGS.print_progress_percent = "{{progress}}%";