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:
@@ -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:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
122
memento-note/components/smart-paste-extended-menu.tsx
Normal file
122
memento-note/components/smart-paste-extended-menu.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user