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

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