Files
Momento/memento-note/components/note-editor/note-editor-context.tsx
Antigravity 45297da333 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é
2026-07-16 16:58:07 +00:00

1048 lines
36 KiB
TypeScript

'use client'
import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from '@/lib/types'
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { useNotebooks } from '@/context/notebooks-context'
import { emitNoteChange, NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useSession } from 'next-auth/react'
import { getAISettings, updateAISettings } from '@/app/actions/ai-settings'
import { extractImagesFromHTML } from '@/lib/utils'
import { queryKeys } from '@/lib/query-keys'
import type { RichTextEditorHandle } from '@/components/rich-text-editor'
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
import type { TagSuggestion } from '@/lib/ai/types'
import type { NoteEditorState, NoteEditorActions, NoteEditorContextValue } from './types'
const NoteEditorContext = createContext<NoteEditorContextValue | undefined>(undefined)
interface NoteEditorProviderProps {
note: Note
readOnly?: boolean
fullPage?: boolean
onNoteSaved?: (savedNote: Note) => void
children: ReactNode
}
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, onNoteSaved, children }: NoteEditorProviderProps) {
const { data: session } = useSession()
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const queryClient = useQueryClient()
const { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId, notebooks } = useNotebooks()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
useEffect(() => {
if (session?.user?.id) {
getAISettings(session.user.id).then(settings => {
setAiAssistantEnabled(settings.paragraphRefactor !== false)
setAutoLabelingEnabled(settings.autoLabeling !== false)
setAutoSaveEnabled(settings.autoSave !== false)
}).catch(err => console.error("Failed to fetch AI settings", err))
}
}, [session?.user?.id])
const [quotaExceededFeature, setQuotaExceededFeature] = useState<string | null>(null)
const [title, setTitle] = useState(note.title || '')
const contentRef = useRef(note.content)
const [content, setContentState] = useState(note.content)
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const setContentImmediate = useCallback((newVal: string) => {
contentRef.current = newVal
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}
setContentState(newVal)
}, [])
// setContent : met à jour contentRef immédiatement (pour les saves),
// mais ne déclenche un re-render React qu'après 800ms (throttle) pour éviter
// un render à chaque frappe.
const setContent = useCallback((newVal: string) => {
contentRef.current = newVal
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}
debounceTimeoutRef.current = setTimeout(() => {
setContentState(newVal)
}, 800)
}, [])
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [labels, setLabels] = useState<string[]>(note.labels || [])
const [images, setImages] = useState<string[]>(note.images || [])
const [links, setLinks] = useState<LinkMetadata[]>(note.links || [])
const [newLabel, setNewLabel] = useState('')
const [color, setColor] = useState(note.color)
const [size, setSize] = useState<NoteSize>(note.size || 'small')
const [isSaving, setIsSaving] = useState(false)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
const [isMarkdown, setIsMarkdown] = useState(note.type === 'markdown')
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const richTextEditorRef = useRef<RichTextEditorHandle>(null)
const prevNoteRef = useRef(note)
useEffect(() => {
const prev = prevNoteRef.current
if (note.id !== prev.id) {
setQuotaExceededFeature(null)
setTitle(note.title || '')
setContentImmediate(note.content)
setCheckItems(note.checkItems || [])
setLabels(note.labels || [])
setImages(note.images || [])
setLinks(note.links || [])
setColor(note.color)
setSize(note.size || 'small')
setIsMarkdown(note.type === 'markdown')
setShowMarkdownPreview(note.type === 'markdown')
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
} else {
if (note.title !== prev.title) setTitle(note.title || '')
// Ne pas réinitialiser le contenu quand seuls images/links changent (post-save)
if (note.content !== prev.content) setContentImmediate(note.content)
if (JSON.stringify(note.checkItems || []) !== JSON.stringify(prev.checkItems || [])) {
setCheckItems(note.checkItems || [])
}
if (JSON.stringify(note.labels || []) !== JSON.stringify(prev.labels || [])) {
setLabels(note.labels || [])
}
if (JSON.stringify(note.images || []) !== JSON.stringify(prev.images || [])) {
setImages(note.images || [])
}
if (JSON.stringify(note.links || []) !== JSON.stringify(prev.links || [])) {
setLinks(note.links || [])
}
if (note.color !== prev.color) setColor(note.color)
if ((note.size || 'small') !== (prev.size || 'small')) setSize(note.size || 'small')
const noteIsMarkdown = note.type === 'markdown'
const prevIsMarkdown = prev.type === 'markdown'
if (noteIsMarkdown !== prevIsMarkdown) {
setIsMarkdown(noteIsMarkdown)
setShowMarkdownPreview(noteIsMarkdown)
}
const prevReminder = prev.reminder ? new Date(prev.reminder as unknown as string).getTime() : null
const nextReminder = note.reminder ? new Date(note.reminder as unknown as string).getTime() : null
if (prevReminder !== nextReminder) {
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
}
}
prevNoteRef.current = note
}, [note])
useEffect(() => {
setContextNotebookId(note.notebookId || null)
}, [note.notebookId, setContextNotebookId])
const [dismissedTags, setDismissedTags] = useState<string[]>([])
const dismissedTagsLoadedRef = useRef(false)
useEffect(() => {
dismissedTagsLoadedRef.current = false
try {
const stored = localStorage.getItem(`dismissed-tags-${note.id}`)
if (stored) {
setDismissedTags(JSON.parse(stored))
dismissedTagsLoadedRef.current = true
} else {
setDismissedTags([])
}
} catch (_) {
setDismissedTags([])
}
}, [note.id])
const autoTaggingEnabled = autoLabelingEnabled && dismissedTags.length < 3
const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
content: content,
notebookId: note.notebookId,
enabled: autoTaggingEnabled,
onQuotaExceeded: () => setQuotaExceededFeature('auto_tag')
})
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [currentReminder, setCurrentReminder] = useState<Date | null>(
note.reminder ? new Date(note.reminder as unknown as string) : null
)
const [showLinkDialog, setShowLinkDialog] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
const [isReformulating, setIsReformulating] = useState(false)
const [reformulationModal, setReformulationModal] = useState<{
originalText: string
reformulatedText: string
option: string
} | null>(null)
const [isProcessingAI, setIsProcessingAI] = useState(false)
const [aiOpen, setAiOpen] = useState(false)
const [infoOpen, setInfoOpen] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
content,
enabled: fullPage && !title && !dismissedTitleSuggestions,
})
useEffect(() => {
if (autoTitleSuggestions.length > 0) {
setTitleSuggestions(autoTitleSuggestions)
}
}, [autoTitleSuggestions])
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const existingLabelsLower = useMemo(
() => (note.labels || []).map((l) => l.toLowerCase()),
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(note.labels)]
)
const filteredSuggestions = useMemo(() => suggestions.filter(s => {
if (!s || !s.tag) return false
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
}), [suggestions, dismissedTags, existingLabelsLower])
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
const uploadImageFile = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', { method: 'POST', body: formData })
if (!response.ok) throw new Error('Upload failed')
const data = await response.json()
return data.url
}
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
for (const file of Array.from(files)) {
try {
const url = await uploadImageFile(file)
setImages(prev => prev.includes(url) ? prev : [...prev, url])
setIsDirty(true)
} catch (error) {
console.error('Upload error:', error)
toast.error(t('notes.uploadFailed', { filename: file.name }))
}
}
}
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (!isMarkdown && (e.target as HTMLElement)?.closest('.notion-editor')) return;
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (!file) continue
try {
const url = await uploadImageFile(file)
setImages(prev => prev.includes(url) ? prev : [...prev, url])
setIsDirty(true)
} catch {
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
}
}
}
}
document.addEventListener('paste', handlePaste, { capture: true })
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
}, [t, isMarkdown])
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
}, [content])
useEffect(() => {
if (showMarkdownPreview) return
const raf = requestAnimationFrame(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
el.focus()
})
return () => cancelAnimationFrame(raf)
}, [showMarkdownPreview])
const handleRemoveImage = (index: number) => {
const removedUrl = images[index]
setImages(images.filter((_, i) => i !== index))
if (removedUrl) {
setRemovedImageUrls(prev => [...prev, removedUrl])
}
setIsDirty(true)
}
const handleAddLink = async () => {
if (!linkUrl) return
setShowLinkDialog(false)
try {
const metadata = await fetchLinkMetadata(linkUrl)
if (metadata) {
setLinks(prev => [...prev, metadata])
toast.success(t('notes.linkAdded'))
} else {
toast.warning(t('notes.linkMetadataFailed'))
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
}
} catch (error) {
console.error('Failed to add link:', error)
toast.error(t('notes.linkAddFailed'))
} finally {
setLinkUrl('')
}
}
const handleRemoveLink = (index: number) => {
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(() => {
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) {
const editor = richTextEditorRef.current?.getEditor()
if (editor) return editor.getHTML()
}
return contentRef.current
}, [isMarkdown])
/**
* 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 () => {
// Utiliser le contenu live de l'éditeur (TipTap) plutôt que le state debounced
const liveContent = resolveContentForSave()
const fullContentForAI = [
liveContent,
...links.map(l => `${l.title || ''} ${l.description || ''}`)
]
.join(' ')
.trim()
const wordCount = fullContentForAI.split(/\s+/).filter(word => word.length > 0).length
if (wordCount < 10) {
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsGeneratingTitles(true)
try {
const response = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: fullContentForAI.substring(0, 8000) }),
})
if (!response.ok) {
if (response.status === 402) {
setQuotaExceededFeature('auto_title')
return
}
const errorData = await response.json()
throw new Error(errorData.error || t('ai.titleGenerationError'))
}
const data = await response.json()
setTitleSuggestions(data.suggestions || [])
if (!fullPage && data.suggestions?.[0]?.title) {
setTitle(data.suggestions[0].title)
setDismissedTitleSuggestions(true)
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
} else if (data.suggestions?.length) {
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
}
} catch (error: any) {
console.error('Error generating titles:', error)
toast.error(error.message || t('ai.titleGenerationFailed'))
} finally {
setIsGeneratingTitles(false)
}
}
const handleSelectTitle = (title: string) => {
setTitle(title)
setTitleSuggestions([])
toast.success(t('ai.titleApplied'))
}
const handleReformulate = async (option: 'clarify' | 'shorten' | 'improve') => {
const selectedText = window.getSelection()?.toString()
if (!selectedText && (!content || content.trim().length === 0)) {
toast.error(t('ai.reformulationNoText'))
return
}
let textToReformulate: string
if (selectedText && selectedText.trim().split(/\s+/).filter(word => word.length > 0).length >= 10) {
textToReformulate = selectedText
} else {
textToReformulate = content
if (selectedText) {
toast.info(t('ai.reformulationSelectionTooShort'))
}
}
const wordCount = textToReformulate.trim().split(/\s+/).filter(word => word.length > 0).length
if (wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
if (wordCount > 500) {
toast.error(t('ai.reformulationMaxWords'))
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsReformulating(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: textToReformulate,
option: option
}),
})
if (!response.ok) {
if (response.status === 402) {
setQuotaExceededFeature('reformulate')
return
}
const errorData = await response.json()
throw new Error(errorData.error || t('ai.reformulationError'))
}
const data = await response.json()
setReformulationModal({
originalText: data.originalText,
reformulatedText: data.reformulatedText,
option: data.option
})
} catch (error: any) {
console.error('Error reformulating:', error)
toast.error(error.message || t('ai.reformulationFailed'))
} finally {
setIsReformulating(false)
}
}
const handleClarifyDirect = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'clarify' })
})
if (response.status === 402) {
setQuotaExceededFeature('reformulate')
return
}
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Clarify error:', error)
toast.error(t('notes.clarifyFailed'))
} finally {
setIsProcessingAI(false)
}
}
const handleShortenDirect = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'shorten' })
})
if (response.status === 402) {
setQuotaExceededFeature('reformulate')
return
}
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Shorten error:', error)
toast.error(t('notes.shortenFailed'))
} finally {
setIsProcessingAI(false)
}
}
const handleImproveDirect = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'improve' })
})
if (response.status === 402) {
setQuotaExceededFeature('reformulate')
return
}
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Improve error:', error)
toast.error(t('notes.improveFailed'))
} finally {
setIsProcessingAI(false)
}
}
const handleTransformMarkdown = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
if (wordCount > 500) {
toast.error(t('ai.reformulationMaxWords'))
return
}
const consented = await requestAiConsent()
if (!consented) return
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/transform-markdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
setContentImmediate(data.transformedText)
setIsMarkdown(true)
setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess'))
} catch (error) {
console.error('Transform to markdown error:', error)
toast.error(t('ai.transformError'))
} finally {
setIsProcessingAI(false)
}
}
const handleApplyRefactor = () => {
if (!reformulationModal) return
setContentImmediate(reformulationModal.reformulatedText)
setReformulationModal(null)
toast.success(t('ai.reformulationApplied'))
}
const handleReminderSave = async (date: Date) => {
if (date < new Date()) {
toast.error(t('notes.reminderPastError'))
return
}
setCurrentReminder(date)
try {
await updateNote(note.id, { reminder: date }, { skipRevalidation: true })
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
} catch {
toast.error(t('notebook.savingReminder'))
}
}
const handleRemoveReminder = async () => {
setCurrentReminder(null)
try {
await updateNote(note.id, { reminder: null }, { skipRevalidation: true })
toast.success(t('notes.reminderRemoved'))
} catch {
toast.error(t('notebook.removingReminder'))
}
}
// Fonction de sauvegarde unifiée — évite la duplication handleSave/handleSaveInPlace
const performSave = useCallback(async ({ silent = false, inPlace = false }: { silent?: boolean; inPlace?: boolean } = {}) => {
setIsSaving(true)
try {
const contentToSave = resolveContentForSave()
const imagesToSave = resolveImagesForSave(contentToSave)
const result = await updateNote(note.id, {
title: title.trim() || null,
content: contentToSave,
checkItems: null,
labels,
images: imagesToSave,
links,
color,
reminder: currentReminder,
isMarkdown,
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
size,
}, { skipRevalidation: true })
if (contentToSave !== content) setContentImmediate(contentToSave)
if (imagesToSave.length !== images.length || imagesToSave.some((u, i) => u !== images[i])) setImages(imagesToSave)
prevNoteRef.current = { ...prevNoteRef.current, ...result }
const deletedImages = Array.from(new Set([
...removedImageUrls,
...images.filter(url => !imagesToSave.includes(url))
]))
if (deletedImages.length > 0) {
cleanupOrphanedImages(deletedImages, note.id).catch(() => {})
setRemovedImageUrls([])
}
await refreshLabels()
onNoteSaved?.(result)
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
emitNoteChange({ type: 'updated', note: result })
setIsDirty(false)
setLastSavedAt(new Date())
if (!silent) toast.success(t('notes.saved') || 'Note sauvegardée !')
} catch (error) {
console.error('[SAVE] updateNote failed:', error)
toast.error(t('notes.saveFailed') || 'Erreur lors de la sauvegarde.')
} finally {
setIsSaving(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id, note.notebookId, title, labels, images, links, color, currentReminder, isMarkdown, size, removedImageUrls, content, t])
// Aliases stables pour la compatibilité
const handleSave = useCallback((opts?: { silent?: boolean }) => performSave({ ...opts, inPlace: false }), [performSave])
const handleSaveInPlace = useCallback((opts?: { silent?: boolean }) => performSave({ ...opts, inPlace: true }), [performSave])
const handleCheckItem = (id: string) => {
setCheckItems(items =>
items.map(item =>
item.id === id ? { ...item, checked: !item.checked } : item
)
)
}
const handleUpdateCheckItem = (id: string, text: string) => {
setCheckItems(items =>
items.map(item => (item.id === id ? { ...item, text } : item))
)
}
const handleAddCheckItem = () => {
setCheckItems([
...checkItems,
{ id: Date.now().toString(), text: '', checked: false },
])
}
const handleRemoveCheckItem = (id: string) => {
setCheckItems(items => items.filter(item => item.id !== id))
}
const handleSelectGhostTag = async (tag: string) => {
const tagExists = labels.some(l => l.toLowerCase() === tag.toLowerCase())
if (!tagExists) {
setLabels(prev => [...prev, tag])
setIsDirty(true)
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) {
try {
await addLabel(tag)
} catch (err) {
console.error('Error creating auto-label:', err)
}
}
toast.success(t('ai.tagAdded', { tag }))
}
}
const handleDismissGhostTag = (tag: string) => {
setDismissedTags(prev => {
const next = [...prev, tag]
try { localStorage.setItem(`dismissed-tags-${note.id}`, JSON.stringify(next)) } catch (_) {}
return next
})
}
const handleRemoveLabel = (label: string) => {
setLabels(labels.filter(l => l !== label))
setIsDirty(true)
}
const handleMakeCopy = async () => {
try {
const newNote = await createNote({
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
content: content,
color: color,
checkItems: checkItems,
labels: labels,
images: images,
links: links,
isMarkdown,
type: isMarkdown ? 'markdown' : 'richtext',
size: size,
})
toast.success(t('notes.copySuccess'))
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
emitNoteChange({ type: 'created', note: newNote })
} catch (error) {
console.error('Failed to copy note:', error)
toast.error(t('notes.copyFailed'))
}
}
// handleSaveInPlace était un alias de handleSave — supprimé, maintenant unifié dans performSave
const handleSaveInPlaceRef = useRef(handleSaveInPlace)
handleSaveInPlaceRef.current = handleSaveInPlace
const handleSaveRef = useRef(handleSave)
handleSaveRef.current = handleSave
const toggleAutoSave = useCallback(async () => {
const nextVal = !autoSaveEnabled
setAutoSaveEnabled(nextVal)
if (session?.user?.id) {
try {
await updateAISettings({ autoSave: nextVal })
toast.success(
nextVal
? t('settings.autoSaveEnabled') || 'Auto-enregistrement activé !'
: t('settings.autoSaveDisabled') || 'Auto-enregistrement désactivé'
)
} catch (err) {
console.error("Failed to update autoSave setting:", err)
toast.error(t('general.error'))
}
}
}, [autoSaveEnabled, session?.user?.id, t])
// Auto-save : 8s après le dernier changement si isDirty et autoSave est activé (silencieux — pas de toast)
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!autoSaveEnabled || !isDirty || isSaving || readOnly) return
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
autoSaveTimerRef.current = setTimeout(() => {
if (fullPage) {
void handleSaveInPlaceRef.current({ silent: true })
} else {
void handleSaveRef.current({ silent: true })
}
}, 8000)
return () => {
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
}
}, [isDirty, isSaving, readOnly, fullPage, autoSaveEnabled])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
if (fullPage) {
void handleSaveInPlaceRef.current()
} else {
void handleSaveRef.current()
}
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [fullPage])
useEffect(() => {
const onRequestSave = (event: Event) => {
const detail = (event as CustomEvent<{ noteId?: string }>).detail
if (detail?.noteId !== note.id || readOnly) return
if (fullPage) {
void handleSaveInPlaceRef.current()
} else {
void handleSaveRef.current()
}
}
window.addEventListener(NOTE_REQUEST_SAVE_EVENT, onRequestSave)
return () => window.removeEventListener(NOTE_REQUEST_SAVE_EVENT, onRequestSave)
}, [note.id, fullPage, readOnly])
const state: NoteEditorState = useMemo(() => ({
title,
content,
checkItems,
labels,
images,
links,
newLabel,
color: color as NoteColor,
size,
showMarkdownPreview,
removedImageUrls,
isSaving,
isDirty,
lastSavedAt,
isProcessingAI,
aiOpen,
infoOpen,
isGeneratingTitles,
titleSuggestions,
dismissedTitleSuggestions,
isReformulating,
reformulationModal,
previousContentForCopilot,
showReminderDialog,
currentReminder,
showLinkDialog,
linkUrl,
comparisonNotes,
fusionNotes,
dismissedTags,
filteredSuggestions,
isAnalyzingSuggestions,
isMarkdown,
allImages,
contentImages,
coverImage,
colorClasses,
quotaExceededFeature,
autoSaveEnabled,
}), [
title, content, checkItems, labels, images, links, newLabel, color, size,
showMarkdownPreview, removedImageUrls, isSaving, isDirty, lastSavedAt, isProcessingAI, aiOpen, infoOpen,
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
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,
handleAddCheckItem,
handleRemoveCheckItem,
setLabels: (l) => { setLabels(l); setIsDirty(true) },
handleSelectGhostTag,
handleDismissGhostTag,
handleRemoveLabel,
setImages,
handleImageUpload,
handleRemoveImage,
setCoverImage,
clearCoverImage,
uploadImageFile,
setLinks,
handleAddLink,
handleRemoveLink,
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
setIsMarkdown: (m) => { setIsMarkdown(m); setIsDirty(true) },
convertToRichText: (html) => {
setContentImmediate(html)
setIsMarkdown(false)
setShowMarkdownPreview(false)
setIsDirty(true)
},
setColor: (c) => { setColor(c); setIsDirty(true) },
setSize: (s) => { setSize(s); setIsDirty(true) },
setShowReminderDialog,
setCurrentReminder,
handleReminderSave,
handleRemoveReminder,
setShowLinkDialog,
setLinkUrl,
handleGenerateTitles,
handleSelectTitle,
handleReformulate,
handleApplyRefactor,
handleClarifyDirect,
handleShortenDirect,
handleImproveDirect,
handleTransformMarkdown,
handleSave,
handleSaveInPlace,
handleMakeCopy,
setComparisonNotes,
setFusionNotes,
setReformulationModal,
setIsDirty,
setAiOpen,
setInfoOpen,
setIsProcessingAI,
setIsGeneratingTitles,
setIsAnalyzingSuggestions: (_a) => { /* handled by useAutoTagging */ },
setPreviousContentForCopilot,
setQuotaExceededFeature,
toggleAutoSave,
}), [
setContent, setContentImmediate, setCoverImage, clearCoverImage,
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,
handleRemoveReminder, handleGenerateTitles, handleSelectTitle, handleReformulate,
handleApplyRefactor, handleClarifyDirect, handleShortenDirect, handleImproveDirect,
handleTransformMarkdown, handleSave, handleSaveInPlace, handleMakeCopy, setQuotaExceededFeature, toggleAutoSave
])
// Notebooks mappés en dehors du useMemo pour éviter de recréer le tableau à chaque render du contexte
const mappedNotebooks = useMemo(
() => notebooks.map(nb => ({ id: nb.id, name: nb.name, parentId: nb.parentId, trashedAt: nb.trashedAt })),
[notebooks]
)
const value: NoteEditorContextValue = useMemo(() => ({
note,
readOnly,
fullPage,
state,
actions,
notebooks: mappedNotebooks,
globalLabels,
fileInputRef,
textareaRef,
richTextEditorRef,
}), [note, readOnly, fullPage, state, actions, mappedNotebooks, globalLabels])
return (
<NoteEditorContext.Provider value={value}>
{children}
</NoteEditorContext.Provider>
)
}
export function useNoteEditorContext() {
const context = useContext(NoteEditorContext)
if (context === undefined) {
throw new Error('useNoteEditorContext must be used within a NoteEditorProvider')
}
return context
}