Skip to content

Commit 3af8fbe

Browse files
fix: preserve multi-line paste in code blocks (#203)
Co-authored-by: Ona <no-reply@ona.com>
1 parent 319f18a commit 3af8fbe

2 files changed

Lines changed: 172 additions & 2 deletions

File tree

e2e/editor-code-paste.spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { test, expect } from "./fixtures/auth";
2+
import { navigateToEditorPage } from "./fixtures/editor-helpers";
3+
4+
test.describe("Code block paste", () => {
5+
test.beforeEach(async ({ authenticatedPage: page }) => {
6+
await navigateToEditorPage(page);
7+
});
8+
9+
test("pasting multi-line text into a code block preserves all lines", async ({
10+
authenticatedPage: page,
11+
}) => {
12+
const editor = page.locator('[contenteditable="true"]');
13+
await expect(editor).toBeVisible({ timeout: 10_000 });
14+
15+
// Insert a code block via slash command
16+
await editor.click();
17+
await page.keyboard.press("End");
18+
await page.keyboard.press("Enter");
19+
await page.keyboard.type("/code");
20+
21+
const codeOption = page.locator('[role="option"]', {
22+
hasText: /code block/i,
23+
});
24+
await expect(codeOption).toBeVisible({ timeout: 3_000 });
25+
await codeOption.click();
26+
27+
// Wait for the code block to appear
28+
const codeBlock = editor.locator("code");
29+
await expect(codeBlock).toBeVisible({ timeout: 3_000 });
30+
31+
// Focus inside the code block
32+
await codeBlock.click();
33+
34+
// Write multi-line text to the clipboard and paste it
35+
const multiLineCode = "const a = 1;\nconst b = 2;\nconst c = 3;";
36+
37+
// Grant clipboard permissions and write to clipboard
38+
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
39+
await page.evaluate(async (text) => {
40+
await navigator.clipboard.writeText(text);
41+
}, multiLineCode);
42+
43+
// Paste using keyboard shortcut
44+
const modifier = process.platform === "darwin" ? "Meta" : "Control";
45+
await page.keyboard.press(`${modifier}+v`);
46+
47+
// Verify all three lines are present inside the code block
48+
await expect(codeBlock).toContainText("const a = 1;", { timeout: 3_000 });
49+
await expect(codeBlock).toContainText("const b = 2;");
50+
await expect(codeBlock).toContainText("const c = 3;");
51+
52+
// Verify line breaks exist between lines. Lexical renders LineBreakNodes as
53+
// <br> elements, so innerText (which respects <br>) should contain newlines.
54+
const codeText = await codeBlock.evaluate(
55+
(el) => (el as HTMLElement).innerText
56+
);
57+
const lines = codeText.split("\n").filter((l: string) => l.trim() !== "");
58+
expect(lines.length).toBeGreaterThanOrEqual(3);
59+
});
60+
61+
test("pasting single-line text into a code block works normally", async ({
62+
authenticatedPage: page,
63+
}) => {
64+
const editor = page.locator('[contenteditable="true"]');
65+
await expect(editor).toBeVisible({ timeout: 10_000 });
66+
67+
// Insert a code block via slash command
68+
await editor.click();
69+
await page.keyboard.press("End");
70+
await page.keyboard.press("Enter");
71+
await page.keyboard.type("/code");
72+
73+
const codeOption = page.locator('[role="option"]', {
74+
hasText: /code block/i,
75+
});
76+
await expect(codeOption).toBeVisible({ timeout: 3_000 });
77+
await codeOption.click();
78+
79+
const codeBlock = editor.locator("code");
80+
await expect(codeBlock).toBeVisible({ timeout: 3_000 });
81+
await codeBlock.click();
82+
83+
// Paste single-line text
84+
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
85+
await page.evaluate(async (text) => {
86+
await navigator.clipboard.writeText(text);
87+
}, "const x = 42;");
88+
89+
const modifier = process.platform === "darwin" ? "Meta" : "Control";
90+
await page.keyboard.press(`${modifier}+v`);
91+
92+
await expect(codeBlock).toContainText("const x = 42;", { timeout: 3_000 });
93+
});
94+
});

src/components/editor/code-highlight-plugin.tsx

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,90 @@
11
"use client";
22

33
import { useEffect } from "react";
4-
import { registerCodeHighlighting } from "@lexical/code";
4+
import {
5+
registerCodeHighlighting,
6+
$isCodeNode,
7+
$createCodeHighlightNode,
8+
} from "@lexical/code";
59
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
10+
import { $findMatchingParent, mergeRegister } from "@lexical/utils";
11+
import {
12+
$createLineBreakNode,
13+
$getSelection,
14+
$isRangeSelection,
15+
COMMAND_PRIORITY_HIGH,
16+
PASTE_COMMAND,
17+
} from "lexical";
18+
19+
/**
20+
* Handles paste inside CodeNode by inserting line breaks instead of paragraphs.
21+
*
22+
* Lexical's default rich-text paste handler splits multi-line text into separate
23+
* paragraph nodes via `selection.insertParagraph()`. Inside a CodeNode this breaks
24+
* out of the code block, losing all lines after the first. This handler intercepts
25+
* paste at a higher priority and inserts CodeHighlightNode + LineBreakNode pairs
26+
* so all pasted lines stay inside the code block. The existing syntax-highlighting
27+
* transform (from `registerCodeHighlighting`) re-tokenizes automatically.
28+
*/
29+
function $handleCodeBlockPaste(event: ClipboardEvent): boolean {
30+
const selection = $getSelection();
31+
if (!$isRangeSelection(selection)) {
32+
return false;
33+
}
34+
35+
const anchorNode = selection.anchor.getNode();
36+
const codeNode = $isCodeNode(anchorNode)
37+
? anchorNode
38+
: $findMatchingParent(anchorNode, $isCodeNode);
39+
40+
if (codeNode === null) {
41+
return false;
42+
}
43+
44+
const clipboardData = event.clipboardData;
45+
if (clipboardData === null) {
46+
return false;
47+
}
48+
49+
const text = clipboardData.getData("text/plain");
50+
if (!text) {
51+
return false;
52+
}
53+
54+
event.preventDefault();
55+
56+
// Remove any selected content first
57+
selection.removeText();
58+
59+
const lines = text.split(/\r?\n/);
60+
for (let i = 0; i < lines.length; i++) {
61+
if (i > 0) {
62+
// Insert a line break between lines (not a paragraph break)
63+
const lineBreak = $createLineBreakNode();
64+
selection.insertNodes([lineBreak]);
65+
}
66+
const line = lines[i];
67+
if (line.length > 0) {
68+
const textNode = $createCodeHighlightNode(line);
69+
selection.insertNodes([textNode]);
70+
}
71+
}
72+
73+
return true;
74+
}
675

776
export function CodeHighlightPlugin(): null {
877
const [editor] = useLexicalComposerContext();
978

1079
useEffect(() => {
11-
return registerCodeHighlighting(editor);
80+
return mergeRegister(
81+
registerCodeHighlighting(editor),
82+
editor.registerCommand(
83+
PASTE_COMMAND,
84+
(event: ClipboardEvent) => $handleCodeBlockPaste(event),
85+
COMMAND_PRIORITY_HIGH
86+
)
87+
);
1288
}, [editor]);
1389

1490
return null;

0 commit comments

Comments
 (0)