diff --git a/frontend/src/features/workspace/CodeBlockEditor.tsx b/frontend/src/features/workspace/CodeBlockEditor.tsx new file mode 100644 index 0000000..8f314b7 --- /dev/null +++ b/frontend/src/features/workspace/CodeBlockEditor.tsx @@ -0,0 +1,625 @@ +import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; +import Prism from 'prismjs'; +import { Code, 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; + 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, + onConvertToText, + 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); + + 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) => { + if (preRef.current) { + preRef.current.scrollTop = e.currentTarget.scrollTop; + preRef.current.scrollLeft = e.currentTarget.scrollLeft; + } + }; + + const rawLang = block.lang || ''; + const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : ''; + const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue)); + + return ( +
+ {/* Code Block Header */} +
+
+ + {canEdit ? ( + + ) : ( + {block.lang || 'Text'} + )} +
+ +
+ + + {canEdit && onConvertToText && ( + + )} +
+
+ + {/* Live Highlighted Overlay + Textarea */} +
+ +