From e2b20198e44bde16efce384d371a366e267590a9 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 12:25:04 +0100 Subject: [PATCH] feat(workspace): add syntax highlighting and language selector to /code blocks --- frontend/package-lock.json | 17 ++ frontend/package.json | 2 + .../src/features/workspace/CodeBlockItem.tsx | 282 ++++++++++++++++++ .../src/features/workspace/WorkspaceView.tsx | 37 ++- frontend/src/index.css | 58 ++++ 5 files changed, 380 insertions(+), 16 deletions(-) create mode 100644 frontend/src/features/workspace/CodeBlockItem.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef3a8a0..2201d64 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "mindsift-frontend", "version": "1.0.0", "dependencies": { + "@types/prismjs": "^1.26.6", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", + "prismjs": "^1.30.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -1224,6 +1226,12 @@ "dev": true, "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": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -2242,6 +2250,15 @@ "dev": true, "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 266a668..ee43213 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,10 @@ "test:run": "echo 'Frontend checks passed'" }, "dependencies": { + "@types/prismjs": "^1.26.6", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", + "prismjs": "^1.30.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/frontend/src/features/workspace/CodeBlockItem.tsx b/frontend/src/features/workspace/CodeBlockItem.tsx new file mode 100644 index 0000000..ab200cf --- /dev/null +++ b/frontend/src/features/workspace/CodeBlockItem.tsx @@ -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 = ({ + block, + isReadOnly, + onChangeContent, + onChangeLang, + onFocus, + onBlur, + inputRef, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [copied, setCopied] = useState(false); + const localTextareaRef = useRef(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, '>'); + } + try { + return Prism.highlight(code, grammar, prismLang); + } catch { + return code + .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) => { + 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 ( +
+ {/* Code Block Header Toolbar */} +
+ {/* Left: Language Selection */} +
+ + +
+ + {/* Right: Mode Toggle & Copy */} +
+ {!isReadOnly && ( + + )} + + +
+
+ + {/* Code Block Content */} + {isEditing && !isReadOnly ? ( +
+