Spaces:
Running
Running
novitacarlen
commited on
Commit
·
1aaa08e
1
Parent(s):
bb7727c
feat: enable code editor highlight
Browse files- src/components/code-editor.tsx +67 -44
- src/lib/utils.ts +30 -0
src/components/code-editor.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"use client"
|
| 2 |
|
| 3 |
import { useEffect, useRef, useState, useCallback } from "react";
|
| 4 |
-
import { debounce } from "@/lib/utils";
|
| 5 |
|
| 6 |
interface CodeEditorProps {
|
| 7 |
code: string;
|
|
@@ -12,6 +12,7 @@ interface CodeEditorProps {
|
|
| 12 |
export function CodeEditor({ code, isLoading = false, onCodeChange }: CodeEditorProps) {
|
| 13 |
const containerRef = useRef<HTMLDivElement>(null);
|
| 14 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
| 15 |
const [isAtBottom, setIsAtBottom] = useState(true);
|
| 16 |
const [localCode, setLocalCode] = useState(code);
|
| 17 |
|
|
@@ -34,66 +35,88 @@ export function CodeEditor({ code, isLoading = false, onCodeChange }: CodeEditor
|
|
| 34 |
debouncedSave(newCode);
|
| 35 |
};
|
| 36 |
|
| 37 |
-
//
|
| 38 |
-
const handleScroll = () => {
|
| 39 |
-
if (
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
| 43 |
const isNearBottom = scrollHeight - scrollTop - clientHeight < 30;
|
| 44 |
setIsAtBottom(isNearBottom);
|
| 45 |
};
|
| 46 |
|
| 47 |
useEffect(() => {
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
}, []);
|
| 54 |
-
|
| 55 |
-
useEffect(() => {
|
| 56 |
-
// Only auto-scroll if user was already at the bottom and not focused on textarea
|
| 57 |
-
if (isAtBottom && containerRef.current && document.activeElement !== textareaRef.current) {
|
| 58 |
-
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
| 59 |
}
|
| 60 |
}, [localCode, isLoading, isAtBottom]);
|
| 61 |
|
| 62 |
-
//
|
| 63 |
-
const
|
| 64 |
-
if (
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
};
|
| 69 |
|
| 70 |
-
|
| 71 |
-
adjustTextareaHeight();
|
| 72 |
-
}, [localCode]);
|
| 73 |
|
| 74 |
return (
|
| 75 |
-
<div className="flex-1 overflow-
|
| 76 |
<div
|
| 77 |
ref={containerRef}
|
| 78 |
-
className="code-area
|
| 79 |
>
|
| 80 |
-
<
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
{isLoading && (
|
| 96 |
-
<span className="absolute bottom-4 right-4 inline-block h-4 w-2 bg-white/70 animate-pulse"></span>
|
| 97 |
)}
|
| 98 |
</div>
|
| 99 |
</div>
|
|
|
|
| 1 |
"use client"
|
| 2 |
|
| 3 |
import { useEffect, useRef, useState, useCallback } from "react";
|
| 4 |
+
import { debounce, highlightHTML } from "@/lib/utils";
|
| 5 |
|
| 6 |
interface CodeEditorProps {
|
| 7 |
code: string;
|
|
|
|
| 12 |
export function CodeEditor({ code, isLoading = false, onCodeChange }: CodeEditorProps) {
|
| 13 |
const containerRef = useRef<HTMLDivElement>(null);
|
| 14 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 15 |
+
const highlightRef = useRef<HTMLDivElement>(null);
|
| 16 |
const [isAtBottom, setIsAtBottom] = useState(true);
|
| 17 |
const [localCode, setLocalCode] = useState(code);
|
| 18 |
|
|
|
|
| 35 |
debouncedSave(newCode);
|
| 36 |
};
|
| 37 |
|
| 38 |
+
// Sync scroll between textarea and highlight
|
| 39 |
+
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
| 40 |
+
if (highlightRef.current && textareaRef.current) {
|
| 41 |
+
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
| 42 |
+
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
| 46 |
const isNearBottom = scrollHeight - scrollTop - clientHeight < 30;
|
| 47 |
setIsAtBottom(isNearBottom);
|
| 48 |
};
|
| 49 |
|
| 50 |
useEffect(() => {
|
| 51 |
+
if (isAtBottom && textareaRef.current && document.activeElement !== textareaRef.current) {
|
| 52 |
+
textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
|
| 53 |
+
if (highlightRef.current) {
|
| 54 |
+
highlightRef.current.scrollTop = highlightRef.current.scrollHeight;
|
| 55 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
}
|
| 57 |
}, [localCode, isLoading, isAtBottom]);
|
| 58 |
|
| 59 |
+
// Handle tab key for indentation
|
| 60 |
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
| 61 |
+
if (e.key === 'Tab') {
|
| 62 |
+
e.preventDefault();
|
| 63 |
+
const textarea = e.currentTarget;
|
| 64 |
+
const start = textarea.selectionStart;
|
| 65 |
+
const end = textarea.selectionEnd;
|
| 66 |
+
|
| 67 |
+
const newValue = localCode.substring(0, start) + ' ' + localCode.substring(end);
|
| 68 |
+
setLocalCode(newValue);
|
| 69 |
+
debouncedSave(newValue);
|
| 70 |
+
|
| 71 |
+
setTimeout(() => {
|
| 72 |
+
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
| 73 |
+
}, 0);
|
| 74 |
}
|
| 75 |
};
|
| 76 |
|
| 77 |
+
const highlightedCode = highlightHTML(localCode);
|
|
|
|
|
|
|
| 78 |
|
| 79 |
return (
|
| 80 |
+
<div className="flex-1 overflow-hidden p-4 pr-2">
|
| 81 |
<div
|
| 82 |
ref={containerRef}
|
| 83 |
+
className="code-area rounded-md font-mono text-sm h-full border border-novita-gray/20 relative overflow-hidden"
|
| 84 |
>
|
| 85 |
+
<div className="relative h-full">
|
| 86 |
+
{/* Syntax highlighted background - always visible */}
|
| 87 |
+
<div
|
| 88 |
+
ref={highlightRef}
|
| 89 |
+
className="absolute inset-0 overflow-auto p-4 pointer-events-none whitespace-pre-wrap font-mono text-sm leading-relaxed"
|
| 90 |
+
style={{
|
| 91 |
+
fontSize: '0.875rem',
|
| 92 |
+
lineHeight: '1.5',
|
| 93 |
+
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
|
| 94 |
+
color: '#d4d4d4'
|
| 95 |
+
}}
|
| 96 |
+
dangerouslySetInnerHTML={{ __html: highlightedCode + '\n' }}
|
| 97 |
+
/>
|
| 98 |
+
|
| 99 |
+
{/* Editable textarea - always transparent */}
|
| 100 |
+
<textarea
|
| 101 |
+
ref={textareaRef}
|
| 102 |
+
value={localCode}
|
| 103 |
+
onChange={handleCodeChange}
|
| 104 |
+
onScroll={handleScroll}
|
| 105 |
+
onKeyDown={handleKeyDown}
|
| 106 |
+
disabled={isLoading}
|
| 107 |
+
className="absolute inset-0 w-full h-full bg-transparent text-transparent caret-white resize-none outline-none whitespace-pre-wrap font-mono text-sm leading-relaxed p-4 selection:bg-blue-500/30"
|
| 108 |
+
style={{
|
| 109 |
+
fontSize: '0.875rem',
|
| 110 |
+
lineHeight: '1.5',
|
| 111 |
+
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace'
|
| 112 |
+
}}
|
| 113 |
+
placeholder="Your generated code will appear here..."
|
| 114 |
+
spellCheck={false}
|
| 115 |
+
/>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
{isLoading && (
|
| 119 |
+
<span className="absolute bottom-4 right-4 inline-block h-4 w-2 bg-white/70 animate-pulse z-10"></span>
|
| 120 |
)}
|
| 121 |
</div>
|
| 122 |
</div>
|
src/lib/utils.ts
CHANGED
|
@@ -15,3 +15,33 @@ export function debounce<T extends (...args: any[]) => any>(
|
|
| 15 |
timeout = setTimeout(() => func(...args), wait);
|
| 16 |
};
|
| 17 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
timeout = setTimeout(() => func(...args), wait);
|
| 16 |
};
|
| 17 |
}
|
| 18 |
+
|
| 19 |
+
export function highlightHTML(code: string): string {
|
| 20 |
+
if (!code) return '';
|
| 21 |
+
|
| 22 |
+
let result = code;
|
| 23 |
+
|
| 24 |
+
result = result
|
| 25 |
+
.replace(/&/g, '&')
|
| 26 |
+
.replace(/</g, '<')
|
| 27 |
+
.replace(/>/g, '>')
|
| 28 |
+
.replace(/"/g, '"')
|
| 29 |
+
.replace(/'/g, ''');
|
| 30 |
+
|
| 31 |
+
result = result.replace(/(<style[^&]*?>)([\s\S]*?)(<\/style>)/gi, (match, openTag, cssContent, closeTag) => {
|
| 32 |
+
const highlighted = cssContent
|
| 33 |
+
.replace(/([a-zA-Z-]+)(\s*:\s*)([^;]+)(;?)/g, '<span style="color: #9cdcfe;">$1</span><span style="color: #d4d4d4;">$2</span><span style="color: #ce9178;">$3</span><span style="color: #d4d4d4;">$4</span>')
|
| 34 |
+
.replace(/(\{|\})/g, '<span style="color: #ffd700;">$1</span>');
|
| 35 |
+
return openTag + highlighted + closeTag;
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
result = result
|
| 39 |
+
.replace(/(<\/?)([a-zA-Z][a-zA-Z0-9-]*)([\s\S]*?)(>)/g,
|
| 40 |
+
'<span style="color: #569cd6;">$1</span><span style="color: #4ec9b0;">$2</span><span style="color: #9cdcfe;">$3</span><span style="color: #569cd6;">$4</span>')
|
| 41 |
+
.replace(/(\s)([a-zA-Z-]+)(=)("[^"]*")/g,
|
| 42 |
+
'$1<span style="color: #92c5f8;">$2</span><span style="color: #d4d4d4;">$3</span><span style="color: #ce9178;">$4</span>')
|
| 43 |
+
.replace(/(<!--[\s\S]*?-->)/g, '<span style="color: #6a9955; font-style: italic;">$1</span>')
|
| 44 |
+
.replace(/(<!DOCTYPE[^&]*?>)/gi, '<span style="color: #c586c0;">$1</span>');
|
| 45 |
+
|
| 46 |
+
return result;
|
| 47 |
+
};
|