1
0
Fork 0
mirror of https://github.com/mozilla/pdf.js.git synced 2025-04-19 06:38:07 +02:00

Merge pull request #17914 from calixteman/freetext_edit

[Editor] Provide an element to render in the annotation layer after a freetext has been edited (bug 1890535)
This commit is contained in:
calixteman 2024-04-18 14:33:12 +02:00 committed by GitHub
commit 4866686749
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 379 additions and 42 deletions

View file

@ -20,6 +20,8 @@
// eslint-disable-next-line max-len
/** @typedef {import("../../web/interfaces").IDownloadManager} IDownloadManager */
/** @typedef {import("../../web/interfaces").IPDFLinkService} IPDFLinkService */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
import {
AnnotationBorderStyleType,
@ -157,6 +159,8 @@ class AnnotationElementFactory {
}
class AnnotationElement {
#updates = null;
#hasBorder = false;
constructor(
@ -197,6 +201,52 @@ class AnnotationElement {
return AnnotationElement._hasPopupData(this.data);
}
updateEdited(params) {
if (!this.container) {
return;
}
this.#updates ||= {
rect: this.data.rect.slice(0),
};
const { rect } = params;
if (rect) {
this.#setRectEdited(rect);
}
}
resetEdited() {
if (!this.#updates) {
return;
}
this.#setRectEdited(this.#updates.rect);
this.#updates = null;
}
#setRectEdited(rect) {
const {
container: { style },
data: { rect: currentRect, rotation },
parent: {
viewport: {
rawDims: { pageWidth, pageHeight, pageX, pageY },
},
},
} = this;
currentRect?.splice(0, 4, ...rect);
const { width, height } = getRectDims(rect);
style.left = `${(100 * (rect[0] - pageX)) / pageWidth}%`;
style.top = `${(100 * (pageHeight - rect[3] + pageY)) / pageHeight}%`;
if (rotation === 0) {
style.width = `${(100 * width) / pageWidth}%`;
style.height = `${(100 * height) / pageHeight}%`;
} else {
this.setRotation(rotation);
}
}
/**
* Create an empty container for the annotation's HTML element.
*
@ -216,13 +266,14 @@ class AnnotationElement {
if (!(this instanceof WidgetAnnotationElement)) {
container.tabIndex = DEFAULT_TAB_INDEX;
}
const { style } = container;
// The accessibility manager will move the annotation in the DOM in
// order to match the visual ordering.
// But if an annotation is above an other one, then we must draw it
// after the other one whatever the order is in the DOM, hence the
// use of the z-index.
container.style.zIndex = this.parent.zIndex++;
style.zIndex = this.parent.zIndex++;
if (data.popupRef) {
container.setAttribute("aria-haspopup", "dialog");
@ -236,8 +287,6 @@ class AnnotationElement {
container.classList.add("norotate");
}
const { pageWidth, pageHeight, pageX, pageY } = viewport.rawDims;
if (!data.rect || this instanceof PopupAnnotationElement) {
const { rotation } = data;
if (!data.hasOwnCanvas && rotation !== 0) {
@ -248,35 +297,26 @@ class AnnotationElement {
const { width, height } = getRectDims(data.rect);
// Do *not* modify `data.rect`, since that will corrupt the annotation
// position on subsequent calls to `_createContainer` (see issue 6804).
const rect = Util.normalizeRect([
data.rect[0],
page.view[3] - data.rect[1] + page.view[1],
data.rect[2],
page.view[3] - data.rect[3] + page.view[1],
]);
if (!ignoreBorder && data.borderStyle.width > 0) {
container.style.borderWidth = `${data.borderStyle.width}px`;
style.borderWidth = `${data.borderStyle.width}px`;
const horizontalRadius = data.borderStyle.horizontalCornerRadius;
const verticalRadius = data.borderStyle.verticalCornerRadius;
if (horizontalRadius > 0 || verticalRadius > 0) {
const radius = `calc(${horizontalRadius}px * var(--scale-factor)) / calc(${verticalRadius}px * var(--scale-factor))`;
container.style.borderRadius = radius;
style.borderRadius = radius;
} else if (this instanceof RadioButtonWidgetAnnotationElement) {
const radius = `calc(${width}px * var(--scale-factor)) / calc(${height}px * var(--scale-factor))`;
container.style.borderRadius = radius;
style.borderRadius = radius;
}
switch (data.borderStyle.style) {
case AnnotationBorderStyleType.SOLID:
container.style.borderStyle = "solid";
style.borderStyle = "solid";
break;
case AnnotationBorderStyleType.DASHED:
container.style.borderStyle = "dashed";
style.borderStyle = "dashed";
break;
case AnnotationBorderStyleType.BEVELED:
@ -288,7 +328,7 @@ class AnnotationElement {
break;
case AnnotationBorderStyleType.UNDERLINE:
container.style.borderBottomStyle = "solid";
style.borderBottomStyle = "solid";
break;
default:
@ -298,24 +338,34 @@ class AnnotationElement {
const borderColor = data.borderColor || null;
if (borderColor) {
this.#hasBorder = true;
container.style.borderColor = Util.makeHexColor(
style.borderColor = Util.makeHexColor(
borderColor[0] | 0,
borderColor[1] | 0,
borderColor[2] | 0
);
} else {
// Transparent (invisible) border, so do not draw it at all.
container.style.borderWidth = 0;
style.borderWidth = 0;
}
}
container.style.left = `${(100 * (rect[0] - pageX)) / pageWidth}%`;
container.style.top = `${(100 * (rect[1] - pageY)) / pageHeight}%`;
// Do *not* modify `data.rect`, since that will corrupt the annotation
// position on subsequent calls to `_createContainer` (see issue 6804).
const rect = Util.normalizeRect([
data.rect[0],
page.view[3] - data.rect[1] + page.view[1],
data.rect[2],
page.view[3] - data.rect[3] + page.view[1],
]);
const { pageWidth, pageHeight, pageX, pageY } = viewport.rawDims;
style.left = `${(100 * (rect[0] - pageX)) / pageWidth}%`;
style.top = `${(100 * (rect[1] - pageY)) / pageHeight}%`;
const { rotation } = data;
if (data.hasOwnCanvas || rotation === 0) {
container.style.width = `${(100 * width) / pageWidth}%`;
container.style.height = `${(100 * height) / pageHeight}%`;
style.width = `${(100 * width) / pageWidth}%`;
style.height = `${(100 * height) / pageHeight}%`;
} else {
this.setRotation(rotation, container);
}
@ -2897,6 +2947,7 @@ class FileAttachmentAnnotationElement extends AnnotationElement {
* @property {Object<string, Array<Object>> | null} [fieldObjects]
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap]
* @property {TextAccessibilityManager} [accessibilityManager]
* @property {AnnotationEditorUIManager} [annotationEditorUIManager]
*/
/**
@ -2913,6 +2964,7 @@ class AnnotationLayer {
div,
accessibilityManager,
annotationCanvasMap,
annotationEditorUIManager,
page,
viewport,
}) {
@ -2922,6 +2974,7 @@ class AnnotationLayer {
this.page = page;
this.viewport = viewport;
this.zIndex = 0;
this._annotationEditorUIManager = annotationEditorUIManager;
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
// For testing purposes.
@ -3011,15 +3064,16 @@ class AnnotationLayer {
}
}
if (element.annotationEditorType > 0) {
this.#editableAnnotations.set(element.data.id, element);
}
const rendered = element.render();
if (data.hidden) {
rendered.style.visibility = "hidden";
}
this.#appendElement(rendered, data.id);
if (element.annotationEditorType > 0) {
this.#editableAnnotations.set(element.data.id, element);
this._annotationEditorUIManager?.renderAnnotationElement(element);
}
}
this.#setAnnotationCanvasMap();
@ -3051,13 +3105,16 @@ class AnnotationLayer {
continue;
}
canvas.className = "annotationContent";
const { firstChild } = element;
if (!firstChild) {
element.append(canvas);
} else if (firstChild.nodeName === "CANVAS") {
firstChild.replaceWith(canvas);
} else {
} else if (!firstChild.classList.contains("annotationContent")) {
firstChild.before(canvas);
} else {
firstChild.after(canvas);
}
}
this.#annotationCanvasMap.clear();

View file

@ -248,7 +248,9 @@ class AnnotationEditorLayer {
const annotationElementIds = new Set();
for (const editor of this.#editors.values()) {
editor.enableEditing();
editor.show(true);
if (editor.annotationElementId) {
this.#uiManager.removeChangedExistingAnnotation(editor);
annotationElementIds.add(editor.annotationElementId);
}
}
@ -283,13 +285,19 @@ class AnnotationEditorLayer {
this.#isDisabling = true;
this.div.tabIndex = -1;
this.togglePointerEvents(false);
const hiddenAnnotationIds = new Set();
const changedAnnotations = new Map();
const resetAnnotations = new Map();
for (const editor of this.#editors.values()) {
editor.disableEditing();
if (!editor.annotationElementId || editor.serialize() !== null) {
hiddenAnnotationIds.add(editor.annotationElementId);
if (!editor.annotationElementId) {
continue;
}
if (editor.serialize() !== null) {
changedAnnotations.set(editor.annotationElementId, editor);
continue;
} else {
resetAnnotations.set(editor.annotationElementId, editor);
}
this.getEditableAnnotation(editor.annotationElementId)?.show();
editor.remove();
}
@ -299,12 +307,23 @@ class AnnotationEditorLayer {
const editables = this.#annotationLayer.getEditableAnnotations();
for (const editable of editables) {
const { id } = editable.data;
if (
hiddenAnnotationIds.has(id) ||
this.#uiManager.isDeletedAnnotationElement(id)
) {
if (this.#uiManager.isDeletedAnnotationElement(id)) {
continue;
}
let editor = resetAnnotations.get(id);
if (editor) {
editor.resetAnnotationElement(editable);
editor.show(false);
editable.show();
continue;
}
editor = changedAnnotations.get(id);
if (editor) {
this.#uiManager.addChangedExistingAnnotation(editor);
editor.renderAnnotationElement(editable);
editor.show(false);
}
editable.show();
}
}
@ -461,7 +480,7 @@ class AnnotationEditorLayer {
return;
}
if (editor.annotationElementId) {
if (editor.parent && editor.annotationElementId) {
this.#uiManager.addDeletedAnnotationElement(editor.annotationElementId);
AnnotationEditor.deleteAnnotationElement(editor);
editor.annotationElementId = null;

View file

@ -1336,6 +1336,17 @@ class AnnotationEditor {
return editor;
}
/**
* Check if an existing annotation associated with this editor has been
* modified.
* @returns {boolean}
*/
get hasBeenModified() {
return (
!!this.annotationElementId && (this.deleted || this.serialize() !== null)
);
}
/**
* Remove this editor.
* It's used on ctrl+backspace action.
@ -1710,6 +1721,37 @@ class AnnotationEditor {
}
this.#disabled = true;
}
/**
* Render an annotation in the annotation layer.
* @param {Object} annotation
* @returns {HTMLElement}
*/
renderAnnotationElement(annotation) {
let content = annotation.container.querySelector(".annotationContent");
if (!content) {
content = document.createElement("div");
content.classList.add("annotationContent", this.editorType);
annotation.container.prepend(content);
} else if (content.nodeName === "CANVAS") {
const canvas = content;
content = document.createElement("div");
content.classList.add("annotationContent", this.editorType);
canvas.before(content);
}
return content;
}
resetAnnotationElement(annotation) {
const { firstChild } = annotation.container;
if (
firstChild.nodeName === "DIV" &&
firstChild.classList.contains("annotationContent")
) {
firstChild.remove();
}
}
}
// This class is used to fake an editor which has been deleted.

View file

@ -408,11 +408,14 @@ class FreeTextEditor extends AnnotationEditor {
// we just insert it in the DOM, get its bounding box and then remove it.
const { currentLayer, div } = this;
const savedDisplay = div.style.display;
const savedVisibility = div.classList.contains("hidden");
div.classList.remove("hidden");
div.style.display = "hidden";
currentLayer.div.append(this.div);
rect = div.getBoundingClientRect();
div.remove();
div.style.display = savedDisplay;
div.classList.toggle("hidden", savedVisibility);
}
// The dimensions are relative to the rotation of the page, hence we need to
@ -778,7 +781,7 @@ class FreeTextEditor extends AnnotationEditor {
value: textContent.join("\n"),
position: textPosition,
pageIndex: pageNumber - 1,
rect,
rect: rect.slice(0),
rotation,
id,
deleted: false,
@ -853,6 +856,38 @@ class FreeTextEditor extends AnnotationEditor {
serialized.pageIndex !== pageIndex
);
}
/** @inheritdoc */
renderAnnotationElement(annotation) {
const content = super.renderAnnotationElement(annotation);
if (this.deleted) {
return content;
}
const { style } = content;
style.fontSize = `calc(${this.#fontSize}px * var(--scale-factor))`;
style.color = this.#color;
content.replaceChildren();
for (const line of this.#content.split("\n")) {
const div = document.createElement("div");
div.append(
line ? document.createTextNode(line) : document.createElement("br")
);
content.append(div);
}
const padding = FreeTextEditor._internalPadding * this.parentScale;
annotation.updateEdited({
rect: this.getRect(padding, padding),
});
return content;
}
resetAnnotationElement(annotation) {
super.resetAnnotationElement(annotation);
annotation.resetEdited();
}
}
export { FreeTextEditor };

View file

@ -544,6 +544,8 @@ class AnnotationEditorUIManager {
#annotationStorage = null;
#changedExistingAnnotations = null;
#commandManager = new CommandManager();
#currentPageIndex = 0;
@ -1682,6 +1684,7 @@ class AnnotationEditorUIManager {
*/
addDeletedAnnotationElement(editor) {
this.#deletedAnnotationsElementIds.add(editor.annotationElementId);
this.addChangedExistingAnnotation(editor);
editor.deleted = true;
}
@ -1700,6 +1703,7 @@ class AnnotationEditorUIManager {
*/
removeDeletedAnnotationElement(editor) {
this.#deletedAnnotationsElementIds.delete(editor.annotationElementId);
this.removeChangedExistingAnnotation(editor);
editor.deleted = false;
}
@ -2243,6 +2247,32 @@ class AnnotationEditorUIManager {
}
return boxes.length === 0 ? null : boxes;
}
addChangedExistingAnnotation({ annotationElementId, id }) {
(this.#changedExistingAnnotations ||= new Map()).set(
annotationElementId,
id
);
}
removeChangedExistingAnnotation({ annotationElementId }) {
this.#changedExistingAnnotations?.delete(annotationElementId);
}
renderAnnotationElement(annotation) {
const editorId = this.#changedExistingAnnotations?.get(annotation.data.id);
if (!editorId) {
return;
}
const editor = this.#annotationStorage.getRawValue(editorId);
if (!editor) {
return;
}
if (this.#mode === AnnotationEditorType.NONE && !editor.hasBeenModified) {
return;
}
editor.renderAnnotationElement(annotation);
}
}
export {

View file

@ -1125,15 +1125,24 @@ describe("FreeText Editor", () => {
);
// We want to check that the editor is displayed but not the original
// annotation.
// canvas.
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
"[data-annotation-id='26R'] canvas",
el => getComputedStyle(el).display === "none"
);
expect(hidden).withContext(`In ${browserName}`).toBeTrue();
// Check we've now a div containing the text.
const newDivText = await page.$eval(
"[data-annotation-id='26R'] div.annotationContent",
el => el.innerText.replaceAll("\xa0", " ")
);
expect(newDivText)
.withContext(`In ${browserName}`)
.toEqual("Hello World from Acrobat and edited in Firefox");
// Re-enable editing mode.
await switchToFreeText(page);
await page.focus(".annotationEditorLayer");
@ -3715,4 +3724,123 @@ describe("FreeText Editor", () => {
);
});
});
describe("Update a freetext and scroll", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait(
"tracemonkey_freetext.pdf",
".annotationEditorLayer"
);
});
afterAll(async () => {
await closePages(pages);
});
it("must check that a freetext is still there after having updated it and scroll the doc", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await switchToFreeText(page);
const editorSelector = getEditorSelector(0);
const editorRect = await page.$eval(editorSelector, 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,
{ count: 2 }
);
await page.waitForSelector(
`${editorSelector} .overlay:not(.enabled)`
);
await kbGoToEnd(page);
await page.waitForFunction(
sel =>
document.getSelection().anchorOffset ===
document.querySelector(sel).innerText.length,
{},
`${editorSelector} .internal`
);
await page.type(
`${editorSelector} .internal`,
" and edited in Firefox"
);
// Disable editing mode.
await page.click("#editorFreeText");
await page.waitForSelector(
`.annotationEditorLayer:not(.freetextEditing)`
);
const oneToOne = Array.from(new Array(13).keys(), n => n + 2).concat(
Array.from(new Array(13).keys(), n => 13 - n)
);
for (const pageNumber of oneToOne) {
await scrollIntoView(
page,
`.page[data-page-number = "${pageNumber}"]`
);
}
await page.waitForSelector("[data-annotation-id='998R'] canvas");
let hidden = await page.$eval(
"[data-annotation-id='998R'] canvas",
el => getComputedStyle(el).display === "none"
);
expect(hidden).withContext(`In ${browserName}`).toBeTrue();
// Check we've now a div containing the text.
await page.waitForSelector(
"[data-annotation-id='998R'] div.annotationContent"
);
const newDivText = await page.$eval(
"[data-annotation-id='998R'] div.annotationContent",
el => el.innerText.replaceAll("\xa0", " ")
);
expect(newDivText)
.withContext(`In ${browserName}`)
.toEqual("Hello World and edited in Firefox");
const oneToThirteen = Array.from(new Array(13).keys(), n => n + 2);
for (const pageNumber of oneToThirteen) {
await scrollIntoView(
page,
`.page[data-page-number = "${pageNumber}"]`
);
}
await switchToFreeText(page);
await kbUndo(page);
await waitForSerialized(page, 0);
// Disable editing mode.
await page.click("#editorFreeText");
await page.waitForSelector(
`.annotationEditorLayer:not(.freetextEditing)`
);
const thirteenToOne = Array.from(new Array(13).keys(), n => 13 - n);
for (const pageNumber of thirteenToOne) {
await scrollIntoView(
page,
`.page[data-page-number = "${pageNumber}"]`
);
}
await page.waitForSelector("[data-annotation-id='998R'] canvas");
hidden = await page.$eval(
"[data-annotation-id='998R'] canvas",
el => getComputedStyle(el).display === "none"
);
expect(hidden).withContext(`In ${browserName}`).toBeFalse();
})
);
});
});
});

View file

@ -643,3 +643,4 @@
!bug1889122.pdf
!issue17929.pdf
!issue12213.pdf
!tracemonkey_freetext.pdf

Binary file not shown.

View file

@ -94,11 +94,22 @@
}
}
canvas {
.annotationContent {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
&.freetext {
background: transparent;
border: none;
inset: 0;
overflow: visible;
white-space: nowrap;
font: 10px sans-serif;
line-height: 1.35;
user-select: none;
}
}
section {
@ -107,6 +118,12 @@
pointer-events: auto;
box-sizing: border-box;
transform-origin: 0 0;
&:has(div.annotationContent) {
canvas.annotationContent {
display: none;
}
}
}
:is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a {

View file

@ -22,6 +22,8 @@
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
import { AnnotationLayer } from "pdfjs-lib";
import { PresentationModeState } from "./ui_utils.js";
@ -41,6 +43,7 @@ import { PresentationModeState } from "./ui_utils.js";
* [fieldObjectsPromise]
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap]
* @property {TextAccessibilityManager} [accessibilityManager]
* @property {AnnotationEditorUIManager} [annotationEditorUIManager]
* @property {function} [onAppend]
*/
@ -64,6 +67,7 @@ class AnnotationLayerBuilder {
fieldObjectsPromise = null,
annotationCanvasMap = null,
accessibilityManager = null,
annotationEditorUIManager = null,
onAppend = null,
}) {
this.pdfPage = pdfPage;
@ -77,6 +81,7 @@ class AnnotationLayerBuilder {
this._fieldObjectsPromise = fieldObjectsPromise || Promise.resolve(null);
this._annotationCanvasMap = annotationCanvasMap;
this._accessibilityManager = accessibilityManager;
this._annotationEditorUIManager = annotationEditorUIManager;
this.#onAppend = onAppend;
this.annotationLayer = null;
@ -128,6 +133,7 @@ class AnnotationLayerBuilder {
div,
accessibilityManager: this._accessibilityManager,
annotationCanvasMap: this._annotationCanvasMap,
annotationEditorUIManager: this._annotationEditorUIManager,
page: this.pdfPage,
viewport: viewport.clone({ dontFlip: true }),
});

View file

@ -938,6 +938,7 @@ class PDFPageView {
) {
const {
annotationStorage,
annotationEditorUIManager,
downloadManager,
enableScripting,
fieldObjectsPromise,
@ -958,6 +959,7 @@ class PDFPageView {
fieldObjectsPromise,
annotationCanvasMap: this._annotationCanvasMap,
accessibilityManager: this._accessibilityManager,
annotationEditorUIManager,
onAppend: annotationLayerDiv => {
this.#addLayer(annotationLayerDiv, "annotationLayer");
},