From 334f0eb06050884cdde9abd09a2d769da0bf5f47 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 8 Nov 2023 18:13:15 +0100 Subject: [PATCH] [Editor] Add a toolbar to selected editors with a button to delete it (bug 1863763) --- gulpfile.mjs | 1 + src/display/editor/editor.js | 23 ++++ src/display/editor/ink.js | 2 +- src/display/editor/toolbar.js | 97 +++++++++++++++ src/display/editor/tools.js | 5 +- test/integration/freetext_editor_spec.mjs | 134 +++++++++++++++++++++ web/annotation_editor_layer_builder.css | 139 ++++++++++++++++++++++ web/images/editor-toolbar-delete.svg | 5 + 8 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 src/display/editor/toolbar.js create mode 100644 web/images/editor-toolbar-delete.svg diff --git a/gulpfile.mjs b/gulpfile.mjs index 2de6f1f8b..cbdc8fe73 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -1069,6 +1069,7 @@ function buildComponents(defines, dir) { "web/images/annotation-*.svg", "web/images/loading-icon.gif", "web/images/altText_*.svg", + "web/images/editor-toolbar-*.svg", ]; return merge([ diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 6fb2b4534..ae1f01920 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -23,6 +23,7 @@ import { KeyboardManager, } from "./tools.js"; import { FeatureTest, shadow, unreachable } from "../../shared/util.js"; +import { EditorToolbar } from "./toolbar.js"; import { noContextMenu } from "../display_utils.js"; /** @@ -62,6 +63,8 @@ class AnnotationEditor { #boundFocusout = this.focusout.bind(this); + #editToolbar = null; + #focusedResizerName = ""; #hasBeenClicked = false; @@ -1034,6 +1037,22 @@ class AnnotationEditor { this.#altTextWasFromKeyBoard = false; } + addEditToolbar() { + if (this.#editToolbar || this.#isInEditMode) { + return; + } + this.#editToolbar = new EditorToolbar(this); + this.div.append(this.#editToolbar.render()); + } + + removeEditToolbar() { + if (!this.#editToolbar) { + return; + } + this.#editToolbar.remove(); + this.#editToolbar = null; + } + getClientDimensions() { return this.div.getBoundingClientRect(); } @@ -1386,6 +1405,7 @@ class AnnotationEditor { this.#moveInDOMTimeout = null; } this.#stopResizing(); + this.removeEditToolbar(); } /** @@ -1543,6 +1563,8 @@ class AnnotationEditor { select() { this.makeResizable(); this.div?.classList.add("selectedEditor"); + this.addEditToolbar(); + this.#editToolbar?.show(); } /** @@ -1556,6 +1578,7 @@ class AnnotationEditor { // go. this._uiManager.currentLayer.div.focus(); } + this.#editToolbar?.hide(); } /** diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index ee2ca03eb..cc5288706 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -624,7 +624,7 @@ class InkEditor extends AnnotationEditor { this.div.classList.add("disabled"); this.#fitToContent(/* firstTime = */ true); - this.makeResizable(); + this.select(); this.parent.addInkEditorIfNeeded(/* isCommitting = */ true); diff --git a/src/display/editor/toolbar.js b/src/display/editor/toolbar.js new file mode 100644 index 000000000..422fd8bdd --- /dev/null +++ b/src/display/editor/toolbar.js @@ -0,0 +1,97 @@ +/* Copyright 2023 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { noContextMenu } from "../display_utils.js"; + +class EditorToolbar { + #toolbar = null; + + #editor; + + #buttons = null; + + constructor(editor) { + this.#editor = editor; + } + + render() { + const editToolbar = (this.#toolbar = document.createElement("div")); + editToolbar.className = "editToolbar"; + editToolbar.addEventListener("contextmenu", noContextMenu); + editToolbar.addEventListener("pointerdown", EditorToolbar.#pointerDown); + + const buttons = (this.#buttons = document.createElement("div")); + buttons.className = "buttons"; + editToolbar.append(buttons); + + this.#addDeleteButton(); + + return editToolbar; + } + + static #pointerDown(e) { + e.stopPropagation(); + } + + #focusIn(e) { + this.#editor._focusEventsAllowed = false; + e.preventDefault(); + e.stopPropagation(); + } + + #focusOut(e) { + this.#editor._focusEventsAllowed = true; + e.preventDefault(); + e.stopPropagation(); + } + + #addListenersToElement(element) { + // If we're clicking on a button with the keyboard or with + // the mouse, we don't want to trigger any focus events on + // the editor. + element.addEventListener("focusin", this.#focusIn.bind(this), { + capture: true, + }); + element.addEventListener("focusout", this.#focusOut.bind(this), { + capture: true, + }); + element.addEventListener("contextmenu", noContextMenu); + } + + hide() { + this.#toolbar.classList.add("hidden"); + } + + show() { + this.#toolbar.classList.remove("hidden"); + } + + #addDeleteButton() { + const button = document.createElement("button"); + button.className = "delete"; + button.tabIndex = 0; + this.#addListenersToElement(button); + button.addEventListener("click", e => { + this.#editor._uiManager.delete(); + }); + this.#buttons.append(button); + } + + remove() { + this.#toolbar.remove(); + } +} + +export { EditorToolbar }; diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index b8ac29c11..4187e6982 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -669,8 +669,9 @@ class AnnotationEditorUIManager { // 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) && + checker: (self, { target: el }) => + !(el instanceof HTMLButtonElement) && + self.#container.contains(el) && !self.isEnterHandled, }, ], diff --git a/test/integration/freetext_editor_spec.mjs b/test/integration/freetext_editor_spec.mjs index a3acdeba3..8751eee48 100644 --- a/test/integration/freetext_editor_spec.mjs +++ b/test/integration/freetext_editor_spec.mjs @@ -3053,4 +3053,138 @@ describe("FreeText Editor", () => { ); }); }); + + describe("Delete a freetext in using the delete button", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("empty.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must check that a freetext is deleted", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await switchToFreeText(page); + + const rect = await page.$eval(".annotationEditorLayer", el => { + // With Chrome something is wrong when serializing a DomRect, + // hence we extract the values and just return them. + const { x, y } = el.getBoundingClientRect(); + return { x, y }; + }); + + const data = "Hello PDF.js World !!"; + await page.mouse.click(rect.x + 100, rect.y + 100); + await page.waitForSelector(getEditorSelector(0), { + visible: true, + }); + await page.type(`${getEditorSelector(0)} .internal`, data); + + // Commit. + await page.keyboard.press("Escape"); + await page.waitForSelector( + `${getEditorSelector(0)} .overlay.enabled` + ); + + // Delete it in using the button. + await page.click(`${getEditorSelector(0)} button.delete`); + await page.waitForFunction( + sel => !document.querySelector(sel), + {}, + getEditorSelector(0) + ); + await waitForStorageEntries(page, 0); + + // Undo. + await kbUndo(page); + await waitForSerialized(page, 1); + + await page.waitForSelector(getEditorSelector(0), { + visible: true, + }); + }) + ); + }); + }); + + describe("Delete two freetexts in using the delete button and the keyboard", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("empty.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must check that freetexts are deleted", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await switchToFreeText(page); + + const rect = await page.$eval(".annotationEditorLayer", el => { + // With Chrome something is wrong when serializing a DomRect, + // hence we extract the values and just return them. + const { x, y } = el.getBoundingClientRect(); + return { x, y }; + }); + + const data = "Hello PDF.js World !!"; + + for (let i = 1; i <= 2; i++) { + const editorSelector = getEditorSelector(i - 1); + await page.mouse.click(rect.x + i * 100, rect.y + i * 100); + await page.waitForSelector(editorSelector, { + visible: true, + }); + await page.type(`${editorSelector} .internal`, data); + + // Commit. + await page.keyboard.press("Escape"); + await page.waitForSelector(`${editorSelector} .overlay.enabled`); + } + + // Select the editor created previously. + const editorRect = await page.$eval(getEditorSelector(0), el => { + const { x, y, width, height } = el.getBoundingClientRect(); + return { x, y, width, height }; + }); + await page.mouse.click( + editorRect.x + editorRect.width / 2, + editorRect.y + editorRect.height / 2 + ); + await waitForSelectedEditor(page, getEditorSelector(0)); + + await selectAll(page); + + // Delete it in using the button. + await page.focus(`${getEditorSelector(0)} button.delete`); + await page.keyboard.press("Enter"); + await page.waitForFunction( + sel => !document.querySelector(sel), + {}, + getEditorSelector(0) + ); + await waitForStorageEntries(page, 0); + + // Undo. + await kbUndo(page); + await waitForSerialized(page, 2); + + await page.waitForSelector(getEditorSelector(0), { + visible: true, + }); + + await page.waitForSelector(getEditorSelector(1), { + visible: true, + }); + }) + ); + }); + }); }); diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 5f0047e7d..c3855c3bb 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -188,6 +188,105 @@ border: var(--focus-outline-around); } } + + .editToolbar { + --editor-toolbar-delete-image: url(images/editor-toolbar-delete.svg); + --editor-toolbar-bg-color: #f0f0f4; + --editor-toolbar-fg-color: #2e2e56; + --editor-toolbar-border-color: #8f8f9d; + --editor-toolbar-hover-bg-color: #e0e0e6; + --editor-toolbar-active-bg-color: #cfcfd8; + --editor-toolbar-focus-outline-color: #0060df; + --editor-toolbar-shadow: 0 2px 6px 0 rgb(58 57 68 / 0.2); + + @media (prefers-color-scheme: dark) { + --editor-toolbar-bg-color: #2b2a33; + --editor-toolbar-fg-color: #fbfbfe; + --editor-toolbar-border-color: #2b2a33; + --editor-toolbar-hover-bg-color: #52525e; + --editor-toolbar-active-bg-color: #5b5b66; + --editor-toolbar-focus-outline-color: #0df; + } + + @media screen and (forced-colors: active) { + --editor-toolbar-bg-color: ButtonFace; + --editor-toolbar-fg-color: ButtonText; + --editor-toolbar-border-color: ButtonText; + --editor-toolbar-hover-bg-color: AccentColor; + --editor-toolbar-active-bg-color: ButtonFace; + --editor-toolbar-focus-outline-color: ButtonBorder; + --editor-toolbar-shadow: none; + } + + display: flex; + width: fit-content; + height: 28px; + flex-direction: column; + justify-content: center; + align-items: center; + cursor: default; + + position: absolute; + inset-inline-end: 0; + inset-block-start: calc(100% + 6px); + + border-radius: 4px; + background-color: var(--editor-toolbar-bg-color); + border: 1px solid var(--editor-toolbar-border-color); + box-shadow: var(--editor-toolbar-shadow); + + &.hidden { + display: none; + } + + &:has(:focus-visible) { + border-color: transparent; + } + + .buttons { + display: flex; + padding: 0 2px; + justify-content: center; + align-items: center; + gap: 4px; + + .delete { + width: 24px; + height: 24px; + cursor: pointer; + border: none; + background-color: transparent; + + &::before { + content: ""; + mask-image: var(--editor-toolbar-delete-image); + mask-repeat: no-repeat; + mask-position: center; + display: inline-block; + background-color: var(--editor-toolbar-fg-color); + width: 100%; + height: 100%; + } + } + + > * { + &:hover { + border-radius: 2px; + background-color: var(--editor-toolbar-hover-bg-color); + } + + &:active { + border-radius: 2px; + background-color: var(--editor-toolbar-active-bg-color); + } + + &:focus-visible { + border-radius: 3px; + outline: 2px solid var(--editor-toolbar-focus-outline-color); + } + } + } + } } .annotationEditorLayer .freeTextEditor { @@ -409,6 +508,21 @@ } } } + + .editToolbar { + rotate: 270deg; + + &:dir(ltr) { + inset-inline-start: calc(100% + 6px); + inset-block-start: 0; + } + + &:dir(rtl) { + inset-inline-end: calc(100% + 6px); + inset-block-end: 0; + inset-block-start: unset; + } + } } & @@ -429,6 +543,13 @@ inset-block-start: -8px; } } + + .editToolbar { + rotate: 180deg; + inset-inline-start: 0; + inset-block-end: calc(100% + 6px); + inset-block-start: unset; + } } & @@ -459,6 +580,21 @@ } } } + + .editToolbar { + rotate: 90deg; + + &:dir(ltr) { + inset-inline-end: calc(100% + 6px); + inset-block-end: 0; + inset-block-start: unset; + } + + &:dir(rtl) { + inset-inline-start: calc(100% + 6px); + inset-block-start: 0; + } + } } } @@ -492,6 +628,7 @@ &:dir(ltr) { transform-origin: 0 100%; } + &:dir(rtl) { transform-origin: 100% 100%; } @@ -500,6 +637,7 @@ &:dir(ltr) { transform-origin: 0 0; } + &:dir(rtl) { transform-origin: 100% 0; } @@ -804,6 +942,7 @@ outline-offset: 0; border-color: transparent; } + &:disabled { pointer-events: none; opacity: 0.4; diff --git a/web/images/editor-toolbar-delete.svg b/web/images/editor-toolbar-delete.svg new file mode 100644 index 000000000..f84520d85 --- /dev/null +++ b/web/images/editor-toolbar-delete.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file