| 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<AestheticCodeEditorProps> = ({ |
| value, |
| onChange, |
| theme, |
| showStats, |
| onShowStats, |
| fontFamily = "mono" |
| }) => { |
| const [foldedHeadingIndices, setFoldedHeadingIndices] = useState<Record<number, boolean>>({}); |
| const [editorFontSize, setEditorFontSize] = useState<number>(12); |
| const [wordWrap, setWordWrap] = useState<boolean>(false); |
| const [activeLineIndex, setActiveLineIndex] = useState<number | null>(null); |
|
|
| const textareaRef = useRef<HTMLTextAreaElement>(null); |
| const highlightedRef = useRef<HTMLPreElement>(null); |
|
|
| |
| 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); |
| }, []); |
|
|
| |
| const editorId = useMemo(() => Math.random().toString(36).substring(2, 9), []); |
|
|
| |
| const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => { |
| const target = e.currentTarget; |
| if (highlightedRef.current) { |
| highlightedRef.current.scrollTop = target.scrollTop; |
| highlightedRef.current.scrollLeft = target.scrollLeft; |
| } |
| }; |
|
|
| |
| const originalLines = useMemo(() => value.split(/\r?\n/), [value]); |
|
|
| const parsedLinesStructure = useMemo(() => { |
| return parseLinesStructure(originalLines); |
| }, [originalLines]); |
|
|
| |
| const visibleLines = useMemo(() => { |
| const visible: ParsedLine[] = []; |
| const hiddenSet = new Set<number>(); |
|
|
| 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]); |
|
|
| |
| const editorVisibleText = useMemo(() => { |
| return visibleLines.map(v => v.text).join("\n"); |
| }, [visibleLines]); |
|
|
| |
| const { |
| misspelledWords, |
| ignoredWords, |
| enableSpellcheck, |
| setEnableSpellcheck, |
| isSpellchecking, |
| showSpellcheckDropdown, |
| setShowSpellcheckDropdown, |
| addToDictionary, |
| clearAllExceptions |
| } = useSpellcheck(editorVisibleText); |
|
|
| |
| 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]; |
|
|
| |
| let prefixCount = 0; |
| const maxPrefix = Math.min(newVisibleLines.length, visibleLines.length); |
| while (prefixCount < maxPrefix && newVisibleLines[prefixCount] === visibleLines[prefixCount].text) { |
| prefixCount++; |
| } |
|
|
| |
| 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++; |
| } |
|
|
| |
| const startVisibleIdx = prefixCount; |
| const endVisibleIdx = visibleLines.length - 1 - suffixCount; |
|
|
| |
| const replacementLines = newVisibleLines.slice(prefixCount, newVisibleLines.length - suffixCount); |
|
|
| let startOrigIdx = 0; |
| let endOrigIdx = 0; |
| let shift = 0; |
|
|
| if (startVisibleIdx <= endVisibleIdx) { |
| |
| startOrigIdx = visibleLines[startVisibleIdx].originalIndex; |
| endOrigIdx = visibleLines[endVisibleIdx].originalIndex; |
| const deleteCount = endOrigIdx - startOrigIdx + 1; |
| const insertCount = replacementLines.length; |
| shift = insertCount - deleteCount; |
|
|
| |
| updatedOriginalLines.splice(startOrigIdx, deleteCount, ...replacementLines); |
| } else { |
| |
| |
| |
| 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; |
| shift = replacementLines.length; |
|
|
| updatedOriginalLines.splice(insertIdx, 0, ...replacementLines); |
| } |
|
|
| |
| const newFoldedHeadingIndices: Record<number, boolean> = {}; |
| 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 |
| }); |
|
|
| |
| const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
| processVisibleTextChange(e.target.value); |
| }; |
|
|
| |
| 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]); |
|
|
| |
| 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(); |
| |
| if (isWord && misspelledWords.has(lowercaseWord) && !ignoredWords.has(lowercaseWord)) { |
| return ( |
| <span |
| key={i} |
| className="border-b-[1.5px] border-dotted border-red-500 text-transparent font-mono relative" |
| title={`Spelling error: "${part}"`} |
| > |
| {part} |
| </span> |
| ); |
| } |
| return <span key={i} className="text-transparent font-mono">{part}</span>; |
| }); |
| }; |
|
|
| |
| 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, "<").replace(/>/g, ">"); |
| } |
| return html === "" ? "​" : html; |
| }); |
| }, [visibleLines]); |
|
|
| |
| 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]); |
|
|
| |
| 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: |
| 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 ( |
| <div |
| id={`editor-wrapper-${editorId}`} |
| className={`flex-grow h-full w-full rounded-xl border ${colors.border} ${colors.bg} flex flex-col overflow-hidden shadow-lg relative`} |
| > |
| <style dangerouslySetInnerHTML={{ __html: inlineThemeCss }} /> |
| |
| {/* Editor top status utility bar */} |
| <div className="flex items-center justify-between px-4 py-2 bg-black/25 border-b border-white/[0.04] text-[10px] uppercase tracking-wider select-none shrink-0 font-sans gap-2 flex-wrap"> |
| <div className="flex items-center gap-2"> |
| <span className="inline-block h-2 w-2 rounded-full bg-indigo-505 animate-pulse" /> |
| <span className="font-mono text-slate-300 font-bold">{theme.replace("-", " ")} Workbench</span> |
| <span className="text-slate-600 font-mono">•</span> |
| <span className="text-slate-400 font-mono text-[9px] font-bold"> |
| {editorFontSize}px Mono |
| </span> |
| </div> |
| |
| {/* Code editor preferences header control tools */} |
| <div className="flex items-center gap-2 flex-wrap"> |
| {/* Font Controls inline */} |
| <div className="flex items-center bg-black/30 rounded border border-white/[0.05] p-0.5"> |
| <button |
| type="button" |
| onClick={handleDecreaseFontSize} |
| className="px-1.5 py-0.5 text-slate-405 hover:text-white transition rounded cursor-pointer text-[10px]" |
| title="Decrease Font Size" |
| > |
| A- |
| </button> |
| <div className="w-px h-3 bg-white/[0.08]" /> |
| <button |
| type="button" |
| onClick={handleIncreaseFontSize} |
| className="px-1.5 py-0.5 text-slate-405 hover:text-white transition rounded cursor-pointer text-[10px]" |
| title="Increase Font Size" |
| > |
| A+ |
| </button> |
| </div> |
| |
| {/* Word wrap Toggle */} |
| <button |
| type="button" |
| onClick={() => setWordWrap(!wordWrap)} |
| className={`px-2 py-0.5 rounded text-[9px] font-bold tracking-tight transition cursor-pointer font-mono ${ |
| wordWrap |
| ? "bg-indigo-600/35 text-indigo-305 border border-indigo-500/20" |
| : "bg-black/20 text-slate-455 border border-white/[0.05] hover:text-white" |
| }`} |
| title="Toggle Soft Word Wrapping" |
| > |
| Wrap: {wordWrap ? "ON" : "OFF"} |
| </button> |
| |
| {/* Spellcheck Toggle & Multi-action Dropdown with Error Badge */} |
| <div className="relative flex items-center gap-1.5 z-30"> |
| <button |
| type="button" |
| onClick={() => setEnableSpellcheck(!enableSpellcheck)} |
| className={`px-2 py-0.5 rounded text-[9px] font-bold tracking-tight transition cursor-pointer font-mono ${ |
| enableSpellcheck |
| ? "bg-emerald-600/30 text-emerald-300 border border-emerald-500/20" |
| : "bg-black/20 text-slate-455 border border-white/[0.05] hover:text-white" |
| }`} |
| title="Toggle Live spelling checking overlays" |
| > |
| Spell: {enableSpellcheck ? "ON" : "OFF"} |
| </button> |
| |
| {enableSpellcheck && ( |
| <button |
| type="button" |
| onClick={() => setShowSpellcheckDropdown(!showSpellcheckDropdown)} |
| className={`px-2 py-0.5 rounded text-[9px] font-bold tracking-tight transition cursor-pointer font-mono flex items-center gap-1 ${ |
| misspelledWords.size > 0 |
| ? "bg-red-500/15 border border-red-500/30 text-red-400 hover:bg-red-500/25 animate-pulse font-bold" |
| : "bg-black/20 border border-white/[0.05] text-slate-400 font-medium" |
| }`} |
| title="View spelling dictionary adjustments and unrecognized words" |
| > |
| <span>ABC</span> |
| <span className="bg-black/40 px-1 rounded text-[8px]">{misspelledWords.size}</span> |
| </button> |
| )} |
| |
| {showSpellcheckDropdown && ( |
| <div className="absolute right-0 top-6 w-64 bg-[#0d1015] border border-slate-800 rounded-lg shadow-xl p-3 z-50 text-left normal-case font-sans"> |
| <div className="flex justify-between items-center pb-2 border-b border-white/[0.05] mb-2"> |
| <span className="font-bold text-[9px] text-slate-300 uppercase tracking-widest flex items-center gap-1"> |
| <AlertTriangle className="h-3 w-3 text-red-400 shrink-0" /> |
| Spelling Errors ({misspelledWords.size}) |
| </span> |
| <button |
| type="button" |
| onClick={() => setShowSpellcheckDropdown(false)} |
| className="text-[10px] text-slate-500 hover:text-white transition cursor-pointer p-0.5" |
| > |
| ✕ |
| </button> |
| </div> |
| {misspelledWords.size === 0 ? ( |
| <div className="text-[10px] text-slate-400 py-3 text-center font-sans"> |
| {isSpellchecking ? ( |
| <span className="animate-pulse">Checking spelling... ⏳</span> |
| ) : ( |
| <span>No spelling errors detected! ✨</span> |
| )} |
| </div> |
| ) : ( |
| <div className="max-h-48 overflow-y-auto space-y-1 pr-1 custom-scrollbar"> |
| {Array.from(misspelledWords).map((word) => ( |
| <div key={word} className="flex justify-between items-center bg-black/20 p-1 px-2 rounded text-[10px] border border-white/[0.02]"> |
| <span className="font-mono text-red-400 font-medium underline decoration-wavy decoration-red-500/80">{word}</span> |
| <button |
| type="button" |
| onClick={() => addToDictionary(word)} |
| className="text-[8.5px] bg-indigo-650 hover:bg-indigo-600 text-white py-0.5 px-1.5 rounded font-sans cursor-pointer transition uppercase font-bold tracking-tight" |
| title="Add word to custom dictionary exceptions" |
| > |
| Learn |
| </button> |
| </div> |
| ))} |
| </div> |
| )} |
| <div className="mt-2 pt-2 border-t border-white/[0.05] text-[8px] text-slate-500 flex justify-between font-sans uppercase tracking-tight"> |
| <span>Exceptions: {ignoredWords.size}</span> |
| {ignoredWords.size > 0 && ( |
| <button |
| type="button" |
| onClick={clearAllExceptions} |
| className="text-indigo-400 hover:underline cursor-pointer font-bold" |
| > |
| Clear All |
| </button> |
| )} |
| </div> |
| </div> |
| )} |
| </div> |
| |
| {Object.keys(foldedHeadingIndices).length > 0 && ( |
| <button |
| onClick={() => setFoldedHeadingIndices({})} |
| className="px-2 py-0.5 bg-indigo-650 hover:bg-indigo-500 text-white rounded text-[9px] cursor-pointer transition flex items-center gap-1 font-mono font-bold" |
| > |
| Unfold All ({Object.keys(foldedHeadingIndices).length}) |
| </button> |
| )} |
| </div> |
| </div> |
| |
| {/* Editor Body Area: Gutter on Left, Interactive inputs on Right */} |
| <div className="flex-grow flex items-stretch overflow-hidden relative w-full h-full"> |
| |
| {/* Container for Highlight and Textarea overlays */} |
| <div className="flex-grow h-full w-full relative overflow-hidden"> |
| |
| {/* Highlight Pre background container with embedded gutter layout */} |
| <pre |
| ref={highlightedRef} |
| aria-hidden="true" |
| className="absolute inset-x-0 top-0 bottom-0 m-0 w-full h-full text-left overflow-y-scroll pointer-events-none select-none z-0" |
| style={{ |
| ...customEditorStyle, |
| background: "transparent", |
| scrollbarWidth: "none" |
| }} |
| > |
| {visibleLines.map((line, idx) => { |
| const html = highlightedLines[idx]; |
| const hasChildrenToFold = line.isFoldHeader; |
| const originalIndex = line.originalIndex; |
| const isActive = idx === activeLineIndex; |
| |
| return ( |
| <div |
| key={originalIndex} |
| className={`flex items-stretch relative group/line w-full transition-colors duration-150 ${ |
| isActive ? `${colors.lineHighlight || "bg-indigo-500/[0.03]"}` : "" |
| }`} |
| style={{ |
| minHeight: currentLineHeight, |
| lineHeight: currentLineHeight, |
| }} |
| > |
| {/* Line Number Gutter Cell - stuck horizontally, scrolls vertically */} |
| <div |
| className={`sticky left-0 w-[52px] shrink-0 select-none pointer-events-auto pr-2 border-r border-white/[0.04] z-20 flex items-stretch justify-end transition-colors duration-150 ${ |
| isActive |
| ? (colors.lineGutterHighlight || "bg-[#181C24]") |
| : (colors.gutterBg || "bg-[#13161C]") |
| }`} |
| > |
| <div className="relative w-full h-full"> |
| <div |
| className="absolute top-0 left-0 right-0 flex items-center justify-between pl-1" |
| style={{ height: currentLineHeight }} |
| > |
| {/* Fold icon */} |
| {hasChildrenToFold ? ( |
| <button |
| type="button" |
| onClick={() => { |
| setFoldedHeadingIndices(prev => ({ |
| ...prev, |
| [originalIndex]: !prev[originalIndex] |
| })); |
| }} |
| className="text-indigo-405 hover:text-indigo-301 hover:scale-105 shrink-0 transition p-0.5 cursor-pointer flex items-center justify-center z-30 pointer-events-auto" |
| title={line.isFolded ? `Unfold segment lines [${line.childrenLineRange[0] + 1}-${line.childrenLineRange[1] + 1}]` : `Fold segment`} |
| > |
| {line.isFolded ? ( |
| <ChevronRight className="h-3 w-3" /> |
| ) : ( |
| <ChevronDown className="h-3 w-3" /> |
| )} |
| </button> |
| ) : ( |
| line.blockType === "paragraph" ? ( |
| <button |
| type="button" |
| onClick={() => { |
| setFoldedHeadingIndices(prev => { |
| const updated = { ...prev }; |
| if (updated[originalIndex]) { |
| delete updated[originalIndex]; |
| } else { |
| updated[originalIndex] = true; |
| } |
| return updated; |
| }); |
| }} |
| className="text-[8px] text-slate-500/30 hover:text-indigo-400 hover:scale-125 cursor-pointer transition opacity-0 group-hover/line:opacity-100 p-0.5 shrink-0 flex items-center justify-center z-30 pointer-events-auto" |
| title="Fold paragraph body" |
| > |
| ▪ |
| </button> |
| ) : ( |
| <div className="w-3.5 h-3.5" /> |
| ) |
| )} |
| |
| {/* Line number label */} |
| <span className={`text-[10px] select-none transition ${line.isFolded ? "text-indigo-400 font-bold underline" : (isActive ? "text-white font-bold" : colors.gutterText)} px-1 rounded`}> |
| {originalIndex + 1} |
| </span> |
| </div> |
| </div> |
| </div> |
| |
| {/* Highlight text code block content */} |
| <div |
| className="flex-1 min-w-0 pl-4 pr-4 line-content break-words relative z-0" |
| style={{ |
| fontFamily: fontStyles, |
| fontSize: `${editorFontSize}px`, |
| lineHeight: currentLineHeight, |
| }} |
| > |
| <span |
| dangerouslySetInnerHTML={{ __html: html }} |
| className="block w-full h-full" |
| /> |
| |
| {/* Foreground Spellcheck Red Underlines overlay */} |
| {enableSpellcheck && ( |
| <div |
| className="absolute inset-y-0 left-4 right-4 pointer-events-none select-none z-10 font-mono" |
| style={{ |
| fontSize: `${editorFontSize}px`, |
| lineHeight: currentLineHeight, |
| whiteSpace: wordWrap ? "pre-wrap" : "pre", |
| wordWrap: wordWrap ? "break-word" : "normal", |
| overflowWrap: wordWrap ? "break-word" : "normal", |
| wordBreak: wordWrap ? "break-word" : "normal", |
| }} |
| > |
| {renderSpellcheckDecorations(line.text)} |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| })} |
| </pre> |
| |
| {/* Transparent Input Textarea precisely offset by 52px */} |
| <textarea |
| ref={textareaRef} |
| value={editorVisibleText} |
| onChange={(e) => { |
| handleTextareaChange(e); |
| setTimeout(updateActiveLine, 0); |
| }} |
| onKeyDown={handleKeyDown} |
| onScroll={handleScroll} |
| onSelect={updateActiveLine} |
| onFocus={updateActiveLine} |
| onBlur={() => setActiveLineIndex(null)} |
| wrap={wordWrap ? "soft" : "off"} |
| placeholder="Type your markdown file code contents. Use # for foldable headings level segmenting..." |
| className="absolute top-0 bottom-0 m-0 bg-transparent text-transparent caret-current focus:outline-none focus:ring-0 resize-none z-10 overflow-y-scroll border-none selection:bg-indigo-500/30 selection:text-white/90" |
| style={{ |
| ...customEditorStyle, |
| outline: "none", |
| border: "none", |
| left: "52px", |
| width: "calc(100% - 52px)", |
| paddingTop: "16px", |
| paddingBottom: "16px", |
| paddingLeft: "16px", |
| paddingRight: "16px" |
| }} |
| spellCheck="false" |
| /> |
| |
| </div> |
| </div> |
| |
| {/* FOOTER: Word Count, Char Count */} |
| <div className="flex select-none items-center justify-between flex-wrap gap-2 px-4 py-1.5 bg-black/15 border-t border-white/[0.04] text-[9px] text-slate-500 font-sans"> |
| <div className="flex items-center gap-3"> |
| <span className="flex items-center gap-1"> |
| <Check className="h-3 w-3 text-indigo-405 shrink-0" /> |
| <span className="text-slate-400 font-mono font-medium">{internalWordCount}</span> words |
| </span> |
| <span className="flex items-center gap-1"> |
| <Info className="h-3 w-3 text-slate-500 shrink-0" /> |
| <span className="text-slate-400 font-mono font-medium">{internalCharCount}</span> characters |
| </span> |
| <span className="text-slate-700 font-mono">•</span> |
| <span className="text-slate-400 font-mono"> |
| {originalLines.length} lines total |
| </span> |
| </div> |
| |
| <div className="flex items-center gap-2"> |
| {onShowStats && ( |
| <button |
| type="button" |
| onClick={onShowStats} |
| title="Show comprehensive copywriting metrics popover" |
| className={`text-[8.5px] px-2 py-0.5 rounded transition cursor-pointer font-mono font-medium border flex items-center gap-1 ${ |
| showStats |
| ? "bg-blue-600/30 border-blue-500 text-blue-300 font-bold" |
| : "bg-slate-900/60 border-slate-855 text-slate-400 hover:text-white hover:border-slate-700" |
| }`} |
| > |
| <Info className="h-2.5 w-2.5" /> |
| <span>{showStats ? "Hide Stats" : "Details"}</span> |
| </button> |
| )} |
| <span className="text-[8px] bg-slate-900 px-1.5 py-0.5 rounded border border-slate-800 text-slate-500 font-bold font-mono"> |
| ESC folds |
| </span> |
| {Object.keys(foldedHeadingIndices).length > 0 && ( |
| <span className="text-indigo-400 font-bold animate-pulse font-mono text-[8px] uppercase"> |
| {Object.keys(foldedHeadingIndices).length} segments folded |
| </span> |
| )} |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|