bookwiz.io / components / BubbleMenu.tsx
BubbleMenu.tsx
Raw
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}
      />
    </>
  )
}