[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.
This commit is contained in:
Calixte Denizet 2026-04-23 14:48:47 +02:00 committed by calixteman
parent 9f42555cfb
commit f266c4d8b8
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
12 changed files with 397 additions and 26 deletions

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1084,11 +1084,13 @@
&::-moz-color-swatch {
border-radius: 100%;
margin: 0;
}
/*#if !MOZCENTRAL*/
&::-webkit-color-swatch {
border-radius: 100%;
margin: 0;
}
/*#endif*/
}

View File

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

View File

@ -36,6 +36,7 @@ const {
getFilenameFromUrl,
getPdfFilenameFromUrl,
getRGB,
getRGBA,
getUuid,
getXfaPageViewport,
GlobalWorkerOptions,
@ -99,6 +100,7 @@ export {
getFilenameFromUrl,
getPdfFilenameFromUrl,
getRGB,
getRGBA,
getUuid,
getXfaPageViewport,
GlobalWorkerOptions,