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,
CheckSquare, Quote, CodeXml, Database,
ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles,
ChevronsRightLeft,
ChevronsRightLeft, MessageSquareWarning, ListTree,
} from 'lucide-react'
import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-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 {
editor: Editor
@@ -377,6 +379,18 @@ export function BlockActionMenu({
<span>{t('richTextEditor.blockActionInsertToggle')}</span>
</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" />
{/* 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 { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-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 { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker'
@@ -63,7 +65,7 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft
ChevronsRightLeft, MessageSquareWarning, ListTree
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@@ -208,6 +210,14 @@ const slashCommands: SlashItem[] = [
title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>',
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> {
@@ -430,6 +440,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
LiveBlockExtension,
StructuredViewBlockExtension,
ToggleExtension,
CalloutExtension,
OutlineExtension,
ClipArticleExtension,
RtlPreserveExtension,
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[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[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'),
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",
"slashToggle": "Toggle 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",
"toggleClosed": "Collapsed section — click to expand",
"toggleDelete": "Delete section",

View File

@@ -2413,6 +2413,21 @@
"slashDatabaseDesc": "Intégrer les données structurées de votre carnet",
"slashToggle": "Section repliable",
"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",
"toggleClosed": "Section repliée — cliquer pour déplier",
"toggleDelete": "Supprimer la section",