diff --git a/src/renderer/components/MarkdownComposer.tsx b/src/renderer/components/MarkdownComposer.tsx index eba944c..c8111f6 100644 --- a/src/renderer/components/MarkdownComposer.tsx +++ b/src/renderer/components/MarkdownComposer.tsx @@ -20,7 +20,7 @@ import { $convertFromMarkdownString, $convertToMarkdownString, } from '@lexical/markdown'; -import { $isCodeNode, $createCodeNode } from '@lexical/code'; +import { $isCodeNode, $createCodeNode, CodeNode } from '@lexical/code'; import { $isListNode, INSERT_ORDERED_LIST_COMMAND, @@ -43,7 +43,7 @@ import { type EditorThemeClasses, type LexicalEditor, } from 'lexical'; -import { Bold, Braces, ChevronDown, Code, Italic, List, ListOrdered } from 'lucide-react'; +import { Bold, Braces, Code, Italic, List, ListOrdered } from 'lucide-react'; import { inspectMarkdownPaste, @@ -241,26 +241,87 @@ function MarkdownPastePlugin() { return null; } -/** Syncs a data-code-language attribute on each CodeNode's DOM element for CSS labels. */ -function CodeBlockLabelPlugin() { +/** Attaches an imperative language-selector overlay to each CodeNode's DOM element. */ +function CodeBlockLanguagePlugin() { 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'); - } - } + const overlays = new Map(); + + function syncCodeBlock(nodeKey: string) { + const codeElement = editor.getElementByKey(nodeKey); + if (!codeElement) return; + + let lang: string | null = null; + editor.getEditorState().read(() => { + const node = $getNodeByKey(nodeKey); + if ($isCodeNode(node)) lang = node.getLanguage() ?? null; }); + + let entry = overlays.get(nodeKey); + if (!entry) { + const wrapper = document.createElement('div'); + wrapper.contentEditable = 'false'; + wrapper.className = 'mc-code-lang-overlay'; + + const select = document.createElement('select'); + select.className = 'mc-code-lang-select'; + for (const { value, label } of CODE_LANGUAGE_OPTIONS) { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + select.appendChild(opt); + } + select.addEventListener('change', () => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isCodeNode(node)) node.setLanguage(select.value || undefined); + }); + requestAnimationFrame(() => editor.focus()); + }); + select.addEventListener('mousedown', (e) => e.stopPropagation()); + + wrapper.appendChild(select); + entry = { wrapper, select }; + overlays.set(nodeKey, entry); + } + + entry.select.value = lang ?? ''; + + // Re-insert if Lexical reconciled it away + if (!codeElement.contains(entry.wrapper)) { + codeElement.insertBefore(entry.wrapper, codeElement.firstChild); + } + } + + function removeCodeBlock(nodeKey: string) { + const entry = overlays.get(nodeKey); + if (entry) { + entry.wrapper.remove(); + overlays.delete(nodeKey); + } + } + + const removeMutation = editor.registerMutationListener(CodeNode, (mutations) => { + for (const [nodeKey, type] of mutations) { + if (type === 'destroyed') removeCodeBlock(nodeKey); + else syncCodeBlock(nodeKey); + } }); + + // Re-sync after every update to survive Lexical reconciliation + const removeUpdate = editor.registerUpdateListener(() => { + for (const nodeKey of overlays.keys()) { + syncCodeBlock(nodeKey); + } + }); + + return () => { + removeMutation(); + removeUpdate(); + for (const entry of overlays.values()) entry.wrapper.remove(); + overlays.clear(); + }; }, [editor]); return null; @@ -273,22 +334,21 @@ interface ToolbarState { isItalic: boolean; isCode: boolean; blockType: string; - codeLanguage: string | null; } -function getSelectionBlockInfo(): { blockType: string; codeLanguage: string | null; codeNodeKey: string | null } { +function getSelectionBlockType(): string { const selection = $getSelection(); - if (!$isRangeSelection(selection)) return { blockType: 'paragraph', codeLanguage: null, codeNodeKey: null }; + if (!$isRangeSelection(selection)) return 'paragraph'; const anchorNode = selection.anchor.getNode(); - if (anchorNode.getKey() === 'root') return { blockType: 'paragraph', codeLanguage: null, codeNodeKey: null }; + if (anchorNode.getKey() === 'root') return 'paragraph'; const topElement = anchorNode.getTopLevelElementOrThrow(); - 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 }; + 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'; } function ToolbarPlugin({ disabled }: { disabled: boolean }) { @@ -298,24 +358,18 @@ 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, - codeLanguage, + blockType: getSelectionBlockType(), }); }); }); @@ -364,27 +418,15 @@ function ToolbarPlugin({ disabled }: { disabled: boolean }) { const text = topElement.getTextContent(); if (text) code.append($createTextNode(text)); topElement.replace(code); + // Ensure a paragraph follows so the user can type below the code block + if (!code.getNextSibling()) { + code.insertAfter($createParagraphNode()); + } code.select(); } }); }, [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)" /> @@ -394,26 +436,6 @@ 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' && ( - <> -
-
- - -
- - )}
); } @@ -512,7 +534,7 @@ export const MarkdownComposer = forwardRef - + diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 24988d2..006ad63 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -228,10 +228,11 @@ textarea { /* Code block */ .mc-code-block { display: block; + position: relative; background: rgba(24, 24, 27, 0.8); border: 1px solid #27272a; border-radius: 6px; - padding: 6px 10px; + padding: 26px 10px 6px 10px; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; font-size: 0.9em; white-space: pre-wrap; @@ -240,15 +241,40 @@ 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; +/* Language selector overlay inside code blocks */ +.mc-code-lang-overlay { + position: absolute; + top: 3px; + right: 4px; + z-index: 1; user-select: none; - letter-spacing: 0.03em; +} + +.mc-code-lang-select { + appearance: none; + -webkit-appearance: none; + background: rgba(39, 39, 42, 0.9); + border: 1px solid #3f3f46; + border-radius: 4px; + color: #a1a1aa; + font-family: inherit; + font-size: 10px; + padding: 2px 18px 2px 6px; + outline: none; + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 3px center; + background-size: 10px; +} + +.mc-code-lang-select:hover { + border-color: #52525b; + color: #d4d4d8; +} + +.mc-code-lang-select:focus { + border-color: rgba(99, 102, 241, 0.5); } /* Link */