diff --git a/__tests__/components/drops/create/lexical/plugins/PlainTextPastePlugin.test.tsx b/__tests__/components/drops/create/lexical/plugins/PlainTextPastePlugin.test.tsx
new file mode 100644
index 0000000000..ad13164725
--- /dev/null
+++ b/__tests__/components/drops/create/lexical/plugins/PlainTextPastePlugin.test.tsx
@@ -0,0 +1,207 @@
+import React from "react";
+import { render } from "@testing-library/react";
+
+import PlainTextPastePlugin from "@/components/drops/create/lexical/plugins/PlainTextPastePlugin";
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import {
+ COMMAND_PRIORITY_LOW,
+ PASTE_COMMAND,
+ $getSelection,
+ $isRangeSelection,
+} from "lexical";
+
+type PasteHandler = (event: ClipboardEvent) => boolean;
+
+let commandHandler: PasteHandler | undefined;
+
+const registerCommandMock = jest.fn(
+ (_command: unknown, handler: PasteHandler) => {
+ commandHandler = handler;
+ return jest.fn();
+ }
+);
+
+const editor = {
+ registerCommand: registerCommandMock,
+ update: (fn: () => void) => fn(),
+} as const;
+
+jest.mock("@lexical/react/LexicalComposerContext", () => ({
+ useLexicalComposerContext: jest.fn(),
+}));
+
+jest.mock("lexical", () => ({
+ COMMAND_PRIORITY_LOW: 1,
+ PASTE_COMMAND: "PASTE_COMMAND",
+ $getSelection: jest.fn(),
+ $isRangeSelection: jest.fn(),
+}));
+
+const createClipboardEvent = ({
+ text = "",
+ uriList = "",
+ files = [],
+}: {
+ readonly text?: string;
+ readonly uriList?: string;
+ readonly files?: File[];
+}) => {
+ const preventDefault = jest.fn();
+ const getData = jest.fn((mimeType: string) => {
+ if (mimeType === "text/plain") {
+ return text;
+ }
+
+ if (mimeType === "text/uri-list") {
+ return uriList;
+ }
+
+ return "";
+ });
+
+ return {
+ event: {
+ preventDefault,
+ clipboardData: {
+ files,
+ getData,
+ },
+ } as unknown as ClipboardEvent,
+ preventDefault,
+ getData,
+ };
+};
+
+const renderPlugin = () => render();
+
+const getCommandHandler = (): PasteHandler => {
+ if (!commandHandler) {
+ throw new Error("Paste command handler was not registered");
+ }
+
+ return commandHandler;
+};
+
+describe("PlainTextPastePlugin", () => {
+ beforeEach(() => {
+ commandHandler = undefined;
+ registerCommandMock.mockClear();
+ ($getSelection as jest.Mock).mockReset();
+ ($isRangeSelection as jest.Mock).mockReset();
+ (useLexicalComposerContext as jest.Mock).mockReturnValue([editor]);
+ });
+
+ it("registers paste command with low priority", () => {
+ renderPlugin();
+
+ expect(registerCommandMock).toHaveBeenCalledWith(
+ PASTE_COMMAND,
+ expect.any(Function),
+ COMMAND_PRIORITY_LOW
+ );
+ });
+
+ it("returns false when clipboardData is missing", () => {
+ renderPlugin();
+
+ const preventDefault = jest.fn();
+ const handled = getCommandHandler()({
+ preventDefault,
+ } as unknown as ClipboardEvent);
+
+ expect(handled).toBe(false);
+ expect(preventDefault).not.toHaveBeenCalled();
+ });
+
+ it("returns false when paste includes files", () => {
+ renderPlugin();
+
+ const { event, preventDefault } = createClipboardEvent({
+ text: "text",
+ files: [{} as File],
+ });
+
+ const handled = getCommandHandler()(event);
+
+ expect(handled).toBe(false);
+ expect(preventDefault).not.toHaveBeenCalled();
+ });
+
+ it("returns false when plain text and uri list are empty", () => {
+ renderPlugin();
+
+ const { event, preventDefault } = createClipboardEvent({});
+ const handled = getCommandHandler()(event);
+
+ expect(handled).toBe(false);
+ expect(preventDefault).not.toHaveBeenCalled();
+ });
+
+ it("preserves pasted blank lines for range selections", () => {
+ renderPlugin();
+
+ const selection = {
+ insertText: jest.fn(),
+ insertParagraph: jest.fn(),
+ insertRawText: jest.fn(),
+ };
+ ($getSelection as jest.Mock).mockReturnValue(selection);
+ ($isRangeSelection as jest.Mock).mockReturnValue(true);
+
+ const { event, preventDefault } = createClipboardEvent({
+ text: "First\n\nSecond",
+ });
+
+ const handled = getCommandHandler()(event);
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalledTimes(1);
+ expect(selection.insertText).toHaveBeenNthCalledWith(1, "First");
+ expect(selection.insertParagraph).toHaveBeenCalledTimes(2);
+ expect(selection.insertText).toHaveBeenNthCalledWith(2, "Second");
+ expect(selection.insertRawText).not.toHaveBeenCalled();
+ });
+
+ it("falls back to text/uri-list when text/plain is empty", () => {
+ renderPlugin();
+
+ const selection = {
+ insertText: jest.fn(),
+ insertParagraph: jest.fn(),
+ insertRawText: jest.fn(),
+ };
+ ($getSelection as jest.Mock).mockReturnValue(selection);
+ ($isRangeSelection as jest.Mock).mockReturnValue(true);
+
+ const { event, getData } = createClipboardEvent({
+ text: "",
+ uriList: "https://example.com",
+ });
+
+ const handled = getCommandHandler()(event);
+
+ expect(handled).toBe(true);
+ expect(getData).toHaveBeenCalledWith("text/plain");
+ expect(getData).toHaveBeenCalledWith("text/uri-list");
+ expect(selection.insertText).toHaveBeenCalledWith("https://example.com");
+ });
+
+ it("uses raw text insertion for non-range selections", () => {
+ renderPlugin();
+
+ const selection = {
+ insertRawText: jest.fn(),
+ };
+ ($getSelection as jest.Mock).mockReturnValue(selection);
+ ($isRangeSelection as jest.Mock).mockReturnValue(false);
+
+ const { event } = createClipboardEvent({
+ text: "First\n\nSecond",
+ });
+
+ const handled = getCommandHandler()(event);
+
+ expect(handled).toBe(true);
+ expect(selection.insertRawText).toHaveBeenCalledWith("First\n\nSecond");
+ });
+});
diff --git a/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx b/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx
index 2b1d166063..ba85758d99 100644
--- a/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx
+++ b/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx
@@ -53,6 +53,14 @@ import userEvent from "@testing-library/user-event";
import DropPartMarkdown from "@/components/drops/view/part/DropPartMarkdown";
+const setQueryDataMock = jest.fn();
+
+jest.mock("@tanstack/react-query", () => ({
+ useQueryClient: () => ({
+ setQueryData: setQueryDataMock,
+ }),
+}));
+
const FALLBACK_BASE_ENDPOINT = "https://6529.io";
const originalBaseEndpoint = publicEnv.BASE_ENDPOINT;
const originalArtBlocksFlags = {
@@ -619,6 +627,25 @@ describe("DropPartMarkdown", () => {
expect(a).toHaveAttribute("href", "https://google.com");
});
+ it("renders separate spaced paragraphs for blank-line content", () => {
+ render(
+
+ );
+
+ const paragraphs = document.querySelectorAll("p.word-break");
+ expect(paragraphs).toHaveLength(2);
+ expect(paragraphs[0]).toHaveTextContent("First");
+ expect(paragraphs[1]).toHaveTextContent("Second");
+ expect(paragraphs[0]?.className).toContain("tw-mb-3");
+ expect(paragraphs[0]?.className).toContain("last:tw-mb-0");
+ });
+
it("renders one inline show-previews action when previews are hidden", async () => {
const onToggle = jest.fn();
const content = "[first](https://google.com) [second](https://example.com)";
diff --git a/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx b/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx
index 0478b8843a..004508f348 100644
--- a/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx
+++ b/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx
@@ -6,10 +6,45 @@ import {
PASTE_COMMAND,
$getSelection,
$isRangeSelection,
+ type RangeSelection,
} from "lexical";
import { useEffect } from "react";
const TEXT_MIME_TYPE = "text/plain";
+const URI_LIST_MIME_TYPE = "text/uri-list";
+const NEWLINE_OR_TAB_REGEX = /(\r?\n|\t)/;
+
+const getClipboardText = (clipboardData: DataTransfer): string =>
+ clipboardData.getData(TEXT_MIME_TYPE) ||
+ clipboardData.getData(URI_LIST_MIME_TYPE);
+
+const insertRangeSelectionText = (
+ selection: RangeSelection,
+ text: string
+): void => {
+ const parts = text.split(NEWLINE_OR_TAB_REGEX);
+ if (parts[parts.length - 1] === "") {
+ parts.pop();
+ }
+
+ for (const part of parts) {
+ if (part === "\n" || part === "\r\n") {
+ selection.insertParagraph();
+ continue;
+ }
+
+ if (part === "\t") {
+ selection.insertText(part);
+ continue;
+ }
+
+ if (part.length === 0) {
+ continue;
+ }
+
+ selection.insertText(part);
+ }
+};
export default function PlainTextPastePlugin(): null {
const [editor] = useLexicalComposerContext();
@@ -27,7 +62,7 @@ export default function PlainTextPastePlugin(): null {
return false;
}
- const text = clipboardData.getData(TEXT_MIME_TYPE);
+ const text = getClipboardText(clipboardData);
if (!text.length) {
return false;
}
@@ -36,9 +71,16 @@ export default function PlainTextPastePlugin(): null {
editor.update(() => {
const selection = $getSelection();
+ if (!selection) {
+ return;
+ }
+
if ($isRangeSelection(selection)) {
- selection.insertRawText(text);
+ insertRangeSelectionText(selection, text);
+ return;
}
+
+ selection.insertRawText(text);
});
return true;
diff --git a/components/drops/view/part/dropPartMarkdown/content.tsx b/components/drops/view/part/dropPartMarkdown/content.tsx
index 0000cf9bd7..c788e70684 100644
--- a/components/drops/view/part/dropPartMarkdown/content.tsx
+++ b/components/drops/view/part/dropPartMarkdown/content.tsx
@@ -207,7 +207,7 @@ export const createMarkdownContentRenderers = ({
) => (
{customRenderer(paragraphParams.children)}