fix(editor): wrap selected text when pasting URL
This commit is contained in:
parent
648b3bd812
commit
e0bb3a2e68
3 changed files with 111 additions and 2 deletions
|
|
@ -1,10 +1,11 @@
|
|||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import { type ClipboardEvent, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import getCaretCoordinates from "textarea-caret";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EDITOR_HEIGHT } from "../constants";
|
||||
import type { EditorProps } from "../types";
|
||||
import { editorCommands } from "./commands";
|
||||
import SlashCommands from "./SlashCommands";
|
||||
import { getMarkdownLinkForPastedUrl } from "./shortcuts";
|
||||
import TagSuggestions from "./TagSuggestions";
|
||||
import { useListCompletion } from "./useListCompletion";
|
||||
|
||||
|
|
@ -181,6 +182,23 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
isInIME,
|
||||
});
|
||||
|
||||
const handleEditorPaste = useCallback(
|
||||
(event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const editor = editorRef.current;
|
||||
const pastedText = event.clipboardData?.getData("text/plain") || event.clipboardData?.getData("text");
|
||||
const markdownLink = editor ? getMarkdownLinkForPastedUrl(editorActions.getSelectedContent(), pastedText) : undefined;
|
||||
|
||||
if (markdownLink) {
|
||||
event.preventDefault();
|
||||
editorActions.insertText(markdownLink);
|
||||
return;
|
||||
}
|
||||
|
||||
onPaste(event);
|
||||
},
|
||||
[editorActions, onPaste],
|
||||
);
|
||||
|
||||
// Recalculate editor height when focus mode changes
|
||||
useEffect(() => {
|
||||
updateEditorHeight();
|
||||
|
|
@ -204,7 +222,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
rows={1}
|
||||
placeholder={placeholder}
|
||||
ref={editorRef}
|
||||
onPaste={onPaste}
|
||||
onPaste={handleEditorPaste}
|
||||
onInput={handleEditorInput}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
|
|
|
|||
|
|
@ -68,3 +68,13 @@ export function hyperlinkHighlightedText(editor: EditorRefActions, url: string):
|
|||
const newPosition = cursorPosition + selectedContent.length + url.length + 4;
|
||||
editor.setCursorPosition(newPosition, newPosition);
|
||||
}
|
||||
|
||||
export function getMarkdownLinkForPastedUrl(selectedContent: string, pastedText: string): string | undefined {
|
||||
const url = pastedText.trim();
|
||||
|
||||
if (!selectedContent || !URL_REGEX.test(url) || URL_REGEX.test(selectedContent.trim())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `[${selectedContent}](${url})`;
|
||||
}
|
||||
|
|
|
|||
81
web/tests/memo-editor-paste.test.tsx
Normal file
81
web/tests/memo-editor-paste.test.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Editor from "@/components/MemoEditor/Editor";
|
||||
|
||||
vi.mock("@/components/MemoEditor/Editor/TagSuggestions", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoEditor/Editor/SlashCommands", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
function pastePlainText(textarea: HTMLTextAreaElement, text: string) {
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
getData: (type: string) => (type === "text/plain" || type === "text" ? text : ""),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderEditor(initialContent: string) {
|
||||
const onPaste = vi.fn();
|
||||
render(
|
||||
<Editor
|
||||
className=""
|
||||
initialContent={initialContent}
|
||||
placeholder="memo"
|
||||
onContentChange={vi.fn()}
|
||||
onPaste={onPaste}
|
||||
/>,
|
||||
);
|
||||
|
||||
return {
|
||||
onPaste,
|
||||
textarea: screen.getByPlaceholderText("memo") as HTMLTextAreaElement,
|
||||
};
|
||||
}
|
||||
|
||||
describe("memo editor paste handling", () => {
|
||||
it("wraps selected text with a pasted URL", () => {
|
||||
const { onPaste, textarea } = renderEditor("read the docs");
|
||||
textarea.setSelectionRange(9, 13);
|
||||
|
||||
pastePlainText(textarea, "https://example.com");
|
||||
|
||||
expect(textarea).toHaveValue("read the [docs](https://example.com)");
|
||||
expect(textarea.selectionStart).toBe("read the [docs](https://example.com)".length);
|
||||
expect(textarea.selectionEnd).toBe("read the [docs](https://example.com)".length);
|
||||
expect(onPaste).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates non-URL text paste", () => {
|
||||
const { onPaste, textarea } = renderEditor("read the docs");
|
||||
textarea.setSelectionRange(9, 13);
|
||||
|
||||
pastePlainText(textarea, "not a url");
|
||||
|
||||
expect(textarea).toHaveValue("read the docs");
|
||||
expect(onPaste).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("delegates pasted URLs when no text is selected", () => {
|
||||
const { onPaste, textarea } = renderEditor("read the docs");
|
||||
textarea.setSelectionRange(13, 13);
|
||||
|
||||
pastePlainText(textarea, "https://example.com");
|
||||
|
||||
expect(textarea).toHaveValue("read the docs");
|
||||
expect(onPaste).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("delegates pasted URLs when the selected text is already a URL", () => {
|
||||
const { onPaste, textarea } = renderEditor("https://memos.example");
|
||||
textarea.setSelectionRange(0, "https://memos.example".length);
|
||||
|
||||
pastePlainText(textarea, "https://example.com");
|
||||
|
||||
expect(textarea).toHaveValue("https://memos.example");
|
||||
expect(onPaste).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue