diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index 70544527b5878..96610fdcf9aae 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -32,6 +32,8 @@ import { import { AnnotationEditor } from "./editor.js"; import { FreeTextAnnotationElement } from "../annotation_layer.js"; +const EOL_PATTERN = /\r\n?|\n/g; + /** * Basic text editor in order to create a FreeTex annotation. */ @@ -44,6 +46,8 @@ class FreeTextEditor extends AnnotationEditor { #boundEditorDivKeydown = this.editorDivKeydown.bind(this); + #boundEditorDivPaste = this.editorDivPaste.bind(this); + #color; #content = ""; @@ -307,6 +311,7 @@ class FreeTextEditor extends AnnotationEditor { this.editorDiv.addEventListener("focus", this.#boundEditorDivFocus); this.editorDiv.addEventListener("blur", this.#boundEditorDivBlur); this.editorDiv.addEventListener("input", this.#boundEditorDivInput); + this.editorDiv.addEventListener("paste", this.#boundEditorDivPaste); } /** @inheritdoc */ @@ -325,6 +330,7 @@ class FreeTextEditor extends AnnotationEditor { this.editorDiv.removeEventListener("focus", this.#boundEditorDivFocus); this.editorDiv.removeEventListener("blur", this.#boundEditorDivBlur); this.editorDiv.removeEventListener("input", this.#boundEditorDivInput); + this.editorDiv.removeEventListener("paste", this.#boundEditorDivPaste); // On Chrome, the focus is given to
when contentEditable is set to // false, hence we focus the div. @@ -386,11 +392,8 @@ class FreeTextEditor extends AnnotationEditor { // We don't use innerText because there are some bugs with line breaks. const buffer = []; this.editorDiv.normalize(); - const EOL_PATTERN = /\r\n?|\n/g; for (const child of this.editorDiv.childNodes) { - const content = - child.nodeType === Node.TEXT_NODE ? child.nodeValue : child.innerText; - buffer.push(content.replaceAll(EOL_PATTERN, "")); + buffer.push(FreeTextEditor.#getNodeContent(child)); } return buffer.join("\n"); } @@ -558,9 +561,6 @@ class FreeTextEditor extends AnnotationEditor { this.overlayDiv.classList.add("overlay", "enabled"); this.div.append(this.overlayDiv); - // TODO: implement paste callback. - // The goal is to sanitize and have something suitable for this - // editor. bindEvents(this, this.div, ["dblclick", "keydown"]); if (this.width) { @@ -632,6 +632,96 @@ class FreeTextEditor extends AnnotationEditor { return this.div; } + static #getNodeContent(node) { + return ( + node.nodeType === Node.TEXT_NODE ? node.nodeValue : node.innerText + ).replaceAll(EOL_PATTERN, ""); + } + + editorDivPaste(event) { + const clipboardData = event.clipboardData || window.clipboardData; + const { types } = clipboardData; + if (types.length === 1 && types[0] === "text/plain") { + return; + } + + event.preventDefault(); + const paste = FreeTextEditor.#deserializeContent( + clipboardData.getData("text") || "" + ).replaceAll(EOL_PATTERN, "\n"); + if (!paste) { + return; + } + const selection = window.getSelection(); + if (!selection.rangeCount) { + return; + } + this.editorDiv.normalize(); + selection.deleteFromDocument(); + const range = selection.getRangeAt(0); + if (!paste.includes("\n")) { + range.insertNode(document.createTextNode(paste)); + this.editorDiv.normalize(); + selection.collapseToStart(); + return; + } + + // Collect the text before and after the caret. + const { startContainer, startOffset } = range; + const bufferBefore = []; + const bufferAfter = []; + if (startContainer.nodeType === Node.TEXT_NODE) { + const parent = startContainer.parentElement; + bufferAfter.push( + startContainer.nodeValue.slice(startOffset).replaceAll(EOL_PATTERN, "") + ); + if (parent !== this.editorDiv) { + let buffer = bufferBefore; + for (const child of this.editorDiv.childNodes) { + if (child === parent) { + buffer = bufferAfter; + continue; + } + buffer.push(FreeTextEditor.#getNodeContent(child)); + } + } + bufferBefore.push( + startContainer.nodeValue + .slice(0, startOffset) + .replaceAll(EOL_PATTERN, "") + ); + } else if (startContainer === this.editorDiv) { + let buffer = bufferBefore; + let i = 0; + for (const child of this.editorDiv.childNodes) { + if (i++ === startOffset) { + buffer = bufferAfter; + } + buffer.push(FreeTextEditor.#getNodeContent(child)); + } + } + this.#content = `${bufferBefore.join("\n")}${paste}${bufferAfter.join("\n")}`; + this.#setContent(); + + // Set the caret at the right position. + const newRange = new Range(); + let beforeLength = bufferBefore.reduce((acc, line) => acc + line.length, 0); + for (const { firstChild } of this.editorDiv.childNodes) { + // Each child is either a div with a text node or a br element. + if (firstChild.nodeType === Node.TEXT_NODE) { + const length = firstChild.nodeValue.length; + if (beforeLength <= length) { + newRange.setStart(firstChild, beforeLength); + newRange.setEnd(firstChild, beforeLength); + break; + } + beforeLength -= length; + } + } + selection.removeAllRanges(); + selection.addRange(newRange); + } + #setContent() { this.editorDiv.replaceChildren(); if (!this.#content) { diff --git a/test/integration/freetext_editor_spec.mjs b/test/integration/freetext_editor_spec.mjs index ffa64f01fbd2f..4aa5d9e76a0a3 100644 --- a/test/integration/freetext_editor_spec.mjs +++ b/test/integration/freetext_editor_spec.mjs @@ -39,6 +39,7 @@ import { kbSelectAll, kbUndo, loadAndWait, + pasteFromClipboard, scrollIntoView, waitForAnnotationEditorLayer, waitForEvent, @@ -3546,4 +3547,166 @@ describe("FreeText Editor", () => { ); }); }); + + describe("Paste some html", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("empty.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must check that pasting html just keep the text", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await switchToFreeText(page); + + const rect = await page.$eval(".annotationEditorLayer", el => { + const { x, y } = el.getBoundingClientRect(); + return { x, y }; + }); + + let editorSelector = getEditorSelector(0); + const data = "Hello PDF.js World !!"; + await page.mouse.click(rect.x + 100, rect.y + 100); + await page.waitForSelector(editorSelector, { + visible: true, + }); + await page.type(`${editorSelector} .internal`, data); + const editorRect = await page.$eval(editorSelector, el => { + const { x, y, width, height } = el.getBoundingClientRect(); + return { x, y, width, height }; + }); + + // Commit. + await page.keyboard.press("Escape"); + await page.waitForSelector(`${editorSelector} .overlay.enabled`); + + const waitForTextChange = (previous, edSelector) => + page.waitForFunction( + (prev, sel) => document.querySelector(sel).innerText !== prev, + {}, + previous, + `${edSelector} .internal` + ); + const getText = edSelector => + page.$eval(`${edSelector} .internal`, el => el.innerText.trimEnd()); + + await page.mouse.click( + editorRect.x + editorRect.width / 2, + editorRect.y + editorRect.height / 2, + { count: 2 } + ); + await page.waitForSelector( + `${editorSelector} .overlay:not(.enabled)` + ); + + const select = position => + page.evaluate( + (sel, pos) => { + const el = document.querySelector(sel); + document.getSelection().setPosition(el.firstChild, pos); + }, + `${editorSelector} .internal`, + position + ); + + await select(0); + await pasteFromClipboard( + page, + { + "text/html": "Bold Foo", + "text/plain": "Foo", + }, + `${editorSelector} .internal` + ); + + let lastText = data; + + await waitForTextChange(lastText, editorSelector); + let text = await getText(editorSelector); + lastText = `Foo${data}`; + expect(text).withContext(`In ${browserName}`).toEqual(lastText); + + await select(3); + await pasteFromClipboard( + page, + { + "text/html": "Bold Bar