Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(<PlainTextPastePlugin />);

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");
});
});
27 changes: 27 additions & 0 deletions __tests__/components/drops/view/part/DropPartMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -619,6 +627,25 @@ describe("DropPartMarkdown", () => {
expect(a).toHaveAttribute("href", "https://google.com");
});

it("renders separate spaced paragraphs for blank-line content", () => {
render(
<DropPartMarkdown
mentionedUsers={[]}
mentionedWaves={[]}
referencedNfts={[]}
partContent={"First\n\nSecond"}
onQuoteClick={jest.fn()}
/>
);

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)";
Expand Down
46 changes: 44 additions & 2 deletions components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion components/drops/view/part/dropPartMarkdown/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export const createMarkdownContentRenderers = ({
) => (
<p
key={getRandomObjectId()}
className={`word-break tw-mb-0 tw-whitespace-pre-wrap tw-break-words tw-font-normal tw-leading-6 tw-text-iron-200 tw-transition tw-duration-300 tw-ease-out ${textSizeClass}`}
className={`word-break tw-mb-3 tw-whitespace-pre-wrap tw-break-words tw-font-normal tw-leading-6 tw-text-iron-200 tw-transition tw-duration-300 tw-ease-out last:tw-mb-0 ${textSizeClass}`}
>
{customRenderer(paragraphParams.children)}
</p>
Expand Down