diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index f8d2bec45eeed..8d63349726632 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -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> | null} [fieldObjects] * @property {Map} [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(); diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 6bbe1ed9e46cd..1e95423d09f15 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -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; diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index d943bb02daafb..8b0a2f6c4126c 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -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. diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index 21d300fffa6ba..66d93030c8bb0 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -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 }; diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index fc7f9f6ece753..b51bca5204c44 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -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 { diff --git a/test/integration/freetext_editor_spec.mjs b/test/integration/freetext_editor_spec.mjs index c7aa0e3c4e939..5bce50fb9b11d 100644 --- a/test/integration/freetext_editor_spec.mjs +++ b/test/integration/freetext_editor_spec.mjs @@ -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(); + }) + ); + }); + }); }); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index e77e3f1157206..5460d43a57af2 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -643,3 +643,4 @@ !bug1889122.pdf !issue17929.pdf !issue12213.pdf +!tracemonkey_freetext.pdf diff --git a/test/pdfs/tracemonkey_freetext.pdf b/test/pdfs/tracemonkey_freetext.pdf new file mode 100755 index 0000000000000..4a9ba675be862 Binary files /dev/null and b/test/pdfs/tracemonkey_freetext.pdf differ diff --git a/web/annotation_layer_builder.css b/web/annotation_layer_builder.css index 20baa7abc95c3..56c71887d8fb1 100644 --- a/web/annotation_layer_builder.css +++ b/web/annotation_layer_builder.css @@ -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 { diff --git a/web/annotation_layer_builder.js b/web/annotation_layer_builder.js index ad013d9b04cc5..c9205ec9e69fd 100644 --- a/web/annotation_layer_builder.js +++ b/web/annotation_layer_builder.js @@ -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} [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 }), }); diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index adcbd6d7846d4..e6ff86bba1ccd 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -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"); },