feat: live markdown preview toggle in chat input
- Eye icon button next to file picker toggles preview on/off - Shows rendered markdown above textarea in real-time - Lazy-loads ReactMarkdown (no bundle impact when off) - Preference persisted in localStorage - i18n: EN/FR labels for show/hide preview
This commit is contained in:
@@ -554,7 +554,8 @@
|
|||||||
## Item #52
|
## Item #52
|
||||||
- **Date:** 2026-02-12
|
- **Date:** 2026-02-12
|
||||||
- **Priority:** medium
|
- **Priority:** medium
|
||||||
- **Status:** pending
|
- **Status:** done
|
||||||
|
- **Completed:** 2026-02-13 — commit `82d2e37`
|
||||||
- **Description:** Raw JSON viewer — toggle to see raw gateway messages
|
- **Description:** Raw JSON viewer — toggle to see raw gateway messages
|
||||||
- Toggle button to switch between rendered view and raw JSON
|
- Toggle button to switch between rendered view and raw JSON
|
||||||
- Show the full gateway message payload as formatted JSON
|
- Show the full gateway message payload as formatted JSON
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback, lazy, Suspense } from 'react';
|
||||||
import { Send, Square, Paperclip, X, FileText } from 'lucide-react';
|
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff } from 'lucide-react';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
|
||||||
|
const ReactMarkdown = lazy(() => import('react-markdown'));
|
||||||
|
const remarkGfm = import('remark-gfm').then(m => m.default);
|
||||||
|
let _remarkGfm: typeof import('remark-gfm').default | null = null;
|
||||||
|
remarkGfm.then(p => { _remarkGfm = p; });
|
||||||
|
|
||||||
interface FileAttachment {
|
interface FileAttachment {
|
||||||
id: string;
|
id: string;
|
||||||
file: File;
|
file: File;
|
||||||
@@ -85,6 +90,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
|
|||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
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 textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -248,6 +254,15 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Markdown preview */}
|
||||||
|
{showPreview && text.trim() && (
|
||||||
|
<div className="mb-3 px-1 max-h-[200px] overflow-y-auto rounded-2xl border border-pc-border bg-pc-elevated/30 p-3 text-sm text-pc-text prose prose-invert prose-sm max-w-none [&_pre]:bg-pc-elevated [&_pre]:rounded-lg [&_pre]:p-2 [&_code]:text-cyan-300 [&_a]:text-cyan-400">
|
||||||
|
<Suspense fallback={<span className="text-pc-text-muted text-xs">Loading…</span>}>
|
||||||
|
<ReactMarkdown remarkPlugins={_remarkGfm ? [_remarkGfm] : []}>{text}</ReactMarkdown>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-end gap-3">
|
<div className="flex items-end gap-3">
|
||||||
{/* File picker button */}
|
{/* File picker button */}
|
||||||
<button
|
<button
|
||||||
@@ -259,6 +274,15 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
|
|||||||
>
|
>
|
||||||
<Paperclip size={18} />
|
<Paperclip size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
{/* Markdown preview toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPreview(v => { const next = !v; localStorage.setItem('pinchchat-md-preview', 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 ${showPreview ? 'text-pc-accent-light bg-[var(--pc-accent-glow)]' : 'text-pc-text-secondary hover:text-pc-accent-light hover:bg-[var(--pc-hover)]'}`}
|
||||||
|
title={showPreview ? t('chat.hidePreview') : t('chat.showPreview')}
|
||||||
|
aria-label={showPreview ? t('chat.hidePreview') : t('chat.showPreview')}
|
||||||
|
>
|
||||||
|
{showPreview ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const en = {
|
|||||||
'chat.attachFile': 'Attach file',
|
'chat.attachFile': 'Attach file',
|
||||||
'chat.send': 'Send',
|
'chat.send': 'Send',
|
||||||
'chat.stop': 'Stop',
|
'chat.stop': 'Stop',
|
||||||
|
'chat.showPreview': 'Preview markdown',
|
||||||
|
'chat.hidePreview': 'Hide preview',
|
||||||
'chat.scrollToBottom': 'New messages',
|
'chat.scrollToBottom': 'New messages',
|
||||||
'chat.collapseTools': 'Collapse all tools',
|
'chat.collapseTools': 'Collapse all tools',
|
||||||
'chat.expandTools': 'Expand all tools',
|
'chat.expandTools': 'Expand all tools',
|
||||||
@@ -151,6 +153,8 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'chat.attachFile': 'Joindre un fichier',
|
'chat.attachFile': 'Joindre un fichier',
|
||||||
'chat.send': 'Envoyer',
|
'chat.send': 'Envoyer',
|
||||||
'chat.stop': 'Arrêter',
|
'chat.stop': 'Arrêter',
|
||||||
|
'chat.showPreview': 'Aperçu markdown',
|
||||||
|
'chat.hidePreview': 'Masquer l\'aperçu',
|
||||||
'chat.scrollToBottom': 'Nouveaux messages',
|
'chat.scrollToBottom': 'Nouveaux messages',
|
||||||
'chat.collapseTools': 'Replier tous les outils',
|
'chat.collapseTools': 'Replier tous les outils',
|
||||||
'chat.expandTools': 'Déplier tous les outils',
|
'chat.expandTools': 'Déplier tous les outils',
|
||||||
|
|||||||
Reference in New Issue
Block a user