nilfam-editor
Version:
A powerful, customizable rich-text editor built with TipTap, React, and Tailwind CSS. Supports RTL/LTR text, resizable media (images/videos), tables, code blocks, font styling, and more for an enhanced content creation experience.
410 lines (362 loc) • 19.3 kB
JSX
import {EditorContent, useEditor} from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import TextAlign from '@tiptap/extension-text-align';
import TextStyle from '@tiptap/extension-text-style';
import BulletList from '@tiptap/extension-bullet-list';
import OrderedList from '@tiptap/extension-ordered-list';
import Highlight from '@tiptap/extension-highlight';
import {ListItem} from '@tiptap/extension-list-item';
import Underline from '@tiptap/extension-underline';
import {forwardRef, useEffect, useImperativeHandle, useRef, useState} from 'react';
import ResizeImageExtension from '../extensions/ResizeImageExtension.jsx';
import ResizeVideoExtension from '../extensions/ResizeVideoExtension.jsx';
import {CustomTable, CustomTableCell, CustomTableHeader, CustomTableRow, InsertTableButton} from '../components/table/CustomTable.jsx';
import BorderColor from '../components/button/BorderColor.jsx';
import TextColor from '../components/button/TextColor.jsx';
import HighlightColor from '../components/button/HighlightColor.jsx';
import UploadModalImage from '../components/modal/UploadModalImage.jsx';
import UploadModalVideo from '../components/modal/UploadModalVideo.jsx';
import UploadModalAudio from '../components/modal/UploadModalAudio.jsx';
import EmojiButton from '../components/button/EmojiButton.jsx';
import SelectFontButton from '../components/button/SelectFontButton.jsx';
import SizeFontButton from '../components/button/SizeFontButton.jsx';
import HeadingButton from '../components/button/HeadingButton.jsx';
import LineHeightButton from '../components/button/LineHeightButton.jsx';
import MenuTable from '../components/table/MenuTable.jsx';
import {FontSize} from '../extensions/FontSize.jsx';
import {FontFamily} from '../extensions/FontFamily.jsx';
import {Video} from '../extensions/Video.jsx';
import {LineHeightExtension} from '../extensions/LineHeightExtension.jsx';
import {t} from '../components/Lang/i18n.js';
import {Configs} from '../components/config/Configs.js';
import CodeButtons from '../components/button/CodeButtons.jsx';
import {CustomCodeBlock} from '../components/code/CustomCodeBlock.js';
import {HeadingWithAutoId} from '../extensions/HeadingWithAutoId.js';
import AnchorLinkMenu from '../components/heading/AnchorLinkMenu.jsx';
import {getHeadings} from '../components/heading/getHeadings.js';
import LinkButton from '../components/button/LinkButton.jsx';
import Audio from '../extensions/Audio.jsx'
import {
AlignCenterIcon,
AlignJustifyIcon,
AlignLeftIcon,
AlignRightIcon,
BlockquoteIcon,
BoldIcon, FormatPainterCopyIcon, FormatPainterPasteIcon,
HtmlIcon,
IndentDecreaseIcon,
IndentIncreaseIcon,
ItalicIcon,
LinkOffIcon,
ListIcon,
ListNumberIcon,
MicrophoneIcon,
MovieIcon,
PhotoIcon,
SourceCodeIcon,
StyleClearIcon, UnderLineIcon,
} from '../assets/icons/Icons.jsx';
import ColoredBoxButton from "../components/button/BoxButton.jsx";
import {ColoredBox} from "../extensions/ColoredBox.js";
import ResizeIframeExtension from "../extensions/ResizeIframeExtension.jsx";
import IndentExtension from "../extensions/IndentExtension.jsx";
import CustomBlockquote from "../extensions/CustomBlockquote.jsx";
import {FormatPainterExtension} from "../extensions/FormatPainterExtension.jsx";
const Editor = forwardRef(({
isDark = false,
lang = "en",
value = "",
onChange = () => {},
fonts = [],
}, ref) => {
const [headingsList, setHeadingsList] = useState([]);
const [htmlCode, setHtmlCode] = useState('');
const [showHTML, setShowHTML] = useState(false);
const [isTableSelected, setIsTableSelected] = useState(false);
const [openUploadImage, setOpenUploadImage] = useState(false);
const [openUploadVideo, setOpenUploadVideo] = useState(false);
const [openUploadAudio, setOpenUploadAudio] = useState(false);
function normalizeEmptyParas(html) {
const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll('p').forEach(p => {
let inner = p.innerHTML.trim()
if (
inner === '' ||
inner === ' ' ||
inner === '<br class="ProseMirror-trailingBreak">'
) {
p.innerHTML = '<br>'
}
})
return doc.body.innerHTML
}
const CustomLink = Link.configure({
openOnClick: false, // هر تنظیم دلخواه دیگر
HTMLAttributes: {
class: 'tw:text-blue-600 tw:cursor-pointer tw:hover:text-blue-800',
target: null, // ⬅️ حتماً همینجا
rel: null, // ⬅️ و اینجا روی null
},
})
const skipNextContentSet = useRef(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false,
heading: false,
}),
FormatPainterExtension,
IndentExtension.configure({ lang: lang }),
CustomBlockquote,
Underline,
ColoredBox,
CustomCodeBlock,
Highlight.configure({
multicolor: true,
}),
HeadingWithAutoId.configure({
levels: [1, 2, 3, 4, 5, 6],
HTMLAttributes: {
class: 'tw:font-bold tw:text-lg',
},
}),
CustomLink,
OrderedList.configure({
HTMLAttributes: {
class: 'tw:list-none tw:pr-0 tw:rtl:pl-0 tw:counter-reset: item',
},
}),
BulletList.configure({
HTMLAttributes: {
class: 'tw:list-none pr-0 tw:rtl:pl-0',
},
}),
ListItem.configure({
HTMLAttributes: {
class: 'tw:relative tw:pl-6 tw:rtl:pr-6 tw:rtl:pl-0 tw:ltr:pl-6 tw:ltr:pr-0 tw:my-1',
},
}),
Video,
Audio,
CustomTable.configure(),
// CustomTable.configure({
// resizable: true,
// }),
CustomTableRow,
CustomTableHeader,
CustomTableCell,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
TextStyle,
FontSize,
FontFamily,
LineHeightExtension.configure({
types: ['paragraph', 'heading'],
lineHeights: ['1', '1.15', '1.5', '2', '2.5', '3'],
}),
ResizeImageExtension,
ResizeVideoExtension,
ResizeIframeExtension
// Image,
],
content: value,
immediatelyRender: false,
// onUpdate({ editor }) {
// onChange(editor.getHTML());
// const newHeadings = getHeadings(editor);
// setHeadingsList(newHeadings);
// },
// onUpdate({ editor }) {
// const rawHTML = editor.getHTML() // چیزی که Tiptap تولید میکند
// const normalizedHTML = normalizeEmptyParas(rawHTML)
// onChange(normalizedHTML)
// },
// onUpdate({ editor }) {
// preserveCaretPosition(editor, () => {
// const rawHTML = editor.getHTML();
// const normalizedHTML = normalizeEmptyParas(rawHTML);
// editor.commands.setContent(normalizedHTML, false); // false برای جلوگیری از trigger کردن onUpdate دوباره
// });
// onChange(editor.getHTML())
// },
onUpdate({ editor }) {
const newHeadings = getHeadings(editor);
setHeadingsList(newHeadings);
const normalized = normalizeEmptyParas(editor.getHTML());
// از داخل ادیتور به والد میفرستیم
skipNextContentSet.current = true; // یعنی این تغییر داخلی است
onChange(normalized);
},
editorProps: {
attributes: {style: ` min-height: 300px; cursor: text; outline: none;`},
//غیر فعال کردم برای رفع مشکل سلکت کردن
// handleClick(view) {
// if (view.hasFocus()) return true
// return false
// },
},
});
// useEffect(() => {
// if (editor && value !== undefined) {
// if (editor.getHTML() !== value) {
// editor.commands.setContent(value);
// }
// }
// }, [editor, value]);
// متدهایی که از طریق ref در دسترس قرار میدیم
useImperativeHandle(ref, () => ({
insertContent: (content) => {
if (editor) {
editor.chain().focus().insertContent(content).run();
}
},
}));
useEffect(() => {
if (!editor || value === undefined) return;
// اگر همین لحظه از ادیتور آمده، پرچم را خنثی کن و چیزی ننویس
if (skipNextContentSet.current) {
skipNextContentSet.current = false;
return;
}
// فقط وقتی واقعاً یک تغییر بیرونی بود
if (editor.getHTML() !== value) {
editor.commands.setContent(value); // محتوا را جایگزین کن
}
}, [editor, value]);
if (!editor) {
return <div>Loading editor...</div>;
}
// سوییچ بین نمایش HTML و WYSIWYG
const toggleHTML = () => {
if (!showHTML) {
let rawHTML = editor.getHTML();
// اضافه کردن یک خط جدید بین تگها (صرفاً برای خواناتر شدن)
rawHTML = rawHTML.replace(/></g, '>\n<');
setHtmlCode(rawHTML);
setShowHTML(true);
} else {
editor.commands.setContent(htmlCode);
setShowHTML(false);
}
};
return (
<div data-theme={isDark ? 'dark' : 'light'}
className="tw:dark:bg-gray-900 tw:relative tw:nilfam-editor tw:flex tw:flex-col tw:p-0.5 tw:gap-0.5 tw:border tw:border-gray-200 tw:dark:border-gray-700 tw:rounded-xl"
dir={Configs.RtlLang.includes(lang) ? 'rtl' : 'ltr'}>
{/* نام ادیتور یا هدر کوچک */}
<div className="tw:add-font tw:dark:text-gray-200 tw:flex tw:text-sm tw:font-bold tw:pt-1 tw:justify-end tw:ltr:justify-start tw:text-gray-600 tw:px-2">
Nilfam-Editor 1.4
</div>
{/* نوار ابزار بالا */}
<div className="tw:add-font tw:flex tw:flex-col tw:sticky tw:top-0 tw:z-10 tw:bg-white tw:dark:bg-gray-600 tw:p-1 tw:border-b tw:border-gray-200 tw:dark:border-gray-600">
{/* بخش اول نوار ابزار (فونت، هدینگ، لاین هیت و ...) */}
<div className="tw:flex tw:flex-wrap tw:items-center tw:gap-1 tw:mb-2">
<SelectFontButton editor={editor} fonts={fonts} lang={lang} />
<SizeFontButton editor={editor} lang={lang} />
<HeadingButton editor={editor} lang={lang} />
<LineHeightButton editor={editor} lang={lang} />
<AnchorLinkMenu editor={editor} headingsList={headingsList} getHeadings={getHeadings} lang={lang} />
</div>
{/* بخش دوم نوار ابزار (دکمههای بولد، رنگ، لینک، آپلود و ...) */}
<div className="tw:flex tw:flex-wrap tw:items-center tw:gap-1">
<div className="class-button tw:data-active:bg-gray-300 tw:dark:data-active:bg-gray-700" data-active={editor.isActive('bold') || null} onClick={() => editor.chain().focus().toggleBold().run()} title={t('bold', lang)}>
<BoldIcon />
</div>
<div className="class-button tw:data-active:bg-gray-300 tw:dark:data-active:bg-gray-700" data-active={editor.isActive('italic') || null} onClick={() => editor.chain().focus().toggleItalic().run()} title={t('italic', lang)}>
<ItalicIcon />
</div>
<div className="class-button tw:data-active:bg-gray-300 tw:dark:data-active:bg-gray-700" data-active={editor.isActive('underline') || null} onClick={() => editor.chain().focus().toggleUnderline().run()} title={t('underline', lang)}>
<UnderLineIcon />
</div>
<TextColor editor={editor} lang={lang} />
<BorderColor editor={editor} lang={lang} />
<HighlightColor editor={editor} lang={lang} />
<LinkButton editor={editor} lang={lang} />
<div className="class-button" onClick={() => editor.chain().focus().unsetLink().run()} title={t('unsetLink', lang)}>
<LinkOffIcon />
</div>
<div className="class-button" onClick={() => editor.chain().focus().unsetTextStyle().clearNodes().run()} title={t('clearStyle', lang)}>
<StyleClearIcon />
</div>
<div className="class-button" title={t('image', lang)} onClick={() => setOpenUploadImage(!openUploadImage)}>
<PhotoIcon />
</div>
<UploadModalImage editor={editor} openUploadImage={openUploadImage} setOpenUploadImage={setOpenUploadImage} lang={lang}/>
<div className="class-button" title={t('video', lang)} onClick={() => setOpenUploadVideo(!openUploadVideo)}>
<MovieIcon />
</div>
<UploadModalVideo editor={editor} openUploadVideo={openUploadVideo} setOpenUploadVideo={setOpenUploadVideo} lang={lang}/>
<div className="class-button" title={t('audio', lang)} onClick={() => setOpenUploadAudio(!openUploadAudio)}>
<MicrophoneIcon />
</div>
<UploadModalAudio editor={editor} openUploadAudio={openUploadAudio} setOpenUploadAudio={setOpenUploadAudio} lang={lang}/>
<div className="class-button" onClick={() => editor.chain().focus().toggleBulletList().run()} title={t('list', lang)}>
<ListIcon />
</div>
<div className="class-button" onClick={() => editor.chain().focus().toggleOrderedList().run()} title={t('listNumber', lang)}>
<ListNumberIcon />
</div>
<div className="class-button" onClick={() => editor.chain().focus().setTextAlign('right').run()} title={t('alignRight', lang)}>
<AlignRightIcon />
</div>
<div className="class-button" onClick={() => editor.chain().focus().setTextAlign('center').run()} title={t('alignCenter', lang)}>
<AlignCenterIcon />
</div>
<div className="class-button" onClick={() => editor.chain().focus().setTextAlign('left').run()} title={t('alignLeft', lang)}>
<AlignLeftIcon />
</div>
<div className="class-button" onClick={() => editor.chain().focus().setTextAlign('justify').run()} title={t('justify', lang)}>
<AlignJustifyIcon />
</div>
<div className="class-button" onClick={() => editor.chain().focus().indent().run()} title={t('forward', lang)}>
<IndentDecreaseIcon />
</div>
<div className="class-button" onClick={() => editor.chain().focus().outdent().run()} title={t('backward', lang)}>
<IndentIncreaseIcon />
</div>
<div className="class-button" onClick={() => editor.chain().focus().toggleBlockquote().run()} title={t('blockquote', lang)}>
<BlockquoteIcon />
</div>
<EmojiButton editor={editor} lang={lang} />
<InsertTableButton editor={editor} isTableSelected={isTableSelected} setIsTableSelected={setIsTableSelected} lang={lang}/>
<CodeButtons editor={editor} lang={lang} />
<div className="class-button" onClick={toggleHTML}>
{showHTML ? <SourceCodeIcon /> : <HtmlIcon />}
</div>
<ColoredBoxButton editor={editor} lang={lang} />
<div className="class-button" onClick={()=>{editor.commands.copyFormat()}} title={t('formatPainterCopy', lang)}>
<FormatPainterCopyIcon/>
</div>
<div className="class-button" onClick={()=>{editor.commands.pasteFormat()}} >
<FormatPainterPasteIcon/>
</div>
<MenuTable editor={editor} isTableSelected={isTableSelected} lang={lang} />
</div>
</div>
{showHTML ? (
<textarea
className="tw:bg-gray-200 tw:dark:bg-gray-800 tw:p-2 tw:min-h-[300px] tw:dark:text-gray-300 "
rows={10}
value={htmlCode}
onChange={(e) => setHtmlCode(e.target.value)}
/>
) : (
<EditorContent
// onClick={() => !editor.isFocused && editor.chain().focus().run()}
editor={editor}
data-active={editor.isFocused || null}
className={`${
Configs.RtlLang.includes(lang) ? 'tw:text-right' : 'tw:text-left'
} tw:p-2 tw:dark:text-gray-300 tw:data-active:ring-2 tw:data-active:ring-red-200 tw:dark:data-active:ring-red-300 tw:min-h-[300px] tw:bg-white tw:dark:bg-gray-800 tw:!outline-none`}
/>
)}
{/* فوتر یا بخش پایین ادیتور */}
<div className="tw:dark:text-gray-500 tw:add-font tw:text-gray-400 tw:text-sm tw:px-2 tw:border-t tw:border-gray-200 tw:dark:border-gray-600">
NilfamEditor.ir
</div>
</div>
);
});
export default Editor;