Spaces:
Running
Running
| import React, { useRef, useState, useEffect, useMemo } from "react"; | |
| import { Editor } from "@monaco-editor/react"; | |
| import type { editor } from "monaco-editor"; | |
| import { Button } from "@/components/ui/button"; | |
| import { ChevronUp, ChevronDown } from "lucide-react"; | |
| // Add interface for range - export for reuse | |
| export interface Range { | |
| start: number; | |
| end: number; | |
| } | |
| interface LineMapping { | |
| originalLineNumber: number; | |
| startChar: number; | |
| endChar: number; | |
| content: string; | |
| } | |
| interface MonacoEditorWithHighlightsProps { | |
| numberedLines: string[]; // each already prefixed with <L#> | |
| highlightRanges: Range[]; | |
| showNavigationControls?: boolean; | |
| height?: string; | |
| theme?: string; | |
| className?: string; | |
| } | |
| export const MonacoEditorWithHighlights: React.FC< | |
| MonacoEditorWithHighlightsProps | |
| > = ({ | |
| numberedLines, | |
| highlightRanges, | |
| showNavigationControls = true, | |
| height = "100%", | |
| theme = "vs-dark", | |
| className = "", | |
| }) => { | |
| const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null); | |
| const [currentHighlightIndex, setCurrentHighlightIndex] = useState(0); | |
| const [decorations, setDecorations] = useState<string[]>([]); | |
| // Parse numbered lines and reconstruct original content | |
| const { reconstructedContent, characterRanges } = useMemo(() => { | |
| const mapping: LineMapping[] = []; | |
| // Parse each numbered line and extract line numbers | |
| const parsedLines: { | |
| lineNumber: number; | |
| content: string; | |
| index: number; | |
| }[] = []; | |
| for (let i = 0; i < numberedLines.length; i++) { | |
| const line = numberedLines[i]; | |
| if (!line) continue; | |
| const match = line.match(/^<L(\d+)>\s*(.*)$/); | |
| if (!match) continue; | |
| const [, lineNumber, content] = match; | |
| if (!lineNumber || content === undefined) continue; | |
| parsedLines.push({ | |
| lineNumber: parseInt(lineNumber), | |
| content, | |
| index: i, | |
| }); | |
| } | |
| // Sort by line number to ensure proper order | |
| parsedLines.sort((a, b) => a.lineNumber - b.lineNumber); | |
| // Simple concatenation - just join all content together | |
| let rawContent = ""; | |
| for (let i = 0; i < parsedLines.length; i++) { | |
| const currentLine = parsedLines[i]; | |
| if (!currentLine) continue; | |
| // Track character mapping for highlighting | |
| const startChar = rawContent.length; | |
| rawContent += currentLine.content; | |
| const endChar = rawContent.length; | |
| mapping.push({ | |
| originalLineNumber: currentLine.lineNumber, | |
| startChar, | |
| endChar, | |
| content: currentLine.content, | |
| }); | |
| } | |
| // Process the raw content for better formatting | |
| let processedContent = rawContent; | |
| // Convert literal \\n to actual line breaks | |
| processedContent = processedContent.replace(/\\n/g, "\n"); | |
| // Convert literal \\t to actual tabs | |
| processedContent = processedContent.replace(/\\t/g, "\t"); | |
| // Try to detect and format JSON content (handle multiple JSON objects) | |
| const jsonRegex = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g; | |
| const jsonMatches = processedContent.match(jsonRegex); | |
| if (jsonMatches) { | |
| jsonMatches.forEach((jsonStr) => { | |
| try { | |
| const parsed = JSON.parse(jsonStr); | |
| const formatted = JSON.stringify(parsed, null, 2); | |
| processedContent = processedContent.replace(jsonStr, formatted); | |
| } catch (e) { | |
| // If JSON parsing fails, keep original content | |
| } | |
| }); | |
| } | |
| // Clean up excessive whitespace but preserve intentional formatting | |
| processedContent = processedContent | |
| .replace(/[ \t]+/g, " ") // Multiple spaces/tabs to single space | |
| .replace(/\n\s*\n\s*\n/g, "\n\n") // Multiple newlines to double newline max | |
| .trim(); // Remove leading/trailing whitespace | |
| // Update character mapping based on content transformation | |
| const contentLengthDiff = processedContent.length - rawContent.length; | |
| // Convert line-based ranges to character-based ranges | |
| const charRanges = highlightRanges | |
| .map((range) => { | |
| const startMapping = mapping.find( | |
| (m) => m.originalLineNumber === range.start + 1 | |
| ); | |
| const endMapping = mapping.find( | |
| (m) => m.originalLineNumber === range.end + 1 | |
| ); | |
| if (!startMapping || !endMapping) { | |
| return { startChar: 0, endChar: 0 }; | |
| } | |
| // Adjust for content transformation | |
| let adjustedStartChar = startMapping.startChar; | |
| let adjustedEndChar = endMapping.endChar; | |
| // Simple adjustment - this might need refinement for complex cases | |
| if (contentLengthDiff > 0) { | |
| const adjustmentRatio = processedContent.length / rawContent.length; | |
| adjustedStartChar = Math.floor( | |
| startMapping.startChar * adjustmentRatio | |
| ); | |
| adjustedEndChar = Math.floor(endMapping.endChar * adjustmentRatio); | |
| } | |
| return { | |
| startChar: adjustedStartChar, | |
| endChar: adjustedEndChar, | |
| }; | |
| }) | |
| .filter((range) => range.startChar !== range.endChar); | |
| return { | |
| reconstructedContent: processedContent, | |
| characterRanges: charRanges, | |
| }; | |
| }, [numberedLines, highlightRanges]); | |
| // Sort ranges by start position for navigation | |
| const sortedRanges = [...characterRanges].sort( | |
| (a, b) => a.startChar - b.startChar | |
| ); | |
| // Scroll to specific highlighted range in Monaco Editor | |
| const scrollToHighlight = (rangeIndex: number) => { | |
| if (!editorRef.current || sortedRanges.length === 0) return; | |
| const range = sortedRanges[rangeIndex]; | |
| if (!range) return; | |
| try { | |
| const model = editorRef.current.getModel(); | |
| if (!model) { | |
| // If model isn't ready, retry after a short delay | |
| setTimeout(() => scrollToHighlight(rangeIndex), 100); | |
| return; | |
| } | |
| const startPosition = model.getPositionAt(range.startChar); | |
| // Scroll to the position with animation | |
| editorRef.current.revealPositionInCenter(startPosition, 1); // 1 = animated | |
| // Update decorations to highlight current range | |
| updateHighlightDecorations(); | |
| } catch (error) { | |
| console.warn("Error scrolling to highlight:", error); | |
| // Retry once after a delay | |
| setTimeout(() => { | |
| try { | |
| const startPosition = editorRef | |
| .current!.getModel()! | |
| .getPositionAt(range.startChar); | |
| editorRef.current!.revealPositionInCenter(startPosition, 1); | |
| updateHighlightDecorations(); | |
| } catch (retryError) { | |
| console.warn("Retry failed for scrolling to highlight:", retryError); | |
| } | |
| }, 200); | |
| } | |
| }; | |
| // Navigation handlers | |
| const goToNextHighlight = () => { | |
| if (sortedRanges.length === 0) return; | |
| const nextIndex = (currentHighlightIndex + 1) % sortedRanges.length; | |
| setCurrentHighlightIndex(nextIndex); | |
| scrollToHighlight(nextIndex); | |
| }; | |
| const goToPreviousHighlight = () => { | |
| if (sortedRanges.length === 0) return; | |
| const prevIndex = | |
| currentHighlightIndex === 0 | |
| ? sortedRanges.length - 1 | |
| : currentHighlightIndex - 1; | |
| setCurrentHighlightIndex(prevIndex); | |
| scrollToHighlight(prevIndex); | |
| }; | |
| // Keyboard navigation | |
| useEffect(() => { | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if ( | |
| e.target instanceof HTMLInputElement || | |
| e.target instanceof HTMLTextAreaElement | |
| ) { | |
| return; // Don't interfere with input fields | |
| } | |
| if (e.key === ">" || (e.key === "ArrowDown" && e.ctrlKey)) { | |
| e.preventDefault(); | |
| goToNextHighlight(); | |
| } else if (e.key === "<" || (e.key === "ArrowUp" && e.ctrlKey)) { | |
| e.preventDefault(); | |
| goToPreviousHighlight(); | |
| } | |
| }; | |
| document.addEventListener("keydown", handleKeyDown); | |
| return () => document.removeEventListener("keydown", handleKeyDown); | |
| }, [currentHighlightIndex, sortedRanges.length]); | |
| // Scroll first highlighted range into view on initial load or when ranges change | |
| useEffect(() => { | |
| if (sortedRanges.length > 0) { | |
| setCurrentHighlightIndex(0); | |
| // Add a small delay to ensure Monaco editor is fully rendered | |
| const timeoutId = setTimeout(() => { | |
| scrollToHighlight(0); | |
| }, 150); | |
| return () => clearTimeout(timeoutId); | |
| } | |
| // Return undefined when no cleanup is needed | |
| return undefined; | |
| }, [highlightRanges]); | |
| // Update decorations when current highlight changes | |
| useEffect(() => { | |
| updateHighlightDecorations(); | |
| }, [currentHighlightIndex, characterRanges]); | |
| // Handle Monaco Editor mount | |
| const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor) => { | |
| editorRef.current = editor; | |
| // Apply initial decorations for highlights | |
| updateHighlightDecorations(); | |
| // Configure editor options | |
| editor.updateOptions({ | |
| readOnly: true, | |
| minimap: { enabled: false }, | |
| scrollBeyondLastLine: false, | |
| wordWrap: "on", | |
| lineNumbers: "on", | |
| folding: false, | |
| renderWhitespace: "selection", | |
| fontSize: 12, | |
| fontFamily: "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace", | |
| lineHeight: 18, | |
| letterSpacing: 0.5, | |
| fontWeight: "400", | |
| renderLineHighlight: "none", | |
| cursorStyle: "line", | |
| cursorBlinking: "blink", | |
| smoothScrolling: true, | |
| }); | |
| }; | |
| // Update highlight decorations in Monaco Editor | |
| const updateHighlightDecorations = () => { | |
| if (!editorRef.current) return; | |
| // Clear all existing decorations first to prevent old highlights from persisting | |
| if (decorations.length > 0) { | |
| editorRef.current.deltaDecorations(decorations, []); | |
| setDecorations([]); | |
| } | |
| const newDecorations: editor.IModelDeltaDecoration[] = []; | |
| characterRanges.forEach((range, index) => { | |
| const startPosition = editorRef | |
| .current!.getModel()! | |
| .getPositionAt(range.startChar); | |
| const endPosition = editorRef | |
| .current!.getModel()! | |
| .getPositionAt(range.endChar); | |
| const isCurrentHighlight = index === currentHighlightIndex; | |
| newDecorations.push({ | |
| range: { | |
| startLineNumber: startPosition.lineNumber, | |
| startColumn: startPosition.column, | |
| endLineNumber: endPosition.lineNumber, | |
| endColumn: endPosition.column, | |
| }, | |
| options: { | |
| className: isCurrentHighlight | |
| ? "current-highlight-decoration" | |
| : "highlight-decoration", | |
| isWholeLine: false, | |
| }, | |
| }); | |
| }); | |
| // Apply new decorations | |
| const decorationIds = editorRef.current.deltaDecorations( | |
| [], | |
| newDecorations | |
| ); | |
| setDecorations(decorationIds); | |
| }; | |
| // Detect language for syntax highlighting | |
| const detectLanguage = (content: string): string => { | |
| // Check for JSON content | |
| if (content.trim().startsWith("{") && content.trim().endsWith("}")) { | |
| try { | |
| JSON.parse(content); | |
| return "json"; | |
| } catch { | |
| // Not valid JSON, continue checking | |
| } | |
| } | |
| // Check for Python code patterns | |
| if (/^(import|from|def|class|if __name__|print\()/m.test(content)) { | |
| return "python"; | |
| } | |
| // Check for JavaScript/TypeScript patterns | |
| if (/^(import|export|function|const|let|var|console\.log)/m.test(content)) { | |
| return "javascript"; | |
| } | |
| // Check for shell/bash patterns | |
| if ( | |
| /^(#!\/bin\/|cd |ls |mkdir |rm |pip install|npm install)/m.test(content) | |
| ) { | |
| return "shell"; | |
| } | |
| // Check for SQL patterns | |
| if (/^(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)/im.test(content)) { | |
| return "sql"; | |
| } | |
| // Default to plain text | |
| return "text"; | |
| }; | |
| const detectedLanguage = useMemo( | |
| () => detectLanguage(reconstructedContent), | |
| [reconstructedContent] | |
| ); | |
| return ( | |
| <div className={`flex flex-col h-full ${className}`}> | |
| {/* Navigation controls */} | |
| {showNavigationControls && sortedRanges.length > 0 && ( | |
| <div className="flex items-center justify-center gap-2 p-2 border-b bg-muted/10"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-6 w-6 p-0" | |
| onClick={goToPreviousHighlight} | |
| title="Previous highlight (<)" | |
| > | |
| <ChevronUp className="h-3 w-3" /> | |
| </Button> | |
| <span className="text-xs text-muted-foreground"> | |
| {currentHighlightIndex + 1} / {sortedRanges.length} | |
| </span> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-6 w-6 p-0" | |
| onClick={goToNextHighlight} | |
| title="Next highlight (>)" | |
| > | |
| <ChevronDown className="h-3 w-3" /> | |
| </Button> | |
| </div> | |
| )} | |
| {/* Monaco Editor */} | |
| <div className="flex-1"> | |
| <Editor | |
| height={height} | |
| defaultLanguage={detectedLanguage} | |
| value={reconstructedContent} | |
| onMount={handleEditorDidMount} | |
| theme={theme} | |
| options={{ | |
| readOnly: true, | |
| minimap: { enabled: false }, | |
| scrollBeyondLastLine: false, | |
| wordWrap: "on", | |
| lineNumbers: "on", | |
| folding: false, | |
| renderWhitespace: "selection", | |
| fontSize: 12, | |
| fontFamily: | |
| "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace", | |
| padding: { top: 10, bottom: 10 }, | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| }; | |