MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m7s
283 lines
9.5 KiB
TypeScript
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, '&')
|
|
.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>
|
|
);
|
|
};
|