'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