[Editor] Allow the user to add and save an alt-text for images (bug 1844952)
This commit is contained in:
parent
daae6589b6
commit
c12049db07
@ -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}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
190
web/alt_text_manager.js
Normal 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 };
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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"),
|
||||
|
Loading…
x
Reference in New Issue
Block a user