fix: images, couverture, slash, agents, wizard, Stripe et admin
- Collage/accès images: ownership path userId + meta legacy, 403 non caché - Couverture note: explicite (choix/aucune), plus d’auto depuis le corps - Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée - Slash: listes par titre (plus d’index), keywords nettoyés - Agents: nextRun à la réactivation, cron sans stampede null - Wizard: titres courts + mode Expert plus robuste - Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings - Indexation notes courtes + test unit aligné
This commit is contained in:
@@ -25,14 +25,14 @@ export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4',
|
||||
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{metrics.map((metric, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="p-6 bg-white dark:bg-zinc-900 border-gray-200 dark:border-gray-800"
|
||||
className="p-6 bg-card border-border"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -1064,13 +1064,13 @@ export function ContextualAIChat({
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
|
||||
<div className="group relative p-6 rounded-2xl bg-card dark:bg-white/5 border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Layout size={80} className="text-brand-accent" />
|
||||
</div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><Layout size={18} /></div>
|
||||
<div className="p-2 bg-slate-50 dark:bg-white/10 rounded-lg text-brand-accent"><Layout size={18} /></div>
|
||||
<div className="space-y-0.5">
|
||||
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.slides')}</h5>
|
||||
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.sectionLabel')}</p>
|
||||
@@ -1170,13 +1170,13 @@ export function ContextualAIChat({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
|
||||
<div className="group relative p-6 rounded-2xl bg-card dark:bg-white/5 border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<BookOpen size={80} className="text-brand-accent" />
|
||||
</div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><BookOpen size={18} /></div>
|
||||
<div className="p-2 bg-slate-50 dark:bg-white/10 rounded-lg text-brand-accent"><BookOpen size={18} /></div>
|
||||
<div className="space-y-0.5">
|
||||
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.diagram')}</h5>
|
||||
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.diagramReadyHint')}</p>
|
||||
@@ -1280,7 +1280,7 @@ export function ContextualAIChat({
|
||||
{ id: 'complete', label: t('ai.resource.modeComplete'), sub: t('ai.resource.modeCompleteDesc') },
|
||||
{ id: 'merge', label: t('ai.resource.modeMerge'), sub: t('ai.resource.modeMergeDesc') },
|
||||
].map((mode) => (
|
||||
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white border-border hover:bg-slate-50 dark:hover:bg-white/5')}>
|
||||
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-card dark:bg-white/5 border-border hover:bg-slate-50 dark:hover:bg-white/10')}>
|
||||
<span className={cn("text-[10px] font-bold", resourceMode === mode.id ? 'text-brand-accent' : 'text-ink')}>{mode.label}</span>
|
||||
<span className="text-[8px] text-concrete opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
|
||||
</button>
|
||||
|
||||
@@ -112,6 +112,7 @@ export function NoteContentArea() {
|
||||
ref={richTextEditorRef}
|
||||
content={state.content}
|
||||
onChange={(v: string) => actions.setContent(v)}
|
||||
onChangeImmediate={(v: string) => actions.setContentImmediate(v)}
|
||||
className="min-h-[280px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
@@ -129,6 +130,7 @@ export function NoteContentArea() {
|
||||
ref={richTextEditorRef}
|
||||
content={state.content}
|
||||
onChange={actions.setContent}
|
||||
onChangeImmediate={actions.setContentImmediate}
|
||||
className="min-h-[200px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
|
||||
192
memento-note/components/note-editor/note-cover-hero.tsx
Normal file
192
memento-note/components/note-editor/note-cover-hero.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Couverture sous le titre (full-page).
|
||||
* - Affichée uniquement si une image de couverture est **explicite** (pas auto depuis le corps).
|
||||
* - Permet de retirer la couverture sans supprimer les images du contenu.
|
||||
* - Permet de choisir parmi les images de la note (corps + galerie).
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ImageIcon, X, Check, Images } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NoteCoverHeroProps {
|
||||
/** URL de couverture explicite (note.images[0]), null = pas de couverture */
|
||||
coverUrl: string | null
|
||||
/** Candidats (images du contenu + galerie) pour le sélecteur */
|
||||
candidates: string[]
|
||||
readOnly?: boolean
|
||||
title?: string
|
||||
onSetCover: (url: string) => void
|
||||
onClearCover: () => void
|
||||
}
|
||||
|
||||
export function NoteCoverHero({
|
||||
coverUrl,
|
||||
candidates,
|
||||
readOnly,
|
||||
title,
|
||||
onSetCover,
|
||||
onClearCover,
|
||||
}: NoteCoverHeroProps) {
|
||||
const { t } = useLanguage()
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
|
||||
const uniqueCandidates = Array.from(new Set(candidates.filter(Boolean)))
|
||||
const hasCover = Boolean(coverUrl)
|
||||
const canPick = !readOnly && uniqueCandidates.length > 0
|
||||
|
||||
// Rien à montrer : pas de cover et pas de candidats (ou lecture seule sans cover)
|
||||
if (!hasCover && (readOnly || uniqueCandidates.length === 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Pas de cover mais des images dans la note → barre discrète pour en choisir une
|
||||
if (!hasCover && canPick) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 px-4 py-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<ImageIcon className="h-4 w-4 shrink-0 opacity-70" />
|
||||
<span className="truncate">
|
||||
{t('notes.coverNoneHint') || 'Aucune image de couverture — choisissez une image de la note ou laissez vide.'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-border bg-background hover:bg-muted transition-colors shrink-0"
|
||||
>
|
||||
<Images className="h-3.5 w-3.5" />
|
||||
{t('notes.coverChoose') || 'Choisir une couverture'}
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<CoverPicker
|
||||
candidates={uniqueCandidates}
|
||||
selected={null}
|
||||
onSelect={(url) => {
|
||||
onSetCover(url)
|
||||
setPickerOpen(false)
|
||||
}}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasCover || !coverUrl) return null
|
||||
|
||||
return (
|
||||
<div className="group/cover relative aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt={title || ''}
|
||||
className="w-full h-full object-cover grayscale contrast-110 group-hover/cover:grayscale-0 transition-all duration-500"
|
||||
/>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="absolute inset-x-0 top-0 p-3 flex items-start justify-end gap-2 opacity-0 group-hover/cover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
{uniqueCandidates.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1.5 rounded-lg bg-black/60 text-white hover:bg-black/75 backdrop-blur-sm transition-colors"
|
||||
title={t('notes.coverChoose') || 'Changer la couverture'}
|
||||
>
|
||||
<Images className="h-3.5 w-3.5" />
|
||||
{t('notes.coverChange') || 'Changer'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearCover}
|
||||
className="inline-flex items-center justify-center h-8 w-8 rounded-lg bg-black/60 text-white hover:bg-red-600/90 backdrop-blur-sm transition-colors"
|
||||
title={t('notes.coverRemove') || 'Retirer la couverture'}
|
||||
aria-label={t('notes.coverRemove') || 'Retirer la couverture'}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pickerOpen && !readOnly && (
|
||||
<div className="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/70 to-transparent">
|
||||
<CoverPicker
|
||||
candidates={uniqueCandidates}
|
||||
selected={coverUrl}
|
||||
onSelect={(url) => {
|
||||
onSetCover(url)
|
||||
setPickerOpen(false)
|
||||
}}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
t={t}
|
||||
dark
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CoverPicker({
|
||||
candidates,
|
||||
selected,
|
||||
onSelect,
|
||||
onClose,
|
||||
t,
|
||||
dark,
|
||||
}: {
|
||||
candidates: string[]
|
||||
selected: string | null
|
||||
onSelect: (url: string) => void
|
||||
onClose: () => void
|
||||
t: (key: string) => string
|
||||
dark?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('w-full', dark ? '' : 'col-span-full pt-1')}>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<p className={cn('text-[11px] font-medium', dark ? 'text-white/80' : 'text-muted-foreground')}>
|
||||
{t('notes.coverPickLabel') || 'Image de couverture'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn('text-[11px] underline-offset-2 hover:underline', dark ? 'text-white/70' : 'text-muted-foreground')}
|
||||
>
|
||||
{t('notes.close') || 'Fermer'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{candidates.map((url) => {
|
||||
const isSelected = selected === url
|
||||
return (
|
||||
<button
|
||||
key={url}
|
||||
type="button"
|
||||
onClick={() => onSelect(url)}
|
||||
className={cn(
|
||||
'relative shrink-0 h-16 w-24 rounded-lg overflow-hidden border-2 transition-all',
|
||||
isSelected
|
||||
? 'border-brand-accent ring-2 ring-brand-accent/40'
|
||||
: dark
|
||||
? 'border-white/20 hover:border-white/50'
|
||||
: 'border-border hover:border-foreground/30',
|
||||
)}
|
||||
>
|
||||
<img src={url} alt="" className="h-full w-full object-cover" />
|
||||
{isSelected && (
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<Check className="h-5 w-5 text-white drop-shadow" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -332,10 +332,29 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setLinks(links.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
/** Images extraites du corps (TipTap / markdown) — pas de couverture auto */
|
||||
const contentImages = useMemo(() => {
|
||||
if (isMarkdown) {
|
||||
const urls = new Set<string>()
|
||||
for (const match of content.matchAll(/!\[.*?\]\((.*?)\)/g)) {
|
||||
const src = match[1]?.trim()
|
||||
if (src) urls.add(src)
|
||||
}
|
||||
return Array.from(urls)
|
||||
}
|
||||
return extractImagesFromHTML(content)
|
||||
}, [content, isMarkdown])
|
||||
|
||||
/**
|
||||
* allImages = galerie explicite + corps (pour IA, sélecteur de couverture).
|
||||
* La couverture hero n'utilise PAS allImages[0] automatiquement.
|
||||
*/
|
||||
const allImages = useMemo(() => {
|
||||
const extracted = !isMarkdown ? extractImagesFromHTML(content) : []
|
||||
return Array.from(new Set([...images, ...extracted]))
|
||||
}, [images, content, isMarkdown])
|
||||
return Array.from(new Set([...images, ...contentImages]))
|
||||
}, [images, contentImages])
|
||||
|
||||
/** Couverture explicite = première entrée de note.images (jamais auto depuis le corps) */
|
||||
const coverImage = images[0] ?? null
|
||||
|
||||
const resolveContentForSave = useCallback((): string => {
|
||||
if (!isMarkdown) {
|
||||
@@ -345,27 +364,32 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return contentRef.current
|
||||
}, [isMarkdown])
|
||||
|
||||
const resolveImagesForSave = useCallback((contentToSave: string): string[] => {
|
||||
// Images présentes dans le contenu de l'éditeur (inline dans le HTML ou Markdown)
|
||||
let contentImages: string[] = []
|
||||
if (contentToSave) {
|
||||
if (!isMarkdown) {
|
||||
contentImages = extractImagesFromHTML(contentToSave)
|
||||
} else {
|
||||
const urls = new Set<string>()
|
||||
const matches = contentToSave.matchAll(/!\[.*?\]\((.*?)\)/g)
|
||||
for (const match of matches) {
|
||||
const src = match[1]?.trim()
|
||||
if (src) urls.add(src)
|
||||
}
|
||||
contentImages = Array.from(urls)
|
||||
}
|
||||
}
|
||||
// Images "standalone" uploadées séparément (hors contenu éditeur), non supprimées
|
||||
const standaloneImages = images.filter(url => !removedImageUrls.includes(url) && !contentImages.includes(url))
|
||||
// Fusion dédupliquée : images du contenu + images standalone conservées
|
||||
return Array.from(new Set([...contentImages, ...standaloneImages]))
|
||||
}, [isMarkdown, images, removedImageUrls])
|
||||
/**
|
||||
* note.images = couverture / galerie **explicite** uniquement.
|
||||
* Ne plus fusionner les images du corps : sinon, coller une image dans l'éditeur
|
||||
* la plaçait sous le titre, et la supprimer du corps la laissait en "standalone" forever.
|
||||
*/
|
||||
const resolveImagesForSave = useCallback((_contentToSave: string): string[] => {
|
||||
return images.filter((url) => !removedImageUrls.includes(url))
|
||||
}, [images, removedImageUrls])
|
||||
|
||||
const setCoverImage = useCallback((url: string) => {
|
||||
setImages((prev) => {
|
||||
const rest = prev.filter((u) => u !== url)
|
||||
return [url, ...rest]
|
||||
})
|
||||
setRemovedImageUrls((prev) => prev.filter((u) => u !== url))
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
const clearCoverImage = useCallback(() => {
|
||||
setImages((prev) => {
|
||||
if (prev.length === 0) return prev
|
||||
setRemovedImageUrls((r) => Array.from(new Set([...r, ...prev])))
|
||||
return []
|
||||
})
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
|
||||
const handleGenerateTitles = async () => {
|
||||
@@ -902,6 +926,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
isAnalyzingSuggestions,
|
||||
isMarkdown,
|
||||
allImages,
|
||||
contentImages,
|
||||
coverImage,
|
||||
colorClasses,
|
||||
quotaExceededFeature,
|
||||
autoSaveEnabled,
|
||||
@@ -911,13 +937,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
|
||||
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
|
||||
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
|
||||
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature, autoSaveEnabled
|
||||
isAnalyzingSuggestions, isMarkdown, allImages, contentImages, coverImage, colorClasses, quotaExceededFeature, autoSaveEnabled
|
||||
])
|
||||
|
||||
const actions: NoteEditorActions = useMemo(() => ({
|
||||
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
|
||||
setDismissedTitleSuggestions,
|
||||
setContent: (c) => { setContent(c); setIsDirty(true) },
|
||||
setContentImmediate: (c) => { setContentImmediate(c); setIsDirty(true) },
|
||||
setCheckItems,
|
||||
handleCheckItem,
|
||||
handleUpdateCheckItem,
|
||||
@@ -930,6 +957,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setImages,
|
||||
handleImageUpload,
|
||||
handleRemoveImage,
|
||||
setCoverImage,
|
||||
clearCoverImage,
|
||||
uploadImageFile,
|
||||
setLinks,
|
||||
handleAddLink,
|
||||
@@ -974,6 +1003,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setQuotaExceededFeature,
|
||||
toggleAutoSave,
|
||||
}), [
|
||||
setContent, setContentImmediate, setCoverImage, clearCoverImage,
|
||||
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
|
||||
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
|
||||
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useNoteEditorContext } from './note-editor-context'
|
||||
import { NoteEditorToolbar } from './note-editor-toolbar'
|
||||
import { NoteTitleBlock } from './note-title-block'
|
||||
import { NoteContentArea } from './note-content-area'
|
||||
import { EditorImages } from '@/components/editor-images'
|
||||
import { NoteCoverHero } from './note-cover-hero'
|
||||
import { ComparisonModal } from '@/components/comparison-modal'
|
||||
import { FusionModal } from '@/components/fusion-modal'
|
||||
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||
@@ -119,16 +119,15 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hero image — show first note image if present */}
|
||||
{state.allImages.length > 0 && (
|
||||
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
|
||||
<img
|
||||
src={state.allImages[0]}
|
||||
alt={state.title}
|
||||
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Couverture explicite — pas d'auto-promo des images collées dans le corps */}
|
||||
<NoteCoverHero
|
||||
coverUrl={state.coverImage}
|
||||
candidates={state.allImages}
|
||||
readOnly={readOnly}
|
||||
title={state.title}
|
||||
onSetCover={actions.setCoverImage}
|
||||
onClearCover={actions.clearCoverImage}
|
||||
/>
|
||||
|
||||
{/* Content area — max-w-4xl for wider reading column */}
|
||||
<div className="max-w-4xl mx-auto w-full space-y-8 pb-32">
|
||||
|
||||
@@ -539,8 +539,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
{/* 1. Status */}
|
||||
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none me-1">
|
||||
{state.isSaving
|
||||
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></>
|
||||
: state.isDirty
|
||||
@@ -557,165 +558,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</span>
|
||||
)}
|
||||
|
||||
{state.isMarkdown && !readOnly && (
|
||||
{/* 2. Save (primary) */}
|
||||
{!readOnly && (
|
||||
<button
|
||||
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
||||
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
||||
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
||||
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
|
||||
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
|
||||
onClick={() => actions.handleSaveInPlace()}
|
||||
disabled={state.isSaving || !state.isDirty}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
state.showMarkdownPreview
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||
state.isDirty
|
||||
? 'bg-foreground text-background border-foreground hover:opacity-80'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Eye size={16} />
|
||||
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state.isMarkdown && !readOnly && (
|
||||
<button
|
||||
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
||||
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
||||
onClick={handleConvertToRichtext}
|
||||
disabled={isConverting}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
isConverting && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
title={t('ai.openAssistant')}
|
||||
aria-label={t('ai.openAssistant')}
|
||||
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
state.aiOpen
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={t('notes.brainstormThisIdea')}
|
||||
aria-label={t('notes.brainstormThisIdeaAria')}
|
||||
onClick={() => {
|
||||
const title = note.title || ''
|
||||
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
|
||||
const seed = title ? `${title}. ${summary}` : summary
|
||||
if (!seed.trim()) return
|
||||
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
|
||||
}}
|
||||
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
|
||||
>
|
||||
<Wind size={16} />
|
||||
</button>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="relative">
|
||||
<button
|
||||
title={t('flashcards.toolbarGenerate')}
|
||||
aria-label={t('flashcards.toolbarGenerate')}
|
||||
onClick={() => setShowEduMenu(v => !v)}
|
||||
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||
>
|
||||
<GraduationCap size={16} />
|
||||
</button>
|
||||
{showEduMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowEduMenu(false)} />
|
||||
<div className="absolute top-full right-0 mt-1 z-50 w-56 rounded-xl border border-border bg-card shadow-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400">
|
||||
<GraduationCap size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowEduMenu(false); handleGenerateExercises() }}
|
||||
disabled={generatingExercises}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
|
||||
{generatingExercises ? <Loader2Icon size={16} className="animate-spin" /> : <PenTool size={16} />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('richTextEditor.generateExercises') || 'Générer des exercices'}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && voiceSupported && (
|
||||
<button
|
||||
title={voiceState === 'listening'
|
||||
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
||||
: (t('editor.voiceStart') || 'Dicter du texte')}
|
||||
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
|
||||
onClick={toggleVoice}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all',
|
||||
voiceState === 'listening'
|
||||
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && onToggleAttachments && (
|
||||
<button
|
||||
title={t('notes.attachments') || 'Attachments'}
|
||||
aria-label={t('notes.attachments') || 'Attachments'}
|
||||
onClick={onToggleAttachments}
|
||||
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
{(attachmentsCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
|
||||
{attachmentsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
|
||||
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
|
||||
onClick={() => actions.handleSaveInPlace()}
|
||||
disabled={state.isSaving || !state.isDirty}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
state.isDirty
|
||||
? 'bg-foreground text-background border-foreground hover:opacity-80'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Publish */}
|
||||
{!readOnly && (
|
||||
<DropdownMenu open={publishOpen} onOpenChange={setPublishOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -904,6 +765,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* 4. Share */}
|
||||
{!readOnly && (
|
||||
<button
|
||||
title={t('notes.shareNoteTitle')}
|
||||
@@ -915,6 +777,151 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 5. AI assistant */}
|
||||
<button
|
||||
title={t('ai.openAssistant')}
|
||||
aria-label={t('ai.openAssistant')}
|
||||
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
state.aiOpen
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
|
||||
{/* 6. Brainstorm */}
|
||||
<button
|
||||
title={t('notes.brainstormThisIdea')}
|
||||
aria-label={t('notes.brainstormThisIdeaAria')}
|
||||
onClick={() => {
|
||||
const title = note.title || ''
|
||||
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
|
||||
const seed = title ? `${title}. ${summary}` : summary
|
||||
if (!seed.trim()) return
|
||||
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
|
||||
}}
|
||||
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
|
||||
>
|
||||
<Wind size={16} />
|
||||
</button>
|
||||
|
||||
{/* 7. Education (flashcards / exercises) */}
|
||||
{!readOnly && (
|
||||
<div className="relative">
|
||||
<button
|
||||
title={t('flashcards.toolbarGenerate')}
|
||||
aria-label={t('flashcards.toolbarGenerate')}
|
||||
onClick={() => setShowEduMenu(v => !v)}
|
||||
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||
>
|
||||
<GraduationCap size={16} />
|
||||
</button>
|
||||
{showEduMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowEduMenu(false)} />
|
||||
<div className="absolute top-full right-0 mt-1 z-50 w-56 rounded-xl border border-border bg-card shadow-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400">
|
||||
<GraduationCap size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowEduMenu(false); handleGenerateExercises() }}
|
||||
disabled={generatingExercises}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
|
||||
{generatingExercises ? <Loader2Icon size={16} className="animate-spin" /> : <PenTool size={16} />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('richTextEditor.generateExercises') || 'Générer des exercices'}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 8. Voice / attachments / markdown tools */}
|
||||
{!readOnly && voiceSupported && (
|
||||
<button
|
||||
title={voiceState === 'listening'
|
||||
? (t('editor.voiceStop') || 'Arrêter la dictée')
|
||||
: (t('editor.voiceStart') || 'Dicter du texte')}
|
||||
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
|
||||
onClick={toggleVoice}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all',
|
||||
voiceState === 'listening'
|
||||
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && onToggleAttachments && (
|
||||
<button
|
||||
title={t('notes.attachments') || 'Attachments'}
|
||||
aria-label={t('notes.attachments') || 'Attachments'}
|
||||
onClick={onToggleAttachments}
|
||||
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
{(attachmentsCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
|
||||
{attachmentsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state.isMarkdown && !readOnly && (
|
||||
<button
|
||||
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
||||
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
||||
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
state.showMarkdownPreview
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state.isMarkdown && !readOnly && (
|
||||
<button
|
||||
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
||||
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
||||
onClick={handleConvertToRichtext}
|
||||
disabled={isConverting}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all duration-300',
|
||||
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
isConverting && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 9. Overflow menu */}
|
||||
{!readOnly && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface NoteEditorState {
|
||||
|
||||
isMarkdown: boolean
|
||||
allImages: string[]
|
||||
/** Images extraites du corps de la note (pas la couverture) */
|
||||
contentImages: string[]
|
||||
/** URL de couverture explicite (images[0]), null si aucune */
|
||||
coverImage: string | null
|
||||
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
|
||||
quotaExceededFeature: string | null
|
||||
}
|
||||
@@ -61,6 +65,8 @@ export interface NoteEditorActions {
|
||||
setDismissedTitleSuggestions: (dismissed: boolean) => void
|
||||
|
||||
setContent: (content: string) => void
|
||||
/** Immediate content state (no 800ms debounce) — image paste, conversions, etc. */
|
||||
setContentImmediate: (content: string) => void
|
||||
|
||||
setCheckItems: (items: CheckItem[]) => void
|
||||
handleCheckItem: (id: string) => void
|
||||
@@ -76,6 +82,10 @@ export interface NoteEditorActions {
|
||||
setImages: (images: string[]) => void
|
||||
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
handleRemoveImage: (index: number) => void
|
||||
/** Définit l'image de couverture (sous le titre) sans retirer l'image du corps */
|
||||
setCoverImage: (url: string) => void
|
||||
/** Retire la couverture (les images du corps restent) */
|
||||
clearCoverImage: () => void
|
||||
uploadImageFile: (file: File) => Promise<string>
|
||||
|
||||
setLinks: (links: LinkMetadata[]) => void
|
||||
|
||||
@@ -70,10 +70,11 @@ import {
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { NodeSelection } from '@tiptap/pm/state'
|
||||
|
||||
export interface RichTextEditorHandle {
|
||||
getEditor: () => Editor | null
|
||||
@@ -88,6 +89,8 @@ export interface RichTextEditorHandle {
|
||||
export interface RichTextEditorProps {
|
||||
content?: string
|
||||
onChange?: (content: string) => void
|
||||
/** Immediate parent state update (image paste, structural inserts) — skips typing debounce. */
|
||||
onChangeImmediate?: (content: string) => void
|
||||
className?: string
|
||||
placeholder?: string
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
@@ -137,6 +140,11 @@ const TRANSLATE_TARGET_API_VALUES = ['Francais', 'English', 'Espanol', 'Deutsch'
|
||||
const AI_REFORMULATE_FALLBACK = '__RICH_TEXT_AI_FALLBACK__'
|
||||
|
||||
const CustomImage = Image.extend({
|
||||
// Treat image as a single selectable unit (Backspace/Delete must remove it)
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
@@ -149,22 +157,98 @@ const CustomImage = Image.extend({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const deleteSelectedImage = () => {
|
||||
const { selection } = this.editor.state
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
|
||||
return this.editor.chain().focus().deleteSelection().run()
|
||||
}
|
||||
// Cursor just after an image block → Backspace removes it
|
||||
const { $from, empty } = selection
|
||||
if (!empty) return false
|
||||
const before = $from.nodeBefore
|
||||
if (before?.type.name === 'image') {
|
||||
const from = $from.pos - before.nodeSize
|
||||
return this.editor.chain().focus().deleteRange({ from, to: $from.pos }).run()
|
||||
}
|
||||
// Cursor at start of paragraph right after image (common after paste under title)
|
||||
if ($from.parentOffset === 0 && $from.depth >= 1) {
|
||||
const index = $from.index($from.depth - 1)
|
||||
if (index > 0) {
|
||||
const prev = $from.node($from.depth - 1).child(index - 1)
|
||||
if (prev.type.name === 'image') {
|
||||
const pos = $from.before($from.depth) - prev.nodeSize
|
||||
return this.editor.chain().focus().deleteRange({ from: pos, to: pos + prev.nodeSize }).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
Backspace: deleteSelectedImage,
|
||||
Delete: () => {
|
||||
const { selection } = this.editor.state
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
|
||||
return this.editor.chain().focus().deleteSelection().run()
|
||||
}
|
||||
// Cursor just before an image → Delete removes it
|
||||
const { $from, empty } = selection
|
||||
if (!empty) return false
|
||||
const after = $from.nodeAfter
|
||||
if (after?.type.name === 'image') {
|
||||
return this.editor.chain().focus().deleteRange({ from: $from.pos, to: $from.pos + after.nodeSize }).run()
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** Insert / toggle a bullet list without the toggle-off → re-insert glitch. */
|
||||
function insertBulletList(editor: Editor) {
|
||||
// Already a bullet list → toggle off (do not re-insert)
|
||||
if (editor.isActive('bulletList')) {
|
||||
editor.chain().focus().toggleBulletList().run()
|
||||
return
|
||||
}
|
||||
// Convert from other list types if needed
|
||||
if (editor.isActive('orderedList')) editor.chain().focus().toggleOrderedList().run()
|
||||
if (editor.isActive('taskList')) editor.chain().focus().toggleTaskList().run()
|
||||
const ok = editor.chain().focus().toggleBulletList().run()
|
||||
if (!ok) {
|
||||
editor.chain().focus().insertContent('<ul><li><p></p></li></ul>').run()
|
||||
}
|
||||
}
|
||||
|
||||
function insertOrderedList(editor: Editor) {
|
||||
if (editor.isActive('orderedList')) {
|
||||
editor.chain().focus().toggleOrderedList().run()
|
||||
return
|
||||
}
|
||||
if (editor.isActive('bulletList')) editor.chain().focus().toggleBulletList().run()
|
||||
if (editor.isActive('taskList')) editor.chain().focus().toggleTaskList().run()
|
||||
const ok = editor.chain().focus().toggleOrderedList().run()
|
||||
if (!ok) {
|
||||
editor.chain().focus().insertContent('<ol><li><p></p></li></ol>').run()
|
||||
}
|
||||
}
|
||||
|
||||
const slashCommands: SlashItem[] = [
|
||||
// Basic blocks
|
||||
// Basic blocks — lists BEFORE table so defaults / shortcuts never collide with Tableau
|
||||
{ title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: '¶', command: (e) => e.chain().focus().setParagraph().run() },
|
||||
{ title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', shortcut: '#', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||
{ title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', shortcut: '##', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', shortcut: '###', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => e.chain().focus().toggleBulletList().run() },
|
||||
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() },
|
||||
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => insertBulletList(e) },
|
||||
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => insertOrderedList(e) },
|
||||
{ title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() },
|
||||
{ title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', shortcut: '>', command: (e) => e.chain().focus().toggleBlockquote().run() },
|
||||
{ title: 'Code Block', description: 'Code snippet', icon: CodeXml, category: 'Basic blocks', shortcut: '```', command: (e) => e.chain().focus().toggleCodeBlock().run() },
|
||||
{ title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() },
|
||||
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
// Media
|
||||
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } },
|
||||
// Formatting
|
||||
@@ -300,7 +384,7 @@ function useImageInsert() {
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
|
||||
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
@@ -361,11 +445,22 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
const editorInstanceRef = useRef<Editor | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
onChangeRef.current = onChange
|
||||
const onChangeImmediateRef = useRef(onChangeImmediate)
|
||||
onChangeImmediateRef.current = onChangeImmediate
|
||||
const htmlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const emitContentChange = useCallback((html: string) => {
|
||||
const emitContentChange = useCallback((html: string, opts?: { immediate?: boolean }) => {
|
||||
lastEmittedContent.current = html
|
||||
onChangeRef.current?.(html)
|
||||
// Cancel pending debounced HTML emit so it cannot overwrite with stale doc
|
||||
if (htmlDebounceRef.current) {
|
||||
clearTimeout(htmlDebounceRef.current)
|
||||
htmlDebounceRef.current = null
|
||||
}
|
||||
if (opts?.immediate && onChangeImmediateRef.current) {
|
||||
onChangeImmediateRef.current(html)
|
||||
} else {
|
||||
onChangeRef.current?.(html)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Debounced version for onUpdate — avoids calling getHTML() on every keystroke
|
||||
@@ -581,6 +676,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
const hasImage = items.some(item => item.type.startsWith('image/'))
|
||||
if (!hasImage) return false
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const imageFiles = items
|
||||
.filter(item => item.type.startsWith('image/'))
|
||||
.map(item => item.getAsFile())
|
||||
@@ -592,20 +688,82 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
toast.info(t('notes.uploading'))
|
||||
const url = await onImageUpload(file)
|
||||
const ed = editorInstanceRef.current
|
||||
if (!ed) continue
|
||||
if (!ed || ed.isDestroyed) continue
|
||||
// Insert at cursor; force immediate parent sync so save/ref don't drop the img
|
||||
const inserted = ed.chain().focus().setImage({ src: url }).run()
|
||||
if (inserted) {
|
||||
emitContentChange(ed.getHTML())
|
||||
const html = ed.getHTML()
|
||||
emitContentChange(html, { immediate: true })
|
||||
// Prefetch so the img is warm in the browser cache
|
||||
const preload = new window.Image()
|
||||
preload.src = url
|
||||
toast.success(t('notes.uploadSuccess') || 'Image ajoutée')
|
||||
} else {
|
||||
toast.error(t('notes.uploadFailed') || 'Insertion image échouée')
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[editor] image paste failed:', err)
|
||||
toast.error(t('notes.uploadFailed'))
|
||||
}
|
||||
}
|
||||
})()
|
||||
return true
|
||||
}
|
||||
},
|
||||
// ProseMirror signature: (view, pos, event)
|
||||
handleClick: (view, pos, event) => {
|
||||
// Click on img → NodeSelection so Backspace/Delete and bubble toolbar work
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target || target.tagName !== 'IMG') return false
|
||||
if (!view.editable) return false
|
||||
try {
|
||||
let imagePos: number | null = null
|
||||
const nodeAt = view.state.doc.nodeAt(pos)
|
||||
if (nodeAt?.type.name === 'image') {
|
||||
imagePos = pos
|
||||
} else {
|
||||
const $pos = view.state.doc.resolve(pos)
|
||||
if ($pos.nodeAfter?.type.name === 'image') {
|
||||
imagePos = $pos.pos
|
||||
} else if ($pos.nodeBefore?.type.name === 'image') {
|
||||
imagePos = $pos.pos - $pos.nodeBefore.nodeSize
|
||||
} else {
|
||||
// Walk up: pos may sit inside / around the leaf
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
if ($pos.node(d).type.name === 'image') {
|
||||
imagePos = $pos.before(d)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imagePos == null) {
|
||||
// Last resort: posAtDOM on the img element
|
||||
const domPos = view.posAtDOM(target, 0)
|
||||
const $dom = view.state.doc.resolve(domPos)
|
||||
if ($dom.nodeAfter?.type.name === 'image') imagePos = $dom.pos
|
||||
else if (view.state.doc.nodeAt(domPos)?.type.name === 'image') imagePos = domPos
|
||||
}
|
||||
if (imagePos == null) return false
|
||||
const node = view.state.doc.nodeAt(imagePos)
|
||||
if (!node || node.type.name !== 'image') return false
|
||||
const tr = view.state.tr.setSelection(NodeSelection.create(view.state.doc, imagePos))
|
||||
view.dispatch(tr)
|
||||
view.focus()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onUpdate: ({ editor: e, transaction }) => {
|
||||
// Image removed → immediate parent sync so a stale content prop cannot resurrect it
|
||||
if (transaction.docChanged && (lastEmittedContent.current || '').includes('<img')) {
|
||||
const html = e.getHTML()
|
||||
if (!html.includes('<img')) {
|
||||
emitContentChange(html, { immediate: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
// Debounced getHTML() — avoids traversing the whole doc on every keystroke
|
||||
emitContentChangeDebounced(e)
|
||||
if (!e.isEditable) return
|
||||
@@ -763,6 +921,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content !== undefined && content !== lastEmittedContent.current) {
|
||||
// While the user is editing, never clobber local deletes (e.g. removed image)
|
||||
// with a stale parent `content` that has not caught up yet.
|
||||
if (editor.isFocused) {
|
||||
return
|
||||
}
|
||||
const html = content || ''
|
||||
lastEmittedContent.current = html
|
||||
queueMicrotask(() => {
|
||||
@@ -774,7 +937,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
}
|
||||
})
|
||||
}
|
||||
if (content !== undefined) {
|
||||
if (content !== undefined && !editor?.isFocused) {
|
||||
setCurrentNoteContent(content || '')
|
||||
}
|
||||
}, [content, editor, sourceUrl])
|
||||
@@ -1103,7 +1266,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
if (!editor) return
|
||||
editor.chain().focus().setImage({ src: url }).run()
|
||||
setSmartPasteExtended(null)
|
||||
emitContentChange(editor.getHTML())
|
||||
emitContentChange(editor.getHTML(), { immediate: true })
|
||||
}, [editor, emitContentChange])
|
||||
|
||||
const handlePasteUrlVideo = useCallback((url: string) => {
|
||||
@@ -1521,9 +1684,47 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
|
||||
{editorState.isImage && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
|
||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">25%</button>
|
||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">50%</button>
|
||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">100%</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()}
|
||||
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
|
||||
title="25%"
|
||||
>
|
||||
25%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()}
|
||||
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
|
||||
title="50%"
|
||||
>
|
||||
50%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()}
|
||||
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
|
||||
title="100%"
|
||||
>
|
||||
100%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const { selection } = editor.state
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
|
||||
editor.chain().focus().deleteSelection().run()
|
||||
} else if (editor.isActive('image')) {
|
||||
// Fallback: delete the nearest selected image node
|
||||
editor.chain().focus().deleteSelection().run()
|
||||
}
|
||||
}}
|
||||
className="notion-bubble-btn rounded-md text-red-600 dark:text-red-400"
|
||||
title={t('richTextEditor.deleteImage') || t('common.delete') || 'Supprimer l\'image'}
|
||||
aria-label={t('richTextEditor.deleteImage') || 'Supprimer l\'image'}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{aiOpen && (
|
||||
@@ -1728,45 +1929,52 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
const menuInteracting = useRef(false)
|
||||
const [frequentCommands, setFrequentCommands] = useState<SlashMenuItem[]>([])
|
||||
|
||||
// Resolve by English command title — NEVER by array index (reorder-safe)
|
||||
const sc = (enTitle: string) => {
|
||||
const cmd = slashCommands.find(c => c.title === enTitle)
|
||||
if (!cmd) throw new Error(`Slash command missing: ${enTitle}`)
|
||||
return cmd
|
||||
}
|
||||
|
||||
const localCommands: SlashMenuItem[] = [
|
||||
{ ...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: '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: '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: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
|
||||
{ ...slashCommands[29], title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer 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'] },
|
||||
{ ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
|
||||
{ ...slashCommands[32], title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
|
||||
{ ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] },
|
||||
{ ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
|
||||
{ ...slashCommands[35], title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
|
||||
{ ...slashCommands[36], title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] },
|
||||
{ ...slashCommands[37], title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] },
|
||||
{ ...sc('Text'), title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' },
|
||||
{ ...sc('Heading 1'), title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' },
|
||||
{ ...sc('Heading 2'), title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' },
|
||||
{ ...sc('Heading 3'), title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' },
|
||||
{ ...sc('Bullet List'), title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text', slashKeywords: ['bullet', 'liste', 'puce', 'ul', 'puces'] },
|
||||
{ ...sc('Numbered List'), title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text', slashKeywords: ['numbered', 'numérotée', 'numerotee', 'ol', '1.'] },
|
||||
{ ...sc('To-do List'), title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text', slashKeywords: ['todo', 'tache', 'tâche', 'checkbox'] },
|
||||
{ ...sc('Quote'), title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' },
|
||||
{ ...sc('Code Block'), title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' },
|
||||
{ ...sc('Divider'), title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' },
|
||||
{ ...sc('Table'), title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data', slashKeywords: ['table', 'tableau', 'grid', 'grille'] },
|
||||
{ ...sc('Image'), title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
|
||||
{ ...sc('Align Left'), title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' },
|
||||
{ ...sc('Align Center'), title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' },
|
||||
{ ...sc('Align Right'), title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' },
|
||||
{ ...sc('Clarifier'), title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Raccourcir'), title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Améliorer'), title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Développer'), title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Bold'), title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' },
|
||||
{ ...sc('Italic'), title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' },
|
||||
{ ...sc('Underline'), title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' },
|
||||
{ ...sc('Strike'), title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' },
|
||||
{ ...sc('Highlight'), title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' },
|
||||
{ ...sc('Superscript'), title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' },
|
||||
{ ...sc('Subscript'), title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
|
||||
{ ...sc('Diagramme'), title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Présentation'), title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
|
||||
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
|
||||
{ ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
|
||||
{ ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
|
||||
{ ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
|
||||
{ ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] },
|
||||
{ ...sc('Link Preview'), title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
|
||||
{ ...sc('Math'), title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
|
||||
{ ...sc('Columns'), title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] },
|
||||
{ ...sc("Écrire avec l'IA"), title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] },
|
||||
{
|
||||
title: t('richTextEditor.slashNoteLink'),
|
||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||
@@ -1870,7 +2078,10 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
toastAi(err)
|
||||
}
|
||||
finally { setAiLoading(false) }
|
||||
} else if (item.title === 'Suggest Charts') {
|
||||
} else if (
|
||||
item.title === 'Suggest Charts'
|
||||
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA')
|
||||
) {
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else if (item.title === t('richTextEditor.slashDatabase')) {
|
||||
deleteSlashText(); closeMenu()
|
||||
|
||||
@@ -68,7 +68,7 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
|
||||
level: t(LEVELS[level]),
|
||||
count,
|
||||
language,
|
||||
notebookName: topic.trim(),
|
||||
// Server generates a short title from the topic; do not force raw prompt as name
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user