From ed22d934e5d436b791277d46bfc416c6f18c3d15 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 24 Jul 2024 09:34:32 +0200 Subject: [PATCH] Implement the new alt text flow (bug 1909604) For the Firefox pdf viewer, we want to use AI to guess an alt-text when adding an image to a pdf. For now the telemtry stuff is not implemented and will come soon. In order to test it locally: - set enableAltText, enableFakeMLManager and enableUpdatedAddImage to true. or in Firefox: - set browser.ml.enable, pdfjs.enableAltText and pdfjs.enableUpdatedAddImage to true. --- gulpfile.mjs | 2 + l10n/en-US/viewer.ftl | 49 +++ src/display/editor/alt_text.js | 148 ++++++++- src/display/editor/editor.js | 68 +++- src/display/editor/stamp.js | 136 +++++--- src/display/editor/tools.js | 12 +- web/annotation_editor_layer_builder.css | 258 +++++++++++++++ web/app.js | 32 +- web/app_options.js | 5 + web/dialog.css | 181 ++++++++++- web/firefoxcom.js | 25 ++ web/genericcom.js | 40 ++- web/images/altText_disclaimer.svg | 3 + web/images/altText_spinner.svg | 16 + web/images/altText_warning.svg | 3 + web/images/messageBar_closingButton.svg | 3 + web/images/messageBar_warning.svg | 3 + web/new_alt_text_manager.js | 399 ++++++++++++++++++++++++ web/stubs-geckoview.js | 2 + web/viewer-geckoview.html | 1 + web/viewer.html | 43 +++ web/viewer.js | 26 ++ 22 files changed, 1365 insertions(+), 90 deletions(-) create mode 100644 web/images/altText_disclaimer.svg create mode 100644 web/images/altText_spinner.svg create mode 100644 web/images/altText_warning.svg create mode 100644 web/images/messageBar_closingButton.svg create mode 100644 web/images/messageBar_warning.svg create mode 100644 web/new_alt_text_manager.js diff --git a/gulpfile.mjs b/gulpfile.mjs index 9385a4e8eaccb..d9ba13cec277e 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -201,6 +201,7 @@ function createWebpackAlias(defines) { "web-annotation_editor_params": "web/annotation_editor_params.js", "web-download_manager": "", "web-external_services": "", + "web-new_alt_text_manager": "web/new_alt_text_manager.js", "web-null_l10n": "", "web-pdf_attachment_viewer": "web/pdf_attachment_viewer.js", "web-pdf_cursor_tools": "web/pdf_cursor_tools.js", @@ -1097,6 +1098,7 @@ function buildComponents(defines, dir) { "web/images/loading-icon.gif", "web/images/altText_*.svg", "web/images/editor-toolbar-*.svg", + "web/images/messageBar_*.svg", "web/images/toolbarButton-{editorHighlight,menuArrow}.svg", "web/images/cursor-*.svg", ]; diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index 8aea43959e0bd..9d0d940fc772a 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -416,3 +416,52 @@ pdfjs-editor-colorpicker-red = pdfjs-editor-highlight-show-all-button-label = Show all pdfjs-editor-highlight-show-all-button = .title = Show all + +## New alt-text dialog +## Group note for entire feature: Alternative text (alt text) helps when people can't see the image. This feature includes a tool to create alt text automatically using an AI model that works locally on the user's device to preserve privacy. + +# Modal header positioned above a text box where users can edit the alt text. +pdfjs-editor-new-alt-text-dialog-edit-label = Edit alt text (image description) + +# Modal header positioned above a text box where users can add the alt text. +pdfjs-editor-new-alt-text-dialog-add-label = Add alt text (image description) + +pdfjs-editor-new-alt-text-textarea = + .placeholder = Write your description here… + +# This text refers to the alt text box above this description. It offers a definition of alt text. +pdfjs-editor-new-alt-text-description = Short description for people who can’t see the image or when the image doesn’t load. + +# This is a required legal disclaimer that refers to the automatically created text inside the alt text box above this text. It disappears if the text is edited by a human. +pdfjs-editor-new-alt-text-disclaimer = This alt text was created automatically. +pdfjs-editor-new-alt-text-disclaimer-learn-more-url = Learn more + +pdfjs-editor-new-alt-text-create-automatically-button-label = Create alt text automatically +pdfjs-editor-new-alt-text-not-now-button = Not now +pdfjs-editor-new-alt-text-error-title = Couldn’t create alt text automatically +pdfjs-editor-new-alt-text-error-description = Please write your own alt text or try again later. +pdfjs-editor-new-alt-text-error-close-button = Close + +# Variables: +# $totalSize (Number) - the total size (in MB) of the AI model. +# $downloadedSize (Number) - the downloaded size (in MB) of the AI model. +# $percent (Number) - the percentage of the downloaded size. +pdfjs-editor-new-alt-text-ai-model-downloading-progress = + .aria-valuemin = 0 + .aria-valuemax = { $totalSize } + .aria-valuenow = { $downloadedSize } + .aria-valuetext = Downloading alt text AI model ({ $downloadedSize } of { $totalSize } MB) + +# This is a button that users can click to edit the alt text they have already added. +pdfjs-editor-new-alt-text-added-button-label = Alt text added + +# This is a button that users can click to open the alt text editor and add alt text when it is not present. +pdfjs-editor-new-alt-text-missing-button-label = Missing alt text + +# This is a button that opens up the alt text modal where users should review the alt text that was automatically generated. +pdfjs-editor-new-alt-text-to-review-button-label = Review alt text + +# "Created automatically" is a prefix that will be added to the beginning of any alt text that has been automatically generated. After the colon, the user will see/hear the actual alt text description. If the alt text has been edited by a human, this prefix will not appear. +# Variables: +# $generatedAltText (String) - the generated alt-text. +pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer = Created automatically: { $generatedAltText } diff --git a/src/display/editor/alt_text.js b/src/display/editor/alt_text.js index fececf29269cf..fc346b4d5b51c 100644 --- a/src/display/editor/alt_text.js +++ b/src/display/editor/alt_text.js @@ -16,7 +16,7 @@ import { noContextMenu } from "../display_utils.js"; class AltText { - #altText = ""; + #altText = null; #altTextDecorative = false; @@ -28,12 +28,21 @@ class AltText { #altTextWasFromKeyBoard = false; + #badge = null; + #editor = null; + #guessedText = null; + + #textWithDisclaimer = null; + + #useNewAltTextFlow = false; + static _l10nPromise = null; constructor(editor) { this.#editor = editor; + this.#useNewAltTextFlow = editor._uiManager.useNewAltTextFlow; } static initialize(l10nPromise) { @@ -43,9 +52,17 @@ class AltText { async render() { const altText = (this.#altTextButton = document.createElement("button")); altText.className = "altText"; - const msg = await AltText._l10nPromise.get( - "pdfjs-editor-alt-text-button-label" - ); + let msg; + if (this.#useNewAltTextFlow) { + altText.classList.add("new"); + msg = await AltText._l10nPromise.get( + "pdfjs-editor-new-alt-text-missing-button-label" + ); + } else { + msg = await AltText._l10nPromise.get( + "pdfjs-editor-alt-text-button-label" + ); + } altText.textContent = msg; altText.setAttribute("aria-label", msg); altText.tabIndex = "0"; @@ -84,9 +101,62 @@ class AltText { } isEmpty() { + if (this.#useNewAltTextFlow) { + return this.#altText === null; + } return !this.#altText && !this.#altTextDecorative; } + hasData() { + if (this.#useNewAltTextFlow) { + return this.#altText !== null || !!this.#guessedText; + } + return this.isEmpty(); + } + + get guessedText() { + return this.#guessedText; + } + + async setGuessedText(guessedText) { + if (this.#altText !== null) { + // The user provided their own alt text, so we don't want to overwrite it. + return; + } + this.#guessedText = guessedText; + this.#textWithDisclaimer = await AltText._l10nPromise.get( + "pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer" + )({ generatedAltText: guessedText }); + this.#setState(); + } + + toggleAltTextBadge(visibility = false) { + if (!this.#useNewAltTextFlow || this.#altText) { + this.#badge?.remove(); + this.#badge = null; + return; + } + if (!this.#badge) { + const badge = (this.#badge = document.createElement("div")); + badge.className = "noAltTextBadge"; + this.#editor.div.append(badge); + } + this.#badge.classList.toggle("hidden", !visibility); + } + + serialize(isForCopying) { + let altText = this.#altText; + if (!isForCopying && this.#guessedText === altText) { + altText = this.#textWithDisclaimer; + } + return { + altText, + decorative: this.#altTextDecorative, + guessedText: this.#guessedText, + textWithDisclaimer: this.#textWithDisclaimer, + }; + } + get data() { return { altText: this.#altText, @@ -97,12 +167,24 @@ class AltText { /** * Set the alt text data. */ - set data({ altText, decorative }) { + set data({ + altText, + decorative, + guessedText, + textWithDisclaimer, + cancel = false, + }) { + if (guessedText) { + this.#guessedText = guessedText; + this.#textWithDisclaimer = textWithDisclaimer; + } if (this.#altText === altText && this.#altTextDecorative === decorative) { return; } - this.#altText = altText; - this.#altTextDecorative = decorative; + if (!cancel) { + this.#altText = altText; + this.#altTextDecorative = decorative; + } this.#setState(); } @@ -121,6 +203,8 @@ class AltText { this.#altTextButton?.remove(); this.#altTextButton = null; this.#altTextTooltip = null; + this.#badge?.remove(); + this.#badge = null; } async #setState() { @@ -128,18 +212,48 @@ class AltText { if (!button) { return; } - if (!this.#altText && !this.#altTextDecorative) { - button.classList.remove("done"); - this.#altTextTooltip?.remove(); - return; + + if (this.#useNewAltTextFlow) { + // If we've an alt text, we get an "added". + // If we've a guessed text and the alt text has never been set, we get a + // "to-review" been set. + // Otherwise, we get a "missing". + const type = + (this.#altText && "added") || + (this.#altText === null && this.guessedText && "to-review") || + "missing"; + button.classList.toggle("done", !!this.#altText); + AltText._l10nPromise + .get(`pdfjs-editor-new-alt-text-${type}-button-label`) + .then(msg => { + button.setAttribute("aria-label", msg); + // We can't just use button.textContent here, because it would remove + // the existing tooltip element. + for (const child of button.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + child.textContent = msg; + break; + } + } + }); + if (!this.#altText) { + this.#altTextTooltip?.remove(); + return; + } + } else { + if (!this.#altText && !this.#altTextDecorative) { + button.classList.remove("done"); + this.#altTextTooltip?.remove(); + return; + } + button.classList.add("done"); + AltText._l10nPromise + .get("pdfjs-editor-alt-text-edit-button-label") + .then(msg => { + button.setAttribute("aria-label", msg); + }); } - button.classList.add("done"); - AltText._l10nPromise - .get("pdfjs-editor-alt-text-edit-button-label") - .then(msg => { - button.setAttribute("aria-label", msg); - }); let tooltip = this.#altTextTooltip; if (!tooltip) { this.#altTextTooltip = tooltip = document.createElement("span"); diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 69dee6f15cfb7..c0c0127008e7b 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -58,8 +58,6 @@ class AnnotationEditor { #boundFocusout = this.focusout.bind(this); - #editToolbar = null; - #focusedResizerName = ""; #hasBeenClicked = false; @@ -80,6 +78,8 @@ class AnnotationEditor { #telemetryTimeouts = null; + _editToolbar = null; + _initialOptions = Object.create(null); _isVisible = true; @@ -210,6 +210,9 @@ class AnnotationEditor { "pdfjs-editor-alt-text-button-label", "pdfjs-editor-alt-text-edit-button-label", "pdfjs-editor-alt-text-decorative-tooltip", + "pdfjs-editor-new-alt-text-added-button-label", + "pdfjs-editor-new-alt-text-missing-button-label", + "pdfjs-editor-new-alt-text-to-review-button-label", "pdfjs-editor-resizer-label-topLeft", "pdfjs-editor-resizer-label-topMiddle", "pdfjs-editor-resizer-label-topRight", @@ -223,6 +226,18 @@ class AnnotationEditor { l10n.get(str.replaceAll(/([A-Z])/g, c => `-${c.toLowerCase()}`)), ]) ); + + // The string isn't in the above list because the string has a parameter + // (i.e. the guessed text) and we must pass it to the l10n function to get + // the correct translation. + AnnotationEditor._l10nPromise.set( + "pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer", + l10n.get.bind( + l10n, + "pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer" + ) + ); + if (options?.strings) { for (const str of options.strings) { AnnotationEditor._l10nPromise.set(str, l10n.get(str)); @@ -952,6 +967,9 @@ class AnnotationEditor { this.fixAndSetPosition(); } + /** + * Called when the alt text dialog is closed. + */ altTextFinish() { this.#altText?.finish(); } @@ -961,24 +979,24 @@ class AnnotationEditor { * @returns {Promise} */ async addEditToolbar() { - if (this.#editToolbar || this.#isInEditMode) { - return this.#editToolbar; + if (this._editToolbar || this.#isInEditMode) { + return this._editToolbar; } - this.#editToolbar = new EditorToolbar(this); - this.div.append(this.#editToolbar.render()); + this._editToolbar = new EditorToolbar(this); + this.div.append(this._editToolbar.render()); if (this.#altText) { - this.#editToolbar.addAltTextButton(await this.#altText.render()); + this._editToolbar.addAltTextButton(await this.#altText.render()); } - return this.#editToolbar; + return this._editToolbar; } removeEditToolbar() { - if (!this.#editToolbar) { + if (!this._editToolbar) { return; } - this.#editToolbar.remove(); - this.#editToolbar = null; + this._editToolbar.remove(); + this._editToolbar = null; // We destroy the alt text but we don't null it because we want to be able // to restore it in case the user undoes the deletion. @@ -1016,8 +1034,24 @@ class AnnotationEditor { this.#altText.data = data; } + get guessedAltText() { + return this.#altText?.guessedText; + } + + async setGuessedAltText(text) { + await this.#altText?.setGuessedText(text); + } + + serializeAltText(isForCopying) { + return this.#altText?.serialize(isForCopying); + } + hasAltText() { - return !this.#altText?.isEmpty(); + return !!this.#altText && !this.#altText.isEmpty(); + } + + hasAltTextData() { + return this.#altText?.hasData() ?? false; } /** @@ -1558,18 +1592,19 @@ class AnnotationEditor { select() { this.makeResizable(); this.div?.classList.add("selectedEditor"); - if (!this.#editToolbar) { + if (!this._editToolbar) { this.addEditToolbar().then(() => { if (this.div?.classList.contains("selectedEditor")) { // The editor can have been unselected while we were waiting for the // edit toolbar to be created, hence we want to be sure that this // editor is still selected. - this.#editToolbar?.show(); + this._editToolbar?.show(); } }); return; } - this.#editToolbar?.show(); + this._editToolbar?.show(); + this.#altText?.toggleAltTextBadge(false); } /** @@ -1585,7 +1620,8 @@ class AnnotationEditor { preventScroll: true, }); } - this.#editToolbar?.hide(); + this._editToolbar?.hide(); + this.#altText?.toggleAltTextBadge(true); } /** diff --git a/src/display/editor/stamp.js b/src/display/editor/stamp.js index c84078336e784..597c4a65348f8 100644 --- a/src/display/editor/stamp.js +++ b/src/display/editor/stamp.js @@ -36,8 +36,6 @@ class StampEditor extends AnnotationEditor { #canvas = null; - #hasMLBeenQueried = false; - #observer = null; #resizeTimeoutId = null; @@ -98,6 +96,14 @@ class StampEditor extends AnnotationEditor { }); } + /** @inheritdoc */ + altTextFinish() { + if (this._uiManager.useNewAltTextFlow) { + this.div.hidden = false; + } + super.altTextFinish(); + } + #getBitmapFetched(data, fromId = false) { if (!data) { this.remove(); @@ -117,7 +123,13 @@ class StampEditor extends AnnotationEditor { #getBitmapDone() { this.#bitmapPromise = null; this._uiManager.enableWaiting(false); - if (this.#canvas) { + if (!this.#canvas) { + return; + } + if (this._uiManager.useNewAltTextFlow && this.#bitmap) { + this._editToolbar.hide(); + this._uiManager.editAltText(this, /* firstTime = */ true); + } else { this.div.focus(); } } @@ -329,7 +341,9 @@ class StampEditor extends AnnotationEditor { this._uiManager.enableWaiting(false); const canvas = (this.#canvas = document.createElement("canvas")); div.append(canvas); - div.hidden = false; + if (!this._uiManager.useNewAltTextFlow) { + div.hidden = false; + } this.#drawBitmap(width, height); this.#createObserver(); if (!this.#hasBeenAddedInUndoStack) { @@ -348,6 +362,77 @@ class StampEditor extends AnnotationEditor { } } + copyCanvas(maxDimension, createImageData = false) { + const { width: bitmapWidth, height: bitmapHeight } = this.#bitmap; + const canvas = document.createElement("canvas"); + + let bitmap = this.#bitmap; + let width = bitmapWidth, + height = bitmapHeight; + if (bitmapWidth > maxDimension || bitmapHeight > maxDimension) { + const ratio = Math.min( + maxDimension / bitmapWidth, + maxDimension / bitmapHeight + ); + width = Math.floor(bitmapWidth * ratio); + height = Math.floor(bitmapHeight * ratio); + + if (!this.#isSvg) { + bitmap = this.#scaleBitmap(width, height); + } + } + + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d", { + willReadFrequently: true, + }); + ctx.filter = this._uiManager.hcmFilter; + + if (createImageData && this._uiManager.hcmFilter !== "none") { + const offscreen = new OffscreenCanvas(width, height); + const offscreenCtx = offscreen.getContext("2d", { + willReadFrequently: true, + }); + offscreenCtx.drawImage( + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + 0, + 0, + width, + height + ); + const data = offscreenCtx.getImageData(0, 0, width, height).data; + ctx.drawImage(offscreen, 0, 0); + + return { canvas, imageData: { width, height, data } }; + } + + ctx.drawImage( + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + 0, + 0, + width, + height + ); + let imageData = null; + if (createImageData) { + imageData = { + width, + height, + data: ctx.getImageData(0, 0, width, height).data, + }; + } + return { canvas, imageData }; + } + /** * When the dimensions of the div change the inner canvas must * renew its dimensions, hence it must redraw its own contents. @@ -425,43 +510,6 @@ class StampEditor extends AnnotationEditor { return bitmap; } - async #mlGuessAltText(bitmap, width, height) { - if (this.#hasMLBeenQueried) { - return; - } - this.#hasMLBeenQueried = true; - const isMLEnabled = await this._uiManager.isMLEnabledFor("altText"); - if (!isMLEnabled || this.hasAltText()) { - return; - } - const offscreen = new OffscreenCanvas(width, height); - const ctx = offscreen.getContext("2d", { willReadFrequently: true }); - ctx.drawImage( - bitmap, - 0, - 0, - bitmap.width, - bitmap.height, - 0, - 0, - width, - height - ); - const response = await this._uiManager.mlGuess({ - service: "moz-image-to-text", - request: { - data: ctx.getImageData(0, 0, width, height).data, - width, - height, - channels: 4, - }, - }); - const altText = response?.output || ""; - if (this.parent && altText && !this.hasAltText()) { - this.altTextData = { altText, decorative: false }; - } - } - #drawBitmap(width, height) { width = Math.ceil(width); height = Math.ceil(height); @@ -475,8 +523,6 @@ class StampEditor extends AnnotationEditor { ? this.#bitmap : this.#scaleBitmap(width, height); - this.#mlGuessAltText(bitmap, width, height); - const ctx = canvas.getContext("2d"); ctx.filter = this._uiManager.hcmFilter; ctx.drawImage( @@ -616,11 +662,11 @@ 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; + serialized.accessibilityData = this.serializeAltText(true); return serialized; } - const { decorative, altText } = this.altTextData; + const { decorative, altText } = this.serializeAltText(false); if (!decorative && altText) { serialized.accessibilityData = { type: "Figure", alt: altText }; } diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index fa4be1fd1ea60..585634941eae9 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -851,6 +851,10 @@ class AnnotationEditorUIManager { } } + hasMLManager() { + return !!this.#mlManager; + } + async mlGuess(data) { return this.#mlManager?.guess(data) || null; } @@ -859,6 +863,10 @@ class AnnotationEditorUIManager { return !!(await this.#mlManager?.isEnabledFor(name)); } + get mlManager() { + return this.#mlManager; + } + get useNewAltTextFlow() { return this.#enableUpdatedAddImage; } @@ -912,8 +920,8 @@ class AnnotationEditorUIManager { this.#mainHighlightColorPicker = colorPicker; } - editAltText(editor) { - this.#altTextManager?.editAltText(this, editor); + editAltText(editor, firstTime = false) { + this.#altTextManager?.editAltText(this, editor, firstTime); } switchToMode(mode, callback) { diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index eb13c7e46c05e..67d634ca3b2f5 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -46,6 +46,8 @@ --editorFreeHighlight-editing-cursor: url(images/cursor-editorFreeHighlight.svg) 1 18, pointer; + + --new-alt-text-warning-image: url(images/altText_warning.svg); } /* The following class is used to hide an element but keep it available to @@ -76,6 +78,12 @@ } } +#viewerContainer.pdfPresentationMode:fullscreen { + .noAltTextBadge { + display: none !important; + } +} + @media (min-resolution: 1.1dppx) { :root { --editorFreeText-editing-cursor: url(images/cursor-editorFreeText.svg) 0 16, @@ -222,12 +230,18 @@ --editor-toolbar-vert-offset: 6px; --editor-toolbar-height: 28px; --editor-toolbar-padding: 2px; + --alt-text-done-color: #2ac3a2; + --alt-text-warning-color: #0090ed; + --alt-text-hover-done-color: var(--alt-text-done-color); + --alt-text-hover-warning-color: var(--alt-text-warning-color); @media (prefers-color-scheme: dark) { --editor-toolbar-bg-color: #2b2a33; --editor-toolbar-fg-color: #fbfbfe; --editor-toolbar-hover-bg-color: #52525e; --editor-toolbar-focus-outline-color: #0df; + --alt-text-done-color: #54ffbd; + --alt-text-warning-color: #80ebff; } @media screen and (forced-colors: active) { @@ -241,6 +255,10 @@ var(--editor-toolbar-hover-border-color); --editor-toolbar-focus-outline-color: ButtonBorder; --editor-toolbar-shadow: none; + --alt-text-done-color: var(--editor-toolbar-fg-color); + --alt-text-warning-color: var(--editor-toolbar-fg-color); + --alt-text-hover-done-color: var(--editor-toolbar-hover-fg-color); + --alt-text-hover-warning-color: var(--editor-toolbar-hover-fg-color); } display: flex; @@ -400,6 +418,31 @@ mask-image: var(--alt-text-done-image); } + &.new { + &::before { + width: 16px; + height: 16px; + mask-image: var(--new-alt-text-warning-image); + background-color: var(--alt-text-warning-color); + mask-size: cover; + } + + &:hover::before { + background-color: var(--alt-text-hover-warning-color); + } + + &.done { + &::before { + mask-image: var(--alt-text-done-image); + background-color: var(--alt-text-done-color); + } + + &:hover::before { + background-color: var(--alt-text-hover-done-color); + } + } + } + .tooltip { display: none; @@ -519,6 +562,50 @@ top: 0; left: 0; } + + .noAltTextBadge { + --no-alt-text-badge-border-color: #f0f0f4; + --no-alt-text-badge-bg-color: #cfcfd8; + --no-alt-text-badge-fg-color: #5b5b66; + + @media (prefers-color-scheme: dark) { + --no-alt-text-badge-border-color: #52525e; + --no-alt-text-badge-bg-color: #fbfbfe; + --no-alt-text-badge-fg-color: #15141a; + } + + @media screen and (forced-colors: active) { + --no-alt-text-badge-border-color: ButtonText; + --no-alt-text-badge-bg-color: ButtonFace; + --no-alt-text-badge-fg-color: ButtonText; + } + + position: absolute; + inset-inline-end: 5px; + inset-block-end: 5px; + display: inline-flex; + width: 32px; + height: 32px; + padding: 3px; + justify-content: center; + align-items: center; + pointer-events: none; + z-index: 1; + + border-radius: 2px; + border: 1px solid var(--no-alt-text-badge-border-color); + background: var(--no-alt-text-badge-bg-color); + + &::before { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + mask-image: var(--new-alt-text-warning-image); + mask-size: cover; + background-color: var(--no-alt-text-badge-fg-color); + } + } } .annotationEditorLayer { @@ -767,6 +854,177 @@ } } +.dialog.newAltText { + --new-alt-text-ai-disclaimer-icon: url(images/altText_disclaimer.svg); + --new-alt-text-spinner-icon: url(images/altText_spinner.svg); + + width: 80%; + max-width: 570px; + min-width: 300px; + padding: 0; + + &.noAi { + #newAltTextDisclaimer, + #newAltTextCreateAutomatically { + display: none !important; + } + } + + &.aiInstalling { + #newAltTextCreateAutomatically { + display: none !important; + } + #newAltTextDownloadModel { + display: flex !important; + } + } + + &.error { + #newAltTextNotNow { + display: none !important; + } + + #newAltTextCancel { + display: inline-block !important; + } + } + + &:not(.error) #newAltTextError { + display: none !important; + } + + #newAltTextContainer { + display: flex; + width: auto; + padding: 16px; + flex-direction: column; + justify-content: flex-end; + align-items: flex-start; + gap: 12px; + flex: 0 1 auto; + + #mainContent { + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 12px; + align-self: stretch; + flex: 1 1 auto; + + #descriptionAndSettings { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + flex: 1 0 0; + align-self: stretch; + } + + #descriptionInstruction { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; + flex: 1 1 auto; + + #newAltTextDescriptionContainer { + width: 100%; + height: 70px; + position: relative; + + textarea { + width: 100%; + height: 100%; + padding: 8px; + + &::placeholder { + color: var(--text-secondary-color); + } + } + + .altTextSpinner { + display: none; + position: absolute; + width: 16px; + height: 16px; + inset-inline-start: 8px; + inset-block-start: 8px; + mask-size: cover; + background-color: var(--text-secondary-color); + pointer-events: none; + } + + &.loading { + textarea::placeholder { + color: transparent; + } + + .altTextSpinner { + display: inline-block; + mask-image: var(--new-alt-text-spinner-icon); + } + } + } + + #newAltTextDescription { + font-size: 11px; + } + + #newAltTextDisclaimer { + display: flex; + align-items: center; + gap: 4px; + align-self: stretch; + flex-wrap: wrap; + font-size: 11px; + + &::before { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + mask-image: var(--new-alt-text-ai-disclaimer-icon); + mask-size: cover; + background-color: var(--text-secondary-color); + } + } + } + + #newAltTextDownloadModel { + display: flex; + align-items: center; + gap: 4px; + align-self: stretch; + + &::before { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + mask-image: var(--new-alt-text-spinner-icon); + mask-size: cover; + background-color: var(--text-secondary-color); + } + } + + #newAltTextImagePreview { + width: 180px; + aspect-ratio: 1; + display: flex; + justify-content: center; + align-items: center; + flex: 0 0 auto; + + > canvas { + max-width: 100%; + max-height: 100%; + } + } + } + } +} + .colorPicker { --hover-outline-color: #0250bb; --selected-outline-color: #0060df; diff --git a/web/app.js b/web/app.js index e28b43b694bcf..107c62004a56e 100644 --- a/web/app.js +++ b/web/app.js @@ -64,6 +64,7 @@ import { AltTextManager } from "web-alt_text_manager"; import { AnnotationEditorParams } from "web-annotation_editor_params"; import { CaretBrowsingMode } from "./caret_browsing.js"; import { DownloadManager } from "web-download_manager"; +import { NewAltTextManager } from "web-new_alt_text_manager"; import { OverlayManager } from "./overlay_manager.js"; import { PasswordPrompt } from "./password_prompt.js"; import { PDFAttachmentViewer } from "web-pdf_attachment_viewer"; @@ -205,6 +206,14 @@ const PDFViewerApplication = { if (mode) { document.documentElement.classList.add(mode); } + if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { + if (AppOptions.get("enableFakeMLManager")) { + this.mlManager = + MLManager.getFakeMLManager?.({ + enableGuessAltText: AppOptions.get("enableGuessAltText"), + }) || null; + } + } } else if (AppOptions.get("enableAltText")) { // We want to load the image-to-text AI engine as soon as possible. this.mlManager = new MLManager({ @@ -433,14 +442,21 @@ const PDFViewerApplication = { foreground: AppOptions.get("pageColorsForeground"), } : null; - const altTextManager = appConfig.altTextDialog - ? new AltTextManager( - appConfig.altTextDialog, - container, - this.overlayManager, - eventBus - ) - : null; + let altTextManager; + if (AppOptions.get("enableUpdatedAddImage")) { + altTextManager = appConfig.newAltTextDialog + ? new NewAltTextManager(appConfig.newAltTextDialog, this.overlayManager) + : null; + } else { + altTextManager = appConfig.altTextDialog + ? new AltTextManager( + appConfig.altTextDialog, + container, + this.overlayManager, + eventBus + ) + : null; + } const enableHWA = AppOptions.get("enableHWA"); const pdfViewer = new PDFViewer({ diff --git a/web/app_options.js b/web/app_options.js index a4fe7105a9a1d..b8803e3315468 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -469,6 +469,11 @@ if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) { value: typeof PDFJSDev !== "undefined" && PDFJSDev.test("CHROME") ? 2 : 0, kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }; + defaultOptions.enableFakeMLManager = { + /** @type {boolean} */ + value: true, + kind: OptionKind.VIEWER, + }; } if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { defaultOptions.disablePreferences = { diff --git a/web/dialog.css b/web/dialog.css index 13a78900f15d6..7fabdd75561a7 100644 --- a/web/dialog.css +++ b/web/dialog.css @@ -22,6 +22,8 @@ --hover-filter: brightness(0.9); --focus-ring-color: #0060df; --focus-ring-outline: 2px solid var(--focus-ring-color); + --link-fg-color: #0060df; + --link-hover-fg-color: #0250bb; --textarea-border-color: #8f8f9d; --textarea-bg-color: white; @@ -53,6 +55,8 @@ --text-secondary-color: #cfcfd8; --focus-ring-color: #0df; --hover-filter: brightness(1.4); + --link-fg-color: #0df; + --link-hover-fg-color: #80ebff; --textarea-bg-color: #42414d; @@ -73,6 +77,8 @@ --text-secondary-color: CanvasText; --hover-filter: none; --focus-ring-color: ButtonBorder; + --link-fg-color: LinkText; + --link-hover-fg-color: LinkText; --textarea-border-color: ButtonBorder; --textarea-bg-color: Field; @@ -112,6 +118,28 @@ outline-offset: 2px; } + .title { + display: flex; + width: auto; + flex-direction: column; + justify-content: flex-end; + align-items: flex-start; + gap: 12px; + + > span { + font-size: 13px; + font-style: normal; + font-weight: 590; + line-height: 150%; /* 19.5px */ + } + } + + .dialogButtonsGroup { + display: flex; + gap: 12px; + align-self: flex-end; + } + .radio { display: flex; flex-direction: column; @@ -159,7 +187,7 @@ } } - button { + button:not(:is(.toggle-button, .closeButton)) { border-radius: 4px; border: 1px solid; font: menu; @@ -199,6 +227,14 @@ } } + a { + color: var(--link-fg-color); + + &:hover { + color: var(--link-hover-fg-color); + } + } + textarea { font: inherit; padding: 8px; @@ -220,5 +256,148 @@ opacity: 0.4; } } + + .messageBar { + --message-bar-warning-icon: url(images/messageBar_warning.svg); + --closing-button-icon: url(images/messageBar_closingButton.svg); + + --message-bar-bg-color: #ffebcd; + --message-bar-fg-color: #15141a; + --message-bar-border-color: rgb(0 0 0 / 0.08); + --message-bar-icon-color: #cd411e; + --message-bar-close-button-border-radius: 4px; + --message-bar-close-button-border: none; + --message-bar-close-button-color: var(--text-primary-color); + --message-bar-close-button-hover-bg-color: rgb(21 20 26 / 0.14); + --message-bar-close-button-active-bg-color: rgb(21 20 26 / 0.21); + --message-bar-close-button-focus-bg-color: rgb(21 20 26 / 0.07); + --message-bar-close-button-color-hover: var(--text-primary-color); + + @media (prefers-color-scheme: dark) { + --message-bar-bg-color: #5a3100; + --message-bar-fg-color: #fbfbfe; + --message-bar-border-color: rgb(255 255 255 / 0.08); + --message-bar-icon-color: #e49c49; + --message-bar-close-button-hover-bg-color: rgb(251 251 254 / 0.14); + --message-bar-close-button-active-bg-color: rgb(251 251 254 / 0.21); + --message-bar-close-button-focus-bg-color: rgb(251 251 254 / 0.07); + } + + @media screen and (forced-colors: active) { + --message-bar-bg-color: HighlightText; + --message-bar-fg-color: CanvasText; + --message-bar-border-color: CanvasText; + --message-bar-icon-color: CanvasText; + --message-bar-close-button-color: ButtonText; + --message-bar-close-button-border: 1px solid ButtonText; + --message-bar-close-button-hover-bg-color: ButtonText; + --message-bar-close-button-active-bg-color: ButtonText; + --message-bar-close-button-focus-bg-color: ButtonText; + --message-bar-close-button-color-hover: HighlightText; + } + + display: flex; + position: relative; + padding: 12px 8px 12px 0; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 8px; + align-self: stretch; + + border-radius: 4px; + border: 1px solid var(--message-bar-border-color); + background: var(--message-bar-bg-color); + + > div { + display: flex; + padding-inline-start: 16px; + align-items: flex-start; + gap: 8px; + align-self: stretch; + + &::before { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + mask-image: var(--message-bar-warning-icon); + mask-size: cover; + background-color: var(--message-bar-icon-color); + } + + > div { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + flex: 1 0 0; + + .title { + font-size: 13px; + font-weight: 590; + } + + .description { + font-size: 13px; + } + } + } + + .closeButton { + position: absolute; + width: 32px; + height: 32px; + inset-inline-end: 8px; + inset-block-start: 8px; + background: none; + border-radius: var(--message-bar-close-button-border-radius); + border: var(--message-bar-close-button-border); + + &::before { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + mask-image: var(--closing-button-icon); + mask-size: cover; + background-color: var(--message-bar-close-button-color); + } + + &:is(:hover, :active, :focus)::before { + background-color: var(--message-bar-close-button-color-hover); + } + + &:hover { + background-color: var(--message-bar-close-button-hover-bg-color); + } + + &:active { + background-color: var(--message-bar-close-button-active-bg-color); + } + + &:focus { + background-color: var(--message-bar-close-button-focus-bg-color); + } + + > span { + display: inline-block; + width: 0; + height: 0; + overflow: hidden; + } + } + } + + .toggler { + display: flex; + align-items: center; + gap: 8px; + align-self: stretch; + + > .togglerLabel { + user-select: none; + } + } } } diff --git a/web/firefoxcom.js b/web/firefoxcom.js index 869b69564ce06..40b70e61085b0 100644 --- a/web/firefoxcom.js +++ b/web/firefoxcom.js @@ -310,6 +310,8 @@ class FirefoxScripting { class MLManager { #enabled = null; + #ready = null; + eventBus = null; constructor(options) { @@ -320,6 +322,10 @@ class MLManager { return !!(await this.#enabled?.get(name)); } + isReady(name) { + return this.#ready?.has(name) ?? false; + } + deleteModel(service) { return FirefoxCom.requestAsync("mlDelete", service); } @@ -338,14 +344,33 @@ class MLManager { this.altTextLearnMoreUrl = altTextLearnMoreUrl; } + async toggleService(name, enabled) { + if (name !== "altText") { + return; + } + + if (enabled) { + await this.#loadAltTextEngine(false); + } else { + this.#enabled?.delete(name); + this.#ready?.delete(name); + } + } + async #loadAltTextEngine(listenToProgress) { if (this.#enabled?.has("altText")) { // We already have a promise for the "altText" service. return; } + this.#ready ||= new Set(); const promise = FirefoxCom.requestAsync("loadAIEngine", { service: "moz-image-to-text", listenToProgress, + }).then(ok => { + if (ok) { + this.#ready.add("altText"); + } + return ok; }); (this.#enabled ||= new Map()).set("altText", promise); if (listenToProgress) { diff --git a/web/genericcom.js b/web/genericcom.js index dc3d60a98c878..4da24c1cbe7d2 100644 --- a/web/genericcom.js +++ b/web/genericcom.js @@ -56,9 +56,47 @@ class MLManager { return null; } - async guess() { + isReady(_name) { + return false; + } + + guess(_data) {} + + toggleService(_name, _enabled) {} + + static getFakeMLManager(options) { + return new FakeMLManager(options); + } +} + +class FakeMLManager { + constructor({ enableGuessAltText }) { + this.enableGuessAltText = enableGuessAltText; + } + + async isEnabledFor(_name) { + return this.enableGuessAltText; + } + + async deleteModel(_service) { return null; } + + isReady(_name) { + return true; + } + + guess({ request: { data } }) { + return new Promise(resolve => { + setTimeout(() => { + resolve(data ? { output: "Fake alt text" } : { error: true }); + }, 3000); + }); + } + + toggleService(_name, enabled) { + this.enableGuessAltText = enabled; + } } export { ExternalServices, initCom, MLManager, Preferences }; diff --git a/web/images/altText_disclaimer.svg b/web/images/altText_disclaimer.svg new file mode 100644 index 0000000000000..6fe79e710c82a --- /dev/null +++ b/web/images/altText_disclaimer.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/images/altText_spinner.svg b/web/images/altText_spinner.svg new file mode 100644 index 0000000000000..aa53ae78c3a5b --- /dev/null +++ b/web/images/altText_spinner.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/web/images/altText_warning.svg b/web/images/altText_warning.svg new file mode 100644 index 0000000000000..03014ceab2506 --- /dev/null +++ b/web/images/altText_warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/images/messageBar_closingButton.svg b/web/images/messageBar_closingButton.svg new file mode 100644 index 0000000000000..8a40715ded3a5 --- /dev/null +++ b/web/images/messageBar_closingButton.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/images/messageBar_warning.svg b/web/images/messageBar_warning.svg new file mode 100644 index 0000000000000..011cfcf3e1460 --- /dev/null +++ b/web/images/messageBar_warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/new_alt_text_manager.js b/web/new_alt_text_manager.js new file mode 100644 index 0000000000000..e2c19b181e551 --- /dev/null +++ b/web/new_alt_text_manager.js @@ -0,0 +1,399 @@ +/* Copyright 2024 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 NewAltTextManager { + #boundCancel = this.#cancel.bind(this); + + #createAutomaticallyButton; + + #currentEditor = null; + + #cancelButton; + + #descriptionContainer; + + #dialog; + + #disclaimer; + + #firstTime = false; + + #guessedAltText; + + #isEditing = null; + + #imagePreview; + + #imageData; + + #isAILoading = false; + + #wasAILoading = false; + + #learnMore; + + #notNowButton; + + #overlayManager; + + #textarea; + + #title; + + #uiManager; + + #previousAltText = null; + + #telemetryData = null; + + constructor( + { + descriptionContainer, + dialog, + imagePreview, + cancelButton, + disclaimer, + notNowButton, + saveButton, + textarea, + learnMore, + errorCloseButton, + createAutomaticallyButton, + title, + }, + overlayManager + ) { + this.#cancelButton = cancelButton; + this.#createAutomaticallyButton = createAutomaticallyButton; + this.#descriptionContainer = descriptionContainer; + this.#dialog = dialog; + this.#disclaimer = disclaimer; + this.#notNowButton = notNowButton; + this.#imagePreview = imagePreview; + this.#textarea = textarea; + this.#learnMore = learnMore; + this.#title = title; + this.#overlayManager = overlayManager; + + dialog.addEventListener("close", this.#close.bind(this)); + dialog.addEventListener("contextmenu", event => { + if (event.target !== this.#textarea) { + event.preventDefault(); + } + }); + cancelButton.addEventListener("click", this.#boundCancel); + notNowButton.addEventListener("click", this.#boundCancel); + saveButton.addEventListener("click", this.#save.bind(this)); + errorCloseButton.addEventListener("click", () => { + this.#toggleError(false); + }); + createAutomaticallyButton.addEventListener("click", async () => { + const checked = + createAutomaticallyButton.getAttribute("aria-pressed") !== "true"; + if (this.#uiManager) { + this.#uiManager.setPreference("enableGuessAltText", checked); + await this.#uiManager.mlManager.toggleService("altText", checked); + } + this.#toggleGuessAltText(checked, /* isInitial = */ false); + }); + textarea.addEventListener("focus", () => { + this.#wasAILoading = this.#isAILoading; + this.#toggleLoading(false); + }); + textarea.addEventListener("blur", () => { + if (textarea.value) { + return; + } + this.#toggleLoading(this.#wasAILoading); + }); + textarea.addEventListener("input", () => { + this.#toggleTitle(); + this.#toggleDisclaimer(); + }); + + this.#overlayManager.register(dialog); + } + + #toggleLoading(value) { + if (!this.#uiManager || this.#isAILoading === value) { + return; + } + this.#isAILoading = value; + this.#descriptionContainer.classList.toggle("loading", value); + } + + #toggleError(value) { + if (!this.#uiManager) { + return; + } + this.#dialog.classList.toggle("error", value); + } + + #toggleTitle() { + const isEditing = this.#isAILoading || !!this.#textarea.value; + if (this.#isEditing === isEditing) { + return; + } + this.#isEditing = isEditing; + this.#title.setAttribute( + "data-l10n-id", + `pdfjs-editor-new-alt-text-dialog-${isEditing ? "edit" : "add"}-label` + ); + } + + async #toggleGuessAltText(value, isInitial = false) { + if (!this.#uiManager) { + return; + } + this.#dialog.classList.toggle("aiDisabled", !value); + this.#createAutomaticallyButton.setAttribute("aria-pressed", value); + + if (value) { + const { altTextLearnMoreUrl } = this.#uiManager.mlManager; + if (altTextLearnMoreUrl) { + this.#learnMore.href = altTextLearnMoreUrl; + } + this.#mlGuessAltText(isInitial); + } else { + this.#toggleLoading(false); + this.#isAILoading = false; + this.#toggleTitle(); + this.#toggleDisclaimer(); + } + } + + #toggleNotNow() { + this.#notNowButton.classList.toggle("hidden", !this.#firstTime); + this.#cancelButton.classList.toggle("hidden", this.#firstTime); + } + + #toggleAI(value) { + this.#dialog.classList.toggle("noAi", !value); + this.#toggleTitle(); + } + + #toggleDisclaimer(value = null) { + if (!this.#uiManager) { + return; + } + const hidden = + value === null + ? !this.#guessedAltText || this.#guessedAltText !== this.#textarea.value + : !value; + this.#disclaimer.classList.toggle("hidden", hidden); + } + + async #mlGuessAltText(isInitial) { + if (this.#isAILoading) { + // We're still loading the previous guess. + return; + } + + if (this.#textarea.value) { + // The user has already set an alt text. + return; + } + + if (isInitial && this.#previousAltText !== null) { + // The user has already set an alt text (empty or not). + return; + } + + this.#guessedAltText = this.#currentEditor.guessedAltText; + if (this.#previousAltText === null && this.#guessedAltText) { + // We have a guessed alt text and the user didn't change it. + this.#addAltText(this.#guessedAltText); + this.#toggleDisclaimer(); + this.#toggleTitle(); + return; + } + + this.#toggleLoading(true); + this.#toggleTitle(); + this.#toggleDisclaimer(true); + + let hasError = false; + try { + const { width, height, data } = this.#imageData; + + // Take a reference on the current editor, as it can be set to null (if + // the dialog is closed before the end of the guess). + // But in case we've an alt-text, we want to set it on the editor. + const editor = this.#currentEditor; + + // When calling #mlGuessAltText we don't wait for it, so we must take care + // that the alt text dialog can have been closed before the response is. + const response = await this.#uiManager.mlGuess({ + service: "moz-image-to-text", + request: { + data, + width, + height, + channels: data.length / (width * height), + }, + }); + if (!response || response.error || !response.output) { + throw new Error("No valid response from the AI service."); + } + const altText = (this.#guessedAltText = response.output); + await editor.setGuessedAltText(altText); + this.#wasAILoading = this.#isAILoading; + if (this.#isAILoading) { + this.#addAltText(altText); + } + } catch (e) { + console.error(e); + hasError = true; + } + + this.#toggleLoading(false); + + if (hasError && this.#uiManager) { + this.#toggleError(true); + this.#toggleTitle(); + this.#toggleDisclaimer(); + } + } + + #addAltText(altText) { + if (!this.#uiManager || this.#textarea.value) { + return; + } + this.#textarea.value = altText; + } + + async editAltText(uiManager, editor, firstTime) { + if (this.#currentEditor || !editor) { + return; + } + + if (firstTime && editor.hasAltTextData()) { + editor.altTextFinish(); + return; + } + + this.#firstTime = firstTime; + let { mlManager } = uiManager; + if (!mlManager?.isReady("altText")) { + mlManager = null; + } + const isAltTextEnabledPromise = mlManager?.isEnabledFor("altText"); + + this.#currentEditor = editor; + this.#uiManager = uiManager; + this.#uiManager.removeEditListeners(); + + ({ altText: this.#previousAltText } = editor.altTextData); + this.#textarea.value = this.#previousAltText ?? ""; + + // TODO: get this value from Firefox + // (https://bugzilla.mozilla.org/show_bug.cgi?id=1908184) + const AI_MAX_IMAGE_DIMENSION = 224; + + // The max dimension of the preview in the dialog is 180px, so we keep 224px + // and rescale it thanks to css. + + let canvas; + if (mlManager) { + ({ canvas, imageData: this.#imageData } = editor.copyCanvas( + AI_MAX_IMAGE_DIMENSION, + /* createImageData = */ true + )); + this.#toggleGuessAltText( + await isAltTextEnabledPromise, + /* isInitial = */ true + ); + } else { + ({ canvas } = editor.copyCanvas( + AI_MAX_IMAGE_DIMENSION, + /* createImageData = */ false + )); + } + + canvas.setAttribute("role", "presentation"); + this.#imagePreview.append(canvas); + + this.#toggleNotNow(); + this.#toggleAI(!!mlManager); + this.#toggleError(false); + + try { + await this.#overlayManager.open(this.#dialog); + } catch (ex) { + this.#close(); + throw ex; + } + } + + #cancel() { + this.#currentEditor.altTextData = { + cancel: true, + }; + this.#finish(); + } + + #finish() { + if (this.#overlayManager.active === this.#dialog) { + this.#overlayManager.close(this.#dialog); + } + } + + #close() { + const canvas = this.#imagePreview.firstChild; + canvas.remove(); + canvas.width = canvas.height = 0; + this.#imageData = null; + + this.#currentEditor._reportTelemetry( + this.#telemetryData || { + action: "alt_text_cancel", + } + ); + + this.#telemetryData = null; + this.#toggleLoading(false); + + this.#uiManager?.addEditListeners(); + this.#currentEditor.altTextFinish(); + this.#uiManager?.setSelected(this.#currentEditor); + this.#currentEditor = null; + this.#uiManager = null; + } + + #save() { + const altText = this.#textarea.value.trim(); + this.#currentEditor.altTextData = { + altText, + decorative: false, + }; + this.#telemetryData = { + action: "alt_text_save", + alt_text_description: !!altText, + alt_text_edit: + !!this.#previousAltText && this.#previousAltText !== altText, + alt_text_decorative: false, + alt_text_altered: + this.#guessedAltText && this.#guessedAltText !== altText, + }; + this.#finish(); + } + + destroy() { + this.#uiManager = null; // Avoid re-adding the edit listeners. + this.#finish(); + } +} + +export { NewAltTextManager }; diff --git a/web/stubs-geckoview.js b/web/stubs-geckoview.js index 726f03242d830..3c0669d0d731d 100644 --- a/web/stubs-geckoview.js +++ b/web/stubs-geckoview.js @@ -15,6 +15,7 @@ const AltTextManager = null; const AnnotationEditorParams = null; +const NewAltTextManager = null; const PDFAttachmentViewer = null; const PDFCursorTools = null; const PDFDocumentProperties = null; @@ -29,6 +30,7 @@ const SecondaryToolbar = null; export { AltTextManager, AnnotationEditorParams, + NewAltTextManager, PDFAttachmentViewer, PDFCursorTools, PDFDocumentProperties, diff --git a/web/viewer-geckoview.html b/web/viewer-geckoview.html index dee94a04d6394..44e2bd8ed78df 100644 --- a/web/viewer-geckoview.html +++ b/web/viewer-geckoview.html @@ -68,6 +68,7 @@ "web-annotation_editor_params": "./stubs-geckoview.js", "web-download_manager": "./download_manager.js", "web-external_services": "./genericcom.js", + "web-new_alt_text_manager": "./stubs-geckoview.js", "web-null_l10n": "./genericl10n.js", "web-pdf_attachment_viewer": "./stubs-geckoview.js", "web-pdf_cursor_tools": "./stubs-geckoview.js", diff --git a/web/viewer.html b/web/viewer.html index 4175bcf5dbe80..3a31cf846d3aa 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -71,6 +71,7 @@ "web-annotation_editor_params": "./annotation_editor_params.js", "web-download_manager": "./download_manager.js", "web-external_services": "./genericcom.js", + "web-new_alt_text_manager": "./new_alt_text_manager.js", "web-null_l10n": "./genericl10n.js", "web-pdf_attachment_viewer": "./pdf_attachment_viewer.js", "web-pdf_cursor_tools": "./pdf_cursor_tools.js", @@ -550,6 +551,48 @@ + +
+
+ Edit alt text (image description) +
+
+
+
+
+
+ +
+ Short description for people who can’t see the image or when the image doesn’t load. +
This alt text was created automatically. Learn more
+
+
+ + +
+ +
+
+
+
+
+
+ Couldn’t create alt text automatically + Please write your own alt text or try again later. +
+ +
+
+
+ + + +
+
+
+
diff --git a/web/viewer.js b/web/viewer.js index ff40485c37d7c..3810d49c58382 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -163,6 +163,32 @@ function getViewerConfiguration() { cancelButton: document.getElementById("altTextCancel"), saveButton: document.getElementById("altTextSave"), }, + newAltTextDialog: { + dialog: document.getElementById("newAltTextDialog"), + title: document.getElementById("newAltTextTitle"), + descriptionContainer: document.getElementById( + "newAltTextDescriptionContainer" + ), + textarea: document.getElementById("newAltTextDescriptionTextarea"), + disclaimer: document.getElementById("newAltTextDisclaimer"), + learnMore: document.getElementById("newAltTextLearnMore"), + imagePreview: document.getElementById("newAltTextImagePreview"), + createAutomatically: document.getElementById( + "newAltTextCreateAutomatically" + ), + createAutomaticallyButton: document.getElementById( + "newAltTextCreateAutomaticallyButton" + ), + downloadModel: document.getElementById("newAltTextDownloadModel"), + downloadModelDescription: document.getElementById( + "newAltTextDownloadModelDescription" + ), + error: document.getElementById("newAltTextError"), + errorCloseButton: document.getElementById("newAltTextCloseButton"), + cancelButton: document.getElementById("newAltTextCancel"), + notNowButton: document.getElementById("newAltTextNotNow"), + saveButton: document.getElementById("newAltTextSave"), + }, annotationEditorParams: { editorFreeTextFontSize: document.getElementById("editorFreeTextFontSize"), editorFreeTextColor: document.getElementById("editorFreeTextColor"),