import React, { useState, useCallback } from 'react'; import { useEditor, EditorContent, type Editor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; import HardBreak from '@tiptap/extension-hard-break'; import Placeholder from '@tiptap/extension-placeholder'; import CharacterCount from '@tiptap/extension-character-count'; import TextAlign from '@tiptap/extension-text-align'; import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; import { lowlight } from 'lowlight'; import { Bold, Italic, List, ListOrdered, Link as LinkIcon, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Quote, Code, Code2, Minus, Undo, Redo, RemoveFormatting, AlignLeft, AlignCenter, AlignRight, AlignJustify, Eye, Edit3, Columns, Maximize2, HelpCircle, Save } from 'lucide-react'; import { Button } from '../common'; import DOMPurify from 'dompurify'; import '../../styles/prose-overrides.css'; interface CMSEditorProps { content: string; onChange: (content: string) => void; onSave?: () => void; isSaving?: boolean; } type ViewMode = 'edit' | 'preview' | 'split'; export const CMSEditor: React.FC = ({ content, onChange, onSave, isSaving }) => { const [linkUrl, setLinkUrl] = useState(''); const [showLinkDialog, setShowLinkDialog] = useState(false); const [viewMode, setViewMode] = useState('edit'); const [isFullscreen, setIsFullscreen] = useState(false); const [showHelp, setShowHelp] = useState(false); const [wordCount, setWordCount] = useState(0); const [charCount, setCharCount] = useState(0); const editor = useEditor({ extensions: [ StarterKit.configure({ hardBreak: false, // We'll use the separate HardBreak extension codeBlock: false, // We'll use CodeBlockLowlight instead }), HardBreak.configure({ keepMarks: true, HTMLAttributes: { class: 'hard-break', }, }), Link.configure({ openOnClick: false, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer', }, }), TextAlign.configure({ types: ['heading', 'paragraph'], alignments: ['left', 'center', 'right', 'justify'], defaultAlignment: 'left', }), CodeBlockLowlight.configure({ lowlight, HTMLAttributes: { class: 'hljs', }, }), Placeholder.configure({ placeholder: 'Start typing your content here...', }), CharacterCount.configure({ limit: null, }), ], content, onUpdate: ({ editor }) => { onChange(editor.getHTML()); updateCounts(editor); }, onCreate: ({ editor }) => { updateCounts(editor); }, }); const updateCounts = useCallback((editorInstance: Editor) => { const textContent = editorInstance.state.doc.textContent; setCharCount(editorInstance.storage.characterCount.characters()); const words = textContent.trim().split(/\s+/).filter((word: string) => word.length > 0); setWordCount(words.length); }, []); // Update editor content when prop changes React.useEffect(() => { if (editor && content !== editor.getHTML()) { editor.commands.setContent(content); } }, [content, editor]); if (!editor) { return null; } const addLink = () => { if (linkUrl) { editor.chain().focus().setLink({ href: linkUrl }).run(); setLinkUrl(''); setShowLinkDialog(false); } }; const MenuButton: React.FC<{ onClick: () => void; active?: boolean; children: React.ReactNode; title: string; disabled?: boolean; }> = ({ onClick, active, children, title, disabled }) => ( ); const toggleFullscreen = () => { setIsFullscreen(!isFullscreen); }; const getPreviewContent = () => { return DOMPurify.sanitize(editor?.getHTML() || '', { ALLOWED_TAGS: [ 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong', 'code', 'pre', 'hr', 'div', 'span' ], ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'], ALLOW_DATA_ATTR: false, KEEP_CONTENT: true, ADD_TAGS: ['br'], // Explicitly allow br tags ADD_ATTR: ['style'], // Allow style for text alignment }); }; return (
{/* Top Toolbar */}
{/* View Mode Controls */}
{onSave && ( )} setShowHelp(true)} title="Help & Keyboard Shortcuts" >
{/* Formatting Toolbar */} {viewMode !== 'preview' && (
editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} title="Heading 1 (Ctrl+Alt+1)" > editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2 (Ctrl+Alt+2)" > editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3 (Ctrl+Alt+3)" > editor.chain().focus().toggleHeading({ level: 4 }).run()} active={editor.isActive('heading', { level: 4 })} title="Heading 4 (Ctrl+Alt+4)" > editor.chain().focus().toggleHeading({ level: 5 }).run()} active={editor.isActive('heading', { level: 5 })} title="Heading 5 (Ctrl+Alt+5)" > editor.chain().focus().toggleHeading({ level: 6 }).run()} active={editor.isActive('heading', { level: 6 })} title="Heading 6 (Ctrl+Alt+6)" >
editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold (Ctrl+B)" > editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic (Ctrl+I)" > editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} title="Inline Code (Ctrl+E)" > editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Code Block (Ctrl+Alt+C)" >
editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet List (Ctrl+Shift+8)" > editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered List (Ctrl+Shift+9)" > editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Blockquote (Ctrl+Shift+B)" >
setShowLinkDialog(true)} active={editor.isActive('link')} title="Add Link (Ctrl+K)" > editor.chain().focus().setHorizontalRule().run()} title="Horizontal Rule" >
editor.chain().focus().setTextAlign('left').run()} active={editor.isActive({ textAlign: 'left' })} title="Align Left" > editor.chain().focus().setTextAlign('center').run()} active={editor.isActive({ textAlign: 'center' })} title="Align Center" > editor.chain().focus().setTextAlign('right').run()} active={editor.isActive({ textAlign: 'right' })} title="Align Right" > editor.chain().focus().setTextAlign('justify').run()} active={editor.isActive({ textAlign: 'justify' })} title="Justify" >
editor.chain().focus().clearNodes().unsetAllMarks().run()} title="Clear Formatting" >
editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo (Ctrl+Z)" > editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo (Ctrl+Y)" >
)}
{/* Link Dialog */} {showLinkDialog && (
setLinkUrl(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && addLink()} placeholder="Enter URL..." className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500" autoFocus />
)} {/* Editor Content Area */}
{/* Editor */} {viewMode !== 'preview' && (
)} {/* Preview */} {viewMode !== 'edit' && (
)}
{/* Status Bar */}
{wordCount} words {charCount} characters
Press Shift+Enter for line break, Enter for new paragraph
{/* Help Modal */} {showHelp && (

Editor Help & Keyboard Shortcuts

Text Formatting

Ctrl+B - Bold
Ctrl+I - Italic
Ctrl+E - Inline code
Ctrl+K - Add link

Headings

Ctrl+Alt+1 - Heading 1
Ctrl+Alt+2 - Heading 2
Ctrl+Alt+3 - Heading 3
Ctrl+Alt+4 - Heading 4
Ctrl+Alt+5 - Heading 5
Ctrl+Alt+6 - Heading 6

Lists & Blocks

Ctrl+Shift+8 - Bullet list
Ctrl+Shift+9 - Numbered list
Ctrl+Shift+B - Blockquote
Ctrl+Alt+C - Code block

Text Alignment

Click alignment buttons in toolbar
Works on paragraphs and headings

Line Breaks

Enter - New paragraph
Shift+Enter - Line break (preserves formatting)

Navigation

Ctrl+Z - Undo
Ctrl+Y - Redo
)}
); }; CMSEditor.displayName = 'CMSEditor';