authentification nocodebackend ok
This commit is contained in:
572
components/RichTextEditor.tsx
Normal file
572
components/RichTextEditor.tsx
Normal file
@@ -0,0 +1,572 @@
|
||||
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<string>;
|
||||
}
|
||||
|
||||
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<RichTextEditorHandle, RichTextEditorProps>(({ initialContent, onChange, onSave, onSelectionChange, onAiTransform }, ref) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Auto-Save State
|
||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved');
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(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<Version[]>([]);
|
||||
const [showHistoryMargin, setShowHistoryMargin] = useState(true);
|
||||
const [expandedGroupIds, setExpandedGroupIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Refs to track selection
|
||||
const savedRange = useRef<Range | null>(null);
|
||||
const lastCursorPosition = useRef<Range | null>(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, '<br>');
|
||||
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<string, string> = {
|
||||
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) => (
|
||||
<button
|
||||
onMouseDown={(e) => {
|
||||
if (onClick) {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
execCommand(cmd, arg);
|
||||
}
|
||||
}}
|
||||
className={`p-1.5 rounded transition-colors ${isActive ? 'bg-indigo-100 text-indigo-700' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200'}`}
|
||||
title={label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</button>
|
||||
);
|
||||
|
||||
const hasSelection = savedRange.current && !savedRange.current.collapsed;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden relative">
|
||||
<style>{`
|
||||
.editor-content:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #cbd5e1;
|
||||
font-style: italic;
|
||||
cursor: text;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 p-2 bg-slate-50 border-b border-slate-200 flex-wrap relative z-20 shadow-sm">
|
||||
<ToolbarButton icon={Bold} cmd="bold" label="Gras" />
|
||||
<ToolbarButton icon={Italic} cmd="italic" label="Italique" />
|
||||
<ToolbarButton icon={Underline} cmd="underline" label="Souligné" />
|
||||
<div className="w-px h-6 bg-slate-300 mx-1" />
|
||||
<ToolbarButton icon={Heading1} cmd="formatBlock" arg="H1" label="Titre 1" />
|
||||
<ToolbarButton icon={Heading2} cmd="formatBlock" arg="H2" label="Titre 2" />
|
||||
<div className="w-px h-6 bg-slate-300 mx-1" />
|
||||
<ToolbarButton icon={AlignLeft} cmd="justifyLeft" label="Aligner à gauche" />
|
||||
<ToolbarButton icon={AlignCenter} cmd="justifyCenter" label="Centrer" />
|
||||
<ToolbarButton icon={AlignRight} cmd="justifyRight" label="Aligner à droite" />
|
||||
<div className="w-px h-6 bg-slate-300 mx-1" />
|
||||
<ToolbarButton icon={List} cmd="insertUnorderedList" label="Liste" />
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Save Status Indicator */}
|
||||
<div className="flex items-center gap-2 mr-4 text-xs font-medium text-slate-400">
|
||||
{saveStatus === 'saving' && <><Loader2 size={12} className="animate-spin" /> Sauvegarde...</>}
|
||||
{saveStatus === 'saved' && <><Check size={12} className="text-green-500" /> Sauvegardé</>}
|
||||
{saveStatus === 'unsaved' && <span className="text-amber-500">Modifications non enregistrées...</span>}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-slate-300 mx-1" />
|
||||
<ToolbarButton
|
||||
icon={History}
|
||||
label="Marge d'historique"
|
||||
onClick={() => setShowHistoryMargin(!showHistoryMargin)}
|
||||
isActive={showHistoryMargin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Container - Scrollable Area */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto relative bg-slate-100"
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div className="flex justify-center relative min-h-full py-8">
|
||||
|
||||
{/* Editor Content Page */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="bg-white shadow-sm w-[800px] min-h-[1000px] p-12 outline-none font-serif text-lg leading-relaxed text-slate-900 editor-content"
|
||||
onInput={handleInput}
|
||||
onBlur={() => { 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 && (
|
||||
<div className="absolute left-[calc(50%+420px)] top-0 bottom-0 w-80 pt-8 pointer-events-none">
|
||||
{/* Placeholder for empty history */}
|
||||
{versionGroups.length === 0 && (
|
||||
<div className="sticky top-10 text-center text-slate-300 p-4">
|
||||
<History size={48} className="mx-auto mb-2 opacity-20" />
|
||||
<p className="text-xs">L'historique des modifications IA apparaîtra ici, aligné avec votre texte.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render Groups */}
|
||||
{versionGroups.map((group) => {
|
||||
const isExpanded = expandedGroupIds.has(group.id);
|
||||
const isStack = group.versions.length > 1;
|
||||
const latest = group.versions[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className="absolute w-72 pointer-events-auto transition-all duration-300 ease-in-out"
|
||||
style={{ top: `${group.topOffset + 32}px` }} // +32 for padding
|
||||
>
|
||||
<div className={`relative bg-white rounded-lg border shadow-sm transition-all duration-200 ${isStack && !isExpanded ? 'border-indigo-200 shadow-md translate-x-1 translate-y-1' : 'border-slate-200'}`}>
|
||||
|
||||
{/* Stack Effect Background Card */}
|
||||
{isStack && !isExpanded && (
|
||||
<div className="absolute inset-0 bg-white border border-indigo-100 rounded-lg transform -translate-x-1 -translate-y-1 -z-10 shadow-sm" />
|
||||
)}
|
||||
|
||||
{/* Main Card Header */}
|
||||
<div
|
||||
className="p-2 border-b border-slate-100 flex justify-between items-center bg-slate-50 rounded-t-lg cursor-pointer hover:bg-slate-100"
|
||||
onClick={() => isStack && toggleGroup(group.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isStack && (
|
||||
<Layers size={14} className="text-indigo-500" />
|
||||
)}
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded uppercase tracking-wide ${latest.type.includes('Correction') ? 'bg-green-100 text-green-700' :
|
||||
latest.type.includes('Insertion') ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{latest.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{new Date(latest.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
{isStack && (
|
||||
isExpanded ? <ChevronUp size={14} className="text-slate-400" /> : <ChevronDown size={14} className="text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Content (Latest) */}
|
||||
{!isExpanded && (
|
||||
<div className="p-2">
|
||||
<div className="text-xs text-slate-500 italic line-clamp-2">
|
||||
"{latest.snippet}"
|
||||
</div>
|
||||
<button
|
||||
onClick={() => restoreVersion(latest)}
|
||||
className="mt-2 w-full flex items-center justify-center gap-1 text-[10px] bg-slate-50 hover:bg-indigo-50 text-slate-600 hover:text-indigo-700 py-1 rounded transition-colors"
|
||||
>
|
||||
<RotateCcw size={10} /> Restaurer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded Stack View */}
|
||||
{isExpanded && (
|
||||
<div className="divide-y divide-slate-100 max-h-64 overflow-y-auto">
|
||||
{group.versions.map((v, i) => (
|
||||
<div key={v.id} className="p-2 bg-white hover:bg-slate-50 transition-colors">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-[10px] font-semibold text-slate-600">
|
||||
{i === 0 ? 'Dernière version' : `Version -${i}`}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400">
|
||||
{new Date(v.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 italic bg-slate-50 p-1.5 rounded mb-2 border border-slate-100">
|
||||
"{v.snippet}"
|
||||
</div>
|
||||
<button
|
||||
onClick={() => restoreVersion(v)}
|
||||
className="w-full flex items-center justify-center gap-1 text-[10px] bg-white border border-slate-200 text-slate-600 hover:text-indigo-600 hover:border-indigo-200 py-1 rounded transition-colors"
|
||||
>
|
||||
<RotateCcw size={10} /> Restaurer cette version
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context Menu Overlay */}
|
||||
{contextMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-transparent"
|
||||
onClick={() => setContextMenu(null)}
|
||||
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
|
||||
/>
|
||||
<div
|
||||
className="fixed z-50 bg-white border border-slate-200 rounded-lg shadow-xl py-1 w-56 animate-in fade-in zoom-in-95 duration-100 flex flex-col"
|
||||
style={{ top: Math.min(contextMenu.y, window.innerHeight - 200), left: Math.min(contextMenu.x, window.innerWidth - 224) }}
|
||||
>
|
||||
{isAiLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-indigo-600 gap-2">
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
<span className="text-xs font-medium">L'IA travaille...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
Outils IA
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleAiAction('correct')}
|
||||
disabled={!hasSelection}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${!hasSelection ? 'text-slate-300 cursor-not-allowed' : 'text-slate-700 hover:bg-indigo-50 hover:text-indigo-700'}`}
|
||||
>
|
||||
<Check size={14} /> Corriger l'orthographe
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAiAction('rewrite')}
|
||||
disabled={!hasSelection}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${!hasSelection ? 'text-slate-300 cursor-not-allowed' : 'text-slate-700 hover:bg-indigo-50 hover:text-indigo-700'}`}
|
||||
>
|
||||
<RefreshCw size={14} /> Reformuler
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAiAction('expand')}
|
||||
disabled={!hasSelection}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${!hasSelection ? 'text-slate-300 cursor-not-allowed' : 'text-slate-700 hover:bg-indigo-50 hover:text-indigo-700'}`}
|
||||
>
|
||||
<Maximize2 size={14} /> Développer
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAiAction('continue')}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-indigo-50 hover:text-indigo-700 text-left transition-colors"
|
||||
>
|
||||
<Wand2 size={14} /> Continuer l'écriture
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-slate-100 my-1" />
|
||||
|
||||
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
Édition
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={!hasSelection}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${!hasSelection ? 'text-slate-300 cursor-not-allowed' : 'text-slate-700 hover:bg-slate-50'}`}
|
||||
>
|
||||
<Copy size={14} /> Copier
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 text-left transition-colors"
|
||||
>
|
||||
<MousePointerClick size={14} /> Tout sélectionner
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default RichTextEditor;
|
||||
Reference in New Issue
Block a user