From ad8b8b815e3a475491a786e1291f6921d7b3de05 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Wed, 27 May 2026 21:47:50 +0000 Subject: [PATCH] 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 --- docs/user-stories.md | 4 +- memento-note/app/globals.css | 312 +++++++++ memento-note/components/rich-text-editor.tsx | 658 +++++++++++++----- .../components/smart-paste-extended-menu.tsx | 122 ++++ .../lib/editor/block-selection-extension.ts | 60 ++ .../editor/turn-into-shortcut-extension.ts | 27 + .../editor/undo-redo-feedback-extension.ts | 48 ++ memento-note/locales/en.json | 19 + memento-note/locales/fr.json | 19 + 9 files changed, 1112 insertions(+), 157 deletions(-) create mode 100644 memento-note/components/smart-paste-extended-menu.tsx create mode 100644 memento-note/lib/editor/block-selection-extension.ts create mode 100644 memento-note/lib/editor/turn-into-shortcut-extension.ts create mode 100644 memento-note/lib/editor/undo-redo-feedback-extension.ts diff --git a/docs/user-stories.md b/docs/user-stories.md index 41a5438..2d3dc8c 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -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 diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css index 1a1759e..70aa7bd 100644 --- a/memento-note/app/globals.css +++ b/memento-note/app/globals.css @@ -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 ============================================ */ diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index d795e9c..1936fea 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -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(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 { + if (node.type.name === 'heading') { + const level = node.attrs.level + if (level === 1) return t('richTextEditor.placeholderH1') || 'Titre principal...' + if (level === 2) return t('richTextEditor.placeholderH2') || 'Titre de section...' + if (level === 3) return t('richTextEditor.placeholderH3') || 'Sous-titre...' + } + if (node.type.name === 'taskItem') { + return t('richTextEditor.placeholderTodo') || 'Ajouter une tâche...' + } + if (node.type.name === 'codeBlock') { + return t('richTextEditor.placeholderCode') || 'Écrire du code...' + } + if (node.type.name === 'blockquote') { + return t('richTextEditor.placeholderQuote') || 'Saisir une citation...' + } + return placeholder || t('richTextEditor.placeholderText') || t('richTextEditor.placeholder') || "Tapez '/' pour insérer un bloc..." + } + }), ], content: content || '', immediatelyRender: false, @@ -529,59 +563,98 @@ export const RichTextEditor = forwardRef { + setSmartPasteMenu({ + anchor: { top: coords.bottom, left: coords.left }, + reference: blockRef, + }) + }) + + void fetch( + `/api/blocks/${encodeURIComponent(blockRef.blockId)}/status?sourceNoteId=${encodeURIComponent(blockRef.sourceNoteId)}`, + ) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { content?: string; sourceNoteTitle?: string; exists?: boolean } | null) => { + if (smartPastePendingRef.current?.reference.raw !== blockRef.raw) return + const recalled = recallLastBlockReference() + const sessionFallback = + recalled?.raw === blockRef.raw + ? { + content: recalled.blockContent?.trim() ?? '', + sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '', + } + : { content: '', sourceNoteTitle: '' } + const exists = Boolean(data?.exists) || sessionFallback.content.length > 0 + const content = data?.exists ? (data.content ?? '') : (sessionFallback.content || data?.content || '') + const sourceNoteTitle = data?.sourceNoteTitle || sessionFallback.sourceNoteTitle || '' + smartPastePendingRef.current!.blockStatus = { + exists, + content, + sourceNoteTitle, + } + setSmartPasteMenu((prev) => + prev?.reference.raw === blockRef.raw + ? { ...prev, sourceNoteTitle: sourceNoteTitle || prev.sourceNoteTitle } + : prev, + ) + }) + .catch(() => {}) + + return true } - queueMicrotask(() => { - setSmartPasteMenu({ + // Détection d'URL HTTP(S) cliquable ou média + const isUrl = /^https?:\/\/[^\s]+$/i.test(clipboardText.trim()) + if (isUrl) { + const url = clipboardText.trim() + event.preventDefault() + event.stopPropagation() + const coords = view.coordsAtPos(view.state.selection.from) + const isImage = /\.(jpeg|jpg|gif|png|svg|webp)($|\?)/i.test(url) + const isVideo = /(youtube\.com|youtu\.be|vimeo\.com|streamable\.com)/i.test(url) || /\.(mp4|webm|ogg)($|\?)/i.test(url) + + setSmartPasteExtended({ + type: 'url', + text: url, anchor: { top: coords.bottom, left: coords.left }, - reference: blockRef, + isImage, + isVideo, }) - }) + return true + } - void fetch( - `/api/blocks/${encodeURIComponent(blockRef.blockId)}/status?sourceNoteId=${encodeURIComponent(blockRef.sourceNoteId)}`, - ) - .then((res) => (res.ok ? res.json() : null)) - .then((data: { content?: string; sourceNoteTitle?: string; exists?: boolean } | null) => { - if (smartPastePendingRef.current?.reference.raw !== blockRef.raw) return - const recalled = recallLastBlockReference() - const sessionFallback = - recalled?.raw === blockRef.raw - ? { - content: recalled.blockContent?.trim() ?? '', - sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '', - } - : { content: '', sourceNoteTitle: '' } - const exists = Boolean(data?.exists) || sessionFallback.content.length > 0 - const content = data?.exists ? (data.content ?? '') : (sessionFallback.content || data?.content || '') - const sourceNoteTitle = data?.sourceNoteTitle || sessionFallback.sourceNoteTitle || '' - smartPastePendingRef.current!.blockStatus = { - exists, - content, - sourceNoteTitle, - } - setSmartPasteMenu((prev) => - prev?.reference.raw === blockRef.raw - ? { ...prev, sourceNoteTitle: sourceNoteTitle || prev.sourceNoteTitle } - : prev, - ) + // Détection de code source technique + const hasCodeSpecialChars = /[{}[\];]/.test(clipboardText) + const hasCodeKeywords = /(const\s+\w+\s*=|let\s+\w+\s*=|function\s+\w*\(|import\s+.*from|class\s+\w+|def\s+\w+\(|public\s+class\s+\w+|#include\s+<|import\s+react|var\s+\w+\s*=)/.test(clipboardText) + const isMultiline = clipboardText.split('\n').length > 1 + if ((hasCodeSpecialChars && hasCodeKeywords) || (isMultiline && hasCodeSpecialChars && clipboardText.includes('('))) { + event.preventDefault() + event.stopPropagation() + const coords = view.coordsAtPos(view.state.selection.from) + + setSmartPasteExtended({ + type: 'code', + text: clipboardText, + anchor: { top: coords.bottom, left: coords.left }, }) - .catch(() => {}) + return true + } - return true + return false } return () => { @@ -915,6 +988,54 @@ export const RichTextEditor = forwardRef { + if (!editor) return + const { from, to, empty } = editor.state.selection + if (empty) { + editor.chain().focus().insertContent(`${url}`).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(`🎥 ${t('richTextEditor.slashVideo') || 'Vidéo'} (${url})`).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 (
{editor && ( @@ -968,6 +1089,22 @@ export const RichTextEditor = forwardRef )} + {smartPasteExtended && ( + handlePasteUrlLink(smartPasteExtended.text)} + onImage={() => handlePasteUrlImage(smartPasteExtended.text)} + onVideo={() => handlePasteUrlVideo(smartPasteExtended.text)} + onCodeBlock={() => handlePasteCodeBlock(smartPasteExtended.text)} + onPlain={() => handlePastePlain(smartPasteExtended.text)} + onClose={() => setSmartPasteExtended(null)} + /> + )} + {imageInsert.open && ( )} @@ -1259,6 +1396,125 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS ) } +function SlashPreview({ itemTitle }: { itemTitle: string }) { + switch (itemTitle) { + case 'Table': + case 'Tableau': + return ( +
+
Tableau
+
+
+
+
+
+
+
+
+
+
+
+

Organisez vos données en lignes et colonnes.

+
+ ) + case 'Database': + case 'Base de données': + return ( +
+
Base de Données
+
+
+ Nom + Statut +
+
+ Tâche A + À faire +
+
+ Tâche B + Fait +
+
+

Ajoutez des colonnes et des vues Kanban structurées.

+
+ ) + case 'Suggest Charts': + case 'Suggest Chart': + return ( +
+
Graphique IA
+
+
+
+
+
+
+

Générez un graphique interactif à partir de votre texte.

+
+ ) + case 'Living Block': + case 'Bloc vivant': + return ( +
+
Bloc Vivant (Transclusion)
+
+
Note A
+
+
Note B
+
+

Synchronisez du contenu en temps réel entre plusieurs notes.

+
+ ) + case 'Diagramme': + case 'Diagram': + return ( +
+
Diagramme Excalidraw
+
+
+
+
+
+

Esquissez des concepts ou générez des diagrammes via IA.

+
+ ) + case 'Présentation': + case 'Presentation': + return ( +
+
Présentation Slides
+
+
+
+
+
+

Créez des présentations interactives exportables.

+
+ ) + case 'Code Block': + case 'Code': + case 'Bloc de code': + return ( +
+
Bloc de Code
+
+
+ + + +
+
+
+
+

Ajoutez du code avec coloration syntaxique automatique.

+
+ ) + 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(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(null) const selectedItemRef = useRef(null) const menuInteracting = useRef(false) + const [frequentCommands, setFrequentCommands] = useState([]) 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 + 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 + 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) - const categories = filtered.reduce((acc, item) => { + const categories = baseFiltered.reduce((acc, item) => { const id = item.categoryId if (!acc[id]) acc[id] = [] acc[id].push(item) @@ -1394,6 +1684,29 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: selectedItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) }, [selectedIndex]) + // Ajustement dynamique de la position de la preview + useEffect(() => { + if (!isOpen) return + const menuWidth = 320 + const previewWidth = 280 + const padding = 12 + const wouldOverflowRight = coords.left + menuWidth + previewWidth + padding > window.innerWidth + + if (wouldOverflowRight) { + setPreviewCoords({ + top: coords.top, + left: coords.left - previewWidth - padding, + side: 'left', + }) + } else { + setPreviewCoords({ + top: coords.top, + left: coords.left + menuWidth + padding, + side: 'right', + }) + } + }, [coords, isOpen]) + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isOpen) return @@ -1437,7 +1750,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: } document.addEventListener('keydown', handleKeyDown, true) return () => document.removeEventListener('keydown', handleKeyDown, true) - }, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, activeCategory, allCategories, availableCategoriesInSearch]) + }, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, activeCategory, allCategories, availableCategoriesInSearch, query, deleteSlashText, editor, t]) useEffect(() => { if (!isOpen) return @@ -1453,7 +1766,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: } else { setCoords({ top: c.bottom + 8, left: c.left }) } - }, [isOpen, editor, query, filtered.length]) + }, [isOpen, editor, query, filtered.length, coords.left]) useEffect(() => { const handleClick = (e: MouseEvent) => { @@ -1491,87 +1804,121 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: const sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0) const totalVisible = sectionIds.length - return createPortal( -
e.stopPropagation()} - > - {/* Category tabs */} - {!query && totalVisible > 1 && ( -
e.preventDefault()}> - - {allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0).map(catId => ( - - ))} -
- )} + const visibleSectionIds = [...sectionIds] + // Si pas de query, ajouter la section Favoris au début + const hasFavorites = !q && !activeCategory && frequentCommands.length > 0 + if (hasFavorites) { + visibleSectionIds.unshift('frequent' as any) + } - {/* Header hint */} -
- {t('richTextEditor.slashHint')} + 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( + <> +
e.stopPropagation()} + > + {/* Category tabs */} + {!query && totalVisible > 1 && ( +
e.preventDefault()}> + + {allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0).map(catId => ( + + ))} +
+ )} + + {/* Header hint */} +
+ {t('richTextEditor.slashHint')} +
+ + {aiLoading && ( +
+ + {t('richTextEditor.slashLoading')} +
+ )} + {!aiLoading && visibleSectionIds.map((catId) => { + const items = catId === ('frequent' as any) + ? frequentCommands.map(c => ({ ...c, isFavorite: true })) + : categories[catId]! + + return ( +
+
+ {catId === 'ai' && } + {catId === ('frequent' as any) ? '★ Fréquents' : slashCategoryLabel(catId, t)} +
+ {items.map((item) => { + flatIndex++ + const idx = flatIndex + const isSelected = idx === selectedIndex + return ( + + ) + })} +
+ ) + })}
- {aiLoading && ( -
- - {t('richTextEditor.slashLoading')} + {showPreview && ( +
+
)} - {!aiLoading && sectionIds.map((catId) => { - const items = categories[catId]! - return ( -
-
- {catId === 'ai' && } - {slashCategoryLabel(catId, t)} -
- {items.map((item) => { - flatIndex++ - const idx = flatIndex - const isSelected = idx === selectedIndex - return ( - - ) - })} -
- ) - })} -
, + , document.body ) } @@ -1580,3 +1927,4 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: + diff --git a/memento-note/components/smart-paste-extended-menu.tsx b/memento-note/components/smart-paste-extended-menu.tsx new file mode 100644 index 0000000..2775502 --- /dev/null +++ b/memento-note/components/smart-paste-extended-menu.tsx @@ -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(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( +
+

+ {type === 'url' ? t('richTextEditor.smartPasteUrlTitle') || 'Lien ou Média détecté' : t('richTextEditor.smartPasteCodeTitle') || 'Code source détecté'} +

+

+ {shortenedText} +

+

+ {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 ?'} +

+ + {type === 'url' && ( + <> + + {isImage && onImage && ( + + )} + {isVideo && onVideo && ( + + )} + + )} + + {type === 'code' && onCodeBlock && ( + + )} + + +
, + document.body, + ) +} diff --git a/memento-note/lib/editor/block-selection-extension.ts b/memento-note/lib/editor/block-selection-extension.ts new file mode 100644 index 0000000..aabc6b0 --- /dev/null +++ b/memento-note/lib/editor/block-selection-extension.ts @@ -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 + }, + }, + }), + ] + }, +}) diff --git a/memento-note/lib/editor/turn-into-shortcut-extension.ts b/memento-note/lib/editor/turn-into-shortcut-extension.ts new file mode 100644 index 0000000..8b50719 --- /dev/null +++ b/memento-note/lib/editor/turn-into-shortcut-extension.ts @@ -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() + } + }, + } + }, +}) diff --git a/memento-note/lib/editor/undo-redo-feedback-extension.ts b/memento-note/lib/editor/undo-redo-feedback-extension.ts new file mode 100644 index 0000000..3536c4a --- /dev/null +++ b/memento-note/lib/editor/undo-redo-feedback-extension.ts @@ -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 + }, + } + }, +}) diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index e140eea..99858f6 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -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", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 5022fe0..be1b69a 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -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",