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

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

View File

@@ -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

View File

@@ -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
============================================ */

View File

@@ -32,11 +32,15 @@ import { BlockPicker, type BlockSuggestion } from './block-picker'
import { EditorBlockDragHandle } from './editor-block-drag-handle'
import { BlockActionMenu } from './block-action-menu'
import { SmartPasteMenu } from './smart-paste-menu'
import { SmartPasteExtendedMenu } from './smart-paste-extended-menu'
import { globalDragHandleExtensions } from '@/lib/editor/global-drag-handle-extension'
import { resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlockReference } from '@/lib/editor/parse-block-reference'
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension'
import { BlockSelectionExtension } from '@/lib/editor/block-selection-extension'
import { TurnIntoShortcutExtension } from '@/lib/editor/turn-into-shortcut-extension'
import { UndoRedoFeedbackExtension } from '@/lib/editor/undo-redo-feedback-extension'
import type { Node as PMNode } from '@tiptap/pm/model'
import { detectTextDirection } from '@/lib/clip/rtl-content'
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
@@ -98,18 +102,19 @@ type SlashItem = {
command: (editor: Editor, range?: any) => void
}
type SlashCategoryId = 'basic' | 'media' | 'formatting' | 'ai'
type SlashCategoryId = 'text' | 'media' | 'data' | 'embed' | 'ai'
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId; slashKeywords?: string[] }
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['basic', 'media', 'formatting', 'ai']
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['text', 'media', 'data', 'embed', 'ai']
function slashCategoryLabel(id: SlashCategoryId, t: (key: string) => string): string {
switch (id) {
case 'basic': return t('richTextEditor.slashCatBasic')
case 'media': return t('richTextEditor.slashCatMedia')
case 'formatting': return t('richTextEditor.slashCatFormatting')
case 'ai': return t('richTextEditor.slashCatAi')
case 'text': return t('richTextEditor.slashCatText') || 'Texte'
case 'media': return t('richTextEditor.slashCatMedia') || 'Médias'
case 'data': return t('richTextEditor.slashCatData') || 'Données'
case 'embed': return t('richTextEditor.slashCatEmbed') || 'Intégré'
case 'ai': return t('richTextEditor.slashCatAi') || 'IA Note'
}
}
@@ -273,6 +278,13 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
reference: ParsedBlockReference
sourceNoteTitle?: string
} | null>(null)
const [smartPasteExtended, setSmartPasteExtended] = useState<{
type: 'url' | 'code'
text: string
anchor: { top: number; left: number }
isImage?: boolean
isVideo?: boolean
} | null>(null)
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
const [noteLinkQuery, setNoteLinkQuery] = useState('')
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
@@ -392,11 +404,33 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
UniqueIdExtension,
...globalDragHandleExtensions,
SmartPasteExtension,
BlockSelectionExtension,
TurnIntoShortcutExtension,
UndoRedoFeedbackExtension,
LiveBlockExtension,
StructuredViewBlockExtension,
ClipArticleExtension,
RtlPreserveExtension,
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
const level = node.attrs.level
if (level === 1) return t('richTextEditor.placeholderH1') || 'Titre principal...'
if (level === 2) return t('richTextEditor.placeholderH2') || 'Titre de section...'
if (level === 3) return t('richTextEditor.placeholderH3') || 'Sous-titre...'
}
if (node.type.name === 'taskItem') {
return t('richTextEditor.placeholderTodo') || 'Ajouter une tâche...'
}
if (node.type.name === 'codeBlock') {
return t('richTextEditor.placeholderCode') || 'Écrire du code...'
}
if (node.type.name === 'blockquote') {
return t('richTextEditor.placeholderQuote') || 'Saisir une citation...'
}
return placeholder || t('richTextEditor.placeholderText') || t('richTextEditor.placeholder') || "Tapez '/' pour insérer un bloc..."
}
}),
],
content: content || '',
immediatelyRender: false,
@@ -529,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:

View File

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

View 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
},
},
}),
]
},
})

View 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()
}
},
}
},
})

View 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
},
}
},
})

View File

@@ -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",

View File

@@ -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",