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:
@@ -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 */}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
202
memento-note/components/tiptap-callout-extension.tsx
Normal file
202
memento-note/components/tiptap-callout-extension.tsx
Normal 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 }
|
||||||
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()
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user