feat(editor): implement next-gen editor with unique gutter drag handle, block actions menu, smart paste transclusion, and redesigned inline structured view block (US-NEXTGEN-EDITOR, US-4)

This commit is contained in:
Antigravity
2026-05-27 21:39:21 +00:00
parent 493108f957
commit 07ace46dd3
17 changed files with 2402 additions and 619 deletions

View File

@@ -25,7 +25,7 @@ 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 { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
import { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker'
@@ -69,13 +69,14 @@ export interface RichTextEditorHandle {
insertLiveBlock: (block: BlockSuggestion, options?: { atEnd?: boolean }) => boolean
}
interface RichTextEditorProps {
export interface RichTextEditorProps {
content?: string
onChange?: (content: string) => void
className?: string
placeholder?: string
onImageUpload?: (file: File) => Promise<string>
noteId?: string
notebookId?: string | null
noteTitle?: string
/** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */
sourceUrl?: string | null
@@ -190,12 +191,12 @@ const slashCommands: SlashItem[] = [
}
},
{
title: 'Database', description: 'Inline authors & works database', icon: Database, category: 'Basic blocks', shortcut: '/database',
command: (e) => { insertDatabaseBlockAtSelection(e) },
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
command: (e) => { insertStructuredViewBlockAtSelection(e) },
},
]
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> {
const res = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -250,7 +251,7 @@ function useImageInsert() {
}
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, noteTitle, sourceUrl }, ref) {
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
@@ -392,7 +393,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
...globalDragHandleExtensions,
SmartPasteExtension,
LiveBlockExtension,
DatabaseBlockExtension,
StructuredViewBlockExtension,
ClipArticleExtension,
RtlPreserveExtension,
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
@@ -409,15 +410,15 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
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
if (!/\/(database|db|vue|tableau|structured)$/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'))
if (!insertStructuredViewBlockAtSelection(ed, notebookId)) {
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
}
return true
},
@@ -509,6 +510,13 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
editor.storage.liveBlock.hostNoteId = noteId ?? null
}, [editor, noteId])
useEffect(() => {
if (!editor) return
if ((editor.storage as any).structuredViewBlock) {
(editor.storage as any).structuredViewBlock.notebookId = notebookId ?? null
}
}, [editor, notebookId])
useEffect(() => {
if (!editor) return
@@ -516,7 +524,10 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
const clipboardText = event.clipboardData?.getData('text/plain') ?? ''
let blockRef = parseBlockReferenceFromText(clipboardText)
if (!blockRef) {
blockRef = recallLastBlockReference()
const recalled = recallLastBlockReference()
if (recalled && (!clipboardText.trim() || clipboardText.trim() === recalled.raw)) {
blockRef = recalled
}
}
if (!blockRef) return false
@@ -961,7 +972,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
)}
{chartSuggestionsOpen && (
{chartSuggestionsOpen && editor && (
<ChartSuggestionsDialog
isOpen={chartSuggestionsOpen}
content={currentNoteContent}
@@ -1096,7 +1107,7 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
setTranslateOpen(false)
try {
const lang = option === 'translate' ? (targetLang || language) : language
const result = await aiReformulate(text, option, lang)
const result = await aiReformulate(text, option, t, lang)
window.dispatchEvent(new Event('ai-usage-changed'))
if (option === 'explain') {
setAiModal({ type: 'explain', origText: text, html: result, from, to })
@@ -1283,7 +1294,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'] },
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'basic', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
{
title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'),
@@ -1323,7 +1334,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
if (!consented) return
const allText = editor.state.doc.textContent
if (!allText || allText.split(/\s+/).length < 5) return
const result = await aiReformulate(allText, item.aiOption)
const result = await aiReformulate(allText, item.aiOption, t)
editor.chain().focus().setContent(result).run()
} catch (err) {
console.error('AI slash error:', err)
@@ -1334,8 +1345,9 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
deleteSlashText(); closeMenu(); onSuggestCharts()
} else if (item.title === t('richTextEditor.slashDatabase')) {
deleteSlashText(); closeMenu()
if (!insertDatabaseBlockAtSelection(editor)) {
toast.error(t('databaseBlock.insertFailed'))
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) {
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
}
} else {
deleteSlashText(); item.command(editor); closeMenu()
@@ -1394,11 +1406,12 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const item = filtered[selectedIndex]
if (item) {
handleSelect(item)
} else if (/^(database|db)$/i.test(query)) {
} else if (/^(database|db|vue|tableau|structured)$/i.test(query)) {
deleteSlashText()
closeMenu()
if (!insertDatabaseBlockAtSelection(editor)) {
toast.error(t('databaseBlock.insertFailed'))
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) {
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
}
}
return