'use client' import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react' import { useEditor, EditorContent } from '@tiptap/react' import { BubbleMenu } from '@tiptap/react/menus' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' import Placeholder from '@tiptap/extension-placeholder' import TiptapLink from '@tiptap/extension-link' import Highlight from '@tiptap/extension-highlight' import Image from '@tiptap/extension-image' import TextAlign from '@tiptap/extension-text-align' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import Superscript from '@tiptap/extension-superscript' import Subscript from '@tiptap/extension-subscript' import Typography from '@tiptap/extension-typography' import type { Editor } from '@tiptap/core' import type { EditorState } from '@tiptap/pm/state' import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code, Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon, Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink, FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight, } from 'lucide-react' import { cn } from '@/lib/utils' export interface RichTextEditorHandle { getEditor: () => Editor | null } interface RichTextEditorProps { content?: string onChange?: (content: string) => void className?: string placeholder?: string } type SlashItem = { title: string description: string icon: typeof Bold category?: string isImage?: boolean isAi?: boolean aiOption?: 'clarify' | 'shorten' | 'improve' command: (editor: Editor) => void } const slashCommands: SlashItem[] = [ { title: 'Text', description: 'Just start writing with plain text', icon: Pilcrow, category: 'Basic blocks', command: (e) => e.chain().focus().setParagraph().run() }, { title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() }, { title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() }, { title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() }, { title: 'Bullet List', description: 'Create a simple bullet list', icon: List, category: 'Basic blocks', command: (e) => e.chain().focus().toggleBulletList().run() }, { title: 'Numbered List', description: 'Create a list with numbering', icon: ListOrdered, category: 'Basic blocks', command: (e) => e.chain().focus().toggleOrderedList().run() }, { title: 'To-do List', description: 'Track tasks with checkboxes', icon: CheckSquare, category: 'Basic blocks', command: (e) => e.chain().focus().toggleTaskList().run() }, { title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', command: (e) => e.chain().focus().toggleBlockquote().run() }, { title: 'Code Block', description: 'Capture a code snippet', icon: CodeXml, category: 'Basic blocks', command: (e) => e.chain().focus().toggleCodeBlock().run() }, { title: 'Divider', description: 'Visually divide blocks', icon: Minus, category: 'Basic blocks', command: (e) => e.chain().focus().setHorizontalRule().run() }, { title: 'Image', description: 'Embed an image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => {} }, { title: 'Clarify', description: 'Make text clearer and easier to understand', icon: Lightbulb, category: 'AI', isAi: true, aiOption: 'clarify', command: () => {} }, { title: 'Shorten', description: 'Make text more concise', icon: Scissors, category: 'AI', isAi: true, aiOption: 'shorten', command: () => {} }, { title: 'Improve', description: 'Improve writing style and flow', icon: Wand2, category: 'AI', isAi: true, aiOption: 'improve', command: () => {} }, ] async function aiReformulate(text: string, option: 'clarify' | 'shorten' | 'improve'): Promise { const res = await fetch('/api/ai/reformulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, option }), }) const data = await res.json() if (!res.ok) throw new Error(data.error || 'AI failed') return data.reformulatedText || data.text || text } function useImageInsert() { const [open, setOpen] = useState(false) const editorRef = useRef(null) const requestInsert = useCallback((editor: Editor) => { editorRef.current = editor setOpen(true) }, []) const confirm = useCallback((url: string) => { if (url.trim() && editorRef.current) { editorRef.current.chain().focus().setImage({ src: url.trim() }).run() } setOpen(false) editorRef.current = null }, []) const cancel = useCallback(() => { setOpen(false) editorRef.current = null }, []) return { open, requestInsert, confirm, cancel } } export const RichTextEditor = forwardRef( function RichTextEditor({ content, onChange, className, placeholder }, ref) { const imageInsert = useImageInsert() const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: false, underline: false }), Underline, TiptapLink.configure({ openOnClick: false, autolink: true }), Highlight.configure({ multicolor: false }), Image.configure({ inline: false, allowBase64: true }), TextAlign.configure({ types: ['heading', 'paragraph'] }), TaskList, TaskItem.configure({ nested: true }), Superscript, Subscript, Typography, Placeholder.configure({ placeholder: placeholder || "Type '/' for commands..." }), ], content: content || '', immediatelyRender: false, editorProps: { attributes: { class: 'notion-editor' }, }, onUpdate: ({ editor: e }) => { onChange?.(e.getHTML()) }, }) useImperativeHandle(ref, () => ({ getEditor: () => editor }), [editor]) return (
{editor && ( { const { from, to } = state.selection return from !== to && !e.isActive('codeBlock') }} > )} {editor && } {imageInsert.open && ( )}
) } ) function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void; onCancel: () => void }) { const [url, setUrl] = useState('') const [preview, setPreview] = useState(null) const [error, setError] = useState('') const inputRef = useRef(null) useEffect(() => { inputRef.current?.focus() }, []) const handleConfirm = () => { if (!url.trim()) return if (!/^https?:\/\/.+/i.test(url.trim())) { setError('Please enter a valid URL'); return } onConfirm(url.trim()) } return (
e.stopPropagation()}>
Insert image
{ setUrl(e.target.value); setError(''); setPreview(null) }} onKeyDown={(e) => { if (e.key === 'Enter') handleConfirm(); if (e.key === 'Escape') onCancel() }} placeholder="https://example.com/image.png" className="notion-modal-input" /> {url.trim() && !preview && ( )} {preview && (
{ setPreview(null); setError('Failed to load image') }} />
)} {error &&
{error}
}
) } function BubbleToolbar({ editor }: { editor: Editor | null }) { const [, setTick] = useState(0) const [aiOpen, setAiOpen] = useState(false) const [aiLoading, setAiLoading] = useState(false) const [linkOpen, setLinkOpen] = useState(false) const [linkUrl, setLinkUrl] = useState('') const linkInputRef = useRef(null) useEffect(() => { if (!editor) return const h = () => setTick(t => t + 1) editor.on('transaction', h) editor.on('selectionUpdate', h) return () => { editor.off('transaction', h); editor.off('selectionUpdate', h) } }, [editor]) useEffect(() => { if (linkOpen && linkInputRef.current) linkInputRef.current.focus() }, [linkOpen]) if (!editor) return null const marks = [ { icon: Bold, active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() }, { icon: Italic, active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() }, { icon: UnderlineIcon, active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() }, { icon: Strikethrough, active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() }, { icon: Code, active: editor.isActive('code'), action: () => editor.chain().focus().toggleCode().run() }, { icon: Highlighter, active: editor.isActive('highlight'), action: () => editor.chain().focus().toggleHighlight().run() }, ] const handleAI = async (option: 'clarify' | 'shorten' | 'improve') => { const { from, to } = editor.state.selection const text = editor.state.doc.textBetween(from, to, ' ') if (!text || text.split(/\s+/).length < 5) return setAiLoading(true) setAiOpen(false) try { const result = await aiReformulate(text, option) editor.chain().focus().insertContentAt({ from, to }, result).run() } catch (err) { console.error('AI error:', err) } finally { setAiLoading(false) } } const openLinkEditor = () => { const existing = editor.getAttributes('link').href || '' setLinkUrl(existing) setLinkOpen(true) setAiOpen(false) } const applyLink = () => { if (linkUrl.trim()) { editor.chain().focus().extendMarkRange('link').setLink({ href: linkUrl.trim() }).run() } else { editor.chain().focus().extendMarkRange('link').unsetLink().run() } setLinkOpen(false) } if (linkOpen) { return (
setLinkUrl(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyLink() }; if (e.key === 'Escape') { setLinkOpen(false); editor.chain().focus().run() } }} placeholder="Paste or type a link..." className="notion-inline-input" /> {editor.isActive('link') && ( )}
) } return (
{marks.map((m, i) => ( ))}
{aiOpen && (
)}
) } function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertImage: (editor: Editor) => void }) { const [isOpen, setIsOpen] = useState(false) const [query, setQuery] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const [coords, setCoords] = useState({ top: 0, left: 0 }) const [aiLoading, setAiLoading] = useState(false) const menuRef = useRef(null) const closeMenu = useCallback(() => { setIsOpen(false); setQuery(''); setSelectedIndex(0) }, []) const deleteSlashText = useCallback(() => { const { from, to } = editor.state.selection const textBefore = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n') const slashIdx = textBefore.lastIndexOf('/') if (slashIdx >= 0) { const deleteFrom = from - (textBefore.length - slashIdx) editor.chain().focus().deleteRange({ from: deleteFrom, to }).run() } }, [editor]) const handleSelect = useCallback(async (item: SlashItem) => { if (item.isImage) { deleteSlashText() closeMenu() onInsertImage(editor) } else if (item.isAi && item.aiOption) { deleteSlashText() closeMenu() setAiLoading(true) try { const allText = editor.state.doc.textContent if (!allText || allText.split(/\s+/).length < 5) return const result = await aiReformulate(allText, item.aiOption) editor.chain().focus().setContent(result).run() } catch (err) { console.error('AI slash error:', err) } finally { setAiLoading(false) } } else { deleteSlashText() item.command(editor) closeMenu() } }, [editor, closeMenu, deleteSlashText, onInsertImage]) const filtered = slashCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase())) const categories = filtered.reduce((acc, item) => { const cat = item.category || 'Basic blocks' if (!acc[cat]) acc[cat] = [] acc[cat].push(item) return acc }, {} as Record) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isOpen) return if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length); return } if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return } if (e.key === 'Enter') { e.preventDefault(); if (filtered[selectedIndex]) handleSelect(filtered[selectedIndex]); return } if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return } } document.addEventListener('keydown', handleKeyDown, true) return () => document.removeEventListener('keydown', handleKeyDown, true) }, [isOpen, selectedIndex, filtered, handleSelect, closeMenu]) useEffect(() => { if (!isOpen) return const updatePosition = () => { const { from } = editor.state.selection const c = editor.view.coordsAtPos(from) setCoords({ top: c.bottom + 8, left: c.left }) } updatePosition() }, [isOpen, editor, query]) useEffect(() => { const handleClick = () => { if (isOpen) closeMenu() } document.addEventListener('click', handleClick) return () => document.removeEventListener('click', handleClick) }, [isOpen, closeMenu]) useEffect(() => { const handler = () => { const { from, to, empty } = editor.state.selection if (!empty) { if (isOpen) closeMenu(); return } const text = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n') const m = text.match(/\/([^\s/]*)$/) if (m) { setQuery(m[1]); setSelectedIndex(0); if (!isOpen) setIsOpen(true) } else if (isOpen) closeMenu() } editor.on('update', handler) editor.on('selectionUpdate', handler) return () => { editor.off('update', handler); editor.off('selectionUpdate', handler) } }, [editor, isOpen, closeMenu]) if (!isOpen || filtered.length === 0) return null let flatIndex = -1 return (
e.stopPropagation()}> {aiLoading && (
AI is thinking...
)} {!aiLoading && Object.entries(categories).map(([cat, items]) => (
{cat}
{items.map((item) => { flatIndex++ const idx = flatIndex return ( ) })}
))}
) }