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 |
+
};
|