feat: syntax highlighting in chat input textarea

Real-time markdown syntax coloring while typing using a transparent
textarea overlay approach. Highlights: code blocks, inline code,
bold, italic, headings, and links. Toggle button (highlighter icon)
to enable/disable, persisted in localStorage.
This commit is contained in:
Nicolas Varrot
2026-02-13 02:26:15 +00:00
parent e3149661d8
commit b0492434d0
3 changed files with 187 additions and 3 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback, lazy, Suspense } from 'react';
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff } from 'lucide-react';
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff, Highlighter } from 'lucide-react';
import { useT } from '../hooks/useLocale';
import { HighlightedTextarea } from './HighlightedTextarea';
const ReactMarkdown = lazy(() => import('react-markdown'));
const remarkGfm = import('remark-gfm').then(m => m.default);
@@ -91,6 +92,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
const [files, setFiles] = useState<FileAttachment[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [showPreview, setShowPreview] = useState(() => localStorage.getItem('pinchchat-md-preview') === '1');
const [highlightEnabled, setHighlightEnabled] = useState(() => localStorage.getItem('pinchchat-syntax-hl') !== '0');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -283,6 +285,15 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
>
{showPreview ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
{/* Syntax highlight toggle */}
<button
onClick={() => setHighlightEnabled(v => { const next = !v; localStorage.setItem('pinchchat-syntax-hl', next ? '1' : '0'); return next; })}
className={`shrink-0 h-11 w-11 rounded-2xl border border-pc-border bg-pc-elevated/30 flex items-center justify-center transition-colors ${highlightEnabled ? 'text-pc-accent-light bg-[var(--pc-accent-glow)]' : 'text-pc-text-secondary hover:text-pc-accent-light hover:bg-[var(--pc-hover)]'}`}
title={highlightEnabled ? 'Disable syntax highlight' : 'Enable syntax highlight'}
aria-label={highlightEnabled ? 'Disable syntax highlight' : 'Enable syntax highlight'}
>
<Highlighter size={18} />
</button>
<input
ref={fileInputRef}
type="file"
@@ -292,10 +303,11 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
accept="image/*,.pdf,.txt,.md,.json,.csv,.log,.py,.js,.ts,.tsx,.jsx,.html,.css,.yaml,.yml,.xml,.sql,.sh,.env,.toml"
/>
<textarea
<HighlightedTextarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
highlightEnabled={highlightEnabled}
onChange={(e) => setText((e.target as HTMLTextAreaElement).value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={t('chat.inputPlaceholder')}

View File

@@ -0,0 +1,97 @@
import { forwardRef, useRef, useEffect, useImperativeHandle, useCallback } from 'react';
/**
* A textarea with a syntax-highlighted backdrop overlay.
* The textarea text is transparent; a <pre> behind it renders colored tokens.
*/
interface Props extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'style'> {
value: string;
highlightEnabled?: boolean;
}
// Simple markdown tokenizer — returns HTML with <span> wrappers
function highlightMarkdown(text: string): string {
if (!text) return '\n'; // pre needs at least a newline to size correctly
// Escape HTML first
let html = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Fenced code blocks: ```...```
html = html.replace(/(```[\s\S]*?```)/g, '<span class="ht-code-block">$1</span>');
// Inline code: `...` (but not inside code blocks — already wrapped)
html = html.replace(/(?<!<span class="ht-code-block">[\s\S]*?)(`[^`\n]+?`)/g, '<span class="ht-inline-code">$1</span>');
// Bold: **...**
html = html.replace(/(\*\*[^*]+?\*\*)/g, '<span class="ht-bold">$1</span>');
// Italic: *...* (single, not inside bold)
html = html.replace(/(?<!\*)(\*[^*\n]+?\*)(?!\*)/g, '<span class="ht-italic">$1</span>');
// Headers at line start: # ...
html = html.replace(/(^|\n)(#{1,6}\s[^\n]*)/g, '$1<span class="ht-heading">$2</span>');
// Links: [text](url)
html = html.replace(/(\[[^\]]*\]\([^)]*\))/g, '<span class="ht-link">$1</span>');
// Ensure trailing newline for proper sizing
if (!html.endsWith('\n')) html += '\n';
return html;
}
export const HighlightedTextarea = forwardRef<HTMLTextAreaElement, Props>(
({ value, highlightEnabled = true, className = '', ...props }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const backdropRef = useRef<HTMLPreElement>(null);
useImperativeHandle(ref, () => textareaRef.current!);
// Sync scroll
const syncScroll = useCallback(() => {
if (textareaRef.current && backdropRef.current) {
backdropRef.current.scrollTop = textareaRef.current.scrollTop;
backdropRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
}, []);
useEffect(() => {
syncScroll();
}, [value, syncScroll]);
if (!highlightEnabled) {
return (
<textarea
ref={textareaRef}
value={value}
className={className}
{...props}
/>
);
}
return (
<div className="ht-container relative">
<pre
ref={backdropRef}
className={`ht-backdrop ${className}`}
aria-hidden="true"
dangerouslySetInnerHTML={{ __html: highlightMarkdown(value) }}
/>
<textarea
ref={textareaRef}
value={value}
className={`ht-textarea ${className}`}
onScroll={syncScroll}
{...props}
/>
</div>
);
}
);
HighlightedTextarea.displayName = 'HighlightedTextarea';

View File

@@ -177,6 +177,81 @@ html, body {
.markdown-body th { background: var(--pc-accent-glow); }
.markdown-body img { max-width: 100%; border-radius: 8px; }
/* Highlighted textarea overlay */
.ht-container {
position: relative;
flex: 1;
}
.ht-backdrop,
.ht-textarea {
/* Must share identical text layout */
font-family: inherit;
font-size: 0.875rem; /* text-sm */
line-height: 1.25rem;
padding: 0.75rem 1rem; /* py-3 px-4 */
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
overflow-x: hidden;
overflow-y: auto;
margin: 0;
box-sizing: border-box;
}
.ht-backdrop {
position: absolute;
inset: 0;
pointer-events: none;
color: var(--pc-text-primary);
border: 1px solid transparent;
border-radius: 1rem; /* rounded-2xl */
max-height: 200px;
}
.ht-textarea {
position: relative;
color: transparent;
caret-color: var(--pc-text-primary);
background: transparent !important;
resize: none;
z-index: 1;
}
/* Token colors */
.ht-code-block {
color: #67e8f9; /* cyan-300 */
background: var(--pc-accent-glow);
border-radius: 4px;
}
.ht-inline-code {
color: #67e8f9;
background: var(--pc-accent-glow);
border-radius: 3px;
padding: 0 2px;
}
.ht-bold {
color: var(--pc-text-primary);
font-weight: 700;
}
.ht-italic {
color: var(--pc-text-secondary);
font-style: italic;
}
.ht-heading {
color: #a78bfa; /* violet-400 */
font-weight: 600;
}
.ht-link {
color: #67e8f9;
text-decoration: underline;
}
/* Accessibility: respect reduced-motion preferences */
@media (prefers-reduced-motion: reduce) {
.animate-fade-in {