import React, { useEffect, useRef } from 'react' import { Bold, Italic, Link2, List, ListOrdered } from 'lucide-react' import MergeTagPicker from './MergeTagPicker' const escapeHtml = (s) => String(s).replace(/&/g, '&').replace(//g, '>') const isHtml = (s) => /<\/?(p|br|div|ul|ol|li|h[1-6]|strong|em|b|i|u|a|span|blockquote)\b/i.test(s || '') /** Saved plain text (older forms) shown with its line breaks. */ const toEditorHtml = (value) => (isHtml(value) ? value : escapeHtml(value || '').replace(/\r?\n/g, '
')) /** Editor HTML → stored value ('' when the editor only holds an empty line). */ const fromEditorHtml = (html) => (html.replace(/| |\s|<\/?(p|div)>/gi, '') === '' ? '' : html) /** * Minimal rich text editor (bold, italic, link, lists) for success messages and email bodies. Stores HTML; the * server keeps HTML as is (wp_kses_post) and adds line breaks only to plain text. Merge tags insert at the caret. */ export default function RichTextEditor({ value, onChange, fields, placeholder = '', allFields = true, minHeight = 140, testId, ariaLabel = 'Message' }) { const ref = useRef(null) const range = useRef(null) // Sync external value changes (opening another item) without moving the caret while typing. useEffect(() => { const el = ref.current if (!el) return if (fromEditorHtml(el.innerHTML) !== (value || '') && document.activeElement !== el) el.innerHTML = toEditorHtml(value || '') }, [value]) const emit = () => { if (ref.current) onChange(fromEditorHtml(ref.current.innerHTML)) } const saveRange = () => { const sel = window.getSelection() if (sel && sel.rangeCount && ref.current && ref.current.contains(sel.anchorNode)) range.current = sel.getRangeAt(0).cloneRange() } const restoreRange = () => { const el = ref.current if (!el) return el.focus() const sel = window.getSelection() if (range.current && sel) { sel.removeAllRanges() sel.addRange(range.current) } else if (sel) { const r = document.createRange() r.selectNodeContents(el) r.collapse(false) sel.removeAllRanges() sel.addRange(r) } } const exec = (cmd, arg) => { restoreRange() document.execCommand(cmd, false, arg) saveRange() emit() } const addLink = () => { const url = window.prompt('Link URL', 'https://') if (url && /^(https?:|mailto:|\/|#|\{)/i.test(url.trim())) exec('createLink', url.trim()) } const tools = [ { id: 'bold', icon: Bold, label: 'Bold', run: () => exec('bold') }, { id: 'italic', icon: Italic, label: 'Italic', run: () => exec('italic') }, { id: 'link', icon: Link2, label: 'Link', run: addLink }, { id: 'ul', icon: List, label: 'Bulleted list', run: () => exec('insertUnorderedList') }, { id: 'ol', icon: ListOrdered, label: 'Numbered list', run: () => exec('insertOrderedList') }, ] return (
{tools.map(({ id, icon: Icon, label, run }) => ( ))}
{fields && ( exec('insertText', tag)} /> )}
{ saveRange(); emit() }} onKeyUp={saveRange} onMouseUp={saveRange} onBlur={() => { saveRange(); emit() }} onPaste={(e) => { // Paste as plain text: no foreign markup in emails. e.preventDefault() document.execCommand('insertText', false, e.clipboardData.getData('text/plain')) }} className="wpvfb-rte px-3 py-2 text-sm text-gray-900 focus:outline-none overflow-y-auto [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_a]:text-blue-600 [&_a]:underline" style={{ minHeight, maxHeight: 360 }} />
) }