feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped

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:
Antigravity
2026-05-24 14:27:29 +00:00
parent 077e665dfc
commit e2672cd2c2
323 changed files with 20670 additions and 42431 deletions

View File

@@ -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)