Files
Momento/memento-note/components/tiptap-outline-extension.tsx
Antigravity 35c79ffd1c
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m39s
CI / Deploy production (on server) (push) Successful in 21s
fix: Outline se met à jour en temps réel quand les titres changent
Ajout d'un listener editor.on('update') dans OutlineView.
Avant: le sommaire était figé au moment de l'insertion.
Maintenant: il se recalcule à chaque ajout/modif/suppression de titre.
2026-06-20 16:28:54 +00:00

152 lines
4.6 KiB
TypeScript

'use client'
import { useState, useEffect, useRef } from 'react'
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, setHeadings] = useState<HeadingEntry[]>(() => collectHeadings(editor as Editor))
// Re-collect headings when the editor content changes
useEffect(() => {
const update = () => setHeadings(collectHeadings(editor as Editor))
editor.on('update', update)
return () => { editor.off('update', update) }
}, [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)
},
})
export function insertOutlineBlock(editor: any) {
editor.chain().focus().insertContent({
type: 'outlineBlock',
}).run()
}