diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 093402c66..c5825d1c9 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -34,8 +34,14 @@ import { FeatureTest, shadow, unreachable } from "../../shared/util.js"; * Base class for editors. */ class AnnotationEditor { + #altText = ""; + + #altTextDecorative = false; + #altTextButton = null; + #altTextAriaDescription = null; + #keepAspectRatio = false; #resizersDiv = null; @@ -810,7 +816,7 @@ class AnnotationEditor { if (this.#altTextButton) { return; } - const altText = (this.#altTextButton = document.createElement("span")); + const altText = (this.#altTextButton = document.createElement("button")); altText.className = "altText"; AnnotationEditor._l10nPromise.get("alt_text_button_label").then(msg => { altText.textContent = msg; @@ -820,14 +826,17 @@ class AnnotationEditor { "click", event => { event.preventDefault(); + this._uiManager.editAltText(this); }, { capture: true } ); altText.addEventListener("keydown", event => { if (event.target === altText && event.key === "Enter") { event.preventDefault(); + this._uiManager.editAltText(this); } }); + this.#setAltTextButtonState(); this.div.append(altText); if (!AnnotationEditor.SMALL_EDITOR_SIZE) { // We take the width of the alt text button and we add 40% to it to be @@ -840,6 +849,55 @@ class AnnotationEditor { } } + #setAltTextButtonState() { + const button = this.#altTextButton; + if (!button) { + return; + } + // TODO: remove the aria-describedby once the tooltip stuff is implemented: + // the tooltip willl contain a span with the description, hence we could use + // it. + if (this.#altTextDecorative) { + button.classList.add("done"); + button.title = ""; + if (this.#altTextAriaDescription) { + button.removeAttribute("aria-describedby"); + this.#altTextAriaDescription.remove(); + this.#altTextAriaDescription = null; + } + } else if (this.#altText) { + button.classList.add("done"); + button.title = this.#altText; + let description = this.#altTextAriaDescription; + if (!description) { + this.#altTextAriaDescription = description = + document.createElement("span"); + description.className = "description"; + const id = (description.id = `${this.id}-alt-text`); + button.append(description); + button.setAttribute("aria-describedby", id); + } + description.innerText = this.#altText; + } + } + + getClientDimensions() { + return this.div.getBoundingClientRect(); + } + + get altTextData() { + return { + altText: this.#altText, + decorative: this.#altTextDecorative, + }; + } + + set altTextData({ altText, decorative }) { + this.#altText = altText; + this.#altTextDecorative = decorative; + this.#setAltTextButtonState(); + } + /** * Render this editor in a div. * @returns {HTMLDivElement} diff --git a/src/display/editor/stamp.js b/src/display/editor/stamp.js index 62651d51a..678ac9546 100644 --- a/src/display/editor/stamp.js +++ b/src/display/editor/stamp.js @@ -484,7 +484,7 @@ class StampEditor extends AnnotationEditor { return null; } const editor = super.deserialize(data, parent, uiManager); - const { rect, bitmapUrl, bitmapId, isSvg } = data; + const { rect, bitmapUrl, bitmapId, isSvg, accessibilityData } = data; if (bitmapId && uiManager.imageManager.isValidId(bitmapId)) { editor.#bitmapId = bitmapId; } else { @@ -496,6 +496,10 @@ class StampEditor extends AnnotationEditor { editor.width = (rect[2] - rect[0]) / parentWidth; editor.height = (rect[3] - rect[1]) / parentHeight; + if (accessibilityData) { + editor.altTextData = accessibilityData; + } + return editor; } @@ -520,9 +524,15 @@ class StampEditor extends AnnotationEditor { // of this annotation and the clipboard doesn't support ImageBitmaps, // hence we serialize the bitmap to a data url. serialized.bitmapUrl = this.#serializeBitmap(/* toUrl = */ true); + serialized.accessibilityData = this.altTextData; return serialized; } + const { decorative, altText } = this.altTextData; + if (!decorative && altText) { + serialized.accessibilityData = { type: "Figure", alt: altText }; + } + if (context === null) { return serialized; } diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 47c26e88d..71983b850 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -525,6 +525,8 @@ class AnnotationEditorUIManager { #allLayers = new Map(); + #altTextManager = null; + #annotationStorage = null; #commandManager = new CommandManager(); @@ -693,9 +695,17 @@ class AnnotationEditorUIManager { ); } - constructor(container, viewer, eventBus, pdfDocument, pageColors) { + constructor( + container, + viewer, + altTextManager, + eventBus, + pdfDocument, + pageColors + ) { this.#container = container; this.#viewer = viewer; + this.#altTextManager = altTextManager; this.#eventBus = eventBus; this.#eventBus._on("editingaction", this.#boundOnEditingAction); this.#eventBus._on("pagechanging", this.#boundOnPageChanging); @@ -711,7 +721,7 @@ class AnnotationEditorUIManager { } destroy() { - this.#removeKeyboardManager(); + this.removeKeyboardManager(); this.#removeFocusManager(); this.#eventBus._off("editingaction", this.#boundOnEditingAction); this.#eventBus._off("pagechanging", this.#boundOnPageChanging); @@ -726,6 +736,7 @@ class AnnotationEditorUIManager { this.#activeEditor = null; this.#selectedEditors.clear(); this.#commandManager.destroy(); + this.#altTextManager.destroy(); } get hcmFilter() { @@ -749,6 +760,10 @@ class AnnotationEditorUIManager { ); } + editAltText(editor) { + this.#altTextManager?.editAltText(this, editor); + } + onPageChanging({ pageNumber }) { this.#currentPageIndex = pageNumber - 1; } @@ -860,13 +875,13 @@ class AnnotationEditorUIManager { lastActiveElement.focus(); } - #addKeyboardManager() { + addKeyboardManager() { // The keyboard events are caught at the container level in order to be able // to execute some callbacks even if the current page doesn't have focus. window.addEventListener("keydown", this.#boundKeydown, { capture: true }); } - #removeKeyboardManager() { + removeKeyboardManager() { window.removeEventListener("keydown", this.#boundKeydown, { capture: true, }); @@ -1039,7 +1054,7 @@ class AnnotationEditorUIManager { setEditingState(isEditing) { if (isEditing) { this.#addFocusManager(); - this.#addKeyboardManager(); + this.addKeyboardManager(); this.#addCopyPasteListeners(); this.#dispatchUpdateStates({ isEditing: this.#mode !== AnnotationEditorType.NONE, @@ -1050,7 +1065,7 @@ class AnnotationEditorUIManager { }); } else { this.#removeFocusManager(); - this.#removeKeyboardManager(); + this.removeKeyboardManager(); this.#removeCopyPasteListeners(); this.#dispatchUpdateStates({ isEditing: false, diff --git a/web/alt_text_manager.js b/web/alt_text_manager.js new file mode 100644 index 000000000..ebdd6a5d9 --- /dev/null +++ b/web/alt_text_manager.js @@ -0,0 +1,190 @@ +/* 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. + */ + +class AltTextManager { + #boundUpdateUIState = this.#updateUIState.bind(this); + + #boundSetPosition = this.#setPosition.bind(this); + + #currentEditor = null; + + #dialog; + + #eventBus = null; + + #optionDescription; + + #optionDecorative; + + #overlayManager; + + #saveButton; + + #textarea; + + #uiManager; + + constructor( + { + dialog, + optionDescription, + optionDecorative, + textarea, + cancelButton, + saveButton, + }, + overlayManager, + eventBus + ) { + this.#dialog = dialog; + this.#optionDescription = optionDescription; + this.#optionDecorative = optionDecorative; + this.#textarea = textarea; + this.#saveButton = saveButton; + this.#overlayManager = overlayManager; + this.#eventBus = eventBus; + + dialog.addEventListener("close", this.#close.bind(this)); + cancelButton.addEventListener("click", this.#finish.bind(this)); + saveButton.addEventListener("click", this.#save.bind(this)); + optionDescription.addEventListener("change", this.#boundUpdateUIState); + optionDecorative.addEventListener("change", this.#boundUpdateUIState); + textarea.addEventListener("input", this.#boundUpdateUIState); + + this.#overlayManager.register(dialog); + } + + async editAltText(uiManager, editor) { + if (this.#currentEditor || !editor) { + return; + } + + const { altText, decorative } = editor.altTextData; + if (decorative === true) { + this.#optionDecorative.checked = true; + this.#optionDescription.checked = false; + } else { + this.#optionDecorative.checked = false; + this.#optionDescription.checked = true; + } + this.#textarea.value = altText?.trim() || ""; + this.#updateUIState(); + + this.#currentEditor = editor; + this.#uiManager = uiManager; + this.#uiManager.removeKeyboardManager(); + this.#eventBus._on("resize", this.#boundSetPosition); + + try { + await this.#overlayManager.open(this.#dialog); + this.#setPosition(); + } catch (ex) { + this.#close(); + throw ex; + } + } + + #setPosition() { + if (!this.#currentEditor) { + return; + } + const dialog = this.#dialog; + const { style } = dialog; + const { innerWidth: windowW, innerHeight: windowH } = window; + const { width: dialogW, height: dialogH } = dialog.getBoundingClientRect(); + const { x, y, width, height } = this.#currentEditor.getClientDimensions(); + const MARGIN = 10; + const isLTR = this.#uiManager.direction === "ltr"; + + let left = null; + let top = Math.max(0, y - MARGIN); + top += Math.min(windowH - (top + dialogH), 0); + + if (isLTR) { + // Prefer to position the dialog "after" (so on the right) the editor. + if (x + width + MARGIN + dialogW < windowW) { + left = x + width + MARGIN; + } else if (x > dialogW + MARGIN) { + left = x - dialogW - MARGIN; + } + } else if (x > dialogW + MARGIN) { + left = x - dialogW - MARGIN; + } else if (x + width + MARGIN + dialogW < windowW) { + left = x + width + MARGIN; + } + + if (left === null) { + top = null; + left = Math.max(0, x - MARGIN); + left += Math.min(windowW - (left + dialogW), 0); + if (y > dialogH + MARGIN) { + top = y - dialogH - MARGIN; + } else if (y + height + MARGIN + dialogH < windowH) { + top = y + height + MARGIN; + } + } + + if (top !== null) { + dialog.classList.add("positioned"); + if (isLTR) { + style.left = `${left}px`; + } else { + style.right = `${windowW - left - dialogW}px`; + } + style.top = `${top}px`; + } else { + dialog.classList.remove("positioned"); + style.left = ""; + style.top = ""; + } + } + + #finish() { + if (this.#dialog) { + this.#overlayManager.close(this.#dialog); + } + } + + #close() { + this.#uiManager?.addKeyboardManager(); + this.#eventBus._off("resize", this.#boundSetPosition); + this.#currentEditor = null; + this.#uiManager = null; + } + + #updateUIState() { + const hasAltText = !!this.#textarea.value.trim(); + const decorative = this.#optionDecorative.checked; + + this.#textarea.disabled = decorative; + this.#saveButton.disabled = !decorative && !hasAltText; + } + + #save() { + this.#currentEditor.altTextData = { + altText: this.#textarea.value.trim(), + decorative: this.#optionDecorative.checked, + }; + this.#finish(); + } + + destroy() { + this.#currentEditor = null; + this.#uiManager = null; + this.#finish(); + } +} + +export { AltTextManager }; diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index dc7104518..6f796b243 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -528,6 +528,15 @@ &.done::before { mask-image: var(--alt-text-done-image); } + + & .description { + position: absolute; + top: 0; + left: 0; + display: none; + width: 0; + height: 0; + } } #altTextDialog { @@ -622,6 +631,10 @@ color: var(--text-primary-color); box-shadow: var(--dialog-shadow); + &.positioned { + margin: 0; + } + & #altTextContainer { width: 300px; height: fit-content; @@ -728,6 +741,7 @@ } &:disabled { pointer-events: none; + opacity: 0.4; } } } diff --git a/web/app.js b/web/app.js index 3db6bf719..6e9a9b9d2 100644 --- a/web/app.js +++ b/web/app.js @@ -55,6 +55,7 @@ import { import { AppOptions, OptionKind } from "./app_options.js"; import { AutomationEventBus, EventBus } from "./event_utils.js"; import { LinkTarget, PDFLinkService } from "./pdf_link_service.js"; +import { AltTextManager } from "./alt_text_manager.js"; import { AnnotationEditorParams } from "web-annotation_editor_params"; import { OverlayManager } from "./overlay_manager.js"; import { PasswordPrompt } from "./password_prompt.js"; @@ -505,6 +506,13 @@ const PDFViewerApplication = { foreground: AppOptions.get("pageColorsForeground"), } : null; + const altTextManager = appConfig.altTextDialog + ? new AltTextManager( + appConfig.altTextDialog, + this.overlayManager, + eventBus + ) + : null; const pdfViewer = new PDFViewer({ container, @@ -513,6 +521,7 @@ const PDFViewerApplication = { renderingQueue: pdfRenderingQueue, linkService: pdfLinkService, downloadManager, + altTextManager, findController, scriptingManager: AppOptions.get("enableScripting") && pdfScriptingManager, diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 3161e4445..69456099a 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -199,6 +199,8 @@ class PDFPageViewBuffer { class PDFViewer { #buffer = null; + #altTextManager = null; + #annotationEditorMode = AnnotationEditorType.NONE; #annotationEditorUIManager = null; @@ -261,6 +263,7 @@ class PDFViewer { this.linkService = options.linkService || new SimpleLinkService(); this.downloadManager = options.downloadManager || null; this.findController = options.findController || null; + this.#altTextManager = options.altTextManager || null; if (this.findController) { this.findController.onIsPageVisible = pageNumber => @@ -854,6 +857,7 @@ class PDFViewer { this.#annotationEditorUIManager = new AnnotationEditorUIManager( this.container, this.viewer, + this.#altTextManager, this.eventBus, pdfDocument, this.pageColors diff --git a/web/viewer.js b/web/viewer.js index 9aa16bc8c..bfe7b3e87 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -157,6 +157,14 @@ function getViewerConfiguration() { linearized: document.getElementById("linearizedField"), }, }, + altTextDialog: { + dialog: document.getElementById("altTextDialog"), + optionDescription: document.getElementById("descriptionButton"), + optionDecorative: document.getElementById("decorativeButton"), + textarea: document.getElementById("descriptionTextarea"), + cancelButton: document.getElementById("altTextCancel"), + saveButton: document.getElementById("altTextSave"), + }, annotationEditorParams: { editorFreeTextFontSize: document.getElementById("editorFreeTextFontSize"), editorFreeTextColor: document.getElementById("editorFreeTextColor"),