From 19117641b4e37c0bcc990aaf97e5351189689086 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 26 Mar 2024 21:17:36 +0100 Subject: [PATCH] [Editor] Correctly handle lines when pasting some text in a freetext --- src/display/editor/freetext.js | 104 +++++++++++++- test/integration/freetext_editor_spec.mjs | 163 ++++++++++++++++++++++ test/integration/stamp_editor_spec.mjs | 44 +----- test/integration/test_utils.mjs | 52 +++++++ 4 files changed, 319 insertions(+), 44 deletions(-) diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index 70544527b5878e..96610fdcf9aae1 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 ffa64f01fbd2f0..4aa5d9e76a0a39 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
Oof", + "text/plain": "Bar\nOof", + }, + `${editorSelector} .internal` + ); + + await waitForTextChange(lastText, editorSelector); + text = await getText(editorSelector); + lastText = `FooBar\nOof${data}`; + expect(text).withContext(`In ${browserName}`).toEqual(lastText); + + await select(0); + await pasteFromClipboard( + page, + { + "text/html": "basic html", + }, + `${editorSelector} .internal` + ); + + // Nothing should change, so it's hard to wait on something. + await waitForTimeout(100); + + text = await getText(editorSelector); + expect(text).withContext(`In ${browserName}`).toEqual(lastText); + + const getHTML = () => + page.$eval(`${editorSelector} .internal`, el => el.innerHTML); + const prevHTML = await getHTML(); + + // Try to paste an image. + await pasteFromClipboard( + page, + { + "image/png": + // 1x1 transparent png. + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + }, + `${editorSelector} .internal` + ); + + // Nothing should change, so it's hard to wait on something. + await waitForTimeout(100); + + const html = await getHTML(); + expect(html).withContext(`In ${browserName}`).toEqual(prevHTML); + + // Commit. + await page.keyboard.press("Escape"); + await page.waitForSelector(`${editorSelector} .overlay.enabled`); + + editorSelector = getEditorSelector(1); + await page.mouse.click(rect.x + 200, rect.y + 200); + await page.waitForSelector(editorSelector, { + visible: true, + }); + + const fooBar = "Foo\nBar\nOof"; + await pasteFromClipboard( + page, + { + "text/html": "html", + "text/plain": fooBar, + }, + `${editorSelector} .internal` + ); + + await waitForTextChange("", editorSelector); + text = await getText(editorSelector); + expect(text).withContext(`In ${browserName}`).toEqual(fooBar); + }) + ); + }); + }); }); diff --git a/test/integration/stamp_editor_spec.mjs b/test/integration/stamp_editor_spec.mjs index 015ad7892b9123..0819550215d2ec 100644 --- a/test/integration/stamp_editor_spec.mjs +++ b/test/integration/stamp_editor_spec.mjs @@ -26,6 +26,7 @@ import { kbSelectAll, kbUndo, loadAndWait, + pasteFromClipboard, scrollIntoView, serializeBitmapDimensions, waitForAnnotationEditorLayer, @@ -72,43 +73,12 @@ const copyImage = async (page, imagePath, number) => { const data = fs .readFileSync(path.join(__dirname, imagePath)) .toString("base64"); - await page.evaluate(async imageData => { - const resp = await fetch(`data:image/png;base64,${imageData}`); - const blob = await resp.blob(); - - await navigator.clipboard.write([ - new ClipboardItem({ - [blob.type]: blob, - }), - ]); - }, data); - - let hasPasteEvent = false; - while (!hasPasteEvent) { - // We retry to paste if nothing has been pasted before 500ms. - const handle = await page.evaluateHandle(() => { - let callback = null; - return [ - Promise.race([ - new Promise(resolve => { - callback = e => resolve(e.clipboardData.items.length !== 0); - document.addEventListener("paste", callback, { - once: true, - }); - }), - new Promise(resolve => { - setTimeout(() => { - document.removeEventListener("paste", callback); - resolve(false); - }, 500); - }), - ]), - ]; - }); - await kbPaste(page); - hasPasteEvent = await awaitPromise(handle); - } - + await pasteFromClipboard( + page, + { "image/png": `data:image/png;base64,${data}` }, + "", + 500 + ); await waitForImage(page, getEditorSelector(number)); }; diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index fdb97d47969711..34718bb428632f 100644 --- a/test/integration/test_utils.mjs +++ b/test/integration/test_utils.mjs @@ -206,6 +206,57 @@ async function mockClipboard(pages) { ); } +async function pasteFromClipboard(page, data, selector, timeout = 100) { + await page.evaluate(async dat => { + const items = Object.create(null); + for (const [type, value] of Object.entries(dat)) { + if (value.startsWith("data:")) { + const resp = await fetch(value); + items[type] = await resp.blob(); + } else { + items[type] = new Blob([value], { type }); + } + } + await navigator.clipboard.write([new ClipboardItem(items)]); + }, data); + + let hasPasteEvent = false; + while (!hasPasteEvent) { + // We retry to paste if nothing has been pasted before the timeout. + const handle = await page.evaluateHandle( + (sel, timeOut) => { + let callback = null; + return [ + Promise.race([ + new Promise(resolve => { + callback = e => resolve(e.clipboardData.items.length !== 0); + (sel ? document.querySelector(sel) : document).addEventListener( + "paste", + callback, + { + once: true, + } + ); + }), + new Promise(resolve => { + setTimeout(() => { + document + .querySelector(sel) + .removeEventListener("paste", callback); + resolve(false); + }, timeOut); + }), + ]), + ]; + }, + selector, + timeout + ); + await kbPaste(page); + hasPasteEvent = await awaitPromise(handle); + } +} + async function getSerialized(page, filter = undefined) { const values = await page.evaluate(() => { const { map } = @@ -526,6 +577,7 @@ export { kbUndo, loadAndWait, mockClipboard, + pasteFromClipboard, scrollIntoView, serializeBitmapDimensions, waitForAnnotationEditorLayer,