diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index 88411cbc0..dabf039e5 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -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).", diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 2495d7a0d..f9724575c 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -3011,4 +3011,9 @@ class AnnotationLayer { } } -export { AnnotationLayer, FreeTextAnnotationElement, InkAnnotationElement }; +export { + AnnotationLayer, + FreeTextAnnotationElement, + InkAnnotationElement, + StampAnnotationElement, +}; diff --git a/src/display/annotation_storage.js b/src/display/annotation_storage.js index 559638478..0d1b4a7bf 100644 --- a/src/display/annotation_storage.js +++ b/src/display/annotation_storage.js @@ -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); diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 623465ab5..c6fd7f2b6 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -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; } diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index c462458af..c0d4be075 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -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. */ diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index ca3c24b7b..eee66d606 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -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], diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index 38904a74e..2f1bd8730 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -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], diff --git a/src/display/editor/stamp.js b/src/display/editor/stamp.js new file mode 100644 index 000000000..907becbe4 --- /dev/null +++ b/src/display/editor/stamp.js @@ -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 }; diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 3bab75ca4..fe4a69fbb 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -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 { diff --git a/src/shared/util.js b/src/shared/util.js index 6935556d8..70184dd8d 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -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, diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index c82babf71..08c5252b9 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -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%; +} diff --git a/web/app.js b/web/app.js index 9b2b9a7c5..8d233e234 100644 --- a/web/app.js +++ b/web/app.js @@ -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 diff --git a/web/app_options.js b/web/app_options.js index a5755fa91..5814e89f2 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -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", diff --git a/web/images/toolbarButton-editorStamp.svg b/web/images/toolbarButton-editorStamp.svg new file mode 100644 index 000000000..4a3e99094 --- /dev/null +++ b/web/images/toolbarButton-editorStamp.svg @@ -0,0 +1,8 @@ + + diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index d417833ee..f8a709058 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -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); diff --git a/web/toolbar.js b/web/toolbar.js index 2a7adf326..826f59733 100644 --- a/web/toolbar.js +++ b/web/toolbar.js @@ -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); diff --git a/web/viewer.css b/web/viewer.css index 0ecb7a150..fd5ec4cbf 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -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); diff --git a/web/viewer.html b/web/viewer.html index 38ea691a8..7bb89b73a 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -330,10 +330,13 @@ See https://github.com/adobe-type-tools/cmap-resources
diff --git a/web/viewer.js b/web/viewer.js index a96653f1f..701d64fcd 100644 --- a/web/viewer.js +++ b/web/viewer.js @@ -63,6 +63,7 @@ function getViewerConfiguration() { ), editorInkButton: document.getElementById("editorInk"), editorInkParamsToolbar: document.getElementById("editorInkParamsToolbar"), + editorStampButton: document.getElementById("editorStamp"), download: document.getElementById("download"), }, secondaryToolbar: {