feat: page interactive, démos Play/Step et simulateur Carnot
Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
||||
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
||||
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
||||
import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
|
||||
import { InteractiveDemoExtension, insertInteractiveDemoAtSelection } from './tiptap-interactive-demo-extension'
|
||||
import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
|
||||
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
|
||||
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
|
||||
@@ -70,7 +71,7 @@ import {
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2, Clapperboard
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -298,6 +299,9 @@ const slashCommands: SlashItem[] = [
|
||||
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
|
||||
command: (e) => { insertStructuredViewBlockAtSelection(e) },
|
||||
},
|
||||
{
|
||||
title: 'Interactive Demo', description: 'AI pedagogical step-by-step demo', icon: Clapperboard, category: 'IA Note', isAi: true, command: () => {},
|
||||
},
|
||||
{
|
||||
title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>',
|
||||
command: (e) => { insertToggleBlock(e) },
|
||||
@@ -385,7 +389,7 @@ function useImageInsert() {
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
|
||||
@@ -589,6 +593,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
UndoRedoFeedbackExtension,
|
||||
LiveBlockExtension,
|
||||
StructuredViewBlockExtension,
|
||||
InteractiveDemoExtension,
|
||||
ToggleExtension,
|
||||
CalloutExtension,
|
||||
OutlineExtension,
|
||||
@@ -951,6 +956,100 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
setChartSuggestionsOpen(true)
|
||||
}, [editor, requestAiConsent])
|
||||
|
||||
const handleGenerateInteractiveDemo = useCallback(async () => {
|
||||
if (!editor || !editor.isEditable) return
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
// Prefer HTML so TipTap math nodes (data-latex) reach formula extractors
|
||||
let content = ''
|
||||
let selection: string | null = null
|
||||
try {
|
||||
content = editor.getHTML() || editor.state.doc.textContent || ''
|
||||
const { from, to, empty } = editor.state.selection
|
||||
if (!empty) {
|
||||
const parts: string[] = []
|
||||
editor.state.doc.nodesBetween(from, to, (node) => {
|
||||
const latex = (node.attrs as { latex?: string } | undefined)?.latex
|
||||
if (typeof latex === 'string' && latex.trim()) {
|
||||
parts.push(
|
||||
node.type.name === 'mathEquationBlock'
|
||||
? `$$${latex}$$`
|
||||
: `$${latex}$`
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (node.isText && node.text) {
|
||||
parts.push(node.text)
|
||||
}
|
||||
return true
|
||||
})
|
||||
selection = parts.join(' ').trim() || null
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[interactive-demo] content extract fallback', err)
|
||||
content = editor.state.doc.textContent || ''
|
||||
}
|
||||
|
||||
const sourceForWords = (selection || editor.state.doc.textContent || '').trim()
|
||||
const words = sourceForWords.split(/\s+/).filter(Boolean).length
|
||||
if (words < 20) {
|
||||
toast.error(
|
||||
t('interactiveDemo.needMoreText') ||
|
||||
'Sélectionne au moins ~20 mots (ou écris plus de contenu) pour générer une démo'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const toastId = toast.loading(
|
||||
t('interactiveDemo.generating') || 'Génération de la démo interactive…'
|
||||
)
|
||||
try {
|
||||
const { generateInteractiveDemo } = await import(
|
||||
'@/lib/ai/services/interactive-demo-client.service'
|
||||
)
|
||||
const result = await generateInteractiveDemo({
|
||||
content,
|
||||
selection,
|
||||
lang: language || 'fr',
|
||||
noteId: noteId || undefined,
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (result.quotaExceeded) {
|
||||
toast.error(t('interactiveDemo.quotaExceeded') || 'Quota IA insuffisant', {
|
||||
id: toastId,
|
||||
})
|
||||
} else {
|
||||
toast.error(
|
||||
typeof result.error === 'string'
|
||||
? result.error
|
||||
: t('interactiveDemo.generateFailed') || 'Échec de la génération',
|
||||
{ id: toastId }
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!insertInteractiveDemoAtSelection(editor, result.demo)) {
|
||||
toast.error(
|
||||
t('interactiveDemo.insertFailed') || 'Impossible d’insérer la démo',
|
||||
{ id: toastId }
|
||||
)
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
toast.success(
|
||||
t('interactiveDemo.generateSuccess') || 'Démo interactive insérée',
|
||||
{ id: toastId }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[interactive-demo] generate error', err)
|
||||
toast.error(
|
||||
t('interactiveDemo.generateFailed') || 'Échec de la génération',
|
||||
{ id: toastId }
|
||||
)
|
||||
}
|
||||
}, [editor, requestAiConsent, t, language, noteId])
|
||||
|
||||
const insertCitationInEditor = useCallback((
|
||||
payload: { noteId: string; noteTitle: string; excerpt: string },
|
||||
options?: { atEnd?: boolean }
|
||||
@@ -1321,7 +1420,14 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
|
||||
{editor && (
|
||||
<SlashCommandMenu
|
||||
editor={editor}
|
||||
onInsertImage={imageInsert.requestInsert}
|
||||
onSuggestCharts={handleOpenChartSuggestions}
|
||||
onGenerateInteractiveDemo={handleGenerateInteractiveDemo}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
|
||||
|
||||
@@ -1910,7 +2016,7 @@ function SlashPreview({ itemTitle, t }: { itemTitle: string; t: (k: string) => s
|
||||
}
|
||||
}
|
||||
|
||||
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
|
||||
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void; onGenerateInteractiveDemo: () => void }) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@@ -1968,6 +2074,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
|
||||
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
|
||||
{ ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
|
||||
{ ...sc('Interactive Demo'), title: t('richTextEditor.slashInteractiveDemo') || 'Démo interactive', description: t('richTextEditor.slashInteractiveDemoDesc') || 'Démo pédagogique étape par étape', categoryId: 'ai', slashKeywords: ['demo', 'interactive', 'attn', 'tutorial', 'démo', 'demo interactive'] },
|
||||
{ ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
|
||||
{ ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
|
||||
{ ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] },
|
||||
@@ -2083,6 +2190,11 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA')
|
||||
) {
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else if (
|
||||
item.title === 'Interactive Demo'
|
||||
|| item.title === (t('richTextEditor.slashInteractiveDemo') || 'Démo interactive')
|
||||
) {
|
||||
deleteSlashText(); closeMenu(); onGenerateInteractiveDemo()
|
||||
} else if (item.title === t('richTextEditor.slashDatabase')) {
|
||||
deleteSlashText(); closeMenu()
|
||||
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
|
||||
@@ -2092,7 +2204,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
} else {
|
||||
deleteSlashText(); item.command(editor); closeMenu()
|
||||
}
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent])
|
||||
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo, t, requestAiConsent])
|
||||
|
||||
// Charger les favoris fréquents lors de l'ouverture
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user