feat(editor): implement US-EDITOR-UX with global block selection, redesigned slash menu (favorites & preview), contextual placeholders, smart paste extended, turn into and undo/redo toasts

This commit is contained in:
Antigravity
2026-05-27 21:47:50 +00:00
parent e3cb1307d3
commit ad8b8b815e
9 changed files with 1112 additions and 157 deletions

View File

@@ -32,11 +32,15 @@ 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 { SmartPasteExtendedMenu } from './smart-paste-extended-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 { BlockSelectionExtension } from '@/lib/editor/block-selection-extension'
import { TurnIntoShortcutExtension } from '@/lib/editor/turn-into-shortcut-extension'
import { UndoRedoFeedbackExtension } from '@/lib/editor/undo-redo-feedback-extension'
import type { Node as PMNode } from '@tiptap/pm/model'
import { detectTextDirection } from '@/lib/clip/rtl-content'
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
@@ -98,18 +102,19 @@ type SlashItem = {
command: (editor: Editor, range?: any) => void
}
type SlashCategoryId = 'basic' | 'media' | 'formatting' | 'ai'
type SlashCategoryId = 'text' | 'media' | 'data' | 'embed' | 'ai'
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId; slashKeywords?: string[] }
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['basic', 'media', 'formatting', 'ai']
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['text', 'media', 'data', 'embed', 'ai']
function slashCategoryLabel(id: SlashCategoryId, t: (key: string) => string): string {
switch (id) {
case 'basic': return t('richTextEditor.slashCatBasic')
case 'media': return t('richTextEditor.slashCatMedia')
case 'formatting': return t('richTextEditor.slashCatFormatting')
case 'ai': return t('richTextEditor.slashCatAi')
case 'text': return t('richTextEditor.slashCatText') || 'Texte'
case 'media': return t('richTextEditor.slashCatMedia') || 'Médias'
case 'data': return t('richTextEditor.slashCatData') || 'Données'
case 'embed': return t('richTextEditor.slashCatEmbed') || 'Intégré'
case 'ai': return t('richTextEditor.slashCatAi') || 'IA Note'
}
}
@@ -273,6 +278,13 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
reference: ParsedBlockReference
sourceNoteTitle?: string
} | null>(null)
const [smartPasteExtended, setSmartPasteExtended] = useState<{
type: 'url' | 'code'
text: string
anchor: { top: number; left: number }
isImage?: boolean
isVideo?: boolean
} | null>(null)
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
const [noteLinkQuery, setNoteLinkQuery] = useState('')
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
@@ -392,11 +404,33 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
UniqueIdExtension,
...globalDragHandleExtensions,
SmartPasteExtension,
BlockSelectionExtension,
TurnIntoShortcutExtension,
UndoRedoFeedbackExtension,
LiveBlockExtension,
StructuredViewBlockExtension,
ClipArticleExtension,
RtlPreserveExtension,
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
const level = node.attrs.level
if (level === 1) return t('richTextEditor.placeholderH1') || 'Titre principal...'
if (level === 2) return t('richTextEditor.placeholderH2') || 'Titre de section...'
if (level === 3) return t('richTextEditor.placeholderH3') || 'Sous-titre...'
}
if (node.type.name === 'taskItem') {
return t('richTextEditor.placeholderTodo') || 'Ajouter une tâche...'
}
if (node.type.name === 'codeBlock') {
return t('richTextEditor.placeholderCode') || 'Écrire du code...'
}
if (node.type.name === 'blockquote') {
return t('richTextEditor.placeholderQuote') || 'Saisir une citation...'
}
return placeholder || t('richTextEditor.placeholderText') || t('richTextEditor.placeholder') || "Tapez '/' pour insérer un bloc..."
}
}),
],
content: content || '',
immediatelyRender: false,
@@ -529,59 +563,98 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
blockRef = recalled
}
}
if (!blockRef) return false
if (blockRef) {
const emptyParagraph = getEmptyParagraphAtSelection(view.state)
if (!emptyParagraph) return false
const emptyParagraph = getEmptyParagraphAtSelection(view.state)
if (!emptyParagraph) return false
event.preventDefault()
event.stopPropagation()
event.preventDefault()
event.stopPropagation()
const coords = view.coordsAtPos(view.state.selection.from)
smartPastePendingRef.current = {
reference: blockRef,
blockPos: emptyParagraph.pos,
blockNode: emptyParagraph.node,
}
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
}
queueMicrotask(() => {
setSmartPasteMenu({
// Détection d'URL HTTP(S) cliquable ou média
const isUrl = /^https?:\/\/[^\s]+$/i.test(clipboardText.trim())
if (isUrl) {
const url = clipboardText.trim()
event.preventDefault()
event.stopPropagation()
const coords = view.coordsAtPos(view.state.selection.from)
const isImage = /\.(jpeg|jpg|gif|png|svg|webp)($|\?)/i.test(url)
const isVideo = /(youtube\.com|youtu\.be|vimeo\.com|streamable\.com)/i.test(url) || /\.(mp4|webm|ogg)($|\?)/i.test(url)
setSmartPasteExtended({
type: 'url',
text: url,
anchor: { top: coords.bottom, left: coords.left },
reference: blockRef,
isImage,
isVideo,
})
})
return true
}
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,
)
// Détection de code source technique
const hasCodeSpecialChars = /[{}[\];]/.test(clipboardText)
const hasCodeKeywords = /(const\s+\w+\s*=|let\s+\w+\s*=|function\s+\w*\(|import\s+.*from|class\s+\w+|def\s+\w+\(|public\s+class\s+\w+|#include\s+<|import\s+react|var\s+\w+\s*=)/.test(clipboardText)
const isMultiline = clipboardText.split('\n').length > 1
if ((hasCodeSpecialChars && hasCodeKeywords) || (isMultiline && hasCodeSpecialChars && clipboardText.includes('('))) {
event.preventDefault()
event.stopPropagation()
const coords = view.coordsAtPos(view.state.selection.from)
setSmartPasteExtended({
type: 'code',
text: clipboardText,
anchor: { top: coords.bottom, left: coords.left },
})
.catch(() => {})
return true
}
return true
return false
}
return () => {
@@ -915,6 +988,54 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
closeSmartPasteMenu()
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus])
const handlePasteUrlLink = useCallback((url: string) => {
if (!editor) return
const { from, to, empty } = editor.state.selection
if (empty) {
editor.chain().focus().insertContent(`<a href="${url}">${url}</a>`).run()
} else {
editor.chain().focus().setLink({ href: url }).run()
}
setSmartPasteExtended(null)
emitContentChange(editor.getHTML())
}, [editor, emitContentChange])
const handlePasteUrlImage = useCallback((url: string) => {
if (!editor) return
editor.chain().focus().setImage({ src: url }).run()
setSmartPasteExtended(null)
emitContentChange(editor.getHTML())
}, [editor, emitContentChange])
const handlePasteUrlVideo = useCallback((url: string) => {
if (!editor) return
const { from, to, empty } = editor.state.selection
if (empty) {
editor.chain().focus().insertContent(`🎥 <a href="${url}">${t('richTextEditor.slashVideo') || 'Vidéo'} (${url})</a>`).run()
} else {
editor.chain().focus().setLink({ href: url }).run()
}
setSmartPasteExtended(null)
emitContentChange(editor.getHTML())
}, [editor, emitContentChange, t])
const handlePasteCodeBlock = useCallback((code: string) => {
if (!editor) return
editor.chain().focus().insertContent({
type: 'codeBlock',
content: [{ type: 'text', text: code }]
}).run()
setSmartPasteExtended(null)
emitContentChange(editor.getHTML())
}, [editor, emitContentChange])
const handlePastePlain = useCallback((text: string) => {
if (!editor) return
editor.chain().focus().insertContent(text).run()
setSmartPasteExtended(null)
emitContentChange(editor.getHTML())
}, [editor, emitContentChange])
return (
<div className={cn('notion-editor-wrapper', className)}>
{editor && (
@@ -968,6 +1089,22 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
/>
)}
{smartPasteExtended && (
<SmartPasteExtendedMenu
type={smartPasteExtended.type}
text={smartPasteExtended.text}
anchor={smartPasteExtended.anchor}
isImage={smartPasteExtended.isImage}
isVideo={smartPasteExtended.isVideo}
onLink={() => handlePasteUrlLink(smartPasteExtended.text)}
onImage={() => handlePasteUrlImage(smartPasteExtended.text)}
onVideo={() => handlePasteUrlVideo(smartPasteExtended.text)}
onCodeBlock={() => handlePasteCodeBlock(smartPasteExtended.text)}
onPlain={() => handlePastePlain(smartPasteExtended.text)}
onClose={() => setSmartPasteExtended(null)}
/>
)}
{imageInsert.open && (
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
)}
@@ -1259,6 +1396,125 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
)
}
function SlashPreview({ itemTitle }: { itemTitle: string }) {
switch (itemTitle) {
case 'Table':
case 'Tableau':
return (
<div className="slash-preview-box">
<div className="slash-preview-title">Tableau</div>
<div className="slash-preview-grid">
<div className="slash-preview-grid-header"></div>
<div className="slash-preview-grid-header"></div>
<div className="slash-preview-grid-header"></div>
<div className="slash-preview-grid-cell"></div>
<div className="slash-preview-grid-cell"></div>
<div className="slash-preview-grid-cell"></div>
<div className="slash-preview-grid-cell"></div>
<div className="slash-preview-grid-cell"></div>
<div className="slash-preview-grid-cell"></div>
</div>
<p className="slash-preview-tip">Organisez vos données en lignes et colonnes.</p>
</div>
)
case 'Database':
case 'Base de données':
return (
<div className="slash-preview-box">
<div className="slash-preview-title">Base de Données</div>
<div className="slash-preview-db">
<div className="slash-preview-db-row header">
<span>Nom</span>
<span>Statut</span>
</div>
<div className="slash-preview-db-row">
<span>Tâche A</span>
<span className="badge badge-todo">À faire</span>
</div>
<div className="slash-preview-db-row">
<span>Tâche B</span>
<span className="badge badge-done">Fait</span>
</div>
</div>
<p className="slash-preview-tip">Ajoutez des colonnes et des vues Kanban structurées.</p>
</div>
)
case 'Suggest Charts':
case 'Suggest Chart':
return (
<div className="slash-preview-box">
<div className="slash-preview-title">Graphique IA</div>
<div className="slash-preview-chart">
<div className="chart-bar" style={{ height: '40%' }}></div>
<div className="chart-bar" style={{ height: '75%' }}></div>
<div className="chart-bar" style={{ height: '50%' }}></div>
<div className="chart-bar" style={{ height: '90%' }}></div>
</div>
<p className="slash-preview-tip">Générez un graphique interactif à partir de votre texte.</p>
</div>
)
case 'Living Block':
case 'Bloc vivant':
return (
<div className="slash-preview-box">
<div className="slash-preview-title">Bloc Vivant (Transclusion)</div>
<div className="slash-preview-live">
<div className="live-note">Note A</div>
<div className="live-sync-line"></div>
<div className="live-note">Note B</div>
</div>
<p className="slash-preview-tip">Synchronisez du contenu en temps réel entre plusieurs notes.</p>
</div>
)
case 'Diagramme':
case 'Diagram':
return (
<div className="slash-preview-box">
<div className="slash-preview-title">Diagramme Excalidraw</div>
<div className="slash-preview-excalidraw">
<div className="excalidraw-circle"></div>
<div className="excalidraw-arrow"></div>
<div className="excalidraw-rect"></div>
</div>
<p className="slash-preview-tip">Esquissez des concepts ou générez des diagrammes via IA.</p>
</div>
)
case 'Présentation':
case 'Presentation':
return (
<div className="slash-preview-box">
<div className="slash-preview-title">Présentation Slides</div>
<div className="slash-preview-slides">
<div className="slide-item"></div>
<div className="slide-item active"></div>
<div className="slide-item"></div>
</div>
<p className="slash-preview-tip">Créez des présentations interactives exportables.</p>
</div>
)
case 'Code Block':
case 'Code':
case 'Bloc de code':
return (
<div className="slash-preview-box">
<div className="slash-preview-title">Bloc de Code</div>
<div className="slash-preview-code">
<div className="flex gap-1 mb-1">
<span className="code-dot red"></span>
<span className="code-dot yellow"></span>
<span className="code-dot green"></span>
</div>
<div className="code-line green"></div>
<div className="code-line blue"></div>
</div>
<p className="slash-preview-tip">Ajoutez du code avec coloration syntaxique automatique.</p>
</div>
)
default:
return null
}
}
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
@@ -1267,48 +1523,50 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const [selectedIndex, setSelectedIndex] = useState(0)
const [activeCategory, setActiveCategory] = useState<SlashCategoryId | null>(null)
const [coords, setCoords] = useState({ top: 0, left: 0 })
const [previewCoords, setPreviewCoords] = useState({ top: 0, left: 0, side: 'right' as 'right' | 'left' })
const [aiLoading, setAiLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const selectedItemRef = useRef<HTMLButtonElement>(null)
const menuInteracting = useRef(false)
const [frequentCommands, setFrequentCommands] = useState<SlashMenuItem[]>([])
const localCommands: SlashMenuItem[] = [
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'basic' },
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'basic' },
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'basic' },
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'basic' },
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'basic' },
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'basic' },
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'basic' },
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'basic' },
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'basic' },
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'basic' },
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'basic' },
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' },
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' },
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' },
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' },
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data' },
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text' },
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text' },
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text' },
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' },
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' },
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' },
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'formatting' },
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'formatting' },
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'formatting' },
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' },
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' },
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' },
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'formatting' },
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'formatting' },
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'formatting' },
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'formatting' },
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'formatting' },
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'formatting' },
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'formatting' },
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' },
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' },
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' },
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' },
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' },
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' },
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
{ ...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' },
{ ...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'] },
{ ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'embed' },
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
{
title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'),
icon: Link2,
categoryId: 'basic' as SlashCategoryId,
categoryId: 'embed' as SlashCategoryId,
command: (e) => {
e.chain().focus().insertContent('[[').run()
},
@@ -1330,6 +1588,16 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
}, [editor])
const handleSelect = useCallback(async (item: SlashMenuItem) => {
// Persister l'historique d'usage pour la section Favoris
try {
const usageStr = localStorage.getItem('memento-slash-command-usage') || '{}'
const usage = JSON.parse(usageStr) as Record<string, number>
usage[item.title] = (usage[item.title] || 0) + 1
localStorage.setItem('memento-slash-command-usage', JSON.stringify(usage))
} catch (e) {
console.warn('Slash usage count error:', e)
}
const toastAi = (err: unknown) => {
const msg = err instanceof Error ? err.message : ''
toast.error(msg && msg !== AI_REFORMULATE_FALLBACK ? msg : t('richTextEditor.aiReformulateFailed'))
@@ -1361,7 +1629,23 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
} else {
deleteSlashText(); item.command(editor); closeMenu()
}
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t])
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent])
// Charger les favoris fréquents lors de l'ouverture
useEffect(() => {
if (!isOpen) return
try {
const usageStr = localStorage.getItem('memento-slash-command-usage') || '{}'
const usage = JSON.parse(usageStr) as Record<string, number>
const sorted = localCommands
.filter(c => (usage[c.title] || 0) > 0)
.sort((a, b) => (usage[b.title] || 0) - (usage[a.title] || 0))
.slice(0, 4)
setFrequentCommands(sorted)
} catch (e) {
setFrequentCommands([])
}
}, [isOpen])
const presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
@@ -1373,7 +1657,13 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|| (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 baseFiltered = activeCategory ? textFiltered.filter(c => c.categoryId === activeCategory) : textFiltered
// Injecter les favoris tout en haut si pas de recherche ni filtre de catégorie actif
const filtered = !q && !activeCategory && frequentCommands.length > 0
? [...frequentCommands.map(c => ({ ...c, isFavorite: true })), ...baseFiltered]
: baseFiltered
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
const id = item.categoryId
@@ -1382,7 +1672,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
return acc
}, {} as Record<SlashCategoryId, SlashMenuItem[]>)
const categories = filtered.reduce((acc, item) => {
const categories = baseFiltered.reduce((acc, item) => {
const id = item.categoryId
if (!acc[id]) acc[id] = []
acc[id].push(item)
@@ -1394,6 +1684,29 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
selectedItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}, [selectedIndex])
// Ajustement dynamique de la position de la preview
useEffect(() => {
if (!isOpen) return
const menuWidth = 320
const previewWidth = 280
const padding = 12
const wouldOverflowRight = coords.left + menuWidth + previewWidth + padding > window.innerWidth
if (wouldOverflowRight) {
setPreviewCoords({
top: coords.top,
left: coords.left - previewWidth - padding,
side: 'left',
})
} else {
setPreviewCoords({
top: coords.top,
left: coords.left + menuWidth + padding,
side: 'right',
})
}
}, [coords, isOpen])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return
@@ -1437,7 +1750,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
}
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, activeCategory, allCategories, availableCategoriesInSearch])
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, activeCategory, allCategories, availableCategoriesInSearch, query, deleteSlashText, editor, t])
useEffect(() => {
if (!isOpen) return
@@ -1453,7 +1766,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
} else {
setCoords({ top: c.bottom + 8, left: c.left })
}
}, [isOpen, editor, query, filtered.length])
}, [isOpen, editor, query, filtered.length, coords.left])
useEffect(() => {
const handleClick = (e: MouseEvent) => {
@@ -1491,87 +1804,121 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0)
const totalVisible = sectionIds.length
return createPortal(
<div
ref={menuRef}
className="notion-slash-menu"
style={{ top: coords.top, left: coords.left }}
onClick={(e) => e.stopPropagation()}
>
{/* Category tabs */}
{!query && totalVisible > 1 && (
<div className="notion-slash-tabs" onMouseDown={(e) => e.preventDefault()}>
<button
className={cn('notion-slash-tab', !activeCategory && 'notion-slash-tab-active')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => { setActiveCategory(null); setSelectedIndex(0) }}
>
{t('richTextEditor.slashTabAll')}
</button>
{allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0).map(catId => (
<button
key={catId}
className={cn('notion-slash-tab', activeCategory === catId && 'notion-slash-tab-active', catId === 'ai' && 'notion-slash-tab-ai')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => { setActiveCategory(activeCategory === catId ? null : catId); setSelectedIndex(0) }}
>
{catId === 'ai' && <Sparkles className="w-2.5 h-2.5" />}
{slashCategoryLabel(catId, t)}
<span className="notion-slash-tab-count">{availableCategoriesInSearch[catId]?.length ?? 0}</span>
</button>
))}
</div>
)}
const visibleSectionIds = [...sectionIds]
// Si pas de query, ajouter la section Favoris au début
const hasFavorites = !q && !activeCategory && frequentCommands.length > 0
if (hasFavorites) {
visibleSectionIds.unshift('frequent' as any)
}
{/* Header hint */}
<div className="notion-slash-header">
<span>{t('richTextEditor.slashHint')}</span>
const selectedItem = filtered[selectedIndex]
const showPreview = selectedItem && [
'Table', 'Tableau', 'Database', 'Suggest Charts', 'Suggest Chart', 'Living Block', 'Bloc vivant', 'Diagramme', 'Diagram', 'Présentation', 'Presentation', 'Code Block', 'Code', 'Bloc de code'
].includes(selectedItem.title)
return createPortal(
<>
<div
ref={menuRef}
className="notion-slash-menu"
style={{ top: coords.top, left: coords.left }}
onClick={(e) => e.stopPropagation()}
>
{/* Category tabs */}
{!query && totalVisible > 1 && (
<div className="notion-slash-tabs" onMouseDown={(e) => e.preventDefault()}>
<button
className={cn('notion-slash-tab', !activeCategory && 'notion-slash-tab-active')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => { setActiveCategory(null); setSelectedIndex(0) }}
>
{t('richTextEditor.slashTabAll')}
</button>
{allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0).map(catId => (
<button
key={catId}
className={cn('notion-slash-tab', activeCategory === catId && 'notion-slash-tab-active', catId === 'ai' && 'notion-slash-tab-ai')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => { setActiveCategory(activeCategory === catId ? null : catId); setSelectedIndex(0) }}
>
{catId === 'ai' && <Sparkles className="w-2.5 h-2.5" />}
{slashCategoryLabel(catId, t)}
<span className="notion-slash-tab-count">{availableCategoriesInSearch[catId]?.length ?? 0}</span>
</button>
))}
</div>
)}
{/* Header hint */}
<div className="notion-slash-header">
<span>{t('richTextEditor.slashHint')}</span>
</div>
{aiLoading && (
<div className="notion-slash-loading">
<Sparkles className="w-3.5 h-3.5 animate-spin" />
<span>{t('richTextEditor.slashLoading')}</span>
</div>
)}
{!aiLoading && visibleSectionIds.map((catId) => {
const items = catId === ('frequent' as any)
? frequentCommands.map(c => ({ ...c, isFavorite: true }))
: categories[catId]!
return (
<div key={catId} className="notion-slash-section">
<div className={cn(
'notion-slash-label',
catId === 'ai' && 'notion-slash-label-ai',
catId === ('frequent' as any) && 'text-amber-500 font-semibold'
)}>
{catId === 'ai' && <Sparkles className="w-3 h-3 inline mr-1" />}
{catId === ('frequent' as any) ? '★ Fréquents' : slashCategoryLabel(catId, t)}
</div>
{items.map((item) => {
flatIndex++
const idx = flatIndex
const isSelected = idx === selectedIndex
return (
<button
key={`${catId}-${item.title}-${item.shortcut ?? ''}-${item.isFavorite ? 'fav' : 'normal'}`}
ref={isSelected ? selectedItemRef : null}
className={cn(
'notion-slash-item',
isSelected && 'notion-slash-item-selected',
item.isFavorite && 'notion-slash-item-favorite'
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
<item.icon className="w-4 h-4" />
</div>
<div className="notion-slash-content">
<span className="notion-slash-title">{item.title}</span>
<span className="notion-slash-desc">{item.description}</span>
</div>
{item.shortcut && (
<span className="notion-slash-shortcut">{item.shortcut}</span>
)}
</button>
)
})}
</div>
)
})}
</div>
{aiLoading && (
<div className="notion-slash-loading">
<Sparkles className="w-3.5 h-3.5 animate-spin" />
<span>{t('richTextEditor.slashLoading')}</span>
{showPreview && (
<div
className="notion-slash-preview"
style={{ top: previewCoords.top, left: previewCoords.left }}
>
<SlashPreview itemTitle={selectedItem.title} />
</div>
)}
{!aiLoading && sectionIds.map((catId) => {
const items = categories[catId]!
return (
<div key={catId} className="notion-slash-section">
<div className={cn('notion-slash-label', catId === 'ai' && 'notion-slash-label-ai')}>
{catId === 'ai' && <Sparkles className="w-3 h-3 inline mr-1" />}
{slashCategoryLabel(catId, t)}
</div>
{items.map((item) => {
flatIndex++
const idx = flatIndex
const isSelected = idx === selectedIndex
return (
<button
key={`${catId}-${item.title}-${item.shortcut ?? ''}`}
ref={isSelected ? selectedItemRef : null}
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
<item.icon className="w-4 h-4" />
</div>
<div className="notion-slash-content">
<span className="notion-slash-title">{item.title}</span>
<span className="notion-slash-desc">{item.description}</span>
</div>
{item.shortcut && (
<span className="notion-slash-shortcut">{item.shortcut}</span>
)}
</button>
)
})}
</div>
)
})}
</div>,
</>,
document.body
)
}
@@ -1580,3 +1927,4 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:

View File

@@ -0,0 +1,122 @@
'use client'
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useLanguage } from '@/lib/i18n'
import { Link2, ImageIcon, Video, Code, FileText } from 'lucide-react'
export type SmartPasteExtendedMenuProps = {
type: 'url' | 'code'
text: string
anchor: { top: number; left: number }
isImage?: boolean
isVideo?: boolean
onLink?: () => void
onImage?: () => void
onVideo?: () => void
onCodeBlock?: () => void
onPlain: () => void
onClose: () => void
}
export function SmartPasteExtendedMenu({
type,
text,
anchor,
isImage,
isVideo,
onLink,
onImage,
onVideo,
onCodeBlock,
onPlain,
onClose,
}: SmartPasteExtendedMenuProps) {
const { t } = useLanguage()
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [onClose])
const menuStyle: React.CSSProperties = {
position: 'fixed',
left: anchor.left,
top: anchor.top + 8,
zIndex: 9999,
maxWidth: 340,
width: '100%',
}
if (Number(menuStyle.left) + 340 > window.innerWidth) {
menuStyle.left = Math.max(8, window.innerWidth - 348)
}
// Estimation de hauteur pour éviter les débordements
const expectedHeight = type === 'url' ? (200) : (140)
if (Number(menuStyle.top) + expectedHeight > window.innerHeight) {
menuStyle.top = Math.max(8, anchor.top - expectedHeight - 8)
}
const shortenedText = text.length > 50 ? `${text.slice(0, 47)}...` : text
return createPortal(
<div ref={menuRef} style={menuStyle} className="block-action-menu smart-paste-menu">
<p className="smart-paste-menu__hint font-semibold">
{type === 'url' ? t('richTextEditor.smartPasteUrlTitle') || 'Lien ou Média détecté' : t('richTextEditor.smartPasteCodeTitle') || 'Code source détecté'}
</p>
<p className="smart-paste-menu__source text-xs truncate max-w-full" title={text}>
{shortenedText}
</p>
<p className="text-[10px] text-muted-foreground px-3 mb-2">
{type === 'url' ? t('richTextEditor.smartPasteUrlHint') || 'Que souhaitez-vous faire avec ce lien ?' : t('richTextEditor.smartPasteCodeHint') || 'Du code source a été détecté. Souhaitez-vous l\'insérer comme bloc de code ?'}
</p>
{type === 'url' && (
<>
<button type="button" className="block-action-item" onClick={onLink}>
<Link2 size={15} className="text-blue-500" />
<span>{t('richTextEditor.smartPasteUrlLink') || 'Coller comme lien hypertexte'}</span>
</button>
{isImage && onImage && (
<button type="button" className="block-action-item" onClick={onImage}>
<ImageIcon size={15} className="text-emerald-500" />
<span>{t('richTextEditor.smartPasteUrlImage') || 'Insérer comme image'}</span>
</button>
)}
{isVideo && onVideo && (
<button type="button" className="block-action-item" onClick={onVideo}>
<Video size={15} className="text-rose-500" />
<span>{t('richTextEditor.smartPasteUrlVideo') || 'Insérer comme lecteur vidéo'}</span>
</button>
)}
</>
)}
{type === 'code' && onCodeBlock && (
<button type="button" className="block-action-item" onClick={onCodeBlock}>
<Code size={15} className="text-purple-500" />
<span>{t('richTextEditor.smartPasteCodeBlock') || 'Insérer comme bloc de code'}</span>
</button>
)}
<button type="button" className="block-action-item border-t border-border/50 mt-1 pt-1" onClick={onPlain}>
<FileText size={15} className="text-muted-foreground" />
<span>{t('richTextEditor.smartPastePlain') || 'Coller en texte brut'}</span>
</button>
</div>,
document.body,
)
}