- 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>
1014 lines
34 KiB
TypeScript
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
|
|
}
|