feat(workspace): add syntax highlighting and language selector to /code blocks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m7s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m7s
This commit is contained in:
1 parent
31847ffaa3
commit
e2b20198e4
5 files changed
+377
-13
No files matched your search
Generated
+17
@@ -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",
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user