Files
Momento/memento-note/components/note-editor/note-editor-context.tsx
Antigravity 1b56af9743
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m23s
CI / Deploy production (on server) (push) Has been cancelled
feat: auto-save 2s + indicateur save + reminders inline actions (compléter/snooze)
- Auto-save debounce 2s dans note-editor-context
- lastSavedAt state + setIsDirty(false) dans handleSave
- Indicateur toolbar: ✓ sauvegardé / ● non enregistré avec timer relatif
- Reminders sidebar: bouton ✓ compléter + bouton +1h snooze (hover inline)
- i18n: clés reminders.markDone/snooze1h + notes.savedJustNow/unsaved (15 locales)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 18:58:19 +00:00

1014 lines
34 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 } 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)
useEffect(() => {
if (session?.user?.id) {
getAISettings(session.user.id).then(settings => {
setAiAssistantEnabled(settings.paragraphRefactor !== false)
setAutoLabelingEnabled(settings.autoLabeling !== 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)
}, [])
const setContent = useCallback((newVal: string) => {
contentRef.current = newVal
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}
debounceTimeoutRef.current = setTimeout(() => {
setContentState(newVal)
}, 1000)
}, [])
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))
}
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<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])
const handleGenerateTitles = async () => {
const fullContentForAI = [
content,
...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 }),
})
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'))
}
}
const handleSave = async () => {
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 (JSON.stringify(imagesToSave) !== JSON.stringify(images)) 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())
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)
}
}
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'))
}
}
const handleSaveInPlace = async () => {
setIsSaving(true)
try {
const contentToSave = resolveContentForSave()
const imagesToSave = resolveImagesForSave(contentToSave)
const updatePayload = {
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,
}
const result = await updateNote(note.id, updatePayload, { skipRevalidation: true })
if (contentToSave !== content) setContentImmediate(contentToSave)
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) 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())
toast.success(t('notes.saved') || 'Saved')
} catch (error) {
console.error('[SAVE] updateNote failed:', error)
toast.error(t('notes.saveFailed') || 'Save failed')
} finally {
setIsSaving(false)
}
}
const handleSaveInPlaceRef = useRef(handleSaveInPlace)
handleSaveInPlaceRef.current = handleSaveInPlace
const handleSaveRef = useRef(handleSave)
handleSaveRef.current = handleSave
// Auto-save : 2s après le dernier changement si isDirty
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!isDirty || isSaving || readOnly) return
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
autoSaveTimerRef.current = setTimeout(() => {
if (fullPage) {
void handleSaveInPlaceRef.current()
} else {
void handleSaveRef.current()
}
}, 2000)
return () => {
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
}
}, [isDirty, isSaving, readOnly, fullPage])
useEffect(() => {
if (!fullPage) return
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
void handleSaveInPlaceRef.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,
}), [
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
])
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) },
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,
}), [
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
])
const value: NoteEditorContextValue = useMemo(() => ({
note,
readOnly,
fullPage,
state,
actions,
notebooks: notebooks.map(nb => ({ id: nb.id, name: nb.name, parentId: nb.parentId, trashedAt: nb.trashedAt })),
globalLabels,
fileInputRef,
textareaRef,
richTextEditorRef,
}), [note, readOnly, fullPage, state, actions, notebooks, 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
}