206 lines
7.2 KiB
TypeScript
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; |