Skip to content

Commit 1911764

Browse files
calixtemanpull[bot]
authored andcommitted
[Editor] Correctly handle lines when pasting some text in a freetext
1 parent c96318b commit 1911764

File tree

4 files changed

+319
-44
lines changed

4 files changed

+319
-44
lines changed

src/display/editor/freetext.js

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import {
3232
import { AnnotationEditor } from "./editor.js";
3333
import { FreeTextAnnotationElement } from "../annotation_layer.js";
3434

35+
const EOL_PATTERN = /\r\n?|\n/g;
36+
3537
/**
3638
* Basic text editor in order to create a FreeTex annotation.
3739
*/
@@ -44,6 +46,8 @@ class FreeTextEditor extends AnnotationEditor {
4446

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

49+
#boundEditorDivPaste = this.editorDivPaste.bind(this);
50+
4751
#color;
4852

4953
#content = "";
@@ -307,6 +311,7 @@ class FreeTextEditor extends AnnotationEditor {
307311
this.editorDiv.addEventListener("focus", this.#boundEditorDivFocus);
308312
this.editorDiv.addEventListener("blur", this.#boundEditorDivBlur);
309313
this.editorDiv.addEventListener("input", this.#boundEditorDivInput);
314+
this.editorDiv.addEventListener("paste", this.#boundEditorDivPaste);
310315
}
311316

312317
/** @inheritdoc */
@@ -325,6 +330,7 @@ class FreeTextEditor extends AnnotationEditor {
325330
this.editorDiv.removeEventListener("focus", this.#boundEditorDivFocus);
326331
this.editorDiv.removeEventListener("blur", this.#boundEditorDivBlur);
327332
this.editorDiv.removeEventListener("input", this.#boundEditorDivInput);
333+
this.editorDiv.removeEventListener("paste", this.#boundEditorDivPaste);
328334

329335
// On Chrome, the focus is given to <body> when contentEditable is set to
330336
// false, hence we focus the div.
@@ -386,11 +392,8 @@ class FreeTextEditor extends AnnotationEditor {
386392
// We don't use innerText because there are some bugs with line breaks.
387393
const buffer = [];
388394
this.editorDiv.normalize();
389-
const EOL_PATTERN = /\r\n?|\n/g;
390395
for (const child of this.editorDiv.childNodes) {
391-
const content =
392-
child.nodeType === Node.TEXT_NODE ? child.nodeValue : child.innerText;
393-
buffer.push(content.replaceAll(EOL_PATTERN, ""));
396+
buffer.push(FreeTextEditor.#getNodeContent(child));
394397
}
395398
return buffer.join("\n");
396399
}
@@ -558,9 +561,6 @@ class FreeTextEditor extends AnnotationEditor {
558561
this.overlayDiv.classList.add("overlay", "enabled");
559562
this.div.append(this.overlayDiv);
560563

561-
// TODO: implement paste callback.
562-
// The goal is to sanitize and have something suitable for this
563-
// editor.
564564
bindEvents(this, this.div, ["dblclick", "keydown"]);
565565

566566
if (this.width) {
@@ -632,6 +632,96 @@ class FreeTextEditor extends AnnotationEditor {
632632
return this.div;
633633
}
634634

635+
static #getNodeContent(node) {
636+
return (
637+
node.nodeType === Node.TEXT_NODE ? node.nodeValue : node.innerText
638+
).replaceAll(EOL_PATTERN, "");
639+
}
640+
641+
editorDivPaste(event) {
642+
const clipboardData = event.clipboardData || window.clipboardData;
643+
const { types } = clipboardData;
644+
if (types.length === 1 && types[0] === "text/plain") {
645+
return;
646+
}
647+
648+
event.preventDefault();
649+
const paste = FreeTextEditor.#deserializeContent(
650+
clipboardData.getData("text") || ""
651+
).replaceAll(EOL_PATTERN, "\n");
652+
if (!paste) {
653+
return;
654+
}
655+
const selection = window.getSelection();
656+
if (!selection.rangeCount) {
657+
return;
658+
}
659+
this.editorDiv.normalize();
660+
selection.deleteFromDocument();
661+
const range = selection.getRangeAt(0);
662+
if (!paste.includes("\n")) {
663+
range.insertNode(document.createTextNode(paste));
664+
this.editorDiv.normalize();
665+
selection.collapseToStart();
666+
return;
667+
}
668+
669+
// Collect the text before and after the caret.
670+
const { startContainer, startOffset } = range;
671+
const bufferBefore = [];
672+
const bufferAfter = [];
673+
if (startContainer.nodeType === Node.TEXT_NODE) {
674+
const parent = startContainer.parentElement;
675+
bufferAfter.push(
676+
startContainer.nodeValue.slice(startOffset).replaceAll(EOL_PATTERN, "")
677+
);
678+
if (parent !== this.editorDiv) {
679+
let buffer = bufferBefore;
680+
for (const child of this.editorDiv.childNodes) {
681+
if (child === parent) {
682+
buffer = bufferAfter;
683+
continue;
684+
}
685+
buffer.push(FreeTextEditor.#getNodeContent(child));
686+
}
687+
}
688+
bufferBefore.push(
689+
startContainer.nodeValue
690+
.slice(0, startOffset)
691+
.replaceAll(EOL_PATTERN, "")
692+
);
693+
} else if (startContainer === this.editorDiv) {
694+
let buffer = bufferBefore;
695+
let i = 0;
696+
for (const child of this.editorDiv.childNodes) {
697+
if (i++ === startOffset) {
698+
buffer = bufferAfter;
699+
}
700+
buffer.push(FreeTextEditor.#getNodeContent(child));
701+
}
702+
}
703+
this.#content = `${bufferBefore.join("\n")}${paste}${bufferAfter.join("\n")}`;
704+
this.#setContent();
705+
706+
// Set the caret at the right position.
707+
const newRange = new Range();
708+
let beforeLength = bufferBefore.reduce((acc, line) => acc + line.length, 0);
709+
for (const { firstChild } of this.editorDiv.childNodes) {
710+
// Each child is either a div with a text node or a br element.
711+
if (firstChild.nodeType === Node.TEXT_NODE) {
712+
const length = firstChild.nodeValue.length;
713+
if (beforeLength <= length) {
714+
newRange.setStart(firstChild, beforeLength);
715+
newRange.setEnd(firstChild, beforeLength);
716+
break;
717+
}
718+
beforeLength -= length;
719+
}
720+
}
721+
selection.removeAllRanges();
722+
selection.addRange(newRange);
723+
}
724+
635725
#setContent() {
636726
this.editorDiv.replaceChildren();
637727
if (!this.#content) {

test/integration/freetext_editor_spec.mjs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
kbSelectAll,
4040
kbUndo,
4141
loadAndWait,
42+
pasteFromClipboard,
4243
scrollIntoView,
4344
waitForAnnotationEditorLayer,
4445
waitForEvent,
@@ -3546,4 +3547,166 @@ describe("FreeText Editor", () => {
35463547
);
35473548
});
35483549
});
3550+
3551+
describe("Paste some html", () => {
3552+
let pages;
3553+
3554+
beforeAll(async () => {
3555+
pages = await loadAndWait("empty.pdf", ".annotationEditorLayer");
3556+
});
3557+
3558+
afterAll(async () => {
3559+
await closePages(pages);
3560+
});
3561+
3562+
it("must check that pasting html just keep the text", async () => {
3563+
await Promise.all(
3564+
pages.map(async ([browserName, page]) => {
3565+
await switchToFreeText(page);
3566+
3567+
const rect = await page.$eval(".annotationEditorLayer", el => {
3568+
const { x, y } = el.getBoundingClientRect();
3569+
return { x, y };
3570+
});
3571+
3572+
let editorSelector = getEditorSelector(0);
3573+
const data = "Hello PDF.js World !!";
3574+
await page.mouse.click(rect.x + 100, rect.y + 100);
3575+
await page.waitForSelector(editorSelector, {
3576+
visible: true,
3577+
});
3578+
await page.type(`${editorSelector} .internal`, data);
3579+
const editorRect = await page.$eval(editorSelector, el => {
3580+
const { x, y, width, height } = el.getBoundingClientRect();
3581+
return { x, y, width, height };
3582+
});
3583+
3584+
// Commit.
3585+
await page.keyboard.press("Escape");
3586+
await page.waitForSelector(`${editorSelector} .overlay.enabled`);
3587+
3588+
const waitForTextChange = (previous, edSelector) =>
3589+
page.waitForFunction(
3590+
(prev, sel) => document.querySelector(sel).innerText !== prev,
3591+
{},
3592+
previous,
3593+
`${edSelector} .internal`
3594+
);
3595+
const getText = edSelector =>
3596+
page.$eval(`${edSelector} .internal`, el => el.innerText.trimEnd());
3597+
3598+
await page.mouse.click(
3599+
editorRect.x + editorRect.width / 2,
3600+
editorRect.y + editorRect.height / 2,
3601+
{ count: 2 }
3602+
);
3603+
await page.waitForSelector(
3604+
`${editorSelector} .overlay:not(.enabled)`
3605+
);
3606+
3607+
const select = position =>
3608+
page.evaluate(
3609+
(sel, pos) => {
3610+
const el = document.querySelector(sel);
3611+
document.getSelection().setPosition(el.firstChild, pos);
3612+
},
3613+
`${editorSelector} .internal`,
3614+
position
3615+
);
3616+
3617+
await select(0);
3618+
await pasteFromClipboard(
3619+
page,
3620+
{
3621+
"text/html": "<b>Bold Foo</b>",
3622+
"text/plain": "Foo",
3623+
},
3624+
`${editorSelector} .internal`
3625+
);
3626+
3627+
let lastText = data;
3628+
3629+
await waitForTextChange(lastText, editorSelector);
3630+
let text = await getText(editorSelector);
3631+
lastText = `Foo${data}`;
3632+
expect(text).withContext(`In ${browserName}`).toEqual(lastText);
3633+
3634+
await select(3);
3635+
await pasteFromClipboard(
3636+
page,
3637+
{
3638+
"text/html": "<b>Bold Bar</b><br><b>Oof</b>",
3639+
"text/plain": "Bar\nOof",
3640+
},
3641+
`${editorSelector} .internal`
3642+
);
3643+
3644+
await waitForTextChange(lastText, editorSelector);
3645+
text = await getText(editorSelector);
3646+
lastText = `FooBar\nOof${data}`;
3647+
expect(text).withContext(`In ${browserName}`).toEqual(lastText);
3648+
3649+
await select(0);
3650+
await pasteFromClipboard(
3651+
page,
3652+
{
3653+
"text/html": "<b>basic html</b>",
3654+
},
3655+
`${editorSelector} .internal`
3656+
);
3657+
3658+
// Nothing should change, so it's hard to wait on something.
3659+
await waitForTimeout(100);
3660+
3661+
text = await getText(editorSelector);
3662+
expect(text).withContext(`In ${browserName}`).toEqual(lastText);
3663+
3664+
const getHTML = () =>
3665+
page.$eval(`${editorSelector} .internal`, el => el.innerHTML);
3666+
const prevHTML = await getHTML();
3667+
3668+
// Try to paste an image.
3669+
await pasteFromClipboard(
3670+
page,
3671+
{
3672+
"image/png":
3673+
// 1x1 transparent png.
3674+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
3675+
},
3676+
`${editorSelector} .internal`
3677+
);
3678+
3679+
// Nothing should change, so it's hard to wait on something.
3680+
await waitForTimeout(100);
3681+
3682+
const html = await getHTML();
3683+
expect(html).withContext(`In ${browserName}`).toEqual(prevHTML);
3684+
3685+
// Commit.
3686+
await page.keyboard.press("Escape");
3687+
await page.waitForSelector(`${editorSelector} .overlay.enabled`);
3688+
3689+
editorSelector = getEditorSelector(1);
3690+
await page.mouse.click(rect.x + 200, rect.y + 200);
3691+
await page.waitForSelector(editorSelector, {
3692+
visible: true,
3693+
});
3694+
3695+
const fooBar = "Foo\nBar\nOof";
3696+
await pasteFromClipboard(
3697+
page,
3698+
{
3699+
"text/html": "<b>html</b>",
3700+
"text/plain": fooBar,
3701+
},
3702+
`${editorSelector} .internal`
3703+
);
3704+
3705+
await waitForTextChange("", editorSelector);
3706+
text = await getText(editorSelector);
3707+
expect(text).withContext(`In ${browserName}`).toEqual(fooBar);
3708+
})
3709+
);
3710+
});
3711+
});
35493712
});

test/integration/stamp_editor_spec.mjs

Lines changed: 7 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
kbSelectAll,
2727
kbUndo,
2828
loadAndWait,
29+
pasteFromClipboard,
2930
scrollIntoView,
3031
serializeBitmapDimensions,
3132
waitForAnnotationEditorLayer,
@@ -72,43 +73,12 @@ const copyImage = async (page, imagePath, number) => {
7273
const data = fs
7374
.readFileSync(path.join(__dirname, imagePath))
7475
.toString("base64");
75-
await page.evaluate(async imageData => {
76-
const resp = await fetch(`data:image/png;base64,${imageData}`);
77-
const blob = await resp.blob();
78-
79-
await navigator.clipboard.write([
80-
new ClipboardItem({
81-
[blob.type]: blob,
82-
}),
83-
]);
84-
}, data);
85-
86-
let hasPasteEvent = false;
87-
while (!hasPasteEvent) {
88-
// We retry to paste if nothing has been pasted before 500ms.
89-
const handle = await page.evaluateHandle(() => {
90-
let callback = null;
91-
return [
92-
Promise.race([
93-
new Promise(resolve => {
94-
callback = e => resolve(e.clipboardData.items.length !== 0);
95-
document.addEventListener("paste", callback, {
96-
once: true,
97-
});
98-
}),
99-
new Promise(resolve => {
100-
setTimeout(() => {
101-
document.removeEventListener("paste", callback);
102-
resolve(false);
103-
}, 500);
104-
}),
105-
]),
106-
];
107-
});
108-
await kbPaste(page);
109-
hasPasteEvent = await awaitPromise(handle);
110-
}
111-
76+
await pasteFromClipboard(
77+
page,
78+
{ "image/png": `data:image/png;base64,${data}` },
79+
"",
80+
500
81+
);
11282
await waitForImage(page, getEditorSelector(number));
11383
};
11484

0 commit comments

Comments
 (0)