import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; import Prism from 'prismjs'; import { Copy, Check } from 'lucide-react'; import { DocumentBlock } from '../../lib/types'; Prism.manual = true; if (typeof globalThis !== 'undefined') { (globalThis as Record).Prism = Prism; } export interface CodeLanguageOption { id: string; label: string; } export interface CodeLanguageGroup { label: string; options: CodeLanguageOption[]; } export const CODE_LANGUAGE_GROUPS: CodeLanguageGroup[] = [ { label: 'Konfiguration & Daten', options: [ { id: 'yaml', label: 'YAML' }, { id: 'json', label: 'JSON' }, { id: 'toml', label: 'TOML' }, { id: 'ini', label: 'INI' }, { id: 'docker', label: 'Dockerfile' }, { id: 'nginx', label: 'Nginx' }, { id: 'hcl', label: 'HCL / Terraform' }, { id: 'xml', label: 'XML' }, { id: 'csv', label: 'CSV' }, { id: 'graphql', label: 'GraphQL' }, { id: 'protobuf', label: 'Protocol Buffers' }, ], }, { label: 'Shell & Betrieb', options: [ { id: 'bash', label: 'Bash / Shell' }, { id: 'powershell', label: 'PowerShell' }, { id: 'makefile', label: 'Makefile' }, { id: 'systemd', label: 'Systemd' }, { id: 'vim', label: 'Vim' }, { id: 'diff', label: 'Diff' }, { id: 'git', label: 'Git' }, { id: 'log', label: 'Log' }, ], }, { label: 'Web & Frontend', options: [ { id: 'javascript', label: 'JavaScript' }, { id: 'typescript', label: 'TypeScript' }, { id: 'jsx', label: 'JSX' }, { id: 'tsx', label: 'TSX' }, { id: 'markup', label: 'HTML / Markup' }, { id: 'html', label: 'HTML' }, { id: 'css', label: 'CSS' }, { id: 'scss', label: 'SCSS' }, ], }, { label: 'Skriptsprachen', options: [ { id: 'python', label: 'Python' }, { id: 'php', label: 'PHP' }, { id: 'ruby', label: 'Ruby' }, { id: 'lua', label: 'Lua' }, { id: 'perl', label: 'Perl' }, ], }, { label: 'System & Backend', options: [ { id: 'go', label: 'Go' }, { id: 'rust', label: 'Rust' }, { id: 'sql', label: 'SQL' }, { id: 'c', label: 'C' }, { id: 'cpp', label: 'C++' }, { id: 'csharp', label: 'C#' }, { id: 'java', label: 'Java' }, { id: 'kotlin', label: 'Kotlin' }, { id: 'swift', label: 'Swift' }, { id: 'dart', label: 'Dart' }, { id: 'zig', label: 'Zig' }, { id: 'elixir', label: 'Elixir' }, { id: 'scala', label: 'Scala' }, { id: 'haskell', label: 'Haskell' }, ], }, { label: 'Dokumentation & Text', options: [ { id: 'markdown', label: 'Markdown' }, { id: 'latex', label: 'LaTeX' }, { id: 'regex', label: 'Regex' }, { id: 'text', label: 'Plain Text' }, ], }, ]; export const CODE_LANGUAGES: CodeLanguageOption[] = CODE_LANGUAGE_GROUPS.flatMap((g) => g.options); const KNOWN_OPTION_IDS = new Set(CODE_LANGUAGES.map((l) => l.id)); const LANGUAGE_ALIASES: Record = { yml: 'yaml', sh: 'bash', shell: 'bash', zsh: 'bash', js: 'javascript', mjs: 'javascript', cjs: 'javascript', ts: 'typescript', mts: 'typescript', cts: 'typescript', py: 'python', rb: 'ruby', rs: 'rust', cs: 'csharp', 'c#': 'csharp', dotnet: 'csharp', 'c++': 'cpp', cxx: 'cpp', cc: 'cpp', hpp: 'cpp', h: 'c', kt: 'kotlin', kts: 'kotlin', hs: 'haskell', md: 'markdown', tex: 'latex', dockerfile: 'docker', containerfile: 'docker', htm: 'html', svg: 'xml', proto: 'protobuf', ps1: 'powershell', pwsh: 'powershell', make: 'makefile', gql: 'graphql', tf: 'hcl', terraform: 'hcl', ex: 'elixir', exs: 'elixir', pl: 'perl', conf: 'ini', golang: 'go', plaintext: 'text', plain: 'text', txt: 'text', }; const SHORT_PREFIX_CANDIDATES = new Set([ 'c', 'go', 'js', 'ts', 'sh', 'py', 'rb', 'rs', 'cs', 'kt', 'hs', 'md', 'ex', 'pl', 'tf', 'tex', 'htm', 'yml', 'doc', 'docker', 'make', 'proto', 'plain', ]); export function resolveLanguageId(raw?: string): string | undefined { if (!raw) return undefined; const key = raw.trim().toLowerCase(); if (!key) return undefined; if (KNOWN_OPTION_IDS.has(key)) return key; return LANGUAGE_ALIASES[key]; } // True when a language token in `/code ` or ````` cannot be a prefix of another language user is still typing export function isUnambiguousLanguageShortcut(raw?: string): boolean { if (!raw) return false; const key = raw.trim().toLowerCase(); if (SHORT_PREFIX_CANDIDATES.has(key)) return false; return Boolean(resolveLanguageId(key)); } // On-demand loaders for Prism grammars (Vite code-splits each into its own chunk) const PRISM_LOADERS: Record Promise> = { yaml: () => import('prismjs/components/prism-yaml'), json: () => import('prismjs/components/prism-json'), toml: () => import('prismjs/components/prism-toml'), ini: () => import('prismjs/components/prism-ini'), docker: () => import('prismjs/components/prism-docker'), nginx: () => import('prismjs/components/prism-nginx'), hcl: () => import('prismjs/components/prism-hcl'), csv: () => import('prismjs/components/prism-csv'), graphql: () => import('prismjs/components/prism-graphql'), protobuf: () => import('prismjs/components/prism-protobuf'), bash: () => import('prismjs/components/prism-bash'), powershell: () => import('prismjs/components/prism-powershell'), makefile: () => import('prismjs/components/prism-makefile'), systemd: () => import('prismjs/components/prism-systemd'), vim: () => import('prismjs/components/prism-vim'), diff: () => import('prismjs/components/prism-diff'), git: () => import('prismjs/components/prism-git'), log: () => import('prismjs/components/prism-log'), typescript: () => import('prismjs/components/prism-typescript'), jsx: () => import('prismjs/components/prism-jsx'), tsx: () => import('prismjs/components/prism-tsx'), scss: () => import('prismjs/components/prism-scss'), python: () => import('prismjs/components/prism-python'), 'markup-templating': () => import('prismjs/components/prism-markup-templating'), php: () => import('prismjs/components/prism-php'), ruby: () => import('prismjs/components/prism-ruby'), lua: () => import('prismjs/components/prism-lua'), perl: () => import('prismjs/components/prism-perl'), go: () => import('prismjs/components/prism-go'), rust: () => import('prismjs/components/prism-rust'), sql: () => import('prismjs/components/prism-sql'), c: () => import('prismjs/components/prism-c'), cpp: () => import('prismjs/components/prism-cpp'), csharp: () => import('prismjs/components/prism-csharp'), java: () => import('prismjs/components/prism-java'), kotlin: () => import('prismjs/components/prism-kotlin'), swift: () => import('prismjs/components/prism-swift'), dart: () => import('prismjs/components/prism-dart'), zig: () => import('prismjs/components/prism-zig'), elixir: () => import('prismjs/components/prism-elixir'), scala: () => import('prismjs/components/prism-scala'), haskell: () => import('prismjs/components/prism-haskell'), markdown: () => import('prismjs/components/prism-markdown'), latex: () => import('prismjs/components/prism-latex'), regex: () => import('prismjs/components/prism-regex'), }; // Prerequisite grammars that must be loaded in order before the target grammar const PRISM_DEPENDENCIES: Record = { tsx: ['jsx', 'typescript'], cpp: ['c'], php: ['markup-templating'], scala: ['java'], }; const inFlightGrammarLoads = new Map>(); export function toPrismGrammarKey(lang?: string): string { if (!lang) return ''; const resolved = resolveLanguageId(lang) || lang.trim().toLowerCase(); if (!resolved || resolved === 'text' || resolved === 'plaintext') return ''; if (resolved === 'html') return 'markup'; return resolved; } export async function loadPrismLanguage(lang?: string): Promise { const key = toPrismGrammarKey(lang); if (!key) return false; if (Prism.languages[key]) return true; const existing = inFlightGrammarLoads.get(key); if (existing) return existing; const task = (async () => { try { const deps = PRISM_DEPENDENCIES[key]; if (deps) { for (const dep of deps) { if (!Prism.languages[dep]) { await loadPrismLanguage(dep); } } } const loader = PRISM_LOADERS[key]; if (loader) { await loader(); } return Boolean(Prism.languages[key]); } catch { return false; } finally { inFlightGrammarLoads.delete(key); } })(); inFlightGrammarLoads.set(key, task); return task; } export function escapeHtml(raw: string): string { return raw .replace(/&/g, '&') .replace(//g, '>'); } export interface CodeBlockEditorProps { block: DocumentBlock; content?: string; canEdit?: boolean; textareaRef?: React.MutableRefObject; registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void; onChangeContent?: (content: string) => void; onChangeLang?: (lang: string) => void; /** Kept for callers; converting back to text lives in the bullet's action bubble now */ onConvertToText?: () => void; handleFocus?: (e: React.FocusEvent) => void; handleBlur?: (e: React.FocusEvent) => void; handleContentChange?: (e: React.ChangeEvent) => void; handleKeyDown?: (e: React.KeyboardEvent) => void; } export const CodeBlockEditor: React.FC = ({ block, content, canEdit = true, textareaRef, registerTextareaRef, onChangeContent, onChangeLang, handleFocus, handleBlur, handleContentChange, handleKeyDown, }) => { const isControlled = content !== undefined; const [uncontrolledContent, setUncontrolledContent] = useState(block.content || ''); const currentContent = isControlled ? content : uncontrolledContent; useEffect(() => { if (!isControlled) { setUncontrolledContent(block.content || ''); } }, [block.content, isControlled]); const [copied, setCopied] = useState(false); const [loadedGrammar, setLoadedGrammar] = useState(''); const copyTimerRef = useRef(null); const localTextareaRef = useRef(null); const preRef = useRef(null); const lineNumbersRef = useRef(null); useEffect(() => { return () => { if (copyTimerRef.current !== null) { window.clearTimeout(copyTimerRef.current); } }; }, []); const prismLang = toPrismGrammarKey(block.lang); useEffect(() => { if (!prismLang) return; if (Prism.languages[prismLang]) { setLoadedGrammar(prismLang); return; } let cancelled = false; loadPrismLanguage(prismLang).then((loaded) => { if (!cancelled && loaded) { setLoadedGrammar(prismLang); } }); return () => { cancelled = true; }; }, [prismLang]); const highlightedHtml = useMemo(() => { if (!currentContent) return ''; const activeLang = prismLang && (Prism.languages[prismLang] || loadedGrammar === prismLang) ? prismLang : ''; if (!activeLang) return escapeHtml(currentContent); const grammar = Prism.languages[activeLang]; if (!grammar) return escapeHtml(currentContent); try { return Prism.highlight(currentContent, grammar, activeLang); } catch { return escapeHtml(currentContent); } }, [currentContent, prismLang, loadedGrammar]); const adjustHeight = useCallback(() => { const el = localTextareaRef.current; if (!el) return; el.style.height = 'auto'; el.style.height = `${Math.max(22, el.scrollHeight)}px`; }, []); useLayoutEffect(() => { adjustHeight(); }, [currentContent, adjustHeight]); const handleCopy = (e: React.MouseEvent) => { e.stopPropagation(); try { const p = navigator.clipboard?.writeText(currentContent); if (p && typeof p.catch === 'function') { p.catch(() => {}); } } catch { // ignore clipboard errors } setCopied(true); if (copyTimerRef.current !== null) { window.clearTimeout(copyTimerRef.current); } copyTimerRef.current = window.setTimeout(() => { copyTimerRef.current = null; setCopied(false); }, 1500); }; const applyContentUpdate = (newVal: string, target: HTMLTextAreaElement) => { if (!isControlled) { setUncontrolledContent(newVal); } if (onChangeContent) { onChangeContent(newVal); } else if (handleContentChange) { target.value = newVal; handleContentChange({ target, currentTarget: target, } as React.ChangeEvent); } }; const onTextareaChange = (e: React.ChangeEvent) => { if (!canEdit) return; if (!isControlled) { setUncontrolledContent(e.target.value); } if (handleContentChange) { handleContentChange(e); } else if (onChangeContent) { onChangeContent(e.target.value); } }; const onTextareaKeyDown = (e: React.KeyboardEvent) => { if (canEdit && e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); const ta = e.currentTarget; const val = currentContent; const start = ta.selectionStart ?? 0; const end = ta.selectionEnd ?? start; const lineStart = start === 0 ? 0 : val.lastIndexOf('\n', start - 1) + 1; const effectiveEnd = end > start && val[end - 1] === '\n' ? end - 1 : end; const lineEndIdx = val.indexOf('\n', effectiveEnd); const blockEnd = lineEndIdx === -1 ? val.length : lineEndIdx; const lines = val.slice(lineStart, blockEnd).split('\n'); if (!e.shiftKey) { const updatedLines = lines.map((line) => ' ' + line); const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd); const newStart = start + 2; const newEnd = end + updatedLines.length * 2; applyContentUpdate(newVal, ta); ta.value = newVal; ta.setSelectionRange(newStart, newEnd); setTimeout(() => { if (localTextareaRef.current) { localTextareaRef.current.setSelectionRange(newStart, newEnd); } }, 0); } else { let removedFirst = 0; let removedTotal = 0; const updatedLines = lines.map((line, idx) => { let removeCount = 0; if (line.startsWith(' ')) removeCount = 2; else if (line.startsWith(' ') || line.startsWith('\t')) removeCount = 1; if (idx === 0) removedFirst = removeCount; removedTotal += removeCount; return line.slice(removeCount); }); if (removedTotal > 0) { const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd); const newStart = Math.max(lineStart, start - removedFirst); const newEnd = Math.max(newStart, end - removedTotal); applyContentUpdate(newVal, ta); ta.value = newVal; ta.setSelectionRange(newStart, newEnd); setTimeout(() => { if (localTextareaRef.current) { localTextareaRef.current.setSelectionRange(newStart, newEnd); } }, 0); } } return; } handleKeyDown?.(e); }; const handleScroll = (e: React.UIEvent) => { const target = e.currentTarget; if (preRef.current) { preRef.current.scrollTop = target.scrollTop; preRef.current.scrollLeft = target.scrollLeft; } if (lineNumbersRef.current) { lineNumbersRef.current.scrollTop = target.scrollTop; } }; const handleLineNumbersScroll = (e: React.UIEvent) => { const target = e.currentTarget; if (localTextareaRef.current) { localTextareaRef.current.scrollTop = target.scrollTop; } if (preRef.current) { preRef.current.scrollTop = target.scrollTop; } }; const handleLineNumbersWheel = (e: React.WheelEvent) => { if (localTextareaRef.current) { localTextareaRef.current.scrollTop += e.deltaY; } }; const lineCount = useMemo(() => { return currentContent.split('\n').length; }, [currentContent]); const rawLang = block.lang || ''; const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : ''; const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue)); return (
{/* Language at the top left; no header bar */}
{canEdit ? ( ) : ( {block.lang || 'Text'} )}
{/* Copy: round icon button at the bottom right, shown on hover (always on touch screens) */} {/* Live Highlighted Overlay + Textarea with Line Numbers */}
{/* Line Numbers Column */} {/* Code Content (Overlay + Textarea) */}