diff --git a/src/renderer/components/MarkdownComposer.tsx b/src/renderer/components/MarkdownComposer.tsx index 957bf3e..eba944c 100644 --- a/src/renderer/components/MarkdownComposer.tsx +++ b/src/renderer/components/MarkdownComposer.tsx @@ -31,6 +31,7 @@ import { $isHeadingNode, $isQuoteNode } from '@lexical/rich-text'; import { $createParagraphNode, $createTextNode, + $getNodeByKey, $getRoot, $getSelection, $isRangeSelection, @@ -42,7 +43,7 @@ import { type EditorThemeClasses, type LexicalEditor, } from 'lexical'; -import { Bold, Braces, Code, Italic, List, ListOrdered } from 'lucide-react'; +import { Bold, Braces, ChevronDown, Code, Italic, List, ListOrdered } from 'lucide-react'; import { inspectMarkdownPaste, @@ -75,6 +76,50 @@ const editorTheme: EditorThemeClasses = { link: 'mc-link', }; +/* ── Code language helpers ─────────────────────────────── */ + +const CODE_LANGUAGE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [ + { value: '', label: 'Plain Text' }, + { value: 'javascript', label: 'JavaScript' }, + { value: 'typescript', label: 'TypeScript' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + { value: 'c', label: 'C' }, + { value: 'cpp', label: 'C++' }, + { value: 'csharp', label: 'C#' }, + { value: 'go', label: 'Go' }, + { value: 'rust', label: 'Rust' }, + { value: 'swift', label: 'Swift' }, + { value: 'kotlin', label: 'Kotlin' }, + { value: 'ruby', label: 'Ruby' }, + { value: 'php', label: 'PHP' }, + { value: 'html', label: 'HTML' }, + { value: 'css', label: 'CSS' }, + { value: 'sql', label: 'SQL' }, + { value: 'bash', label: 'Bash' }, + { value: 'json', label: 'JSON' }, + { value: 'yaml', label: 'YAML' }, + { value: 'markdown', label: 'Markdown' }, + { value: 'xml', label: 'XML' }, +]; + +const CODE_LANGUAGE_FRIENDLY_NAMES: Record = Object.fromEntries( + CODE_LANGUAGE_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]), +); +// Add common aliases +CODE_LANGUAGE_FRIENDLY_NAMES['js'] = 'JavaScript'; +CODE_LANGUAGE_FRIENDLY_NAMES['ts'] = 'TypeScript'; +CODE_LANGUAGE_FRIENDLY_NAMES['py'] = 'Python'; +CODE_LANGUAGE_FRIENDLY_NAMES['rb'] = 'Ruby'; +CODE_LANGUAGE_FRIENDLY_NAMES['yml'] = 'YAML'; +CODE_LANGUAGE_FRIENDLY_NAMES['md'] = 'Markdown'; +CODE_LANGUAGE_FRIENDLY_NAMES['shell'] = 'Bash'; +CODE_LANGUAGE_FRIENDLY_NAMES['sh'] = 'Bash'; + +function friendlyLanguageName(lang: string): string { + return CODE_LANGUAGE_FRIENDLY_NAMES[lang.toLowerCase()] ?? lang; +} + /* ── Public API ───────────────────────────────────────── */ export interface MarkdownComposerHandle { @@ -196,6 +241,31 @@ function MarkdownPastePlugin() { return null; } +/** Syncs a data-code-language attribute on each CodeNode's DOM element for CSS labels. */ +function CodeBlockLabelPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + for (const child of $getRoot().getChildren()) { + if (!$isCodeNode(child)) continue; + const element = editor.getElementByKey(child.getKey()); + if (!element) continue; + const lang = child.getLanguage(); + if (lang) { + element.setAttribute('data-code-language', friendlyLanguageName(lang)); + } else { + element.removeAttribute('data-code-language'); + } + } + }); + }); + }, [editor]); + + return null; +} + /* ── Toolbar ──────────────────────────────────────────── */ interface ToolbarState { @@ -203,21 +273,22 @@ interface ToolbarState { isItalic: boolean; isCode: boolean; blockType: string; + codeLanguage: string | null; } -function getSelectionBlockType(): string { +function getSelectionBlockInfo(): { blockType: string; codeLanguage: string | null; codeNodeKey: string | null } { const selection = $getSelection(); - if (!$isRangeSelection(selection)) return 'paragraph'; + if (!$isRangeSelection(selection)) return { blockType: 'paragraph', codeLanguage: null, codeNodeKey: null }; const anchorNode = selection.anchor.getNode(); - if (anchorNode.getKey() === 'root') return 'paragraph'; + if (anchorNode.getKey() === 'root') return { blockType: 'paragraph', codeLanguage: null, codeNodeKey: null }; const topElement = anchorNode.getTopLevelElementOrThrow(); - if ($isHeadingNode(topElement)) return topElement.getTag(); - if ($isListNode(topElement)) return topElement.getListType() === 'number' ? 'ol' : 'ul'; - if ($isCodeNode(topElement)) return 'code'; - if ($isQuoteNode(topElement)) return 'quote'; - return 'paragraph'; + if ($isHeadingNode(topElement)) return { blockType: topElement.getTag(), codeLanguage: null, codeNodeKey: null }; + if ($isListNode(topElement)) return { blockType: topElement.getListType() === 'number' ? 'ol' : 'ul', codeLanguage: null, codeNodeKey: null }; + if ($isCodeNode(topElement)) return { blockType: 'code', codeLanguage: topElement.getLanguage() ?? null, codeNodeKey: topElement.getKey() }; + if ($isQuoteNode(topElement)) return { blockType: 'quote', codeLanguage: null, codeNodeKey: null }; + return { blockType: 'paragraph', codeLanguage: null, codeNodeKey: null }; } function ToolbarPlugin({ disabled }: { disabled: boolean }) { @@ -227,18 +298,24 @@ function ToolbarPlugin({ disabled }: { disabled: boolean }) { isItalic: false, isCode: false, blockType: 'paragraph', + codeLanguage: null, }); + // Preserve the code block key so the language dropdown works even when the select steals focus + const activeCodeBlockKeyRef = useRef(null); useEffect(() => { return editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection)) return; + const { blockType, codeLanguage, codeNodeKey } = getSelectionBlockInfo(); + activeCodeBlockKeyRef.current = codeNodeKey; setState({ isBold: selection.hasFormat('bold'), isItalic: selection.hasFormat('italic'), isCode: selection.hasFormat('code'), - blockType: getSelectionBlockType(), + blockType, + codeLanguage, }); }); }); @@ -292,6 +369,22 @@ function ToolbarPlugin({ disabled }: { disabled: boolean }) { }); }, [editor]); + const handleLanguageChange = useCallback( + (e: React.ChangeEvent) => { + const key = activeCodeBlockKeyRef.current; + if (!key) return; + const newLang = e.target.value || undefined; + editor.update(() => { + const node = $getNodeByKey(key); + if ($isCodeNode(node)) { + node.setLanguage(newLang); + } + }); + requestAnimationFrame(() => editor.focus()); + }, + [editor], + ); + return (
} onClick={formatBold} onMouseDown={preventFocus} title="Bold (Ctrl+B)" /> @@ -301,6 +394,26 @@ function ToolbarPlugin({ disabled }: { disabled: boolean }) { } onClick={toggleBulletList} onMouseDown={preventFocus} title="Bullet List" /> } onClick={toggleNumberedList} onMouseDown={preventFocus} title="Numbered List" /> } onClick={toggleCodeBlock} onMouseDown={preventFocus} title="Code Block" /> + {state.blockType === 'code' && ( + <> +
+
+ + +
+ + )}
); } @@ -399,6 +512,7 @@ export const MarkdownComposer = forwardRef + diff --git a/src/renderer/styles.css b/src/renderer/styles.css index c2e8243..24988d2 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -240,6 +240,17 @@ textarea { overflow-x: auto; } +.mc-code-block[data-code-language]::before { + content: attr(data-code-language); + display: block; + font-size: 10px; + line-height: 1; + color: #71717a; + padding-bottom: 4px; + user-select: none; + letter-spacing: 0.03em; +} + /* Link */ .mc-link { color: #818cf8;