feat: blocs Callout (encadrés colorés) + Outline (sommaire auto)
- Callout : 5 types (info, alerte, astuce, succès, danger), icône cliquable pour changer de type, unwrap + supprimer, i18n FR/EN - Outline : table des matières auto-générée depuis H1/H2/H3, design hiérarchique (disque/cercle/point), cliquable pour naviguer, i18n FR/EN - Accessibles depuis slash menu + drag handle - Raccourcis: Mod+Shift+C (callout), Mod+Shift+O (outline)
This commit is contained in:
202
memento-note/components/tiptap-callout-extension.tsx
Normal file
202
memento-note/components/tiptap-callout-extension.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'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 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">
|
||||
<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 }
|
||||
Reference in New Issue
Block a user