Enhance Richtext editor
This commit is contained in:
@@ -5,8 +5,7 @@
|
|||||||
"sup", "sub", "small", "big", "mark", "pre", "code", "kbd", "samp", "var",
|
"sup", "sub", "small", "big", "mark", "pre", "code", "kbd", "samp", "var",
|
||||||
"ul", "ol", "li", "dl", "dt", "dd",
|
"ul", "ol", "li", "dl", "dt", "dd",
|
||||||
"a", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
|
"a", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
|
||||||
"blockquote", "cite", "q", "hr", "details", "summary",
|
"blockquote", "cite", "q", "hr", "details", "summary"
|
||||||
"section", "article", "font", "center", "abbr", "dfn", "tt"
|
|
||||||
],
|
],
|
||||||
"allowedAttributes": {
|
"allowedAttributes": {
|
||||||
"p": ["class", "style"],
|
"p": ["class", "style"],
|
||||||
@@ -33,44 +32,12 @@
|
|||||||
"pre": ["class", "style"],
|
"pre": ["class", "style"],
|
||||||
"code": ["class", "style"],
|
"code": ["class", "style"],
|
||||||
"details": ["class", "style"],
|
"details": ["class", "style"],
|
||||||
"summary": ["class", "style"],
|
"summary": ["class", "style"]
|
||||||
"section": ["class", "style"],
|
|
||||||
"article": ["class", "style"],
|
|
||||||
"font": ["class", "style", "color", "size", "face"],
|
|
||||||
"center": ["class", "style"],
|
|
||||||
"abbr": ["class", "style", "title"],
|
|
||||||
"dfn": ["class", "style"],
|
|
||||||
"tt": ["class", "style"],
|
|
||||||
"b": ["class", "style"],
|
|
||||||
"strong": ["class", "style"],
|
|
||||||
"i": ["class", "style"],
|
|
||||||
"em": ["class", "style"],
|
|
||||||
"u": ["class", "style"],
|
|
||||||
"s": ["class", "style"],
|
|
||||||
"small": ["class", "style"],
|
|
||||||
"big": ["class", "style"],
|
|
||||||
"mark": ["class", "style"],
|
|
||||||
"sup": ["class", "style"],
|
|
||||||
"sub": ["class", "style"],
|
|
||||||
"del": ["class", "style"],
|
|
||||||
"ins": ["class", "style"],
|
|
||||||
"strike": ["class", "style"],
|
|
||||||
"kbd": ["class", "style"],
|
|
||||||
"samp": ["class", "style"],
|
|
||||||
"var": ["class", "style"]
|
|
||||||
},
|
},
|
||||||
"allowedCssProperties": [
|
"allowedCssProperties": [
|
||||||
"color", "background-color", "font-size", "font-weight",
|
"color", "font-size", "font-weight",
|
||||||
"font-style", "text-align", "text-decoration", "margin",
|
"font-style", "text-align", "text-decoration", "margin",
|
||||||
"padding", "text-indent", "line-height",
|
"padding", "text-indent", "line-height"
|
||||||
"border", "border-color", "border-width", "border-style",
|
|
||||||
"font-family", "font-variant", "font-variant-ligatures",
|
|
||||||
"font-variant-caps", "font-variant-numeric", "font-variant-east-asian",
|
|
||||||
"font-variant-alternates", "font-variant-position", "font-variant-emoji",
|
|
||||||
"font-stretch", "letter-spacing", "word-spacing",
|
|
||||||
"text-transform", "text-shadow", "white-space",
|
|
||||||
"vertical-align", "display", "float", "clear",
|
|
||||||
"width", "height", "min-width", "min-height", "max-width", "max-height"
|
|
||||||
],
|
],
|
||||||
"removedAttributes": {
|
"removedAttributes": {
|
||||||
"a": ["href", "target"]
|
"a": ["href", "target"]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default function RichTextEditor({
|
|||||||
const [htmlValue, setHtmlValue] = useState(value);
|
const [htmlValue, setHtmlValue] = useState(value);
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
const visualTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const visualTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const visualDivRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Preload sanitization config
|
// Preload sanitization config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,7 +46,16 @@ export default function RichTextEditor({
|
|||||||
setHtmlValue(htmlContent);
|
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;
|
if (viewMode !== 'visual') return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -123,53 +133,92 @@ export default function RichTextEditor({
|
|||||||
console.warn('Sanitization removed >90% of content - this might be too aggressive');
|
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;
|
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 start = textarea.selectionStart;
|
||||||
const end = textarea.selectionEnd;
|
const end = textarea.selectionEnd;
|
||||||
const currentPlainText = getPlainText(value);
|
const currentPlainText = getPlainText(value);
|
||||||
|
|
||||||
// Split current content at cursor position
|
|
||||||
const beforeCursor = currentPlainText.substring(0, start);
|
const beforeCursor = currentPlainText.substring(0, start);
|
||||||
const afterCursor = currentPlainText.substring(end);
|
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 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 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;
|
const newHtmlValue = beforeHtml + (beforeHtml ? '\n' : '') + sanitizedHtml + (afterHtml ? '\n' : '') + afterHtml;
|
||||||
|
|
||||||
onChange(newHtmlValue);
|
|
||||||
setHtmlValue(newHtmlValue);
|
|
||||||
} else {
|
|
||||||
// Fallback: just append
|
|
||||||
const newHtmlValue = value + sanitizedHtml;
|
|
||||||
onChange(newHtmlValue);
|
onChange(newHtmlValue);
|
||||||
setHtmlValue(newHtmlValue);
|
setHtmlValue(newHtmlValue);
|
||||||
}
|
}
|
||||||
} else if (plainText && plainText.trim().length > 0) {
|
} else if (plainText && plainText.trim().length > 0) {
|
||||||
console.log('Processing plain text content...');
|
console.log('Processing plain text content...');
|
||||||
// For plain text, convert to paragraphs and insert at cursor
|
// For plain text, insert directly into contentEditable div
|
||||||
const textarea = visualTextareaRef.current;
|
const visualDiv = visualDivRef.current;
|
||||||
if (textarea) {
|
if (visualDiv) {
|
||||||
const start = textarea.selectionStart;
|
const selection = window.getSelection();
|
||||||
const end = textarea.selectionEnd;
|
if (selection && selection.rangeCount > 0) {
|
||||||
const currentPlainText = getPlainText(value);
|
const range = selection.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
|
||||||
const beforeCursor = currentPlainText.substring(0, start);
|
// Split plain text into paragraphs and insert as HTML
|
||||||
const afterCursor = currentPlainText.substring(end);
|
const paragraphs = plainText.split('\n\n').filter(p => p.trim());
|
||||||
const newPlainText = beforeCursor + plainText + afterCursor;
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
// Convert to HTML
|
range.collapse(false);
|
||||||
const textAsHtml = newPlainText
|
selection.removeAllRanges();
|
||||||
.split('\n\n')
|
selection.addRange(range);
|
||||||
.filter(paragraph => paragraph.trim())
|
} else {
|
||||||
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
|
// No selection, append to end
|
||||||
.join('\n');
|
const textAsHtml = plainText
|
||||||
|
.split('\n\n')
|
||||||
|
.filter(paragraph => paragraph.trim())
|
||||||
|
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
|
||||||
|
.join('\n');
|
||||||
|
visualDiv.innerHTML += textAsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
onChange(textAsHtml);
|
onChange(visualDiv.innerHTML);
|
||||||
setHtmlValue(textAsHtml);
|
setHtmlValue(visualDiv.innerHTML);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('No usable clipboard content found');
|
console.log('No usable clipboard content found');
|
||||||
@@ -208,41 +257,48 @@ export default function RichTextEditor({
|
|||||||
|
|
||||||
const formatText = (tag: string) => {
|
const formatText = (tag: string) => {
|
||||||
if (viewMode === 'visual') {
|
if (viewMode === 'visual') {
|
||||||
// For visual mode, we'll insert formatted HTML directly
|
const visualDiv = visualDivRef.current;
|
||||||
const textarea = visualTextareaRef.current;
|
if (!visualDiv) return;
|
||||||
if (!textarea) return;
|
|
||||||
|
|
||||||
const start = textarea.selectionStart;
|
const selection = window.getSelection();
|
||||||
const end = textarea.selectionEnd;
|
if (selection && selection.rangeCount > 0) {
|
||||||
const selectedText = getPlainText(value).substring(start, end);
|
const range = selection.getRangeAt(0);
|
||||||
|
const selectedText = range.toString();
|
||||||
|
|
||||||
if (selectedText) {
|
if (selectedText) {
|
||||||
// Create formatted HTML
|
// Wrap selected text in the formatting tag
|
||||||
const formattedHtml = `<${tag}>${selectedText}</${tag}>`;
|
const formattedElement = document.createElement(tag);
|
||||||
|
formattedElement.textContent = selectedText;
|
||||||
|
|
||||||
// Get current plain text
|
range.deleteContents();
|
||||||
const currentPlainText = getPlainText(value);
|
range.insertNode(formattedElement);
|
||||||
const beforeText = currentPlainText.substring(0, start);
|
|
||||||
const afterText = currentPlainText.substring(end);
|
|
||||||
|
|
||||||
// Convert back to HTML while preserving existing formatting
|
// Move cursor to end of inserted content
|
||||||
// This is a simplified approach - in practice you'd want more sophisticated HTML merging
|
range.selectNodeContents(formattedElement);
|
||||||
const beforeHtml = beforeText ? `<p>${beforeText.replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>')}</p>` : '';
|
range.collapse(false);
|
||||||
const afterHtml = afterText ? `<p>${afterText.replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>')}</p>` : '';
|
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 newHtmlValue = beforeHtml + formattedHtml + afterHtml;
|
const formattedElement = document.createElement(tag);
|
||||||
setHtmlValue(newHtmlValue);
|
formattedElement.textContent = template;
|
||||||
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;
|
range.insertNode(formattedElement);
|
||||||
setHtmlValue(newHtmlValue);
|
|
||||||
onChange(newHtmlValue);
|
// Select the inserted text for easy editing
|
||||||
|
range.selectNodeContents(formattedElement);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the state
|
||||||
|
onChange(visualDiv.innerHTML);
|
||||||
|
setHtmlValue(visualDiv.innerHTML);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// HTML mode - existing logic with improvements
|
// HTML mode - existing logic with improvements
|
||||||
@@ -382,14 +438,15 @@ export default function RichTextEditor({
|
|||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
<div className="border theme-border rounded-b-lg overflow-hidden">
|
<div className="border theme-border rounded-b-lg overflow-hidden">
|
||||||
{viewMode === 'visual' ? (
|
{viewMode === 'visual' ? (
|
||||||
<Textarea
|
<div
|
||||||
ref={visualTextareaRef}
|
ref={visualDivRef}
|
||||||
value={getPlainText(value)}
|
contentEditable
|
||||||
onChange={handleVisualChange}
|
onInput={handleVisualContentChange}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder={placeholder}
|
className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
|
||||||
rows={12}
|
style={{ minHeight: '300px' }}
|
||||||
className="border-0 rounded-none focus:ring-0"
|
dangerouslySetInnerHTML={{ __html: value || `<p>${placeholder}</p>` }}
|
||||||
|
suppressContentEditableWarning={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -420,11 +477,11 @@ export default function RichTextEditor({
|
|||||||
|
|
||||||
<div className="text-xs theme-text">
|
<div className="text-xs theme-text">
|
||||||
<p>
|
<p>
|
||||||
<strong>Visual mode:</strong> Write in plain text or paste formatted content.
|
<strong>Visual mode:</strong> WYSIWYG editor - see your formatting as you type.
|
||||||
Bold, italic, and other basic formatting will be preserved when pasting.
|
Paste formatted content from websites and it will preserve styling. Use toolbar buttons for formatting.
|
||||||
</p>
|
</p>
|
||||||
<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.
|
Allowed tags: p, br, div, span, strong, em, b, i, u, s, h1-h6, ul, ol, li, blockquote, and more.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user