perf: memo GridCard, fuse save fns, fix slash tab active color
This commit is contained in:
@@ -13,7 +13,7 @@ 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 { 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'
|
||||
@@ -40,12 +40,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
|
||||
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])
|
||||
@@ -64,6 +66,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
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) {
|
||||
@@ -71,7 +76,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
setContentState(newVal)
|
||||
}, 1000)
|
||||
}, 800)
|
||||
}, [])
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||
const [labels, setLabels] = useState<string[]>(note.labels || [])
|
||||
@@ -364,8 +369,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
|
||||
|
||||
const handleGenerateTitles = async () => {
|
||||
// Utiliser le contenu live de l'éditeur (TipTap) plutôt que le state debounced
|
||||
const liveContent = resolveContentForSave()
|
||||
const fullContentForAI = [
|
||||
content,
|
||||
liveContent,
|
||||
...links.map(l => `${l.title || ''} ${l.description || ''}`)
|
||||
]
|
||||
.join(' ')
|
||||
@@ -386,7 +393,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
const response = await fetch('/api/ai/title-suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: fullContentForAI }),
|
||||
body: JSON.stringify({ content: fullContentForAI.substring(0, 8000) }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -658,7 +665,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Fonction de sauvegarde unifiée — évite la duplication handleSave/handleSaveInPlace
|
||||
const performSave = useCallback(async ({ silent = false, inPlace = false }: { silent?: boolean; inPlace?: boolean } = {}) => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const contentToSave = resolveContentForSave()
|
||||
@@ -677,7 +685,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
size,
|
||||
}, { skipRevalidation: true })
|
||||
if (contentToSave !== content) setContentImmediate(contentToSave)
|
||||
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
|
||||
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,
|
||||
@@ -694,14 +702,19 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
emitNoteChange({ type: 'updated', note: result })
|
||||
setIsDirty(false)
|
||||
setLastSavedAt(new Date())
|
||||
toast.success(t('notes.saved') || 'Note sauvegardée !')
|
||||
if (!silent) toast.success(t('notes.saved') || 'Note sauvegardée !')
|
||||
} catch (error) {
|
||||
console.error('[SAVE] updateNote failed:', error)
|
||||
toast.error(t('notes.saveFailed') || 'Erreur lors de la sauvegarde.')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [note.id, note.notebookId, title, labels, images, links, color, currentReminder, isMarkdown, size, removedImageUrls, content, t])
|
||||
|
||||
// Aliases stables pour la compatibilité
|
||||
const handleSave = useCallback((opts?: { silent?: boolean }) => performSave({ ...opts, inPlace: false }), [performSave])
|
||||
const handleSaveInPlace = useCallback((opts?: { silent?: boolean }) => performSave({ ...opts, inPlace: true }), [performSave])
|
||||
|
||||
const handleCheckItem = (id: string) => {
|
||||
setCheckItems(items =>
|
||||
@@ -783,73 +796,47 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
||||
// Auto-save : 2s après le dernier changement si isDirty
|
||||
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 (!isDirty || isSaving || readOnly) return
|
||||
if (!autoSaveEnabled || !isDirty || isSaving || readOnly) return
|
||||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
if (fullPage) {
|
||||
void handleSaveInPlaceRef.current()
|
||||
void handleSaveInPlaceRef.current({ silent: true })
|
||||
} else {
|
||||
void handleSaveRef.current()
|
||||
void handleSaveRef.current({ silent: true })
|
||||
}
|
||||
}, 2000)
|
||||
}, 8000)
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
|
||||
}
|
||||
}, [isDirty, isSaving, readOnly, fullPage])
|
||||
}, [isDirty, isSaving, readOnly, fullPage, autoSaveEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullPage) return
|
||||
@@ -914,13 +901,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
allImages,
|
||||
colorClasses,
|
||||
quotaExceededFeature,
|
||||
autoSaveEnabled,
|
||||
}), [
|
||||
title, content, checkItems, labels, images, links, newLabel, color, size,
|
||||
showMarkdownPreview, removedImageUrls, isSaving, isDirty, lastSavedAt, isProcessingAI, aiOpen, infoOpen,
|
||||
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
|
||||
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
|
||||
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
|
||||
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature
|
||||
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature, autoSaveEnabled
|
||||
])
|
||||
|
||||
const actions: NoteEditorActions = useMemo(() => ({
|
||||
@@ -975,27 +963,34 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setIsAnalyzingSuggestions: (_a) => { /* handled by useAutoTagging */ },
|
||||
setPreviousContentForCopilot,
|
||||
setQuotaExceededFeature,
|
||||
toggleAutoSave,
|
||||
}), [
|
||||
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
|
||||
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
|
||||
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,
|
||||
handleRemoveReminder, handleGenerateTitles, handleSelectTitle, handleReformulate,
|
||||
handleApplyRefactor, handleClarifyDirect, handleShortenDirect, handleImproveDirect,
|
||||
handleTransformMarkdown, handleSave, handleSaveInPlace, handleMakeCopy, setQuotaExceededFeature
|
||||
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: notebooks.map(nb => ({ id: nb.id, name: nb.name, parentId: nb.parentId, trashedAt: nb.trashedAt })),
|
||||
notebooks: mappedNotebooks,
|
||||
globalLabels,
|
||||
fileInputRef,
|
||||
textareaRef,
|
||||
richTextEditorRef,
|
||||
}), [note, readOnly, fullPage, state, actions, notebooks, globalLabels])
|
||||
}), [note, readOnly, fullPage, state, actions, mappedNotebooks, globalLabels])
|
||||
|
||||
return (
|
||||
<NoteEditorContext.Provider value={value}>
|
||||
|
||||
Reference in New Issue
Block a user