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 type { EditorProps } from "../types";
|
||||||
import { editorCommands } from "./commands";
|
import { editorCommands } from "./commands";
|
||||||
import SlashCommands from "./SlashCommands";
|
import SlashCommands from "./SlashCommands";
|
||||||
import { getMarkdownLinkForPastedUrl } from "./shortcuts";
|
import { getMarkdownLinkForPastedUrl, handleMarkdownShortcuts } from "./shortcuts";
|
||||||
import TagSuggestions from "./TagSuggestions";
|
import TagSuggestions from "./TagSuggestions";
|
||||||
import { useListCompletion } from "./useListCompletion";
|
import { useListCompletion } from "./useListCompletion";
|
||||||
|
|
||||||
|
|
@ -182,6 +182,15 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
||||||
isInIME,
|
isInIME,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && !isInIME) {
|
||||||
|
handleMarkdownShortcuts(event, editorActions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editorActions, isInIME],
|
||||||
|
);
|
||||||
|
|
||||||
const handleEditorPaste = useCallback(
|
const handleEditorPaste = useCallback(
|
||||||
(event: ClipboardEvent<HTMLTextAreaElement>) => {
|
(event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
|
|
@ -222,6 +231,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handleEditorPaste}
|
onPaste={handleEditorPaste}
|
||||||
onInput={handleEditorInput}
|
onInput={handleEditorInput}
|
||||||
onCompositionStart={onCompositionStart}
|
onCompositionStart={onCompositionStart}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type { EditorRefActions } from "./index";
|
||||||
const SHORTCUTS = {
|
const SHORTCUTS = {
|
||||||
BOLD: { key: "b", delimiter: "**" },
|
BOLD: { key: "b", delimiter: "**" },
|
||||||
ITALIC: { key: "i", delimiter: "*" },
|
ITALIC: { key: "i", delimiter: "*" },
|
||||||
LINK: { key: "k" },
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const URL_PLACEHOLDER = "url";
|
const URL_PLACEHOLDER = "url";
|
||||||
|
|
@ -18,9 +17,6 @@ export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: Edit
|
||||||
} else if (key === SHORTCUTS.ITALIC.key) {
|
} else if (key === SHORTCUTS.ITALIC.key) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleTextStyle(editor, SHORTCUTS.ITALIC.delimiter);
|
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 {
|
function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
|
||||||
const cursorPosition = editor.getCursorPosition();
|
const cursorPosition = editor.getCursorPosition();
|
||||||
const selectedContent = editor.getSelectedContent();
|
const selectedContent = editor.getSelectedContent();
|
||||||
const isStyled = selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter);
|
const isStyled = isTextStyled(selectedContent, delimiter);
|
||||||
|
|
||||||
if (isStyled) {
|
if (isStyled) {
|
||||||
const unstyled = selectedContent.slice(delimiter.length, -delimiter.length);
|
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 {
|
export function hyperlinkHighlightedText(editor: EditorRefActions, url: string): void {
|
||||||
const selectedContent = editor.getSelectedContent();
|
const selectedContent = editor.getSelectedContent();
|
||||||
const cursorPosition = editor.getCursorPosition();
|
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