diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index b0369cc22..40f684d1d 100644 --- a/l10n/en-US/viewer.properties +++ b/l10n/en-US/viewer.properties @@ -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 diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index ea15eb434..026284ced 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -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. */ diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index b631e9798..a03bb0bf4 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -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 diff --git a/test/integration/stamp_editor_spec.js b/test/integration/stamp_editor_spec.js index 9b79b1863..e57677d3c 100644 --- a/test/integration/stamp_editor_spec.js +++ b/test/integration/stamp_editor_spec.js @@ -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") + ); + }) + ); + }); + }); }); diff --git a/web/l10n_utils.js b/web/l10n_utils.js index 097a02221..56c22b202 100644 --- a/web/l10n_utils.js +++ b/web/l10n_utils.js @@ -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}}%";