Files
sandrodach e2b20198e4
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m7s
feat(workspace): add syntax highlighting and language selector to /code blocks
2026-10-25 12:25:04 +01:00

283 lines
9.5 KiB
TypeScript

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
try {
return Prism.highlight(code, grammar, prismLang);
} catch {
return code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
}, [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>
);
};