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>
pull/6028/head
ay5399 4 weeks ago committed by GitHub
parent 9eabb554d5
commit 8f1377324f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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();

@ -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…
Cancel
Save