Skip to content

Commit

Permalink
[Editor] Correctly handle lines when pasting some text in a freetext
Browse files Browse the repository at this point in the history
  • Loading branch information
calixteman committed Mar 27, 2024
1 parent 3d7ea60 commit 2dbd7ac
Show file tree
Hide file tree
Showing 4 changed files with 319 additions and 44 deletions.
104 changes: 97 additions & 7 deletions src/display/editor/freetext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -44,6 +46,8 @@ class FreeTextEditor extends AnnotationEditor {

#boundEditorDivKeydown = this.editorDivKeydown.bind(this);

#boundEditorDivPaste = this.editorDivPaste.bind(this);

#color;

#content = "";
Expand Down Expand Up @@ -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 */
Expand All @@ -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 <body> when contentEditable is set to
// false, hence we focus the div.
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
163 changes: 163 additions & 0 deletions test/integration/freetext_editor_spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
kbSelectAll,
kbUndo,
loadAndWait,
pasteFromClipboard,
scrollIntoView,
waitForAnnotationEditorLayer,
waitForEvent,
Expand Down Expand Up @@ -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": "<b>Bold Foo</b>",
"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": "<b>Bold Bar</b><br><b>Oof</b>",
"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": "<b>basic html</b>",
},
`${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": "<b>html</b>",
"text/plain": fooBar,
},
`${editorSelector} .internal`
);

await waitForTextChange("", editorSelector);
text = await getText(editorSelector);
expect(text).withContext(`In ${browserName}`).toEqual(fooBar);
})
);
});
});
});
44 changes: 7 additions & 37 deletions test/integration/stamp_editor_spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
kbSelectAll,
kbUndo,
loadAndWait,
pasteFromClipboard,
scrollIntoView,
serializeBitmapDimensions,
waitForAnnotationEditorLayer,
Expand Down Expand Up @@ -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));
};

Expand Down
Loading

0 comments on commit 2dbd7ac

Please sign in to comment.