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:
151
memento-note/components/tiptap-outline-extension.tsx
Normal file
151
memento-note/components/tiptap-outline-extension.tsx
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user