From 8f1377324f9c59505a39d395c09ff7536459911a Mon Sep 17 00:00:00 2001 From: ay5399 Date: Mon, 8 Jun 2026 01:24:05 +0900 Subject: [PATCH] fix(editor): wire Ctrl+B and Ctrl+I markdown shortcuts to textarea (#6016) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: boojack --- .../components/MemoEditor/Editor/index.tsx | 12 +- .../components/MemoEditor/Editor/shortcuts.ts | 32 ++++- web/tests/memo-editor-shortcuts.test.tsx | 116 ++++++++++++++++++ 3 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 web/tests/memo-editor-shortcuts.test.tsx diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 4ce1dd59..1d2ff135 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -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) => { + if ((event.ctrlKey || event.metaKey) && !isInIME) { + handleMarkdownShortcuts(event, editorActions); + } + }, + [editorActions, isInIME], + ); + const handleEditorPaste = useCallback( (event: ClipboardEvent) => { 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} diff --git a/web/src/components/MemoEditor/Editor/shortcuts.ts b/web/src/components/MemoEditor/Editor/shortcuts.ts index 81056e2f..5ce3e18f 100644 --- a/web/src/components/MemoEditor/Editor/shortcuts.ts +++ b/web/src/components/MemoEditor/Editor/shortcuts.ts @@ -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(); diff --git a/web/tests/memo-editor-shortcuts.test.tsx b/web/tests/memo-editor-shortcuts.test.tsx new file mode 100644 index 00000000..46090029 --- /dev/null +++ b/web/tests/memo-editor-shortcuts.test.tsx @@ -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( + , + ); + 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"); + }); + }); +});