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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user