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:
@@ -22,7 +22,7 @@
|
||||
| **US-STRUCTURED-VIEWS** | Vues Structurées (Tableau/Kanban/Galerie) | ✅ **LIVRÉ** | `/api/notebooks/[id]/schema`, `/api/notes/[id]/properties`, vues structurées + panneau propriétés éditeur |
|
||||
| **US-NEXTGEN-EDITOR** | Éditeur Next-Gen : Drag Handle + Menu Bloc + **Vue Structurée Inline** (redesign US-4) + Smart Paste | ✅ **LIVRÉ** | Voir `docs/story-nextgen-editor.md` + `docs/story-nextgen-editor-us4-redesign.md` |
|
||||
| **US-EDITOR-PERF** | Performance de frappe TipTap (quick wins) | ✅ **LIVRÉ** | `rich-text-editor.tsx` (useEditorState), `note-editor-context.tsx` (debounced setContent) |
|
||||
| **US-EDITOR-UX** | Micro-interactions saisie (slash menu, sélection multi-blocs, paste étendu, placeholders) | ⏳ **À FAIRE** | — |
|
||||
| **US-EDITOR-UX** | Micro-interactions saisie (slash menu, sélection multi-blocs, paste étendu, placeholders) | ✅ **LIVRÉ** | Sélection globale, redesign Slash Menu (favoris/preview), placeholders contextuels, smart paste étendu, Turn Into & Undo/Redo |
|
||||
| **US-EDITOR-MOBILE** | Expérience tactile & toolbar mobile adaptée | ⏳ **À FAIRE** | — |
|
||||
| **US-EDITOR-MARKDOWN** | Rendu WYSIWYG Markdown fidèle (round-trip byte-for-byte) | ⏳ **À FAIRE** | — |
|
||||
|
||||
@@ -703,7 +703,7 @@ const { isBold, isItalic, isHeading } = useEditorState({
|
||||
|
||||
## US-EDITOR-UX — Micro-Interactions de Saisie
|
||||
|
||||
> **Status :** À FAIRE
|
||||
> **Status :** LIVRÉ
|
||||
> **Depends on :** US-NEXTGEN-EDITOR (drag handle et menu bloc doivent être en place)
|
||||
> **Source recherche :** Mintlify "22 UX improvements" (mai 2026), BlockNote v0.50, BlockNote v0.49
|
||||
|
||||
|
||||
@@ -2497,6 +2497,318 @@ html.font-system * {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Sélection premium de blocs */
|
||||
.block-selected {
|
||||
background-color: hsl(var(--accent) / 0.3) !important;
|
||||
outline: 1.5px solid hsl(var(--accent) / 0.5) !important;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
transition: background-color 0.15s ease, outline 0.15s ease;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Slash Menu Preview & Favorites — Premium Design
|
||||
============================================ */
|
||||
.notion-slash-preview {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
width: 280px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), 0 12px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 12px;
|
||||
animation: preview-enter 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dark .notion-slash-preview {
|
||||
background: rgba(20, 20, 20, 0.85);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3), 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
@keyframes preview-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.slash-preview-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.slash-preview-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.02em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.slash-preview-tip {
|
||||
font-size: 10.5px;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Miniatures de preview interactives */
|
||||
.slash-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 3px;
|
||||
background: var(--muted);
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.slash-preview-grid-header {
|
||||
height: 14px;
|
||||
background: hsl(var(--primary) / 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.slash-preview-grid-cell {
|
||||
height: 14px;
|
||||
background: var(--background);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.slash-preview-db {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
background: var(--muted);
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.slash-preview-db-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 9px;
|
||||
height: 16px;
|
||||
background: var(--background);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.slash-preview-db-row.header {
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 8px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-todo {
|
||||
background: hsl(200 80% 50% / 0.15);
|
||||
color: hsl(200 80% 40%);
|
||||
}
|
||||
|
||||
.badge-done {
|
||||
background: hsl(120 80% 50% / 0.15);
|
||||
color: hsl(120 80% 35%);
|
||||
}
|
||||
|
||||
.slash-preview-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
background: var(--muted);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--border);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
flex: 1;
|
||||
background: linear-gradient(to top, hsl(var(--primary)), hsl(var(--primary) / 0.5));
|
||||
border-radius: 2px 2px 0 0;
|
||||
min-height: 4px;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.slash-preview-live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--muted);
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.live-note {
|
||||
font-size: 9px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.live-sync-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
border-top: 1.5px dotted hsl(var(--primary) / 0.6);
|
||||
margin: 0 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.live-sync-line::after {
|
||||
content: '⚡';
|
||||
position: absolute;
|
||||
font-size: 8px;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.slash-preview-excalidraw {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 48px;
|
||||
background: var(--muted);
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.excalidraw-circle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1.5px solid hsl(var(--foreground));
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
left: 30%;
|
||||
}
|
||||
|
||||
.excalidraw-rect {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1.5px solid hsl(var(--foreground));
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
right: 30%;
|
||||
}
|
||||
|
||||
.excalidraw-arrow {
|
||||
width: 24px;
|
||||
height: 1.5px;
|
||||
background: hsl(var(--primary));
|
||||
position: absolute;
|
||||
transform: rotate(-15deg);
|
||||
}
|
||||
|
||||
.excalidraw-arrow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -2.5px;
|
||||
border-top: 3px solid transparent;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-left: 5px solid hsl(var(--primary));
|
||||
}
|
||||
|
||||
.slash-preview-slides {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 48px;
|
||||
background: var(--muted);
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--border);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.slide-item {
|
||||
width: 32px;
|
||||
height: 22px;
|
||||
background: var(--background);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 3px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.slide-item.active {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slash-preview-code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: #1e1e1e;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.code-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.code-dot.red { background: #ff5f56; }
|
||||
.code-dot.yellow { background: #ffbd2e; }
|
||||
.code-dot.green { background: #27c93f; }
|
||||
|
||||
.code-line {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.code-line.green { background: #a9ff68; width: 45%; }
|
||||
.code-line.blue { background: #54b2ff; width: 70%; }
|
||||
|
||||
/* Style des éléments favoris */
|
||||
.notion-slash-item-favorite {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notion-slash-item-favorite::after {
|
||||
content: '★';
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #ffb700;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dark .notion-slash-item-favorite::after {
|
||||
color: #ffd260;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
Note Card Rich Text Preview
|
||||
============================================ */
|
||||
|
||||
@@ -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,8 +563,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
blockRef = recalled
|
||||
}
|
||||
}
|
||||
if (!blockRef) return false
|
||||
|
||||
if (blockRef) {
|
||||
const emptyParagraph = getEmptyParagraphAtSelection(view.state)
|
||||
if (!emptyParagraph) return false
|
||||
|
||||
@@ -584,6 +617,46 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
return true
|
||||
}
|
||||
|
||||
// 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 },
|
||||
isImage,
|
||||
isVideo,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// 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 },
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return () => {
|
||||
editor.storage.smartPaste.onPaste = null
|
||||
}
|
||||
@@ -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,7 +1804,20 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
const sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0)
|
||||
const totalVisible = sectionIds.length
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -1534,13 +1860,20 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
<span>{t('richTextEditor.slashLoading')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!aiLoading && sectionIds.map((catId) => {
|
||||
const items = categories[catId]!
|
||||
{!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')}>
|
||||
<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" />}
|
||||
{slashCategoryLabel(catId, t)}
|
||||
{catId === ('frequent' as any) ? '★ Fréquents' : slashCategoryLabel(catId, t)}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
flatIndex++
|
||||
@@ -1548,9 +1881,13 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
const isSelected = idx === selectedIndex
|
||||
return (
|
||||
<button
|
||||
key={`${catId}-${item.title}-${item.shortcut ?? ''}`}
|
||||
key={`${catId}-${item.title}-${item.shortcut ?? ''}-${item.isFavorite ? 'fav' : 'normal'}`}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
|
||||
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)}
|
||||
@@ -1571,7 +1908,17 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<div
|
||||
className="notion-slash-preview"
|
||||
style={{ top: previewCoords.top, left: previewCoords.left }}
|
||||
>
|
||||
<SlashPreview itemTitle={selectedItem.title} />
|
||||
</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,
|
||||
)
|
||||
}
|
||||
60
memento-note/lib/editor/block-selection-extension.ts
Normal file
60
memento-note/lib/editor/block-selection-extension.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey, NodeSelection } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
|
||||
/**
|
||||
* Extension ProseMirror/TipTap qui ajoute la classe CSS 'block-selected'
|
||||
* aux blocs de premier niveau inclus ou traversés par la sélection active.
|
||||
*/
|
||||
export const BlockSelectionExtension = Extension.create({
|
||||
name: 'blockSelection',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('blockSelection'),
|
||||
props: {
|
||||
decorations(state) {
|
||||
const { from, to, empty } = state.selection
|
||||
if (empty) return DecorationSet.empty
|
||||
|
||||
const decorations: Decoration[] = []
|
||||
|
||||
// Parcourir uniquement les nœuds de premier niveau (enfants directs de doc)
|
||||
state.doc.forEach((node, offset) => {
|
||||
const start = offset
|
||||
const end = offset + node.nodeSize
|
||||
|
||||
// Vérifier si la plage de sélection [from, to] intersecte le bloc [start, end]
|
||||
const intersects = Math.max(start, from) < Math.min(end, to)
|
||||
if (intersects) {
|
||||
// Il faut s'assurer que c'est un nœud de bloc pour appliquer Decoration.node
|
||||
if (node.isBlock) {
|
||||
try {
|
||||
decorations.push(
|
||||
Decoration.node(start, end, {
|
||||
class: 'block-selected',
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
// Fallback en cas d'erreur sur des structures de nœuds particulières
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isNodeSel = state.selection instanceof NodeSelection
|
||||
|
||||
// Si la sélection s'étend sur plusieurs blocs ou si c'est une NodeSelection explicite,
|
||||
// on renvoie l'ensemble des décorations pour coloration visuelle.
|
||||
if (decorations.length > 1 || (decorations.length === 1 && isNodeSel)) {
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
}
|
||||
|
||||
return DecorationSet.empty
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
27
memento-note/lib/editor/turn-into-shortcut-extension.ts
Normal file
27
memento-note/lib/editor/turn-into-shortcut-extension.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
/**
|
||||
* Extension TipTap qui permet de transformer instantanément le bloc sous le curseur
|
||||
* en cyclant entre Titre 1, Titre 2, Titre 3 et Paragraphe normal via le raccourci clavier Cmd+Shift+H (ou Ctrl+Shift+H).
|
||||
*/
|
||||
export const TurnIntoShortcutExtension = Extension.create({
|
||||
name: 'turnIntoShortcut',
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-Shift-h': () => {
|
||||
const { editor } = this
|
||||
|
||||
if (editor.isActive('heading', { level: 1 })) {
|
||||
return editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
} else if (editor.isActive('heading', { level: 2 })) {
|
||||
return editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
} else if (editor.isActive('heading', { level: 3 })) {
|
||||
return editor.chain().focus().setParagraph().run()
|
||||
} else {
|
||||
return editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
48
memento-note/lib/editor/undo-redo-feedback-extension.ts
Normal file
48
memento-note/lib/editor/undo-redo-feedback-extension.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/**
|
||||
* Extension TipTap qui intercepte les commandes d'annulation (Undo) et de rétablissement (Redo)
|
||||
* pour lever un toast discret (2 secondes) de confirmation, en indiquant dynamiquement le raccourci de l'action inverse selon l'OS.
|
||||
*/
|
||||
export const UndoRedoFeedbackExtension = Extension.create({
|
||||
name: 'undoRedoFeedback',
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const isMac = typeof window !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent)
|
||||
const modKey = isMac ? '⌘' : 'Ctrl'
|
||||
|
||||
return {
|
||||
'Mod-z': () => {
|
||||
const success = this.editor.commands.undo()
|
||||
if (success) {
|
||||
toast.info('Action annulée', {
|
||||
description: `Faites ${modKey}+Maj+Z pour rétablir.`,
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
return success
|
||||
},
|
||||
'Mod-y': () => {
|
||||
const success = this.editor.commands.redo()
|
||||
if (success) {
|
||||
toast.info('Action rétablie', {
|
||||
description: `Faites ${modKey}+Z pour annuler.`,
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
return success
|
||||
},
|
||||
'Mod-Shift-z': () => {
|
||||
const success = this.editor.commands.redo()
|
||||
if (success) {
|
||||
toast.info('Action rétablie', {
|
||||
description: `Faites ${modKey}+Z pour annuler.`,
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
return success
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -2320,9 +2320,28 @@
|
||||
"slashLoading": "AI thinking...",
|
||||
"slashTabAll": "All",
|
||||
"slashCatBasic": "Basic blocks",
|
||||
"slashCatText": "Text",
|
||||
"slashCatMedia": "Media",
|
||||
"slashCatData": "Data",
|
||||
"slashCatEmbed": "Embed",
|
||||
"slashCatFormatting": "Formatting",
|
||||
"slashCatAi": "AI Note",
|
||||
"placeholderH1": "Main heading...",
|
||||
"placeholderH2": "Section heading...",
|
||||
"placeholderH3": "Subsection heading...",
|
||||
"placeholderTodo": "Add a task...",
|
||||
"placeholderCode": "Write code...",
|
||||
"placeholderQuote": "Capture a quote...",
|
||||
"placeholderText": "Type '/' to insert a block...",
|
||||
"smartPasteUrlTitle": "Link or Media Detected",
|
||||
"smartPasteUrlHint": "What would you like to do with this link?",
|
||||
"smartPasteUrlLink": "Paste as hyperlink",
|
||||
"smartPasteUrlImage": "Insert as image",
|
||||
"smartPasteUrlVideo": "Insert as video player",
|
||||
"smartPastePlain": "Paste as plain text",
|
||||
"smartPasteCodeTitle": "Source Code Detected",
|
||||
"smartPasteCodeHint": "Source code was detected. Would you like to insert it as a code block?",
|
||||
"smartPasteCodeBlock": "Insert as code block",
|
||||
"insertImage": "Insert image",
|
||||
"imageUrlPlaceholder": "https://example.com/image.png",
|
||||
"preview": "Preview",
|
||||
|
||||
@@ -2324,9 +2324,28 @@
|
||||
"slashLoading": "IA Note réfléchit...",
|
||||
"slashTabAll": "Tout",
|
||||
"slashCatBasic": "Blocs de base",
|
||||
"slashCatText": "Texte",
|
||||
"slashCatMedia": "Médias",
|
||||
"slashCatData": "Données",
|
||||
"slashCatEmbed": "Intégré",
|
||||
"slashCatFormatting": "Mise en forme",
|
||||
"slashCatAi": "IA Note",
|
||||
"placeholderH1": "Titre principal...",
|
||||
"placeholderH2": "Titre de section...",
|
||||
"placeholderH3": "Sous-titre...",
|
||||
"placeholderTodo": "Ajouter une tâche...",
|
||||
"placeholderCode": "Écrire du code...",
|
||||
"placeholderQuote": "Saisir une citation...",
|
||||
"placeholderText": "Tapez '/' pour insérer un bloc...",
|
||||
"smartPasteUrlTitle": "Lien ou Média détecté",
|
||||
"smartPasteUrlHint": "Que souhaitez-vous faire avec ce lien ?",
|
||||
"smartPasteUrlLink": "Coller comme lien hypertexte",
|
||||
"smartPasteUrlImage": "Insérer comme image",
|
||||
"smartPasteUrlVideo": "Insérer comme lecteur vidéo",
|
||||
"smartPastePlain": "Coller en texte brut",
|
||||
"smartPasteCodeTitle": "Code source détecté",
|
||||
"smartPasteCodeHint": "Du code source a été détecté. Souhaitez-vous l'insérer comme bloc de code ?",
|
||||
"smartPasteCodeBlock": "Insérer comme bloc de code",
|
||||
"insertImage": "Insérer une image",
|
||||
"imageUrlPlaceholder": "https://exemple.com/image.png",
|
||||
"preview": "Aperçu",
|
||||
|
||||
Reference in New Issue
Block a user