import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import Prism from "prismjs"; import "prismjs/components/prism-markdown"; import "prismjs/components/prism-clike"; import "prismjs/components/prism-javascript"; import "prismjs/components/prism-typescript"; import "prismjs/components/prism-css"; import "prismjs/components/prism-json"; import { ChevronRight, ChevronDown, RefreshCw, AlertTriangle, Check, Info } from "lucide-react"; import { useSpellcheck } from "../hooks/useSpellcheck"; import { useEditorShortcuts } from "../hooks/useEditorShortcuts"; import { ParsedLine, parseLinesStructure } from "../utils/foldingUtils"; export type EditorThemeType = "slate" | "monokai" | "darcula" | "solarized-dark" | "solarized-light" | "terminal"; interface AestheticCodeEditorProps { value: string; onChange: (newValue: string) => void; theme: EditorThemeType; showStats?: boolean; onShowStats?: () => void; fontFamily?: "sans" | "serif" | "mono"; } export const AestheticCodeEditor: React.FC = ({ value, onChange, theme, showStats, onShowStats, fontFamily = "mono" }) => { const [foldedHeadingIndices, setFoldedHeadingIndices] = useState>({}); const [editorFontSize, setEditorFontSize] = useState(12); const [wordWrap, setWordWrap] = useState(false); const [activeLineIndex, setActiveLineIndex] = useState(null); const textareaRef = useRef(null); const highlightedRef = useRef(null); // Helper to determine active line based on cursor position const updateActiveLine = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; const selectionStart = textarea.selectionStart; const textBeforeCursor = textarea.value.substring(0, selectionStart); const lineIndex = textBeforeCursor.split(/\r?\n/).length - 1; setActiveLineIndex(lineIndex); }, []); // Generate unique ID for styles isolation const editorId = useMemo(() => Math.random().toString(36).substring(2, 9), []); // Sync scroll positions between Textarea and Highlight layer (pre) const handleScroll = (e: React.UIEvent) => { const target = e.currentTarget; if (highlightedRef.current) { highlightedRef.current.scrollTop = target.scrollTop; highlightedRef.current.scrollLeft = target.scrollLeft; } }; // Parsing full document into logical folders structure const originalLines = useMemo(() => value.split(/\r?\n/), [value]); const parsedLinesStructure = useMemo(() => { return parseLinesStructure(originalLines); }, [originalLines]); // Compute folded lines list const visibleLines = useMemo(() => { const visible: ParsedLine[] = []; const hiddenSet = new Set(); parsedLinesStructure.forEach((line) => { const parentIsFolded = foldedHeadingIndices[line.originalIndex]; if (parentIsFolded && line.isFoldHeader) { const [start, end] = line.childrenLineRange; for (let j = start; j <= end; j++) { hiddenSet.add(j); } } }); parsedLinesStructure.forEach((line) => { if (!hiddenSet.has(line.originalIndex)) { visible.push({ ...line, isFolded: !!foldedHeadingIndices[line.originalIndex] }); } }); return visible; }, [parsedLinesStructure, foldedHeadingIndices]); // Generate text shown in editor const editorVisibleText = useMemo(() => { return visibleLines.map(v => v.text).join("\n"); }, [visibleLines]); // Custom Hooks integration const { misspelledWords, ignoredWords, enableSpellcheck, setEnableSpellcheck, isSpellchecking, showSpellcheckDropdown, setShowSpellcheckDropdown, addToDictionary, clearAllExceptions } = useSpellcheck(editorVisibleText); // Unified visible text change processor to handle edits safely when sections are folded const processVisibleTextChange = useCallback((newVisibleText: string) => { const foldedCount = Object.keys(foldedHeadingIndices).length; if (foldedCount === 0) { onChange(newVisibleText); return; } const newVisibleLines = newVisibleText.split(/\r?\n/); const updatedOriginalLines = [...originalLines]; // Find common prefix from top let prefixCount = 0; const maxPrefix = Math.min(newVisibleLines.length, visibleLines.length); while (prefixCount < maxPrefix && newVisibleLines[prefixCount] === visibleLines[prefixCount].text) { prefixCount++; } // Find common suffix from bottom, avoiding overlap with prefix let suffixCount = 0; const maxSuffix = Math.min(newVisibleLines.length - prefixCount, visibleLines.length - prefixCount); while (suffixCount < maxSuffix && newVisibleLines[newVisibleLines.length - 1 - suffixCount] === visibleLines[visibleLines.length - 1 - suffixCount].text) { suffixCount++; } // Determine the range of visible lines that were modified const startVisibleIdx = prefixCount; const endVisibleIdx = visibleLines.length - 1 - suffixCount; // Determine the replacement lines in newVisibleLines const replacementLines = newVisibleLines.slice(prefixCount, newVisibleLines.length - suffixCount); let startOrigIdx = 0; let endOrigIdx = 0; let shift = 0; if (startVisibleIdx <= endVisibleIdx) { // Get the range of original lines that correspond to these modified visible lines startOrigIdx = visibleLines[startVisibleIdx].originalIndex; endOrigIdx = visibleLines[endVisibleIdx].originalIndex; const deleteCount = endOrigIdx - startOrigIdx + 1; const insertCount = replacementLines.length; shift = insertCount - deleteCount; // Replace the original range with replacementLines updatedOriginalLines.splice(startOrigIdx, deleteCount, ...replacementLines); } else { // No visible lines were modified directly, meaning it's a pure insertion at some boundary. // We can insert the replacement lines after the original index of the line before startVisibleIdx, // or at the beginning if startVisibleIdx is 0. let insertIdx = 0; if (startVisibleIdx > 0) { insertIdx = visibleLines[startVisibleIdx - 1].originalIndex + 1; } else if (visibleLines.length > 0) { insertIdx = visibleLines[0].originalIndex; } startOrigIdx = insertIdx; endOrigIdx = insertIdx - 1; // negative range to signify pure insertion shift = replacementLines.length; updatedOriginalLines.splice(insertIdx, 0, ...replacementLines); } // Shift foldedHeadingIndices accordingly const newFoldedHeadingIndices: Record = {}; Object.entries(foldedHeadingIndices).forEach(([keyStr, value]) => { const key = parseInt(keyStr, 10); if (key < startOrigIdx) { newFoldedHeadingIndices[key] = value; } else if (key > endOrigIdx) { newFoldedHeadingIndices[key + shift] = value; } }); setFoldedHeadingIndices(newFoldedHeadingIndices); onChange(updatedOriginalLines.join("\n")); }, [originalLines, visibleLines, foldedHeadingIndices, onChange]); const { handleKeyDown } = useEditorShortcuts({ textareaRef, onChange: processVisibleTextChange, updateActiveLine }); // Handle updates directly in text-area const handleTextareaChange = (e: React.ChangeEvent) => { processVisibleTextChange(e.target.value); }; // Standard Word & Character Counts calculated internally const internalWordCount = useMemo(() => { const clean = value.trim(); if (clean === "") return 0; return clean.split(/\s+/).filter(Boolean).length; }, [value]); const internalCharCount = useMemo(() => { return value.length; }, [value]); // Method to render spellcheck decoration text overlay on top of standard line text const renderSpellcheckDecorations = (text: string) => { if (!text) return "\u200B"; const parts = text.split(/(\b[A-Za-z']+\b)/); return parts.map((part, i) => { const isWord = /^[A-Za-z']+$/.test(part); const lowercaseWord = part.toLowerCase(); // Only underline if spelling checker is active and word is unrecognized if (isWord && misspelledWords.has(lowercaseWord) && !ignoredWords.has(lowercaseWord)) { return ( {part} ); } return {part}; }); }; // Highlight markdown tokens line-by-line with Prism to avoid multi-line tag clipping const highlightedLines = useMemo(() => { return visibleLines.map((line) => { let html = ""; try { html = Prism.highlight(line.text, Prism.languages.markdown || Prism.languages.markup, "markdown"); } catch { html = line.text.replace(/&/g, "&").replace(//g, ">"); } return html === "" ? "​" : html; }); }, [visibleLines]); // Sync scroll positions useEffect(() => { if (textareaRef.current) { const top = textareaRef.current.scrollTop; const left = textareaRef.current.scrollLeft; if (highlightedRef.current) { highlightedRef.current.scrollTop = top; highlightedRef.current.scrollLeft = left; } } }, [editorVisibleText]); // Precise styles for themes to overwrite colorings & comments const getThemeStyles = () => { switch (theme) { case "monokai": return { bg: "bg-[#272822] text-[#F8F8F2]", border: "border-[#1E1F1C]", caret: "caret-[#F8F8F0]", gutterBg: "bg-[#1E1F1C] border-[#272822]/40", gutterText: "text-[#75715E]", lineNumHover: "hover:text-[#F8F8F2] hover:bg-[#272822]", scrollThumb: "rgba(255,255,255,0.08)", tagColor: "#F92672", commentColor: "#75715E", keywordColor: "#F92672", stringColor: "#E6DB74", numberColor: "#AE81FF", headingColor: "#A6E22E", lineHighlight: "bg-white/[0.04]", lineGutterHighlight: "bg-[#272822]" }; case "darcula": return { bg: "bg-[#2B2B2B] text-[#A9B7C6]", border: "border-[#1E1E1E]", caret: "caret-[#BBBBBB]", gutterBg: "bg-[#232323] border-[#2B2B2B]/40", gutterText: "text-[#606366]", lineNumHover: "hover:text-[#A9B7C6] hover:bg-[#2B2B2B]", scrollThumb: "rgba(255,255,255,0.06)", tagColor: "#CC7832", commentColor: "#808080", keywordColor: "#CC7832", stringColor: "#6A8759", numberColor: "#6897BB", headingColor: "#FFC66D", lineHighlight: "bg-white/[0.03]", lineGutterHighlight: "bg-[#2e2e2e]" }; case "solarized-dark": return { bg: "bg-[#002B36] text-[#839496]", border: "border-[#073642]", caret: "caret-[#93A1A1]", gutterBg: "bg-[#073642] border-[#002B36]/40", gutterText: "text-[#586E75]", lineNumHover: "hover:text-[#93A1A1] hover:bg-[#002B36]", scrollThumb: "rgba(255,255,255,0.05)", tagColor: "#268BD2", commentColor: "#586E75", keywordColor: "#B58900", stringColor: "#2AA198", numberColor: "#D33682", headingColor: "#268BD2", lineHighlight: "bg-[#073642]/60", lineGutterHighlight: "bg-[#0b3e4c]" }; case "solarized-light": return { bg: "bg-[#FDF6E3] text-[#586E75]", border: "border-[#EEE8D5]", caret: "caret-[#073642]", gutterBg: "bg-[#EEE8D5] border-[#FDF6E3]/40", gutterText: "text-[#93A1A1]", lineNumHover: "hover:text-[#586E75] hover:bg-[#FDF6E3]", scrollThumb: "rgba(0,0,0,0.06)", tagColor: "#268BD2", commentColor: "#93A1A1", keywordColor: "#B58900", stringColor: "#2AA198", numberColor: "#D33682", headingColor: "#268BD2", lineHighlight: "bg-[#EEE8D5]/50", lineGutterHighlight: "bg-[#e2dac2]" }; case "terminal": return { bg: "bg-[#04070A] text-[#10B981]", border: "border-emerald-500/10", caret: "caret-[#10B981]", gutterBg: "bg-[#020406] border-emerald-500/5", gutterText: "text-emerald-900/60", lineNumHover: "hover:text-[#10B981] hover:bg-emerald-950/20", scrollThumb: "rgba(16,185,129,0.1)", tagColor: "#059669", commentColor: "#065F46", keywordColor: "#34D399", stringColor: "#6EE7B7", numberColor: "#10B981", headingColor: "#34D399", lineHighlight: "bg-emerald-500/[0.04]", lineGutterHighlight: "bg-[#030609]" }; default: // slate return { bg: "bg-[#0F1115] text-slate-200", border: "border-slate-850", caret: "caret-indigo-505", gutterBg: "bg-[#13161C] border-slate-900/10", gutterText: "text-slate-600", lineNumHover: "hover:text-white hover:bg-slate-800/45", scrollThumb: "rgba(255,255,255,0.08)", tagColor: "#C084FC", commentColor: "#64748B", keywordColor: "#F472B6", stringColor: "#38BDF8", numberColor: "#A78BFA", headingColor: "#818CF8", lineHighlight: "bg-indigo-500/[0.03]", lineGutterHighlight: "bg-[#181C24]" }; } }; const colors = getThemeStyles(); const fontStyles = useMemo(() => { switch (fontFamily) { case "sans": return `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`; case "serif": return `Georgia, Cambria, "Times New Roman", Times, serif`; case "mono": default: return `"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace`; } }, [fontFamily]); const inlineThemeCss = useMemo(() => { return ` #editor-wrapper-${editorId} textarea, #editor-wrapper-${editorId} pre, #editor-wrapper-${editorId} code, #editor-wrapper-${editorId} .line-content { font-family: ${fontStyles} !important; font-size: ${editorFontSize}px !important; line-height: 24px !important; tab-size: 4 !important; -moz-tab-size: 4 !important; font-variant-ligatures: none !important; letter-spacing: normal !important; word-spacing: normal !important; text-transform: none !important; } #editor-wrapper-${editorId} pre .line-content { white-space: ${wordWrap ? "pre-wrap" : "pre"} !important; word-wrap: ${wordWrap ? "break-word" : "normal"} !important; overflow-wrap: ${wordWrap ? "break-word" : "normal"} !important; word-break: ${wordWrap ? "break-word" : "normal"} !important; } #editor-wrapper-${editorId} textarea { padding: 16px 16px 16px 16px !important; margin: 0 !important; border: none !important; outline: none !important; box-sizing: border-box !important; background: transparent !important; overflow-y: scroll !important; overflow-x: ${wordWrap ? "hidden" : "auto"} !important; resize: none !important; width: calc(100% - 52px) !important; height: 100% !important; left: 52px !important; white-space: ${wordWrap ? "pre-wrap" : "pre"} !important; word-wrap: ${wordWrap ? "break-word" : "normal"} !important; overflow-wrap: ${wordWrap ? "break-word" : "normal"} !important; word-break: ${wordWrap ? "break-word" : "normal"} !important; } #editor-wrapper-${editorId} pre { padding: 16px 0px 16px 0px !important; margin: 0 !important; border: none !important; outline: none !important; box-sizing: border-box !important; background: transparent !important; overflow-y: scroll !important; overflow-x: ${wordWrap ? "hidden" : "auto"} !important; width: 100% !important; height: 100% !important; scrollbar-width: thin !important; scrollbar-color: transparent transparent !important; } #editor-wrapper-${editorId} pre::-webkit-scrollbar { width: 8px !important; height: 8px !important; } #editor-wrapper-${editorId} pre::-webkit-scrollbar-thumb { background-color: transparent !important; } #editor-wrapper-${editorId} pre::-webkit-scrollbar-track { background-color: transparent !important; } #editor-wrapper-${editorId} textarea::-webkit-scrollbar { width: 8px !important; height: 8px !important; } #editor-wrapper-${editorId} textarea::-webkit-scrollbar-track { background: transparent !important; } #editor-wrapper-${editorId} textarea::-webkit-scrollbar-thumb { background: ${colors.scrollThumb} !important; border-radius: 4px !important; } #editor-wrapper-${editorId} textarea::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.15) !important; } #editor-wrapper-${editorId} .token.comment, #editor-wrapper-${editorId} .token.prolog, #editor-wrapper-${editorId} .token.doctype, #editor-wrapper-${editorId} .token.cdata { color: ${colors.commentColor} !important; font-style: italic !important; } #editor-wrapper-${editorId} .token.punctuation { color: inherit !important; opacity: 0.6 !important; } #editor-wrapper-${editorId} .token.property, #editor-wrapper-${editorId} .token.tag, #editor-wrapper-${editorId} .token.boolean, #editor-wrapper-${editorId} .token.number, #editor-wrapper-${editorId} .token.constant, #editor-wrapper-${editorId} .token.symbol, #editor-wrapper-${editorId} .token.deleted { color: ${colors.numberColor} !important; } #editor-wrapper-${editorId} .token.selector, #editor-wrapper-${editorId} .token.attr-name, #editor-wrapper-${editorId} .token.string, #editor-wrapper-${editorId} .token.char, #editor-wrapper-${editorId} .token.builtin, #editor-wrapper-${editorId} .token.inserted { color: ${colors.stringColor} !important; } #editor-wrapper-${editorId} .token.operator, #editor-wrapper-${editorId} .token.entity, #editor-wrapper-${editorId} .token.url { color: ${colors.tagColor} !important; } #editor-wrapper-${editorId} .token.atrule, #editor-wrapper-${editorId} .token.attr-value, #editor-wrapper-${editorId} .token.keyword { color: ${colors.keywordColor} !important; font-weight: bold !important; } #editor-wrapper-${editorId} .token.title, #editor-wrapper-${editorId} .token.important { color: ${colors.headingColor} !important; font-weight: bold !important; } #editor-wrapper-${editorId} .token.bold { font-weight: bold !important; } #editor-wrapper-${editorId} .token.italic { font-style: italic !important; } `; }, [colors, editorId, fontStyles, editorFontSize, wordWrap]); const handleIncreaseFontSize = () => { setEditorFontSize(prev => Math.min(prev + 1, 20)); }; const handleDecreaseFontSize = () => { setEditorFontSize(prev => Math.max(prev - 1, 9)); }; const currentLineHeight = "24px"; const customEditorStyle = { fontFamily: fontStyles, fontSize: `${editorFontSize}px`, lineHeight: currentLineHeight, }; return (