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)}