fix(editor): wire Ctrl+B and Ctrl+I markdown shortcuts to textarea (#6016)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: boojack <stevenlgtm@gmail.com>
This commit is contained in:
parent
9eabb554d5
commit
8f1377324f
3 changed files with 154 additions and 6 deletions
|
|
@ -5,7 +5,7 @@ import { EDITOR_HEIGHT } from "../constants";
|
|||
import type { EditorProps } from "../types";
|
||||
import { editorCommands } from "./commands";
|
||||
import SlashCommands from "./SlashCommands";
|
||||
import { getMarkdownLinkForPastedUrl } from "./shortcuts";
|
||||
import { getMarkdownLinkForPastedUrl, handleMarkdownShortcuts } from "./shortcuts";
|
||||
import TagSuggestions from "./TagSuggestions";
|
||||
import { useListCompletion } from "./useListCompletion";
|
||||
|
||||
|
|
@ -182,6 +182,15 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
isInIME,
|
||||
});
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((event.ctrlKey || event.metaKey) && !isInIME) {
|
||||
handleMarkdownShortcuts(event, editorActions);
|
||||
}
|
||||
},
|
||||
[editorActions, isInIME],
|
||||
);
|
||||
|
||||
const handleEditorPaste = useCallback(
|
||||
(event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const editor = editorRef.current;
|
||||
|
|
@ -222,6 +231,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
rows={1}
|
||||
placeholder={placeholder}
|
||||
ref={editorRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handleEditorPaste}
|
||||
onInput={handleEditorInput}
|
||||
onCompositionStart={onCompositionStart}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { EditorRefActions } from "./index";
|
|||
const SHORTCUTS = {
|
||||
BOLD: { key: "b", delimiter: "**" },
|
||||
ITALIC: { key: "i", delimiter: "*" },
|
||||
LINK: { key: "k" },
|
||||
} as const;
|
||||
|
||||
const URL_PLACEHOLDER = "url";
|
||||
|
|
@ -18,9 +17,6 @@ export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: Edit
|
|||
} else if (key === SHORTCUTS.ITALIC.key) {
|
||||
event.preventDefault();
|
||||
toggleTextStyle(editor, SHORTCUTS.ITALIC.delimiter);
|
||||
} else if (key === SHORTCUTS.LINK.key) {
|
||||
event.preventDefault();
|
||||
insertHyperlink(editor);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +43,7 @@ export function insertHyperlink(editor: EditorRefActions, url?: string): void {
|
|||
function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
const isStyled = selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter);
|
||||
const isStyled = isTextStyled(selectedContent, delimiter);
|
||||
|
||||
if (isStyled) {
|
||||
const unstyled = selectedContent.slice(delimiter.length, -delimiter.length);
|
||||
|
|
@ -59,6 +55,32 @@ function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
function isTextStyled(text: string, delimiter: string): boolean {
|
||||
if (!text.startsWith(delimiter) || !text.endsWith(delimiter)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (delimiter !== "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const leadingAsterisks = countConsecutive(text, "*", "start");
|
||||
const trailingAsterisks = countConsecutive(text, "*", "end");
|
||||
return leadingAsterisks % 2 === 1 && trailingAsterisks % 2 === 1;
|
||||
}
|
||||
|
||||
function countConsecutive(text: string, character: string, position: "start" | "end"): number {
|
||||
let count = 0;
|
||||
let index = position === "start" ? 0 : text.length - 1;
|
||||
|
||||
while (index >= 0 && index < text.length && text[index] === character) {
|
||||
count += 1;
|
||||
index += position === "start" ? 1 : -1;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export function hyperlinkHighlightedText(editor: EditorRefActions, url: string): void {
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
|
|
|
|||
116
web/tests/memo-editor-shortcuts.test.tsx
Normal file
116
web/tests/memo-editor-shortcuts.test.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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 renderEditor(initialContent: string, isInIME = false) {
|
||||
render(
|
||||
<Editor
|
||||
className=""
|
||||
initialContent={initialContent}
|
||||
placeholder="memo"
|
||||
onContentChange={vi.fn()}
|
||||
onPaste={vi.fn()}
|
||||
isInIME={isInIME}
|
||||
/>,
|
||||
);
|
||||
return screen.getByPlaceholderText("memo") as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
describe("memo editor markdown shortcuts", () => {
|
||||
describe("Ctrl+B (bold)", () => {
|
||||
it("wraps selected text with **", () => {
|
||||
const textarea = renderEditor("read the docs");
|
||||
textarea.setSelectionRange(9, 13);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "b", ctrlKey: true });
|
||||
|
||||
expect(textarea).toHaveValue("read the **docs**");
|
||||
});
|
||||
|
||||
it("removes ** from already-bolded text", () => {
|
||||
const textarea = renderEditor("read the **docs**");
|
||||
textarea.setSelectionRange(9, 17);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "b", ctrlKey: true });
|
||||
|
||||
expect(textarea).toHaveValue("read the docs");
|
||||
});
|
||||
|
||||
it("works with metaKey (Mac Cmd+B)", () => {
|
||||
const textarea = renderEditor("read the docs");
|
||||
textarea.setSelectionRange(9, 13);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "b", metaKey: true });
|
||||
|
||||
expect(textarea).toHaveValue("read the **docs**");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ctrl+I (italic)", () => {
|
||||
it("wraps selected text with *", () => {
|
||||
const textarea = renderEditor("read the docs");
|
||||
textarea.setSelectionRange(9, 13);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "i", ctrlKey: true });
|
||||
|
||||
expect(textarea).toHaveValue("read the *docs*");
|
||||
});
|
||||
|
||||
it("removes * from already-italicized text", () => {
|
||||
const textarea = renderEditor("read the *docs*");
|
||||
textarea.setSelectionRange(9, 15);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "i", ctrlKey: true });
|
||||
|
||||
expect(textarea).toHaveValue("read the docs");
|
||||
});
|
||||
|
||||
it("works with metaKey (Mac Cmd+I)", () => {
|
||||
const textarea = renderEditor("read the docs");
|
||||
textarea.setSelectionRange(9, 13);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "i", metaKey: true });
|
||||
|
||||
expect(textarea).toHaveValue("read the *docs*");
|
||||
});
|
||||
|
||||
it("preserves bold when italicizing already-bolded text", () => {
|
||||
const textarea = renderEditor("read the **docs**");
|
||||
textarea.setSelectionRange(9, 17);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "i", ctrlKey: true });
|
||||
|
||||
expect(textarea).toHaveValue("read the ***docs***");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortcuts suppressed during IME composition", () => {
|
||||
it("does not apply bold during IME input", () => {
|
||||
const textarea = renderEditor("テスト", true);
|
||||
textarea.setSelectionRange(0, 3);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "b", ctrlKey: true });
|
||||
|
||||
expect(textarea).toHaveValue("テスト");
|
||||
});
|
||||
});
|
||||
|
||||
describe("no modifier key — no action", () => {
|
||||
it("does not apply bold when Ctrl/Cmd is not held", () => {
|
||||
const textarea = renderEditor("read the docs");
|
||||
textarea.setSelectionRange(9, 13);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "b" });
|
||||
|
||||
expect(textarea).toHaveValue("read the docs");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue