Files
EmailManager/components/RichTextEditor.tsx
2025-12-10 12:10:20 +01:00

206 lines
7.2 KiB
TypeScript

import React, { useRef, useState, useEffect } from 'react';
import {
Bold, Italic, Underline, List, ListOrdered, Link,
Code, Type, Eraser, AlignLeft, AlignCenter, AlignRight, Braces
} from 'lucide-react';
interface Props {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
const RichTextEditor: React.FC<Props> = ({ value, onChange, placeholder, className = '' }) => {
const [isSourceMode, setIsSourceMode] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const selectionRef = useRef<Range | null>(null);
// Sync value to contentEditable div
useEffect(() => {
if (contentRef.current && !isSourceMode) {
if (document.activeElement !== contentRef.current) {
if (contentRef.current.innerHTML !== value) {
contentRef.current.innerHTML = value;
}
}
}
}, [value, isSourceMode]);
const saveSelection = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
// Ensure the selection is actually inside our editor
if (contentRef.current && contentRef.current.contains(range.commonAncestorContainer)) {
selectionRef.current = range;
}
}
};
const restoreSelection = () => {
if (contentRef.current) {
contentRef.current.focus();
if (selectionRef.current) {
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(selectionRef.current);
}
} else {
// Fallback: move cursor to the end if no selection saved
const range = document.createRange();
range.selectNodeContents(contentRef.current);
range.collapse(false);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
};
const execCommand = (command: string, value: string | undefined = undefined) => {
restoreSelection();
document.execCommand(command, false, value);
handleInput();
};
const handleInput = () => {
if (contentRef.current) {
onChange(contentRef.current.innerHTML);
saveSelection();
}
};
const insertVariable = (e: React.MouseEvent) => {
e.preventDefault(); // Prevent default button behavior
e.stopPropagation();
const varName = prompt("Enter variable name (without brackets):", "variable_name");
if (varName) {
if (isSourceMode) {
alert("Switch to Visual mode to use the inserter, or type {{name}} manually.");
} else {
restoreSelection();
const text = `{{${varName}}}`;
// Try standard command first, fallback to range manipulation
const success = document.execCommand('insertText', false, text);
if (!success) {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(text);
range.insertNode(textNode);
// Move cursor after inserted text
range.setStartAfter(textNode);
range.setEndAfter(textNode);
sel.removeAllRanges();
sel.addRange(range);
}
}
handleInput();
}
}
};
const ToolbarButton = ({
icon: Icon,
command,
arg,
active = false,
title
}: { icon: any, command?: string, arg?: string, active?: boolean, title: string }) => (
<button
type="button"
onMouseDown={(e) => e.preventDefault()} // Prevent focus loss from editor
onClick={() => command && execCommand(command, arg)}
className={`p-1.5 rounded hover:bg-slate-200 text-slate-600 transition-colors ${active ? 'bg-slate-200 text-slate-900' : ''}`}
title={title}
>
<Icon size={16} />
</button>
);
return (
<div className={`flex flex-col border border-slate-300 rounded-lg overflow-hidden bg-white ${className}`}>
{/* Toolbar */}
<div className="flex items-center gap-1 p-2 bg-slate-50 border-b border-slate-200 flex-wrap">
<div className="flex items-center gap-1 border-r border-slate-200 pr-2 mr-2">
<button
type="button"
onClick={() => setIsSourceMode(!isSourceMode)}
className={`flex items-center gap-1 px-2 py-1.5 rounded text-xs font-medium transition-colors ${isSourceMode ? 'bg-slate-800 text-white' : 'hover:bg-slate-200 text-slate-600'}`}
>
{isSourceMode ? <><Type size={14} /> Visual</> : <><Code size={14} /> Source</>}
</button>
</div>
{!isSourceMode && (
<>
<ToolbarButton icon={Bold} command="bold" title="Bold" />
<ToolbarButton icon={Italic} command="italic" title="Italic" />
<ToolbarButton icon={Underline} command="underline" title="Underline" />
<div className="w-px h-4 bg-slate-300 mx-1" />
<ToolbarButton icon={AlignLeft} command="justifyLeft" title="Align Left" />
<ToolbarButton icon={AlignCenter} command="justifyCenter" title="Align Center" />
<ToolbarButton icon={AlignRight} command="justifyRight" title="Align Right" />
<div className="w-px h-4 bg-slate-300 mx-1" />
<ToolbarButton icon={List} command="insertUnorderedList" title="Bullet List" />
<ToolbarButton icon={ListOrdered} command="insertOrderedList" title="Numbered List" />
<div className="w-px h-4 bg-slate-300 mx-1" />
<ToolbarButton icon={Eraser} command="removeFormat" title="Clear Formatting" />
<div className="flex-1" />
<button
type="button"
onClick={insertVariable}
className="flex items-center gap-1 px-2 py-1.5 rounded bg-brand-50 text-brand-600 hover:bg-brand-100 text-xs font-medium border border-brand-200"
title="Insert Variable Placeholder"
>
<Braces size={14} />
Variable
</button>
</>
)}
</div>
{/* Editor Area */}
<div className="relative flex-1 min-h-[300px] bg-white">
{isSourceMode ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full h-full p-4 font-mono text-sm text-slate-800 resize-none focus:outline-none bg-slate-50"
spellCheck={false}
/>
) : (
<div
ref={contentRef}
contentEditable
onInput={handleInput}
onKeyUp={saveSelection}
onMouseUp={saveSelection}
onTouchEnd={saveSelection}
suppressContentEditableWarning={true}
className="w-full h-full p-4 focus:outline-none prose prose-sm max-w-none overflow-y-auto"
style={{ minHeight: '300px' }}
/>
)}
{!value && !isSourceMode && (
<div className="absolute top-4 left-4 text-slate-400 pointer-events-none select-none">
{placeholder || "Start typing..."}
</div>
)}
</div>
</div>
);
};
export default RichTextEditor;