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:
Antigravity
2026-05-23 08:58:46 +00:00
parent 4e8f45deae
commit a122a0eade
6 changed files with 845 additions and 4 deletions

View File

@@ -0,0 +1,244 @@
'use client'
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { NoteChartFromCode } from './note-chart'
import { suggestCharts, chartSuggestionToMarkdown, type ChartSuggestion, type SuggestChartsResponse } from '@/lib/ai/services/chart-suggestion.service'
import { BarChart3, X, Search, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ChartSuggestionsDialogProps {
isOpen: boolean
content: string
selection: string | null
noteId?: string
onClose: () => void
onSelectChart: (markdown: string) => void
}
export function ChartSuggestionsDialog({
isOpen,
content,
selection,
noteId,
onClose,
onSelectChart,
}: ChartSuggestionsDialogProps) {
const [response, setResponse] = useState<SuggestChartsResponse | null>(null)
const [loading, setLoading] = useState(false)
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
// Reset state when dialog opens
useEffect(() => {
let aborted = false
if (isOpen) {
setResponse(null)
setLoading(true)
setSelectedIndex(null)
// Call the suggestion API
suggestCharts({ content, selection, noteId })
.then(data => {
if (aborted) return
setResponse(data)
setLoading(false)
})
.catch(err => {
if (aborted) return
console.error('[ChartSuggestionsDialog] Error:', err)
setResponse({
suggestions: [],
analyzedText: '',
detectedData: '',
hasData: false,
error: err.message || 'Failed to load suggestions',
})
setLoading(false)
})
}
return () => {
aborted = true
}
}, [isOpen, content, selection, noteId])
// Handle keyboard shortcuts
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
if (e.key === 'ArrowRight' && response?.suggestions) {
setSelectedIndex(i => i === null ? 0 : (i + 1) % response.suggestions.length)
}
if (e.key === 'ArrowLeft' && response?.suggestions) {
setSelectedIndex(i => i === null ? 0 : (i === 0 ? response.suggestions.length - 1 : i - 1))
}
if (e.key === 'Enter' && selectedIndex !== null && response?.suggestions?.[selectedIndex]) {
onSelectChart(chartSuggestionToMarkdown(response.suggestions[selectedIndex]))
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose, selectedIndex, response, onSelectChart])
if (!isOpen) return null
const textToAnalyze = selection || content
const isAnalyzingSelection = !!selection
const wordCount = textToAnalyze.split(/\s+/).length
const handleSelectChart = (suggestion: ChartSuggestion) => {
onSelectChart(chartSuggestionToMarkdown(suggestion))
onClose()
}
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
<div
className="bg-background rounded-xl shadow-lg w-full max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b">
<div className="flex items-center gap-3">
<BarChart3 className="w-5 h-5 text-primary" />
<div>
<h2 className="text-lg font-semibold">Chart Suggestions</h2>
<p className="text-sm text-muted-foreground">
{loading ? (
<span className="flex items-center gap-2">
<Search className="w-3 h-3 animate-spin" />
Analyzing {isAnalyzingSelection ? 'selection' : 'note'} ({wordCount} words)...
</span>
) : response?.hasData ? (
`Found ${response?.detectedData || 'data'}`
) : (
'No data detected'
)}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-muted rounded-lg transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
<p className="text-muted-foreground">Analyzing your content for chart data...</p>
</div>
</div>
) : response?.error ? (
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
<h3 className="text-lg font-semibold mb-2">Error</h3>
<p className="text-muted-foreground">{response.error}</p>
</div>
</div>
) : !response?.hasData ? (
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold mb-2">No Data Detected</h3>
<p className="text-muted-foreground mb-4">
Try adding numerical data to your note. Charts work best with:
</p>
<ul className="text-sm text-muted-foreground text-left inline-block">
<li> Sales figures, metrics, or measurements</li>
<li> Lists with values (e.g., "Jan: 5000, Feb: 7500")</li>
<li> Percentages or proportions</li>
<li> Time-series data</li>
</ul>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Select a chart type to insert:
</p>
{/* Chart suggestions grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{response.suggestions.map((suggestion, index) => {
const isSelected = selectedIndex === index
const markdown = chartSuggestionToMarkdown(suggestion)
return (
<button
key={index}
onClick={() => handleSelectChart(suggestion)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
'text-left p-4 rounded-xl border-2 transition-all hover:shadow-md',
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
)}
>
{/* Mini thumbnail */}
<div className="aspect-video bg-card rounded-lg mb-3 overflow-hidden flex items-center justify-center p-2">
<div className="w-full scale-75 origin-center">
<NoteChartFromCode code={markdown} />
</div>
</div>
{/* Chart type badge */}
<div className="mb-2">
<span className="text-xs font-medium px-2 py-1 bg-primary/10 text-primary rounded-full">
{suggestion.type}
</span>
</div>
{/* Title */}
<h3 className="font-semibold text-sm mb-1">{suggestion.title}</h3>
{/* Description */}
<p className="text-xs text-muted-foreground mb-2">{suggestion.description}</p>
{/* Rationale */}
{suggestion.rationale && (
<p className="text-xs text-muted-foreground/70 italic">
"{suggestion.rationale}"
</p>
)}
{/* Data preview */}
<div className="mt-2 pt-2 border-t border-border/50">
<p className="text-xs text-muted-foreground">
{suggestion.data.length} data points
</p>
</div>
</button>
)
})}
</div>
{/* Keyboard hint */}
<div className="flex items-center justify-center gap-4 pt-4 text-xs text-muted-foreground">
<span> Navigate</span>
<span> Select</span>
<span>Esc Cancel</span>
</div>
</div>
)}
</div>
</div>
</div>,
document.body
)
}

View File

@@ -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))

View File

@@ -0,0 +1,171 @@
'use client'
import { Node } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from '@tiptap/react'
import { NoteChartFromCode } from './note-chart'
import { useState } from 'react'
import { Code, BarChart3, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
/**
* ChartExtension - TipTap Node extension for rendering chart blocks
*
* Detects <pre><code class="language-chart"> blocks and renders them
* as visual charts using the NoteChartFromCode component.
*/
export const ChartExtension = Node.create({
name: 'chartBlock',
group: 'block',
code: true,
defining: true,
addOptions() {
return {
HTMLAttributes: {},
}
},
addAttributes() {
return {
code: {
default: '',
parseHTML: element => {
if (element instanceof HTMLElement) {
const codeEl = element.querySelector('code')
return codeEl?.textContent || ''
}
return ''
},
renderHTML: () => ({})
},
language: {
default: 'chart',
parseHTML: element => {
if (element instanceof HTMLElement) {
const codeEl = element.querySelector('code')
// Check for class="language-chart" or data-language="chart"
if (codeEl?.classList.contains('language-chart')) return 'chart'
return element.getAttribute('data-language') || 'chart'
}
return 'chart'
},
renderHTML: attributes => ({
'data-language': attributes.language,
})
}
}
},
parseHTML() {
return [
{
tag: 'pre',
getAttrs: node => {
if (typeof node === 'string') return false
const element = node as HTMLElement
const codeEl = element.querySelector('code')
// Detect chart blocks by class="language-chart"
if (codeEl && codeEl.classList.contains('language-chart')) {
return {}
}
return false
}
}
]
},
renderHTML({ HTMLAttributes }) {
return ['pre', { ...HTMLAttributes, class: 'language-chart' }, ['code', { class: 'language-chart' }, 0]]
},
addNodeView() {
return ReactNodeViewRenderer(ChartBlockView, {
contentEditable: false,
})
}
})
/**
* ChartBlockView - React component for rendering chart blocks in TipTap
*
* Features:
* - Visual chart rendering with NoteChartFromCode
* - Toggle between visual and code view
* - Edit mode for modifying chart data
* - Error handling for invalid chart data
*/
function ChartBlockView(props: any) {
const [isEditing, setIsEditing] = useState(false)
const [parseError, setParseError] = useState(false)
const code = props.node?.attrs?.code || ''
// Check if chart code is valid when not editing
const isValidChart = !isEditing && code.trim().length > 0
if (isEditing) {
return (
<NodeViewWrapper className="notion-chart-code-block my-4">
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border-b border-border">
<Code className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Chart Code</span>
<button
onClick={() => setIsEditing(false)}
className="ml-auto text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
>
Done
</button>
</div>
<NodeViewContent as="pre" className="p-4 bg-muted/30 rounded-b-lg overflow-x-auto" />
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="notion-chart-block my-4 group relative">
<div className="relative">
{/* Chart visual rendering */}
{isValidChart ? (
<NoteChartFromCode code={code} />
) : (
<div className="my-6 rounded-xl border border-dashed border-border bg-muted/30 p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertCircle className="w-5 h-5" />
<div>
<p className="font-medium">Invalid Chart</p>
<p className="text-sm">This chart block contains invalid or empty data.</p>
</div>
</div>
</div>
)}
{/* Edit button - visible on hover */}
<button
onClick={() => setIsEditing(true)}
className={cn(
'absolute top-2 right-2 p-2 rounded-lg bg-background/80 backdrop-blur border border-border shadow-sm opacity-0 group-hover:opacity-100 transition-opacity',
'hover:bg-background hover:shadow-md'
)}
title="Edit chart source"
>
<Code className="w-4 h-4 text-muted-foreground" />
</button>
{/* Chart type indicator */}
{isValidChart && (
<div className="absolute top-2 left-2 px-2 py-1 rounded bg-background/80 backdrop-blur border border-border shadow-sm opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<BarChart3 className="w-3 h-3" />
<span>Chart</span>
</div>
</div>
)}
</div>
</NodeViewWrapper>
)
}