feat: add code block language selector to composer

When the cursor is inside a code block, the formatting toolbar shows
a language dropdown (22 languages). Selecting a language updates the
CodeNode, which the markdown serializer writes as the fenced code
block language tag (e.g. \\\	ypescript).

A CodeBlockLabelPlugin syncs a data-code-language attribute on each
code block element so CSS ::before shows the friendly language name
as a small label at the top of the block.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 19:37:08 +01:00
co-authored by Copilot
parent 1f9203e1da
commit 039b9037ee
2 changed files with 135 additions and 10 deletions
+124 -10
View File
@@ -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<string, string> = 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<string | null>(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<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 (
<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)" />
@@ -301,6 +394,26 @@ 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 === '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" />
{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>
);
}
@@ -399,6 +512,7 @@ export const MarkdownComposer = forwardRef<MarkdownComposerHandle, MarkdownCompo
<LinkPlugin />
<MarkdownShortcutPlugin transformers={[...markdownEditorTransformers]} />
<ClearEditorPlugin />
<CodeBlockLabelPlugin />
<ContentTrackingPlugin onContentChange={onContentChange} />
<SubmitOnEnterPlugin disabled={disabled} submitRef={submitRef} />
<MarkdownPastePlugin />
+11
View File
@@ -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;