feat: blocs Callout (encadrés colorés) + Outline (sommaire auto)
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m11s
CI / Deploy production (on server) (push) Has been skipped

- 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:
Antigravity
2026-06-14 16:43:43 +00:00
parent fccad72d47
commit 2723e06b80
6 changed files with 413 additions and 2 deletions

View File

@@ -14,10 +14,12 @@ import {
Heading1, Heading2, Heading3, List, ListOrdered, Heading1, Heading2, Heading3, List, ListOrdered,
CheckSquare, Quote, CodeXml, Database, CheckSquare, Quote, CodeXml, Database,
ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles, ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles,
ChevronsRightLeft, ChevronsRightLeft, MessageSquareWarning, ListTree,
} from 'lucide-react' } from 'lucide-react'
import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension' import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension'
import { insertToggleBlock, turnIntoToggleBlock } from '@/components/tiptap-toggle-extension' import { insertToggleBlock, turnIntoToggleBlock } from '@/components/tiptap-toggle-extension'
import { turnIntoCalloutBlock } from '@/components/tiptap-callout-extension'
import { insertOutlineBlock } from '@/components/tiptap-outline-extension'
interface BlockActionMenuProps { interface BlockActionMenuProps {
editor: Editor editor: Editor
@@ -377,6 +379,18 @@ export function BlockActionMenu({
<span>{t('richTextEditor.blockActionInsertToggle')}</span> <span>{t('richTextEditor.blockActionInsertToggle')}</span>
</button> </button>
{/* Encadré */}
<button type="button" className="block-action-item" onClick={() => { turnIntoCalloutBlock(editor, blockPos, blockNode, 'info'); onClose() }}>
<MessageSquareWarning size={16} />
<span>{t('richTextEditor.blockActionInsertCallout')}</span>
</button>
{/* Sommaire */}
<button type="button" className="block-action-item" onClick={() => { insertOutlineBlock(editor); onClose() }}>
<ListTree size={16} />
<span>{t('richTextEditor.slashOutline')}</span>
</button>
<div className="block-action-separator" /> <div className="block-action-separator" />
{/* Création de diagramme */} {/* Création de diagramme */}

View File

@@ -27,6 +27,8 @@ import { UniqueIdExtension } from './tiptap-unique-id-extension'
import { LiveBlockExtension } from './tiptap-live-block-extension' import { LiveBlockExtension } from './tiptap-live-block-extension'
import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension' import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension' import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension' import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
import { ClipArticleExtension } from './tiptap-clip-article-extension' import { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker' import { BlockPicker, type BlockSuggestion } from './block-picker'
@@ -63,7 +65,7 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight, FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus, Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database, SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft ChevronsRightLeft, MessageSquareWarning, ListTree
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -208,6 +210,14 @@ const slashCommands: SlashItem[] = [
title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>', title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>',
command: (e) => { insertToggleBlock(e) }, command: (e) => { insertToggleBlock(e) },
}, },
{
title: 'Callout', description: 'Highlighted info box', icon: MessageSquareWarning, category: 'Basic blocks', shortcut: '!',
command: (e) => { insertCalloutBlock(e, 'info') },
},
{
title: 'Outline', description: 'Table of contents from headings', icon: ListTree, category: 'Basic blocks', shortcut: '/toc',
command: (e) => { insertOutlineBlock(e) },
},
] ]
async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> { async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> {
@@ -430,6 +440,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
LiveBlockExtension, LiveBlockExtension,
StructuredViewBlockExtension, StructuredViewBlockExtension,
ToggleExtension, ToggleExtension,
CalloutExtension,
OutlineExtension,
ClipArticleExtension, ClipArticleExtension,
RtlPreserveExtension, RtlPreserveExtension,
Placeholder.configure({ Placeholder.configure({
@@ -1600,6 +1612,8 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'embed' }, { ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'embed' },
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] }, { ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
{ ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] }, { ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
{ ...slashCommands[32], title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] },
{ {
title: t('richTextEditor.slashNoteLink'), title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'), description: t('richTextEditor.slashNoteLinkDesc'),

View 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 }

View File

@@ -0,0 +1,151 @@
'use client'
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { List, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import type { Editor } from '@tiptap/core'
interface HeadingEntry {
id: string
level: number
text: string
pos: number
}
function collectHeadings(editor: Editor): HeadingEntry[] {
const headings: HeadingEntry[] = []
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
const level = node.attrs.level as number
if (level >= 1 && level <= 3) {
const text = node.textContent.trim()
if (text) {
headings.push({
id: node.attrs['data-id'] || `heading-${pos}`,
level,
text,
pos,
})
}
}
}
})
return headings
}
const OutlineView = ({ editor, deleteNode }: any) => {
const { t } = useLanguage()
const headings = collectHeadings(editor as Editor)
const scrollToHeading = (pos: number) => {
const docSize = editor.state.doc.content.size
const safePos = Math.min(pos + 1, docSize)
editor.chain().focus().setTextSelection(safePos).scrollIntoView().run()
}
return (
<NodeViewWrapper className="outline-block my-3 rounded-lg border border-border bg-muted/20" contentEditable={false} dir="auto">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/30">
<div className="flex items-center gap-1.5">
<List className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">
{t('richTextEditor.outlineTitle')}
</span>
</div>
<button
onClick={deleteNode}
className="p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title={t('richTextEditor.outlineDelete')}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="px-3 py-2.5 overflow-hidden">
{headings.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
{t('richTextEditor.outlineEmpty')}
</p>
) : (
<nav className="space-y-0.5">
{headings.map((h, i) => {
const indent = (h.level - 1) * 20
return (
<div
key={`${h.id}-${i}`}
style={{ paddingLeft: `${indent}px` }}
className={cn(
'group flex items-center gap-2 rounded-md px-2 py-1 transition-colors hover:bg-foreground/5 cursor-pointer min-w-0',
)}
onClick={() => scrollToHeading(h.pos)}
>
<span
className={cn(
'flex-shrink-0 rounded-full',
h.level === 1 && 'w-1.5 h-1.5 bg-foreground/60',
h.level === 2 && 'w-1.5 h-1.5 border border-foreground/40',
h.level === 3 && 'w-1 h-1 bg-foreground/30',
)}
/>
<span className={cn(
'text-sm leading-snug break-words min-w-0 flex-1 transition-colors',
h.level === 1 && 'font-semibold',
h.level === 2 && 'font-medium text-foreground/80',
h.level === 3 && 'text-foreground/60',
)}>
{h.text}
</span>
</div>
)
})}
</nav>
)}
</div>
</NodeViewWrapper>
)
}
export const OutlineExtension = Node.create({
name: 'outlineBlock',
group: 'block',
atom: true,
defining: true,
parseHTML() {
return [
{ tag: 'div[data-type="outline-block"]' },
]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-type': 'outline-block',
class: 'outline-block',
}),
]
},
addNodeView() {
return ReactNodeViewRenderer(OutlineView)
},
addKeyboardShortcuts() {
return {
'Mod-Shift-O': () => this.editor.commands.insertContent({
type: this.name,
}),
}
},
})
export function insertOutlineBlock(editor: any) {
editor.chain().focus().insertContent({
type: 'outlineBlock',
}).run()
}

View File

@@ -2409,6 +2409,21 @@
"slashDatabaseDesc": "Embed your notebook's structured data", "slashDatabaseDesc": "Embed your notebook's structured data",
"slashToggle": "Toggle Section", "slashToggle": "Toggle Section",
"slashToggleDesc": "Create a collapsible section", "slashToggleDesc": "Create a collapsible section",
"slashCallout": "Callout",
"slashCalloutDesc": "Highlight text (info, warning, tip)",
"slashOutline": "Table of Contents",
"slashOutlineDesc": "Auto-generated outline from your headings",
"outlineTitle": "Outline",
"outlineEmpty": "Add headings (H1, H2, H3) to generate the outline",
"outlineDelete": "Delete outline",
"calloutDelete": "Delete callout",
"calloutUnwrap": "Disable callout",
"calloutInfo": "Information",
"calloutWarning": "Warning",
"calloutTip": "Tip",
"calloutSuccess": "Success",
"calloutDanger": "Danger",
"blockActionInsertCallout": "Turn into callout",
"toggleOpened": "Expanded section — click to collapse", "toggleOpened": "Expanded section — click to collapse",
"toggleClosed": "Collapsed section — click to expand", "toggleClosed": "Collapsed section — click to expand",
"toggleDelete": "Delete section", "toggleDelete": "Delete section",

View File

@@ -2413,6 +2413,21 @@
"slashDatabaseDesc": "Intégrer les données structurées de votre carnet", "slashDatabaseDesc": "Intégrer les données structurées de votre carnet",
"slashToggle": "Section repliable", "slashToggle": "Section repliable",
"slashToggleDesc": "Créer une section dépliable", "slashToggleDesc": "Créer une section dépliable",
"slashCallout": "Encadré",
"slashCalloutDesc": "Mettre en valeur du texte (info, alerte, astuce)",
"slashOutline": "Sommaire",
"slashOutlineDesc": "Table des matières générée depuis vos titres",
"outlineTitle": "Sommaire",
"outlineEmpty": "Ajoutez des titres (H1, H2, H3) pour générer le sommaire",
"outlineDelete": "Supprimer le sommaire",
"calloutDelete": "Supprimer l'encadré",
"calloutUnwrap": "Désactiver l'encadré",
"calloutInfo": "Information",
"calloutWarning": "Avertissement",
"calloutTip": "Astuce",
"calloutSuccess": "Succès",
"calloutDanger": "Danger",
"blockActionInsertCallout": "Transformer en encadré",
"toggleOpened": "Section dépliée — cliquer pour replier", "toggleOpened": "Section dépliée — cliquer pour replier",
"toggleClosed": "Section repliée — cliquer pour déplier", "toggleClosed": "Section repliée — cliquer pour déplier",
"toggleDelete": "Supprimer la section", "toggleDelete": "Supprimer la section",