ifixkart-admin/components/ui/RichTextEditor.tsx

839 lines
32 KiB
TypeScript

"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(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&amp;/gi, '&');
}
}
return normalized;
}
export const RichTextEditor: React.FC<RichTextEditorProps> = ({
value,
onChange,
maxLength = 20000,
placeholder = "Write rich product description...",
}) => {
const editorRef = useRef<HTMLDivElement>(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<HTMLAnchorElement | null>(null);
const savedRangeRef = useRef<Range | null>(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<HTMLTextAreaElement>) => {
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<HTMLDivElement>) => {
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 <ul><li>...</li></ul>) 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 = `<a href="${formattedUrl}"${targetAttr} class="text-primary underline font-medium hover:text-primary/80">${display}</a>`;
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<HTMLDivElement>) => {
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 = `<img src="${url}" alt="${alt}" class="max-w-full h-auto rounded-lg my-2 inline-block border border-border" />`;
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<HTMLInputElement>) => {
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 (
<div className="border border-border rounded-xl overflow-hidden bg-card text-foreground shadow-sm relative">
<style>{`
[contenteditable]:empty:before {
content: attr(data-placeholder);
color: #94a3b8;
opacity: 0.7;
pointer-events: none;
display: block;
}
.editor-content {
font-family: inherit;
}
.editor-content h1 { font-size: 1.75rem; font-weight: 700; margin-top: 1rem; margin-bottom: 0.5rem; line-height: 1.25; }
.editor-content h2 { font-size: 1.4rem; font-weight: 700; margin-top: 0.875rem; margin-bottom: 0.375rem; line-height: 1.3; }
.editor-content h3 { font-size: 1.2rem; font-weight: 600; margin-top: 0.75rem; margin-bottom: 0.25rem; line-height: 1.35; }
.editor-content h4 { font-size: 1rem; font-weight: 600; margin-top: 0.6rem; margin-bottom: 0.25rem; }
.editor-content h5 { font-size: 0.875rem; font-weight: 600; margin-top: 0.5rem; margin-bottom: 0.25rem; }
.editor-content h6 { font-size: 0.75rem; font-weight: 600; margin-top: 0.5rem; margin-bottom: 0.25rem; }
.editor-content p { margin-bottom: 0.5rem; line-height: 1.6; }
.editor-content ul, .editor-content ul li { list-style-type: disc !important; }
.editor-content ol, .editor-content ol li { list-style-type: decimal !important; }
.editor-content ul, .editor-content ol { padding-left: 1.75rem !important; margin-top: 0.5rem !important; margin-bottom: 0.5rem !important; }
.editor-content li { display: list-item !important; margin-bottom: 0.25rem !important; }
.editor-content [style*="text-align: center"], .editor-content p[style*="text-align: center"], .editor-content div[style*="text-align: center"] { text-align: center !important; }
.editor-content [style*="text-align: right"], .editor-content p[style*="text-align: right"], .editor-content div[style*="text-align: right"] { text-align: right !important; }
.editor-content [style*="text-align: justify"], .editor-content p[style*="text-align: justify"], .editor-content div[style*="text-align: justify"] { text-align: justify !important; }
.editor-content [style*="text-align: left"], .editor-content p[style*="text-align: left"], .editor-content div[style*="text-align: left"] { text-align: left !important; }
.editor-content a { color: #3b82f6; text-decoration: underline; cursor: pointer; }
.editor-content blockquote { border-left: 3px solid #3b82f6; padding-left: 0.75rem; margin-left: 0; margin-bottom: 0.5rem; color: #64748b; font-style: italic; }
.editor-content pre { background: rgba(0,0,0,0.05); padding: 0.5rem; rounded: 0.375rem; font-family: monospace; font-size: 0.8rem; overflow-x: auto; margin-bottom: 0.5rem; }
`}</style>
{/* Formatting Toolbar */}
<div className="flex flex-wrap items-center gap-1 p-2 bg-muted/60 border-b border-border select-none text-xs">
{/* Shopify-Style Heading Dropdown */}
<div className="relative">
<button
type="button"
disabled={isHtmlMode}
onClick={() => setShowHeadingDropdown(!showHeadingDropdown)}
className="flex items-center gap-1.5 h-7 px-2.5 rounded-md border border-border bg-card hover:bg-accent text-foreground text-xs font-medium cursor-pointer disabled:opacity-40 transition-colors"
>
<span>{currentHeadingLabel}</span>
<ChevronDown size={13} className="text-muted-foreground" />
</button>
{showHeadingDropdown && !isHtmlMode && (
<div className="absolute left-0 top-full mt-1 z-30 w-40 bg-card border border-border rounded-lg shadow-xl py-1 text-xs text-foreground animate-in fade-in zoom-in-95 duration-100">
{HEADING_OPTIONS.map((h) => (
<button
key={h.tag}
type="button"
onClick={() => setHeading(h.tag)}
className={`w-full text-left px-3 py-1.5 hover:bg-muted flex items-center justify-between cursor-pointer transition-colors ${
activeBlock === h.tag ? 'font-bold text-primary bg-primary/10' : ''
}`}
>
<span>{h.label}</span>
{activeBlock === h.tag && <Check size={13} className="text-primary" />}
</button>
))}
</div>
)}
</div>
<div className="w-px h-4 bg-border mx-1" />
{/* Text Formatting */}
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('bold')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Bold (Ctrl+B)"
>
<Bold size={15} />
</button>
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('italic')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Italic (Ctrl+I)"
>
<Italic size={15} />
</button>
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('underline')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Underline (Ctrl+U)"
>
<UnderlineIcon size={15} />
</button>
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('strikeThrough')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Strikethrough"
>
<Strikethrough size={15} />
</button>
<div className="w-px h-4 bg-border mx-1" />
{/* Alignment */}
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('justifyLeft')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Align Left"
>
<AlignLeft size={15} />
</button>
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('justifyCenter')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Align Center"
>
<AlignCenter size={15} />
</button>
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('justifyRight')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Align Right"
>
<AlignRight size={15} />
</button>
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('justifyFull')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Justify"
>
<AlignJustify size={15} />
</button>
<div className="w-px h-4 bg-border mx-1" />
{/* Lists & Indents */}
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('insertUnorderedList')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Bulleted List"
>
<List size={15} />
</button>
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('insertOrderedList')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Numbered List"
>
<ListOrdered size={15} />
</button>
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('outdent')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Outdent"
>
<Outdent size={15} />
</button>
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('indent')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Indent"
>
<Indent size={15} />
</button>
<div className="w-px h-4 bg-border mx-1" />
{/* Hyperlink & Media */}
<button
type="button"
disabled={isHtmlMode}
onClick={() => openLinkModal()}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Insert / Edit Hyperlink"
>
<LinkIcon size={15} />
</button>
<button
type="button"
disabled={isHtmlMode}
onClick={() => setShowImageModal(true)}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Insert Image"
>
<ImageIcon size={15} />
</button>
<div className="w-px h-4 bg-border mx-1" />
{/* Clear Formatting */}
<button
type="button"
disabled={isHtmlMode}
onClick={() => execCommand('removeFormat')}
className="p-1.5 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors cursor-pointer disabled:opacity-40"
title="Clear Formatting"
>
<RemoveFormatting size={15} />
</button>
{/* HTML Source Code Toggle (<>) */}
<button
type="button"
onClick={toggleHtmlMode}
className={`ml-auto px-2 py-1 rounded-md text-xs font-mono font-semibold flex items-center gap-1 transition-colors cursor-pointer ${
isHtmlMode ? 'bg-primary text-primary-foreground shadow-xs' : 'bg-card border border-border hover:bg-accent text-muted-foreground hover:text-foreground'
}`}
title={isHtmlMode ? "Switch to WYSIWYG Visual Editor" : "Show HTML Source Code (<>)"}
>
<Code2 size={14} />
<span>{isHtmlMode ? 'Visual' : '<>'}</span>
</button>
</div>
{/* Editor Main Content Area */}
{isHtmlMode ? (
<textarea
value={rawHtml}
onChange={handleRawHtmlChange}
placeholder="<h1>Title</h1><p>Write raw HTML code here...</p>"
className="w-full min-h-[160px] max-h-[340px] p-3 text-xs font-mono bg-muted/20 text-foreground focus:outline-none leading-relaxed border-none resize-y"
/>
) : (
<div
ref={editorRef}
contentEditable
onInput={handleInput}
onBlur={handleInput}
onPaste={handlePaste}
onClick={(e) => {
handleEditorClick(e);
detectActiveBlock();
}}
onKeyUp={detectActiveBlock}
className="editor-content p-3.5 min-h-[160px] max-h-[340px] overflow-y-auto text-xs text-foreground focus:outline-none leading-relaxed prose prose-sm max-w-none"
data-placeholder={placeholder}
/>
)}
{/* Interactive Link Modal */}
{showLinkModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md p-5 space-y-4 text-foreground animate-in fade-in zoom-in-95 duration-150">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<LinkIcon size={16} className="text-primary" />
<span>{activeAnchor ? 'Edit Hyperlink' : 'Add Hyperlink'}</span>
</div>
<button
type="button"
onClick={() => setShowLinkModal(false)}
className="text-muted-foreground hover:text-foreground p-1 rounded-md transition-colors"
>
<X size={16} />
</button>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Link URL</label>
<input
type="url"
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
className="w-full h-9 px-3 bg-muted/40 border border-border rounded-lg text-xs text-foreground focus:outline-none focus:border-primary transition-colors"
autoFocus
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Display Text</label>
<input
type="text"
placeholder="Text to display..."
value={linkText}
onChange={(e) => setLinkText(e.target.value)}
className="w-full h-9 px-3 bg-muted/40 border border-border rounded-lg text-xs text-foreground focus:outline-none focus:border-primary transition-colors"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer pt-1">
<input
type="checkbox"
checked={linkTargetBlank}
onChange={(e) => setLinkTargetBlank(e.target.checked)}
className="rounded border-border text-primary focus:ring-primary w-4 h-4"
/>
<span className="text-xs text-muted-foreground flex items-center gap-1">
Open link in new tab <ExternalLink size={12} />
</span>
</label>
</div>
<div className="flex items-center justify-between border-t border-border pt-3 gap-2">
{activeAnchor ? (
<button
type="button"
onClick={handleRemoveLink}
className="px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10 border border-destructive/30 rounded-lg transition-colors flex items-center gap-1 font-medium cursor-pointer"
>
<Unlink size={14} /> Remove Link
</button>
) : (
<div />
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowLinkModal(false)}
className="px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted rounded-lg transition-colors font-medium cursor-pointer"
>
Cancel
</button>
<button
type="button"
onClick={handleApplyLink}
className="px-4 py-1.5 text-xs bg-primary text-primary-foreground font-semibold rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-1 cursor-pointer"
>
<Check size={14} /> Apply Link
</button>
</div>
</div>
</div>
</div>
)}
{/* Interactive Image Modal */}
{showImageModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md p-5 space-y-4 text-foreground animate-in fade-in zoom-in-95 duration-150">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<ImageIcon size={16} className="text-primary" />
<span>Insert Image</span>
</div>
<button
type="button"
onClick={() => setShowImageModal(false)}
className="text-muted-foreground hover:text-foreground p-1 rounded-md transition-colors"
>
<X size={16} />
</button>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Image URL</label>
<input
type="url"
placeholder="https://example.com/image.jpg"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
className="w-full h-9 px-3 bg-muted/40 border border-border rounded-lg text-xs text-foreground focus:outline-none focus:border-primary transition-colors"
autoFocus
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Or Upload Image File</label>
<label className="flex items-center justify-center gap-2 w-full h-10 px-3 border border-dashed border-border rounded-lg bg-muted/20 hover:bg-muted/40 text-xs text-muted-foreground cursor-pointer transition-colors">
<Upload size={14} />
<span>Choose file to embed</span>
<input type="file" accept="image/*" onChange={handleImageFileUpload} className="hidden" />
</label>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Alt Description</label>
<input
type="text"
placeholder="Product image description..."
value={imageAlt}
onChange={(e) => setImageAlt(e.target.value)}
className="w-full h-9 px-3 bg-muted/40 border border-border rounded-lg text-xs text-foreground focus:outline-none focus:border-primary transition-colors"
/>
</div>
{imageUrl && (
<div className="border border-border rounded-lg p-2 bg-muted/20 max-h-32 overflow-hidden flex items-center justify-center">
<img src={imageUrl} alt="Preview" className="max-h-28 object-contain rounded" />
</div>
)}
</div>
<div className="flex items-center justify-end border-t border-border pt-3 gap-2">
<button
type="button"
onClick={() => setShowImageModal(false)}
className="px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted rounded-lg transition-colors font-medium cursor-pointer"
>
Cancel
</button>
<button
type="button"
onClick={handleApplyImage}
disabled={!imageUrl.trim()}
className="px-4 py-1.5 text-xs bg-primary text-primary-foreground font-semibold rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-1 cursor-pointer disabled:opacity-50"
>
<Check size={14} /> Insert Image
</button>
</div>
</div>
</div>
)}
{/* Footer Character Counter & Limit Warning */}
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/40 border-t border-border text-[11px]">
<div className="flex items-center gap-1.5 text-muted-foreground font-medium">
<Sparkles size={12} className="text-primary" />
<span>{isHtmlMode ? 'HTML Source Code Mode' : 'WYSIWYG Rich Editor'}</span>
</div>
<div className={`font-mono font-bold ${isOverLimit ? 'text-destructive animate-pulse' : 'text-muted-foreground'}`}>
{charCount} / {maxLength} chars
{isOverLimit && <span className="ml-1 text-destructive">(Limit Exceeded)</span>}
</div>
</div>
</div>
);
};