diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 04296b46f..180c3c1b7 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -276,6 +276,13 @@ class AnnotationEditor { this.div?.classList.toggle("draggable", value); } + /** + * @returns {boolean} true if the editor handles the Enter key itself. + */ + get isEnterHandled() { + return true; + } + center() { const [pageWidth, pageHeight] = this.pageDimensions; switch (this.parentRotation) { diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index a03bb0bf4..1b180ac8f 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -605,10 +605,8 @@ class AnnotationEditorUIManager { const arrowChecker = self => { // If the focused element is an input, we don't want to handle the arrow. // For example, sliders can be controlled with the arrow keys. - const { activeElement } = document; return ( - activeElement && - self.#container.contains(activeElement) && + self.#container.contains(document.activeElement) && self.hasSomethingToControl() ); }; @@ -650,6 +648,28 @@ class AnnotationEditorUIManager { ], proto.delete, ], + [ + ["Enter", "mac+Enter"], + proto.addNewEditorFromKeyboard, + { + // Those shortcuts can be used in the toolbar for some other actions + // like zooming, hence we need to check if the container has the + // focus. + checker: self => + self.#container.contains(document.activeElement) && + !self.isEnterHandled, + }, + ], + [ + [" ", "mac+ "], + proto.addNewEditorFromKeyboard, + { + // Those shortcuts can be used in the toolbar for some other actions + // like zooming, hence we need to check if the container has the + // focus. + checker: self => self.#container.contains(document.activeElement), + }, + ], [["Escape", "mac+Escape"], proto.unselectAll], [ ["ArrowLeft", "mac+ArrowLeft"], @@ -1147,8 +1167,10 @@ class AnnotationEditorUIManager { * Change the editor mode (None, FreeText, Ink, ...) * @param {number} mode * @param {string|null} editId + * @param {boolean} [isFromKeyboard] - true if the mode change is due to a + * keyboard action. */ - updateMode(mode, editId = null) { + updateMode(mode, editId = null, isFromKeyboard = false) { if (this.#mode === mode) { return; } @@ -1164,6 +1186,11 @@ class AnnotationEditorUIManager { for (const layer of this.#allLayers.values()) { layer.updateMode(mode); } + if (!editId && isFromKeyboard) { + this.addNewEditorFromKeyboard(); + return; + } + if (!editId) { return; } @@ -1176,6 +1203,10 @@ class AnnotationEditorUIManager { } } + addNewEditorFromKeyboard() { + this.currentLayer.addNewEditor(); + } + /** * Update the toolbar if it's required to reflect the tool currently used. * @param {number} mode @@ -1201,7 +1232,7 @@ class AnnotationEditorUIManager { return; } if (type === AnnotationEditorParamsType.CREATE) { - this.currentLayer.addNewEditor(type); + this.currentLayer.addNewEditor(); return; } @@ -1416,6 +1447,10 @@ class AnnotationEditorUIManager { return this.#selectedEditors.has(editor); } + get firstSelectedEditor() { + return this.#selectedEditors.values().next().value; + } + /** * Unselect an editor. * @param {AnnotationEditor} editor @@ -1432,6 +1467,13 @@ class AnnotationEditorUIManager { return this.#selectedEditors.size !== 0; } + get isEnterHandled() { + return ( + this.#selectedEditors.size === 1 && + this.firstSelectedEditor.isEnterHandled + ); + } + /** * Undo the last command. */ @@ -1736,7 +1778,7 @@ class AnnotationEditorUIManager { return ( this.getActive()?.shouldGetKeyboardEvents() || (this.#selectedEditors.size === 1 && - this.#selectedEditors.values().next().value.shouldGetKeyboardEvents()) + this.firstSelectedEditor.shouldGetKeyboardEvents()) ); } diff --git a/test/integration/freetext_editor_spec.js b/test/integration/freetext_editor_spec.js index 805f37377..7a2889b76 100644 --- a/test/integration/freetext_editor_spec.js +++ b/test/integration/freetext_editor_spec.js @@ -2808,4 +2808,159 @@ describe("FreeText Editor", () => { ); }); }); + + describe("Create editor with keyboard", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("empty.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must create an editor from the toolbar", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await page.focus("#editorFreeText"); + await page.keyboard.press("Enter"); + + let selectorEditor = getEditorSelector(0); + await page.waitForSelector(selectorEditor, { + visible: true, + }); + + let xy = await getXY(page, selectorEditor); + for (let i = 0; i < 5; i++) { + await page.keyboard.down("Control"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.up("Control"); + await waitForPositionChange(page, selectorEditor, xy); + xy = await getXY(page, selectorEditor); + } + + const data = "Hello PDF.js World !!"; + await page.type(`${selectorEditor} .internal`, data); + + // Commit. + await page.keyboard.press("Escape"); + await page.waitForSelector(`${selectorEditor} .overlay.enabled`); + + let content = await page.$eval(selectorEditor, el => + el.innerText.trimEnd() + ); + + expect(content).withContext(`In ${browserName}`).toEqual(data); + + // Disable editing mode. + await page.click("#editorFreeText"); + await page.waitForSelector( + `.annotationEditorLayer:not(.freetextEditing)` + ); + + await page.focus("#editorFreeText"); + await page.keyboard.press(" "); + selectorEditor = getEditorSelector(1); + await page.waitForSelector(selectorEditor, { + visible: true, + }); + + xy = await getXY(page, selectorEditor); + for (let i = 0; i < 5; i++) { + await page.keyboard.down("Control"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.up("Control"); + await waitForPositionChange(page, selectorEditor, xy); + xy = await getXY(page, selectorEditor); + } + + await page.type(`${selectorEditor} .internal`, data); + + // Commit. + await page.keyboard.press("Escape"); + await page.waitForSelector(`${selectorEditor} .overlay.enabled`); + + // Unselect. + await page.keyboard.press("Escape"); + await waitForUnselectedEditor(page, selectorEditor); + + content = await page.$eval(getEditorSelector(1), el => + el.innerText.trimEnd() + ); + + expect(content).withContext(`In ${browserName}`).toEqual(data); + }) + ); + }); + + it("must create an editor with keyboard", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await page.keyboard.press("Enter"); + let selectorEditor = getEditorSelector(2); + await page.waitForSelector(selectorEditor, { + visible: true, + }); + + let xy = await getXY(page, selectorEditor); + for (let i = 0; i < 10; i++) { + await page.keyboard.down("Control"); + await page.keyboard.press("ArrowLeft"); + await page.keyboard.up("Control"); + await waitForPositionChange(page, selectorEditor, xy); + xy = await getXY(page, selectorEditor); + } + + const data = "Hello PDF.js World !!"; + await page.type(`${selectorEditor} .internal`, data); + + // Commit. + await page.keyboard.press("Escape"); + await page.waitForSelector(`${selectorEditor} .overlay.enabled`); + + // Unselect. + await page.keyboard.press("Escape"); + await waitForUnselectedEditor(page, selectorEditor); + + let content = await page.$eval(getEditorSelector(2), el => + el.innerText.trimEnd() + ); + + expect(content).withContext(`In ${browserName}`).toEqual(data); + + await page.keyboard.press(" "); + selectorEditor = getEditorSelector(3); + await page.waitForSelector(selectorEditor, { + visible: true, + }); + + xy = await getXY(page, selectorEditor); + for (let i = 0; i < 10; i++) { + await page.keyboard.down("Control"); + await page.keyboard.press("ArrowRight"); + await page.keyboard.up("Control"); + await waitForPositionChange(page, selectorEditor, xy); + xy = await getXY(page, selectorEditor); + } + + await page.type(`${selectorEditor} .internal`, data); + + // Commit. + await page.keyboard.press("Escape"); + await page.waitForSelector(`${selectorEditor} .overlay.enabled`); + + // Unselect. + await page.keyboard.press("Escape"); + await waitForUnselectedEditor(page, selectorEditor); + + content = await page.$eval(selectorEditor, el => + el.innerText.trimEnd() + ); + + expect(content).withContext(`In ${browserName}`).toEqual(data); + }) + ); + }); + }); }); diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 94d9acc9a..71e10f0bf 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -2208,7 +2208,7 @@ class PDFViewer { /** * @param {number} mode - AnnotationEditor mode (None, FreeText, Ink, ...) */ - set annotationEditorMode({ mode, editId = null }) { + set annotationEditorMode({ mode, editId = null, isFromKeyboard = false }) { if (!this.#annotationEditorUIManager) { throw new Error(`The AnnotationEditor is not enabled.`); } @@ -2227,7 +2227,7 @@ class PDFViewer { mode, }); - this.#annotationEditorUIManager.updateMode(mode, editId); + this.#annotationEditorUIManager.updateMode(mode, editId, isFromKeyboard); } // eslint-disable-next-line accessor-pairs diff --git a/web/toolbar.js b/web/toolbar.js index 550685e59..879f57b2b 100644 --- a/web/toolbar.js +++ b/web/toolbar.js @@ -162,7 +162,12 @@ class Toolbar { for (const { element, eventName, eventDetails } of this.buttons) { element.addEventListener("click", evt => { if (eventName !== null) { - this.eventBus.dispatch(eventName, { source: this, ...eventDetails }); + this.eventBus.dispatch(eventName, { + source: this, + ...eventDetails, + // evt.detail is the number of clicks. + isFromKeyboard: evt.detail === 0, + }); } }); }