"use client"; import React, { useRef, useEffect, useState } from 'react'; import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, List, ListOrdered, Link as LinkIcon, Unlink, ExternalLink, Image as ImageIcon, Code2, RemoveFormatting, Sparkles, X, Check, AlignLeft, AlignCenter, AlignRight, AlignJustify, Indent, Outdent, ChevronDown, Upload, } from 'lucide-react'; interface RichTextEditorProps { value: string; onChange: (value: string) => void; maxLength?: number; placeholder?: string; } const HEADING_OPTIONS = [ { label: 'Paragraph', tag: 'p' }, { label: 'Heading 1', tag: 'h1' }, { label: 'Heading 2', tag: 'h2' }, { label: 'Heading 3', tag: 'h3' }, { label: 'Heading 4', tag: 'h4' }, { label: 'Heading 5', tag: 'h5' }, { label: 'Heading 6', tag: 'h6' }, ]; function normalizeHtmlString(val: string): string { if (!val) return ''; let normalized = val; if (/<[a-z0-9_\/!]/i.test(normalized) || />/i.test(normalized)) { if (typeof window !== 'undefined') { const doc = new DOMParser().parseFromString(normalized, 'text/html'); normalized = doc.body.textContent || normalized; } else { normalized = normalized .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/&/gi, '&'); } } return normalized; } export const RichTextEditor: React.FC = ({ value, onChange, maxLength = 20000, placeholder = "Write rich product description...", }) => { const editorRef = useRef(null); const [charCount, setCharCount] = useState(0); const [isHtmlMode, setIsHtmlMode] = useState(false); const [rawHtml, setRawHtml] = useState(() => normalizeHtmlString(value || '')); // Heading / Block Format Dropdown State const [activeBlock, setActiveBlock] = useState('p'); const [showHeadingDropdown, setShowHeadingDropdown] = useState(false); // Hyperlink Modal State const [showLinkModal, setShowLinkModal] = useState(false); const [linkUrl, setLinkUrl] = useState(''); const [linkText, setLinkText] = useState(''); const [linkTargetBlank, setLinkTargetBlank] = useState(true); const [activeAnchor, setActiveAnchor] = useState(null); const savedRangeRef = useRef(null); // Image Modal State const [showImageModal, setShowImageModal] = useState(false); const [imageUrl, setImageUrl] = useState(''); const [imageAlt, setImageAlt] = useState(''); useEffect(() => { const normalized = normalizeHtmlString(value || ''); if (!isHtmlMode && editorRef.current) { if (editorRef.current.innerHTML !== normalized) { editorRef.current.innerHTML = normalized; updateCharCount(); } } if (isHtmlMode && value !== undefined) { setRawHtml(normalized); } }, [value, isHtmlMode]); const updateCharCount = () => { if (editorRef.current) { const text = editorRef.current.innerText || ''; setCharCount(text.length); } else { const tempDiv = document.createElement('div'); tempDiv.innerHTML = rawHtml || ''; setCharCount((tempDiv.innerText || '').length); } }; const handleInput = () => { if (editorRef.current) { const html = editorRef.current.innerHTML; const text = editorRef.current.innerText || ''; setCharCount(text.length); setRawHtml(html); onChange(html); detectActiveBlock(); } }; const handleRawHtmlChange = (e: React.ChangeEvent) => { const html = e.target.value; setRawHtml(html); onChange(html); const tempDiv = document.createElement('div'); tempDiv.innerHTML = html || ''; setCharCount((tempDiv.innerText || '').length); }; const toggleHtmlMode = () => { if (isHtmlMode) { // Switching from Code mode to Visual mode — inject raw HTML into editor DOM const targetHtml = rawHtml || ''; setIsHtmlMode(false); onChange(targetHtml); requestAnimationFrame(() => { if (editorRef.current) { editorRef.current.innerHTML = targetHtml; editorRef.current.focus(); updateCharCount(); } }); } else { // Switching from Visual to Code mode — write innerHTML to textarea if (editorRef.current) { const currentHtml = editorRef.current.innerHTML || ''; setRawHtml(currentHtml); onChange(currentHtml); } setIsHtmlMode(true); } }; const detectActiveBlock = () => { if (!editorRef.current) return; const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; let parent: Node | null = sel.anchorNode; if (parent && parent.nodeType === 3) { parent = parent.parentNode; } let block = 'p'; while (parent && parent !== editorRef.current) { const tag = (parent as HTMLElement).tagName?.toLowerCase(); if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p'].includes(tag)) { block = tag; break; } parent = parent.parentNode; } setActiveBlock(block); }; const execCommand = (command: string, valueArg: string = '') => { if (isHtmlMode) return; if (editorRef.current) { editorRef.current.focus(); } document.execCommand(command, false, valueArg); handleInput(); }; const setHeading = (tag: string) => { setShowHeadingDropdown(false); if (isHtmlMode) return; execCommand('formatBlock', `<${tag}>`); setActiveBlock(tag); }; // Paste handler: preserve HTML structure, alignment styles, lists, and formatting tags const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault(); const clipboardData = e.clipboardData; const pastedHtml = clipboardData.getData('text/html'); const pastedText = clipboardData.getData('text/plain'); if (pastedHtml) { const parser = new DOMParser(); const doc = parser.parseFromString(pastedHtml, 'text/html'); const allElements = doc.body.querySelectorAll('*'); allElements.forEach((el) => { const element = el as HTMLElement; if (element.style) { // Preserve text-align, list-style, font-weight, remove unwanted font-sizes & families const align = element.style.textAlign; element.style.fontSize = ''; element.style.fontFamily = ''; element.style.color = ''; element.style.backgroundColor = ''; element.style.lineHeight = ''; if (align) { element.style.textAlign = align; } if (!element.getAttribute('style')?.trim()) { element.removeAttribute('style'); } } if (element.tagName === 'FONT') { element.removeAttribute('size'); element.removeAttribute('face'); element.removeAttribute('color'); } }); const cleanHtml = doc.body.innerHTML; document.execCommand('insertHTML', false, cleanHtml); } else if (pastedText) { // If raw HTML text (like
  • ...
) was pasted as plain text if (/<[a-z][\s\S]*>/i.test(pastedText)) { document.execCommand('insertHTML', false, pastedText); } else { document.execCommand('insertText', false, pastedText); } } handleInput(); }; // Open Link Modal const openLinkModal = (existingAnchor?: HTMLAnchorElement | null) => { if (isHtmlMode) return; const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { savedRangeRef.current = sel.getRangeAt(0).cloneRange(); } else { savedRangeRef.current = null; } let anchor = existingAnchor || null; if (!anchor && sel && sel.rangeCount > 0) { let parent: Node | null = sel.anchorNode; while (parent && parent !== editorRef.current) { if (parent.nodeName === 'A') { anchor = parent as HTMLAnchorElement; break; } parent = parent.parentNode; } } if (anchor) { setActiveAnchor(anchor); setLinkUrl(anchor.getAttribute('href') || ''); setLinkText(anchor.innerText || ''); setLinkTargetBlank(anchor.getAttribute('target') === '_blank'); } else { setActiveAnchor(null); setLinkUrl(''); setLinkText(sel ? sel.toString() : ''); setLinkTargetBlank(true); } setShowLinkModal(true); }; const handleApplyLink = () => { if (!editorRef.current) return; editorRef.current.focus(); if (savedRangeRef.current) { const sel = window.getSelection(); if (sel) { sel.removeAllRanges(); sel.addRange(savedRangeRef.current); } } const finalUrl = linkUrl.trim(); if (!finalUrl) { handleRemoveLink(); return; } const formattedUrl = /^https?:\/\//i.test(finalUrl) || finalUrl.startsWith('/') || finalUrl.startsWith('#') ? finalUrl : `https://${finalUrl}`; if (activeAnchor) { activeAnchor.setAttribute('href', formattedUrl); if (linkTargetBlank) { activeAnchor.setAttribute('target', '_blank'); activeAnchor.setAttribute('rel', 'noopener noreferrer'); } else { activeAnchor.removeAttribute('target'); activeAnchor.removeAttribute('rel'); } if (linkText.trim()) { activeAnchor.innerText = linkText; } } else { const targetAttr = linkTargetBlank ? ' target="_blank" rel="noopener noreferrer"' : ''; const display = linkText.trim() || formattedUrl; const linkHtml = `${display}`; document.execCommand('insertHTML', false, linkHtml); } setShowLinkModal(false); handleInput(); }; const handleRemoveLink = () => { if (!editorRef.current) return; editorRef.current.focus(); if (savedRangeRef.current) { const sel = window.getSelection(); if (sel) { sel.removeAllRanges(); sel.addRange(savedRangeRef.current); } } if (activeAnchor) { const textNode = document.createTextNode(activeAnchor.innerText || activeAnchor.textContent || ''); activeAnchor.parentNode?.replaceChild(textNode, activeAnchor); } else { document.execCommand('unlink', false); } setShowLinkModal(false); handleInput(); }; const handleEditorClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement; const anchor = target.closest('a') as HTMLAnchorElement | null; if (anchor && editorRef.current?.contains(anchor)) { e.preventDefault(); openLinkModal(anchor); } }; const handleApplyImage = () => { const url = imageUrl.trim(); if (!url) return; const alt = imageAlt.trim() || 'Product image'; const imgHtml = `${alt}`; if (!isHtmlMode && editorRef.current) { editorRef.current.focus(); document.execCommand('insertHTML', false, imgHtml); handleInput(); } else { setRawHtml((prev) => prev + imgHtml); onChange(rawHtml + imgHtml); } setShowImageModal(false); setImageUrl(''); setImageAlt(''); }; const handleImageFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { if (event.target?.result) { setImageUrl(event.target.result as string); } }; reader.readAsDataURL(file); } }; const isOverLimit = charCount > maxLength; const currentHeadingLabel = HEADING_OPTIONS.find((h) => h.tag === activeBlock)?.label || 'Paragraph'; return (
{/* Formatting Toolbar */}
{/* Shopify-Style Heading Dropdown */}
{showHeadingDropdown && !isHtmlMode && (
{HEADING_OPTIONS.map((h) => ( ))}
)}
{/* Text Formatting */}
{/* Alignment */}
{/* Lists & Indents */}
{/* Hyperlink & Media */}
{/* Clear Formatting */} {/* HTML Source Code Toggle (<>) */}
{/* Editor Main Content Area */} {isHtmlMode ? (