bookwiz.io / components / SlashCommandMenu.tsx
SlashCommandMenu.tsx
Raw
'use client'

import { forwardRef, useEffect, useImperativeHandle, useState, useRef } from 'react'
import { Editor } from '@tiptap/react'
import { useUser } from '@/lib/hooks/useUser'
import { MODELS } from '@/lib/config/models'
import { IoSettingsOutline, IoArrowForwardOutline } from 'react-icons/io5'

interface CommandItem {
  title: string
  description: string
  icon: string
  command: () => void
  aliases?: string[]
}

interface SlashCommandMenuProps {
  editor: Editor
  query: string
  range: { from: number; to: number }
  onItemSelect?: () => void
}

export interface SlashCommandMenuRef {
  onKeyDown: (event: KeyboardEvent) => boolean
}

const SlashCommandMenu = forwardRef<SlashCommandMenuRef, SlashCommandMenuProps>(
  ({ editor, query, range, onItemSelect }, ref) => {
    const { user } = useUser()
    const [selectedIndex, setSelectedIndex] = useState(0)
    const [isAIGenerating, setIsAIGenerating] = useState(false)
    const [showAIPrompt, setShowAIPrompt] = useState(false)
    const [aiPrompt, setAiPrompt] = useState('')
    const [selectedModel, setSelectedModel] = useState('gpt-4o-mini') // Default fast model
    const [showModelSelector, setShowModelSelector] = useState(false)
    const scrollContainerRef = useRef<HTMLDivElement>(null)
    const selectedItemRef = useRef<HTMLButtonElement>(null)
    const promptInputRef = useRef<HTMLInputElement>(null)

    // Helper function to detect current formatting context at cursor position
    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 handleAIGeneration = async (customPrompt?: string) => {
      if (!user?.id) {
        // If no user, just insert a placeholder
        editor.chain().focus().deleteRange(range).insertContent('Ask AI to... (Please log in to use AI features)').run()
        onItemSelect?.()
        return
      }

      const finalPrompt = customPrompt || aiPrompt || 'Write creative, engaging content. Be helpful and informative.'
      
      setIsAIGenerating(true)
      setShowAIPrompt(false)
      
      try {
        // Get the position where we'll insert content
        const insertPosition = range.from
        
        // Delete the slash command
        editor.chain().focus().deleteRange(range).run()
        
        // Get context around the cursor position for better AI generation
        const contextSize = 200
        const precedingText = editor.state.doc.textBetween(
          Math.max(0, insertPosition - contextSize), 
          insertPosition
        ).trim()
        const followingText = editor.state.doc.textBetween(
          insertPosition, 
          Math.min(editor.state.doc.content.size, insertPosition + contextSize)
        ).trim()
        
        // Get formatting context at cursor position
        const formattingContext = getFormattingContext()
        
        // Build context-aware prompt with formatting preservation
        const contextPrompt = `Context:
Preceding text: "${precedingText}"
Following text: "${followingText}"
Current formatting: ${formattingContext}

Instruction: ${finalPrompt}

IMPORTANT: You are generating content at a position that is formatted as ${formattingContext}. Please generate content that matches this formatting structure:
- If it's a bullet list item, generate ONLY the text content for the list item (no bullets, no dashes, no numbers)
- If it's a numbered list item, generate ONLY the text content for the list item (no bullets, no dashes, no numbers)
- If it's a heading level X, generate content suitable for that heading level
- If it's within a blockquote, generate content that fits a blockquote style
- If it's a code block, generate code content
- If it's a task list item, generate ONLY the text content for the task item (no checkboxes, no bullets)
- If it's a table cell, generate content suitable for a table cell (concise, structured)
- If it's a paragraph, generate well-structured paragraph content

CRITICAL: Do not include list markers (-, *, 1., etc.) in your response when generating content for list items. The formatting structure is already provided by the editor.

Please generate content that flows naturally with the surrounding context and maintains the formatting structure.`
        
        // Call AI generation API with streaming
        const response = await fetch('/api/ai/generate-text', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            prompt: contextPrompt,
            userId: user.id,
            model: selectedModel,
            stream: true
          })
        })

        if (!response.ok) {
          const errorData = await response.json()
          throw new Error(errorData.error || 'Failed to generate content')
        }

        // 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 content each time
                  accumulatedContent += parsed.content
                  
                  // Replace the entire content from insert position
                  try {
                    const currentDocSize = editor.state.doc.content.size
                    const safeFrom = Math.min(insertPosition, 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(insertPosition, Math.min(insertPosition + (accumulatedContent.length - parsed.content.length), editor.state.doc.content.size))
                      .insert(insertPosition, 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 generation error:', error)
        // Insert error message at the position
        const errorMessage = '❌ Failed to generate content. Please try again.'
        editor.chain().focus().setTextSelection(range.from).insertContent(errorMessage).run()
      } finally {
        setIsAIGenerating(false)
        setAiPrompt('')
        onItemSelect?.()
      }
    }

    const commands: CommandItem[] = [
      {
        title: 'Ask AI to...',
        description: 'Generate content with AI',
        icon: '🤖',
        command: () => {
          setShowAIPrompt(true)
          // Focus the prompt input after a short delay
          setTimeout(() => {
            promptInputRef.current?.focus()
          }, 100)
        },
        aliases: ['ai', 'generate', 'write', 'create', 'ask']
      },
      {
        title: 'Heading 1',
        description: 'Large heading',
        icon: '📝',
        command: () => {
          editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run()
        },
        aliases: ['h1', 'heading1', 'title']
      },
      {
        title: 'Heading 2',
        description: 'Medium heading',
        icon: '📄',
        command: () => {
          editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run()
        },
        aliases: ['h2', 'heading2', 'subtitle']
      },
      {
        title: 'Heading 3',
        description: 'Small heading',
        icon: '📃',
        command: () => {
          editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run()
        },
        aliases: ['h3', 'heading3']
      },
      {
        title: 'Paragraph',
        description: 'Regular text',
        icon: '📝',
        command: () => {
          editor.chain().focus().deleteRange(range).setParagraph().run()
        },
        aliases: ['p', 'paragraph', 'text']
      },
      {
        title: 'Bullet List',
        description: 'Create a bulleted list',
        icon: '• ',
        command: () => {
          editor.chain().focus().deleteRange(range).toggleBulletList().run()
        },
        aliases: ['ul', 'list', 'bullet', 'unordered']
      },
      {
        title: 'Numbered List',
        description: 'Create a numbered list',
        icon: '1.',
        command: () => {
          editor.chain().focus().deleteRange(range).toggleOrderedList().run()
        },
        aliases: ['ol', 'numbered', 'ordered']
      },
      {
        title: 'Task List',
        description: 'Create a checklist',
        icon: '☑️',
        command: () => {
          editor.chain().focus().deleteRange(range).toggleTaskList().run()
        },
        aliases: ['todo', 'checklist', 'task', 'checkbox']
      },
      {
        title: 'Quote',
        description: 'Create a blockquote',
        icon: '"',
        command: () => {
          editor.chain().focus().deleteRange(range).setBlockquote().run()
        },
        aliases: ['blockquote', 'citation']
      },
      {
        title: 'Code Block',
        description: 'Create a code block',
        icon: '💻',
        command: () => {
          editor.chain().focus().deleteRange(range).setCodeBlock().run()
        },
        aliases: ['code', 'codeblock', 'pre']
      },
      {
        title: 'Divider',
        description: 'Add a horizontal line',
        icon: '—',
        command: () => {
          editor.chain().focus().deleteRange(range).setHorizontalRule().run()
        },
        aliases: ['hr', 'line', 'separator', 'break']
      },
      {
        title: 'Table',
        description: 'Insert a table',
        icon: '📊',
        command: () => {
          editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
        },
        aliases: ['grid']
      }
    ]

    // Filter commands based on query
    const filteredCommands = commands.filter(command => {
      const searchQuery = query.toLowerCase()
      return (
        command.title.toLowerCase().includes(searchQuery) ||
        command.description.toLowerCase().includes(searchQuery) ||
        (command.aliases && command.aliases.some(alias => alias.includes(searchQuery)))
      )
    })

    useEffect(() => {
      setSelectedIndex(0)
    }, [query])

    // Auto-focus custom prompt input when AI dropdown opens
    useEffect(() => {
      if (showAIPrompt && promptInputRef.current) {
        // Small delay to ensure the dropdown is fully rendered
        setTimeout(() => {
          promptInputRef.current?.focus()
        }, 100)
      }
    }, [showAIPrompt])

    // Scroll selected item into view when selectedIndex changes
    useEffect(() => {
      if (selectedItemRef.current) {
        selectedItemRef.current.scrollIntoView({
          behavior: 'smooth',
          block: 'nearest',
        })
      }
    }, [selectedIndex])

    const selectItem = (index: number) => {
      const item = filteredCommands[index]
      if (item) {
        item.command()
        onItemSelect?.()
      }
    }

    const upHandler = () => {
      setSelectedIndex((selectedIndex + filteredCommands.length - 1) % filteredCommands.length)
    }

    const downHandler = () => {
      setSelectedIndex((selectedIndex + 1) % filteredCommands.length)
    }

    const enterHandler = () => {
      selectItem(selectedIndex)
    }

    useImperativeHandle(ref, () => ({
      onKeyDown: (event: KeyboardEvent) => {
        if (event.key === 'ArrowUp') {
          upHandler()
          return true
        }

        if (event.key === 'ArrowDown') {
          downHandler()
          return true
        }

        if (event.key === 'Enter') {
          enterHandler()
          return true
        }

        if (event.key === 'Escape') {
          onItemSelect?.()
          return true
        }

        return false
      }
    }))

    if (showAIPrompt) {
        // 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 (
          <div className="slash-command-menu" ref={scrollContainerRef}>
            <div className="bg-slate-800 border border-slate-700 rounded-md shadow-xl w-[280px] max-w-[90vw]">
            {/* 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>
              
              <div className="text-xs text-slate-300 font-medium">
                {currentModelConfig.name}
              </div>
              
              {showModelSelector && (
                <div className="mt-2 max-h-32 overflow-y-auto">
                  {editingModels.map((model) => (
                    <button
                      key={model.id}
                      onClick={() => {
                        setSelectedModel(model.id)
                        setShowModelSelector(false)
                      }}
                      className={`w-full text-left px-2 py-1 text-xs rounded hover:bg-slate-700/50 transition-colors ${
                        selectedModel === model.id ? 'bg-purple-600/20 text-purple-300' : 'text-slate-300'
                      }`}
                    >
                      <div className="font-medium">{model.name}</div>
                      <div className="text-slate-400 text-[10px]">{model.description}</div>
                    </button>
                  ))}
                </div>
              )}
            </div>

            {/* Quick 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={() => handleAIGeneration('Write a well-structured paragraph with engaging content')}
                  className="px-2 py-1 text-xs text-slate-300 hover:bg-slate-700/50 rounded transition-colors text-left"
                  title="Generate a paragraph of content"
                >
                  Paragraph
                </button>
                <button
                  onClick={() => handleAIGeneration('Create a bulleted list with relevant items')}
                  className="px-2 py-1 text-xs text-slate-300 hover:bg-slate-700/50 rounded transition-colors text-left"
                  title="Generate a bulleted list"
                >
                  List
                </button>
                <button
                  onClick={() => handleAIGeneration('Write natural, engaging dialogue between characters')}
                  className="px-2 py-1 text-xs text-slate-300 hover:bg-slate-700/50 rounded transition-colors text-left"
                  title="Generate character dialogue"
                >
                  Dialogue
                </button>
                <button
                  onClick={() => handleAIGeneration('Write a vivid, detailed scene description')}
                  className="px-2 py-1 text-xs text-slate-300 hover:bg-slate-700/50 rounded transition-colors text-left"
                  title="Generate a scene description"
                >
                  Scene
                </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={promptInputRef}
                  type="text"
                  value={aiPrompt}
                  onChange={(e) => setAiPrompt(e.target.value)}
                  placeholder="Describe what you want AI to write..."
                  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' && aiPrompt.trim()) {
                      handleAIGeneration()
                    } else if (e.key === 'Escape') {
                      setShowAIPrompt(false)
                      setAiPrompt('')
                      setShowModelSelector(false)
                    }
                  }}
                />
                <button
                  onClick={() => handleAIGeneration()}
                  disabled={!aiPrompt.trim() || isAIGenerating}
                  className={`px-2 py-1 rounded text-xs transition-colors ${
                    aiPrompt.trim() && !isAIGenerating
                      ? '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>

            {/* Back button */}
            <div className="p-2 pt-0">
              <button
                onClick={() => {
                  setShowAIPrompt(false)
                  setAiPrompt('')
                  setShowModelSelector(false)
                }}
                className="text-xs text-slate-400 hover:text-slate-300"
              >
                 Back to commands
              </button>
            </div>
          </div>
        </div>
      )
    }

    if (filteredCommands.length === 0) {
      return (
        <div className="slash-command-menu" ref={scrollContainerRef}>
          <div className="slash-command-item">
            <span className="text-slate-400 text-sm">No commands found</span>
          </div>
        </div>
      )
    }

    return (
      <div className="slash-command-menu" ref={scrollContainerRef}>
        {filteredCommands.map((item, index) => (
          <button
            key={item.title}
            ref={index === selectedIndex ? selectedItemRef : null}
            className={`slash-command-item ${index === selectedIndex ? 'selected' : ''}`}
            onClick={() => selectItem(index)}
            onMouseEnter={() => setSelectedIndex(index)}
            disabled={isAIGenerating && item.title === 'Ask AI to...'}
          >
            <span className="slash-command-icon">{item.icon}</span>
            <div className="slash-command-content">
              <div className="slash-command-title">
                {item.title}
                {isAIGenerating && item.title === 'Ask AI to...' && (
                  <span className="ml-2 text-xs text-purple-400">Generating...</span>
                )}
              </div>
              <div className="slash-command-description">{item.description}</div>
            </div>
          </button>
        ))}
      </div>
    )
  }
)

SlashCommandMenu.displayName = 'SlashCommandMenu'

export default SlashCommandMenu