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