feat(ai): add AI chart suggestions in TipTap editor
Implement slash command "/suggest-charts" that analyzes note content and suggests 3 appropriate chart types with visual previews. Created: - chart-suggestion.tool.ts: AI tool for data extraction and chart recommendations - suggest-charts/route.ts: API endpoint for chart suggestions - chart-suggestion.service.ts: Frontend service layer - chart-suggestions-dialog.tsx: Modal with 3 chart proposals and thumbnails - tiptap-chart-extension.tsx: TipTap Node extension for rendering chart blocks Modified: - rich-text-editor.tsx: Added slash command, toolbar button, and dialog integration Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,8 @@ import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import Superscript from '@tiptap/extension-superscript'
|
||||
import Subscript from '@tiptap/extension-subscript'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import { ChartExtension } from './tiptap-chart-extension'
|
||||
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
import {
|
||||
@@ -30,7 +32,7 @@ import {
|
||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen, Presentation
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -140,6 +142,11 @@ const slashCommands: SlashItem[] = [
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', icon: BarChart3, category: 'IA Note', isAi: true, command: (e) => {
|
||||
// Handler will be called by SlashCommandMenu
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||
@@ -218,6 +225,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
Superscript,
|
||||
Subscript,
|
||||
Typography,
|
||||
ChartExtension,
|
||||
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
||||
],
|
||||
content: content || '',
|
||||
@@ -255,15 +263,62 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
|
||||
const lastEmittedContent = useRef<string>(content || '')
|
||||
|
||||
// Chart suggestions dialog state
|
||||
const [chartSuggestionsOpen, setChartSuggestionsOpen] = useState(false)
|
||||
const [currentNoteContent, setCurrentNoteContent] = useState(content || '')
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content !== undefined && content !== lastEmittedContent.current) {
|
||||
editor.commands.setContent(content || '')
|
||||
lastEmittedContent.current = content || ''
|
||||
}
|
||||
// Update current note content for chart suggestions
|
||||
if (content !== undefined) {
|
||||
setCurrentNoteContent(content || '')
|
||||
}
|
||||
}, [content, editor])
|
||||
|
||||
useImperativeHandle(ref, () => ({ getEditor: () => editor }), [editor])
|
||||
|
||||
// Chart suggestion handlers
|
||||
const handleOpenChartSuggestions = useCallback(() => {
|
||||
if (!editor || !editor.isEditable) return
|
||||
|
||||
// Get current selection text if any
|
||||
const { from, to, empty } = editor.state.selection
|
||||
const selectionText = !empty ? editor.state.doc.textBetween(from, to, ' ') : null
|
||||
|
||||
setChartSuggestionsOpen(true)
|
||||
}, [editor])
|
||||
|
||||
const handleSelectChart = useCallback((markdown: string) => {
|
||||
if (!editor || !editor.isEditable) return
|
||||
|
||||
try {
|
||||
// Insert the chart markdown at current position
|
||||
const { from } = editor.state.selection
|
||||
|
||||
// Check if we're in the middle of text, if so create a new paragraph
|
||||
const $pos = editor.state.doc.resolve(from)
|
||||
const isInTextNode = $pos.parent.type.name === 'paragraph'
|
||||
|
||||
if (isInTextNode && $pos.parentOffset > 0) {
|
||||
// Create a new paragraph after current one
|
||||
const afterPos = $pos.after()
|
||||
// Ensure we don't exceed document bounds
|
||||
if (afterPos < editor.state.doc.content.size) {
|
||||
editor.chain().focus().selectParentNode().insertContentAt(afterPos, '<p></p>').run()
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the chart
|
||||
editor.chain().focus().insertContent(markdown).run()
|
||||
} catch (error) {
|
||||
console.error('[handleSelectChart] Failed to insert chart:', error)
|
||||
toast.error('Failed to insert chart. Please try again.')
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<div className={cn('notion-editor-wrapper', className)}>
|
||||
{editor && (
|
||||
@@ -287,13 +342,23 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} />}
|
||||
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{imageInsert.open && (
|
||||
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
|
||||
)}
|
||||
|
||||
{chartSuggestionsOpen && (
|
||||
<ChartSuggestionsDialog
|
||||
isOpen={chartSuggestionsOpen}
|
||||
content={currentNoteContent}
|
||||
selection={editor.state.selection.empty ? null : editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' ')}
|
||||
onClose={() => setChartSuggestionsOpen(false)}
|
||||
onSelectChart={handleSelectChart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -539,7 +604,7 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertImage: (editor: Editor) => void }) {
|
||||
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
|
||||
const { t } = useLanguage()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -580,6 +645,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'formatting' },
|
||||
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[28], title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', categoryId: 'ai' },
|
||||
]
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
@@ -615,10 +681,12 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
toastAi(err)
|
||||
}
|
||||
finally { setAiLoading(false) }
|
||||
} else if (item.title === 'Suggest Charts') {
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else {
|
||||
deleteSlashText(); item.command(editor); closeMenu()
|
||||
}
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, t])
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t])
|
||||
|
||||
const presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
|
||||
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
|
||||
|
||||
Reference in New Issue
Block a user