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.
144 lines
4.2 KiB
TypeScript
144 lines
4.2 KiB
TypeScript
'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)
|
|
},
|
|
})
|
|
|
|
export function insertOutlineBlock(editor: any) {
|
|
editor.chain().focus().insertContent({
|
|
type: 'outlineBlock',
|
|
}).run()
|
|
}
|