novitacarlen commited on
Commit
1aaa08e
·
1 Parent(s): bb7727c

feat: enable code editor highlight

Browse files
Files changed (2) hide show
  1. src/components/code-editor.tsx +67 -44
  2. 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
- // Check if user is near bottom when scrolling
38
- const handleScroll = () => {
39
- if (!containerRef.current) return;
40
-
41
- const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
42
- // Consider "at bottom" if within 30px of the bottom
 
 
43
  const isNearBottom = scrollHeight - scrollTop - clientHeight < 30;
44
  setIsAtBottom(isNearBottom);
45
  };
46
 
47
  useEffect(() => {
48
- const container = containerRef.current;
49
- if (container) {
50
- container.addEventListener('scroll', handleScroll);
51
- return () => container.removeEventListener('scroll', handleScroll);
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
- // Auto-resize textarea
63
- const adjustTextareaHeight = () => {
64
- if (textareaRef.current) {
65
- textareaRef.current.style.height = 'auto';
66
- textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
 
 
 
 
 
 
 
 
 
 
67
  }
68
  };
69
 
70
- useEffect(() => {
71
- adjustTextareaHeight();
72
- }, [localCode]);
73
 
74
  return (
75
- <div className="flex-1 overflow-auto p-4 pr-2">
76
  <div
77
  ref={containerRef}
78
- className="code-area p-4 rounded-md font-mono text-sm h-full overflow-auto border border-novita-gray/20 relative"
79
  >
80
- <textarea
81
- ref={textareaRef}
82
- value={localCode}
83
- onChange={handleCodeChange}
84
- disabled={isLoading}
85
- className="w-full min-h-full bg-transparent text-white resize-none outline-none whitespace-pre-wrap font-mono text-sm leading-relaxed"
86
- style={{
87
- minHeight: '100%',
88
- fontFamily: 'inherit',
89
- fontSize: 'inherit',
90
- lineHeight: 'inherit'
91
- }}
92
- placeholder="Your generated code will appear here..."
93
- spellCheck={false}
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, '&amp;')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;')
29
+ .replace(/'/g, '&#039;');
30
+
31
+ result = result.replace(/(&lt;style[^&]*?&gt;)([\s\S]*?)(&lt;\/style&gt;)/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(/(&lt;\/?)([a-zA-Z][a-zA-Z0-9-]*)([\s\S]*?)(&gt;)/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-]+)(=)(&quot;[^&quot;]*&quot;)/g,
42
+ '$1<span style="color: #92c5f8;">$2</span><span style="color: #d4d4d4;">$3</span><span style="color: #ce9178;">$4</span>')
43
+ .replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span style="color: #6a9955; font-style: italic;">$1</span>')
44
+ .replace(/(&lt;!DOCTYPE[^&]*?&gt;)/gi, '<span style="color: #c586c0;">$1</span>');
45
+
46
+ return result;
47
+ };