Calixte Denizet 17e1519410 [Editor] Init the default highlight color before creating the first editor instance
We want to be able to draw an highlight with the default color but without having an
instance of the HighlightEditor.
2024-01-05 17:52:54 +01:00

598 lines
16 KiB
JavaScript

/* 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 { AnnotationEditorType, shadow } from "../../shared/util.js";
import { AnnotationEditor } from "./editor.js";
import { PixelsPerInch } from "../display_utils.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;
#bitmapFile = null;
#bitmapFileName = "";
#canvas = null;
#observer = null;
#resizeTimeoutId = null;
#isSvg = false;
#hasBeenAddedInUndoStack = false;
static _type = "stamp";
static _editorType = AnnotationEditorType.STAMP;
constructor(params) {
super({ ...params, name: "stampEditor" });
this.#bitmapUrl = params.bitmapUrl;
this.#bitmapFile = params.bitmapFile;
}
/** @inheritdoc */
static initialize(l10n, uiManager) {
AnnotationEditor.initialize(l10n, uiManager);
}
static get supportedTypes() {
// See https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
// to know which types are supported by the browser.
const types = [
"apng",
"avif",
"bmp",
"gif",
"jpeg",
"png",
"svg+xml",
"webp",
"x-icon",
];
return shadow(
this,
"supportedTypes",
types.map(type => `image/${type}`)
);
}
static get supportedTypesStr() {
return shadow(this, "supportedTypesStr", this.supportedTypes.join(","));
}
/** @inheritdoc */
static isHandlingMimeForPasting(mime) {
return this.supportedTypes.includes(mime);
}
/** @inheritdoc */
static paste(item, parent) {
parent.pasteEditor(AnnotationEditorType.STAMP, {
bitmapFile: item.getAsFile(),
});
}
#getBitmapFetched(data, fromId = false) {
if (!data) {
this.remove();
return;
}
this.#bitmap = data.bitmap;
if (!fromId) {
this.#bitmapId = data.id;
this.#isSvg = data.isSvg;
}
if (data.file) {
this.#bitmapFileName = data.file.name;
}
this.#createCanvas();
}
#getBitmapDone() {
this.#bitmapPromise = null;
this._uiManager.enableWaiting(false);
if (this.#canvas) {
this.div.focus();
}
}
#getBitmap() {
if (this.#bitmapId) {
this._uiManager.enableWaiting(true);
this._uiManager.imageManager
.getFromId(this.#bitmapId)
.then(data => this.#getBitmapFetched(data, /* fromId = */ true))
.finally(() => this.#getBitmapDone());
return;
}
if (this.#bitmapUrl) {
const url = this.#bitmapUrl;
this.#bitmapUrl = null;
this._uiManager.enableWaiting(true);
this.#bitmapPromise = this._uiManager.imageManager
.getFromUrl(url)
.then(data => this.#getBitmapFetched(data))
.finally(() => this.#getBitmapDone());
return;
}
if (this.#bitmapFile) {
const file = this.#bitmapFile;
this.#bitmapFile = null;
this._uiManager.enableWaiting(true);
this.#bitmapPromise = this._uiManager.imageManager
.getFromFile(file)
.then(data => this.#getBitmapFetched(data))
.finally(() => this.#getBitmapDone());
return;
}
const input = document.createElement("input");
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
input.hidden = true;
input.id = "stampEditorFileInput";
document.body.append(input);
}
input.type = "file";
input.accept = StampEditor.supportedTypesStr;
this.#bitmapPromise = new Promise(resolve => {
input.addEventListener("change", async () => {
if (!input.files || input.files.length === 0) {
this.remove();
} else {
this._uiManager.enableWaiting(true);
const data = await this._uiManager.imageManager.getFromFile(
input.files[0]
);
this.#getBitmapFetched(data);
}
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
input.remove();
}
resolve();
});
input.addEventListener("cancel", () => {
this.remove();
resolve();
});
}).finally(() => this.#getBitmapDone());
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("TESTING")) {
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;
if (this.#resizeTimeoutId) {
clearTimeout(this.#resizeTimeoutId);
this.#resizeTimeoutId = null;
}
}
super.remove();
}
/** @inheritdoc */
rebuild() {
if (!this.parent) {
// It's possible to have to rebuild an editor which is not on a visible
// page.
if (this.#bitmapId) {
this.#getBitmap();
}
return;
}
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._isDraggable = true;
this.div.focus();
}
/** @inheritdoc */
isEmpty() {
return !(
this.#bitmapPromise ||
this.#bitmap ||
this.#bitmapUrl ||
this.#bitmapFile
);
}
/** @inheritdoc */
get isResizable() {
return true;
}
/** @inheritdoc */
render() {
if (this.div) {
return this.div;
}
let baseX, baseY;
if (this.width) {
baseX = this.x;
baseY = this.y;
}
super.render();
this.div.hidden = true;
if (this.#bitmap) {
this.#createCanvas();
} else {
this.#getBitmap();
}
if (this.width) {
// This editor was created in using copy (ctrl+c).
const [parentWidth, parentHeight] = this.parentDimensions;
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._uiManager.enableWaiting(false);
const canvas = (this.#canvas = document.createElement("canvas"));
div.append(canvas);
div.hidden = false;
this.#drawBitmap(width, height);
this.#createObserver();
if (!this.#hasBeenAddedInUndoStack) {
this.parent.addUndoableEditor(this);
this.#hasBeenAddedInUndoStack = true;
}
// There are multiple ways to add an image to the page, so here we just
// count the number of times an image is added to the page whatever the way
// is.
this._uiManager._eventBus.dispatch("reporttelemetry", {
source: this,
details: {
type: "editing",
subtype: this.editorType,
data: {
action: "inserted_image",
},
},
});
this.addAltTextButton();
if (this.#bitmapFileName) {
canvas.setAttribute("aria-label", this.#bitmapFileName);
}
}
/**
* 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;
this.width = width / parentWidth;
this.height = height / parentHeight;
this.setDims(width, height);
if (this._initialOptions?.isCentered) {
this.center();
} else {
this.fixAndSetPosition();
}
this._initialOptions = null;
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) {
width = Math.ceil(width);
height = Math.ceil(height);
const canvas = this.#canvas;
if (!canvas || (canvas.width === width && canvas.height === height)) {
return;
}
canvas.width = width;
canvas.height = height;
const bitmap = this.#isSvg
? this.#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
);
}
/** @inheritdoc */
getImageForAltText() {
return this.#canvas;
}
#serializeBitmap(toUrl) {
if (toUrl) {
if (this.#isSvg) {
const url = this._uiManager.imageManager.getSvgUrl(this.#bitmapId);
if (url) {
return url;
}
}
// 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();
}
if (this.#isSvg) {
const [pageWidth, pageHeight] = this.pageDimensions;
// Multiply by PixelsPerInch.PDF_TO_CSS_UNITS in order to increase the
// image resolution when rasterizing it.
const width = Math.round(
this.width * pageWidth * PixelsPerInch.PDF_TO_CSS_UNITS
);
const height = Math.round(
this.height * pageHeight * PixelsPerInch.PDF_TO_CSS_UNITS
);
const offscreen = new OffscreenCanvas(width, height);
const ctx = offscreen.getContext("2d");
ctx.drawImage(
this.#bitmap,
0,
0,
this.#bitmap.width,
this.#bitmap.height,
0,
0,
width,
height
);
return offscreen.transferToImageBitmap();
}
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, isSvg, accessibilityData } = data;
if (bitmapId && uiManager.imageManager.isValidId(bitmapId)) {
editor.#bitmapId = bitmapId;
} else {
editor.#bitmapUrl = bitmapUrl;
}
editor.#isSvg = isSvg;
const [parentWidth, parentHeight] = editor.pageDimensions;
editor.width = (rect[2] - rect[0]) / parentWidth;
editor.height = (rect[3] - rect[1]) / parentHeight;
if (accessibilityData) {
editor.altTextData = accessibilityData;
}
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,
isSvg: this.#isSvg,
structTreeParentId: this._structTreeParentId,
};
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);
serialized.accessibilityData = this.altTextData;
return serialized;
}
const { decorative, altText } = this.altTextData;
if (!decorative && altText) {
serialized.accessibilityData = { type: "Figure", alt: altText };
}
if (context === null) {
return serialized;
}
context.stamps ||= new Map();
const area = this.#isSvg
? (serialized.rect[2] - serialized.rect[0]) *
(serialized.rect[3] - serialized.rect[1])
: null;
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.set(this.#bitmapId, { area, serialized });
serialized.bitmap = this.#serializeBitmap(/* toUrl = */ false);
} else if (this.#isSvg) {
// If we have multiple copies of the same svg but with different sizes,
// then we want to keep the biggest one.
const prevData = context.stamps.get(this.#bitmapId);
if (area > prevData.area) {
prevData.area = area;
prevData.serialized.bitmap.close();
prevData.serialized.bitmap = this.#serializeBitmap(/* toUrl = */ false);
}
}
return serialized;
}
}
export { StampEditor };