feat: move language selector onto code block, fix code block insertion

Language selector:
- Replaced toolbar dropdown with an imperative overlay rendered
  directly inside each code block's DOM element
- Overlay uses contentEditable='false' + position:absolute at the
  top-right of the code block, styled as a compact native select
- Plugin re-inserts overlays after each Lexical reconciliation to
  survive DOM diffing

Code block insertion:
- toggleCodeBlock now inserts a trailing paragraph after the code
  block when it would otherwise be the last element, so the user
  can always type regular text below a code block

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 19:42:58 +01:00
co-authored by Copilot
parent 039b9037ee
commit 9ef7150183
2 changed files with 127 additions and 79 deletions
+92 -70
View File
@@ -20,7 +20,7 @@ import {
$convertFromMarkdownString, $convertFromMarkdownString,
$convertToMarkdownString, $convertToMarkdownString,
} from '@lexical/markdown'; } from '@lexical/markdown';
import { $isCodeNode, $createCodeNode } from '@lexical/code'; import { $isCodeNode, $createCodeNode, CodeNode } from '@lexical/code';
import { import {
$isListNode, $isListNode,
INSERT_ORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND,
@@ -43,7 +43,7 @@ import {
type EditorThemeClasses, type EditorThemeClasses,
type LexicalEditor, type LexicalEditor,
} from 'lexical'; } from 'lexical';
import { Bold, Braces, ChevronDown, Code, Italic, List, ListOrdered } from 'lucide-react'; import { Bold, Braces, Code, Italic, List, ListOrdered } from 'lucide-react';
import { import {
inspectMarkdownPaste, inspectMarkdownPaste,
@@ -241,26 +241,87 @@ function MarkdownPastePlugin() {
return null; return null;
} }
/** Syncs a data-code-language attribute on each CodeNode's DOM element for CSS labels. */ /** Attaches an imperative language-selector overlay to each CodeNode's DOM element. */
function CodeBlockLabelPlugin() { function CodeBlockLanguagePlugin() {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
useEffect(() => { useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => { const overlays = new Map<string, { wrapper: HTMLDivElement; select: HTMLSelectElement }>();
editorState.read(() => {
for (const child of $getRoot().getChildren()) { function syncCodeBlock(nodeKey: string) {
if (!$isCodeNode(child)) continue; const codeElement = editor.getElementByKey(nodeKey);
const element = editor.getElementByKey(child.getKey()); if (!codeElement) return;
if (!element) continue;
const lang = child.getLanguage(); let lang: string | null = null;
if (lang) { editor.getEditorState().read(() => {
element.setAttribute('data-code-language', friendlyLanguageName(lang)); const node = $getNodeByKey(nodeKey);
} else { if ($isCodeNode(node)) lang = node.getLanguage() ?? null;
element.removeAttribute('data-code-language');
}
}
}); });
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]); }, [editor]);
return null; return null;
@@ -273,22 +334,21 @@ interface ToolbarState {
isItalic: boolean; isItalic: boolean;
isCode: boolean; isCode: boolean;
blockType: string; blockType: string;
codeLanguage: string | null;
} }
function getSelectionBlockInfo(): { blockType: string; codeLanguage: string | null; codeNodeKey: string | null } { function getSelectionBlockType(): string {
const selection = $getSelection(); const selection = $getSelection();
if (!$isRangeSelection(selection)) return { blockType: 'paragraph', codeLanguage: null, codeNodeKey: null }; if (!$isRangeSelection(selection)) return 'paragraph';
const anchorNode = selection.anchor.getNode(); 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(); const topElement = anchorNode.getTopLevelElementOrThrow();
if ($isHeadingNode(topElement)) return { blockType: topElement.getTag(), codeLanguage: null, codeNodeKey: null }; if ($isHeadingNode(topElement)) return topElement.getTag();
if ($isListNode(topElement)) return { blockType: topElement.getListType() === 'number' ? 'ol' : 'ul', codeLanguage: null, codeNodeKey: null }; if ($isListNode(topElement)) return topElement.getListType() === 'number' ? 'ol' : 'ul';
if ($isCodeNode(topElement)) return { blockType: 'code', codeLanguage: topElement.getLanguage() ?? null, codeNodeKey: topElement.getKey() }; if ($isCodeNode(topElement)) return 'code';
if ($isQuoteNode(topElement)) return { blockType: 'quote', codeLanguage: null, codeNodeKey: null }; if ($isQuoteNode(topElement)) return 'quote';
return { blockType: 'paragraph', codeLanguage: null, codeNodeKey: null }; return 'paragraph';
} }
function ToolbarPlugin({ disabled }: { disabled: boolean }) { function ToolbarPlugin({ disabled }: { disabled: boolean }) {
@@ -298,24 +358,18 @@ function ToolbarPlugin({ disabled }: { disabled: boolean }) {
isItalic: false, isItalic: false,
isCode: false, isCode: false,
blockType: 'paragraph', blockType: 'paragraph',
codeLanguage: null,
}); });
// Preserve the code block key so the language dropdown works even when the select steals focus
const activeCodeBlockKeyRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => { return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => { editorState.read(() => {
const selection = $getSelection(); const selection = $getSelection();
if (!$isRangeSelection(selection)) return; if (!$isRangeSelection(selection)) return;
const { blockType, codeLanguage, codeNodeKey } = getSelectionBlockInfo();
activeCodeBlockKeyRef.current = codeNodeKey;
setState({ setState({
isBold: selection.hasFormat('bold'), isBold: selection.hasFormat('bold'),
isItalic: selection.hasFormat('italic'), isItalic: selection.hasFormat('italic'),
isCode: selection.hasFormat('code'), isCode: selection.hasFormat('code'),
blockType, blockType: getSelectionBlockType(),
codeLanguage,
}); });
}); });
}); });
@@ -364,27 +418,15 @@ function ToolbarPlugin({ disabled }: { disabled: boolean }) {
const text = topElement.getTextContent(); const text = topElement.getTextContent();
if (text) code.append($createTextNode(text)); if (text) code.append($createTextNode(text));
topElement.replace(code); topElement.replace(code);
// Ensure a paragraph follows so the user can type below the code block
if (!code.getNextSibling()) {
code.insertAfter($createParagraphNode());
}
code.select(); code.select();
} }
}); });
}, [editor]); }, [editor]);
const handleLanguageChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
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 ( return (
<div className="flex items-center gap-0.5 border-b border-zinc-700/50 px-2 py-1"> <div className="flex items-center gap-0.5 border-b border-zinc-700/50 px-2 py-1">
<ToolbarButton active={state.isBold} disabled={disabled} icon={<Bold className="size-3.5" />} onClick={formatBold} onMouseDown={preventFocus} title="Bold (Ctrl+B)" /> <ToolbarButton active={state.isBold} disabled={disabled} icon={<Bold className="size-3.5" />} onClick={formatBold} onMouseDown={preventFocus} title="Bold (Ctrl+B)" />
@@ -394,26 +436,6 @@ function ToolbarPlugin({ disabled }: { disabled: boolean }) {
<ToolbarButton active={state.blockType === 'ul'} disabled={disabled} icon={<List className="size-3.5" />} onClick={toggleBulletList} onMouseDown={preventFocus} title="Bullet List" /> <ToolbarButton active={state.blockType === 'ul'} disabled={disabled} icon={<List className="size-3.5" />} onClick={toggleBulletList} onMouseDown={preventFocus} title="Bullet List" />
<ToolbarButton active={state.blockType === 'ol'} disabled={disabled} icon={<ListOrdered className="size-3.5" />} onClick={toggleNumberedList} onMouseDown={preventFocus} title="Numbered List" /> <ToolbarButton active={state.blockType === 'ol'} disabled={disabled} icon={<ListOrdered className="size-3.5" />} onClick={toggleNumberedList} onMouseDown={preventFocus} title="Numbered List" />
<ToolbarButton active={state.blockType === 'code'} disabled={disabled} icon={<Braces className="size-3.5" />} onClick={toggleCodeBlock} onMouseDown={preventFocus} title="Code Block" /> <ToolbarButton active={state.blockType === 'code'} disabled={disabled} icon={<Braces className="size-3.5" />} onClick={toggleCodeBlock} onMouseDown={preventFocus} title="Code Block" />
{state.blockType === 'code' && (
<>
<div className="mx-1 h-4 w-px bg-zinc-700/50" />
<div className="relative">
<select
className="mc-lang-select appearance-none rounded bg-zinc-800 py-0.5 pl-2 pr-6 text-[11px] text-zinc-300 outline-none transition hover:bg-zinc-700 focus:ring-1 focus:ring-indigo-500/50"
disabled={disabled}
onChange={handleLanguageChange}
tabIndex={-1}
title="Code language"
value={state.codeLanguage ?? ''}
>
{CODE_LANGUAGE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-1 top-1/2 size-3 -translate-y-1/2 text-zinc-500" />
</div>
</>
)}
</div> </div>
); );
} }
@@ -512,7 +534,7 @@ export const MarkdownComposer = forwardRef<MarkdownComposerHandle, MarkdownCompo
<LinkPlugin /> <LinkPlugin />
<MarkdownShortcutPlugin transformers={[...markdownEditorTransformers]} /> <MarkdownShortcutPlugin transformers={[...markdownEditorTransformers]} />
<ClearEditorPlugin /> <ClearEditorPlugin />
<CodeBlockLabelPlugin /> <CodeBlockLanguagePlugin />
<ContentTrackingPlugin onContentChange={onContentChange} /> <ContentTrackingPlugin onContentChange={onContentChange} />
<SubmitOnEnterPlugin disabled={disabled} submitRef={submitRef} /> <SubmitOnEnterPlugin disabled={disabled} submitRef={submitRef} />
<MarkdownPastePlugin /> <MarkdownPastePlugin />
+35 -9
View File
@@ -228,10 +228,11 @@ textarea {
/* Code block */ /* Code block */
.mc-code-block { .mc-code-block {
display: block; display: block;
position: relative;
background: rgba(24, 24, 27, 0.8); background: rgba(24, 24, 27, 0.8);
border: 1px solid #27272a; border: 1px solid #27272a;
border-radius: 6px; border-radius: 6px;
padding: 6px 10px; padding: 26px 10px 6px 10px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 0.9em; font-size: 0.9em;
white-space: pre-wrap; white-space: pre-wrap;
@@ -240,15 +241,40 @@ textarea {
overflow-x: auto; overflow-x: auto;
} }
.mc-code-block[data-code-language]::before { /* Language selector overlay inside code blocks */
content: attr(data-code-language); .mc-code-lang-overlay {
display: block; position: absolute;
font-size: 10px; top: 3px;
line-height: 1; right: 4px;
color: #71717a; z-index: 1;
padding-bottom: 4px;
user-select: none; 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 */ /* Link */