feat(workspace): add syntax highlighting and language selector to /code blocks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m7s

This commit is contained in:
sandrodach committed 2026-10-25 12:25:04 +01:00
1 parent 31847ffaa3
commit e2b20198e4
5 files changed
+377 -13

No files matched your search

+17
View File
@@ -8,8 +8,10 @@
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@types/prismjs": "^1.26.6",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"prismjs": "^1.30.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@@ -1224,6 +1226,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
"license": "MIT"
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2242,6 +2250,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+2
View File
@@ -11,8 +11,10 @@
"test:run": "echo 'Frontend checks passed'" "test:run": "echo 'Frontend checks passed'"
}, },
"dependencies": { "dependencies": {
"@types/prismjs": "^1.26.6",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"prismjs": "^1.30.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@@ -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, '&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>
);
};
@@ -51,6 +51,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, User } from '../../lib/types'; import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, User } from '../../lib/types';
import { api, getToken, triggerHaptic } from '../../lib/api'; import { api, getToken, triggerHaptic } from '../../lib/api';
import { CodeBlockItem } from './CodeBlockItem';
interface WorkspaceViewProps { interface WorkspaceViewProps {
currentUser?: User | null; currentUser?: User | null;
@@ -1377,7 +1378,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return; return;
} }
updateBlock(blockId, { type: cmd.type, content: cleanContent, checked: cmd.type === 'todo' ? false : undefined }); updateBlock(blockId, {
type: cmd.type,
content: cleanContent,
checked: cmd.type === 'todo' ? false : undefined,
lang: cmd.type === 'code' ? (block.lang || 'javascript') : block.lang,
});
focusBlock(blockId); focusBlock(blockId);
} }
}; };
@@ -2839,21 +2845,20 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* TYPE: CODE */} {/* TYPE: CODE */}
{block.type === 'code' && ( {block.type === 'code' && (
<div className="p-2 code-block"> <CodeBlockItem
<textarea block={block}
ref={(el) => (blockInputRefs.current[block.id] = el)} isReadOnly={isReadOnly}
readOnly={isReadOnly} inputRef={(el) => (blockInputRefs.current[block.id] = el)}
rows={Math.max(2, (block.content || '').split('\n').length)}
value={block.content}
onFocus={handleBlockFocus} onFocus={handleBlockFocus}
onBlur={handleBlockBlur} onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)} onChangeContent={(content) => handleContentChange(block, content)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} onChangeLang={(lang) => {
className={`w-full bg-transparent text-xs font-mono text-main focus:outline-none resize-none ${ if (!isReadOnly) {
isReadOnly ? 'cursor-default' : '' ensureLock();
}`} updateBlock(block.id, { lang });
}
}}
/> />
</div>
)} )}
{/* TYPE: TABLE */} {/* TYPE: TABLE */}
+58
View File
@@ -358,4 +358,62 @@ body {
border-left: 2px solid var(--accent-primary); border-left: 2px solid var(--accent-primary);
color: var(--text-main); color: var(--text-main);
} }
/* Prism Syntax Highlighting Tokens */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #71717a;
font-style: italic;
}
.token.punctuation {
color: #94a3b8;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #f43f5e;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #10b981;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #38bdf8;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #818cf8;
font-weight: 600;
}
.token.function,
.token.class-name {
color: #fbbf24;
}
.token.regex,
.token.important,
.token.variable {
color: #f97316;
}
} }