Add files via upload
This commit is contained in:
206
components/RichTextEditor.tsx
Normal file
206
components/RichTextEditor.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user