Merge pull request #16535 from calixteman/restore_freetext

[Editor] Allow to edit FreeText annotations
This commit is contained in:
calixteman 2023-06-15 18:10:41 +02:00 committed by GitHub
commit 5581e22cc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 459 additions and 39 deletions

View File

@ -3553,7 +3553,7 @@ class FreeTextAnnotation extends MarkupAnnotation {
constructor(params) {
super(params);
this.data.hasOwnCanvas = this.data.noRotate;
this.data.hasOwnCanvas = true;
const { xref } = params;
this.data.annotationType = AnnotationType.FREETEXT;

View File

@ -2629,7 +2629,7 @@ class AnnotationLayer {
#div = null;
#editableAnnotations = new Set();
#editableAnnotations = new Map();
constructor({ div, accessibilityManager, annotationCanvasMap }) {
this.#div = div;
@ -2696,7 +2696,7 @@ class AnnotationLayer {
}
if (element.annotationEditorType > 0) {
this.#editableAnnotations.add(element);
this.#editableAnnotations.set(element.data.id, element);
}
const rendered = element.render();
@ -2767,7 +2767,11 @@ class AnnotationLayer {
}
getEditableAnnotations() {
return this.#editableAnnotations;
return Array.from(this.#editableAnnotations.values());
}
getEditableAnnotation(id) {
return this.#editableAnnotations.get(id);
}
}

View File

@ -13,7 +13,6 @@
* limitations under the License.
*/
/** @typedef {import("./editor.js").AnnotationEditor} AnnotationEditor */
// eslint-disable-next-line max-len
/** @typedef {import("./tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
/** @typedef {import("../display_utils.js").PageViewport} PageViewport */
@ -24,6 +23,7 @@
/** @typedef {import("../src/display/annotation_layer.js").AnnotationLayer} AnnotationLayer */
import { AnnotationEditorType, FeatureTest } from "../../shared/util.js";
import { AnnotationEditor } from "./editor.js";
import { bindEvents } from "./tools.js";
import { FreeTextEditor } from "./freetext.js";
import { InkEditor } from "./ink.js";
@ -66,6 +66,8 @@ class AnnotationEditorLayer {
#isCleaningUp = false;
#isDisabling = false;
#uiManager;
static _initialized = false;
@ -86,6 +88,7 @@ class AnnotationEditorLayer {
this.div = options.div;
this.#accessibilityManager = options.accessibilityManager;
this.#annotationLayer = options.annotationLayer;
this.viewport = options.viewport;
this.#uiManager.addLayer(this);
}
@ -175,18 +178,33 @@ class AnnotationEditorLayer {
*/
enable() {
this.div.style.pointerEvents = "auto";
if (this.#annotationLayer) {
const annotationElementIds = new Set();
for (const editor of this.#editors.values()) {
editor.enableEditing();
if (editor.annotationElementId) {
annotationElementIds.add(editor.annotationElementId);
}
}
if (!this.#annotationLayer) {
return;
}
const editables = this.#annotationLayer.getEditableAnnotations();
for (const editable of editables) {
// The element must be hidden whatever its state is.
editable.hide();
if (this.#uiManager.isDeletedAnnotationElement(editable.data.id)) {
continue;
}
if (annotationElementIds.has(editable.data.id)) {
continue;
}
const editor = this.deserialize(editable);
if (!editor) {
continue;
}
editable.hide();
this.addOrRebuild(editor);
}
}
for (const editor of this.#editors.values()) {
editor.enableEditing();
}
}
@ -195,18 +213,25 @@ class AnnotationEditorLayer {
* Disable editor creation.
*/
disable() {
this.#isDisabling = true;
this.div.style.pointerEvents = "none";
for (const editor of this.#editors.values()) {
editor.disableEditing();
if (!editor.hasElementChanged()) {
editor.annotationElement.show();
editor.remove();
if (!editor.annotationElementId || editor.serialize() !== null) {
continue;
}
this.getEditableAnnotation(editor.annotationElementId)?.show();
editor.remove();
}
this.#cleanup();
if (this.isEmpty) {
this.div.hidden = true;
}
this.#isDisabling = false;
}
getEditableAnnotation(id) {
return this.#annotationLayer?.getEditableAnnotation(id) || null;
}
/**
@ -234,11 +259,22 @@ class AnnotationEditorLayer {
attach(editor) {
this.#editors.set(editor.id, editor);
const { annotationElementId } = editor;
if (
annotationElementId &&
this.#uiManager.isDeletedAnnotationElement(annotationElementId)
) {
this.#uiManager.removeDeletedAnnotationElement(editor);
}
}
detach(editor) {
this.#editors.delete(editor.id);
this.#accessibilityManager?.removePointerInTextLayer(editor.contentDiv);
if (!this.#isDisabling && editor.annotationElementId) {
this.#uiManager.addDeletedAnnotationElement(editor);
}
}
/**
@ -249,8 +285,8 @@ class AnnotationEditorLayer {
// 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.#uiManager.removeEditor(editor);
editor.div.style.display = "none";
setTimeout(() => {
// When the div is removed from DOM the focus can move on the
@ -280,6 +316,12 @@ class AnnotationEditorLayer {
return;
}
if (editor.annotationElementId) {
this.#uiManager.addDeletedAnnotationElement(editor.annotationElementId);
AnnotationEditor.deleteAnnotationElement(editor);
editor.annotationElementId = null;
}
this.attach(editor);
editor.parent?.detach(editor);
editor.setParent(this);

View File

@ -67,7 +67,7 @@ class AnnotationEditor {
this.name = parameters.name;
this.div = null;
this._uiManager = parameters.uiManager;
this.annotationElement = null;
this.annotationElementId = null;
const {
rotation,
@ -85,6 +85,7 @@ class AnnotationEditor {
this.y = parameters.y / height;
this.isAttachedToDOM = false;
this.deleted = false;
}
static get _defaultLineColor() {
@ -95,6 +96,17 @@ class AnnotationEditor {
);
}
static deleteAnnotationElement(editor) {
const fakeEditor = new FakeEditor({
id: editor.parent.getNextId(),
parent: editor.parent,
uiManager: editor._uiManager,
});
fakeEditor.annotationElementId = editor.annotationElementId;
fakeEditor.deleted = true;
fakeEditor._uiManager.addToAnnotationStorage(fakeEditor);
}
/**
* Add some commands into the CommandManager (undo/redo stuff).
* @param {Object} params
@ -601,14 +613,22 @@ class AnnotationEditor {
this.parent.setActiveEditor(null);
}
}
}
/**
* Check if the editor has been changed.
* @param {Object} serialized
* @returns {boolean}
*/
hasElementChanged(serialized = null) {
return false;
// This class is used to fake an editor which has been deleted.
class FakeEditor extends AnnotationEditor {
constructor(params) {
super(params);
this.annotationElementId = params.annotationElementId;
this.deleted = true;
}
serialize() {
return {
id: this.annotationElementId,
deleted: true,
pageIndex: this.pageIndex,
};
}
}

View File

@ -48,6 +48,8 @@ class FreeTextEditor extends AnnotationEditor {
#fontSize;
#initialData = null;
static _freeTextDefaultContent = "";
static _l10nPromise;
@ -285,6 +287,7 @@ class FreeTextEditor extends AnnotationEditor {
/** @inheritdoc */
onceAdded() {
if (this.width) {
this.#cheatInitialRect();
// The editor was created in using ctrl+c.
return;
}
@ -481,12 +484,17 @@ class FreeTextEditor extends AnnotationEditor {
if (this.width) {
// This editor was created in using copy (ctrl+c).
const [parentWidth, parentHeight] = this.parentDimensions;
if (this.annotationElementId) {
const [tx] = this.getInitialTranslation();
this.setAt(baseX * parentWidth, baseY * parentHeight, tx, tx);
} else {
this.setAt(
baseX * parentWidth,
baseY * parentHeight,
this.width * parentWidth,
this.height * parentHeight
);
}
this.#setContent();
this.div.draggable = true;
@ -519,14 +527,37 @@ class FreeTextEditor extends AnnotationEditor {
/** @inheritdoc */
static deserialize(data, parent, uiManager) {
let initialData = null;
if (data instanceof FreeTextAnnotationElement) {
return null;
const {
data: {
defaultAppearanceData: { fontSize, fontColor },
rect,
rotation,
id,
},
textContent,
page: { pageNumber },
} = data;
initialData = data = {
annotationType: AnnotationEditorType.FREETEXT,
color: Array.from(fontColor),
fontSize,
value: textContent.join("\n"),
pageIndex: pageNumber - 1,
rect,
rotation,
id,
deleted: false,
};
}
const editor = super.deserialize(data, parent, uiManager);
editor.#fontSize = data.fontSize;
editor.#color = Util.makeHexColor(...data.color);
editor.#content = data.value;
editor.annotationElementId = data.id || null;
editor.#initialData = initialData;
return editor;
}
@ -537,16 +568,23 @@ class FreeTextEditor extends AnnotationEditor {
return null;
}
if (this.deleted) {
return {
pageIndex: this.pageIndex,
id: this.annotationElementId,
deleted: true,
};
}
const padding = FreeTextEditor._internalPadding * this.parentScale;
const rect = this.getRect(padding, padding);
const color = AnnotationEditor._colorManager.convert(
this.isAttachedToDOM
? getComputedStyle(this.editorDiv).color
: this.#color
);
return {
const serialized = {
annotationType: AnnotationEditorType.FREETEXT,
color,
fontSize: this.#fontSize,
@ -554,7 +592,45 @@ class FreeTextEditor extends AnnotationEditor {
pageIndex: this.pageIndex,
rect,
rotation: this.rotation,
id: this.annotationElementId,
};
if (this.annotationElementId && !this.#hasElementChanged(serialized)) {
return null;
}
return serialized;
}
#hasElementChanged(serialized) {
const { value, fontSize, color, rect, pageIndex } = this.#initialData;
return (
serialized.value !== value ||
serialized.fontSize !== fontSize ||
serialized.rect.some((x, i) => Math.abs(x - rect[i]) >= 1) ||
serialized.color.some((c, i) => c !== color[i]) ||
serialized.pageIndex !== pageIndex
);
}
#cheatInitialRect(delayed = false) {
// The annotation has a rect but the editor has an other one.
// When we want to know if the annotation has changed (e.g. has been moved)
// we must compare the editor initial rect with the current one.
// So this method is a hack to have a way to compare the real rects.
if (!this.annotationElementId) {
return;
}
this.#setEditorDimensions();
if (!delayed && (this.width === 0 || this.height === 0)) {
setTimeout(() => this.#cheatInitialRect(/* delayed = */ true), 0);
return;
}
const padding = FreeTextEditor._internalPadding * this.parentScale;
this.#initialData.rect = this.getRect(padding, padding);
}
}

View File

@ -362,6 +362,8 @@ class AnnotationEditorUIManager {
#currentPageIndex = 0;
#deletedAnnotationsElementIds = new Set();
#editorTypes = null;
#editorsToRescale = new Set();
@ -554,7 +556,11 @@ class AnnotationEditorUIManager {
const editors = [];
for (const editor of this.#selectedEditors) {
if (!editor.isEmpty()) {
editors.push(editor.serialize());
const serialized = editor.serialize();
// Remove the id from the serialized data because it mustn't be linked
// to an existing annotation.
delete serialized.id;
editors.push(serialized);
}
}
if (editors.length === 0) {
@ -862,8 +868,40 @@ class AnnotationEditorUIManager {
removeEditor(editor) {
this.#allEditors.delete(editor.id);
this.unselect(editor);
if (
!editor.annotationElementId ||
!this.#deletedAnnotationsElementIds.has(editor.annotationElementId)
) {
this.#annotationStorage?.remove(editor.id);
}
}
/**
* The annotation element with the given id has been deleted.
* @param {AnnotationEditor} editor
*/
addDeletedAnnotationElement(editor) {
this.#deletedAnnotationsElementIds.add(editor.annotationElementId);
editor.deleted = true;
}
/**
* Check if the annotation element with the given id has been deleted.
* @param {string} annotationElementId
* @returns {boolean}
*/
isDeletedAnnotationElement(annotationElementId) {
return this.#deletedAnnotationsElementIds.has(annotationElementId);
}
/**
* The annotation element with the given id have been restored.
* @param {AnnotationEditor} editor
*/
removeDeletedAnnotationElement(editor) {
this.#deletedAnnotationsElementIds.delete(editor.annotationElementId);
editor.deleted = false;
}
/**
* Add an editor to the layer it belongs to or add it to the global map.

View File

@ -15,8 +15,10 @@
const {
closePages,
getEditors,
getEditorSelector,
getSelectedEditors,
getSerialized,
loadAndWait,
waitForEvent,
waitForSelectedEditor,
@ -39,7 +41,7 @@ const copyPaste = async page => {
await promise;
};
describe("Editor", () => {
describe("FreeText Editor", () => {
describe("FreeText", () => {
let pages;
@ -837,4 +839,224 @@ describe("Editor", () => {
);
});
});
describe("FreeText (move existing)", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait("freetexts.pdf", ".annotationEditorLayer");
});
afterAll(async () => {
await closePages(pages);
});
it("must move an annotation", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
if (browserName === "firefox") {
pending(
"Disabled in Firefox, because DnD isn't implemented yet (see bug 1838638)."
);
}
await page.setDragInterception(true);
await page.click("#editorFreeText");
const editorIds = await getEditors(page, "freeText");
expect(editorIds.length).withContext(`In ${browserName}`).toEqual(6);
// All the current annotations should be serialized as null objects
// because they haven't been edited yet.
let serialized = await getSerialized(page);
expect(serialized).withContext(`In ${browserName}`).toEqual([]);
const editorRect = await page.$eval(getEditorSelector(0), el => {
const { x, y, width, height } = el.getBoundingClientRect();
return { x, y, width, height };
});
await page.mouse.dragAndDrop(
{
x: editorRect.x + editorRect.width / 2,
y: editorRect.y + editorRect.height / 2,
},
{
x: editorRect.x + editorRect.width / 2 + 100,
y: editorRect.y + editorRect.height / 2 + 100,
},
{ delay: 100 }
);
serialized = await getSerialized(page);
expect(serialized.length).withContext(`In ${browserName}`).toEqual(1);
})
);
});
});
describe("FreeText (update existing)", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait("freetexts.pdf", ".annotationEditorLayer");
});
afterAll(async () => {
await closePages(pages);
});
it("must update an existing annotation", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await page.click("#editorFreeText");
let editorIds = await getEditors(page, "freeText");
expect(editorIds.length).withContext(`In ${browserName}`).toEqual(6);
const editorRect = await page.$eval(getEditorSelector(0), el => {
const { x, y, width, height } = el.getBoundingClientRect();
return { x, y, width, height };
});
await page.mouse.click(
editorRect.x + editorRect.width / 2,
editorRect.y + editorRect.height / 2,
{ clickCount: 2 }
);
await page.keyboard.down("Control");
await page.keyboard.press("End");
await page.keyboard.up("Control");
await page.waitForTimeout(10);
await page.type(
`${getEditorSelector(0)} .internal`,
" and edited in Firefox"
);
// Commit.
await page.mouse.click(
editorRect.x,
editorRect.y + 2 * editorRect.height
);
let serialized = await getSerialized(page);
expect(serialized.length).withContext(`In ${browserName}`).toEqual(1);
expect(serialized[0]).toEqual(
jasmine.objectContaining({
color: [107, 217, 41],
fontSize: 14,
value: "Hello World from Acrobat and edited in Firefox",
id: "26R",
})
);
// Disable editing mode.
await page.click("#editorFreeText");
// We want to check that the editor is displayed but not the original
// annotation.
editorIds = await getEditors(page, "freeText");
expect(editorIds.length).withContext(`In ${browserName}`).toEqual(1);
const hidden = await page.$eval(
"[data-annotation-id='26R']",
el => el.hidden
);
expect(hidden).withContext(`In ${browserName}`).toBeTrue();
// Re-enable editing mode.
await page.click("#editorFreeText");
await page.focus(".annotationEditorLayer");
// Undo.
await page.keyboard.down("Control");
await page.keyboard.press("z");
await page.keyboard.up("Control");
await page.waitForTimeout(10);
serialized = await getSerialized(page);
expect(serialized).withContext(`In ${browserName}`).toEqual([]);
editorIds = await getEditors(page, "freeText");
expect(editorIds.length).withContext(`In ${browserName}`).toEqual(6);
// Undo again.
await page.keyboard.down("Control");
await page.keyboard.press("z");
await page.keyboard.up("Control");
await page.waitForTimeout(10);
// We check that the editor hasn't been removed.
editorIds = await getEditors(page, "freeText");
expect(editorIds.length).withContext(`In ${browserName}`).toEqual(6);
})
);
});
});
describe("FreeText (delete existing)", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait("freetexts.pdf", ".annotationEditorLayer");
});
afterAll(async () => {
await closePages(pages);
});
it("must delete an existing annotation", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await page.click("#editorFreeText");
let editorIds = await getEditors(page, "freeText");
expect(editorIds.length).withContext(`In ${browserName}`).toEqual(6);
const editorRect = await page.$eval(getEditorSelector(3), el => {
const { x, y, width, height } = el.getBoundingClientRect();
return { x, y, width, height };
});
await page.mouse.click(
editorRect.x + editorRect.width / 2,
editorRect.y + editorRect.height / 2
);
await page.keyboard.press("Backspace");
await page.waitForTimeout(10);
let serialized = await getSerialized(page);
expect(serialized).toEqual([
{
pageIndex: 0,
id: "51R",
deleted: true,
},
]);
await page.click("#editorFreeText");
// We want to check that nothing is displayed.
editorIds = await getEditors(page, "freeText");
expect(editorIds.length).withContext(`In ${browserName}`).toEqual(0);
const hidden = await page.$eval(
"[data-annotation-id='51R']",
el => el.hidden
);
expect(hidden).withContext(`In ${browserName}`).toBeTrue();
// Re-enable editing mode.
await page.click("#editorFreeText");
await page.focus(".annotationEditorLayer");
// Undo.
await page.keyboard.down("Control");
await page.keyboard.press("z");
await page.keyboard.up("Control");
await page.waitForTimeout(10);
serialized = await getSerialized(page);
expect(serialized).withContext(`In ${browserName}`).toEqual([]);
})
);
});
});
});

View File

@ -134,3 +134,21 @@ const mockClipboard = async pages => {
);
};
exports.mockClipboard = mockClipboard;
const getSerialized = page =>
page.evaluate(() => [
...window.PDFViewerApplication.pdfDocument.annotationStorage.serializable.values(),
]);
exports.getSerialized = getSerialized;
function getEditors(page, kind) {
return page.evaluate(aKind => {
const elements = document.querySelectorAll(`.${aKind}Editor`);
const results = [];
for (const { id } of elements) {
results.push(id);
}
return results;
}, kind);
}
exports.getEditors = getEditors;