perf(editor): optimize typing performance by debouncing context state updates and using useEditorState for BubbleToolbar (US-EDITOR-PERF)

This commit is contained in:
Antigravity
2026-05-27 21:41:19 +00:00
parent 07ace46dd3
commit e3cb1307d3
4 changed files with 74 additions and 38 deletions

View File

@@ -59,6 +59,6 @@ development_status:
4-6-sso-saml-audit-logging: backlog
epic-4-retrospective: optional
epic-5: in-progress
5-1-nextgen-editor: ready-for-dev
5-1-nextgen-editor: done

View File

@@ -20,8 +20,8 @@
| **US-TEMPORAL** | Prédictions d'accès temporelles | ⏸️ **REPORTÉ** | Remplacé par rappels + révision SM-2 + Memory Echo ; heuristique faible, migration NoteAccessLog non prioritaire |
| **US-FLASHCARDS** | Révision IA — Répétition espacée SM-2 | ✅ **LIVRÉ** | `/revision`, `/api/flashcards/*`, SM-2, génération IA depuis l'éditeur |
| **US-STRUCTURED-VIEWS** | Vues Structurées (Tableau/Kanban/Galerie) | ✅ **LIVRÉ** | `/api/notebooks/[id]/schema`, `/api/notes/[id]/properties`, vues structurées + panneau propriétés éditeur |
| **US-NEXTGEN-EDITOR** | Éditeur Next-Gen : Drag Handle + Menu Bloc + **Vue Structurée Inline** (redesign US-4) + Smart Paste | 🚧 **PLANIFIÉ** | Voir `docs/story-nextgen-editor.md` + `docs/story-nextgen-editor-us4-redesign.md` |
| **US-EDITOR-PERF** | Performance de frappe TipTap (quick wins) | 🚧 **PLANIFIÉ** | |
| **US-NEXTGEN-EDITOR** | Éditeur Next-Gen : Drag Handle + Menu Bloc + **Vue Structurée Inline** (redesign US-4) + Smart Paste | **LIVRÉ** | Voir `docs/story-nextgen-editor.md` + `docs/story-nextgen-editor-us4-redesign.md` |
| **US-EDITOR-PERF** | Performance de frappe TipTap (quick wins) | **LIVRÉ** | `rich-text-editor.tsx` (useEditorState), `note-editor-context.tsx` (debounced setContent) |
| **US-EDITOR-UX** | Micro-interactions saisie (slash menu, sélection multi-blocs, paste étendu, placeholders) | ⏳ **À FAIRE** | — |
| **US-EDITOR-MOBILE** | Expérience tactile & toolbar mobile adaptée | ⏳ **À FAIRE** | — |
| **US-EDITOR-MARKDOWN** | Rendu WYSIWYG Markdown fidèle (round-trip byte-for-byte) | ⏳ **À FAIRE** | — |

View File

@@ -51,7 +51,27 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}, [session?.user?.id])
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content)
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 || [])
@@ -74,7 +94,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
if (note.id !== prev.id) {
setTitle(note.title || '')
setContent(note.content)
setContentImmediate(note.content)
setCheckItems(note.checkItems || [])
setLabels(note.labels || [])
setImages(note.images || [])
@@ -87,7 +107,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
} 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) setContent(note.content)
if (note.content !== prev.content) setContentImmediate(note.content)
if (JSON.stringify(note.checkItems || []) !== JSON.stringify(prev.checkItems || [])) {
setCheckItems(note.checkItems || [])
}
@@ -309,8 +329,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const editor = richTextEditorRef.current?.getEditor()
if (editor) return editor.getHTML()
}
return content
}, [content, isMarkdown])
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)
@@ -472,7 +492,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
setContent(data.reformulatedText || data.text)
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Clarify error:', error)
@@ -501,7 +521,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
setContent(data.reformulatedText || data.text)
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Shorten error:', error)
@@ -530,7 +550,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
setContent(data.reformulatedText || data.text)
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Improve error:', error)
@@ -565,7 +585,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
setContent(data.transformedText)
setContentImmediate(data.transformedText)
setIsMarkdown(true)
setShowMarkdownPreview(false)
@@ -581,7 +601,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const handleApplyRefactor = () => {
if (!reformulationModal) return
setContent(reformulationModal.reformulatedText)
setContentImmediate(reformulationModal.reformulatedText)
setReformulationModal(null)
toast.success(t('ai.reformulationApplied'))
}
@@ -628,7 +648,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
size,
}, { skipRevalidation: true })
if (contentToSave !== content) setContent(contentToSave)
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([
@@ -752,7 +772,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
size,
}
const result = await updateNote(note.id, updatePayload, { skipRevalidation: true })
if (contentToSave !== content) setContent(contentToSave)
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([
@@ -853,7 +873,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
])
const actions: NoteEditorActions = {
const actions: NoteEditorActions = useMemo(() => ({
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
setDismissedTitleSuggestions,
setContent: (c) => { setContent(c); setIsDirty(true) },
@@ -904,7 +924,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setIsGeneratingTitles,
setIsAnalyzingSuggestions: (_a) => { /* handled by useAutoTagging */ },
setPreviousContentForCopilot,
}
}), [
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,
handleRemoveReminder, handleGenerateTitles, handleSelectTitle, handleReformulate,
handleApplyRefactor, handleClarifyDirect, handleShortenDirect, handleImproveDirect,
handleTransformMarkdown, handleSave, handleSaveInPlace, handleMakeCopy
])
const value: NoteEditorContextValue = useMemo(() => ({
note,

View File

@@ -3,7 +3,7 @@
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import { createPortal } from 'react-dom'
import { useLanguage } from '@/lib/i18n'
import { useEditor, EditorContent } from '@tiptap/react'
import { useEditor, EditorContent, useEditorState } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
@@ -1061,7 +1061,6 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
toast.error(msg && msg !== AI_REFORMULATE_FALLBACK ? msg : t('richTextEditor.aiReformulateFailed'))
}
const [, setTick] = useState(0)
const [aiOpen, setAiOpen] = useState(false)
const [aiLoading, setAiLoading] = useState(false)
const [linkOpen, setLinkOpen] = useState(false)
@@ -1071,29 +1070,39 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
const [customLang, setCustomLang] = useState('')
const [aiModal, setAiModal] = useState<{ type: 'explain' | 'preview'; origText: string; html: string; from: number; to: number } | null>(null)
useEffect(() => {
if (!editor) return
const h = () => setTick(t => t + 1)
editor.on('transaction', h)
editor.on('selectionUpdate', h)
return () => { editor.off('transaction', h); editor.off('selectionUpdate', h) }
}, [editor])
const editorState = useEditorState({
editor,
selector: (ctx) => ({
isBold: ctx.editor?.isActive('bold') ?? false,
isItalic: ctx.editor?.isActive('italic') ?? false,
isUnderline: ctx.editor?.isActive('underline') ?? false,
isStrike: ctx.editor?.isActive('strike') ?? false,
isCode: ctx.editor?.isActive('code') ?? false,
isHighlight: ctx.editor?.isActive('highlight') ?? false,
isSuperscript: ctx.editor?.isActive('superscript') ?? false,
isSubscript: ctx.editor?.isActive('subscript') ?? false,
isLink: ctx.editor?.isActive('link') ?? false,
isImage: ctx.editor?.isActive('image') ?? false,
selectionFrom: ctx.editor?.state?.selection?.from ?? 0,
selectionTo: ctx.editor?.state?.selection?.to ?? 0,
})
})
useEffect(() => {
if (linkOpen && linkInputRef.current) linkInputRef.current.focus()
}, [linkOpen])
if (!editor) return null
if (!editor || !editorState) return null
const marks = [
{ icon: Bold, active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run(), title: t('richTextEditor.bold') },
{ icon: Italic, active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run(), title: t('richTextEditor.italic') },
{ icon: UnderlineIcon, active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run(), title: t('richTextEditor.underline') },
{ icon: Strikethrough, active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run(), title: t('richTextEditor.strike') },
{ icon: Code, active: editor.isActive('code'), action: () => editor.chain().focus().toggleCode().run(), title: t('richTextEditor.code') },
{ icon: Highlighter, active: editor.isActive('highlight'), action: () => editor.chain().focus().toggleHighlight().run(), title: t('richTextEditor.highlight') },
{ icon: SuperscriptIcon, active: editor.isActive('superscript'), action: () => editor.chain().focus().toggleSuperscript().run(), title: t('richTextEditor.superscript') },
{ icon: SubscriptIcon, active: editor.isActive('subscript'), action: () => editor.chain().focus().toggleSubscript().run(), title: t('richTextEditor.subscript') },
{ icon: Bold, active: editorState.isBold, action: () => editor.chain().focus().toggleBold().run(), title: t('richTextEditor.bold') },
{ icon: Italic, active: editorState.isItalic, action: () => editor.chain().focus().toggleItalic().run(), title: t('richTextEditor.italic') },
{ icon: UnderlineIcon, active: editorState.isUnderline, action: () => editor.chain().focus().toggleUnderline().run(), title: t('richTextEditor.underline') },
{ icon: Strikethrough, active: editorState.isStrike, action: () => editor.chain().focus().toggleStrike().run(), title: t('richTextEditor.strike') },
{ icon: Code, active: editorState.isCode, action: () => editor.chain().focus().toggleCode().run(), title: t('richTextEditor.code') },
{ icon: Highlighter, active: editorState.isHighlight, action: () => editor.chain().focus().toggleHighlight().run(), title: t('richTextEditor.highlight') },
{ icon: SuperscriptIcon, active: editorState.isSuperscript, action: () => editor.chain().focus().toggleSuperscript().run(), title: t('richTextEditor.superscript') },
{ icon: SubscriptIcon, active: editorState.isSubscript, action: () => editor.chain().focus().toggleSubscript().run(), title: t('richTextEditor.subscript') },
]
const handleAI = async (option: 'clarify' | 'shorten' | 'improve' | 'fix_grammar' | 'translate' | 'explain', targetLang?: string) => {
@@ -1159,7 +1168,7 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
className="notion-inline-input"
/>
<button onClick={applyLink} className="notion-bubble-btn notion-bubble-btn-active"><Check className="w-3.5 h-3.5" /></button>
{editor.isActive('link') && (
{editorState.isLink && (
<button onClick={() => { editor.chain().focus().extendMarkRange('link').unsetLink().run(); setLinkOpen(false) }} className="notion-bubble-btn">
<ExternalLink className="w-3.5 h-3.5" />
</button>
@@ -1177,9 +1186,9 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
</button>
))}
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
<button onClick={openLinkEditor} className={cn('notion-bubble-btn rounded-md', editor.isActive('link') && 'notion-bubble-btn-active')}><LinkIcon className="w-3.5 h-3.5" /></button>
<button onClick={openLinkEditor} className={cn('notion-bubble-btn rounded-md', editorState.isLink && 'notion-bubble-btn-active')}><LinkIcon className="w-3.5 h-3.5" /></button>
<button onClick={() => setAiOpen(!aiOpen)} className={cn('notion-bubble-btn rounded-md', aiLoading && 'animate-pulse')}><Sparkles className="w-3.5 h-3.5" /></button>
{editor.isActive('image') && (
{editorState.isImage && (
<>
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">25%</button>