mirror of
https://github.com/mozilla/pdf.js.git
synced 2025-04-19 22:58:07 +02:00
Merge pull request #17852 from calixteman/freetext_paste
[Editor] Correctly handle lines when pasting some text in a freetext
This commit is contained in:
commit
e384df6f16
4 changed files with 319 additions and 44 deletions
|
@ -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 <body> 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) {
|
||||
|
|
|
@ -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": "<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);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue