import React, { useRef, useEffect, useState, useImperativeHandle, forwardRef, useMemo } from 'react'; import { Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, List, Heading1, Heading2, Copy, Wand2, Check, RefreshCw, Maximize2, Loader2, MousePointerClick, History, RotateCcw, ChevronDown, ChevronUp, Layers } from 'lucide-react'; export interface RichTextEditorHandle { insertHtml: (html: string) => void; } interface RichTextEditorProps { initialContent: string; onChange?: (html: string) => void; onSave?: (html: string) => void; onSelectionChange?: (text: string) => void; onAiTransform?: (text: string, mode: 'correct' | 'rewrite' | 'expand' | 'continue') => Promise; } interface Version { id: string; timestamp: number; type: string; content: string; // Full HTML snapshot snippet: string; // Selected text snippet before change topOffset: number; // Y position relative to editor top } interface VersionGroup { id: string; topOffset: number; versions: Version[]; } const RichTextEditor = forwardRef(({ initialContent, onChange, onSave, onSelectionChange, onAiTransform }, ref) => { const contentRef = useRef(null); const scrollContainerRef = useRef(null); const [isFocused, setIsFocused] = useState(false); // Auto-Save State const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved'); const saveTimeoutRef = useRef(null); // Context Menu State const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [isAiLoading, setIsAiLoading] = useState(false); // History State const [versions, setVersions] = useState([]); const [showHistoryMargin, setShowHistoryMargin] = useState(true); const [expandedGroupIds, setExpandedGroupIds] = useState>(new Set()); // Refs to track selection const savedRange = useRef(null); const lastCursorPosition = useRef(null); // --- Helpers --- // Group versions by proximity (within 60px) to stack them const versionGroups = useMemo(() => { const sortedVersions = [...versions].sort((a, b) => b.timestamp - a.timestamp); const groups: VersionGroup[] = []; sortedVersions.forEach(v => { // Find an existing group close to this version const existingGroup = groups.find(g => Math.abs(g.topOffset - v.topOffset) < 60); if (existingGroup) { existingGroup.versions.push(v); // Keep the group timestamp sorted existingGroup.versions.sort((a, b) => b.timestamp - a.timestamp); } else { groups.push({ id: `group-${v.id}`, topOffset: v.topOffset, versions: [v] }); } }); return groups; }, [versions]); const toggleGroup = (groupId: string) => { const newSet = new Set(expandedGroupIds); if (newSet.has(groupId)) { newSet.delete(groupId); } else { newSet.add(groupId); } setExpandedGroupIds(newSet); }; const getSelectionTopOffset = () => { const sel = window.getSelection(); if (sel && sel.rangeCount > 0 && contentRef.current) { const range = sel.getRangeAt(0); const rect = range.getBoundingClientRect(); // We need offset relative to the content container (contentRef) // contentRef is the white page div. const containerRect = contentRef.current.getBoundingClientRect(); return rect.top - containerRect.top; } return 0; }; const saveVersion = (type: string, textSnippet: string) => { if (!contentRef.current) return; const topOffset = getSelectionTopOffset(); const newVersion: Version = { id: Date.now().toString(), timestamp: Date.now(), type: type, content: contentRef.current.innerHTML, snippet: textSnippet.substring(0, 80) + (textSnippet.length > 80 ? '...' : ''), topOffset }; setVersions(prev => [newVersion, ...prev]); setShowHistoryMargin(true); }; const restoreVersion = (version: Version) => { if (!contentRef.current) return; if (confirm('Restaurer cette version ? Le contenu actuel sera remplacé.')) { contentRef.current.innerHTML = version.content; handleInput(); } }; // --- Exposed Methods --- useImperativeHandle(ref, () => ({ insertHtml: (text: string) => { saveVersion('Insertion Chat', 'Insertion depuis le panneau IA'); contentRef.current?.focus(); const sel = window.getSelection(); if (lastCursorPosition.current) { sel?.removeAllRanges(); sel?.addRange(lastCursorPosition.current); } else if (contentRef.current) { const range = document.createRange(); range.selectNodeContents(contentRef.current); range.collapse(false); sel?.removeAllRanges(); sel?.addRange(range); } const htmlToInsert = text.includes('<') ? text : text.replace(/\n/g, '
'); document.execCommand('insertHTML', false, htmlToInsert); handleInput(); } })); // --- Effects --- useEffect(() => { if (contentRef.current && contentRef.current.innerHTML !== initialContent) { // Only update if difference is significant to avoid cursor jumps on small re-renders? // OR better: Only update if NOT focused? if (!isFocused && Math.abs(contentRef.current.innerHTML.length - initialContent.length) > 5) { contentRef.current.innerHTML = initialContent; } } }, [initialContent, isFocused]); // --- Event Handlers --- const execCommand = (command: string, value: string | undefined = undefined) => { document.execCommand(command, false, value); handleInput(); contentRef.current?.focus(); }; const handleInput = () => { if (contentRef.current) { if (onChange) onChange(contentRef.current.innerHTML); // Auto-Save Debounce if (onSave) { setSaveStatus('unsaved'); if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(async () => { setSaveStatus('saving'); await onSave(contentRef.current?.innerHTML || ""); setSaveStatus('saved'); }, 2000); // 2 seconds } } }; const saveSelection = () => { const sel = window.getSelection(); if (sel && sel.rangeCount > 0 && contentRef.current?.contains(sel.anchorNode)) { lastCursorPosition.current = sel.getRangeAt(0).cloneRange(); } }; const handleSelection = () => { const selection = window.getSelection(); saveSelection(); if (selection && selection.toString().length > 0 && onSelectionChange) { onSelectionChange(selection.toString()); } else if (onSelectionChange) { onSelectionChange(""); } }; const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); if (contentRef.current?.contains(range.commonAncestorContainer)) { savedRange.current = range.cloneRange(); setContextMenu({ x: e.clientX, y: e.clientY }); return; } } savedRange.current = null; setContextMenu({ x: e.clientX, y: e.clientY }); }; const handleAiAction = async (mode: 'correct' | 'rewrite' | 'expand' | 'continue') => { if (!onAiTransform) return; const range = savedRange.current; const text = range?.toString() || ""; if (!text && mode !== 'continue') return; const typeLabels: Record = { correct: 'Correction', rewrite: 'Reformulation', expand: 'Développement', continue: 'Continuation' }; saveVersion(typeLabels[mode], text || "Position curseur"); setIsAiLoading(true); try { const result = await onAiTransform(text, mode); if (result) { contentRef.current?.focus(); const sel = window.getSelection(); sel?.removeAllRanges(); if (range) { sel?.addRange(range); } if (mode === 'continue') { sel?.collapseToEnd(); document.execCommand('insertText', false, " " + result); } else { document.execCommand('insertText', false, result); } handleInput(); } } catch (e) { console.error("AI Action failed", e); } finally { setIsAiLoading(false); setContextMenu(null); } }; const handleCopy = () => { if (savedRange.current) { const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(savedRange.current); document.execCommand('copy'); } setContextMenu(null); }; const handleSelectAll = () => { contentRef.current?.focus(); document.execCommand('selectAll'); handleSelection(); setContextMenu(null); } const ToolbarButton = ({ icon: Icon, cmd, arg, label, onClick, isActive }: any) => ( ); const hasSelection = savedRange.current && !savedRange.current.collapsed; return (
{/* Toolbar */}
{/* Save Status Indicator */}
{saveStatus === 'saving' && <> Sauvegarde...} {saveStatus === 'saved' && <> Sauvegardé} {saveStatus === 'unsaved' && Modifications non enregistrées...}
setShowHistoryMargin(!showHistoryMargin)} isActive={showHistoryMargin} />
{/* Main Container - Scrollable Area */}
{/* Editor Content Page */}
{ setIsFocused(false); saveSelection(); }} onFocus={() => setIsFocused(true)} onKeyUp={saveSelection} onMouseUp={saveSelection} onSelect={handleSelection} onClick={() => contentRef.current?.focus()} onContextMenu={handleContextMenu} data-placeholder="Commencez à écrire votre chef-d'œuvre... (Clic droit pour outils IA)" /> {/* History Track - Moving with the page */} {showHistoryMargin && (
{/* Placeholder for empty history */} {versionGroups.length === 0 && (

L'historique des modifications IA apparaîtra ici, aligné avec votre texte.

)} {/* Render Groups */} {versionGroups.map((group) => { const isExpanded = expandedGroupIds.has(group.id); const isStack = group.versions.length > 1; const latest = group.versions[0]; return (
{/* Stack Effect Background Card */} {isStack && !isExpanded && (
)} {/* Main Card Header */}
isStack && toggleGroup(group.id)} >
{isStack && ( )} {latest.type}
{new Date(latest.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {isStack && ( isExpanded ? : )}
{/* Card Content (Latest) */} {!isExpanded && (
"{latest.snippet}"
)} {/* Expanded Stack View */} {isExpanded && (
{group.versions.map((v, i) => (
{i === 0 ? 'Dernière version' : `Version -${i}`} {new Date(v.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
"{v.snippet}"
))}
)}
); })}
)}
{/* Context Menu Overlay */} {contextMenu && ( <>
setContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }} />
{isAiLoading ? (
L'IA travaille...
) : ( <>
Outils IA
Édition
)}
)}
); }); export default RichTextEditor;