mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-09 05:08: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 {
|
import {
|
||||||
$createParagraphNode,
|
$createParagraphNode,
|
||||||
$createTextNode,
|
$createTextNode,
|
||||||
|
$getNodeByKey,
|
||||||
$getRoot,
|
$getRoot,
|
||||||
$getSelection,
|
$getSelection,
|
||||||
$isRangeSelection,
|
$isRangeSelection,
|
||||||
@@ -42,7 +43,7 @@ import {
|
|||||||
type EditorThemeClasses,
|
type EditorThemeClasses,
|
||||||
type LexicalEditor,
|
type LexicalEditor,
|
||||||
} from 'lexical';
|
} from 'lexical';
|
||||||
import { Bold, Braces, Code, Italic, List, ListOrdered } from 'lucide-react';
|
import { Bold, Braces, ChevronDown, Code, Italic, List, ListOrdered } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
inspectMarkdownPaste,
|
inspectMarkdownPaste,
|
||||||
@@ -75,6 +76,50 @@ const editorTheme: EditorThemeClasses = {
|
|||||||
link: 'mc-link',
|
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 ───────────────────────────────────────── */
|
/* ── Public API ───────────────────────────────────────── */
|
||||||
|
|
||||||
export interface MarkdownComposerHandle {
|
export interface MarkdownComposerHandle {
|
||||||
@@ -196,6 +241,31 @@ function MarkdownPastePlugin() {
|
|||||||
return null;
|
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 ──────────────────────────────────────────── */
|
/* ── Toolbar ──────────────────────────────────────────── */
|
||||||
|
|
||||||
interface ToolbarState {
|
interface ToolbarState {
|
||||||
@@ -203,21 +273,22 @@ interface ToolbarState {
|
|||||||
isItalic: boolean;
|
isItalic: boolean;
|
||||||
isCode: boolean;
|
isCode: boolean;
|
||||||
blockType: string;
|
blockType: string;
|
||||||
|
codeLanguage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectionBlockType(): string {
|
function getSelectionBlockInfo(): { blockType: string; codeLanguage: string | null; codeNodeKey: string | null } {
|
||||||
const selection = $getSelection();
|
const selection = $getSelection();
|
||||||
if (!$isRangeSelection(selection)) return 'paragraph';
|
if (!$isRangeSelection(selection)) return { blockType: 'paragraph', codeLanguage: null, codeNodeKey: null };
|
||||||
|
|
||||||
const anchorNode = selection.anchor.getNode();
|
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();
|
const topElement = anchorNode.getTopLevelElementOrThrow();
|
||||||
if ($isHeadingNode(topElement)) return topElement.getTag();
|
if ($isHeadingNode(topElement)) return { blockType: topElement.getTag(), codeLanguage: null, codeNodeKey: null };
|
||||||
if ($isListNode(topElement)) return topElement.getListType() === 'number' ? 'ol' : 'ul';
|
if ($isListNode(topElement)) return { blockType: topElement.getListType() === 'number' ? 'ol' : 'ul', codeLanguage: null, codeNodeKey: null };
|
||||||
if ($isCodeNode(topElement)) return 'code';
|
if ($isCodeNode(topElement)) return { blockType: 'code', codeLanguage: topElement.getLanguage() ?? null, codeNodeKey: topElement.getKey() };
|
||||||
if ($isQuoteNode(topElement)) return 'quote';
|
if ($isQuoteNode(topElement)) return { blockType: 'quote', codeLanguage: null, codeNodeKey: null };
|
||||||
return 'paragraph';
|
return { blockType: 'paragraph', codeLanguage: null, codeNodeKey: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolbarPlugin({ disabled }: { disabled: boolean }) {
|
function ToolbarPlugin({ disabled }: { disabled: boolean }) {
|
||||||
@@ -227,18 +298,24 @@ 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: getSelectionBlockType(),
|
blockType,
|
||||||
|
codeLanguage,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -292,6 +369,22 @@ function ToolbarPlugin({ disabled }: { disabled: boolean }) {
|
|||||||
});
|
});
|
||||||
}, [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)" />
|
||||||
@@ -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 === '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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -399,6 +512,7 @@ export const MarkdownComposer = forwardRef<MarkdownComposerHandle, MarkdownCompo
|
|||||||
<LinkPlugin />
|
<LinkPlugin />
|
||||||
<MarkdownShortcutPlugin transformers={[...markdownEditorTransformers]} />
|
<MarkdownShortcutPlugin transformers={[...markdownEditorTransformers]} />
|
||||||
<ClearEditorPlugin />
|
<ClearEditorPlugin />
|
||||||
|
<CodeBlockLabelPlugin />
|
||||||
<ContentTrackingPlugin onContentChange={onContentChange} />
|
<ContentTrackingPlugin onContentChange={onContentChange} />
|
||||||
<SubmitOnEnterPlugin disabled={disabled} submitRef={submitRef} />
|
<SubmitOnEnterPlugin disabled={disabled} submitRef={submitRef} />
|
||||||
<MarkdownPastePlugin />
|
<MarkdownPastePlugin />
|
||||||
|
|||||||
@@ -240,6 +240,17 @@ textarea {
|
|||||||
overflow-x: auto;
|
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 */
|
/* Link */
|
||||||
.mc-link {
|
.mc-link {
|
||||||
color: #818cf8;
|
color: #818cf8;
|
||||||
|
|||||||
Reference in New Issue
Block a user