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,
|
||||
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 */}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user