[edition] Add a FreeText editor ()

- add a basic UI to edit some text in a pdf;
- an editor can be moved, suppressed, cut, copied, pasted, selected;
- add an undo/redo manager.
This commit is contained in:
Calixte Denizet 2022-06-01 10:38:08 +02:00
parent 1ac33c960d
commit be1aa11986
28 changed files with 2321 additions and 18 deletions

@ -160,6 +160,10 @@
],
"default": 2
},
"annotationEditorEnabled": {
"type": "boolean",
"default": false
},
"enablePermissions": {
"type": "boolean",
"default": false

@ -249,3 +249,10 @@ password_cancel=Cancel
printing_not_supported=Warning: Printing is not fully supported by this browser.
printing_not_ready=Warning: The PDF is not fully loaded for printing.
web_fonts_disabled=Web fonts are disabled: unable to use embedded PDF fonts.
# Editor
editor_none.title=Disable Annotation Editing
editor_none_label=Disable Editing
freetext_default_content=Enter some text…
editor_free_text.title=Add FreeText Annotation
editor_free_text_label=FreeText Annotation

@ -13,6 +13,7 @@
* limitations under the License.
*/
import { AnnotationEditor } from "./editor/editor.js";
import { MurmurHash3_64 } from "../shared/murmurhash3.js";
import { objectFromMap } from "../shared/util.js";
@ -62,6 +63,14 @@ class AnnotationStorage {
return this._storage.get(key);
}
/**
* Remove a value from the storage.
* @param {string} key
*/
removeKey(key) {
this._storage.delete(key);
}
/**
* Set the value for a given key
*
@ -123,7 +132,19 @@ class AnnotationStorage {
* @ignore
*/
get serializable() {
return this._storage.size > 0 ? this._storage : null;
if (this._storage.size === 0) {
return null;
}
const clone = new Map();
for (const [key, value] of this._storage) {
if (value instanceof AnnotationEditor) {
clone.set(key, value.serialize());
} else {
clone.set(key, value);
}
}
return clone;
}
/**

@ -0,0 +1,432 @@
/* 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.
*/
/** @typedef {import("./editor.js").AnnotationEditor} AnnotationEditor */
// eslint-disable-next-line max-len
/** @typedef {import("./tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
// eslint-disable-next-line max-len
/** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */
/** @typedef {import("../../web/interfaces").IL10n} IL10n */
import { AnnotationEditorType, Util } from "../../shared/util.js";
import { bindEvents, KeyboardManager } from "./tools.js";
import { FreeTextEditor } from "./freetext.js";
import { PixelsPerInch } from "../display_utils.js";
/**
* @typedef {Object} AnnotationEditorLayerOptions
* @property {Object} mode
* @property {HTMLDivElement} div
* @property {AnnotationEditorUIManager} uiManager
* @property {boolean} enabled
* @property {AnnotationStorage} annotationStorag
* @property {number} pageIndex
* @property {IL10n} l10n
*/
/**
* Manage all the different editors on a page.
*/
class AnnotationEditorLayer {
#boundClick;
#editors = new Map();
#uiManager;
static _l10nInitialized = false;
static _keyboardManager = new KeyboardManager([
[["ctrl+a", "mac+meta+a"], AnnotationEditorLayer.prototype.selectAll],
[["ctrl+c", "mac+meta+c"], AnnotationEditorLayer.prototype.copy],
[["ctrl+v", "mac+meta+v"], AnnotationEditorLayer.prototype.paste],
[["ctrl+x", "mac+meta+x"], AnnotationEditorLayer.prototype.cut],
[["ctrl+z", "mac+meta+z"], AnnotationEditorLayer.prototype.undo],
[
["ctrl+y", "ctrl+shift+Z", "mac+meta+shift+Z"],
AnnotationEditorLayer.prototype.redo,
],
[
[
"ctrl+Backspace",
"mac+Backspace",
"mac+ctrl+Backspace",
"mac+alt+Backspace",
],
AnnotationEditorLayer.prototype.suppress,
],
]);
/**
* @param {AnnotationEditorLayerOptions} options
*/
constructor(options) {
if (!AnnotationEditorLayer._l10nInitialized) {
AnnotationEditorLayer._l10nInitialized = true;
FreeTextEditor.setL10n(options.l10n);
}
this.#uiManager = options.uiManager;
this.annotationStorage = options.annotationStorage;
this.pageIndex = options.pageIndex;
this.div = options.div;
this.#boundClick = this.click.bind(this);
for (const editor of this.#uiManager.getEditors(options.pageIndex)) {
this.add(editor);
}
this.#uiManager.addLayer(this);
}
/**
* Add some commands into the CommandManager (undo/redo stuff).
* @param {function} cmd
* @param {function} undo
*/
addCommands(cmd, undo) {
this.#uiManager.addCommands(cmd, undo);
}
/**
* Undo the last command.
*/
undo() {
this.#uiManager.undo();
}
/**
* Redo the last command.
*/
redo() {
this.#uiManager.redo();
}
/**
* Suppress the selected editor or all editors.
* @returns {undefined}
*/
suppress() {
this.#uiManager.suppress();
}
/**
* Copy the selected editor.
*/
copy() {
this.#uiManager.copy();
}
/**
* Cut the selected editor.
*/
cut() {
this.#uiManager.cut(this);
}
/**
* Paste a previously copied editor.
* @returns {undefined}
*/
paste() {
this.#uiManager.paste(this);
}
/**
* Select all the editors.
*/
selectAll() {
this.#uiManager.selectAll();
}
/**
* Unselect all the editors.
*/
unselectAll() {
this.#uiManager.unselectAll();
}
/**
* Enable pointer events on the main div in order to enable
* editor creation.
*/
enable() {
this.div.style.pointerEvents = "auto";
}
/**
* Disable editor creation.
*/
disable() {
this.div.style.pointerEvents = "none";
}
/**
* Set the current editor.
* @param {AnnotationEditor} editor
*/
setActiveEditor(editor) {
if (editor) {
this.unselectAll();
this.div.removeEventListener("click", this.#boundClick);
} else {
this.#uiManager.allowClick = false;
this.div.addEventListener("click", this.#boundClick);
}
this.#uiManager.setActiveEditor(editor);
}
attach(editor) {
this.#editors.set(editor.id, editor);
}
detach(editor) {
this.#editors.delete(editor.id);
}
/**
* Remove an editor.
* @param {AnnotationEditor} editor
*/
remove(editor) {
// Since we can undo a removal we need to keep the
// parent property as it is, so don't null it!
this.#uiManager.removeEditor(editor);
this.detach(editor);
this.annotationStorage.removeKey(editor.id);
editor.div.remove();
editor.isAttachedToDOM = false;
if (this.#uiManager.isActive(editor) || this.#editors.size === 0) {
this.setActiveEditor(null);
this.#uiManager.allowClick = true;
this.div.focus();
}
}
/**
* An editor can have a different parent, for example after having
* being dragged and droped from a page to another.
* @param {AnnotationEditor} editor
* @returns {undefined}
*/
#changeParent(editor) {
if (editor.parent === this) {
return;
}
this.attach(editor);
editor.pageIndex = this.pageIndex;
editor.parent.detach(editor);
editor.parent = this;
if (editor.div && editor.isAttachedToDOM) {
editor.div.remove();
this.div.appendChild(editor.div);
}
}
/**
* Add a new editor in the current view.
* @param {AnnotationEditor} editor
*/
add(editor) {
this.#changeParent(editor);
this.annotationStorage.setValue(editor.id, editor);
this.#uiManager.addEditor(editor);
this.attach(editor);
if (!editor.isAttachedToDOM) {
const div = editor.render();
this.div.appendChild(div);
editor.isAttachedToDOM = true;
}
editor.onceAdded();
}
/**
* Add or rebuild depending if it has been removed or not.
* @param {AnnotationEditor} editor
*/
addOrRebuild(editor) {
if (editor.needsToBeRebuilt()) {
editor.rebuild();
} else {
this.add(editor);
}
}
/**
* Add a new editor and make this addition undoable.
* @param {AnnotationEditor} editor
*/
addANewEditor(editor) {
const cmd = () => {
this.addOrRebuild(editor);
};
const undo = () => {
editor.remove();
};
this.addCommands(cmd, undo);
}
/**
* Get an id for an editor.
* @returns {string}
*/
getNextId() {
return this.#uiManager.getId();
}
/**
* Create a new editor
* @param {Object} params
* @returns {AnnotationEditor}
*/
#createNewEditor(params) {
switch (this.#uiManager.getMode()) {
case AnnotationEditorType.FREETEXT:
return new FreeTextEditor(params);
}
return null;
}
/**
* Mouseclick callback.
* @param {MouseEvent} event
* @returns {undefined}
*/
click(event) {
if (!this.#uiManager.allowClick) {
this.#uiManager.allowClick = true;
return;
}
const id = this.getNextId();
const editor = this.#createNewEditor({
parent: this,
id,
x: event.offsetX,
y: event.offsetY,
});
if (editor) {
this.addANewEditor(editor);
}
}
/**
* Drag callback.
* @param {DragEvent} event
* @returns {undefined}
*/
drop(event) {
const id = event.dataTransfer.getData("text/plain");
const editor = this.#uiManager.getEditor(id);
if (!editor) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "move";
this.#changeParent(editor);
const rect = this.div.getBoundingClientRect();
editor.setAt(
event.clientX - rect.x - editor.mouseX,
event.clientY - rect.y - editor.mouseY
);
}
/**
* Dragover callback.
* @param {DragEvent} event
*/
dragover(event) {
event.preventDefault();
}
/**
* Keydown callback.
* @param {KeyboardEvent} event
*/
keydown(event) {
if (!this.#uiManager.getActive()?.shouldGetKeyboardEvents()) {
AnnotationEditorLayer._keyboardManager.exec(this, event);
}
}
/**
* Destroy the main editor.
*/
destroy() {
for (const editor of this.#editors.values()) {
editor.isAttachedToDOM = false;
editor.div.remove();
editor.parent = null;
this.div = null;
}
this.#editors.clear();
this.#uiManager.removeLayer(this);
}
/**
* Render the main editor.
* @param {Object} parameters
*/
render(parameters) {
this.viewport = parameters.viewport;
this.inverseViewportTransform = Util.inverseTransform(
this.viewport.transform
);
bindEvents(this, this.div, ["dragover", "drop", "keydown"]);
this.div.addEventListener("click", this.#boundClick);
}
/**
* Update the main editor.
* @param {Object} parameters
*/
update(parameters) {
const transform = Util.transform(
parameters.viewport.transform,
this.inverseViewportTransform
);
this.viewport = parameters.viewport;
this.inverseViewportTransform = Util.inverseTransform(
this.viewport.transform
);
for (const editor of this.#editors.values()) {
editor.transform(transform);
}
}
/**
* Get the scale factor from the viewport.
* @returns {number}
*/
get scaleFactor() {
return this.viewport.scale;
}
/**
* Get the zoom factor.
* @returns {number}
*/
get zoomFactor() {
return this.viewport.scale / PixelsPerInch.PDF_TO_CSS_UNITS;
}
}
export { AnnotationEditorLayer };

@ -0,0 +1,305 @@
/* 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.
*/
// eslint-disable-next-line max-len
/** @typedef {import("./annotation_editor_layer.js").AnnotationEditorLayer} AnnotationEditorLayer */
import { unreachable, Util } from "../../shared/util.js";
import { bindEvents } from "./tools.js";
/**
* @typedef {Object} AnnotationEditorParameters
* @property {AnnotationEditorLayer} parent - the layer containing this editor
* @property {string} id - editor id
* @property {number} x - x-coordinate
* @property {number} y - y-coordinate
*/
/**
* Base class for editors.
*/
class AnnotationEditor {
#isInEditMode = false;
/**
* @param {AnnotationEditorParameters} parameters
*/
constructor(parameters) {
if (this.constructor === AnnotationEditor) {
unreachable("Cannot initialize AnnotationEditor.");
}
this.parent = parameters.parent;
this.id = parameters.id;
this.width = this.height = null;
this.pageIndex = parameters.parent.pageIndex;
this.name = parameters.name;
this.div = null;
this.x = Math.round(parameters.x);
this.y = Math.round(parameters.y);
this.isAttachedToDOM = false;
}
/**
* onfocus callback.
*/
focusin(/* event */) {
this.parent.setActiveEditor(this);
}
/**
* onblur callback.
* @param {FocusEvent} event
* @returns {undefined}
*/
focusout(event) {
if (!this.isAttachedToDOM) {
return;
}
// In case of focusout, the relatedTarget is the element which
// is grabbing the focus.
// So if the related target is an element under the div for this
// editor, then the editor isn't unactive.
const target = event.relatedTarget;
if (target?.closest(`#${this.id}`)) {
return;
}
event.preventDefault();
if (this.isEmpty()) {
this.remove();
} else {
this.commit();
}
this.parent.setActiveEditor(null);
}
/**
* Get the pointer coordinates in order to correctly translate the
* div in case of drag-and-drop.
* @param {MouseEvent} event
*/
mousedown(event) {
this.mouseX = event.offsetX;
this.mouseY = event.offsetY;
}
/**
* We use drag-and-drop in order to move an editor on a page.
* @param {DragEvent} event
*/
dragstart(event) {
event.dataTransfer.setData("text/plain", this.id);
event.dataTransfer.effectAllowed = "move";
}
/**
* Set the editor position within its parent.
* @param {number} x
* @param {number} y
*/
setAt(x, y) {
this.x = Math.round(x);
this.y = Math.round(y);
this.div.style.left = `${this.x}px`;
this.div.style.top = `${this.y}px`;
}
/**
* Translate the editor position within its parent.
* @param {number} x
* @param {number} y
*/
translate(x, y) {
this.setAt(this.x + x, this.y + y);
}
/**
* Set the dimensions of this editor.
* @param {number} width
* @param {number} height
*/
setDims(width, height) {
this.div.style.width = `${width}px`;
this.div.style.height = `${height}px`;
}
/**
* Render this editor in a div.
* @returns {HTMLDivElement}
*/
render() {
this.div = document.createElement("div");
this.div.className = this.name;
this.div.setAttribute("id", this.id);
this.div.draggable = true;
this.div.tabIndex = 100;
this.div.style.left = `${this.x}px`;
this.div.style.top = `${this.y}px`;
bindEvents(this, this.div, [
"dragstart",
"focusin",
"focusout",
"mousedown",
]);
return this.div;
}
/**
* Executed once this editor has been rendered.
*/
onceAdded() {}
/**
* Apply the current transform (zoom) to this editor.
* @param {Array<number>} transform
*/
transform(transform) {
const { style } = this.div;
const width = parseFloat(style.width);
const height = parseFloat(style.height);
const [x1, y1] = Util.applyTransform([this.x, this.y], transform);
if (!Number.isNaN(width)) {
const [x2] = Util.applyTransform([this.x + width, 0], transform);
this.div.style.width = `${x2 - x1}px`;
}
if (!Number.isNaN(height)) {
const [, y2] = Util.applyTransform([0, this.y + height], transform);
this.div.style.height = `${y2 - y1}px`;
}
this.setAt(x1, y1);
}
/**
* Check if the editor contains something.
* @returns {boolean}
*/
isEmpty() {
return false;
}
/**
* Enable edit mode.
* @returns {undefined}
*/
enableEditMode() {
this.#isInEditMode = true;
}
/**
* Disable edit mode.
* @returns {undefined}
*/
disableEditMode() {
this.#isInEditMode = false;
}
/**
* Check if the editor is edited.
* @returns {boolean}
*/
isInEditMode() {
return this.#isInEditMode;
}
/**
* If it returns true, then this editor handle the keyboard
* events itself.
* @returns {boolean}
*/
shouldGetKeyboardEvents() {
return false;
}
/**
* Copy the elements of an editor in order to be able to build
* a new one from these data.
* It's used on ctrl+c action.
*
* To implement in subclasses.
* @returns {AnnotationEditor}
*/
copy() {
unreachable("An editor must be copyable");
}
/**
* Check if this editor needs to be rebuilt or not.
* @returns {boolean}
*/
needsToBeRebuilt() {
return this.div && !this.isAttachedToDOM;
}
/**
* Rebuild the editor in case it has been removed on undo.
*
* To implement in subclasses.
* @returns {undefined}
*/
rebuild() {
unreachable("An editor must be rebuildable");
}
/**
* Serialize the editor.
* The result of the serialization will be used to construct a
* new annotation to add to the pdf document.
*
* To implement in subclasses.
* @returns {undefined}
*/
serialize() {
unreachable("An editor must be serializable");
}
/**
* Remove this editor.
* It's used on ctrl+backspace action.
*
* @returns {undefined}
*/
remove() {
this.parent.remove(this);
}
/**
* Select this editor.
*/
select() {
if (this.div) {
this.div.classList.add("selectedEditor");
}
}
/**
* Unselect this editor.
*/
unselect() {
if (this.div) {
this.div.classList.remove("selectedEditor");
}
}
}
export { AnnotationEditor };

@ -0,0 +1,225 @@
/* 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, Util } from "../../shared/util.js";
import { AnnotationEditor } from "./editor.js";
import { bindEvents } from "./tools.js";
/**
* Basic text editor in order to create a FreeTex annotation.
*/
class FreeTextEditor extends AnnotationEditor {
#color;
#content = "";
#contentHTML = "";
#fontSize;
static _freeTextDefaultContent = "";
static _l10nPromise;
constructor(params) {
super({ ...params, name: "freeTextEditor" });
this.#color = params.color || "CanvasText";
this.#fontSize = params.fontSize || 10;
}
static setL10n(l10n) {
this._l10nPromise = l10n.get("freetext_default_content");
}
/** @inheritdoc */
copy() {
const editor = new FreeTextEditor({
parent: this.parent,
id: this.parent.getNextId(),
x: this.x,
y: this.y,
});
editor.width = this.width;
editor.height = this.height;
editor.#color = this.#color;
editor.#fontSize = this.#fontSize;
editor.#content = this.#content;
editor.#contentHTML = this.#contentHTML;
return editor;
}
/** @inheritdoc */
rebuild() {
if (this.div === null) {
return;
}
if (!this.isAttachedToDOM) {
// At some point this editor has been removed and
// we're rebuilting it, hence we must add it to its
// parent.
this.parent.add(this);
}
}
/** @inheritdoc */
enableEditMode() {
super.enableEditMode();
this.overlayDiv.classList.remove("enabled");
this.div.draggable = false;
}
/** @inheritdoc */
disableEditMode() {
super.disableEditMode();
this.overlayDiv.classList.add("enabled");
this.div.draggable = true;
}
/** @inheritdoc */
onceAdded() {
if (this.width) {
// The editor has been created in using ctrl+c.
this.div.focus();
return;
}
this.enableEditMode();
this.editorDiv.focus();
}
/** @inheritdoc */
isEmpty() {
return this.editorDiv.innerText.trim() === "";
}
/**
* Extract the text from this editor.
* @returns {string}
*/
#extractText() {
const divs = this.editorDiv.getElementsByTagName("div");
if (divs.length === 0) {
return this.editorDiv.innerText;
}
const buffer = [];
for (let i = 0, ii = divs.length; i < ii; i++) {
const div = divs[i];
const first = div.firstChild;
if (first?.nodeName === "#text") {
buffer.push(first.data);
} else {
buffer.push("");
}
}
return buffer.join("\n");
}
/**
* Commit the content we have in this editor.
* @returns {undefined}
*/
commit() {
this.disableEditMode();
this.#contentHTML = this.editorDiv.innerHTML;
this.#content = this.#extractText().trimEnd();
const style = getComputedStyle(this.div);
this.width = parseFloat(style.width);
this.height = parseFloat(style.height);
}
/** @inheritdoc */
shouldGetKeyboardEvents() {
return this.isInEditMode();
}
/**
* ondblclick callback.
* @param {MouseEvent} event
*/
dblclick(event) {
this.enableEditMode();
this.editorDiv.focus();
}
/** @inheritdoc */
render() {
if (this.div) {
return this.div;
}
super.render();
this.editorDiv = document.createElement("div");
this.editorDiv.tabIndex = 0;
this.editorDiv.className = "internal";
FreeTextEditor._l10nPromise.then(msg =>
this.editorDiv.setAttribute("default-content", msg)
);
this.editorDiv.contentEditable = true;
const { style } = this.editorDiv;
style.fontSize = `calc(${this.#fontSize}px * var(--zoom-factor))`;
style.minHeight = `calc(${1.5 * this.#fontSize}px * var(--zoom-factor))`;
style.color = this.#color;
this.div.appendChild(this.editorDiv);
this.overlayDiv = document.createElement("div");
this.overlayDiv.classList.add("overlay", "enabled");
this.div.appendChild(this.overlayDiv);
// TODO: implement paste callback.
// The goal is to sanitize and have something suitable for this
// editor.
bindEvents(this, this.div, ["dblclick"]);
if (this.width) {
// This editor has been created in using copy (ctrl+c).
this.setAt(this.x + this.width, this.y + this.height);
// eslint-disable-next-line no-unsanitized/property
this.editorDiv.innerHTML = this.#contentHTML;
}
return this.div;
}
/** @inheritdoc */
serialize() {
const rect = this.div.getBoundingClientRect();
const [x1, y1] = Util.applyTransform(
[this.x, this.y + rect.height],
this.parent.viewport.inverseTransform
);
const [x2, y2] = Util.applyTransform(
[this.x + rect.width, this.y],
this.parent.viewport.inverseTransform
);
return {
annotationType: AnnotationEditorType.FREETEXT,
color: [0, 0, 0],
fontSize: this.#fontSize,
value: this.#content,
pageIndex: this.parent.pageIndex,
rect: [x1, y1, x2, y2],
};
}
}
export { FreeTextEditor };

574
src/display/editor/tools.js Normal file

@ -0,0 +1,574 @@
/* 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.
*/
/** @typedef {import("./editor.js").AnnotationEditor} AnnotationEditor */
// eslint-disable-next-line max-len
/** @typedef {import("./annotation_editor_layer.js").AnnotationEditorLayer} AnnotationEditorLayer */
import {
AnnotationEditorPrefix,
AnnotationEditorType,
shadow,
} from "../../shared/util.js";
function bindEvents(obj, element, names) {
for (const name of names) {
element.addEventListener(name, obj[name].bind(obj));
}
}
/**
* Class to create some unique ids for the different editors.
*/
class IdManager {
#id = 0;
/**
* Get a unique id.
* @returns {string}
*/
getId() {
return `${AnnotationEditorPrefix}${this.#id++}`;
}
}
/**
* Class to handle undo/redo.
* Commands are just saved in a buffer.
* If we hit some memory issues we could likely use a circular buffer.
* It has to be used as a singleton.
*/
class CommandManager {
#commands = [];
#maxSize = 100;
// When the position is NaN, it means the buffer is empty.
#position = NaN;
#start = 0;
/**
* Add a new couple of commands to be used in case of redo/undo.
* @param {function} cmd
* @param {function} undo
*/
add(cmd, undo) {
const save = [cmd, undo];
const next = (this.#position + 1) % this.#maxSize;
if (next !== this.#start) {
if (this.#start < next) {
this.#commands = this.#commands.slice(this.#start, next);
} else {
this.#commands = this.#commands
.slice(this.#start)
.concat(this.#commands.slice(0, next));
}
this.#start = 0;
this.#position = this.#commands.length - 1;
}
this.#setCommands(save);
cmd();
}
/**
* Undo the last command.
*/
undo() {
if (isNaN(this.#position)) {
// Nothing to undo.
return;
}
this.#commands[this.#position][1]();
if (this.#position === this.#start) {
this.#position = NaN;
} else {
this.#position = (this.#maxSize + this.#position - 1) % this.#maxSize;
}
}
/**
* Redo the last command.
*/
redo() {
if (isNaN(this.#position)) {
if (this.#start < this.#commands.length) {
this.#commands[this.#start][0]();
this.#position = this.#start;
}
return;
}
const next = (this.#position + 1) % this.#maxSize;
if (next !== this.#start && next < this.#commands.length) {
this.#commands[next][0]();
this.#position = next;
}
}
#setCommands(cmds) {
if (this.#commands.length < this.#maxSize) {
this.#commands.push(cmds);
this.#position = isNaN(this.#position) ? 0 : this.#position + 1;
return;
}
if (isNaN(this.#position)) {
this.#position = this.#start;
} else {
this.#position = (this.#position + 1) % this.#maxSize;
if (this.#position === this.#start) {
this.#start = (this.#start + 1) % this.#maxSize;
}
}
this.#commands[this.#position] = cmds;
}
}
/**
* Class to handle the different keyboards shortcuts we can have on mac or
* non-mac OSes.
*/
class KeyboardManager {
/**
* Create a new keyboard manager class.
* @param {Array<Array>} callbacks - an array containing an array of shortcuts
* and a callback to call.
* A shortcut is a string like `ctrl+c` or `mac+ctrl+c` for mac OS.
*/
constructor(callbacks) {
this.buffer = [];
this.callbacks = new Map();
this.allKeys = new Set();
const isMac = KeyboardManager.platform.isMac;
for (const [keys, callback] of callbacks) {
for (const key of keys) {
const isMacKey = key.startsWith("mac+");
if (isMac && isMacKey) {
this.callbacks.set(key.slice(4), callback);
this.allKeys.add(key.split("+").at(-1));
} else if (!isMac && !isMacKey) {
this.callbacks.set(key, callback);
this.allKeys.add(key.split("+").at(-1));
}
}
}
}
static get platform() {
const platform = typeof navigator !== "undefined" ? navigator.platform : "";
return shadow(this, "platform", {
isWin: platform.includes("Win"),
isMac: platform.includes("Mac"),
});
}
/**
* Serialize an event into a string in order to match a
* potential key for a callback.
* @param {KeyboardEvent} event
* @returns {string}
*/
#serialize(event) {
if (event.altKey) {
this.buffer.push("alt");
}
if (event.ctrlKey) {
this.buffer.push("ctrl");
}
if (event.metaKey) {
this.buffer.push("meta");
}
if (event.shiftKey) {
this.buffer.push("shift");
}
this.buffer.push(event.key);
const str = this.buffer.join("+");
this.buffer.length = 0;
return str;
}
/**
* Execute a callback, if any, for a given keyboard event.
* The page is used as `this` in the callback.
* @param {AnnotationEditorLayer} page.
* @param {KeyboardEvent} event
* @returns
*/
exec(page, event) {
if (!this.allKeys.has(event.key)) {
return;
}
const callback = this.callbacks.get(this.#serialize(event));
if (!callback) {
return;
}
callback.bind(page)();
event.preventDefault();
}
}
/**
* Basic clipboard to copy/paste some editors.
* It has to be used as a singleton.
*/
class ClipboardManager {
constructor() {
this.element = null;
}
/**
* Copy an element.
* @param {AnnotationEditor} element
*/
copy(element) {
this.element = element.copy();
}
/**
* Create a new element.
* @returns {AnnotationEditor|null}
*/
paste() {
return this.element?.copy() || null;
}
}
/**
* A pdf has several pages and each of them when it will rendered
* will have an AnnotationEditorLayer which will contain the some
* new Annotations associated to an editor in order to modify them.
*
* This class is used to manage all the different layers, editors and
* some action like copy/paste, undo/redo, ...
*/
class AnnotationEditorUIManager {
#activeEditor = null;
#allEditors = new Map();
#allLayers = new Set();
#allowClick = true;
#clipboardManager = new ClipboardManager();
#commandManager = new CommandManager();
#idManager = new IdManager();
#isAllSelected = false;
#isEnabled = false;
#mode = AnnotationEditorType.NONE;
/**
* Get an id.
* @returns {string}
*/
getId() {
return this.#idManager.getId();
}
/**
* Add a new layer for a page which will contains the editors.
* @param {AnnotationEditorLayer} layer
*/
addLayer(layer) {
this.#allLayers.add(layer);
if (this.#isEnabled) {
layer.enable();
} else {
layer.disable();
}
}
/**
* Remove a layer.
* @param {AnnotationEditorLayer} layer
*/
removeLayer(layer) {
this.#allLayers.delete(layer);
}
/**
* Change the editor mode (None, FreeText, Ink, ...)
* @param {number} mode
*/
updateMode(mode) {
this.#mode = mode;
if (mode === AnnotationEditorType.NONE) {
this.#disableAll();
} else {
this.#enableAll();
}
}
/**
* Enable all the layers.
*/
#enableAll() {
if (!this.#isEnabled) {
this.#isEnabled = true;
for (const layer of this.#allLayers) {
layer.enable();
}
}
}
/**
* Disable all the layers.
*/
#disableAll() {
if (this.#isEnabled) {
this.#isEnabled = false;
for (const layer of this.#allLayers) {
layer.disable();
}
}
}
/**
* Get all the editors belonging to a give page.
* @param {number} pageIndex
* @returns {Array<AnnotationEditor>}
*/
getEditors(pageIndex) {
const editors = [];
for (const editor of this.#allEditors.values()) {
if (editor.pageIndex === pageIndex) {
editors.push(editor);
}
}
return editors;
}
/**
* Get an editor with the given id.
* @param {string} id
* @returns {AnnotationEditor}
*/
getEditor(id) {
return this.#allEditors.get(id);
}
/**
* Add a new editor.
* @param {AnnotationEditor} editor
*/
addEditor(editor) {
this.#allEditors.set(editor.id, editor);
}
/**
* Remove an editor.
* @param {AnnotationEditor} editor
*/
removeEditor(editor) {
this.#allEditors.delete(editor.id);
}
/**
* Set the given editor as the active one.
* @param {AnnotationEditor} editor
*/
setActiveEditor(editor) {
this.#activeEditor = editor;
}
/**
* Undo the last command.
*/
undo() {
this.#commandManager.undo();
}
/**
* Redo the last undoed command.
*/
redo() {
this.#commandManager.redo();
}
/**
* Add a command to execute (cmd) and another one to undo it.
* @param {function} cmd
* @param {function} undo
*/
addCommands(cmd, undo) {
this.#commandManager.add(cmd, undo);
}
/**
* When set to true a click on the current layer will trigger
* an editor creation.
* @return {boolean}
*/
get allowClick() {
return this.#allowClick;
}
/**
* @param {boolean} allow
*/
set allowClick(allow) {
this.#allowClick = allow;
}
/**
* Unselect the current editor.
*/
unselect() {
if (this.#activeEditor) {
this.#activeEditor.parent.setActiveEditor(null);
}
this.#allowClick = true;
}
/**
* Suppress some editors from the given layer.
* @param {AnnotationEditorLayer} layer
*/
suppress(layer) {
let cmd, undo;
if (this.#isAllSelected) {
const editors = Array.from(this.#allEditors.values());
cmd = () => {
for (const editor of editors) {
editor.remove();
}
};
undo = () => {
for (const editor of editors) {
layer.addOrRebuild(editor);
}
};
this.addCommands(cmd, undo);
} else {
if (!this.#activeEditor) {
return;
}
const editor = this.#activeEditor;
cmd = () => {
editor.remove();
};
undo = () => {
layer.addOrRebuild(editor);
};
}
this.addCommands(cmd, undo);
}
/**
* Copy the selected editor.
*/
copy() {
if (this.#activeEditor) {
this.#clipboardManager.copy(this.#activeEditor);
}
}
/**
* Cut the selected editor.
* @param {AnnotationEditorLayer}
*/
cut(layer) {
if (this.#activeEditor) {
this.#clipboardManager.copy(this.#activeEditor);
const editor = this.#activeEditor;
const cmd = () => {
editor.remove();
};
const undo = () => {
layer.addOrRebuild(editor);
};
this.addCommands(cmd, undo);
}
}
/**
* Paste a previously copied editor.
* @param {AnnotationEditorLayer}
* @returns {undefined}
*/
paste(layer) {
const editor = this.#clipboardManager.paste();
if (!editor) {
return;
}
const cmd = () => {
layer.addOrRebuild(editor);
};
const undo = () => {
editor.remove();
};
this.addCommands(cmd, undo);
}
/**
* Select all the editors.
*/
selectAll() {
this.#isAllSelected = true;
for (const editor of this.#allEditors.values()) {
editor.select();
}
}
/**
* Unselect all the editors.
*/
unselectAll() {
this.#isAllSelected = false;
for (const editor of this.#allEditors.values()) {
editor.unselect();
}
}
/**
* Is the current editor the one passed as argument?
* @param {AnnotationEditor} editor
* @returns
*/
isActive(editor) {
return this.#activeEditor === editor;
}
/**
* Get the current active editor.
* @returns {AnnotationEditor|null}
*/
getActive() {
return this.#activeEditor;
}
/**
* Get the current editor mode.
* @returns {number}
*/
getMode() {
return this.#mode;
}
}
export { AnnotationEditorUIManager, bindEvents, KeyboardManager };

@ -21,6 +21,7 @@
/** @typedef {import("./display/display_utils").PageViewport} PageViewport */
import {
AnnotationEditorType,
AnnotationMode,
CMapCompressionType,
createPromiseCapability,
@ -56,6 +57,8 @@ import {
PixelsPerInch,
RenderingCancelledException,
} from "./display/display_utils.js";
import { AnnotationEditorLayer } from "./display/editor/annotation_editor_layer.js";
import { AnnotationEditorUIManager } from "./display/editor/tools.js";
import { AnnotationLayer } from "./display/annotation_layer.js";
import { GlobalWorkerOptions } from "./display/worker_options.js";
import { isNodeJS } from "./shared/is_node.js";
@ -104,6 +107,9 @@ if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("PRODUCTION")) {
}
export {
AnnotationEditorLayer,
AnnotationEditorType,
AnnotationEditorUIManager,
AnnotationLayer,
AnnotationMode,
build,

@ -51,6 +51,13 @@ const AnnotationMode = {
ENABLE_STORAGE: 3,
};
const AnnotationEditorPrefix = "pdfjs_internal_editor_";
const AnnotationEditorType = {
NONE: 0,
FREETEXT: 1,
};
// Permission flags from Table 22, Section 7.6.3.2 of the PDF specification.
const PermissionFlag = {
PRINT: 0x04,
@ -1135,6 +1142,8 @@ export {
AbortException,
AnnotationActionEventType,
AnnotationBorderStyleType,
AnnotationEditorPrefix,
AnnotationEditorType,
AnnotationFieldFlag,
AnnotationFlag,
AnnotationMarkedState,

@ -30,6 +30,7 @@ async function runTests(results) {
"annotation_spec.js",
"accessibility_spec.js",
"find_spec.js",
"freetext_editor_spec.js",
],
});

@ -0,0 +1,200 @@
/* 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.
*/
const { closePages, loadAndWait } = require("./test_utils.js");
const editorPrefix = "#pdfjs_internal_editor_";
describe("Editor", () => {
describe("FreeText", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait("tracemonkey.pdf", ".annotationEditorLayer");
});
afterAll(async () => {
await closePages(pages);
});
it("must write a string in a FreeText editor", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await page.click("#editorFreeText");
const rect = await page.$eval(".annotationEditorLayer", el => {
// With Chrome something is wrong when serializing a DomRect,
// hence we extract the values and just return them.
const { x, y } = el.getBoundingClientRect();
return { x, y };
});
const data = "Hello PDF.js World !!";
await page.mouse.click(rect.x + 10, rect.y + 10);
await page.type(`${editorPrefix}0 .internal`, data);
const editorRect = await page.$eval(`${editorPrefix}0`, el => {
const { x, y, width, height } = el.getBoundingClientRect();
return {
x,
y,
width,
height,
};
});
// Commit.
await page.mouse.click(
editorRect.x,
editorRect.y + 2 * editorRect.height
);
const content = await page.$eval(`${editorPrefix}0`, el =>
el.innerText.trimEnd()
);
expect(content).withContext(`In ${browserName}`).toEqual(data);
})
);
});
it("must copy/paste", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const editorRect = await page.$eval(`${editorPrefix}0`, el => {
const { x, y, width, height } = el.getBoundingClientRect();
return { x, y, width, height };
});
// Select the editor created previously.
await page.mouse.click(
editorRect.x + editorRect.width / 2,
editorRect.y + editorRect.height / 2
);
await page.keyboard.down("Control");
await page.keyboard.press("c");
await page.keyboard.up("Control");
await page.keyboard.down("Control");
await page.keyboard.press("v");
await page.keyboard.up("Control");
const content = await page.$eval(`${editorPrefix}0`, el =>
el.innerText.trimEnd()
);
let pastedContent = await page.$eval(`${editorPrefix}2`, el =>
el.innerText.trimEnd()
);
expect(pastedContent)
.withContext(`In ${browserName}`)
.toEqual(content);
await page.keyboard.down("Control");
await page.keyboard.press("c");
await page.keyboard.up("Control");
await page.keyboard.down("Control");
await page.keyboard.press("v");
await page.keyboard.up("Control");
pastedContent = await page.$eval(`${editorPrefix}4`, el =>
el.innerText.trimEnd()
);
expect(pastedContent)
.withContext(`In ${browserName}`)
.toEqual(content);
})
);
});
it("must clear all", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await page.keyboard.down("Control");
await page.keyboard.press("a");
await page.keyboard.up("Control");
await page.keyboard.down("Control");
await page.keyboard.press("Backspace");
await page.keyboard.up("Control");
for (const n of [0, 2, 4]) {
const hasEditor = await page.evaluate(sel => {
return !!document.querySelector(sel);
}, `${editorPrefix}${n}`);
expect(hasEditor).withContext(`In ${browserName}`).toEqual(false);
}
})
);
});
it("must check that a paste has been undone", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
const rect = await page.$eval(".annotationEditorLayer", el => {
const { x, y } = el.getBoundingClientRect();
return { x, y };
});
const data = "Hello PDF.js World !!";
await page.mouse.click(rect.x + 10, rect.y + 10);
await page.type(`${editorPrefix}5 .internal`, data);
const editorRect = await page.$eval(`${editorPrefix}5`, el => {
const { x, y, width, height } = el.getBoundingClientRect();
return { x, y, width, height };
});
// Commit.
await page.mouse.click(
editorRect.x,
editorRect.y + 2 * editorRect.height
);
// And select it again.
await page.mouse.click(
editorRect.x + editorRect.width / 2,
editorRect.y + editorRect.height / 2
);
await page.keyboard.down("Control");
await page.keyboard.press("c");
await page.keyboard.up("Control");
await page.keyboard.down("Control");
await page.keyboard.press("v");
await page.keyboard.up("Control");
let hasEditor = await page.evaluate(sel => {
return !!document.querySelector(sel);
}, `${editorPrefix}7`);
expect(hasEditor).withContext(`In ${browserName}`).toEqual(true);
await page.keyboard.down("Control");
await page.keyboard.press("z");
await page.keyboard.up("Control");
hasEditor = await page.evaluate(sel => {
return !!document.querySelector(sel);
}, `${editorPrefix}7`);
expect(hasEditor).withContext(`In ${browserName}`).toEqual(false);
})
);
});
});
});

@ -950,6 +950,7 @@ async function startBrowser(browserName, startUrl = "") {
// Avoid popup when saving is done
"browser.download.always_ask_before_handling_new_types": true,
"browser.download.panel.shown": true,
"browser.download.alwaysOpenPanel": false,
// Save file in output
"browser.download.folderList": 2,
"browser.download.dir": tempDir,

@ -0,0 +1,85 @@
/* 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.
*/
:root {
--focus-outline: solid 2px red;
--hover-outline: dashed 2px blue;
}
.annotationEditorLayer {
background: transparent;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
.annotationEditorLayer .freeTextEditor {
position: absolute;
background: transparent;
border-radius: 3px;
padding: 5px;
resize: none;
width: auto;
height: auto;
}
.annotationEditorLayer .freeTextEditor .internal {
background: transparent;
border: none;
top: 0;
left: 0;
min-height: 15px;
overflow: visible;
white-space: nowrap;
resize: none;
}
.annotationEditorLayer .freeTextEditor .overlay {
position: absolute;
display: none;
background: transparent;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.annotationEditorLayer .freeTextEditor .overlay.enabled {
display: block;
}
.annotationEditorLayer .freeTextEditor .internal:empty::before {
content: attr(default-content);
color: gray;
}
.annotationEditorLayer .freeTextEditor .internal:focus {
outline: none;
}
.annotationEditorLayer .freeTextEditor:focus-within {
outline: var(--focus-outline);
}
.annotationEditorLayer .freeTextEditor:hover:not(:focus-within) {
outline: var(--hover-outline);
}
.annotationEditorLayer .selectedEditor {
outline: var(--focus-outline);
resize: none;
}

@ -0,0 +1,128 @@
/* 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.
*/
/** @typedef {import("../src/display/api").PDFPageProxy} PDFPageProxy */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
// eslint-disable-next-line max-len
/** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */
/** @typedef {import("./interfaces").IL10n} IL10n */
import { AnnotationEditorLayer } from "pdfjs-lib";
import { NullL10n } from "./l10n_utils.js";
/**
* @typedef {Object} AnnotationEditorLayerBuilderOptions
* @property {number} mode - Editor mode
* @property {HTMLDivElement} pageDiv
* @property {PDFPageProxy} pdfPage
* @property {AnnotationStorage} annotationStorage
* @property {IL10n} l10n - Localization service.
* @property {AnnotationEditorUIManager} uiManager
*/
class AnnotationEditorLayerBuilder {
#uiManager;
/**
* @param {AnnotationEditorLayerBuilderOptions} options
*/
constructor(options) {
this.pageDiv = options.pageDiv;
this.pdfPage = options.pdfPage;
this.annotationStorage = options.annotationStorage || null;
this.l10n = options.l10n || NullL10n;
this.annotationEditorLayer = null;
this.div = null;
this._cancelled = false;
this.#uiManager = options.uiManager;
}
/**
* @param {PageViewport} viewport
* @param {string} intent (default value is 'display')
*/
async render(viewport, intent = "display") {
if (intent !== "display") {
return;
}
if (this._cancelled) {
return;
}
if (this.div) {
this.annotationEditorLayer.update({ viewport: viewport.clone() });
this.show();
return;
}
// Create an AnnotationEditor layer div
this.div = document.createElement("div");
this.div.className = "annotationEditorLayer";
this.div.tabIndex = 0;
this.annotationEditorLayer = new AnnotationEditorLayer({
uiManager: this.#uiManager,
div: this.div,
annotationStorage: this.annotationStorage,
pageIndex: this.pdfPage._pageIndex,
l10n: this.l10n,
});
const parameters = {
viewport: viewport.clone(),
div: this.div,
annotations: null,
intent,
};
this.annotationEditorLayer.render(parameters);
this.pageDiv.appendChild(this.div);
}
cancel() {
this._cancelled = true;
}
hide() {
if (!this.div) {
return;
}
this.div.hidden = true;
}
show() {
if (!this.div) {
return;
}
this.div.hidden = false;
}
destroy() {
if (!this.div) {
return;
}
this.pageDiv = null;
this.div.remove();
this.annotationEditorLayer.destroy();
}
}
export { AnnotationEditorLayerBuilder };

@ -525,6 +525,7 @@ const PDFViewerApplication = {
l10n: this.l10n,
textLayerMode: AppOptions.get("textLayerMode"),
annotationMode: AppOptions.get("annotationMode"),
annotationEditorEnabled: AppOptions.get("annotationEditorEnabled"),
imageResourcesPath: AppOptions.get("imageResourcesPath"),
enablePrintAutoRotate: AppOptions.get("enablePrintAutoRotate"),
useOnlyCssZoom: AppOptions.get("useOnlyCssZoom"),
@ -560,6 +561,10 @@ const PDFViewerApplication = {
this.findBar = new PDFFindBar(appConfig.findBar, eventBus, this.l10n);
}
if (AppOptions.get("annotationEditorEnabled")) {
document.getElementById("editorModeButtons").classList.remove("hidden");
}
this.pdfDocumentProperties = new PDFDocumentProperties(
appConfig.documentProperties,
this.overlayManager,
@ -1878,6 +1883,10 @@ const PDFViewerApplication = {
eventBus._on("namedaction", webViewerNamedAction);
eventBus._on("presentationmodechanged", webViewerPresentationModeChanged);
eventBus._on("presentationmode", webViewerPresentationMode);
eventBus._on(
"switchannotationeditormode",
webViewerSwitchAnnotationEditorMode
);
eventBus._on("print", webViewerPrint);
eventBus._on("download", webViewerDownload);
eventBus._on("firstpage", webViewerFirstPage);
@ -2459,6 +2468,13 @@ if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
function webViewerPresentationMode() {
PDFViewerApplication.requestPresentationMode();
}
function webViewerSwitchAnnotationEditorMode(evt) {
if (evt.toggle) {
PDFViewerApplication.pdfViewer.annotionEditorEnabled = true;
} else {
PDFViewerApplication.pdfViewer.annotationEditorMode = evt.mode;
}
}
function webViewerPrint() {
PDFViewerApplication.triggerPrinting();
}

@ -58,6 +58,11 @@ const defaultOptions = {
value: 2,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
annotationEditorEnabled: {
/** @type {boolean} */
value: typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING"),
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
cursorToolOnLoad: {
/** @type {number} */
value: 0,

@ -22,6 +22,8 @@
/** @typedef {import("./interfaces").IL10n} IL10n */
// eslint-disable-next-line max-len
/** @typedef {import("./interfaces").IPDFAnnotationLayerFactory} IPDFAnnotationLayerFactory */
// eslint-disable-next-line max-len
/** @typedef {import("./interfaces").IPDFAnnotationEditorLayerFactory} IPDFAnnotationEditorLayerFactory */
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
// eslint-disable-next-line max-len
/** @typedef {import("./interfaces").IPDFStructTreeLayerFactory} IPDFStructTreeLayerFactory */
@ -30,6 +32,8 @@
/** @typedef {import("./interfaces").IPDFXfaLayerFactory} IPDFXfaLayerFactory */
import {
AnnotationEditorType,
AnnotationEditorUIManager,
AnnotationMode,
createPromiseCapability,
PermissionFlag,
@ -61,6 +65,7 @@ import {
VERTICAL_PADDING,
watchScroll,
} from "./ui_utils.js";
import { AnnotationEditorLayerBuilder } from "./annotation_editor_layer_builder.js";
import { AnnotationLayerBuilder } from "./annotation_layer_builder.js";
import { NullL10n } from "./l10n_utils.js";
import { PDFPageView } from "./pdf_page_view.js";
@ -104,6 +109,8 @@ const PagesCountLimit = {
* being rendered. The constants from {@link AnnotationMode} should be used;
* see also {@link RenderParameters} and {@link GetOperatorListParameters}.
* The default value is `AnnotationMode.ENABLE_FORMS`.
* @property {boolean} [annotationEditorEnabled] - Enables the creation and
* editing of new Annotations.
* @property {string} [imageResourcesPath] - Path for image resources, mainly
* mainly for annotation icons. Include trailing slash.
* @property {boolean} [enablePrintAutoRotate] - Enables automatic rotation of
@ -194,6 +201,7 @@ class PDFPageViewBuffer {
* Simple viewer control to display PDF content/pages.
*
* @implements {IPDFAnnotationLayerFactory}
* @implements {IPDFAnnotationEditorLayerFactory}
* @implements {IPDFStructTreeLayerFactory}
* @implements {IPDFTextLayerFactory}
* @implements {IPDFXfaLayerFactory}
@ -201,6 +209,10 @@ class PDFPageViewBuffer {
class BaseViewer {
#buffer = null;
#annotationEditorMode = AnnotationEditorType.NONE;
#annotationEditorUIManager = null;
#annotationMode = AnnotationMode.ENABLE_FORMS;
#previousAnnotationMode = null;
@ -268,6 +280,10 @@ class BaseViewer {
this.#enablePermissions = options.enablePermissions || false;
this.pageColors = options.pageColors || null;
if (options.annotationEditorEnabled === true) {
this.#annotationEditorUIManager = new AnnotationEditorUIManager();
}
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
if (
this.pageColors &&
@ -699,6 +715,9 @@ class BaseViewer {
const annotationLayerFactory =
this.#annotationMode !== AnnotationMode.DISABLE ? this : null;
const xfaLayerFactory = isPureXfa ? this : null;
const annotationEditorLayerFactory = this.#annotationEditorUIManager
? this
: null;
for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) {
const pageView = new PDFPageView({
@ -714,6 +733,7 @@ class BaseViewer {
annotationLayerFactory,
annotationMode: this.#annotationMode,
xfaLayerFactory,
annotationEditorLayerFactory,
textHighlighterFactory: this,
structTreeLayerFactory: this,
imageResourcesPath: this.imageResourcesPath,
@ -1656,6 +1676,30 @@ class BaseViewer {
});
}
/**
* @param {HTMLDivElement} pageDiv
* @param {PDFPageProxy} pdfPage
* @param {IL10n} l10n
* @param {AnnotationStorage} [annotationStorage] - Storage for annotation
* data in forms.
* @returns {AnnotationEditorLayerBuilder}
*/
createAnnotationEditorLayerBuilder(
pageDiv,
pdfPage,
l10n,
annotationStorage = null
) {
return new AnnotationEditorLayerBuilder({
uiManager: this.#annotationEditorUIManager,
pageDiv,
pdfPage,
annotationStorage:
annotationStorage || this.pdfDocument?.annotationStorage,
l10n,
});
}
/**
* @param {HTMLDivElement} pageDiv
* @param {PDFPageProxy} pdfPage
@ -2072,6 +2116,36 @@ class BaseViewer {
docStyle.setProperty("--viewer-container-height", `${height}px`);
}
}
get annotationEditorMode() {
return this.#annotationEditorMode;
}
/**
* @param {number} mode - Annotation Editor mode (None, FreeText, Ink, ...)
*/
set annotationEditorMode(mode) {
if (!this.#annotationEditorUIManager) {
throw new Error(`The AnnotationEditor is not enabled.`);
}
if (this.#annotationEditorMode === mode) {
return;
}
if (!Object.values(AnnotationEditorType).includes(mode)) {
throw new Error(`Invalid AnnotationEditor mode: ${mode}`);
}
// If the mode is the same as before, it means that this mode is disabled
// and consequently the mode is NONE.
this.#annotationEditorMode = mode;
this.eventBus.dispatch("annotationeditormodechanged", {
source: this,
mode,
});
this.#annotationEditorUIManager.updateMode(mode);
}
}
export { BaseViewer, PagesCountLimit, PDFPageViewBuffer };

@ -21,6 +21,8 @@
/** @typedef {import("./interfaces").IL10n} IL10n */
// eslint-disable-next-line max-len
/** @typedef {import("./interfaces").IPDFAnnotationLayerFactory} IPDFAnnotationLayerFactory */
// eslint-disable-next-line max-len
/** @typedef {import("./interfaces").IPDFAnnotationEditorLayerFactory} IPDFAnnotationEditorLayerFactory */
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
// eslint-disable-next-line max-len
/** @typedef {import("./interfaces").IPDFStructTreeLayerFactory} IPDFStructTreeLayerFactory */
@ -29,6 +31,7 @@
/** @typedef {import("./interfaces").IPDFXfaLayerFactory} IPDFXfaLayerFactory */
/** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */
import { AnnotationEditorLayerBuilder } from "./annotation_editor_layer_builder.js";
import { AnnotationLayerBuilder } from "./annotation_layer_builder.js";
import { NullL10n } from "./l10n_utils.js";
import { SimpleLinkService } from "./pdf_link_service.js";
@ -87,6 +90,32 @@ class DefaultAnnotationLayerFactory {
}
}
/**
* @implements IPDFAnnotationEditorLayerFactory
*/
class DefaultAnnotationEditorLayerFactory {
/**
* @param {HTMLDivElement} pageDiv
* @param {PDFPageProxy} pdfPage
* @param {IL10n} l10n
* @param {AnnotationStorage} [annotationStorage]
* @returns {AnnotationEditorLayerBuilder}
*/
createAnnotationEditorLayerBuilder(
pageDiv,
pdfPage,
l10n,
annotationStorage = null
) {
return new AnnotationEditorLayerBuilder({
pageDiv,
pdfPage,
l10n,
annotationStorage,
});
}
}
/**
* @implements IPDFStructTreeLayerFactory
*/
@ -161,6 +190,7 @@ class DefaultXfaLayerFactory {
}
export {
DefaultAnnotationEditorLayerFactory,
DefaultAnnotationLayerFactory,
DefaultStructTreeLayerFactory,
DefaultTextLayerFactory,

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- copied from https://www.svgrepo.com/svg/255881/text -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;" xml:space="preserve">
<g>
<g transform="scale(0.03125)">
<path d="M405.787,43.574H8.17c-4.513,0-8.17,3.658-8.17,8.17v119.83c0,4.512,3.657,8.17,8.17,8.17h32.681
c4.513,0,8.17-3.658,8.17-8.17v-24.511h95.319v119.83c0,4.512,3.657,8.17,8.17,8.17c4.513,0,8.17-3.658,8.17-8.17v-128
c0-4.512-3.657-8.17-8.17-8.17H40.851c-4.513,0-8.17,3.658-8.17,8.17v24.511H16.34V59.915h381.277v103.489h-16.34v-24.511
c0-4.512-3.657-8.17-8.17-8.17h-111.66c-4.513,0-8.17,3.658-8.17,8.17v288.681c0,4.512,3.657,8.17,8.17,8.17h57.191v16.34H95.319
v-16.34h57.191c4.513,0,8.17-3.658,8.17-8.17v-128c0-4.512-3.657-8.17-8.17-8.17c-4.513,0-8.17,3.658-8.17,8.17v119.83H87.149
c-4.513,0-8.17,3.658-8.17,8.17v32.681c0,4.512,3.657,8.17,8.17,8.17h239.66c4.513,0,8.17-3.658,8.17-8.17v-32.681
c0-4.512-3.657-8.17-8.17-8.17h-57.192v-272.34h95.319v24.511c0,4.512,3.657,8.17,8.17,8.17h32.681c4.513,0,8.17-3.658,8.17-8.17
V51.745C413.957,47.233,410.3,43.574,405.787,43.574z"/>
</g>
</g>
<g>
<g transform="scale(0.03125)">
<path d="M503.83,452.085h-24.511V59.915h24.511c4.513,0,8.17-3.658,8.17-8.17s-3.657-8.17-8.17-8.17h-65.362
c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17h24.511v392.17h-24.511c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17
h65.362c4.513,0,8.17-3.658,8.17-8.17S508.343,452.085,503.83,452.085z"/>
</g>
</g>
</svg>

After

(image error) Size: 1.6 KiB

@ -0,0 +1,4 @@
<!-- 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" width="16" height="16" viewBox="0 0 16 16"><path d="M12.408 8.217l-8.083-6.7A.2.2 0 0 0 4 1.672V12.3a.2.2 0 0 0 .333.146l2.56-2.372 1.857 3.9A1.125 1.125 0 1 0 10.782 13L8.913 9.075l3.4-.51a.2.2 0 0 0 .095-.348z"></path></svg>

After

(image error) Size: 478 B

@ -19,6 +19,8 @@
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
// eslint-disable-next-line max-len
/** @typedef {import("./annotation_layer_builder").AnnotationLayerBuilder} AnnotationLayerBuilder */
// eslint-disable-next-line max-len
/** @typedef {import("./annotation_editor_layer_builder").AnnotationEditorLayerBuilder} AnnotationEditorLayerBuilder */
/** @typedef {import("./event_utils").EventBus} EventBus */
// eslint-disable-next-line max-len
/** @typedef {import("./struct_tree_builder").StructTreeLayerBuilder} StructTreeLayerBuilder */
@ -208,6 +210,26 @@ class IPDFAnnotationLayerFactory {
) {}
}
/**
* @interface
*/
class IPDFAnnotationEditorLayerFactory {
/**
* @param {HTMLDivElement} pageDiv
* @param {PDFPageProxy} pdfPage
* @param {IL10n} l10n
* @param {AnnotationStorage} [annotationStorage] - Storage for annotation
* data in forms.
* @returns {AnnotationEditorLayerBuilder}
*/
createAnnotationEditorLayerBuilder(
pageDiv,
pdfPage,
l10n = undefined,
annotationStorage = null
) {}
}
/**
* @interface
*/
@ -307,6 +329,7 @@ class IL10n {
export {
IDownloadManager,
IL10n,
IPDFAnnotationEditorLayerFactory,
IPDFAnnotationLayerFactory,
IPDFLinkService,
IPDFStructTreeLayerFactory,

@ -81,6 +81,7 @@ const DEFAULT_L10N_STRINGS = {
printing_not_ready: "Warning: The PDF is not fully loaded for printing.",
web_fonts_disabled:
"Web fonts are disabled: unable to use embedded PDF fonts.",
freetext_default_content: "Enter some text…",
};
function getL10nFallback(key, args) {

@ -22,6 +22,8 @@
// eslint-disable-next-line max-len
/** @typedef {import("./interfaces").IPDFAnnotationLayerFactory} IPDFAnnotationLayerFactory */
// eslint-disable-next-line max-len
/** @typedef {import("./interfaces").IPDFAnnotationEditorLayerFactory} IPDFAnnotationEditorLayerFactory */
// eslint-disable-next-line max-len
/** @typedef {import("./interfaces").IPDFStructTreeLayerFactory} IPDFStructTreeLayerFactory */
// eslint-disable-next-line max-len
/** @typedef {import("./interfaces").IPDFTextLayerFactory} IPDFTextLayerFactory */
@ -72,6 +74,7 @@ import { NullL10n } from "./l10n_utils.js";
* see also {@link RenderParameters} and {@link GetOperatorListParameters}.
* The default value is `AnnotationMode.ENABLE_FORMS`.
* @property {IPDFAnnotationLayerFactory} annotationLayerFactory
* @property {IPDFAnnotationEditorLayerFactory} annotationEditorLayerFactory
* @property {IPDFXfaLayerFactory} xfaLayerFactory
* @property {IPDFStructTreeLayerFactory} structTreeLayerFactory
* @property {Object} [textHighlighterFactory]
@ -128,6 +131,7 @@ class PDFPageView {
this.renderingQueue = options.renderingQueue;
this.textLayerFactory = options.textLayerFactory;
this.annotationLayerFactory = options.annotationLayerFactory;
this.annotationEditorLayerFactory = options.annotationEditorLayerFactory;
this.xfaLayerFactory = options.xfaLayerFactory;
this.textHighlighter =
options.textHighlighterFactory?.createTextHighlighter(
@ -148,6 +152,7 @@ class PDFPageView {
this._annotationCanvasMap = null;
this.annotationLayer = null;
this.annotationEditorLayer = null;
this.textLayer = null;
this.zoomLayer = null;
this.xfaLayer = null;
@ -204,6 +209,24 @@ class PDFPageView {
}
}
/**
* @private
*/
async _renderAnnotationEditorLayer() {
let error = null;
try {
await this.annotationEditorLayer.render(this.viewport, "display");
} catch (ex) {
error = ex;
} finally {
this.eventBus.dispatch("annotationeditorlayerrendered", {
source: this,
pageNumber: this.id,
error,
});
}
}
/**
* @private
*/
@ -259,9 +282,14 @@ class PDFPageView {
reset({
keepZoomLayer = false,
keepAnnotationLayer = false,
keepAnnotationEditorLayer = false,
keepXfaLayer = false,
} = {}) {
this.cancelRendering({ keepAnnotationLayer, keepXfaLayer });
this.cancelRendering({
keepAnnotationLayer,
keepAnnotationEditorLayer,
keepXfaLayer,
});
this.renderingState = RenderingStates.INITIAL;
const div = this.div;
@ -272,12 +300,15 @@ class PDFPageView {
zoomLayerNode = (keepZoomLayer && this.zoomLayer) || null,
annotationLayerNode =
(keepAnnotationLayer && this.annotationLayer?.div) || null,
annotationEditorLayerNode =
(keepAnnotationEditorLayer && this.annotationEditorLayer?.div) || null,
xfaLayerNode = (keepXfaLayer && this.xfaLayer?.div) || null;
for (let i = childNodes.length - 1; i >= 0; i--) {
const node = childNodes[i];
switch (node) {
case zoomLayerNode:
case annotationLayerNode:
case annotationEditorLayerNode:
case xfaLayerNode:
continue;
}
@ -290,6 +321,12 @@ class PDFPageView {
// so they are not displayed on the already resized page.
this.annotationLayer.hide();
}
if (annotationEditorLayerNode) {
this.annotationEditorLayer.hide();
} else {
this.annotationEditorLayer?.destroy();
}
if (xfaLayerNode) {
// Hide the XFA layer until all elements are resized
// so they are not displayed on the already resized page.
@ -347,6 +384,7 @@ class PDFPageView {
this.cssTransform({
target: this.svg,
redrawAnnotationLayer: true,
redrawAnnotationEditorLayer: true,
redrawXfaLayer: true,
});
@ -380,6 +418,7 @@ class PDFPageView {
this.cssTransform({
target: this.canvas,
redrawAnnotationLayer: true,
redrawAnnotationEditorLayer: true,
redrawXfaLayer: true,
});
@ -403,6 +442,7 @@ class PDFPageView {
this.reset({
keepZoomLayer: true,
keepAnnotationLayer: true,
keepAnnotationEditorLayer: true,
keepXfaLayer: true,
});
}
@ -411,7 +451,11 @@ class PDFPageView {
* PLEASE NOTE: Most likely you want to use the `this.reset()` method,
* rather than calling this one directly.
*/
cancelRendering({ keepAnnotationLayer = false, keepXfaLayer = false } = {}) {
cancelRendering({
keepAnnotationLayer = false,
keepAnnotationEditorLayer = false,
keepXfaLayer = false,
} = {}) {
if (this.paintTask) {
this.paintTask.cancel();
this.paintTask = null;
@ -430,6 +474,13 @@ class PDFPageView {
this.annotationLayer = null;
this._annotationCanvasMap = null;
}
if (
this.annotationEditorLayer &&
(!keepAnnotationEditorLayer || !this.annotationEditorLayer.div)
) {
this.annotationEditorLayer.cancel();
this.annotationEditorLayer = null;
}
if (this.xfaLayer && (!keepXfaLayer || !this.xfaLayer.div)) {
this.xfaLayer.cancel();
this.xfaLayer = null;
@ -444,6 +495,7 @@ class PDFPageView {
cssTransform({
target,
redrawAnnotationLayer = false,
redrawAnnotationEditorLayer = false,
redrawXfaLayer = false,
}) {
// Scale target (canvas or svg), its wrapper and page container.
@ -517,6 +569,9 @@ class PDFPageView {
if (redrawAnnotationLayer && this.annotationLayer) {
this._renderAnnotationLayer();
}
if (redrawAnnotationEditorLayer && this.annotationEditorLayer) {
this._renderAnnotationEditorLayer();
}
if (redrawXfaLayer && this.xfaLayer) {
this._renderXfaLayer();
}
@ -567,9 +622,12 @@ class PDFPageView {
canvasWrapper.style.height = div.style.height;
canvasWrapper.classList.add("canvasWrapper");
if (this.annotationLayer?.div) {
const lastDivBeforeTextDiv =
this.annotationLayer?.div || this.annotationEditorLayer?.div;
if (lastDivBeforeTextDiv) {
// The annotation layer needs to stay on top.
div.insertBefore(canvasWrapper, this.annotationLayer.div);
div.insertBefore(canvasWrapper, lastDivBeforeTextDiv);
} else {
div.appendChild(canvasWrapper);
}
@ -580,9 +638,9 @@ class PDFPageView {
textLayerDiv.className = "textLayer";
textLayerDiv.style.width = canvasWrapper.style.width;
textLayerDiv.style.height = canvasWrapper.style.height;
if (this.annotationLayer?.div) {
if (lastDivBeforeTextDiv) {
// The annotation layer needs to stay on top.
div.insertBefore(textLayerDiv, this.annotationLayer.div);
div.insertBefore(textLayerDiv, lastDivBeforeTextDiv);
} else {
div.appendChild(textLayerDiv);
}
@ -693,7 +751,18 @@ class PDFPageView {
}
if (this.annotationLayer) {
this._renderAnnotationLayer();
this._renderAnnotationLayer().then(() => {
if (this.annotationEditorLayerFactory) {
this.annotationEditorLayer ||=
this.annotationEditorLayerFactory.createAnnotationEditorLayerBuilder(
div,
pdfPage,
this.l10n,
/* annotationStorage = */ null
);
this._renderAnnotationEditorLayer();
}
});
}
});
},

@ -15,6 +15,7 @@
@import url(text_layer_builder.css);
@import url(annotation_layer_builder.css);
@import url(xfa_layer_builder.css);
@import url(annotation_editor_layer_builder.css);
:root {
--viewer-container-height: 0;

@ -22,6 +22,7 @@ import {
MIN_SCALE,
noContextMenuHandler,
} from "./ui_utils.js";
import { AnnotationEditorType } from "pdfjs-lib";
const PAGE_NUMBER_LOADING_INDICATOR = "visiblePageIsLoading";
@ -43,6 +44,9 @@ const PAGE_NUMBER_LOADING_INDICATOR = "visiblePageIsLoading";
* @property {HTMLButtonElement} openFile - Button to open a new document.
* @property {HTMLButtonElement} presentationModeButton - Button to switch to
* presentation mode.
* @property {HTMLButtonElement} editorNoneButton - Button to disable editing.
* @property {HTMLButtonElement} editorFreeTextButton - Button to switch to Free
* Text edition.
* @property {HTMLButtonElement} download - Button to download the document.
* @property {HTMLAnchorElement} viewBookmark - Button to obtain a bookmark link
* to the current location in the document.
@ -70,6 +74,16 @@ class Toolbar {
},
{ element: options.download, eventName: "download" },
{ element: options.viewBookmark, eventName: null },
{
element: options.editorNoneButton,
eventName: "switchannotationeditormode",
eventDetails: { mode: AnnotationEditorType.NONE },
},
{
element: options.editorFreeTextButton,
eventName: "switchannotationeditormode",
eventDetails: { mode: AnnotationEditorType.FREETEXT },
},
];
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
this.buttons.push({ element: options.openFile, eventName: "openfile" });
@ -89,7 +103,7 @@ class Toolbar {
this.reset();
// Bind the event listeners for click and various other actions.
this._bindListeners();
this._bindListeners(options);
}
setPageNumber(pageNumber, pageLabel) {
@ -121,15 +135,21 @@ class Toolbar {
this.updateLoadingIndicatorState();
}
_bindListeners() {
_bindListeners(options) {
const { pageNumber, scaleSelect } = this.items;
const self = this;
// The buttons within the toolbar.
for (const { element, eventName } of this.buttons) {
for (const { element, eventName, eventDetails } of this.buttons) {
element.addEventListener("click", evt => {
if (eventName !== null) {
this.eventBus.dispatch(eventName, { source: this });
const details = { source: this };
if (eventDetails) {
for (const property in eventDetails) {
details[property] = eventDetails[property];
}
}
this.eventBus.dispatch(eventName, details);
}
});
}
@ -174,6 +194,23 @@ class Toolbar {
this.#adjustScaleWidth();
this._updateUIState(true);
});
this.#bindEditorToolsListener(options);
}
#bindEditorToolsListener({ editorNoneButton, editorFreeTextButton }) {
this.eventBus._on("annotationeditormodechanged", evt => {
const editorButtons = [
[AnnotationEditorType.NONE, editorNoneButton],
[AnnotationEditorType.FREETEXT, editorFreeTextButton],
];
for (const [mode, button] of editorButtons) {
const checked = mode === evt.mode;
button.classList.toggle("toggled", checked);
button.setAttribute("aria-checked", checked);
}
});
}
_updateUIState(resetNumPages = false) {

@ -71,6 +71,8 @@
--loading-icon: url(images/loading.svg);
--treeitem-expanded-icon: url(images/treeitem-expanded.svg);
--treeitem-collapsed-icon: url(images/treeitem-collapsed.svg);
--toolbarButton-editorFreeText-icon: url(images/toolbarButton-editorFreeText.svg);
--toolbarButton-editorNone-icon: url(images/toolbarButton-editorNone.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);
@ -824,6 +826,14 @@ select {
mask-image: var(--toolbarButton-presentationMode-icon);
}
#editorNone::before {
mask-image: var(--toolbarButton-editorNone-icon);
}
#editorFreeText::before {
mask-image: var(--toolbarButton-editorFreeText-icon);
}
#print::before,
#secondaryPrint::before {
mask-image: var(--toolbarButton-print-icon);

@ -263,30 +263,39 @@ See https://github.com/adobe-type-tools/cmap-resources
<span id="numPages" class="toolbarLabel"></span>
</div>
<div id="toolbarViewerRight">
<button id="presentationMode" class="toolbarButton hiddenLargeView" title="Switch to Presentation Mode" tabindex="31" data-l10n-id="presentation_mode">
<div id="editorModeButtons" class="splitToolbarButton hidden" role="radiogroup">
<button id="editorNone" class="toolbarButton" title="Disable Annotation Editing" role="radio" aria-checked="false" tabindex="31" data-l10n-id="editor_none">
<span data-l10n-id="editor_none_label">Disable Editing</span>
</button>
<button id="editorFreeText" class="toolbarButton" title="Add FreeText Annotation" role="radio" aria-checked="false" tabindex="32" data-l10n-id="editor_free_text">
<span data-l10n-id="editor_free_text_label">FreeText Annotation</span>
</button>
</div>
<button id="presentationMode" class="toolbarButton hiddenLargeView" title="Switch to Presentation Mode" tabindex="43" data-l10n-id="presentation_mode">
<span data-l10n-id="presentation_mode_label">Presentation Mode</span>
</button>
<!--#if GENERIC-->
<button id="openFile" class="toolbarButton hiddenLargeView" title="Open File" tabindex="32" data-l10n-id="open_file">
<button id="openFile" class="toolbarButton hiddenLargeView" title="Open File" tabindex="44" data-l10n-id="open_file">
<span data-l10n-id="open_file_label">Open</span>
</button>
<!--#endif-->
<button id="print" class="toolbarButton hiddenMediumView" title="Print" tabindex="33" data-l10n-id="print">
<button id="print" class="toolbarButton hiddenMediumView" title="Print" tabindex="45" data-l10n-id="print">
<span data-l10n-id="print_label">Print</span>
</button>
<button id="download" class="toolbarButton hiddenMediumView" title="Download" tabindex="34" data-l10n-id="download">
<button id="download" class="toolbarButton hiddenMediumView" title="Download" tabindex="46" data-l10n-id="download">
<span data-l10n-id="download_label">Download</span>
</button>
<a href="#" id="viewBookmark" class="toolbarButton hiddenSmallView" title="Current view (copy or open in new window)" tabindex="35" data-l10n-id="bookmark">
<a href="#" id="viewBookmark" class="toolbarButton hiddenSmallView" title="Current view (copy or open in new window)" tabindex="47" data-l10n-id="bookmark">
<span data-l10n-id="bookmark_label">Current View</span>
</a>
<div class="verticalToolbarSeparator hiddenSmallView"></div>
<button id="secondaryToolbarToggle" class="toolbarButton" title="Tools" tabindex="36" data-l10n-id="tools" aria-expanded="false" aria-controls="secondaryToolbar">
<button id="secondaryToolbarToggle" class="toolbarButton" title="Tools" tabindex="48" data-l10n-id="tools" aria-expanded="false" aria-controls="secondaryToolbar">
<span data-l10n-id="tools_label">Tools</span>
</button>
</div>

@ -93,6 +93,8 @@ function getViewerConfiguration() {
? document.getElementById("openFile")
: null,
print: document.getElementById("print"),
editorFreeTextButton: document.getElementById("editorFreeText"),
editorNoneButton: document.getElementById("editorNone"),
presentationModeButton: document.getElementById("presentationMode"),
download: document.getElementById("download"),
viewBookmark: document.getElementById("viewBookmark"),