Les blocs (toggle, callout, outline, columns, math) n'ont plus de raccourcis clavier. Insertion via le menu / uniquement, comme Notion. Plus de confusion pour l'utilisateur.
193 lines
6.7 KiB
TypeScript
193 lines
6.7 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)
|
|
},
|
|
})
|
|
|
|
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 }
|