From ad0d98f846ff89063cdd02f6520f0afd9914d019 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Mon, 24 Aug 2026 15:08:12 +0330 Subject: [PATCH] feat: enhance RichTextEditor with color/bg pickers, active states, and code toggle; fix hero subtitle typography --- .../src/components/ui/RichTextEditor.tsx | 493 +++++++++++++++--- frontend/application/components/Hero.tsx | 4 +- 2 files changed, 426 insertions(+), 71 deletions(-) diff --git a/frontend/admin-panel/src/components/ui/RichTextEditor.tsx b/frontend/admin-panel/src/components/ui/RichTextEditor.tsx index 37d1853..7d8df13 100644 --- a/frontend/admin-panel/src/components/ui/RichTextEditor.tsx +++ b/frontend/admin-panel/src/components/ui/RichTextEditor.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState, useCallback } from 'react'; import { Bold, Italic, @@ -8,13 +8,16 @@ import { Heading3, List, ListOrdered, - Link, + Link as LinkIcon, AlignRight, AlignCenter, AlignLeft, Palette, + PaintBucket, Undo, Redo, + Code, + RemoveFormatting, } from 'lucide-react'; interface RichTextEditorProps { @@ -24,7 +27,7 @@ interface RichTextEditorProps { className?: string; } -const COLORS = [ +const PRESET_COLORS = [ { label: 'سیاه / پیش‌فرض', value: '#111827' }, { label: 'خاکستری', value: '#4b5563' }, { label: 'آبی کنینا', value: '#1d4ed8' }, @@ -34,6 +37,29 @@ const COLORS = [ { label: 'کهربایی / زرد', value: '#d97706' }, ]; +const PRESET_BG_COLORS = [ + { label: 'بی‌رنگ / حذف پس‌زمینه', value: 'transparent' }, + { label: 'هایلایت زرد', value: '#fef08a' }, + { label: 'هایلایت آبی', value: '#dbeafe' }, + { label: 'هایلایت بنفش', value: '#f3e8ff' }, + { label: 'هایلایت سبز', value: '#dcfce7' }, + { label: 'هایلایت قرمز', value: '#fee2e2' }, + { label: 'خاکستری ملایم', value: '#f3f4f6' }, +]; + +interface ActiveStates { + isBold: boolean; + isItalic: boolean; + isUnderline: boolean; + isH1: boolean; + isH2: boolean; + isH3: boolean; + isP: boolean; + isUL: boolean; + isOL: boolean; + align: 'right' | 'center' | 'left' | 'justify'; +} + export default function RichTextEditor({ value, onChange, @@ -43,8 +69,80 @@ export default function RichTextEditor({ const editorRef = useRef(null); const isTypingRef = useRef(false); const isMountedRef = useRef(false); + const [isCodeMode, setIsCodeMode] = useState(false); + const [showColorPicker, setShowColorPicker] = useState(false); + const [showBgColorPicker, setShowBgColorPicker] = useState(false); + const [customColor, setCustomColor] = useState('#111827'); + const [customBgColor, setCustomBgColor] = useState('#fef08a'); - // Sync value from props on initial mount or when external change occurs (not while active) + const [activeStates, setActiveStates] = useState({ + isBold: false, + isItalic: false, + isUnderline: false, + isH1: false, + isH2: false, + isH3: false, + isP: false, + isUL: false, + isOL: false, + align: 'right', + }); + + // Query formatting states on cursor/selection change + const updateActiveStates = useCallback(() => { + if (!editorRef.current || isCodeMode) return; + try { + const isBold = document.queryCommandState('bold'); + const isItalic = document.queryCommandState('italic'); + const isUnderline = document.queryCommandState('underline'); + const isUL = document.queryCommandState('insertUnorderedList'); + const isOL = document.queryCommandState('insertOrderedList'); + + let block = document.queryCommandValue('formatBlock') || ''; + block = block.toLowerCase().replace(/[<>]/g, ''); + + // Check parent tag if queryCommandValue is ambiguous + const selection = window.getSelection(); + let parentTag = ''; + if (selection && selection.rangeCount > 0) { + let node: Node | null = selection.getRangeAt(0).commonAncestorContainer; + if (node.nodeType === 3 && node.parentNode) { + node = node.parentNode; + } + while (node && node !== editorRef.current) { + const tag = (node as HTMLElement).tagName?.toLowerCase(); + if (['h1', 'h2', 'h3', 'p', 'ul', 'ol'].includes(tag)) { + parentTag = tag; + break; + } + node = node.parentNode; + } + } + + const activeBlock = parentTag || block; + + setActiveStates({ + isBold, + isItalic, + isUnderline, + isH1: activeBlock === 'h1', + isH2: activeBlock === 'h2', + isH3: activeBlock === 'h3', + isP: activeBlock === 'p' || (!['h1', 'h2', 'h3'].includes(activeBlock) && !isUL && !isOL), + isUL, + isOL, + align: document.queryCommandState('justifyCenter') + ? 'center' + : document.queryCommandState('justifyLeft') + ? 'left' + : 'right', + }); + } catch { + // Ignored if document commands aren't ready + } + }, [isCodeMode]); + + // Sync value from props on initial mount or when external change occurs useEffect(() => { if (!editorRef.current) return; if (!isMountedRef.current) { @@ -53,22 +151,43 @@ export default function RichTextEditor({ return; } - // Only update innerHTML if it's different and not currently focused/typing if (editorRef.current.innerHTML !== (value || '')) { if (!isTypingRef.current && document.activeElement !== editorRef.current) { editorRef.current.innerHTML = value || ''; } } - }, [value]); + }, [value, isCodeMode]); const exec = (command: string, val: string | undefined = undefined) => { + if (isCodeMode) return; + editorRef.current?.focus(); document.execCommand(command, false, val); if (editorRef.current) { onChange(editorRef.current.innerHTML); } + setTimeout(updateActiveStates, 20); + }; + + // Robust formatBlock handler for H1, H2, H3, P + const setBlockTag = (tagName: 'h1' | 'h2' | 'h3' | 'p') => { + if (isCodeMode) return; + editorRef.current?.focus(); + + // Cross-browser formatBlock + const blockValue = `<${tagName}>`; + const success = document.execCommand('formatBlock', false, blockValue); + if (!success) { + document.execCommand('formatBlock', false, tagName); + } + + if (editorRef.current) { + onChange(editorRef.current.innerHTML); + } + setTimeout(updateActiveStates, 20); }; const handleLink = () => { + if (isCodeMode) return; const url = prompt('آدرس لینک (URL) را وارد کنید:', 'https://'); if (url) { exec('createLink', url); @@ -82,99 +201,167 @@ export default function RichTextEditor({ setTimeout(() => { isTypingRef.current = false; }, 50); + updateActiveStates(); } }; + const clearFormatting = () => { + exec('removeFormat'); + setBlockTag('p'); + }; + return ( -
+
{/* Toolbar */} -
+
{/* Headings */} -
+
- {/* Basic Formats */} -
+ {/* Basic Formats (Bold, Italic, Underline) */} +
+
{/* Alignment */} -
+
{/* Lists & Link */} -
+
- {/* Color Palette */} -
- -
- {COLORS.map((c) => ( -
+ {/* Text Color Picker & Custom HEX */} +
+ + + {showColorPicker && ( +
+ رنگ‌های پیشنهادی: +
+ {PRESET_COLORS.map((c) => ( +
+ +
+ { + setCustomColor(e.target.value); + exec('foreColor', e.target.value); + }} + className="w-8 h-8 rounded-lg cursor-pointer border-none bg-transparent" + /> + { + setCustomColor(e.target.value); + exec('foreColor', e.target.value); + }} + placeholder="#111827" + className="w-full text-xs font-mono font-bold px-2 py-1 border border-gray-200 rounded-lg outline-none uppercase" + dir="ltr" + /> +
+
+ )} +
+ + {/* Background Color Picker & Custom HEX */} +
+ + + {showBgColorPicker && ( +
+ رنگ‌های هایلایت: +
+ {PRESET_BG_COLORS.map((c) => ( +
+ +
+ { + setCustomBgColor(e.target.value); + exec('hiliteColor', e.target.value); + }} + className="w-8 h-8 rounded-lg cursor-pointer border-none bg-transparent" + /> + { + setCustomBgColor(e.target.value); + exec('hiliteColor', e.target.value); + }} + placeholder="#fef08a" + className="w-full text-xs font-mono font-bold px-2 py-1 border border-gray-200 rounded-lg outline-none uppercase" + dir="ltr" + /> +
+
+ )} +
+ + {/* HTML Code View vs Visual */} +
+
{/* Undo / Redo */}
- {/* Editable Area */} -
+ {/* Editor Content Area */} + {isCodeMode ? ( +