mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-04 02:48:44 +02:00
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:
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user