Enhance Richtext editor
This commit is contained in:
@@ -22,6 +22,7 @@ export default function RichTextEditor({
|
||||
const [htmlValue, setHtmlValue] = useState(value);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const visualTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const visualDivRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Preload sanitization config
|
||||
useEffect(() => {
|
||||
@@ -45,7 +46,16 @@ export default function RichTextEditor({
|
||||
setHtmlValue(htmlContent);
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const handleVisualContentChange = () => {
|
||||
const div = visualDivRef.current;
|
||||
if (div) {
|
||||
const newHtml = div.innerHTML;
|
||||
onChange(newHtml);
|
||||
setHtmlValue(newHtml);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement | HTMLDivElement>) => {
|
||||
if (viewMode !== 'visual') return;
|
||||
|
||||
e.preventDefault();
|
||||
@@ -123,53 +133,92 @@ export default function RichTextEditor({
|
||||
console.warn('Sanitization removed >90% of content - this might be too aggressive');
|
||||
}
|
||||
|
||||
// Insert at cursor position instead of just appending
|
||||
// Insert HTML directly into contentEditable div or at cursor in textarea
|
||||
const visualDiv = visualDivRef.current;
|
||||
const textarea = visualTextareaRef.current;
|
||||
if (textarea) {
|
||||
|
||||
if (visualDiv && viewMode === 'visual') {
|
||||
// For contentEditable div, insert HTML at current selection
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
|
||||
// Create a temporary container to parse the HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = sanitizedHtml;
|
||||
|
||||
// Insert the nodes from the temporary container
|
||||
while (tempDiv.firstChild) {
|
||||
range.insertNode(tempDiv.firstChild);
|
||||
}
|
||||
|
||||
// Move cursor to end of inserted content
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else {
|
||||
// No selection, append to end
|
||||
visualDiv.innerHTML += sanitizedHtml;
|
||||
}
|
||||
|
||||
// Update the state
|
||||
onChange(visualDiv.innerHTML);
|
||||
setHtmlValue(visualDiv.innerHTML);
|
||||
} else if (textarea) {
|
||||
// Fallback for textarea mode (shouldn't happen in visual mode but good to have)
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const currentPlainText = getPlainText(value);
|
||||
|
||||
// Split current content at cursor position
|
||||
const beforeCursor = currentPlainText.substring(0, start);
|
||||
const afterCursor = currentPlainText.substring(end);
|
||||
|
||||
// Convert plain text parts back to HTML
|
||||
const beforeHtml = beforeCursor ? beforeCursor.split('\n\n').filter(p => p.trim()).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('\n') : '';
|
||||
const afterHtml = afterCursor ? afterCursor.split('\n\n').filter(p => p.trim()).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('\n') : '';
|
||||
|
||||
const newHtmlValue = beforeHtml + (beforeHtml ? '\n' : '') + sanitizedHtml + (afterHtml ? '\n' : '') + afterHtml;
|
||||
|
||||
onChange(newHtmlValue);
|
||||
setHtmlValue(newHtmlValue);
|
||||
} else {
|
||||
// Fallback: just append
|
||||
const newHtmlValue = value + sanitizedHtml;
|
||||
onChange(newHtmlValue);
|
||||
setHtmlValue(newHtmlValue);
|
||||
}
|
||||
} else if (plainText && plainText.trim().length > 0) {
|
||||
console.log('Processing plain text content...');
|
||||
// For plain text, convert to paragraphs and insert at cursor
|
||||
const textarea = visualTextareaRef.current;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const currentPlainText = getPlainText(value);
|
||||
// For plain text, insert directly into contentEditable div
|
||||
const visualDiv = visualDivRef.current;
|
||||
if (visualDiv) {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
|
||||
// Split plain text into paragraphs and insert as HTML
|
||||
const paragraphs = plainText.split('\n\n').filter(p => p.trim());
|
||||
paragraphs.forEach((paragraph, index) => {
|
||||
if (index > 0) {
|
||||
// Add some spacing between paragraphs
|
||||
range.insertNode(document.createElement('br'));
|
||||
}
|
||||
const p = document.createElement('p');
|
||||
p.textContent = paragraph.replace(/\n/g, ' ');
|
||||
range.insertNode(p);
|
||||
});
|
||||
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else {
|
||||
// No selection, append to end
|
||||
const textAsHtml = plainText
|
||||
.split('\n\n')
|
||||
.filter(paragraph => paragraph.trim())
|
||||
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
|
||||
.join('\n');
|
||||
visualDiv.innerHTML += textAsHtml;
|
||||
}
|
||||
|
||||
const beforeCursor = currentPlainText.substring(0, start);
|
||||
const afterCursor = currentPlainText.substring(end);
|
||||
const newPlainText = beforeCursor + plainText + afterCursor;
|
||||
|
||||
// Convert to HTML
|
||||
const textAsHtml = newPlainText
|
||||
.split('\n\n')
|
||||
.filter(paragraph => paragraph.trim())
|
||||
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
|
||||
.join('\n');
|
||||
|
||||
onChange(textAsHtml);
|
||||
setHtmlValue(textAsHtml);
|
||||
onChange(visualDiv.innerHTML);
|
||||
setHtmlValue(visualDiv.innerHTML);
|
||||
}
|
||||
} else {
|
||||
console.log('No usable clipboard content found');
|
||||
@@ -208,41 +257,48 @@ export default function RichTextEditor({
|
||||
|
||||
const formatText = (tag: string) => {
|
||||
if (viewMode === 'visual') {
|
||||
// For visual mode, we'll insert formatted HTML directly
|
||||
const textarea = visualTextareaRef.current;
|
||||
if (!textarea) return;
|
||||
const visualDiv = visualDivRef.current;
|
||||
if (!visualDiv) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selectedText = getPlainText(value).substring(start, end);
|
||||
|
||||
if (selectedText) {
|
||||
// Create formatted HTML
|
||||
const formattedHtml = `<${tag}>${selectedText}</${tag}>`;
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const selectedText = range.toString();
|
||||
|
||||
// Get current plain text
|
||||
const currentPlainText = getPlainText(value);
|
||||
const beforeText = currentPlainText.substring(0, start);
|
||||
const afterText = currentPlainText.substring(end);
|
||||
if (selectedText) {
|
||||
// Wrap selected text in the formatting tag
|
||||
const formattedElement = document.createElement(tag);
|
||||
formattedElement.textContent = selectedText;
|
||||
|
||||
range.deleteContents();
|
||||
range.insertNode(formattedElement);
|
||||
|
||||
// Move cursor to end of inserted content
|
||||
range.selectNodeContents(formattedElement);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else {
|
||||
// No selection - insert template
|
||||
const template = tag === 'h1' ? 'Heading 1' :
|
||||
tag === 'h2' ? 'Heading 2' :
|
||||
tag === 'h3' ? 'Heading 3' :
|
||||
'Formatted text';
|
||||
|
||||
const formattedElement = document.createElement(tag);
|
||||
formattedElement.textContent = template;
|
||||
|
||||
range.insertNode(formattedElement);
|
||||
|
||||
// Select the inserted text for easy editing
|
||||
range.selectNodeContents(formattedElement);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
// Convert back to HTML while preserving existing formatting
|
||||
// This is a simplified approach - in practice you'd want more sophisticated HTML merging
|
||||
const beforeHtml = beforeText ? `<p>${beforeText.replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>')}</p>` : '';
|
||||
const afterHtml = afterText ? `<p>${afterText.replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>')}</p>` : '';
|
||||
|
||||
const newHtmlValue = beforeHtml + formattedHtml + afterHtml;
|
||||
setHtmlValue(newHtmlValue);
|
||||
onChange(newHtmlValue);
|
||||
} else {
|
||||
// No selection - insert template
|
||||
const template = tag === 'h1' ? '<h1>Heading 1</h1>' :
|
||||
tag === 'h2' ? '<h2>Heading 2</h2>' :
|
||||
tag === 'h3' ? '<h3>Heading 3</h3>' :
|
||||
`<${tag}>Formatted text</${tag}>`;
|
||||
|
||||
const newHtmlValue = value + template;
|
||||
setHtmlValue(newHtmlValue);
|
||||
onChange(newHtmlValue);
|
||||
// Update the state
|
||||
onChange(visualDiv.innerHTML);
|
||||
setHtmlValue(visualDiv.innerHTML);
|
||||
}
|
||||
} else {
|
||||
// HTML mode - existing logic with improvements
|
||||
@@ -382,14 +438,15 @@ export default function RichTextEditor({
|
||||
{/* Editor */}
|
||||
<div className="border theme-border rounded-b-lg overflow-hidden">
|
||||
{viewMode === 'visual' ? (
|
||||
<Textarea
|
||||
ref={visualTextareaRef}
|
||||
value={getPlainText(value)}
|
||||
onChange={handleVisualChange}
|
||||
<div
|
||||
ref={visualDivRef}
|
||||
contentEditable
|
||||
onInput={handleVisualContentChange}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
rows={12}
|
||||
className="border-0 rounded-none focus:ring-0"
|
||||
className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
|
||||
style={{ minHeight: '300px' }}
|
||||
dangerouslySetInnerHTML={{ __html: value || `<p>${placeholder}</p>` }}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
@@ -420,11 +477,11 @@ export default function RichTextEditor({
|
||||
|
||||
<div className="text-xs theme-text">
|
||||
<p>
|
||||
<strong>Visual mode:</strong> Write in plain text or paste formatted content.
|
||||
Bold, italic, and other basic formatting will be preserved when pasting.
|
||||
<strong>Visual mode:</strong> WYSIWYG editor - see your formatting as you type.
|
||||
Paste formatted content from websites and it will preserve styling. Use toolbar buttons for formatting.
|
||||
</p>
|
||||
<p>
|
||||
<strong>HTML mode:</strong> Write HTML directly for advanced formatting.
|
||||
<strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
|
||||
Allowed tags: p, br, div, span, strong, em, b, i, u, s, h1-h6, ul, ol, li, blockquote, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user