import { BubbleMenu, Editor } from '@tiptap/react'
import {
IoListOutline,
IoCheckboxOutline,
IoCodeSlashOutline,
IoChatbubbleEllipsesOutline,
IoLinkOutline,
IoChevronDownOutline,
IoSparklesOutline,
IoArrowForwardOutline,
IoSettingsOutline
} from 'react-icons/io5'
import { BsTypeBold, BsTypeItalic, BsListOl } from 'react-icons/bs'
import { useState, useEffect, useRef } from 'react'
import LinkDialog from './LinkDialog'
import { MODELS } from '@/lib/config/models'
import { useUser } from '@/lib/hooks/useUser'
interface BubbleMenuProps {
editor: Editor
}
export default function BubbleMenuComponent({ editor }: BubbleMenuProps) {
const { user } = useUser()
const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false)
const [isHeadingDropdownOpen, setIsHeadingDropdownOpen] = useState(false)
const [isAIDropdownOpen, setIsAIDropdownOpen] = useState(false)
const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>('top')
const [aiDropdownPosition, setAiDropdownPosition] = useState<'top' | 'bottom'>('top')
const [customPrompt, setCustomPrompt] = useState('')
const [isProcessingAI, setIsProcessingAI] = useState(false)
const [selectedModel, setSelectedModel] = useState('gpt-4o-mini') // Default fast model for text editing
const [showModelSelector, setShowModelSelector] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const aiDropdownRef = useRef<HTMLDivElement>(null)
const customPromptInputRef = useRef<HTMLInputElement>(null)
const handleLink = () => {
const previousUrl = editor.getAttributes('link').href
setIsLinkDialogOpen(true)
}
const handleLinkConfirm = (url: string) => {
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({ href: url })
.run()
}
const handleLinkRemove = () => {
editor
.chain()
.focus()
.extendMarkRange('link')
.unsetLink()
.run()
}
// Get current heading level or return 0 for paragraph
const getCurrentHeadingLevel = () => {
for (let i = 1; i <= 6; i++) {
if (editor.isActive('heading', { level: i })) {
return i
}
}
return 0 // paragraph
}
// Helper function to detect current formatting context
const getFormattingContext = () => {
const context = []
// Check for heading levels first (most specific)
for (let i = 1; i <= 6; i++) {
if (editor.isActive('heading', { level: i })) {
context.push(`heading level ${i}`)
break
}
}
// Check for list types (can be combined with other formatting)
if (editor.isActive('bulletList')) {
context.push('bullet list item')
} else if (editor.isActive('orderedList')) {
context.push('numbered list item')
} else if (editor.isActive('taskList')) {
context.push('task list item')
}
// Check for blockquote (can contain other elements)
if (editor.isActive('blockquote')) {
context.push('blockquote')
}
// Check for code block (usually standalone)
if (editor.isActive('codeBlock')) {
context.push('code block')
}
// Check for table cell
if (editor.isActive('tableCell')) {
context.push('table cell')
}
// Check for paragraph (default when no other structure)
if (context.length === 0) {
context.push('paragraph')
}
return context.join(' within ')
}
const currentLevel = getCurrentHeadingLevel()
const headingLabel = currentLevel === 0 ? 'Text' : `H${currentLevel}`
const handleHeadingSelect = (level: number) => {
if (level === 0) {
editor.chain().focus().setParagraph().run()
} else {
editor.chain().focus().toggleHeading({ level: level as 1 | 2 | 3 | 4 | 5 | 6 }).run()
}
setIsHeadingDropdownOpen(false)
}
const toggleHeadingDropdown = () => {
if (!isHeadingDropdownOpen) {
// Calculate available space before opening
const button = document.querySelector('[data-heading-select]') as HTMLElement
if (button) {
const rect = button.getBoundingClientRect()
const viewportHeight = window.innerHeight
const dropdownHeight = 200 // Approximate height of dropdown
// Check if there's enough space above
const spaceAbove = rect.top
// Check if there's enough space below
const spaceBelow = viewportHeight - rect.bottom
// Choose the direction with more space
if (spaceBelow >= dropdownHeight || spaceBelow > spaceAbove) {
setDropdownPosition('bottom')
} else {
setDropdownPosition('top')
}
}
}
setIsHeadingDropdownOpen(!isHeadingDropdownOpen)
}
const toggleAIDropdown = () => {
if (!isAIDropdownOpen) {
// Calculate available space before opening
const button = document.querySelector('[data-ai-select]') as HTMLElement
if (button) {
const rect = button.getBoundingClientRect()
const viewportHeight = window.innerHeight
const dropdownHeight = 300 // Approximate height of AI dropdown
// Check if there's enough space above
const spaceAbove = rect.top
// Check if there's enough space below
const spaceBelow = viewportHeight - rect.bottom
// Choose the direction with more space
if (spaceBelow >= dropdownHeight || spaceBelow > spaceAbove) {
setAiDropdownPosition('bottom')
} else {
setAiDropdownPosition('top')
}
}
}
setIsAIDropdownOpen(!isAIDropdownOpen)
}
const handleAIAction = async (action: string, prompt?: string) => {
const { from, to } = editor.state.selection
if (from === to) return // No text selected
const selectedText = editor.state.doc.textBetween(from, to)
if (!selectedText.trim()) return
// Get context - surrounding text (preceding and following)
const contextSize = 200 // Number of characters to include for context
const precedingText = editor.state.doc.textBetween(
Math.max(0, from - contextSize),
from
).trim()
const followingText = editor.state.doc.textBetween(
to,
Math.min(editor.state.doc.content.size, to + contextSize)
).trim()
// Get formatting context
const formattingContext = getFormattingContext()
setIsProcessingAI(true)
setIsAIDropdownOpen(false)
try {
const finalPrompt = prompt || action
console.log(`AI Action: ${action}`, {
selectedText,
finalPrompt,
precedingText,
followingText,
formattingContext
})
// Store original selection for replacement
const originalFrom = from
const originalTo = to
// Call the AI API with streaming
const response = await fetch('/api/ai/edit-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
context: {
preceding: precedingText,
selected: selectedText,
following: followingText,
formatting: formattingContext
},
instruction: finalPrompt,
model: selectedModel,
userId: user?.id,
stream: true
})
})
if (!response.ok) {
const errorData = await response.json()
if (errorData.limitExceeded) {
throw new Error(errorData.error)
}
throw new Error(`AI API error: ${response.status}`)
}
// Clear the selected text first
editor.chain().focus().setTextSelection({ from: originalFrom, to: originalTo }).deleteSelection().run()
// Process streaming response
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
const decoder = new TextDecoder()
let buffer = ''
let accumulatedContent = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (!data) continue
try {
const parsed = JSON.parse(data)
if (parsed.error) {
throw new Error(parsed.error)
}
if (parsed.content) {
// Accumulate content and replace entire selection each time
accumulatedContent += parsed.content
// Replace the entire content from original position
try {
const currentDocSize = editor.state.doc.content.size
const safeFrom = Math.min(originalFrom, currentDocSize)
const safeTo = Math.min(originalFrom + accumulatedContent.length, currentDocSize)
// Clear any existing content at the position and insert accumulated content
editor.chain()
.focus()
.setTextSelection({ from: safeFrom, to: Math.min(safeFrom + (accumulatedContent.length - parsed.content.length), currentDocSize) })
.deleteSelection()
.insertContentAt(safeFrom, accumulatedContent)
.run()
} catch (e) {
// Fallback: use transaction-based insertion
console.warn('Content insertion failed, using transaction fallback:', e)
const textNode = editor.schema.text(accumulatedContent)
const transaction = editor.state.tr
.delete(originalFrom, Math.min(originalFrom + (accumulatedContent.length - parsed.content.length), editor.state.doc.content.size))
.insert(originalFrom, textNode)
editor.view.dispatch(transaction)
}
}
if (parsed.done) {
break
}
} catch (e) {
if (e instanceof Error && e.message !== 'Unexpected end of JSON input') {
throw e
}
// Skip invalid JSON
}
}
}
}
} catch (error) {
console.error('AI processing failed:', error)
// Could show an error toast here
} finally {
setIsProcessingAI(false)
}
}
const handleCustomPrompt = async () => {
if (!customPrompt.trim()) return
await handleAIAction('Custom', customPrompt)
setCustomPrompt('')
}
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsHeadingDropdownOpen(false)
}
if (aiDropdownRef.current && !aiDropdownRef.current.contains(event.target as Node)) {
setIsAIDropdownOpen(false)
setShowModelSelector(false)
}
}
if (isHeadingDropdownOpen || isAIDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isHeadingDropdownOpen, isAIDropdownOpen])
// Auto-focus custom prompt input when AI dropdown opens
useEffect(() => {
if (isAIDropdownOpen && customPromptInputRef.current) {
// Small delay to ensure the dropdown is fully rendered
setTimeout(() => {
customPromptInputRef.current?.focus()
}, 100)
}
}, [isAIDropdownOpen])
// Get suitable models for text editing (fast models preferred)
const editingModels = MODELS.filter(model =>
model.tier === 'fast' || ['gpt-4o-mini', 'gemini-2-5-flash', 'claude-sonnet-4'].includes(model.id)
)
const currentModelConfig = MODELS.find(m => m.id === selectedModel) || MODELS[0]
return (
<>
<BubbleMenu
editor={editor}
tippyOptions={{
duration: 100,
placement: 'top',
maxWidth: 'none'
}}
className="flex flex-col gap-1 p-1 bg-slate-800/95 backdrop-blur-sm border border-slate-700/50 rounded-lg shadow-xl"
>
{/* First row: Text formatting and links */}
<div className="flex items-center gap-1">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={`p-1.5 rounded-md hover:bg-slate-700/50 transition-colors ${
editor.isActive('bold') ? 'text-teal-400' : 'text-slate-400 hover:text-slate-300'
}`}
title="Bold"
>
<BsTypeBold className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`p-1.5 rounded-md hover:bg-slate-700/50 transition-colors ${
editor.isActive('italic') ? 'text-teal-400' : 'text-slate-400 hover:text-slate-300'
}`}
title="Italic"
>
<BsTypeItalic className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={`p-1.5 rounded-md hover:bg-slate-700/50 transition-colors ${
editor.isActive('code') ? 'text-teal-400' : 'text-slate-400 hover:text-slate-300'
}`}
title="Inline Code"
>
<IoCodeSlashOutline className="w-4 h-4" />
</button>
<button
onClick={handleLink}
className={`p-1.5 rounded-md hover:bg-slate-700/50 transition-colors ${
editor.isActive('link') ? 'text-teal-400' : 'text-slate-400 hover:text-slate-300'
}`}
title="Link"
>
<IoLinkOutline className="w-4 h-4" />
</button>
</div>
{/* Second row: Heading select, AI features, and lists/block formatting */}
<div className="flex items-center gap-1">
{/* Heading Select */}
<div className="relative" ref={dropdownRef}>
<button
data-heading-select
onClick={toggleHeadingDropdown}
className={`px-2 py-1 rounded-md hover:bg-slate-700/50 transition-colors text-xs font-medium flex items-center gap-1 ${
currentLevel > 0 ? 'text-teal-400' : 'text-slate-400 hover:text-slate-300'
}`}
title="Text Style"
>
{headingLabel}
<IoChevronDownOutline className={`w-3 h-3 transition-transform ${isHeadingDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown Menu */}
{isHeadingDropdownOpen && (
<div className={`absolute left-0 bg-slate-800 border border-slate-700 rounded-md shadow-xl z-50 min-w-[80px] ${
dropdownPosition === 'top'
? 'bottom-full mb-1'
: 'top-full mt-1'
}`}>
<button
onClick={() => handleHeadingSelect(0)}
className={`w-full px-3 py-1.5 text-left text-xs hover:bg-slate-700/50 transition-colors ${
currentLevel === 0 ? 'text-teal-400' : 'text-slate-300'
}`}
>
Text
</button>
{[1, 2, 3, 4, 5, 6].map((level) => (
<button
key={level}
onClick={() => handleHeadingSelect(level)}
className={`w-full px-3 py-1.5 text-left text-xs font-bold hover:bg-slate-700/50 transition-colors ${
currentLevel === level ? 'text-teal-400' : 'text-slate-300'
}`}
>
H{level}
</button>
))}
</div>
)}
</div>
{/* AI Features */}
<div className="relative" ref={aiDropdownRef}>
<button
data-ai-select
onClick={toggleAIDropdown}
disabled={isProcessingAI}
className={`px-2 py-1 rounded-md hover:bg-slate-700/50 transition-colors text-xs font-medium flex items-center gap-1 ${
isProcessingAI ? 'text-slate-500 cursor-not-allowed' : 'text-purple-400 hover:text-purple-300'
}`}
title="AI Edit"
>
<IoSparklesOutline className="w-3 h-3" />
AI
<IoChevronDownOutline className={`w-3 h-3 transition-transform ${isAIDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{/* AI Dropdown Menu */}
{isAIDropdownOpen && (
<div className={`absolute left-0 bg-slate-800 border border-slate-700 rounded-md shadow-xl z-50 min-w-[200px] ${
aiDropdownPosition === 'top'
? 'bottom-full mb-1'
: 'top-full mt-1'
}`}>
{/* Model Selection */}
<div className="p-2 border-b border-slate-700">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-slate-400 font-medium">Model</div>
<button
onClick={() => setShowModelSelector(!showModelSelector)}
className="text-xs text-purple-400 hover:text-purple-300 flex items-center gap-1"
>
<IoSettingsOutline className="w-3 h-3" />
{showModelSelector ? 'Hide' : 'Change'}
</button>
</div>
{showModelSelector ? (
<div className="space-y-1">
{editingModels.map((model) => (
<button
key={model.id}
onClick={() => {
setSelectedModel(model.id)
setShowModelSelector(false)
}}
className={`w-full px-2 py-1 text-xs text-left rounded transition-colors ${
selectedModel === model.id
? 'bg-purple-600 text-white'
: 'text-slate-300 hover:bg-slate-700/50'
}`}
>
<div className="font-medium">{model.name}</div>
<div className="text-xs opacity-75">
{model.tier === 'fast' ? '⚡ Fast' : '🧠 Smart'} • {model.provider}
</div>
</button>
))}
</div>
) : (
<div className="text-xs text-slate-300">
<div className="font-medium">{currentModelConfig.name}</div>
<div className="text-xs opacity-75">
{currentModelConfig.tier === 'fast' ? '⚡ Fast' : '🧠 Smart'} • {currentModelConfig.provider}
</div>
</div>
)}
</div>
{/* Preset Actions */}
<div className="p-2 border-b border-slate-700">
<div className="text-xs text-slate-400 mb-2 font-medium">Quick Actions</div>
<div className="grid grid-cols-2 gap-1">
<button
onClick={() => handleAIAction('Make this text shorter and more concise')}
className="px-2 py-1 text-xs text-slate-300 hover:bg-slate-700/50 rounded transition-colors text-left"
title="Make the selected text shorter and more concise"
>
Shorter
</button>
<button
onClick={() => handleAIAction('Expand this text with more details and examples')}
className="px-2 py-1 text-xs text-slate-300 hover:bg-slate-700/50 rounded transition-colors text-left"
title="Expand the selected text with more details and examples"
>
Longer
</button>
<button
onClick={() => handleAIAction('Rewrite this text in a formal, professional tone')}
className="px-2 py-1 text-xs text-slate-300 hover:bg-slate-700/50 rounded transition-colors text-left"
title="Rewrite the selected text in a formal, professional tone"
>
Formal
</button>
<button
onClick={() => handleAIAction('Rewrite this text in a casual, conversational tone')}
className="px-2 py-1 text-xs text-slate-300 hover:bg-slate-700/50 rounded transition-colors text-left"
title="Rewrite the selected text in a casual, conversational tone"
>
Informal
</button>
</div>
</div>
{/* Custom Prompt */}
<div className="p-2">
<div className="text-xs text-slate-400 mb-2 font-medium">Custom Prompt</div>
<div className="flex gap-1">
<input
ref={customPromptInputRef}
type="text"
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="Describe how to edit..."
className="flex-1 px-2 py-1 text-xs bg-slate-700 border border-slate-600 rounded text-slate-200 placeholder-slate-400 focus:outline-none focus:border-purple-400"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCustomPrompt()
}
}}
/>
<button
onClick={handleCustomPrompt}
disabled={!customPrompt.trim()}
className={`px-2 py-1 rounded text-xs transition-colors ${
customPrompt.trim()
? 'bg-purple-600 text-white hover:bg-purple-500'
: 'bg-slate-700 text-slate-500 cursor-not-allowed'
}`}
title="Apply custom prompt"
>
<IoArrowForwardOutline className="w-3 h-3" />
</button>
</div>
</div>
</div>
)}
</div>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`p-1.5 rounded-md hover:bg-slate-700/50 transition-colors ${
editor.isActive('bulletList') ? 'text-teal-400' : 'text-slate-400 hover:text-slate-300'
}`}
title="Bullet List"
>
<IoListOutline className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`p-1.5 rounded-md hover:bg-slate-700/50 transition-colors ${
editor.isActive('orderedList') ? 'text-teal-400' : 'text-slate-400 hover:text-slate-300'
}`}
title="Numbered List"
>
<BsListOl className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleTaskList().run()}
className={`p-1.5 rounded-md hover:bg-slate-700/50 transition-colors ${
editor.isActive('taskList') ? 'text-teal-400' : 'text-slate-400 hover:text-slate-300'
}`}
title="Task List"
>
<IoCheckboxOutline className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={`p-1.5 rounded-md hover:bg-slate-700/50 transition-colors ${
editor.isActive('blockquote') ? 'text-teal-400' : 'text-slate-400 hover:text-slate-300'
}`}
title="Quote"
>
<IoChatbubbleEllipsesOutline className="w-4 h-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={`p-1.5 rounded-md hover:bg-slate-700/50 transition-colors ${
editor.isActive('codeBlock') ? 'text-teal-400' : 'text-slate-400 hover:text-slate-300'
}`}
title="Code Block"
>
<IoCodeSlashOutline className="w-4 h-4" />
</button>
</div>
</BubbleMenu>
<LinkDialog
isOpen={isLinkDialogOpen}
onClose={() => setIsLinkDialogOpen(false)}
onConfirm={handleLinkConfirm}
onRemove={handleLinkRemove}
initialUrl={editor.getAttributes('link').href}
/>
</>
)
}