Files
Momento/memento-note/components/tiptap-callout-extension.tsx
Antigravity 940c3daf62
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped
feat: Wizard IA + Export PDF + fixes blocs
- Wizard IA: création de carnet complet (étudiant/prof/ingénieur)
  - 3 profils, choix niveau, nombre de notes (3-12)
  - Étapes numérotées, messages de progression, écran de succès
  - Notes riches avec callouts, toggles, équations, colonnes
  - Structured View auto-créé avec propriétés Statut/Difficulté
  - Embeddings en arrière-plan
- Export PDF: clone le DOM réel, rend KaTeX, préserve couleurs callouts
- Fix: boutons toggle/callout en hover-only
- Fix: parsing JSON robuste (backslashes LaTeX)
- Fix: flushSync warning (queueMicrotask)
- Fix: drag handle clamp viewport
- Bouton wizard dans la sidebar ( à côté du +)
- i18n FR/EN complet
2026-06-14 19:51:02 +00:00

203 lines
6.9 KiB
TypeScript

'use client'
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from '@tiptap/react'
import { Info, AlertTriangle, Lightbulb, CheckCircle, XCircle, Trash2, X } from 'lucide-react'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
type CalloutType = 'info' | 'warning' | 'tip' | 'success' | 'danger'
const CALLOUT_STYLES: Record<CalloutType, { bg: string; border: string; icon: string; iconColor: string }> = {
info: { bg: 'bg-blue-50 dark:bg-blue-950/30', border: 'border-blue-300 dark:border-blue-800', icon: 'text-blue-500', iconColor: 'text-blue-500' },
warning: { bg: 'bg-amber-50 dark:bg-amber-950/30', border: 'border-amber-300 dark:border-amber-800', icon: 'text-amber-500', iconColor: 'text-amber-500' },
tip: { bg: 'bg-purple-50 dark:bg-purple-950/30', border: 'border-purple-300 dark:border-purple-800', icon: 'text-purple-500', iconColor: 'text-purple-500' },
success: { bg: 'bg-green-50 dark:bg-green-950/30', border: 'border-green-300 dark:border-green-800', icon: 'text-green-500', iconColor: 'text-green-500' },
danger: { bg: 'bg-red-50 dark:bg-red-950/30', border: 'border-red-300 dark:border-red-800', icon: 'text-red-500', iconColor: 'text-red-500' },
}
const CALLOUT_ICONS: Record<CalloutType, typeof Info> = {
info: Info,
warning: AlertTriangle,
tip: Lightbulb,
success: CheckCircle,
danger: XCircle,
}
const CALLOUT_TYPES: CalloutType[] = ['info', 'warning', 'tip', 'success', 'danger']
function CalloutTypePicker({ current, onChange }: { current: CalloutType; onChange: (t: CalloutType) => void }) {
const [open, setOpen] = useState(false)
const CurrentIcon = CALLOUT_ICONS[current]
return (
<div className="relative" contentEditable={false}>
<button
onClick={() => setOpen(v => !v)}
className={cn('p-1 rounded hover:bg-black/5 dark:hover:bg-white/10', CALLOUT_STYLES[current].iconColor)}
>
<CurrentIcon className="h-4 w-4" />
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 z-20 mt-1 rounded-lg border border-border bg-popover p-1 shadow-lg flex gap-1">
{CALLOUT_TYPES.map(t => {
const Icon = CALLOUT_ICONS[t]
return (
<button
key={t}
onClick={() => { onChange(t); setOpen(false) }}
className={cn('p-1.5 rounded hover:bg-muted', CALLOUT_STYLES[t].iconColor, t === current && 'ring-2 ring-primary')}
>
<Icon className="h-4 w-4" />
</button>
)
})}
</div>
</>
)}
</div>
)
}
const CalloutView = ({ node, updateAttributes, deleteNode, getPos, editor }: any) => {
const type = (node.attrs.type || 'info') as CalloutType
const styles = CALLOUT_STYLES[type]
const { t } = useLanguage()
const unwrap = () => {
const pos = getPos()
if (typeof pos !== 'number') return
const toggleNode = editor.state.doc.nodeAt(pos)
if (!toggleNode) return
const children: any[] = []
toggleNode.forEach((child: any) => { children.push(child.toJSON()) })
if (children.length === 0) children.push({ type: 'paragraph' })
editor.chain().focus().deleteRange({ from: pos, to: pos + toggleNode.nodeSize }).run()
editor.chain().focus().insertContentAt(pos, children).run()
}
return (
<NodeViewWrapper className="callout-block group/callout my-2" dir="auto">
<div className={cn('flex items-start gap-2 rounded-lg border px-3 py-2.5', styles.bg, styles.border)}>
<div className="flex flex-col items-center gap-1 pt-0.5">
<CalloutTypePicker
current={type}
onChange={(newType) => updateAttributes({ type: newType })}
/>
</div>
<div className="flex-1 min-w-0 text-sm leading-relaxed">
<NodeViewContent className="callout-content" />
</div>
<div className="flex flex-shrink-0 items-center gap-0.5 pt-0.5 opacity-0 group-hover/callout:opacity-100 transition-opacity">
<button
onClick={unwrap}
contentEditable={false}
className="p-0.5 rounded hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground"
title={t('richTextEditor.calloutUnwrap')}
>
<X className="h-3.5 w-3.5" />
</button>
<button
onClick={deleteNode}
contentEditable={false}
className="p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title={t('richTextEditor.calloutDelete')}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</NodeViewWrapper>
)
}
export const CalloutExtension = Node.create({
name: 'calloutBlock',
group: 'block',
content: 'block+',
defining: true,
isolating: true,
addAttributes() {
return {
type: {
default: 'info',
parseHTML: (element) => {
const t = element.getAttribute('data-callout-type')
return (t && CALLOUT_TYPES.includes(t as CalloutType)) ? t : 'info'
},
renderHTML: (attributes) => ({
'data-callout-type': attributes.type,
}),
},
}
},
parseHTML() {
return [
{ tag: 'div[data-type="callout-block"]' },
{ tag: 'div[data-callout-type]' },
]
},
renderHTML({ HTMLAttributes }) {
const type = (HTMLAttributes['data-callout-type'] as CalloutType) || 'info'
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-type': 'callout-block',
'data-callout-type': type,
class: `callout-block callout-${type}`,
}),
0,
]
},
addNodeView() {
return ReactNodeViewRenderer(CalloutView)
},
addKeyboardShortcuts() {
return {
'Mod-Shift-C': () => this.editor.commands.insertContent({
type: this.name,
attrs: { type: 'info' },
content: [{ type: 'paragraph' }],
}),
}
},
})
export function insertCalloutBlock(editor: any, type: CalloutType = 'info') {
editor.chain().focus().insertContent({
type: 'calloutBlock',
attrs: { type },
content: [{ type: 'paragraph' }],
}).run()
}
export function turnIntoCalloutBlock(editor: any, blockPos: number, blockNode: any, type: CalloutType = 'info') {
if (!blockNode || blockPos < 0) return
const nodeJson = blockNode.toJSON()
editor.chain().focus().deleteRange({
from: blockPos,
to: blockPos + blockNode.nodeSize,
}).insertContentAt(blockPos, {
type: 'calloutBlock',
attrs: { type },
content: [nodeJson],
}).setTextSelection(blockPos + 2).run()
}
export { CALLOUT_TYPES, CALLOUT_ICONS, CALLOUT_STYLES }
export type { CalloutType }