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 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(null); const [currentHighlightIndex, setCurrentHighlightIndex] = useState(0); const [decorations, setDecorations] = useState([]); // 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(/^\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 (
{/* Navigation controls */} {showNavigationControls && sortedRanges.length > 0 && (
{currentHighlightIndex + 1} / {sortedRanges.length}
)} {/* Monaco Editor */}
); };