From e3cb1307d345274c1ffbd2c3ec0948f7d135dcc7 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Wed, 27 May 2026 21:41:19 +0000 Subject: [PATCH] perf(editor): optimize typing performance by debouncing context state updates and using useEditorState for BubbleToolbar (US-EDITOR-PERF) --- docs/sprint-status.yaml | 2 +- docs/user-stories.md | 4 +- .../note-editor/note-editor-context.tsx | 55 ++++++++++++++----- memento-note/components/rich-text-editor.tsx | 51 ++++++++++------- 4 files changed, 74 insertions(+), 38 deletions(-) diff --git a/docs/sprint-status.yaml b/docs/sprint-status.yaml index a5bd9ff..1e03995 100644 --- a/docs/sprint-status.yaml +++ b/docs/sprint-status.yaml @@ -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 diff --git a/docs/user-stories.md b/docs/user-stories.md index da13a07..41a5438 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -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** | — | diff --git a/memento-note/components/note-editor/note-editor-context.tsx b/memento-note/components/note-editor/note-editor-context.tsx index 10a20ba..e44eb6b 100644 --- a/memento-note/components/note-editor/note-editor-context.tsx +++ b/memento-note/components/note-editor/note-editor-context.tsx @@ -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 | 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(note.checkItems || []) const [labels, setLabels] = useState(note.labels || []) const [images, setImages] = useState(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, diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index 30fcac3..d795e9c 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -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" /> - {editor.isActive('link') && ( + {editorState.isLink && ( @@ -1177,9 +1186,9 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS ))}
- + - {editor.isActive('image') && ( + {editorState.isImage && ( <>