'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(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(null) const [title, setTitle] = useState(note.title || '') const contentRef = useRef(note.content) const [content, setContentState] = useState(note.content) const debounceTimeoutRef = useRef | 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(note.checkItems || []) const [labels, setLabels] = useState(note.labels || []) const [images, setImages] = useState(note.images || []) const [links, setLinks] = useState(note.links || []) const [newLabel, setNewLabel] = useState('') const [color, setColor] = useState(note.color) const [size, setSize] = useState(note.size || 'small') const [isSaving, setIsSaving] = useState(false) const [lastSavedAt, setLastSavedAt] = useState(null) const [removedImageUrls, setRemovedImageUrls] = useState([]) const [isMarkdown, setIsMarkdown] = useState(note.type === 'markdown') const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown') const fileInputRef = useRef(null) const textareaRef = useRef(null) const richTextEditorRef = useRef(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([]) 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( note.reminder ? new Date(note.reminder as unknown as string) : null ) const [showLinkDialog, setShowLinkDialog] = useState(false) const [linkUrl, setLinkUrl] = useState('') const [titleSuggestions, setTitleSuggestions] = useState([]) 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(null) const [comparisonNotes, setComparisonNotes] = useState>>([]) const [fusionNotes, setFusionNotes] = useState>>([]) 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) => { 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)) } const allImages = useMemo(() => { const extracted = !isMarkdown ? extractImagesFromHTML(content) : [] return Array.from(new Set([...images, ...extracted])) }, [images, content, isMarkdown]) const resolveContentForSave = useCallback((): string => { if (!isMarkdown) { const editor = richTextEditorRef.current?.getEditor() if (editor) return editor.getHTML() } 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() 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]) 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 | 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, 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, colorClasses, quotaExceededFeature, autoSaveEnabled ]) const actions: NoteEditorActions = useMemo(() => ({ setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) }, setDismissedTitleSuggestions, setContent: (c) => { setContent(c); setIsDirty(true) }, setCheckItems, handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem, setLabels: (l) => { setLabels(l); setIsDirty(true) }, handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, setImages, handleImageUpload, handleRemoveImage, 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, }), [ 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 ( {children} ) } export function useNoteEditorContext() { const context = useContext(NoteEditorContext) if (context === undefined) { throw new Error('useNoteEditorContext must be used within a NoteEditorProvider') } return context }