UNPKG

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.

411 lines (378 loc) 16.9 kB
import { EditorContent, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Heading from '@tiptap/extension-heading'; import Link from '@tiptap/extension-link'; import Image from '@tiptap/extension-image'; 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 { 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 { Audio } from '../extensions/Audio.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 { AlignCenterIcon, AlignJustifyIcon, AlignLeftIcon, AlignRightIcon, BoldIcon, HtmlIcon, IndentDecreaseIcon, IndentIncreaseIcon, ItalicIcon, LinkOffIcon, ListIcon, ListNumberIcon, MicrophoneIcon, MovieIcon, PhotoIcon, SourceCodeIcon, StyleClearIcon, } from '../assets/icons/Icons.jsx'; const Editor = ({ lang = 'en', value, onChange = () => {}, fonts = [], }) => { // 1) تعریف تمام stateها در ابتدای کامپوننت 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); // محتوای پیش‌فرض بر اساس زبان const defaultContent = Configs.RtlLang.includes(lang) ? '<p>شروع ویرایش...</p>' : '<p>Start editing...</p>'; // 2) مقداردهی ادیتور با تمام اکستنشن‌ها در بالای بدنه تابع کامپوننت const editor = useEditor({ extensions: [ StarterKit.configure({ codeBlock: false, heading: false, }), CustomCodeBlock, Highlight.configure({ multicolor: true, }), HeadingWithAutoId.configure({ levels: [1, 2, 3, 4, 5, 6], }), Link.configure({ openOnClick: false, HTMLAttributes: { class: 'text-blue-500 hover:text-blue-700', }, }), OrderedList.configure({ HTMLAttributes: { class: 'list-none pr-0 rtl:pl-0 counter-reset: item', }, }), BulletList.configure({ HTMLAttributes: { class: 'list-none pr-0 rtl:pl-0', }, }), ListItem.configure({ HTMLAttributes: { class: 'relative pl-6 rtl:pr-6 rtl:pl-0 my-1', }, }), Image, Video, Audio, CustomTable.configure(), 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, ], content: value || defaultContent, immediatelyRender: false, onUpdate({ editor }) { onChange(editor.getHTML()); const newHeadings = getHeadings(editor); setHeadingsList(newHeadings); }, }); // اگر هنوز editor ساخته نشده باشد، می‌توان یک Loading یا null بازگرداند if (!editor) { return <div>Loading editor...</div>; } // توابع کمکی بدون استفاده از هوک: const bringForward = () => { const dom = editor.view.domAtPos(editor.state.selection.from).node; if (dom && dom.style) { let currentZ = parseInt(dom.style.zIndex) || 0; dom.style.zIndex = currentZ + 1; } }; const sendBackward = () => { const dom = editor.view.domAtPos(editor.state.selection.from).node; if (dom && dom.style) { let currentZ = parseInt(dom.style.zIndex) || 0; dom.style.zIndex = currentZ - 1; } }; // سوییچ بین نمایش 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 className="relative nilfam-editor flex flex-col p-0.5 gap-0.5 border border-gray-200 rounded-xl" dir={Configs.RtlLang.includes(lang) ? 'rtl' : 'ltr'} > {/* نام ادیتور یا هدر کوچک */} <div className="add-font flex text-sm font-bold pt-1 justify-end text-gray-600 px-2"> Nilfam-Editor </div> {/* نوار ابزار بالا */} <div className="add-font flex flex-col sticky top-0 z-10 bg-white p-1 border-b border-gray-200"> {/* بخش اول نوار ابزار (فونت، هدینگ، لاین هیت و ...) */} <div className="flex flex-wrap items-center gap-1 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} lang={lang} /> </div> {/* بخش دوم نوار ابزار (دکمه‌های بولد، رنگ، لینک، آپلود و ...) */} <div className="flex flex-wrap items-center gap-1"> <button className="class-button data-active:bg-gray-300" data-active={editor.isActive('bold') || null} onClick={() => editor.chain().focus().toggleBold().run()} title={t('bold', lang)} > <BoldIcon /> </button> <button className="class-button data-active:bg-gray-300" data-active={editor.isActive('italic') || null} onClick={() => editor.chain().focus().toggleItalic().run()} title={t('italic', lang)} > <ItalicIcon /> </button> <TextColor editor={editor} lang={lang} /> <BorderColor editor={editor} lang={lang} /> <HighlightColor editor={editor} lang={lang} /> <LinkButton editor={editor} lang={lang} /> <button className="class-button" onClick={() => editor.chain().focus().unsetLink().run()} title={t('unsetLink', lang)} > <LinkOffIcon /> </button> <button className="class-button" onClick={() => { editor.chain().focus().unsetTextStyle().clearNodes().run(); }} title={t('clearStyle', lang)} > <StyleClearIcon /> </button> <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} /> <button className="class-button" onClick={() => editor.chain().focus().toggleBulletList().run()} title={t('list', lang)} > <ListIcon /> </button> <button className="class-button" onClick={() => editor.chain().focus().toggleOrderedList().run()} title={t('listNumber', lang)} > <ListNumberIcon /> </button> <button className="class-button" onClick={() => { editor.chain().focus().setTextAlign('right').run(); }} title={t('alignRight', lang)} > <AlignRightIcon /> </button> <button className="class-button" onClick={() => { editor.chain().focus().setTextAlign('center').run(); }} title={t('alignCenter', lang)} > <AlignCenterIcon /> </button> <button className="class-button" onClick={() => { editor.chain().focus().setTextAlign('left').run(); }} title={t('alignLeft', lang)} > <AlignLeftIcon /> </button> <button className="class-button" onClick={() => { editor.chain().focus().setTextAlign('justify').run(); }} title={t('justify', lang)} > <AlignJustifyIcon /> </button> <button className="class-button" onClick={bringForward} title={t('forward', lang)} > <IndentDecreaseIcon /> </button> <button className="class-button" onClick={sendBackward} title={t('backward', lang)} > <IndentIncreaseIcon /> </button> <EmojiButton editor={editor} lang={lang} /> <InsertTableButton editor={editor} isTableSelected={isTableSelected} setIsTableSelected={setIsTableSelected} lang={lang} /> <CodeButtons editor={editor} lang={lang} /> <button className="class-button" onClick={toggleHTML}> {showHTML ? <SourceCodeIcon /> : <HtmlIcon />} </button> <MenuTable editor={editor} isTableSelected={isTableSelected} lang={lang} /> </div> </div> {/* محتوای اصلی ادیتور یا نمایش HTML */} {showHTML ? ( <textarea className="bg-gray-200 p-2 min-h-[300px]" 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) ? 'text-right' : 'text-left' } p-2 data-active:ring-2 data-active:ring-red-200 min-h-[300px] bg-white !outline-none`} /> )} {/* فوتر یا بخش پایین ادیتور */} <div className="add-font text-gray-400 text-sm px-2 border-t border-gray-200"> NilfamEditor.ir </div> </div> ); }; export default Editor;