From 2369e40d2e5262f3794a44de5ce13754a9149227 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Thu, 18 Apr 2024 17:28:22 +0200 Subject: [PATCH] [Editor] Update popup position and contents after a FreeText has been edited --- src/display/annotation_layer.js | 188 +++++++++++++++++----- src/display/editor/freetext.js | 1 + test/integration/freetext_editor_spec.mjs | 79 +++++++++ 3 files changed, 225 insertions(+), 43 deletions(-) diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 5245ecbc7714f..d55386b78fca7 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -162,6 +162,8 @@ class AnnotationElement { #hasBorder = false; + #popupElement = null; + constructor( parameters, { @@ -214,6 +216,8 @@ class AnnotationElement { if (rect) { this.#setRectEdited(rect); } + + this.#popupElement?.popup.updateEdited(params); } resetEdited() { @@ -221,6 +225,7 @@ class AnnotationElement { return; } this.#setRectEdited(this.#updates.rect); + this.#popupElement?.popup.resetEdited(); this.#updates = null; } @@ -610,7 +615,7 @@ class AnnotationElement { const { container, data } = this; container.setAttribute("aria-haspopup", "dialog"); - const popup = new PopupAnnotationElement({ + const popup = (this.#popupElement = new PopupAnnotationElement({ data: { color: data.color, titleObj: data.titleObj, @@ -624,7 +629,7 @@ class AnnotationElement { }, parent: this.parent, elements: [this], - }); + })); this.parent.div.append(popup.render()); } @@ -2055,12 +2060,13 @@ class PopupAnnotationElement extends AnnotationElement { const { data, elements } = parameters; super(parameters, { isRenderable: AnnotationElement._hasPopupData(data) }); this.elements = elements; + this.popup = null; } render() { this.container.classList.add("popupAnnotation"); - const popup = new PopupElement({ + const popup = (this.popup = new PopupElement({ container: this.container, color: this.data.color, titleObj: this.data.titleObj, @@ -2072,7 +2078,7 @@ class PopupAnnotationElement extends AnnotationElement { parent: this.parent, elements: this.elements, open: this.data.open, - }); + })); const elementIds = []; for (const element of this.elements) { @@ -2117,12 +2123,16 @@ class PopupElement { #popup = null; + #position = null; + #rect = null; #richText = null; #titleObj = null; + #updates = null; + #wasVisible = false; constructor({ @@ -2188,12 +2198,6 @@ class PopupElement { return; } - const { - page: { view }, - viewport: { - rawDims: { pageWidth, pageHeight, pageX, pageY }, - }, - } = this.#parent; const popup = (this.#popup = document.createElement("div")); popup.className = "popup"; @@ -2244,52 +2248,74 @@ class PopupElement { header.append(modificationDate); } - const contentsObj = this.#contentsObj; - const richText = this.#richText; - if ( - richText?.str && - (!contentsObj?.str || contentsObj.str === richText.str) - ) { + const html = this.#html; + if (html) { XfaLayer.render({ - xfaHtml: richText.html, + xfaHtml: html, intent: "richText", div: popup, }); popup.lastChild.classList.add("richText", "popupContent"); } else { - const contents = this._formatContents(contentsObj); + const contents = this._formatContents(this.#contentsObj); popup.append(contents); } + this.#container.append(popup); + } - let useParentRect = !!this.#parentRect; - let rect = useParentRect ? this.#parentRect : this.#rect; - for (const element of this.#elements) { - if (!rect || Util.intersect(element.data.rect, rect) !== null) { - rect = element.data.rect; - useParentRect = true; - break; - } + get #html() { + const richText = this.#richText; + const contentsObj = this.#contentsObj; + if ( + richText?.str && + (!contentsObj?.str || contentsObj.str === richText.str) + ) { + return this.#richText.html || null; } + return null; + } - const normalizedRect = Util.normalizeRect([ - rect[0], - view[3] - rect[1] + view[1], - rect[2], - view[3] - rect[3] + view[1], - ]); - - const HORIZONTAL_SPACE_AFTER_ANNOTATION = 5; - const parentWidth = useParentRect - ? rect[2] - rect[0] + HORIZONTAL_SPACE_AFTER_ANNOTATION - : 0; - const popupLeft = normalizedRect[0] + parentWidth; - const popupTop = normalizedRect[1]; + get #fontSize() { + return this.#html?.attributes?.style?.fontSize || 0; + } - const { style } = this.#container; - style.left = `${(100 * (popupLeft - pageX)) / pageWidth}%`; - style.top = `${(100 * (popupTop - pageY)) / pageHeight}%`; + get #fontColor() { + return this.#html?.attributes?.style?.color || null; + } - this.#container.append(popup); + #makePopupContent(text) { + const popupLines = []; + const popupContent = { + str: text, + html: { + name: "div", + attributes: { + dir: "auto", + }, + children: [ + { + name: "p", + children: popupLines, + }, + ], + }, + }; + const lineAttributes = { + style: { + color: this.#fontColor, + fontSize: this.#fontSize + ? `calc(${this.#fontSize}px * var(--scale-factor))` + : "", + }, + }; + for (const line of text.split("\n")) { + popupLines.push({ + name: "span", + value: line, + attributes: lineAttributes, + }); + } + return popupContent; } /** @@ -2325,6 +2351,78 @@ class PopupElement { } } + updateEdited({ rect, popupContent }) { + this.#updates ||= { + contentsObj: this.#contentsObj, + richText: this.#richText, + }; + if (rect) { + this.#position = null; + } + if (popupContent) { + this.#richText = this.#makePopupContent(popupContent); + this.#contentsObj = null; + } + this.#popup?.remove(); + this.#popup = null; + } + + resetEdited() { + if (!this.#updates) { + return; + } + ({ contentsObj: this.#contentsObj, richText: this.#richText } = + this.#updates); + this.#updates = null; + this.#popup?.remove(); + this.#popup = null; + this.#position = null; + } + + #setPosition() { + if (this.#position !== null) { + return; + } + const { + page: { view }, + viewport: { + rawDims: { pageWidth, pageHeight, pageX, pageY }, + }, + } = this.#parent; + + let useParentRect = !!this.#parentRect; + let rect = useParentRect ? this.#parentRect : this.#rect; + for (const element of this.#elements) { + if (!rect || Util.intersect(element.data.rect, rect) !== null) { + rect = element.data.rect; + useParentRect = true; + break; + } + } + + const normalizedRect = Util.normalizeRect([ + rect[0], + view[3] - rect[1] + view[1], + rect[2], + view[3] - rect[3] + view[1], + ]); + + const HORIZONTAL_SPACE_AFTER_ANNOTATION = 5; + const parentWidth = useParentRect + ? rect[2] - rect[0] + HORIZONTAL_SPACE_AFTER_ANNOTATION + : 0; + const popupLeft = normalizedRect[0] + parentWidth; + const popupTop = normalizedRect[1]; + this.#position = [ + (100 * (popupLeft - pageX)) / pageWidth, + (100 * (popupTop - pageY)) / pageHeight, + ]; + + const { style } = this.#container; + style.left = `${this.#position[0]}%`; + style.top = `${this.#position[1]}%`; + } + /** * Toggle the visibility of the popup. */ @@ -2349,6 +2447,7 @@ class PopupElement { this.render(); } if (!this.isVisible) { + this.#setPosition(); this.#container.hidden = false; this.#container.style.zIndex = parseInt(this.#container.style.zIndex) + 1000; @@ -2382,6 +2481,9 @@ class PopupElement { if (!this.#wasVisible) { return; } + if (!this.#popup) { + this.#show(); + } this.#wasVisible = false; this.#container.hidden = false; } diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index 66d93030c8bb0..2280021d8c88c 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -879,6 +879,7 @@ class FreeTextEditor extends AnnotationEditor { const padding = FreeTextEditor._internalPadding * this.parentScale; annotation.updateEdited({ rect: this.getRect(padding, padding), + popupContent: this.#content, }); return content; diff --git a/test/integration/freetext_editor_spec.mjs b/test/integration/freetext_editor_spec.mjs index 2b21ba4bda9c0..36c9409a2a5b0 100644 --- a/test/integration/freetext_editor_spec.mjs +++ b/test/integration/freetext_editor_spec.mjs @@ -1165,6 +1165,85 @@ describe("FreeText Editor", () => { }); }); + describe("FreeText (update existing and popups)", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("freetexts.pdf", "[data-annotation-id='32R']"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must update an existing annotation and show the right popup", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // Show the popup on "Hello World from Firefox" + await page.click(`[data-annotation-id='32R']`); + await page.waitForSelector(`[data-annotation-id='popup_32R']`, { + visible: true, + }); + + await switchToFreeText(page); + await page.waitForSelector(`[data-annotation-id='popup_32R']`, { + visible: false, + }); + + const editorSelector = getEditorSelector(1); + 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" + ); + + // Commit. + await page.keyboard.press("Escape"); + await page.waitForSelector(`${editorSelector} .overlay.enabled`); + + // Disable editing mode. + await page.click("#editorFreeText"); + await page.waitForSelector( + `.annotationEditorLayer:not(.freetextEditing)` + ); + + await page.waitForSelector(`[data-annotation-id='popup_32R']`, { + visible: true, + }); + + const newPopupText = await page.$eval( + "[data-annotation-id='popup_32R'] .popupContent", + el => el.innerText.replaceAll("\xa0", " ") + ); + expect(newPopupText) + .withContext(`In ${browserName}`) + .toEqual("Hello World From Firefox and edited in Firefox"); + }) + ); + }); + }); + describe("FreeText (update existing but not empty ones)", () => { let pages;