diff --git a/memento-note/components/block-action-menu.tsx b/memento-note/components/block-action-menu.tsx index b0e2c0d..ab61524 100644 --- a/memento-note/components/block-action-menu.tsx +++ b/memento-note/components/block-action-menu.tsx @@ -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({ {t('richTextEditor.blockActionInsertToggle')} + {/* Encadré */} + + + {/* Sommaire */} + +
{/* Création de diagramme */} diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index 609f114..fc81383 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -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 { @@ -430,6 +440,8 @@ export const RichTextEditor = forwardRef = { + 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 = { + 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 ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ {CALLOUT_TYPES.map(t => { + const Icon = CALLOUT_ICONS[t] + return ( + + ) + })} +
+ + )} +
+ ) +} + +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 ( + +
+
+ updateAttributes({ type: newType })} + /> +
+
+ +
+
+ + +
+
+
+ ) +} + +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 } diff --git a/memento-note/components/tiptap-outline-extension.tsx b/memento-note/components/tiptap-outline-extension.tsx new file mode 100644 index 0000000..fc73552 --- /dev/null +++ b/memento-note/components/tiptap-outline-extension.tsx @@ -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 ( + +
+
+ + + {t('richTextEditor.outlineTitle')} + +
+ +
+
+ {headings.length === 0 ? ( +

+ {t('richTextEditor.outlineEmpty')} +

+ ) : ( + + )} +
+
+ ) +} + +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() +} diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index db974e9..ee40167 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -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", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 940004f..0455472 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -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",