feat: editor improvements and architectural grid prototype

Multiple feature additions and improvements across the application:

- NextGen Editor: drag handles, smart paste, block actions
- Structured views: Kanban and table layouts for notes
- Architectural Grid: new brainstorming/agent interface prototype
- Flashcards: SM-2 revision algorithm with AI generation
- MCP server: robustness improvements
- Graph/PDF chat: fix click propagation and copy behavior
- Various UI/UX enhancements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-27 19:45:15 +00:00
parent 2de66a863d
commit f46654f574
99 changed files with 29948 additions and 919 deletions

View File

@@ -25,13 +25,25 @@ 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 { DatabaseBlockExtension, insertDatabaseBlockAtSelection } from './tiptap-database-block-extension'
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
import { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker'
import { EditorBlockDragHandle } from './editor-block-drag-handle'
import { BlockActionMenu } from './block-action-menu'
import { SmartPasteMenu } from './smart-paste-menu'
import { globalDragHandleExtensions } from '@/lib/editor/global-drag-handle-extension'
import { resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlockReference } from '@/lib/editor/parse-block-reference'
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension'
import type { Node as PMNode } from '@tiptap/pm/model'
import { detectTextDirection } from '@/lib/clip/rtl-content'
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
import { applyClipRtlDirection } from '@/lib/editor/apply-clip-rtl-direction'
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import { openNotePeek } from '@/lib/note-peek-sync'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import type { Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'
@@ -42,7 +54,7 @@ import {
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@@ -64,6 +76,7 @@ interface RichTextEditorProps {
placeholder?: string
onImageUpload?: (file: File) => Promise<string>
noteId?: string
noteTitle?: string
/** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */
sourceUrl?: string | null
}
@@ -86,7 +99,7 @@ type SlashItem = {
type SlashCategoryId = 'basic' | 'media' | 'formatting' | 'ai'
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId }
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId; slashKeywords?: string[] }
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['basic', 'media', 'formatting', 'ai']
@@ -176,6 +189,10 @@ const slashCommands: SlashItem[] = [
window.dispatchEvent(new CustomEvent('memento-open-block-picker'))
}
},
{
title: 'Database', description: 'Inline authors & works database', icon: Database, category: 'Basic blocks', shortcut: '/database',
command: (e) => { insertDatabaseBlockAtSelection(e) },
},
]
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
@@ -233,11 +250,28 @@ function useImageInsert() {
}
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, sourceUrl }, ref) {
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, noteTitle, sourceUrl }, ref) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
const [blockMenuState, setBlockMenuState] = useState<{
anchor: DOMRect
pos: number
node: PMNode | null
} | null>(null)
const dragBlockRef = useRef<{ node: PMNode | null; pos: number }>({ node: null, pos: -1 })
const smartPastePendingRef = useRef<{
reference: ParsedBlockReference
blockPos: number
blockNode: PMNode
blockStatus?: { exists: boolean; content: string; sourceNoteTitle: string }
} | null>(null)
const [smartPasteMenu, setSmartPasteMenu] = useState<{
anchor: { top: number; left: number }
reference: ParsedBlockReference
sourceNoteTitle?: string
} | null>(null)
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
const [noteLinkQuery, setNoteLinkQuery] = useState('')
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
@@ -355,16 +389,38 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
Typography,
ChartExtension,
UniqueIdExtension,
...globalDragHandleExtensions,
SmartPasteExtension,
LiveBlockExtension,
DatabaseBlockExtension,
ClipArticleExtension,
RtlPreserveExtension,
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
],
content: content || '',
immediatelyRender: false,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: { class: 'notion-editor' },
attributes: { class: 'notion-editor tiptap' },
handleDOMEvents: {
keydown: (view, event) => {
if (event.defaultPrevented) return false
if (event.key !== 'Enter' && event.key !== ' ') return false
const { from, empty } = view.state.selection
if (!empty) return false
const textBefore = view.state.doc.textBetween(Math.max(0, from - 32), from, '\n')
if (!/\/(database|db)$/i.test(textBefore)) return false
event.preventDefault()
const slashIdx = textBefore.lastIndexOf('/')
const deleteFrom = from - (textBefore.length - slashIdx)
const ed = editorInstanceRef.current
if (!ed) return false
ed.chain().focus().deleteRange({ from: deleteFrom, to: from }).run()
if (!insertDatabaseBlockAtSelection(ed)) {
toast.error(t('databaseBlock.insertFailed'))
}
return true
},
click: (_view, event) => {
const link = (event.target as HTMLElement).closest('a[href]')
if (!link) return false
@@ -373,11 +429,12 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
if (noteIdMatch) {
event.preventDefault()
event.stopPropagation()
window.open(
`/home?openNote=${decodeURIComponent(noteIdMatch[1])}`,
'_blank',
'noopener,noreferrer'
)
const targetId = decodeURIComponent(noteIdMatch[1])
const blockMatch = href.match(/#block-([^&#]+)/)
openNotePeek({
noteId: targetId,
blockId: blockMatch ? decodeURIComponent(blockMatch[1]) : undefined,
})
return true
}
if (href.startsWith('http://') || href.startsWith('https://')) {
@@ -405,11 +462,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
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()
const ed = editorInstanceRef.current
if (!ed) continue
const inserted = ed.chain().focus().setImage({ src: url }).run()
if (inserted) {
emitContentChange(editorInstance.getHTML())
emitContentChange(ed.getHTML())
}
} catch {
toast.error(t('notes.uploadFailed'))
@@ -447,6 +504,80 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
editorInstanceRef.current = editor ?? null
}, [editor])
useEffect(() => {
if (!editor) return
editor.storage.liveBlock.hostNoteId = noteId ?? null
}, [editor, noteId])
useEffect(() => {
if (!editor) return
editor.storage.smartPaste.onPaste = (view, event) => {
const clipboardText = event.clipboardData?.getData('text/plain') ?? ''
let blockRef = parseBlockReferenceFromText(clipboardText)
if (!blockRef) {
blockRef = recallLastBlockReference()
}
if (!blockRef) return false
const emptyParagraph = getEmptyParagraphAtSelection(view.state)
if (!emptyParagraph) return false
event.preventDefault()
event.stopPropagation()
const coords = view.coordsAtPos(view.state.selection.from)
smartPastePendingRef.current = {
reference: blockRef,
blockPos: emptyParagraph.pos,
blockNode: emptyParagraph.node,
}
queueMicrotask(() => {
setSmartPasteMenu({
anchor: { top: coords.bottom, left: coords.left },
reference: blockRef,
})
})
void fetch(
`/api/blocks/${encodeURIComponent(blockRef.blockId)}/status?sourceNoteId=${encodeURIComponent(blockRef.sourceNoteId)}`,
)
.then((res) => (res.ok ? res.json() : null))
.then((data: { content?: string; sourceNoteTitle?: string; exists?: boolean } | null) => {
if (smartPastePendingRef.current?.reference.raw !== blockRef.raw) return
const recalled = recallLastBlockReference()
const sessionFallback =
recalled?.raw === blockRef.raw
? {
content: recalled.blockContent?.trim() ?? '',
sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '',
}
: { content: '', sourceNoteTitle: '' }
const exists = Boolean(data?.exists) || sessionFallback.content.length > 0
const content = data?.exists ? (data.content ?? '') : (sessionFallback.content || data?.content || '')
const sourceNoteTitle = data?.sourceNoteTitle || sessionFallback.sourceNoteTitle || ''
smartPastePendingRef.current!.blockStatus = {
exists,
content,
sourceNoteTitle,
}
setSmartPasteMenu((prev) =>
prev?.reference.raw === blockRef.raw
? { ...prev, sourceNoteTitle: sourceNoteTitle || prev.sourceNoteTitle }
: prev,
)
})
.catch(() => {})
return true
}
return () => {
editor.storage.smartPaste.onPaste = null
}
}, [editor])
// Chart suggestions dialog state
const [chartSuggestionsOpen, setChartSuggestionsOpen] = useState(false)
const [currentNoteContent, setCurrentNoteContent] = useState(content || '')
@@ -620,6 +751,159 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
handleSelectBlockRef.current = handleSelectBlock
const openBlockActionMenu = useCallback((anchorRect: DOMRect) => {
if (!editor) return
const block = resolveBlockAtDragHandle(editor)
if (!block) return
dragBlockRef.current = block
setBlockMenuState({ anchor: anchorRect, pos: block.pos, node: block.node })
}, [editor])
const closeBlockActionMenu = useCallback(() => {
setBlockMenuState(null)
}, [])
const closeSmartPasteMenu = useCallback(() => {
smartPastePendingRef.current = null
setSmartPasteMenu(null)
}, [])
const handleBlockReferenceCopied = useCallback((html: string) => {
emitContentChange(html)
if (noteId) {
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
detail: { noteId, reason: 'block-reference-copy' },
}))
}
}, [emitContentChange, noteId])
const fetchBlockStatus = useCallback(async (reference: ParsedBlockReference) => {
const cached = smartPastePendingRef.current?.blockStatus
if (cached && smartPastePendingRef.current?.reference.raw === reference.raw) {
return cached
}
const recalled = recallLastBlockReference()
const sessionFallback =
recalled?.raw === reference.raw
? {
content: recalled.blockContent?.trim() ?? '',
sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '',
}
: { content: '', sourceNoteTitle: '' }
const res = await fetch(
`/api/blocks/${encodeURIComponent(reference.blockId)}/status?sourceNoteId=${encodeURIComponent(reference.sourceNoteId)}`,
)
if (!res.ok) {
return {
exists: sessionFallback.content.length > 0,
content: sessionFallback.content,
sourceNoteTitle: sessionFallback.sourceNoteTitle,
}
}
const data = await res.json()
if (data.exists) {
return {
exists: true,
content: data.content ?? '',
sourceNoteTitle: data.sourceNoteTitle ?? '',
}
}
return {
exists: sessionFallback.content.length > 0,
content: sessionFallback.content || data.content || '',
sourceNoteTitle: sessionFallback.sourceNoteTitle || data.sourceNoteTitle || '',
}
}, [])
const handleSmartPasteLive = useCallback(async () => {
const pending = smartPastePendingRef.current
const ed = editorInstanceRef.current
if (!pending || !ed) {
closeSmartPasteMenu()
return
}
const status = await fetchBlockStatus(pending.reference)
if (noteId) {
try {
await fetch('/api/blocks/embed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceNoteId: pending.reference.sourceNoteId,
blockId: pending.reference.blockId,
targetNoteId: noteId,
}),
})
} catch {
// Non-fatal
}
}
const liveBlockType = ed.schema.nodes.liveBlock
if (liveBlockType) {
ed.chain()
.focus()
.command(({ tr, dispatch }) => {
const node = liveBlockType.create({
sourceNoteId: pending.reference.sourceNoteId,
blockId: pending.reference.blockId,
snapshotContent: status.content,
sourceNoteTitle: status.sourceNoteTitle,
})
tr.replaceWith(pending.blockPos, pending.blockPos + pending.blockNode.nodeSize, node)
if (dispatch) dispatch(tr)
return true
})
.run()
emitContentChange(ed.getHTML())
if (noteId) {
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
detail: { noteId, reason: 'smart-paste-live-block' },
}))
}
}
closeSmartPasteMenu()
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus, noteId])
const handleSmartPastePlain = useCallback(async () => {
const pending = smartPastePendingRef.current
const ed = editorInstanceRef.current
if (!pending || !ed) {
closeSmartPasteMenu()
return
}
const status = await fetchBlockStatus(pending.reference)
const linkHref = pending.reference.raw.match(/^https?:\/\//)
? pending.reference.raw
: `${window.location.origin}/home?openNote=${encodeURIComponent(pending.reference.sourceNoteId)}#block-${pending.reference.blockId}`
const linkText = status.sourceNoteTitle?.trim() || pending.reference.raw
ed.chain()
.focus()
.insertContent({
type: 'text',
text: linkText,
marks: [{
type: 'link',
attrs: {
href: linkHref,
target: '_blank',
rel: 'noopener noreferrer',
},
}],
})
.run()
emitContentChange(ed.getHTML())
closeSmartPasteMenu()
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus])
return (
<div className={cn('notion-editor-wrapper', className)}>
{editor && (
@@ -645,8 +929,34 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
<EditorContent editor={editor} />
{editor && blockMenuState && (
<BlockActionMenu
editor={editor}
anchorRect={blockMenuState.anchor}
blockPos={blockMenuState.pos}
blockNode={blockMenuState.node}
noteId={noteId}
sourceNoteTitle={noteTitle}
onBlockReferenceCopied={handleBlockReferenceCopied}
onClose={closeBlockActionMenu}
/>
)}
{smartPasteMenu && (
<SmartPasteMenu
anchor={smartPasteMenu.anchor}
reference={smartPasteMenu.reference}
sourceNoteTitle={smartPasteMenu.sourceNoteTitle}
onLive={() => { void handleSmartPasteLive() }}
onPlain={() => { void handleSmartPastePlain() }}
onClose={closeSmartPasteMenu}
/>
)}
{imageInsert.open && (
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
)}
@@ -973,6 +1283,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...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' },
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'basic', slashKeywords: ['database', 'db', 'base', 'données', 'donnees'] },
{
title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'),
@@ -1021,6 +1332,11 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
finally { setAiLoading(false) }
} else if (item.title === 'Suggest Charts') {
deleteSlashText(); closeMenu(); onSuggestCharts()
} else if (item.title === t('richTextEditor.slashDatabase')) {
deleteSlashText(); closeMenu()
if (!insertDatabaseBlockAtSelection(editor)) {
toast.error(t('databaseBlock.insertFailed'))
}
} else {
deleteSlashText(); item.command(editor); closeMenu()
}
@@ -1029,7 +1345,13 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
const q = query.toLowerCase()
const textFiltered = localCommands.filter(c =>
c.title.toLowerCase().includes(q)
|| c.description.toLowerCase().includes(q)
|| (c.shortcut?.toLowerCase().includes(q) ?? false)
|| (c.slashKeywords?.some((kw) => kw.includes(q) || q.includes(kw)) ?? false)
)
const filtered = activeCategory ? textFiltered.filter(c => c.categoryId === activeCategory) : textFiltered
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
@@ -1070,7 +1392,15 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
if (e.key === 'Enter') {
e.preventDefault()
const item = filtered[selectedIndex]
if (item) handleSelect(item)
if (item) {
handleSelect(item)
} else if (/^(database|db)$/i.test(query)) {
deleteSlashText()
closeMenu()
if (!insertDatabaseBlockAtSelection(editor)) {
toast.error(t('databaseBlock.insertFailed'))
}
}
return
}
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return }