[Editor] Add a basic stamp editor (bug 1790255)

For now it allows to add a stamp annotation with an image selected from the file system.
This commit is contained in:
Calixte Denizet 2023-06-22 12:16:07 +02:00
parent 2a837ba0b5
commit 37bd78c707
19 changed files with 754 additions and 39 deletions

View File

@ -81,6 +81,10 @@
"description": "Whether to allow execution of active content (JavaScript) by PDF files.",
"default": false
},
"enableStampEditor": {
"type": "boolean",
"default": true
},
"disableRange": {
"title": "Disable range requests",
"description": "Whether to disable range requests (not recommended).",

View File

@ -3011,4 +3011,9 @@ class AnnotationLayer {
}
}
export { AnnotationLayer, FreeTextAnnotationElement, InkAnnotationElement };
export {
AnnotationLayer,
FreeTextAnnotationElement,
InkAnnotationElement,
StampAnnotationElement,
};

View File

@ -182,9 +182,13 @@ class AnnotationStorage {
const map = new Map(),
hash = new MurmurHash3_64(),
transfers = [];
const context = Object.create(null);
for (const [key, val] of this.#storage) {
const serialized =
val instanceof AnnotationEditor ? val.serialize() : val;
val instanceof AnnotationEditor
? val.serialize(/* isForCopying = */ false, context)
: val;
if (serialized) {
map.set(key, serialized);

View File

@ -28,6 +28,7 @@ import { bindEvents } from "./tools.js";
import { FreeTextEditor } from "./freetext.js";
import { InkEditor } from "./ink.js";
import { setLayerDimensions } from "../display_utils.js";
import { StampEditor } from "./stamp.js";
/**
* @typedef {Object} AnnotationEditorLayerOptions
@ -39,6 +40,7 @@ import { setLayerDimensions } from "../display_utils.js";
* @property {number} pageIndex
* @property {IL10n} l10n
* @property {AnnotationLayer} [annotationLayer]
* @property {PageViewport} viewport
*/
/**
@ -75,20 +77,30 @@ class AnnotationEditorLayer {
/**
* @param {AnnotationEditorLayerOptions} options
*/
constructor(options) {
constructor({
uiManager,
pageIndex,
div,
accessibilityManager,
annotationLayer,
viewport,
l10n,
}) {
const editorTypes = [FreeTextEditor, InkEditor, StampEditor];
if (!AnnotationEditorLayer._initialized) {
AnnotationEditorLayer._initialized = true;
FreeTextEditor.initialize(options.l10n);
InkEditor.initialize(options.l10n);
for (const editorType of editorTypes) {
editorType.initialize(l10n);
}
}
options.uiManager.registerEditorTypes([FreeTextEditor, InkEditor]);
uiManager.registerEditorTypes(editorTypes);
this.#uiManager = options.uiManager;
this.pageIndex = options.pageIndex;
this.div = options.div;
this.#accessibilityManager = options.accessibilityManager;
this.#annotationLayer = options.annotationLayer;
this.viewport = options.viewport;
this.#uiManager = uiManager;
this.pageIndex = pageIndex;
this.div = div;
this.#accessibilityManager = accessibilityManager;
this.#annotationLayer = annotationLayer;
this.viewport = viewport;
this.#uiManager.addLayer(this);
}
@ -129,6 +141,10 @@ class AnnotationEditorLayer {
"inkEditing",
mode === AnnotationEditorType.INK
);
this.div.classList.toggle(
"stampEditing",
mode === AnnotationEditorType.STAMP
);
this.div.hidden = false;
}
}
@ -390,6 +406,21 @@ class AnnotationEditorLayer {
}
}
/**
* Add a new editor and make this addition undoable.
* @param {AnnotationEditor} editor
*/
addUndoableEditor(editor) {
const cmd = () => {
this.addOrRebuild(editor);
};
const undo = () => {
editor.remove();
};
this.addCommands({ cmd, undo, mustExec: false });
}
/**
* Get an id for an editor.
* @returns {string}
@ -409,6 +440,8 @@ class AnnotationEditorLayer {
return new FreeTextEditor(params);
case AnnotationEditorType.INK:
return new InkEditor(params);
case AnnotationEditorType.STAMP:
return new StampEditor(params);
}
return null;
}
@ -424,6 +457,8 @@ class AnnotationEditorLayer {
return FreeTextEditor.deserialize(data, this, this.#uiManager);
case AnnotationEditorType.INK:
return InkEditor.deserialize(data, this, this.#uiManager);
case AnnotationEditorType.STAMP:
return StampEditor.deserialize(data, this, this.#uiManager);
}
return null;
}

View File

@ -114,6 +114,35 @@ class AnnotationEditor {
fakeEditor._uiManager.addToAnnotationStorage(fakeEditor);
}
/**
* Initialize the l10n stuff for this type of editor.
* @param {Object} _l10n
*/
static initialize(_l10n) {}
/**
* Update the default parameters for this type of editor.
* @param {number} _type
* @param {*} _value
*/
static updateDefaultParams(_type, _value) {}
/**
* Get the default properties to set in the UI for this type of editor.
* @returns {Array}
*/
static get defaultPropertiesToUpdate() {
return [];
}
/**
* Get the properties to update in the UI for this editor.
* @returns {Array}
*/
get propertiesToUpdate() {
return [];
}
/**
* Add some commands into the CommandManager (undo/redo stuff).
* @param {Object} params
@ -503,8 +532,9 @@ class AnnotationEditor {
*
* To implement in subclasses.
* @param {boolean} isForCopying
* @param {Object} [context]
*/
serialize(_isForCopying = false) {
serialize(_isForCopying = false, _context = null) {
unreachable("An editor must be serializable");
}
@ -587,14 +617,6 @@ class AnnotationEditor {
*/
enableEditing() {}
/**
* Get some properties to update in the UI.
* @returns {Object}
*/
get propertiesToUpdate() {
return {};
}
/**
* Get the div which really contains the displayed content.
*/

View File

@ -92,6 +92,7 @@ class FreeTextEditor extends AnnotationEditor {
this.#fontSize = params.fontSize || FreeTextEditor._defaultFontSize;
}
/** @inheritdoc */
static initialize(l10n) {
this._l10nPromise = new Map(
["free_text2_default_content", "editor_free_text2_aria_label"].map(
@ -116,6 +117,7 @@ class FreeTextEditor extends AnnotationEditor {
);
}
/** @inheritdoc */
static updateDefaultParams(type, value) {
switch (type) {
case AnnotationEditorParamsType.FREETEXT_SIZE:
@ -139,6 +141,7 @@ class FreeTextEditor extends AnnotationEditor {
}
}
/** @inheritdoc */
static get defaultPropertiesToUpdate() {
return [
[
@ -152,6 +155,7 @@ class FreeTextEditor extends AnnotationEditor {
];
}
/** @inheritdoc */
get propertiesToUpdate() {
return [
[AnnotationEditorParamsType.FREETEXT_SIZE, this.#fontSize],

View File

@ -81,6 +81,7 @@ class InkEditor extends AnnotationEditor {
this.y = 0;
}
/** @inheritdoc */
static initialize(l10n) {
this._l10nPromise = new Map(
["editor_ink_canvas_aria_label", "editor_ink2_aria_label"].map(str => [
@ -90,6 +91,7 @@ class InkEditor extends AnnotationEditor {
);
}
/** @inheritdoc */
static updateDefaultParams(type, value) {
switch (type) {
case AnnotationEditorParamsType.INK_THICKNESS:
@ -119,6 +121,7 @@ class InkEditor extends AnnotationEditor {
}
}
/** @inheritdoc */
static get defaultPropertiesToUpdate() {
return [
[AnnotationEditorParamsType.INK_THICKNESS, InkEditor._defaultThickness],

406
src/display/editor/stamp.js Normal file
View File

@ -0,0 +1,406 @@
/* Copyright 2022 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.
*/
import { AnnotationEditor } from "./editor.js";
import { AnnotationEditorType } from "../../shared/util.js";
import { StampAnnotationElement } from "../annotation_layer.js";
/**
* Basic text editor in order to create a FreeTex annotation.
*/
class StampEditor extends AnnotationEditor {
#bitmap = null;
#bitmapId = null;
#bitmapPromise = null;
#bitmapUrl = null;
#canvas = null;
#observer = null;
#resizeTimeoutId = null;
static _type = "stamp";
constructor(params) {
super({ ...params, name: "stampEditor" });
this.#bitmapUrl = params.bitmapUrl;
}
#getBitmap() {
if (this.#bitmapId) {
this._uiManager.imageManager.getFromId(this.#bitmapId).then(data => {
if (!data) {
this.remove();
return;
}
this.#bitmap = data.bitmap;
this.#createCanvas();
});
return;
}
if (this.#bitmapUrl) {
const url = this.#bitmapUrl;
this.#bitmapUrl = null;
this.#bitmapPromise = this._uiManager.imageManager
.getFromUrl(url)
.then(data => {
this.#bitmapPromise = null;
if (!data) {
this.remove();
return;
}
({ bitmap: this.#bitmap, id: this.#bitmapId } = data);
this.#createCanvas();
});
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
this.#bitmapPromise = new Promise(resolve => {
input.addEventListener("change", async () => {
this.#bitmapPromise = null;
if (!input.files || input.files.length === 0) {
this.remove();
} else {
const data = await this._uiManager.imageManager.getFromFile(
input.files[0]
);
if (!data) {
this.remove();
return;
}
({ bitmap: this.#bitmap, id: this.#bitmapId } = data);
this.#createCanvas();
}
resolve();
});
input.addEventListener("cancel", () => {
this.#bitmapPromise = null;
this.remove();
resolve();
});
});
input.click();
}
/** @inheritdoc */
remove() {
if (this.#bitmapId) {
this.#bitmap = null;
this._uiManager.imageManager.deleteId(this.#bitmapId);
this.#canvas?.remove();
this.#canvas = null;
this.#observer?.disconnect();
this.#observer = null;
}
super.remove();
}
/** @inheritdoc */
rebuild() {
super.rebuild();
if (this.div === null) {
return;
}
if (this.#bitmapId) {
this.#getBitmap();
}
if (!this.isAttachedToDOM) {
// At some point this editor was removed and we're rebuilting it,
// hence we must add it to its parent.
this.parent.add(this);
}
}
/** @inheritdoc */
onceAdded() {
this.div.draggable = true;
this.parent.addUndoableEditor(this);
this.div.focus();
}
/** @inheritdoc */
isEmpty() {
return this.#bitmapPromise === null && this.#bitmap === null;
}
/** @inheritdoc */
render() {
if (this.div) {
return this.div;
}
let baseX, baseY;
if (this.width) {
baseX = this.x;
baseY = this.y;
}
super.render();
if (this.#bitmap) {
this.#createCanvas();
} else {
this.div.classList.add("loading");
this.#getBitmap();
}
if (this.width) {
// This editor was created in using copy (ctrl+c).
const [parentWidth, parentHeight] = this.parentDimensions;
this.setAspectRatio(this.width * parentWidth, this.height * parentHeight);
this.setAt(
baseX * parentWidth,
baseY * parentHeight,
this.width * parentWidth,
this.height * parentHeight
);
}
return this.div;
}
#createCanvas() {
const { div } = this;
let { width, height } = this.#bitmap;
const [pageWidth, pageHeight] = this.pageDimensions;
const MAX_RATIO = 0.75;
if (this.width) {
width = this.width * pageWidth;
height = this.height * pageHeight;
} else if (
width > MAX_RATIO * pageWidth ||
height > MAX_RATIO * pageHeight
) {
// If the the image is too big compared to the page dimensions
// (more than MAX_RATIO) then we scale it down.
const factor = Math.min(
(MAX_RATIO * pageWidth) / width,
(MAX_RATIO * pageHeight) / height
);
width *= factor;
height *= factor;
}
const [parentWidth, parentHeight] = this.parentDimensions;
this.setDims(
(width * parentWidth) / pageWidth,
(height * parentHeight) / pageHeight
);
this.setAspectRatio(width, height);
const canvas = (this.#canvas = document.createElement("canvas"));
div.append(canvas);
this.#drawBitmap(width, height);
this.#createObserver();
div.classList.remove("loading");
}
/**
* When the dimensions of the div change the inner canvas must
* renew its dimensions, hence it must redraw its own contents.
* @param {number} width - the new width of the div
* @param {number} height - the new height of the div
* @returns
*/
#setDimensions(width, height) {
const [parentWidth, parentHeight] = this.parentDimensions;
if (
Math.abs(width - this.width * parentWidth) < 1 &&
Math.abs(height - this.height * parentHeight) < 1
) {
return;
}
this.width = width / parentWidth;
this.height = height / parentHeight;
this.setDims(width, height);
if (this.#resizeTimeoutId !== null) {
clearTimeout(this.#resizeTimeoutId);
}
// When the user is resizing the editor we just use CSS to scale the image
// to avoid redrawing it too often.
// And once the user stops resizing the editor we redraw the image in
// rescaling it correctly (see this.#scaleBitmap).
const TIME_TO_WAIT = 200;
this.#resizeTimeoutId = setTimeout(() => {
this.#resizeTimeoutId = null;
this.#drawBitmap(width, height);
}, TIME_TO_WAIT);
}
#scaleBitmap(width, height) {
const { width: bitmapWidth, height: bitmapHeight } = this.#bitmap;
let newWidth = bitmapWidth;
let newHeight = bitmapHeight;
let bitmap = this.#bitmap;
while (newWidth > 2 * width || newHeight > 2 * height) {
const prevWidth = newWidth;
const prevHeight = newHeight;
if (newWidth > 2 * width) {
// See bug 1820511 (Windows specific bug).
// TODO: once the above bug is fixed we could revert to:
// newWidth = Math.ceil(newWidth / 2);
newWidth =
newWidth >= 16384
? Math.floor(newWidth / 2) - 1
: Math.ceil(newWidth / 2);
}
if (newHeight > 2 * height) {
newHeight =
newHeight >= 16384
? Math.floor(newHeight / 2) - 1
: Math.ceil(newHeight / 2);
}
const offscreen = new OffscreenCanvas(newWidth, newHeight);
const ctx = offscreen.getContext("2d");
ctx.drawImage(
bitmap,
0,
0,
prevWidth,
prevHeight,
0,
0,
newWidth,
newHeight
);
bitmap = offscreen.transferToImageBitmap();
}
return bitmap;
}
#drawBitmap(width, height) {
const canvas = this.#canvas;
if (!canvas || (canvas.width === width && canvas.height === height)) {
return;
}
canvas.width = width;
canvas.height = height;
const bitmap = this.#scaleBitmap(width, height);
const ctx = canvas.getContext("2d");
ctx.filter = this._uiManager.hcmFilter;
ctx.drawImage(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
0,
0,
width,
height
);
}
#serializeBitmap(toUrl) {
if (toUrl) {
// We convert to a data url because it's sync and the url can live in the
// clipboard.
const canvas = document.createElement("canvas");
({ width: canvas.width, height: canvas.height } = this.#bitmap);
const ctx = canvas.getContext("2d");
ctx.drawImage(this.#bitmap, 0, 0);
return canvas.toDataURL();
}
return structuredClone(this.#bitmap);
}
/**
* Create the resize observer.
*/
#createObserver() {
this.#observer = new ResizeObserver(entries => {
const rect = entries[0].contentRect;
if (rect.width && rect.height) {
this.#setDimensions(rect.width, rect.height);
}
});
this.#observer.observe(this.div);
}
/** @inheritdoc */
static deserialize(data, parent, uiManager) {
if (data instanceof StampAnnotationElement) {
return null;
}
const editor = super.deserialize(data, parent, uiManager);
const { rect, bitmapUrl, bitmapId } = data;
if (bitmapId && uiManager.imageManager.isValidId(bitmapId)) {
editor.#bitmapId = bitmapId;
} else {
editor.#bitmapUrl = bitmapUrl;
}
const [parentWidth, parentHeight] = editor.pageDimensions;
editor.width = (rect[2] - rect[0]) / parentWidth;
editor.height = (rect[3] - rect[1]) / parentHeight;
return editor;
}
/** @inheritdoc */
serialize(isForCopying = false, context = null) {
if (this.isEmpty()) {
return null;
}
const serialized = {
annotationType: AnnotationEditorType.STAMP,
bitmapId: this.#bitmapId,
pageIndex: this.pageIndex,
rect: this.getRect(0, 0),
rotation: this.rotation,
};
if (isForCopying) {
// We don't know what's the final destination (this pdf or another one)
// 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);
return serialized;
}
if (context === null) {
return serialized;
}
context.stamps ||= new Set();
if (!context.stamps.has(this.#bitmapId)) {
// We don't want to have multiple copies of the same bitmap in the
// annotationMap, hence we only add the bitmap the first time we meet it.
context.stamps.add(this.#bitmapId);
serialized.bitmap = this.#serializeBitmap(/* toUrl = */ false);
}
return serialized;
}
}
export { StampEditor };

View File

@ -21,6 +21,7 @@ import {
AnnotationEditorPrefix,
AnnotationEditorType,
FeatureTest,
getUuid,
shadow,
Util,
warn,
@ -59,6 +60,113 @@ class IdManager {
}
}
/**
* Class to manage the images used by the editors.
* The main idea is to try to minimize the memory used by the images.
* The images are cached and reused when possible
* We use a refCounter to know when an image is not used anymore but we need to
* be able to restore an image after a remove+undo, so we keep a file reference
* or an url one.
*/
class ImageManager {
#baseId = getUuid();
#id = 0;
#cache = null;
async #get(key, rawData) {
this.#cache ||= new Map();
let data = this.#cache.get(key);
if (data === null) {
// We already tried to load the image but it failed.
return null;
}
if (data?.bitmap) {
data.refCounter += 1;
return data;
}
try {
data ||= {
bitmap: null,
id: `image_${this.#baseId}_${this.#id++}`,
refCounter: 0,
};
let image;
if (typeof rawData === "string") {
data.url = rawData;
const response = await fetch(rawData);
if (!response.ok) {
throw new Error(response.statusText);
}
image = await response.blob();
} else {
data.file = rawData;
image = rawData;
}
data.bitmap = await createImageBitmap(image);
data.refCounter = 1;
} catch (e) {
console.error(e);
data = null;
}
this.#cache.set(key, data);
if (data) {
this.#cache.set(data.id, data);
}
return data;
}
async getFromFile(file) {
const { lastModified, name, size, type } = file;
return this.#get(`${lastModified}_${name}_${size}_${type}`, file);
}
async getFromUrl(url) {
return this.#get(url, url);
}
async getFromId(id) {
this.#cache ||= new Map();
const data = this.#cache.get(id);
if (!data) {
return null;
}
if (data.bitmap) {
data.refCounter += 1;
return data;
}
if (data.file) {
return this.getFromFile(data.file);
}
return this.getFromUrl(data.url);
}
deleteId(id) {
this.#cache ||= new Map();
const data = this.#cache.get(id);
if (!data) {
return;
}
data.refCounter -= 1;
if (data.refCounter !== 0) {
return;
}
data.bitmap = null;
}
// We can use the id only if it belongs this manager.
// We must take care of having the right manager because we can copy/paste
// some images from other documents, hence it'd be a pity to use an id from an
// other manager.
isValidId(id) {
return id.startsWith(`image_${this.#baseId}_`);
}
}
/**
* Class to handle undo/redo.
* Commands are just saved in a buffer.
@ -370,6 +478,8 @@ class AnnotationEditorUIManager {
#eventBus = null;
#filterFactory = null;
#idManager = new IdManager();
#isEnabled = false;
@ -378,6 +488,8 @@ class AnnotationEditorUIManager {
#selectedEditors = new Set();
#pageColors = null;
#boundCopy = this.copy.bind(this);
#boundCut = this.cut.bind(this);
@ -441,14 +553,16 @@ class AnnotationEditorUIManager {
);
}
constructor(container, eventBus, annotationStorage) {
constructor(container, eventBus, pdfDocument, pageColors) {
this.#container = container;
this.#eventBus = eventBus;
this.#eventBus._on("editingaction", this.#boundOnEditingAction);
this.#eventBus._on("pagechanging", this.#boundOnPageChanging);
this.#eventBus._on("scalechanging", this.#boundOnScaleChanging);
this.#eventBus._on("rotationchanging", this.#boundOnRotationChanging);
this.#annotationStorage = annotationStorage;
this.#annotationStorage = pdfDocument.annotationStorage;
this.#filterFactory = pdfDocument.filterFactory;
this.#pageColors = pageColors;
this.viewParameters = {
realScale: PixelsPerInch.PDF_TO_CSS_UNITS,
rotation: 0,
@ -472,6 +586,19 @@ class AnnotationEditorUIManager {
this.#commandManager.destroy();
}
get hcmFilter() {
return shadow(
this,
"hcmFilter",
this.#pageColors
? this.#filterFactory.addHCMFilter(
this.#pageColors.foreground,
this.#pageColors.background
)
: "none"
);
}
onPageChanging({ pageNumber }) {
this.#currentPageIndex = pageNumber - 1;
}
@ -1145,6 +1272,10 @@ class AnnotationEditorUIManager {
getMode() {
return this.#mode;
}
get imageManager() {
return shadow(this, "imageManager", new ImageManager());
}
}
export {

View File

@ -1014,6 +1014,27 @@ function normalizeUnicode(str) {
});
}
function getUuid() {
if (
(typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) ||
(typeof crypto !== "undefined" && typeof crypto?.randomUUID === "function")
) {
return crypto.randomUUID();
}
const buf = new Uint8Array(32);
if (
typeof crypto !== "undefined" &&
typeof crypto?.getRandomValues === "function"
) {
crypto.getRandomValues(buf);
} else {
for (let i = 0; i < 32; i++) {
buf[i] = Math.floor(Math.random() * 255);
}
}
return bytesToString(buf);
}
export {
AbortException,
AnnotationActionEventType,
@ -1037,6 +1058,7 @@ export {
FONT_IDENTITY_MATRIX,
FormatError,
getModificationDate,
getUuid,
getVerbosityLevel,
IDENTITY_MATRIX,
ImageKind,

View File

@ -71,7 +71,8 @@
cursor: var(--editorInk-editing-cursor);
}
.annotationEditorLayer :is(.freeTextEditor, .inkEditor)[draggable="true"] {
.annotationEditorLayer
:is(.freeTextEditor, .inkEditor, .stampEditor)[draggable="true"] {
cursor: move;
}
@ -80,18 +81,25 @@
resize: none;
}
.annotationEditorLayer .freeTextEditor {
.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor) {
position: absolute;
background: transparent;
border-radius: 3px;
z-index: 1;
transform-origin: 0 0;
cursor: auto;
}
.annotationEditorLayer :is(.inkEditor, .stampEditor) {
overflow: auto;
}
.annotationEditorLayer .freeTextEditor {
padding: calc(var(--freetext-padding) * var(--scale-factor));
resize: none;
width: auto;
height: auto;
z-index: 1;
transform-origin: 0 0;
touch-action: none;
cursor: auto;
}
.annotationEditorLayer .freeTextEditor .internal {
@ -138,20 +146,13 @@
}
.annotationEditorLayer
:is(.freeTextEditor, .inkEditor):hover:not(.selectedEditor) {
:is(.freeTextEditor, .inkEditor, .stampEditor):hover:not(.selectedEditor) {
outline: var(--hover-outline);
}
.annotationEditorLayer .inkEditor {
position: absolute;
background: transparent;
border-radius: 3px;
overflow: auto;
width: 100%;
height: 100%;
z-index: 1;
transform-origin: 0 0;
cursor: auto;
}
.annotationEditorLayer .inkEditor.editing {
@ -167,3 +168,30 @@
height: 100%;
touch-action: none;
}
.annotationEditorLayer .stampEditor {
width: auto;
height: auto;
}
.annotationEditorLayer .stampEditor.loading {
aspect-ratio: 1;
width: 10%;
height: auto;
background-color: rgba(128, 128, 128, 0.5);
background-image: var(--loading-icon);
background-repeat: no-repeat;
background-position: 50%;
background-size: 16px 16px;
transition-property: background-size;
transition-delay: var(--loading-icon-delay);
}
.annotationEditorLayer .stampEditor.selectedEditor {
resize: horizontal;
}
.annotationEditorLayer .stampEditor canvas {
width: 100%;
height: 100%;
}

View File

@ -560,6 +560,16 @@ const PDFViewerApplication = {
if (appConfig.annotationEditorParams) {
if (annotationEditorMode !== AnnotationEditorType.DISABLE) {
const editorStampButton = appConfig.toolbar?.editorStampButton;
if (
editorStampButton &&
AppOptions.get("enableStampEditor") &&
AppOptions.get("isOffscreenCanvasSupported") &&
FeatureTest.isOffscreenCanvasSupported
) {
editorStampButton.hidden = false;
}
this.annotationEditorParams = new AnnotationEditorParams(
appConfig.annotationEditorParams,
eventBus

View File

@ -103,6 +103,14 @@ const defaultOptions = {
value: typeof PDFJSDev === "undefined" || !PDFJSDev.test("CHROME"),
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
enableStampEditor: {
// We'll probably want to make some experiments before enabling this
// in Firefox release, but it has to be temporary.
// TODO: remove it when unnecessary.
/** @type {boolean} */
value: true,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
externalLinkRel: {
/** @type {string} */
value: "noopener noreferrer nofollow",

View File

@ -0,0 +1,8 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
<path d="M3 1a2 2 0 0 0-2 2l0 10a2 2 0 0 0 2 2l10 0a2 2 0 0 0 2-2l0-10a2 2 0 0 0-2-2L3 1zm10.75 12.15-.6.6-10.3 0-.6-.6 0-10.3.6-.6 10.3 0 .6.6 0 10.3z"/>
<path d="m11 12-6 0a1 1 0 0 1-1-1l0-1.321a.75.75 0 0 1 .218-.529L6.35 7.005a.75.75 0 0 1 1.061-.003l2.112 2.102.612-.577a.75.75 0 0 1 1.047.017l.6.605a.75.75 0 0 1 .218.529L12 11a1 1 0 0 1-1 1z"/>
<path d="m11.6 5-1.2 0-.4.4 0 1.2.4.4 1.2 0 .4-.4 0-1.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 777 B

View File

@ -846,7 +846,8 @@ class PDFViewer {
this.#annotationEditorUIManager = new AnnotationEditorUIManager(
this.container,
this.eventBus,
pdfDocument?.annotationStorage
pdfDocument,
this.pageColors
);
if (mode !== AnnotationEditorType.NONE) {
this.#annotationEditorUIManager.updateMode(mode);

View File

@ -90,6 +90,18 @@ class Toolbar {
},
},
},
{
element: options.editorStampButton,
eventName: "switchannotationeditormode",
eventDetails: {
get mode() {
const { classList } = options.editorStampButton;
return classList.contains("toggled")
? AnnotationEditorType.NONE
: AnnotationEditorType.STAMP;
},
},
},
];
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
this.buttons.push({ element: options.openFile, eventName: "openfile" });
@ -205,6 +217,7 @@ class Toolbar {
editorFreeTextParamsToolbar,
editorInkButton,
editorInkParamsToolbar,
editorStampButton,
}) {
const editorModeChanged = ({ mode }) => {
toggleCheckedBtn(
@ -217,10 +230,12 @@ class Toolbar {
mode === AnnotationEditorType.INK,
editorInkParamsToolbar
);
toggleCheckedBtn(editorStampButton, mode === AnnotationEditorType.STAMP);
const isDisable = mode === AnnotationEditorType.DISABLE;
editorFreeTextButton.disabled = isDisable;
editorInkButton.disabled = isDisable;
editorStampButton.disabled = isDisable;
};
this.eventBus._on("annotationeditormodechanged", editorModeChanged);

View File

@ -80,6 +80,7 @@
--treeitem-collapsed-icon: url(images/treeitem-collapsed.svg);
--toolbarButton-editorFreeText-icon: url(images/toolbarButton-editorFreeText.svg);
--toolbarButton-editorInk-icon: url(images/toolbarButton-editorInk.svg);
--toolbarButton-editorStamp-icon: url(images/toolbarButton-editorStamp.svg);
--toolbarButton-menuArrow-icon: url(images/toolbarButton-menuArrow.svg);
--toolbarButton-sidebarToggle-icon: url(images/toolbarButton-sidebarToggle.svg);
--toolbarButton-secondaryToolbarToggle-icon: url(images/toolbarButton-secondaryToolbarToggle.svg);
@ -896,6 +897,10 @@ body {
mask-image: var(--toolbarButton-editorInk-icon);
}
#editorStamp::before {
mask-image: var(--toolbarButton-editorStamp-icon);
}
#print::before,
#secondaryPrint::before {
mask-image: var(--toolbarButton-print-icon);

View File

@ -330,10 +330,13 @@ See https://github.com/adobe-type-tools/cmap-resources
<div class="verticalToolbarSeparator hiddenMediumView"></div>
<div id="editorModeButtons" class="splitToolbarButton toggled" role="radiogroup">
<button id="editorFreeText" class="toolbarButton" disabled="disabled" title="Text" role="radio" aria-checked="false" aria-controls="editorFreeTextParamsToolbar" tabindex="34" data-l10n-id="editor_free_text2">
<button id="editorStamp" class="toolbarButton" hidden="true" disabled="disabled" title="Image" role="radio" aria-checked="false" tabindex="34" data-l10n-id="editor_stamp">
<span data-l10n-id="editor_stamp_label">Image</span>
</button>
<button id="editorFreeText" class="toolbarButton" disabled="disabled" title="Text" role="radio" aria-checked="false" aria-controls="editorFreeTextParamsToolbar" tabindex="35" data-l10n-id="editor_free_text2">
<span data-l10n-id="editor_free_text2_label">Text</span>
</button>
<button id="editorInk" class="toolbarButton" disabled="disabled" title="Draw" role="radio" aria-checked="false" aria-controls="editorInkParamsToolbar" tabindex="35" data-l10n-id="editor_ink2">
<button id="editorInk" class="toolbarButton" disabled="disabled" title="Draw" role="radio" aria-checked="false" aria-controls="editorInkParamsToolbar" tabindex="36" data-l10n-id="editor_ink2">
<span data-l10n-id="editor_ink2_label">Draw</span>
</button>
</div>

View File

@ -63,6 +63,7 @@ function getViewerConfiguration() {
),
editorInkButton: document.getElementById("editorInk"),
editorInkParamsToolbar: document.getElementById("editorInkParamsToolbar"),
editorStampButton: document.getElementById("editorStamp"),
download: document.getElementById("download"),
},
secondaryToolbar: {