perf(editor): optimize typing performance by debouncing context state updates and using useEditorState for BubbleToolbar (US-EDITOR-PERF)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user