From a47cd370affe15ccc713cc6074b940a706b849e0 Mon Sep 17 00:00:00 2001 From: avdoseferovic Date: Sat, 18 Jan 2025 23:29:31 +0100 Subject: [PATCH] Add text wrapping to FreeText editors (issue 18191) Text wrapping is ensured by preventing the FreeText editor from going out of bounds. --- src/display/editor/freetext.js | 75 ++++++++++++++++++++++- test/integration/freetext_editor_spec.mjs | 52 ++++++++++++++++ web/annotation_editor_layer_builder.css | 3 +- 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index e17b5c3486a04..cd852c3934879 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -409,12 +409,56 @@ class FreeTextEditor extends AnnotationEditor { // text and one for the br element). continue; } - buffer.push(FreeTextEditor.#getNodeContent(child)); + if (child.nodeType === Node.TEXT_NODE && child.textContent.trim()) { + const visualLines = this.#detectVisualLineBreaks(child); + buffer.push(...visualLines); + } else { + buffer.push(FreeTextEditor.#getNodeContent(child)); + } + prevChild = child; } return buffer.join("\n"); } + /** + * Detects line breaks within a text node. + * Algorithm is based on this gist: + * https://gist.github.com/bennadel/033e0158f47bff9e066016f99567ebba + * @param {Text} textNode + * @returns {Array} + */ + #detectVisualLineBreaks(textNode) { + const range = document.createRange(); + const lines = []; + let lineCharacters = []; + + const text = textNode.textContent.trim().replaceAll(/\s+/g, " "); + + if (!text) { + return []; + } + + for (let i = 0; i < text.length; i++) { + range.setStart(textNode, 0); + range.setEnd(textNode, i + 1); + + const lineIndex = range.getClientRects().length - 1; + + if (!lines[lineIndex]) { + lines.push((lineCharacters = [])); + } + + lineCharacters.push(text.charAt(i)); + } + + range.detach(); + + return lines + .map(characters => characters.join("").trim()) + .filter(line => line.length > 0); + } + #setEditorDimensions() { const [parentWidth, parentHeight] = this.parentDimensions; @@ -642,6 +686,9 @@ class FreeTextEditor extends AnnotationEditor { this.div.setAttribute("annotation-id", this.annotationElementId); } + const [, pageHeight] = this.pageDimensions; + this.setMaxWidth(this.div, this.rotation, pageHeight); + return this.div; } @@ -867,6 +914,32 @@ class FreeTextEditor extends AnnotationEditor { ); } + setMaxWidth(div, rotation, pageHeight) { + const style = div.style; + const leftPercent = parseFloat(style.left) || 0; + const topPercent = parseFloat(style.top) || 0; + const fontSize = `calc(${this.#fontSize}px * var(--total-scale-factor))`; + + switch (rotation) { + case 0: { + style.maxWidth = `calc(100% - ${leftPercent}% - ${fontSize})`; + break; + } + case 90: { + style.maxWidth = `calc(var(--total-scale-factor) * ${pageHeight}px * ${topPercent} / 100 - ${fontSize})`; + break; + } + case 180: { + style.maxWidth = `calc(${leftPercent}% - ${fontSize})`; + break; + } + case 270: { + style.maxWidth = `calc(var(--total-scale-factor) * ${pageHeight}px * (100 - ${topPercent}) / 100 - ${fontSize})`; + break; + } + } + } + /** @inheritdoc */ renderAnnotationElement(annotation) { const content = super.renderAnnotationElement(annotation); diff --git a/test/integration/freetext_editor_spec.mjs b/test/integration/freetext_editor_spec.mjs index 84b886b70b740..478c6c37b69b8 100644 --- a/test/integration/freetext_editor_spec.mjs +++ b/test/integration/freetext_editor_spec.mjs @@ -3267,4 +3267,56 @@ describe("FreeText Editor", () => { ); }); }); + + describe("FreeText text wrapping", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("empty.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must wrap long text into multiple lines", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await switchToFreeText(page); + + const rect = await getRect(page, ".annotationEditorLayer"); + const editorSelector = getEditorSelector(0); + + await page.mouse.click(rect.x + 100, rect.y + 100); + await page.waitForSelector(editorSelector, { visible: true }); + + const longText = + "This is a very long text string that should definitely need to wrap onto multiple lines of text so that it can be displayed properly within the FreeText annotation editor."; + await page.type(`${editorSelector} .internal`, longText); + + const hasMultipleLines = await page.evaluate(selector => { + const el = document.querySelector(`${selector} .internal`); + const style = window.getComputedStyle(el); + const lineHeight = parseFloat(style.lineHeight); + const totalHeight = el.getBoundingClientRect().height; + return totalHeight > lineHeight; + }, editorSelector); + + expect(hasMultipleLines).withContext(`In ${browserName}`).toBeTrue(); + + await commit(page); + + const maintainsWrapping = await page.evaluate(selector => { + const el = document.querySelector(`${selector} .internal`); + const style = window.getComputedStyle(el); + const lineHeight = parseFloat(style.lineHeight); + const totalHeight = el.getBoundingClientRect().height; + return totalHeight > lineHeight * 1.5; + }, editorSelector); + + expect(maintainsWrapping).withContext(`In ${browserName}`).toBeTrue(); + }) + ); + }); + }); }); diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index d6a5e6827f4aa..de78483639f12 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -523,7 +523,8 @@ border: none; inset: 0; overflow: visible; - white-space: nowrap; + white-space: pre-wrap; + word-wrap: break-word; font: 10px sans-serif; line-height: var(--freetext-line-height); user-select: none;