refactor: remove HighlightedTextarea (cursor desync unfixable)
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, lazy, Suspense } from 'react';
|
import { useState, useRef, useEffect, useCallback, lazy, Suspense } from 'react';
|
||||||
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff, Highlighter } from 'lucide-react';
|
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff } from 'lucide-react';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
import { HighlightedTextarea } from './HighlightedTextarea';
|
|
||||||
|
|
||||||
const ReactMarkdown = lazy(() => import('react-markdown'));
|
const ReactMarkdown = lazy(() => import('react-markdown'));
|
||||||
const remarkGfm = import('remark-gfm').then(m => m.default);
|
const remarkGfm = import('remark-gfm').then(m => m.default);
|
||||||
@@ -92,7 +91,6 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
|
|||||||
const [files, setFiles] = useState<FileAttachment[]>([]);
|
const [files, setFiles] = useState<FileAttachment[]>([]);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [showPreview, setShowPreview] = useState(() => localStorage.getItem('pinchchat-md-preview') === '1');
|
const [showPreview, setShowPreview] = useState(() => localStorage.getItem('pinchchat-md-preview') === '1');
|
||||||
const [highlightEnabled, setHighlightEnabled] = useState(() => localStorage.getItem('pinchchat-syntax-hl') === '1');
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -285,15 +283,6 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
|
|||||||
>
|
>
|
||||||
{showPreview ? <EyeOff size={18} /> : <Eye size={18} />}
|
{showPreview ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
</button>
|
</button>
|
||||||
{/* Syntax highlight toggle — hidden on mobile */}
|
|
||||||
<button
|
|
||||||
onClick={() => setHighlightEnabled(v => { const next = !v; localStorage.setItem('pinchchat-syntax-hl', next ? '1' : '0'); return next; })}
|
|
||||||
className={`hidden sm:flex shrink-0 h-11 w-11 rounded-2xl border border-pc-border bg-pc-elevated/30 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
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -303,12 +292,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"
|
accept="image/*,.pdf,.txt,.md,.json,.csv,.log,.py,.js,.ts,.tsx,.jsx,.html,.css,.yaml,.yml,.xml,.sql,.sh,.env,.toml"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HighlightedTextarea
|
<textarea
|
||||||
id="chat-input"
|
id="chat-input"
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={text}
|
value={text}
|
||||||
highlightEnabled={highlightEnabled}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onChange={(e) => setText((e.target as HTMLTextAreaElement).value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder={t('chat.inputPlaceholder')}
|
placeholder={t('chat.inputPlaceholder')}
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
|
|
||||||
// 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 flex-1 min-w-0">
|
|
||||||
<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';
|
|
||||||
@@ -53,8 +53,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Chat textarea: no focus ring (container handles visual feedback) */
|
/* Chat textarea: no focus ring (container handles visual feedback) */
|
||||||
.ht-textarea:focus-visible,
|
|
||||||
.ht-textarea:focus,
|
|
||||||
#chat-input:focus-visible,
|
#chat-input:focus-visible,
|
||||||
#chat-input:focus {
|
#chat-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -202,77 +200,6 @@ html, body {
|
|||||||
.markdown-body th { background: var(--pc-accent-glow); }
|
.markdown-body th { background: var(--pc-accent-glow); }
|
||||||
.markdown-body img { max-width: 100%; border-radius: 8px; }
|
.markdown-body img { max-width: 100%; border-radius: 8px; }
|
||||||
|
|
||||||
/* Highlighted textarea overlay */
|
|
||||||
.ht-container {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-backdrop,
|
|
||||||
.ht-textarea {
|
|
||||||
/* Must share EXACTLY identical text layout — any difference causes cursor desync */
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
line-height: inherit;
|
|
||||||
letter-spacing: inherit;
|
|
||||||
word-spacing: inherit;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
/* padding, border, border-radius all come from the shared className */
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
color: var(--pc-text-primary);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-textarea {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
color: transparent;
|
|
||||||
caret-color: var(--pc-text-primary);
|
|
||||||
background: transparent !important;
|
|
||||||
resize: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Token colors — ONLY color allowed, nothing that changes text geometry */
|
|
||||||
.ht-code-block {
|
|
||||||
color: #67e8f9; /* cyan-300 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-inline-code {
|
|
||||||
color: #67e8f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-bold {
|
|
||||||
color: var(--pc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-italic {
|
|
||||||
color: var(--pc-text-secondary);
|
|
||||||
/* No font-style: italic — italic glyphs have different widths, causes cursor desync */
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-heading {
|
|
||||||
color: #a78bfa; /* violet-400 */
|
|
||||||
/* No font-weight — bolder glyphs are wider, causes cursor desync */
|
|
||||||
}
|
|
||||||
|
|
||||||
.ht-link {
|
|
||||||
color: #67e8f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accessibility: respect reduced-motion preferences */
|
/* Accessibility: respect reduced-motion preferences */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
|
|||||||
Reference in New Issue
Block a user