From f266c4d8b8b9e8bd76f985d16c2a4dad049b9eef Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Thu, 23 Apr 2026 14:48:47 +0200 Subject: [PATCH] [Ink] Replace the opacity slider with an alpha-enabled color input The alpha feature is available in Firefox nightly (with the pref `dom.forms.html_color_picker.enabled` set to `true`). It's available in Safari but not in Chrome. --- src/display/display_utils.js | 53 +++++++++++++---- src/display/editor/color_picker.js | 73 ++++++++++++++++++++++-- src/display/editor/draw.js | 39 +++++++++++++ src/display/editor/ink.js | 33 +++++++++++ src/pdf.js | 3 + src/shared/util.js | 20 +++++++ test/integration/ink_editor_spec.mjs | 71 +++++++++++++++++++++++ test/unit/display_utils_spec.js | 49 ++++++++++++++++ test/unit/pdf_spec.js | 2 + web/annotation_editor_layer_builder.css | 2 + web/annotation_editor_params.js | 76 ++++++++++++++++++++++--- web/pdfjs.js | 2 + 12 files changed, 397 insertions(+), 26 deletions(-) diff --git a/src/display/display_utils.js b/src/display/display_utils.js index be5bcd486..df43b09d7 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -579,33 +579,63 @@ function getXfaPageViewport(xfaPage, { scale = 1, rotation = 0 }) { }); } -function getRGB(color) { +function getRGBA(color) { if (color.startsWith("#")) { - const colorRGB = parseInt(color.slice(1), 16); + // #RRGGBB or #RRGGBBAA + const hex = color.slice(1); return [ - (colorRGB & 0xff0000) >> 16, - (colorRGB & 0x00ff00) >> 8, - colorRGB & 0x0000ff, + parseInt(hex.slice(0, 2), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(4, 6), 16), + hex.length >= 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1, ]; } if (color.startsWith("rgb(")) { // getComputedStyle(...).color returns a `rgb(R, G, B)` color. - return color + const [r, g, b] = color .slice(/* "rgb(".length */ 4, -1) // Strip out "rgb(" and ")". .split(",") .map(x => parseInt(x)); + return [r, g, b, 1]; } if (color.startsWith("rgba(")) { - return color + const parts = color .slice(/* "rgba(".length */ 5, -1) // Strip out "rgba(" and ")". - .split(",", 3) - .map(x => parseInt(x)); + .split(","); + return [ + parseInt(parts[0]), + parseInt(parts[1]), + parseInt(parts[2]), + parseFloat(parts[3]), + ]; } - warn(`Not a valid color format: "${color}"`); - return [0, 0, 0]; + // color(srgb r g b / a) — CSS Color 4, used e.g. by Firefox alpha inputs. + // Components are in [0, 1]; alpha may be "none" (treated as fully opaque). + const m = color.match( + /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+|none))?\)$/ + ); + if (m) { + return [ + Math.round(parseFloat(m[1]) * 255), + Math.round(parseFloat(m[2]) * 255), + Math.round(parseFloat(m[3]) * 255), + m[4] !== undefined && m[4] !== "none" ? parseFloat(m[4]) : 1, + ]; + } + + return null; +} + +function getRGB(color) { + const rgba = getRGBA(color); + if (!rgba) { + warn(`Not a valid color format: "${color}"`); + return [0, 0, 0]; + } + return rgba.slice(0, 3); } function getColorValues(colors) { @@ -1037,6 +1067,7 @@ export { getFilenameFromUrl, getPdfFilenameFromUrl, getRGB, + getRGBA, getXfaPageViewport, isDataScheme, isPdfFile, diff --git a/src/display/editor/color_picker.js b/src/display/editor/color_picker.js index e7561516b..693d78871 100644 --- a/src/display/editor/color_picker.js +++ b/src/display/editor/color_picker.js @@ -13,9 +13,14 @@ * limitations under the License. */ -import { AnnotationEditorParamsType, shadow } from "../../shared/util.js"; +import { + AnnotationEditorParamsType, + FeatureTest, + shadow, + Util, +} from "../../shared/util.js"; +import { getRGBA, noContextMenu } from "../display_utils.js"; import { KeyboardManager } from "./tools.js"; -import { noContextMenu } from "../display_utils.js"; /** * ColorPicker class provides a color picker for the annotation editor. @@ -314,6 +319,8 @@ class ColorPicker { class BasicColorPicker { #input = null; + #hasAlpha = false; + #editor = null; #uiManager = null; @@ -334,17 +341,52 @@ class BasicColorPicker { if (this.#input) { return this.#input; } - const { editorType, colorType, color } = this.#editor; + const { + editorType, + colorType, + colorAndOpacityType, + opacityType, + color, + opacity, + } = this.#editor; + const hasAlpha = (this.#hasAlpha = + FeatureTest.isAlphaColorInputSupported && opacityType !== undefined); const input = (this.#input = document.createElement("input")); input.type = "color"; - input.value = color || "#000000"; + if (hasAlpha) { + input.setAttribute("alpha", ""); + const alphaHex = Math.round((opacity ?? 1) * 255) + .toString(16) + .padStart(2, "0"); + input.value = (color || "#000000") + alphaHex; + } else { + input.value = color || "#000000"; + } input.className = "basicColorPicker"; input.tabIndex = 0; input.setAttribute("data-l10n-id", BasicColorPicker.#l10nColor[editorType]); input.addEventListener( "input", () => { - this.#uiManager.updateParams(colorType, input.value); + if (hasAlpha) { + const rgba = getRGBA(input.value); + if (!rgba) { + return; + } + const [r, g, b, op] = rgba; + const hex = Util.makeHexColor(r, g, b); + if (colorAndOpacityType !== undefined) { + this.#uiManager.updateParams(colorAndOpacityType, { + color: hex, + opacity: op, + }); + } else { + this.#uiManager.updateParams(colorType, hex); + this.#uiManager.updateParams(opacityType, op); + } + } else { + this.#uiManager.updateParams(colorType, input.value); + } }, { signal: this.#uiManager._signal } ); @@ -355,7 +397,26 @@ class BasicColorPicker { if (!this.#input) { return; } - this.#input.value = value; + if (this.#hasAlpha) { + // Reconstruct #RRGGBBAA using the editor's current opacity. + const alphaHex = Math.round(this.#editor.opacity * 255) + .toString(16) + .padStart(2, "0"); + this.#input.value = value + alphaHex; + } else { + this.#input.value = value; + } + } + + updateOpacity(value) { + if (!this.#input || !this.#hasAlpha) { + return; + } + // Reconstruct #RRGGBBAA using the editor's current color. + const alphaHex = Math.round(value * 255) + .toString(16) + .padStart(2, "0"); + this.#input.value = this.#editor.color + alphaHex; } destroy() { diff --git a/src/display/editor/draw.js b/src/display/editor/draw.js index 778b1d73f..d26c0a220 100644 --- a/src/display/editor/draw.js +++ b/src/display/editor/draw.js @@ -97,6 +97,11 @@ class DrawingEditor extends AnnotationEditor { super.onUpdatedColor(); } + /** @inheritdoc */ + onUpdatedOpacity() { + this._colorPicker?.updateOpacity?.(this.opacity); + } + _addOutlines(params) { if (params.drawOutlines) { this.#createDrawOutlines(params); @@ -243,6 +248,8 @@ class DrawingEditor extends AnnotationEditor { ); if (type === this.colorType) { this.onUpdatedColor(); + } else if (type === this.opacityType) { + this.onUpdatedOpacity(); } }; this.addCommands({ @@ -256,6 +263,38 @@ class DrawingEditor extends AnnotationEditor { }); } + /** + * Update color and opacity atomically as one undoable command. + */ + _updateColorAndOpacity(color, opacity) { + const colorName = this.constructor.typesMap.get(this.colorType); + const opacityName = this.constructor.typesMap.get(this.opacityType); + const options = this._drawingOptions; + const savedColor = options[colorName]; + const savedOpacity = options[opacityName]; + const setter = (c, op) => { + options.updateProperty(colorName, c); + options.updateProperty(opacityName, op); + this.#drawOutlines.updateProperty(colorName, c); + this.#drawOutlines.updateProperty(opacityName, op); + this.parent?.drawLayer.updateProperties( + this._drawId, + options.toSVGProperties() + ); + this.onUpdatedColor(); + this.onUpdatedOpacity(); + }; + this.addCommands({ + cmd: setter.bind(this, color, opacity), + undo: setter.bind(this, savedColor, savedOpacity), + post: this._uiManager.updateUI.bind(this._uiManager, this), + mustExec: true, + type: AnnotationEditorParamsType.INK_COLOR_AND_OPACITY, + overwriteIfSameType: true, + keepUndo: true, + }); + } + /** @inheritdoc */ _onResizing() { this.parent?.drawLayer.updateProperties( diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index ad7414048..f42ca1af0 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -199,6 +199,39 @@ class InkEditor extends DrawingEditor { return AnnotationEditorParamsType.INK_COLOR; } + get colorAndOpacityType() { + return AnnotationEditorParamsType.INK_COLOR_AND_OPACITY; + } + + get opacityType() { + return AnnotationEditorParamsType.INK_OPACITY; + } + + /** @inheritdoc */ + updateParams(type, value) { + if (type === AnnotationEditorParamsType.INK_COLOR_AND_OPACITY) { + this._updateColorAndOpacity(value.color, value.opacity); + return; + } + super.updateParams(type, value); + } + + /** @inheritdoc */ + static updateDefaultParams(type, value) { + if (type === AnnotationEditorParamsType.INK_COLOR_AND_OPACITY) { + super.updateDefaultParams( + AnnotationEditorParamsType.INK_COLOR, + value.color + ); + super.updateDefaultParams( + AnnotationEditorParamsType.INK_OPACITY, + value.opacity + ); + return; + } + super.updateDefaultParams(type, value); + } + get color() { return this._drawingOptions.stroke; } diff --git a/src/pdf.js b/src/pdf.js index 7cce4c0af..73a844159 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -54,6 +54,7 @@ import { getFilenameFromUrl, getPdfFilenameFromUrl, getRGB, + getRGBA, getXfaPageViewport, isDataScheme, isPdfFile, @@ -119,6 +120,7 @@ globalThis.pdfjsLib = { getFilenameFromUrl, getPdfFilenameFromUrl, getRGB, + getRGBA, getUuid, getXfaPageViewport, GlobalWorkerOptions, @@ -182,6 +184,7 @@ export { getFilenameFromUrl, getPdfFilenameFromUrl, getRGB, + getRGBA, getUuid, getXfaPageViewport, GlobalWorkerOptions, diff --git a/src/shared/util.js b/src/shared/util.js index 43cbebbe1..7a7c6f8d1 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -92,6 +92,7 @@ const AnnotationEditorParamsType = { INK_COLOR: 21, INK_THICKNESS: 22, INK_OPACITY: 23, + INK_COLOR_AND_OPACITY: 24, HIGHLIGHT_COLOR: 31, HIGHLIGHT_THICKNESS: 32, HIGHLIGHT_FREE: 33, @@ -666,6 +667,25 @@ class FeatureTest { globalThis.CSS?.supports?.("width: round(1.5px, 1px)") ); } + + static get isAlphaColorInputSupported() { + return shadow( + this, + "isAlphaColorInputSupported", + (() => { + if (typeof document === "undefined") { + return false; + } + const input = document.createElement("input"); + input.type = "color"; + input.setAttribute("alpha", ""); + input.value = "#ff000080"; + // If alpha is supported the color picker retains the alpha channel, so + // the value won't be a plain opaque color (7-char #rrggbb). + return input.value !== "#ff0000"; + })() + ); + } } const hexNumbers = Array.from(Array(256).keys(), n => diff --git a/test/integration/ink_editor_spec.mjs b/test/integration/ink_editor_spec.mjs index bdd4a372d..0dcbfd8cd 100644 --- a/test/integration/ink_editor_spec.mjs +++ b/test/integration/ink_editor_spec.mjs @@ -1238,6 +1238,77 @@ describe("Ink must update its color", () => { }); }); +describe("Ink color and opacity change must be a single undo step", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait("empty.pdf", ".annotationEditorLayer"); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("must restore both color and opacity with a single undo", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await switchToInk(page); + + const rect = await getRect(page, ".annotationEditorLayer"); + const x = rect.x + 20; + const y = rect.y + 20; + await drawLine(page, x, y, x + 50, y + 50); + await commit(page); + + const drawSelector = ".canvasWrapper svg.draw"; + await page.waitForSelector(drawSelector, { visible: true }); + + // Dispatch a combined color+opacity update (single undo step). + await page.evaluate( + value => { + window.PDFViewerApplication.eventBus.dispatch( + "switchannotationeditorparams", + { + source: null, + type: window.pdfjsLib.AnnotationEditorParamsType + .INK_COLOR_AND_OPACITY, + value, + } + ); + }, + { color: "#ff0000", opacity: 0.5 } + ); + + await page.waitForSelector(`${drawSelector}[stroke='#ff0000']`, { + visible: true, + }); + let opacity = await page.evaluate( + sel => document.querySelector(sel).getAttribute("stroke-opacity"), + drawSelector + ); + expect(opacity).withContext(`In ${browserName}`).toEqual("0.5"); + + // One undo must restore both color and opacity atomically. + await kbUndo(page); + + await page.waitForSelector(`${drawSelector}[stroke='#000000']`, { + visible: true, + }); + opacity = await page.evaluate( + sel => document.querySelector(sel).getAttribute("stroke-opacity"), + drawSelector + ); + expect(opacity).withContext(`In ${browserName}`).toEqual("1"); + + // A second undo removes the draw, proving the color+opacity change + // was a single undo step and not two. + await kbUndo(page); + await waitForNoElement(page, drawSelector); + }) + ); + }); +}); + describe("Ink must committed when leaving the tab", () => { let pages; diff --git a/test/unit/display_utils_spec.js b/test/unit/display_utils_spec.js index 28be4d441..0859107ad 100644 --- a/test/unit/display_utils_spec.js +++ b/test/unit/display_utils_spec.js @@ -18,6 +18,8 @@ import { findContrastColor, getFilenameFromUrl, getPdfFilenameFromUrl, + getRGB, + getRGBA, isValidFetchUrl, PDFDateString, renderRichText, @@ -302,6 +304,53 @@ describe("display_utils", function () { }); }); + describe("getRGBA", function () { + it("parses a 6-digit hex color as fully opaque", function () { + expect(getRGBA("#ff0000")).toEqual([255, 0, 0, 1]); + expect(getRGBA("#00ff00")).toEqual([0, 255, 0, 1]); + expect(getRGBA("#1a2b3c")).toEqual([26, 43, 60, 1]); + }); + + it("parses an 8-digit hex color with alpha", function () { + expect(getRGBA("#ff000080")).toEqual([255, 0, 0, 128 / 255]); + expect(getRGBA("#00ff00ff")).toEqual([0, 255, 0, 1]); + expect(getRGBA("#00000000")).toEqual([0, 0, 0, 0]); + }); + + it("parses an rgb() color as fully opaque", function () { + expect(getRGBA("rgb(255, 0, 0)")).toEqual([255, 0, 0, 1]); + expect(getRGBA("rgb(0, 128, 64)")).toEqual([0, 128, 64, 1]); + }); + + it("parses an rgba() color with alpha", function () { + expect(getRGBA("rgba(255, 0, 0, 0.5)")).toEqual([255, 0, 0, 0.5]); + expect(getRGBA("rgba(0, 0, 0, 0)")).toEqual([0, 0, 0, 0]); + expect(getRGBA("rgba(1, 2, 3, 1)")).toEqual([1, 2, 3, 1]); + }); + + it("parses a color(srgb) value as fully opaque when no alpha", function () { + expect(getRGBA("color(srgb 1 0 0)")).toEqual([255, 0, 0, 1]); + expect(getRGBA("color(srgb 0 0.5 0.25)")).toEqual([0, 128, 64, 1]); + }); + + it("parses a color(srgb) value with alpha", function () { + expect(getRGBA("color(srgb 1 0 0 / 0.5)")).toEqual([255, 0, 0, 0.5]); + expect(getRGBA("color(srgb 0 0 0 / 0)")).toEqual([0, 0, 0, 0]); + }); + + it("treats 'none' alpha in color(srgb) as fully opaque", function () { + expect(getRGBA("color(srgb 1 0 0 / none)")).toEqual([255, 0, 0, 1]); + }); + }); + + describe("getRGB", function () { + it("returns only the RGB components, dropping alpha", function () { + expect(getRGB("#ff000080")).toEqual([255, 0, 0]); + expect(getRGB("rgba(0, 128, 64, 0.5)")).toEqual([0, 128, 64]); + expect(getRGB("color(srgb 0 0.5 0.25 / 0.8)")).toEqual([0, 128, 64]); + }); + }); + describe("findContrastColor", function () { it("Check that the lightness is changed correctly", function () { expect(findContrastColor([210, 98, 76], [197, 113, 89])).toEqual( diff --git a/test/unit/pdf_spec.js b/test/unit/pdf_spec.js index 2a03df8b1..2923c626e 100644 --- a/test/unit/pdf_spec.js +++ b/test/unit/pdf_spec.js @@ -45,6 +45,7 @@ import { getFilenameFromUrl, getPdfFilenameFromUrl, getRGB, + getRGBA, getXfaPageViewport, isDataScheme, isPdfFile, @@ -103,6 +104,7 @@ const expectedAPI = Object.freeze({ getFilenameFromUrl, getPdfFilenameFromUrl, getRGB, + getRGBA, getUuid, getXfaPageViewport, GlobalWorkerOptions, diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 32af44726..72d0222ca 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -1084,11 +1084,13 @@ &::-moz-color-swatch { border-radius: 100%; + margin: 0; } /*#if !MOZCENTRAL*/ &::-webkit-color-swatch { border-radius: 100%; + margin: 0; } /*#endif*/ } diff --git a/web/annotation_editor_params.js b/web/annotation_editor_params.js index 1e4fc431d..d12879774 100644 --- a/web/annotation_editor_params.js +++ b/web/annotation_editor_params.js @@ -15,7 +15,12 @@ /** @typedef {import("./event_utils.js").EventBus} EventBus */ -import { AnnotationEditorParamsType } from "pdfjs-lib"; +import { + AnnotationEditorParamsType, + FeatureTest, + getRGBA, + Util, +} from "pdfjs-lib"; /** * @typedef {Object} AnnotationEditorParamsOptions @@ -69,15 +74,68 @@ class AnnotationEditorParams { editorFreeTextColor.addEventListener("input", function () { dispatchEvent("FREETEXT_COLOR", this.value); }); - editorInkColor.addEventListener("input", function () { - dispatchEvent("INK_COLOR", this.value); - }); + + // Handlers for INK_COLOR and INK_OPACITY sync-back, set up differently + // depending on whether alpha is supported. + let updateInkColor, updateInkOpacity; + + if (FeatureTest.isAlphaColorInputSupported) { + // Enable alpha on the color input and remove the now-redundant opacity + // slider from the DOM. + editorInkColor.setAttribute("alpha", ""); + editorInkOpacity.closest(".editorParamsSetter").remove(); + + // Track last-known color/opacity so that sync-back events for either + // property can reconstruct the full #RRGGBBAA without re-parsing the + // input's current (format-varying) value. + let currentInkColor = "#000000"; + let currentInkOpacity = 1; + + const toAlphaHex = opacity => + Math.round(opacity * 255) + .toString(16) + .padStart(2, "0"); + + editorInkColor.addEventListener("input", function () { + // The returned value format varies by browser; normalize it. + const rgba = getRGBA(this.value); + if (!rgba) { + return; + } + const [r, g, b, opacity] = rgba; + const hex = Util.makeHexColor(r, g, b); + currentInkColor = hex; + currentInkOpacity = opacity; + dispatchEvent("INK_COLOR_AND_OPACITY", { color: hex, opacity }); + }); + + updateInkColor = value => { + currentInkColor = value; + editorInkColor.value = currentInkColor + toAlphaHex(currentInkOpacity); + }; + updateInkOpacity = value => { + currentInkOpacity = value; + editorInkColor.value = currentInkColor + toAlphaHex(currentInkOpacity); + }; + } else { + editorInkColor.addEventListener("input", function () { + dispatchEvent("INK_COLOR", this.value); + }); + editorInkOpacity.addEventListener("input", function () { + dispatchEvent("INK_OPACITY", this.valueAsNumber); + }); + + updateInkColor = value => { + editorInkColor.value = value; + }; + updateInkOpacity = value => { + editorInkOpacity.value = value; + }; + } + editorInkThickness.addEventListener("input", function () { dispatchEvent("INK_THICKNESS", this.valueAsNumber); }); - editorInkOpacity.addEventListener("input", function () { - dispatchEvent("INK_OPACITY", this.valueAsNumber); - }); editorStampAddImage.addEventListener("click", () => { eventBus.dispatch("reporttelemetry", { source: this, @@ -110,13 +168,13 @@ class AnnotationEditorParams { editorFreeTextColor.value = value; break; case AnnotationEditorParamsType.INK_COLOR: - editorInkColor.value = value; + updateInkColor(value); break; case AnnotationEditorParamsType.INK_THICKNESS: editorInkThickness.value = value; break; case AnnotationEditorParamsType.INK_OPACITY: - editorInkOpacity.value = value; + updateInkOpacity(value); break; case AnnotationEditorParamsType.HIGHLIGHT_COLOR: eventBus.dispatch("mainhighlightcolorpickerupdatecolor", { diff --git a/web/pdfjs.js b/web/pdfjs.js index 5f0478843..dfd7fef36 100644 --- a/web/pdfjs.js +++ b/web/pdfjs.js @@ -36,6 +36,7 @@ const { getFilenameFromUrl, getPdfFilenameFromUrl, getRGB, + getRGBA, getUuid, getXfaPageViewport, GlobalWorkerOptions, @@ -99,6 +100,7 @@ export { getFilenameFromUrl, getPdfFilenameFromUrl, getRGB, + getRGBA, getUuid, getXfaPageViewport, GlobalWorkerOptions,