feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,12 +23,18 @@ import Subscript from '@tiptap/extension-subscript'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import { ChartExtension } from './tiptap-chart-extension'
|
||||
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
||||
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
||||
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
||||
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
import {
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
|
||||
Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
|
||||
Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon,
|
||||
Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon, Link2,
|
||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
@@ -39,6 +45,12 @@ import { toast } from 'sonner'
|
||||
|
||||
export interface RichTextEditorHandle {
|
||||
getEditor: () => Editor | null
|
||||
triggerChartSuggestions: () => void
|
||||
insertCitation: (
|
||||
payload: { noteId: string; noteTitle: string; excerpt: string },
|
||||
options?: { atEnd?: boolean }
|
||||
) => boolean
|
||||
insertLiveBlock: (block: BlockSuggestion, options?: { atEnd?: boolean }) => boolean
|
||||
}
|
||||
|
||||
interface RichTextEditorProps {
|
||||
@@ -47,6 +59,11 @@ interface RichTextEditorProps {
|
||||
className?: string
|
||||
placeholder?: string
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
noteId?: string
|
||||
}
|
||||
|
||||
interface RichTextEditorRef {
|
||||
triggerChartSuggestions: () => void
|
||||
}
|
||||
|
||||
type SlashItem = {
|
||||
@@ -147,6 +164,12 @@ const slashCommands: SlashItem[] = [
|
||||
// Handler will be called by SlashCommandMenu
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', icon: Link2, category: 'Basic blocks', shortcut: '/bloc',
|
||||
command: (_e) => {
|
||||
window.dispatchEvent(new CustomEvent('memento-open-block-picker'))
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||
@@ -204,9 +227,108 @@ function useImageInsert() {
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload }, ref) {
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
|
||||
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
|
||||
const [noteLinkQuery, setNoteLinkQuery] = useState('')
|
||||
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
|
||||
const noteLinkPickerOpenRef = useRef(false)
|
||||
noteLinkPickerOpenRef.current = noteLinkPickerOpen
|
||||
const lastEmittedContent = useRef<string>(content || '')
|
||||
const editorInstanceRef = useRef<Editor | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
onChangeRef.current = onChange
|
||||
|
||||
const emitContentChange = useCallback((html: string) => {
|
||||
lastEmittedContent.current = html
|
||||
onChangeRef.current?.(html)
|
||||
}, [])
|
||||
|
||||
// Listen to the slash-command event to open the BlockPicker
|
||||
useEffect(() => {
|
||||
const openHandler = () => setBlockPickerOpen(true)
|
||||
window.addEventListener('memento-open-block-picker', openHandler)
|
||||
return () => window.removeEventListener('memento-open-block-picker', openHandler)
|
||||
}, [])
|
||||
|
||||
const handleSelectBlockRef = useRef<(block: BlockSuggestion) => void>(() => {})
|
||||
const insertCitationRef = useRef<(payload: { noteId: string; noteTitle: string; excerpt: string }, options?: { atEnd?: boolean }) => boolean>(() => false)
|
||||
|
||||
useEffect(() => {
|
||||
const insertHandler = (event: Event) => {
|
||||
const block = (event as CustomEvent<{ block: BlockSuggestion }>).detail?.block
|
||||
if (block) handleSelectBlockRef.current(block)
|
||||
}
|
||||
const citationHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ noteId: string; noteTitle: string; excerpt: string; atEnd?: boolean }>).detail
|
||||
if (!detail) return
|
||||
insertCitationRef.current(detail, { atEnd: detail.atEnd !== false })
|
||||
}
|
||||
window.addEventListener('memento-insert-live-block', insertHandler)
|
||||
window.addEventListener('memento-insert-citation', citationHandler)
|
||||
return () => {
|
||||
window.removeEventListener('memento-insert-live-block', insertHandler)
|
||||
window.removeEventListener('memento-insert-citation', citationHandler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSelectNoteLink = useCallback((selected: NoteLinkOption) => {
|
||||
const ed = editorInstanceRef.current
|
||||
if (!ed) {
|
||||
setNoteLinkPickerOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
let range = noteLinkRangeRef.current
|
||||
if (!range) {
|
||||
const { from } = ed.state.selection
|
||||
const start = Math.max(0, from - 80)
|
||||
const textBefore = ed.state.doc.textBetween(start, from, '\n', '\0')
|
||||
const match = textBefore.match(/\[\[([^\]]*)$/)
|
||||
if (match) {
|
||||
range = { from: from - match[0].length, to: from }
|
||||
}
|
||||
}
|
||||
if (!range) {
|
||||
setNoteLinkPickerOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
const title = (selected.title || t('documentInfo.network.untitled')).trim()
|
||||
const href = `/home?openNote=${encodeURIComponent(selected.id)}`
|
||||
|
||||
ed.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: range.from, to: range.to })
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: title,
|
||||
marks: [{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
}],
|
||||
})
|
||||
.insertContent(' ')
|
||||
.run()
|
||||
|
||||
const html = ed.getHTML()
|
||||
emitContentChange(html)
|
||||
setNoteLinkPickerOpen(false)
|
||||
noteLinkRangeRef.current = null
|
||||
|
||||
if (noteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId, reason: 'note-link' },
|
||||
}))
|
||||
}
|
||||
}, [emitContentChange, noteId, t])
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -226,42 +348,91 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
Subscript,
|
||||
Typography,
|
||||
ChartExtension,
|
||||
UniqueIdExtension,
|
||||
LiveBlockExtension,
|
||||
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
||||
],
|
||||
content: content || '',
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: { class: 'notion-editor' },
|
||||
handlePaste: (view, event, slice) => {
|
||||
if (!onImageUpload) return false;
|
||||
const items = Array.from(event.clipboardData?.items || []);
|
||||
const hasImage = items.some(item => item.type.startsWith('image/'));
|
||||
if (!hasImage) return false;
|
||||
event.preventDefault();
|
||||
const images = items.filter(item => item.type.startsWith('image/')).map(item => item.getAsFile()).filter(f => f !== null) as File[];
|
||||
images.forEach(async (file) => {
|
||||
try {
|
||||
toast.info(t('notes.uploading'));
|
||||
const url = await onImageUpload(file);
|
||||
const { schema } = view.state;
|
||||
const node = schema.nodes.image.create({ src: url });
|
||||
const tr = view.state.tr.replaceSelectionWith(node);
|
||||
view.dispatch(tr);
|
||||
} catch (err) {
|
||||
toast.error(t('notes.uploadFailed'));
|
||||
handleDOMEvents: {
|
||||
click: (_view, event) => {
|
||||
const link = (event.target as HTMLElement).closest('a[href]')
|
||||
if (!link) return false
|
||||
const href = link.getAttribute('href') || ''
|
||||
const noteIdMatch = href.match(/[?&]openNote=([^&#]+)/)
|
||||
if (noteIdMatch) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
window.open(
|
||||
`/home?openNote=${decodeURIComponent(noteIdMatch[1])}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
return true
|
||||
}
|
||||
});
|
||||
return true;
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
if (!onImageUpload) return false
|
||||
const items = Array.from(event.clipboardData?.items || [])
|
||||
const hasImage = items.some(item => item.type.startsWith('image/'))
|
||||
if (!hasImage) return false
|
||||
event.preventDefault()
|
||||
const imageFiles = items
|
||||
.filter(item => item.type.startsWith('image/'))
|
||||
.map(item => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
|
||||
void (async () => {
|
||||
for (const file of imageFiles) {
|
||||
try {
|
||||
toast.info(t('notes.uploading'))
|
||||
const url = await onImageUpload(file)
|
||||
const editorInstance = editorInstanceRef.current
|
||||
if (!editorInstance) continue
|
||||
const inserted = editorInstance.chain().focus().setImage({ src: url }).run()
|
||||
if (inserted) {
|
||||
emitContentChange(editorInstance.getHTML())
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed'))
|
||||
}
|
||||
}
|
||||
})()
|
||||
return true
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor: e }) => {
|
||||
const html = e.getHTML()
|
||||
lastEmittedContent.current = html
|
||||
onChange?.(html)
|
||||
emitContentChange(e.getHTML())
|
||||
if (!e.isEditable) return
|
||||
const { from, empty } = e.state.selection
|
||||
if (!empty) return
|
||||
const start = Math.max(0, from - 80)
|
||||
const textBefore = e.state.doc.textBetween(start, from, '\n', '\0')
|
||||
const match = textBefore.match(/\[\[([^\]]*)$/)
|
||||
if (match) {
|
||||
noteLinkRangeRef.current = { from: from - match[0].length, to: from }
|
||||
setNoteLinkQuery(match[1])
|
||||
setNoteLinkPickerOpen(true)
|
||||
} else if (!noteLinkPickerOpenRef.current) {
|
||||
setNoteLinkPickerOpen(false)
|
||||
noteLinkRangeRef.current = null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const lastEmittedContent = useRef<string>(content || '')
|
||||
useEffect(() => {
|
||||
editorInstanceRef.current = editor ?? null
|
||||
}, [editor])
|
||||
|
||||
// Chart suggestions dialog state
|
||||
const [chartSuggestionsOpen, setChartSuggestionsOpen] = useState(false)
|
||||
@@ -278,18 +449,83 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
}
|
||||
}, [content, editor])
|
||||
|
||||
useImperativeHandle(ref, () => ({ getEditor: () => editor }), [editor])
|
||||
|
||||
// Chart suggestion handlers
|
||||
const handleOpenChartSuggestions = useCallback(() => {
|
||||
const handleOpenChartSuggestions = useCallback(async () => {
|
||||
if (!editor || !editor.isEditable) return
|
||||
|
||||
// Get current selection text if any
|
||||
const { from, to, empty } = editor.state.selection
|
||||
const selectionText = !empty ? editor.state.doc.textBetween(from, to, ' ') : null
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setChartSuggestionsOpen(true)
|
||||
}, [editor])
|
||||
}, [editor, requestAiConsent])
|
||||
|
||||
const insertCitationInEditor = useCallback((
|
||||
payload: { noteId: string; noteTitle: string; excerpt: string },
|
||||
options?: { atEnd?: boolean }
|
||||
) => {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
const plainExcerpt = payload.excerpt.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (!plainExcerpt) return false
|
||||
const chain = editor.chain()
|
||||
if (options?.atEnd !== false) {
|
||||
chain.focus('end')
|
||||
} else {
|
||||
chain.focus()
|
||||
}
|
||||
chain.insertContent([
|
||||
{ type: 'paragraph', content: [] },
|
||||
{
|
||||
type: 'blockquote',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: plainExcerpt }] }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: '— ' },
|
||||
{
|
||||
type: 'text',
|
||||
text: payload.noteTitle,
|
||||
marks: [{ type: 'link', attrs: { href: `/home?openNote=${payload.noteId}`, target: '_blank', rel: 'noopener noreferrer' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]).scrollIntoView().run()
|
||||
emitContentChange(editor.getHTML())
|
||||
return true
|
||||
}, [editor, emitContentChange])
|
||||
|
||||
const insertLiveBlockInEditor = useCallback((block: BlockSuggestion, options?: { atEnd?: boolean }) => {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
const chain = editor.chain()
|
||||
if (options?.atEnd !== false) {
|
||||
chain.focus('end')
|
||||
} else {
|
||||
chain.focus()
|
||||
}
|
||||
chain.insertContent({
|
||||
type: 'liveBlock',
|
||||
attrs: {
|
||||
sourceNoteId: block.noteId,
|
||||
blockId: block.blockId,
|
||||
snapshotContent: block.content,
|
||||
sourceNoteTitle: block.noteTitle,
|
||||
},
|
||||
}).scrollIntoView().run()
|
||||
emitContentChange(editor.getHTML())
|
||||
return true
|
||||
}, [editor, emitContentChange])
|
||||
|
||||
insertCitationRef.current = insertCitationInEditor
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getEditor: () => editor,
|
||||
triggerChartSuggestions: () => {
|
||||
if (editor) {
|
||||
handleOpenChartSuggestions()
|
||||
}
|
||||
},
|
||||
insertCitation: insertCitationInEditor,
|
||||
insertLiveBlock: insertLiveBlockInEditor,
|
||||
}), [editor, handleOpenChartSuggestions, insertCitationInEditor, insertLiveBlockInEditor])
|
||||
|
||||
const handleSelectChart = useCallback((chartContent: string) => {
|
||||
if (!editor || !editor.isEditable) return
|
||||
@@ -297,29 +533,69 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
try {
|
||||
console.log('[handleSelectChart] Inserting chart type:', chartContent.split('\n')[0])
|
||||
|
||||
// Use TipTap's insertContent with proper JSON content
|
||||
// This is the most reliable way to insert custom nodes
|
||||
editor.commands.insertContent([
|
||||
{
|
||||
type: 'chartBlock',
|
||||
attrs: {
|
||||
code: chartContent,
|
||||
language: 'chart'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: []
|
||||
}
|
||||
])
|
||||
// Get current selection
|
||||
const { from, to, empty } = editor.state.selection
|
||||
|
||||
console.log('[handleSelectChart] Chart inserted')
|
||||
// Insert chart AFTER the selected text (not replace it)
|
||||
// First insert a paragraph for spacing, then the chart, then another paragraph
|
||||
editor.chain()
|
||||
.focus()
|
||||
.insertContentAt(to, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: []
|
||||
},
|
||||
{
|
||||
type: 'chartBlock',
|
||||
attrs: {
|
||||
code: chartContent,
|
||||
language: 'chart'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: []
|
||||
}
|
||||
])
|
||||
.run()
|
||||
|
||||
console.log('[handleSelectChart] Chart inserted after selection')
|
||||
} catch (error) {
|
||||
console.error('[handleSelectChart] Failed:', error)
|
||||
toast.error('Failed to insert chart: ' + (error as Error).message)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const handleSelectBlock = useCallback(async (block: BlockSuggestion) => {
|
||||
setBlockPickerOpen(false)
|
||||
if (!editor) return
|
||||
|
||||
if (noteId) {
|
||||
try {
|
||||
await fetch('/api/blocks/embed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sourceNoteId: block.noteId, blockId: block.blockId, targetNoteId: noteId }),
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
editor.chain().focus().insertContent({
|
||||
type: 'liveBlock',
|
||||
attrs: {
|
||||
sourceNoteId: block.noteId,
|
||||
blockId: block.blockId,
|
||||
snapshotContent: block.content,
|
||||
sourceNoteTitle: block.noteTitle,
|
||||
},
|
||||
}).run()
|
||||
emitContentChange(editor.getHTML())
|
||||
}, [editor, noteId, emitContentChange])
|
||||
|
||||
handleSelectBlockRef.current = handleSelectBlock
|
||||
|
||||
return (
|
||||
<div className={cn('notion-editor-wrapper', className)}>
|
||||
{editor && (
|
||||
@@ -339,7 +615,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
return (from !== to && !e.isActive('codeBlock')) || isImage
|
||||
}}
|
||||
>
|
||||
<BubbleToolbar editor={editor} />
|
||||
<BubbleToolbar editor={editor} onSuggestCharts={handleOpenChartSuggestions} />
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
@@ -360,6 +636,24 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
onSelectChart={handleSelectChart}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BlockPicker
|
||||
isOpen={blockPickerOpen}
|
||||
onClose={() => setBlockPickerOpen(false)}
|
||||
currentNoteId={noteId}
|
||||
onSelectBlock={handleSelectBlock}
|
||||
/>
|
||||
|
||||
<NoteLinkPicker
|
||||
isOpen={noteLinkPickerOpen}
|
||||
query={noteLinkQuery}
|
||||
currentNoteId={noteId}
|
||||
onClose={() => {
|
||||
setNoteLinkPickerOpen(false)
|
||||
noteLinkRangeRef.current = null
|
||||
}}
|
||||
onSelect={handleSelectNoteLink}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -413,8 +707,9 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
|
||||
)
|
||||
}
|
||||
|
||||
function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onSuggestCharts?: () => void }) {
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
|
||||
const toastAiError = (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
@@ -460,6 +755,8 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
const { from, to } = editor.state.selection
|
||||
const text = editor.state.doc.textBetween(from, to, ' ')
|
||||
if (!text || text.split(/\s+/).length < 2) return
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
setAiLoading(true)
|
||||
setAiOpen(false)
|
||||
setTranslateOpen(false)
|
||||
@@ -571,6 +868,9 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
</div>
|
||||
)}
|
||||
<button className="notion-ai-subitem" onClick={() => handleAI('explain')}><BookOpen className="w-3.5 h-3.5 text-orange-500" /><span>{t('ai.action.explain')}</span></button>
|
||||
{onSuggestCharts && (
|
||||
<button className="notion-ai-subitem" onClick={() => { setAiOpen(false); onSuggestCharts() }}><BarChart3 className="w-3.5 h-3.5 text-blue-500" /><span>{t('ai.action.generateChart') || 'Générer un graphique'}</span></button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{aiModal && (
|
||||
@@ -607,6 +907,7 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
|
||||
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
@@ -647,6 +948,16 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[28], title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', categoryId: 'ai' },
|
||||
{ ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'basic' },
|
||||
{
|
||||
title: t('richTextEditor.slashNoteLink'),
|
||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||
icon: Link2,
|
||||
categoryId: 'basic' as SlashCategoryId,
|
||||
command: (e) => {
|
||||
e.chain().focus().insertContent('[[').run()
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
@@ -673,6 +984,8 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
} else if (item.isAi && item.aiOption) {
|
||||
deleteSlashText(); closeMenu(); setAiLoading(true)
|
||||
try {
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
const allText = editor.state.doc.textContent
|
||||
if (!allText || allText.split(/\s+/).length < 5) return
|
||||
const result = await aiReformulate(allText, item.aiOption)
|
||||
|
||||
Reference in New Issue
Block a user