[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:
parent
2a837ba0b5
commit
37bd78c707
@ -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).",
|
||||
|
@ -3011,4 +3011,9 @@ class AnnotationLayer {
|
||||
}
|
||||
}
|
||||
|
||||
export { AnnotationLayer, FreeTextAnnotationElement, InkAnnotationElement };
|
||||
export {
|
||||
AnnotationLayer,
|
||||
FreeTextAnnotationElement,
|
||||
InkAnnotationElement,
|
||||
StampAnnotationElement,
|
||||
};
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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],
|
||||
|
@ -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
406
src/display/editor/stamp.js
Normal 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 };
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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%;
|
||||
}
|
||||
|
10
web/app.js
10
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
|
||||
|
@ -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",
|
||||
|
8
web/images/toolbarButton-editorStamp.svg
Normal file
8
web/images/toolbarButton-editorStamp.svg
Normal 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 |
@ -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);
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -63,6 +63,7 @@ function getViewerConfiguration() {
|
||||
),
|
||||
editorInkButton: document.getElementById("editorInk"),
|
||||
editorInkParamsToolbar: document.getElementById("editorInkParamsToolbar"),
|
||||
editorStampButton: document.getElementById("editorStamp"),
|
||||
download: document.getElementById("download"),
|
||||
},
|
||||
secondaryToolbar: {
|
||||
|
Loading…
Reference in New Issue
Block a user