[Editor] Allow the user to add and save an alt-text for images (bug 1844952)

This commit is contained in:
Calixte Denizet 2023-09-19 00:03:49 +02:00
parent daae6589b6
commit c12049db07
8 changed files with 316 additions and 8 deletions

View File

@ -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}

View File

@ -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;
}

View File

@ -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,

190
web/alt_text_manager.js Normal file
View File

@ -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 };

View File

@ -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;
}
}
}

View File

@ -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,

View File

@ -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

View File

@ -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"),