perf: memo GridCard, fuse save fns, fix slash tab active color
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-06-14 14:06:05 +00:00
parent a8785ed4f1
commit a623454347
120 changed files with 12301 additions and 785 deletions

View File

@@ -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}>

View File

@@ -297,7 +297,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<button
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={actions.handleSaveInPlace}
onClick={() => actions.handleSaveInPlace()}
disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
@@ -339,6 +339,21 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
{t('richTextEditor.importMarkdown')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={actions.toggleAutoSave} className="flex items-center justify-between cursor-pointer">
<span className="flex items-center">
<Save className="h-4 w-4 me-2 text-muted-foreground" />
{t('settings.autoSave') || 'Auto-enregistrement'}
</span>
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded font-bold uppercase tracking-wider',
state.autoSaveEnabled
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400'
)}>
{state.autoSaveEnabled ? (t('common.on') || 'Actif') : (t('common.off') || 'Désactivé')}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
try {
@@ -524,7 +539,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<Button variant="ghost" onClick={onClose}>
{t('general.cancel')}
</Button>
<Button onClick={actions.handleSave} disabled={state.isSaving}>
<Button onClick={() => actions.handleSave()} disabled={state.isSaving}>
{state.isSaving ? t('notes.saving') : t('general.save')}
</Button>
</>

View File

@@ -20,6 +20,7 @@ export interface NoteEditorState {
isSaving: boolean
isDirty: boolean
lastSavedAt: Date | null
autoSaveEnabled: boolean
isProcessingAI: boolean
aiOpen: boolean
@@ -105,8 +106,8 @@ export interface NoteEditorActions {
handleImproveDirect: () => Promise<void>
handleTransformMarkdown: () => Promise<void>
handleSave: () => Promise<void>
handleSaveInPlace: () => Promise<void>
handleSave: (opts?: { silent?: boolean }) => Promise<void>
handleSaveInPlace: (opts?: { silent?: boolean }) => Promise<void>
handleMakeCopy: () => Promise<void>
setComparisonNotes: (notes: Array<Partial<Note>>) => void
@@ -122,6 +123,7 @@ export interface NoteEditorActions {
setIsAnalyzingSuggestions: (analyzing: boolean) => void
setPreviousContentForCopilot: (content: string | null) => void
setQuotaExceededFeature: (feature: string | null) => void
toggleAutoSave: () => void
}
export interface NoteEditorContextValue {