docuflow / src /components /AestheticCodeEditor.tsx
Joedroid's picture
Resolve all security vulnerabilities, bug fixes, and print watermarking configurations based on product review
7dfd4d1
Raw
History Blame Contribute Delete
38 kB
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);
// 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<HTMLTextAreaElement>) => {
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<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]);
// 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<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
});
// Handle updates directly in text-area
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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 (
<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>;
});
};
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
return html === "" ? "&#8203;" : 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 (
<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>
);
};