839 lines
32 KiB
TypeScript
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(/</gi, '<')
|
|
.replace(/>/gi, '>')
|
|
.replace(/"/gi, '"')
|
|
.replace(/'/gi, "'")
|
|
.replace(/&/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>
|
|
);
|
|
};
|