feat(workspace): add syntax highlighting and language selector to /code blocks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m7s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m7s
This commit is contained in:
1 parent
31847ffaa3
commit
e2b20198e4
5 files changed
+380
-16
No files matched your search
@@ -0,0 +1,282 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-cpp';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-php';
|
||||
import 'prismjs/components/prism-docker';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import { Code, Copy, Check, Eye, Edit3 } from 'lucide-react';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
|
||||
export interface CodeBlockItemProps {
|
||||
block: DocumentBlock;
|
||||
isReadOnly: boolean;
|
||||
onChangeContent: (content: string) => void;
|
||||
onChangeLang: (lang: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
inputRef?: (el: HTMLTextAreaElement | null) => void;
|
||||
}
|
||||
|
||||
export const CODE_LANGUAGES = [
|
||||
{ id: 'javascript', label: 'JavaScript' },
|
||||
{ id: 'typescript', label: 'TypeScript' },
|
||||
{ id: 'python', label: 'Python' },
|
||||
{ id: 'go', label: 'Go' },
|
||||
{ id: 'bash', label: 'Bash / Shell' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
{ id: 'html', label: 'HTML / XML', alias: 'markup' },
|
||||
{ id: 'css', label: 'CSS' },
|
||||
{ id: 'sql', label: 'SQL' },
|
||||
{ id: 'rust', label: 'Rust' },
|
||||
{ id: 'cpp', label: 'C++' },
|
||||
{ id: 'c', label: 'C' },
|
||||
{ id: 'java', label: 'Java' },
|
||||
{ id: 'php', label: 'PHP' },
|
||||
{ id: 'yaml', label: 'YAML' },
|
||||
{ id: 'docker', label: 'Dockerfile' },
|
||||
{ id: 'markdown', label: 'Markdown' },
|
||||
{ id: 'text', label: 'Plain Text' },
|
||||
];
|
||||
|
||||
export const CodeBlockItem: React.FC<CodeBlockItemProps> = ({
|
||||
block,
|
||||
isReadOnly,
|
||||
onChangeContent,
|
||||
onChangeLang,
|
||||
onFocus,
|
||||
onBlur,
|
||||
inputRef,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const currentLang = block.lang || 'javascript';
|
||||
|
||||
// Highlighted HTML generated by Prism
|
||||
const highlightedCode = useMemo(() => {
|
||||
const code = block.content || '';
|
||||
if (!code) return '';
|
||||
|
||||
const prismLang = currentLang === 'html' ? 'markup' : currentLang;
|
||||
const grammar = Prism.languages[prismLang] || Prism.languages.javascript;
|
||||
if (!grammar) {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
try {
|
||||
return Prism.highlight(code, grammar, prismLang);
|
||||
} catch {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}, [block.content, currentLang]);
|
||||
|
||||
// Code lines for line numbers
|
||||
const lines = useMemo(() => {
|
||||
return (block.content || '').split('\n');
|
||||
}, [block.content]);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(block.content || '');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
// Tab key indent/outdent
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const val = textarea.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (val.substring(start - 2, start) === ' ') {
|
||||
const newVal = val.substring(0, start - 2) + val.substring(start);
|
||||
onChangeContent(newVal);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = Math.max(0, start - 2);
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
const newVal = val.substring(0, start) + ' ' + val.substring(end);
|
||||
onChangeContent(newVal);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const switchToEdit = () => {
|
||||
if (isReadOnly) return;
|
||||
setIsEditing(true);
|
||||
setTimeout(() => {
|
||||
localTextareaRef.current?.focus();
|
||||
}, 20);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-panel overflow-hidden shadow-xs my-1 group/code">
|
||||
{/* Code Block Header Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-canvas border-b border-border text-xs">
|
||||
{/* Left: Language Selection */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<select
|
||||
value={currentLang}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => onChangeLang(e.target.value)}
|
||||
className="bg-transparent text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded px-1.5 py-0.5 outline-none cursor-pointer"
|
||||
>
|
||||
{CODE_LANGUAGES.map((lang) => (
|
||||
<option key={lang.id} value={lang.id} className="bg-panel text-main">
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Right: Mode Toggle & Copy */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isEditing) {
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
switchToEdit();
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-[11px] font-medium text-muted hover:text-main hover:bg-panel-elevated transition-colors flex items-center gap-1 cursor-pointer"
|
||||
title={isEditing ? 'Zur farbigen Vorschau wechseln (Esc)' : 'Code bearbeiten'}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Eye className="w-3 h-3 text-accent" />
|
||||
<span>Vorschau</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Edit3 className="w-3 h-3 text-muted" />
|
||||
<span>Bearbeiten</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors flex items-center gap-1 cursor-pointer ${
|
||||
copied
|
||||
? 'text-emerald-500 bg-emerald-500/10'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
title="Code in Zwischenablage kopieren"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
<span>Kopiert!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 opacity-70" />
|
||||
<span>Kopieren</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Block Content */}
|
||||
{isEditing && !isReadOnly ? (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
localTextareaRef.current = el;
|
||||
if (inputRef) inputRef(el);
|
||||
}}
|
||||
rows={Math.max(3, lines.length)}
|
||||
value={block.content || ''}
|
||||
onFocus={() => onFocus && onFocus()}
|
||||
onBlur={() => {
|
||||
if (onBlur) onBlur();
|
||||
}}
|
||||
onChange={(e) => onChangeContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="// Code hier eingeben..."
|
||||
className="w-full p-3 bg-panel-elevated/40 font-mono text-xs text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint"
|
||||
/>
|
||||
<div className="px-3 py-1 bg-canvas/40 border-t border-border/50 text-[10px] text-faint flex items-center justify-between">
|
||||
<span>Tab: Einrücken (2 Leerzeichen) • Shift+Tab: Ausrücken</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="hover:text-accent cursor-pointer"
|
||||
>
|
||||
Vorschau anzeigen (Esc)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={switchToEdit}
|
||||
className={`relative p-3 font-mono text-xs overflow-x-auto ${
|
||||
isReadOnly ? '' : 'cursor-pointer hover:bg-panel-elevated/20 transition-colors'
|
||||
}`}
|
||||
title={isReadOnly ? undefined : 'Klicken zum Bearbeiten'}
|
||||
>
|
||||
{block.content ? (
|
||||
<div className="flex gap-3">
|
||||
{/* Line numbers */}
|
||||
<div className="select-none text-right text-faint opacity-50 font-mono text-xs leading-relaxed flex-shrink-0">
|
||||
{lines.map((_, idx) => (
|
||||
<div key={idx}>{idx + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Syntax highlighted code */}
|
||||
<pre className="m-0 p-0 font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto flex-1">
|
||||
<code
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
className={`language-${currentLang}`}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-faint italic py-2">
|
||||
{isReadOnly ? '(Kein Code hinterlegt)' : '(Klicken, um Code einzugeben...)'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user