feat(editor): implement US-EDITOR-UX with global block selection, redesigned slash menu (favorites & preview), contextual placeholders, smart paste extended, turn into and undo/redo toasts
This commit is contained in:
@@ -22,7 +22,7 @@
|
|||||||
| **US-STRUCTURED-VIEWS** | Vues Structurées (Tableau/Kanban/Galerie) | ✅ **LIVRÉ** | `/api/notebooks/[id]/schema`, `/api/notes/[id]/properties`, vues structurées + panneau propriétés éditeur |
|
| **US-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-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-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-MOBILE** | Expérience tactile & toolbar mobile adaptée | ⏳ **À FAIRE** | — |
|
||||||
| **US-EDITOR-MARKDOWN** | Rendu WYSIWYG Markdown fidèle (round-trip byte-for-byte) | ⏳ **À 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
|
## 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)
|
> **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
|
> **Source recherche :** Mintlify "22 UX improvements" (mai 2026), BlockNote v0.50, BlockNote v0.49
|
||||||
|
|
||||||
|
|||||||
@@ -2497,6 +2497,318 @@ html.font-system * {
|
|||||||
color: var(--muted-foreground);
|
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
|
Note Card Rich Text Preview
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ import { BlockPicker, type BlockSuggestion } from './block-picker'
|
|||||||
import { EditorBlockDragHandle } from './editor-block-drag-handle'
|
import { EditorBlockDragHandle } from './editor-block-drag-handle'
|
||||||
import { BlockActionMenu } from './block-action-menu'
|
import { BlockActionMenu } from './block-action-menu'
|
||||||
import { SmartPasteMenu } from './smart-paste-menu'
|
import { SmartPasteMenu } from './smart-paste-menu'
|
||||||
|
import { SmartPasteExtendedMenu } from './smart-paste-extended-menu'
|
||||||
import { globalDragHandleExtensions } from '@/lib/editor/global-drag-handle-extension'
|
import { globalDragHandleExtensions } from '@/lib/editor/global-drag-handle-extension'
|
||||||
import { resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
|
import { resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
|
||||||
import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlockReference } from '@/lib/editor/parse-block-reference'
|
import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlockReference } from '@/lib/editor/parse-block-reference'
|
||||||
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
|
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
|
||||||
import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension'
|
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 type { Node as PMNode } from '@tiptap/pm/model'
|
||||||
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||||
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
|
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||||
@@ -98,18 +102,19 @@ type SlashItem = {
|
|||||||
command: (editor: Editor, range?: any) => void
|
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[] }
|
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 {
|
function slashCategoryLabel(id: SlashCategoryId, t: (key: string) => string): string {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'basic': return t('richTextEditor.slashCatBasic')
|
case 'text': return t('richTextEditor.slashCatText') || 'Texte'
|
||||||
case 'media': return t('richTextEditor.slashCatMedia')
|
case 'media': return t('richTextEditor.slashCatMedia') || 'Médias'
|
||||||
case 'formatting': return t('richTextEditor.slashCatFormatting')
|
case 'data': return t('richTextEditor.slashCatData') || 'Données'
|
||||||
case 'ai': return t('richTextEditor.slashCatAi')
|
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
|
reference: ParsedBlockReference
|
||||||
sourceNoteTitle?: string
|
sourceNoteTitle?: string
|
||||||
} | null>(null)
|
} | 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 [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
|
||||||
const [noteLinkQuery, setNoteLinkQuery] = useState('')
|
const [noteLinkQuery, setNoteLinkQuery] = useState('')
|
||||||
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
|
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
|
||||||
@@ -392,11 +404,33 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
UniqueIdExtension,
|
UniqueIdExtension,
|
||||||
...globalDragHandleExtensions,
|
...globalDragHandleExtensions,
|
||||||
SmartPasteExtension,
|
SmartPasteExtension,
|
||||||
|
BlockSelectionExtension,
|
||||||
|
TurnIntoShortcutExtension,
|
||||||
|
UndoRedoFeedbackExtension,
|
||||||
LiveBlockExtension,
|
LiveBlockExtension,
|
||||||
StructuredViewBlockExtension,
|
StructuredViewBlockExtension,
|
||||||
ClipArticleExtension,
|
ClipArticleExtension,
|
||||||
RtlPreserveExtension,
|
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 || '',
|
content: content || '',
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
@@ -529,59 +563,98 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
blockRef = recalled
|
blockRef = recalled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!blockRef) return false
|
if (blockRef) {
|
||||||
|
const emptyParagraph = getEmptyParagraphAtSelection(view.state)
|
||||||
|
if (!emptyParagraph) return false
|
||||||
|
|
||||||
const emptyParagraph = getEmptyParagraphAtSelection(view.state)
|
event.preventDefault()
|
||||||
if (!emptyParagraph) return false
|
event.stopPropagation()
|
||||||
|
|
||||||
event.preventDefault()
|
const coords = view.coordsAtPos(view.state.selection.from)
|
||||||
event.stopPropagation()
|
smartPastePendingRef.current = {
|
||||||
|
reference: blockRef,
|
||||||
|
blockPos: emptyParagraph.pos,
|
||||||
|
blockNode: emptyParagraph.node,
|
||||||
|
}
|
||||||
|
|
||||||
const coords = view.coordsAtPos(view.state.selection.from)
|
queueMicrotask(() => {
|
||||||
smartPastePendingRef.current = {
|
setSmartPasteMenu({
|
||||||
reference: blockRef,
|
anchor: { top: coords.bottom, left: coords.left },
|
||||||
blockPos: emptyParagraph.pos,
|
reference: blockRef,
|
||||||
blockNode: emptyParagraph.node,
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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(() => {
|
// Détection d'URL HTTP(S) cliquable ou média
|
||||||
setSmartPasteMenu({
|
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 },
|
anchor: { top: coords.bottom, left: coords.left },
|
||||||
reference: blockRef,
|
isImage,
|
||||||
|
isVideo,
|
||||||
})
|
})
|
||||||
})
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
void fetch(
|
// Détection de code source technique
|
||||||
`/api/blocks/${encodeURIComponent(blockRef.blockId)}/status?sourceNoteId=${encodeURIComponent(blockRef.sourceNoteId)}`,
|
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)
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
const isMultiline = clipboardText.split('\n').length > 1
|
||||||
.then((data: { content?: string; sourceNoteTitle?: string; exists?: boolean } | null) => {
|
if ((hasCodeSpecialChars && hasCodeKeywords) || (isMultiline && hasCodeSpecialChars && clipboardText.includes('('))) {
|
||||||
if (smartPastePendingRef.current?.reference.raw !== blockRef.raw) return
|
event.preventDefault()
|
||||||
const recalled = recallLastBlockReference()
|
event.stopPropagation()
|
||||||
const sessionFallback =
|
const coords = view.coordsAtPos(view.state.selection.from)
|
||||||
recalled?.raw === blockRef.raw
|
|
||||||
? {
|
setSmartPasteExtended({
|
||||||
content: recalled.blockContent?.trim() ?? '',
|
type: 'code',
|
||||||
sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '',
|
text: clipboardText,
|
||||||
}
|
anchor: { top: coords.bottom, left: coords.left },
|
||||||
: { 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
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -915,6 +988,54 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
closeSmartPasteMenu()
|
closeSmartPasteMenu()
|
||||||
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus])
|
}, [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 (
|
return (
|
||||||
<div className={cn('notion-editor-wrapper', className)}>
|
<div className={cn('notion-editor-wrapper', className)}>
|
||||||
{editor && (
|
{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 && (
|
{imageInsert.open && (
|
||||||
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
|
<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 }) {
|
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { requestAiConsent } = useAiConsent()
|
const { requestAiConsent } = useAiConsent()
|
||||||
@@ -1267,48 +1523,50 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const [activeCategory, setActiveCategory] = useState<SlashCategoryId | null>(null)
|
const [activeCategory, setActiveCategory] = useState<SlashCategoryId | null>(null)
|
||||||
const [coords, setCoords] = useState({ top: 0, left: 0 })
|
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 [aiLoading, setAiLoading] = useState(false)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const selectedItemRef = useRef<HTMLButtonElement>(null)
|
const selectedItemRef = useRef<HTMLButtonElement>(null)
|
||||||
const menuInteracting = useRef(false)
|
const menuInteracting = useRef(false)
|
||||||
|
const [frequentCommands, setFrequentCommands] = useState<SlashMenuItem[]>([])
|
||||||
|
|
||||||
const localCommands: SlashMenuItem[] = [
|
const localCommands: SlashMenuItem[] = [
|
||||||
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), 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: 'basic' },
|
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' },
|
||||||
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'basic' },
|
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' },
|
||||||
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'basic' },
|
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' },
|
||||||
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'basic' },
|
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data' },
|
||||||
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'basic' },
|
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text' },
|
||||||
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'basic' },
|
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text' },
|
||||||
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'basic' },
|
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text' },
|
||||||
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'basic' },
|
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' },
|
||||||
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'basic' },
|
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' },
|
||||||
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'basic' },
|
{ ...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[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
|
||||||
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), 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: 'formatting' },
|
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' },
|
||||||
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'formatting' },
|
{ ...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[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
|
||||||
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), 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[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
|
||||||
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), 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[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' },
|
||||||
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'formatting' },
|
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' },
|
||||||
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'formatting' },
|
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' },
|
||||||
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'formatting' },
|
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' },
|
||||||
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'formatting' },
|
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' },
|
||||||
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'formatting' },
|
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' },
|
||||||
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'formatting' },
|
{ ...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[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
|
||||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), 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[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[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: 'basic', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
|
{ ...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'),
|
title: t('richTextEditor.slashNoteLink'),
|
||||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||||
icon: Link2,
|
icon: Link2,
|
||||||
categoryId: 'basic' as SlashCategoryId,
|
categoryId: 'embed' as SlashCategoryId,
|
||||||
command: (e) => {
|
command: (e) => {
|
||||||
e.chain().focus().insertContent('[[').run()
|
e.chain().focus().insertContent('[[').run()
|
||||||
},
|
},
|
||||||
@@ -1330,6 +1588,16 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
}, [editor])
|
}, [editor])
|
||||||
|
|
||||||
const handleSelect = useCallback(async (item: SlashMenuItem) => {
|
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 toastAi = (err: unknown) => {
|
||||||
const msg = err instanceof Error ? err.message : ''
|
const msg = err instanceof Error ? err.message : ''
|
||||||
toast.error(msg && msg !== AI_REFORMULATE_FALLBACK ? msg : t('richTextEditor.aiReformulateFailed'))
|
toast.error(msg && msg !== AI_REFORMULATE_FALLBACK ? msg : t('richTextEditor.aiReformulateFailed'))
|
||||||
@@ -1361,7 +1629,23 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
} else {
|
} else {
|
||||||
deleteSlashText(); item.command(editor); closeMenu()
|
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 presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
|
||||||
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
|
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.shortcut?.toLowerCase().includes(q) ?? false)
|
||||||
|| (c.slashKeywords?.some((kw) => kw.includes(q) || q.includes(kw)) ?? 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 availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
|
||||||
const id = item.categoryId
|
const id = item.categoryId
|
||||||
@@ -1382,7 +1672,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
return acc
|
return acc
|
||||||
}, {} as Record<SlashCategoryId, SlashMenuItem[]>)
|
}, {} as Record<SlashCategoryId, SlashMenuItem[]>)
|
||||||
|
|
||||||
const categories = filtered.reduce((acc, item) => {
|
const categories = baseFiltered.reduce((acc, item) => {
|
||||||
const id = item.categoryId
|
const id = item.categoryId
|
||||||
if (!acc[id]) acc[id] = []
|
if (!acc[id]) acc[id] = []
|
||||||
acc[id].push(item)
|
acc[id].push(item)
|
||||||
@@ -1394,6 +1684,29 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
selectedItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
selectedItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||||
}, [selectedIndex])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@@ -1437,7 +1750,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleKeyDown, true)
|
document.addEventListener('keydown', handleKeyDown, true)
|
||||||
return () => document.removeEventListener('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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@@ -1453,7 +1766,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
} else {
|
} else {
|
||||||
setCoords({ top: c.bottom + 8, left: c.left })
|
setCoords({ top: c.bottom + 8, left: c.left })
|
||||||
}
|
}
|
||||||
}, [isOpen, editor, query, filtered.length])
|
}, [isOpen, editor, query, filtered.length, coords.left])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClick = (e: MouseEvent) => {
|
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 sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0)
|
||||||
const totalVisible = sectionIds.length
|
const totalVisible = sectionIds.length
|
||||||
|
|
||||||
return createPortal(
|
const visibleSectionIds = [...sectionIds]
|
||||||
<div
|
// Si pas de query, ajouter la section Favoris au début
|
||||||
ref={menuRef}
|
const hasFavorites = !q && !activeCategory && frequentCommands.length > 0
|
||||||
className="notion-slash-menu"
|
if (hasFavorites) {
|
||||||
style={{ top: coords.top, left: coords.left }}
|
visibleSectionIds.unshift('frequent' as any)
|
||||||
onClick={(e) => e.stopPropagation()}
|
}
|
||||||
>
|
|
||||||
{/* Category tabs */}
|
|
||||||
{!query && totalVisible > 1 && (
|
|
||||||
<div className="notion-slash-tabs" onMouseDown={(e) => e.preventDefault()}>
|
|
||||||
<button
|
|
||||||
className={cn('notion-slash-tab', !activeCategory && 'notion-slash-tab-active')}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => { setActiveCategory(null); setSelectedIndex(0) }}
|
|
||||||
>
|
|
||||||
{t('richTextEditor.slashTabAll')}
|
|
||||||
</button>
|
|
||||||
{allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0).map(catId => (
|
|
||||||
<button
|
|
||||||
key={catId}
|
|
||||||
className={cn('notion-slash-tab', activeCategory === catId && 'notion-slash-tab-active', catId === 'ai' && 'notion-slash-tab-ai')}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => { setActiveCategory(activeCategory === catId ? null : catId); setSelectedIndex(0) }}
|
|
||||||
>
|
|
||||||
{catId === 'ai' && <Sparkles className="w-2.5 h-2.5" />}
|
|
||||||
{slashCategoryLabel(catId, t)}
|
|
||||||
<span className="notion-slash-tab-count">{availableCategoriesInSearch[catId]?.length ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Header hint */}
|
const selectedItem = filtered[selectedIndex]
|
||||||
<div className="notion-slash-header">
|
const showPreview = selectedItem && [
|
||||||
<span>{t('richTextEditor.slashHint')}</span>
|
'Table', 'Tableau', 'Database', 'Suggest Charts', 'Suggest Chart', 'Living Block', 'Bloc vivant', 'Diagramme', 'Diagram', 'Présentation', 'Presentation', 'Code Block', 'Code', 'Bloc de code'
|
||||||
|
].includes(selectedItem.title)
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="notion-slash-menu"
|
||||||
|
style={{ top: coords.top, left: coords.left }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Category tabs */}
|
||||||
|
{!query && totalVisible > 1 && (
|
||||||
|
<div className="notion-slash-tabs" onMouseDown={(e) => e.preventDefault()}>
|
||||||
|
<button
|
||||||
|
className={cn('notion-slash-tab', !activeCategory && 'notion-slash-tab-active')}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => { setActiveCategory(null); setSelectedIndex(0) }}
|
||||||
|
>
|
||||||
|
{t('richTextEditor.slashTabAll')}
|
||||||
|
</button>
|
||||||
|
{allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0).map(catId => (
|
||||||
|
<button
|
||||||
|
key={catId}
|
||||||
|
className={cn('notion-slash-tab', activeCategory === catId && 'notion-slash-tab-active', catId === 'ai' && 'notion-slash-tab-ai')}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => { setActiveCategory(activeCategory === catId ? null : catId); setSelectedIndex(0) }}
|
||||||
|
>
|
||||||
|
{catId === 'ai' && <Sparkles className="w-2.5 h-2.5" />}
|
||||||
|
{slashCategoryLabel(catId, t)}
|
||||||
|
<span className="notion-slash-tab-count">{availableCategoriesInSearch[catId]?.length ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header hint */}
|
||||||
|
<div className="notion-slash-header">
|
||||||
|
<span>{t('richTextEditor.slashHint')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiLoading && (
|
||||||
|
<div className="notion-slash-loading">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
<span>{t('richTextEditor.slashLoading')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!aiLoading && visibleSectionIds.map((catId) => {
|
||||||
|
const items = catId === ('frequent' as any)
|
||||||
|
? frequentCommands.map(c => ({ ...c, isFavorite: true }))
|
||||||
|
: categories[catId]!
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={catId} className="notion-slash-section">
|
||||||
|
<div className={cn(
|
||||||
|
'notion-slash-label',
|
||||||
|
catId === 'ai' && 'notion-slash-label-ai',
|
||||||
|
catId === ('frequent' as any) && 'text-amber-500 font-semibold'
|
||||||
|
)}>
|
||||||
|
{catId === 'ai' && <Sparkles className="w-3 h-3 inline mr-1" />}
|
||||||
|
{catId === ('frequent' as any) ? '★ Fréquents' : slashCategoryLabel(catId, t)}
|
||||||
|
</div>
|
||||||
|
{items.map((item) => {
|
||||||
|
flatIndex++
|
||||||
|
const idx = flatIndex
|
||||||
|
const isSelected = idx === selectedIndex
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${catId}-${item.title}-${item.shortcut ?? ''}-${item.isFavorite ? 'fav' : 'normal'}`}
|
||||||
|
ref={isSelected ? selectedItemRef : null}
|
||||||
|
className={cn(
|
||||||
|
'notion-slash-item',
|
||||||
|
isSelected && 'notion-slash-item-selected',
|
||||||
|
item.isFavorite && 'notion-slash-item-favorite'
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(idx)}
|
||||||
|
>
|
||||||
|
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
|
||||||
|
<item.icon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="notion-slash-content">
|
||||||
|
<span className="notion-slash-title">{item.title}</span>
|
||||||
|
<span className="notion-slash-desc">{item.description}</span>
|
||||||
|
</div>
|
||||||
|
{item.shortcut && (
|
||||||
|
<span className="notion-slash-shortcut">{item.shortcut}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{aiLoading && (
|
{showPreview && (
|
||||||
<div className="notion-slash-loading">
|
<div
|
||||||
<Sparkles className="w-3.5 h-3.5 animate-spin" />
|
className="notion-slash-preview"
|
||||||
<span>{t('richTextEditor.slashLoading')}</span>
|
style={{ top: previewCoords.top, left: previewCoords.left }}
|
||||||
|
>
|
||||||
|
<SlashPreview itemTitle={selectedItem.title} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!aiLoading && sectionIds.map((catId) => {
|
</>,
|
||||||
const items = categories[catId]!
|
|
||||||
return (
|
|
||||||
<div key={catId} className="notion-slash-section">
|
|
||||||
<div className={cn('notion-slash-label', catId === 'ai' && 'notion-slash-label-ai')}>
|
|
||||||
{catId === 'ai' && <Sparkles className="w-3 h-3 inline mr-1" />}
|
|
||||||
{slashCategoryLabel(catId, t)}
|
|
||||||
</div>
|
|
||||||
{items.map((item) => {
|
|
||||||
flatIndex++
|
|
||||||
const idx = flatIndex
|
|
||||||
const isSelected = idx === selectedIndex
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${catId}-${item.title}-${item.shortcut ?? ''}`}
|
|
||||||
ref={isSelected ? selectedItemRef : null}
|
|
||||||
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => handleSelect(item)}
|
|
||||||
onMouseEnter={() => setSelectedIndex(idx)}
|
|
||||||
>
|
|
||||||
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
|
|
||||||
<item.icon className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div className="notion-slash-content">
|
|
||||||
<span className="notion-slash-title">{item.title}</span>
|
|
||||||
<span className="notion-slash-desc">{item.description}</span>
|
|
||||||
</div>
|
|
||||||
{item.shortcut && (
|
|
||||||
<span className="notion-slash-shortcut">{item.shortcut}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>,
|
|
||||||
document.body
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1580,3 +1927,4 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
122
memento-note/components/smart-paste-extended-menu.tsx
Normal file
122
memento-note/components/smart-paste-extended-menu.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { Link2, ImageIcon, Video, Code, FileText } from 'lucide-react'
|
||||||
|
|
||||||
|
export type SmartPasteExtendedMenuProps = {
|
||||||
|
type: 'url' | 'code'
|
||||||
|
text: string
|
||||||
|
anchor: { top: number; left: number }
|
||||||
|
isImage?: boolean
|
||||||
|
isVideo?: boolean
|
||||||
|
onLink?: () => void
|
||||||
|
onImage?: () => void
|
||||||
|
onVideo?: () => void
|
||||||
|
onCodeBlock?: () => void
|
||||||
|
onPlain: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SmartPasteExtendedMenu({
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
anchor,
|
||||||
|
isImage,
|
||||||
|
isVideo,
|
||||||
|
onLink,
|
||||||
|
onImage,
|
||||||
|
onVideo,
|
||||||
|
onCodeBlock,
|
||||||
|
onPlain,
|
||||||
|
onClose,
|
||||||
|
}: SmartPasteExtendedMenuProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const menuStyle: React.CSSProperties = {
|
||||||
|
position: 'fixed',
|
||||||
|
left: anchor.left,
|
||||||
|
top: anchor.top + 8,
|
||||||
|
zIndex: 9999,
|
||||||
|
maxWidth: 340,
|
||||||
|
width: '100%',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(menuStyle.left) + 340 > window.innerWidth) {
|
||||||
|
menuStyle.left = Math.max(8, window.innerWidth - 348)
|
||||||
|
}
|
||||||
|
// Estimation de hauteur pour éviter les débordements
|
||||||
|
const expectedHeight = type === 'url' ? (200) : (140)
|
||||||
|
if (Number(menuStyle.top) + expectedHeight > window.innerHeight) {
|
||||||
|
menuStyle.top = Math.max(8, anchor.top - expectedHeight - 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortenedText = text.length > 50 ? `${text.slice(0, 47)}...` : text
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div ref={menuRef} style={menuStyle} className="block-action-menu smart-paste-menu">
|
||||||
|
<p className="smart-paste-menu__hint font-semibold">
|
||||||
|
{type === 'url' ? t('richTextEditor.smartPasteUrlTitle') || 'Lien ou Média détecté' : t('richTextEditor.smartPasteCodeTitle') || 'Code source détecté'}
|
||||||
|
</p>
|
||||||
|
<p className="smart-paste-menu__source text-xs truncate max-w-full" title={text}>
|
||||||
|
{shortenedText}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground px-3 mb-2">
|
||||||
|
{type === 'url' ? t('richTextEditor.smartPasteUrlHint') || 'Que souhaitez-vous faire avec ce lien ?' : t('richTextEditor.smartPasteCodeHint') || 'Du code source a été détecté. Souhaitez-vous l\'insérer comme bloc de code ?'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{type === 'url' && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="block-action-item" onClick={onLink}>
|
||||||
|
<Link2 size={15} className="text-blue-500" />
|
||||||
|
<span>{t('richTextEditor.smartPasteUrlLink') || 'Coller comme lien hypertexte'}</span>
|
||||||
|
</button>
|
||||||
|
{isImage && onImage && (
|
||||||
|
<button type="button" className="block-action-item" onClick={onImage}>
|
||||||
|
<ImageIcon size={15} className="text-emerald-500" />
|
||||||
|
<span>{t('richTextEditor.smartPasteUrlImage') || 'Insérer comme image'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isVideo && onVideo && (
|
||||||
|
<button type="button" className="block-action-item" onClick={onVideo}>
|
||||||
|
<Video size={15} className="text-rose-500" />
|
||||||
|
<span>{t('richTextEditor.smartPasteUrlVideo') || 'Insérer comme lecteur vidéo'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'code' && onCodeBlock && (
|
||||||
|
<button type="button" className="block-action-item" onClick={onCodeBlock}>
|
||||||
|
<Code size={15} className="text-purple-500" />
|
||||||
|
<span>{t('richTextEditor.smartPasteCodeBlock') || 'Insérer comme bloc de code'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" className="block-action-item border-t border-border/50 mt-1 pt-1" onClick={onPlain}>
|
||||||
|
<FileText size={15} className="text-muted-foreground" />
|
||||||
|
<span>{t('richTextEditor.smartPastePlain') || 'Coller en texte brut'}</span>
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
60
memento-note/lib/editor/block-selection-extension.ts
Normal file
60
memento-note/lib/editor/block-selection-extension.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey, NodeSelection } from '@tiptap/pm/state'
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension ProseMirror/TipTap qui ajoute la classe CSS 'block-selected'
|
||||||
|
* aux blocs de premier niveau inclus ou traversés par la sélection active.
|
||||||
|
*/
|
||||||
|
export const BlockSelectionExtension = Extension.create({
|
||||||
|
name: 'blockSelection',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey('blockSelection'),
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
const { from, to, empty } = state.selection
|
||||||
|
if (empty) return DecorationSet.empty
|
||||||
|
|
||||||
|
const decorations: Decoration[] = []
|
||||||
|
|
||||||
|
// Parcourir uniquement les nœuds de premier niveau (enfants directs de doc)
|
||||||
|
state.doc.forEach((node, offset) => {
|
||||||
|
const start = offset
|
||||||
|
const end = offset + node.nodeSize
|
||||||
|
|
||||||
|
// Vérifier si la plage de sélection [from, to] intersecte le bloc [start, end]
|
||||||
|
const intersects = Math.max(start, from) < Math.min(end, to)
|
||||||
|
if (intersects) {
|
||||||
|
// Il faut s'assurer que c'est un nœud de bloc pour appliquer Decoration.node
|
||||||
|
if (node.isBlock) {
|
||||||
|
try {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.node(start, end, {
|
||||||
|
class: 'block-selected',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback en cas d'erreur sur des structures de nœuds particulières
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isNodeSel = state.selection instanceof NodeSelection
|
||||||
|
|
||||||
|
// Si la sélection s'étend sur plusieurs blocs ou si c'est une NodeSelection explicite,
|
||||||
|
// on renvoie l'ensemble des décorations pour coloration visuelle.
|
||||||
|
if (decorations.length > 1 || (decorations.length === 1 && isNodeSel)) {
|
||||||
|
return DecorationSet.create(state.doc, decorations)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecorationSet.empty
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
27
memento-note/lib/editor/turn-into-shortcut-extension.ts
Normal file
27
memento-note/lib/editor/turn-into-shortcut-extension.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension TipTap qui permet de transformer instantanément le bloc sous le curseur
|
||||||
|
* en cyclant entre Titre 1, Titre 2, Titre 3 et Paragraphe normal via le raccourci clavier Cmd+Shift+H (ou Ctrl+Shift+H).
|
||||||
|
*/
|
||||||
|
export const TurnIntoShortcutExtension = Extension.create({
|
||||||
|
name: 'turnIntoShortcut',
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
'Mod-Shift-h': () => {
|
||||||
|
const { editor } = this
|
||||||
|
|
||||||
|
if (editor.isActive('heading', { level: 1 })) {
|
||||||
|
return editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||||
|
} else if (editor.isActive('heading', { level: 2 })) {
|
||||||
|
return editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||||
|
} else if (editor.isActive('heading', { level: 3 })) {
|
||||||
|
return editor.chain().focus().setParagraph().run()
|
||||||
|
} else {
|
||||||
|
return editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
48
memento-note/lib/editor/undo-redo-feedback-extension.ts
Normal file
48
memento-note/lib/editor/undo-redo-feedback-extension.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension TipTap qui intercepte les commandes d'annulation (Undo) et de rétablissement (Redo)
|
||||||
|
* pour lever un toast discret (2 secondes) de confirmation, en indiquant dynamiquement le raccourci de l'action inverse selon l'OS.
|
||||||
|
*/
|
||||||
|
export const UndoRedoFeedbackExtension = Extension.create({
|
||||||
|
name: 'undoRedoFeedback',
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
const isMac = typeof window !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent)
|
||||||
|
const modKey = isMac ? '⌘' : 'Ctrl'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Mod-z': () => {
|
||||||
|
const success = this.editor.commands.undo()
|
||||||
|
if (success) {
|
||||||
|
toast.info('Action annulée', {
|
||||||
|
description: `Faites ${modKey}+Maj+Z pour rétablir.`,
|
||||||
|
duration: 2000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
},
|
||||||
|
'Mod-y': () => {
|
||||||
|
const success = this.editor.commands.redo()
|
||||||
|
if (success) {
|
||||||
|
toast.info('Action rétablie', {
|
||||||
|
description: `Faites ${modKey}+Z pour annuler.`,
|
||||||
|
duration: 2000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
},
|
||||||
|
'Mod-Shift-z': () => {
|
||||||
|
const success = this.editor.commands.redo()
|
||||||
|
if (success) {
|
||||||
|
toast.info('Action rétablie', {
|
||||||
|
description: `Faites ${modKey}+Z pour annuler.`,
|
||||||
|
duration: 2000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -2320,9 +2320,28 @@
|
|||||||
"slashLoading": "AI thinking...",
|
"slashLoading": "AI thinking...",
|
||||||
"slashTabAll": "All",
|
"slashTabAll": "All",
|
||||||
"slashCatBasic": "Basic blocks",
|
"slashCatBasic": "Basic blocks",
|
||||||
|
"slashCatText": "Text",
|
||||||
"slashCatMedia": "Media",
|
"slashCatMedia": "Media",
|
||||||
|
"slashCatData": "Data",
|
||||||
|
"slashCatEmbed": "Embed",
|
||||||
"slashCatFormatting": "Formatting",
|
"slashCatFormatting": "Formatting",
|
||||||
"slashCatAi": "AI Note",
|
"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",
|
"insertImage": "Insert image",
|
||||||
"imageUrlPlaceholder": "https://example.com/image.png",
|
"imageUrlPlaceholder": "https://example.com/image.png",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
|
|||||||
@@ -2324,9 +2324,28 @@
|
|||||||
"slashLoading": "IA Note réfléchit...",
|
"slashLoading": "IA Note réfléchit...",
|
||||||
"slashTabAll": "Tout",
|
"slashTabAll": "Tout",
|
||||||
"slashCatBasic": "Blocs de base",
|
"slashCatBasic": "Blocs de base",
|
||||||
|
"slashCatText": "Texte",
|
||||||
"slashCatMedia": "Médias",
|
"slashCatMedia": "Médias",
|
||||||
|
"slashCatData": "Données",
|
||||||
|
"slashCatEmbed": "Intégré",
|
||||||
"slashCatFormatting": "Mise en forme",
|
"slashCatFormatting": "Mise en forme",
|
||||||
"slashCatAi": "IA Note",
|
"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",
|
"insertImage": "Insérer une image",
|
||||||
"imageUrlPlaceholder": "https://exemple.com/image.png",
|
"imageUrlPlaceholder": "https://exemple.com/image.png",
|
||||||
"preview": "Aperçu",
|
"preview": "Aperçu",
|
||||||
|
|||||||
Reference in New Issue
Block a user