From 3f1a1982ca807e46b87cb76c74c612e6312d845d Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 1 Aug 2026 13:04:24 +0000 Subject: [PATCH] fix: use uncontrolled inputs for note title and editor to prevent focus leak to search field --- apps/web/src/routes/_app/notes.tsx | 84 ++++++------------------------ fix_cleanup.py | 45 ++++++++++++++++ 2 files changed, 62 insertions(+), 67 deletions(-) create mode 100644 fix_cleanup.py diff --git a/apps/web/src/routes/_app/notes.tsx b/apps/web/src/routes/_app/notes.tsx index 0c2f7f9..6ca0239 100644 --- a/apps/web/src/routes/_app/notes.tsx +++ b/apps/web/src/routes/_app/notes.tsx @@ -17,7 +17,6 @@ import { cn } from "@/lib/utils"; import type { Note, PaginatedResponse } from "@/lib/types"; // Simple TipTap-like editor using contentEditable -// Uses a key to force remount on note change instead of setting innerHTML const NoteEditor = memo(function NoteEditor({ initialContent, onSave, placeholder = "Start writing..." }: { initialContent: string; onSave: (html: string) => void; placeholder?: string }) { const editorRef = useRef(null); const [isPlaceholder, setIsPlaceholder] = useState(!initialContent); @@ -27,7 +26,7 @@ const NoteEditor = memo(function NoteEditor({ initialContent, onSave, placeholde editorRef.current.innerHTML = initialContent || ""; } setIsPlaceholder(!initialContent); - }, []); // Only set initial content once on mount + }, []); const handleBlur = () => { const html = editorRef.current?.innerHTML || ""; @@ -50,57 +49,32 @@ const NoteEditor = memo(function NoteEditor({ initialContent, onSave, placeholde ); }); -// Isolated title input component - memo prevents re-render when parent re-renders +// Separate component for title input - completely uncontrolled to prevent focus stealing const NoteTitleInput = memo(function NoteTitleInput({ noteId, initialTitle }: { noteId: string; initialTitle: string }) { - const [title, setTitle] = useState(initialTitle); - const queryClient = useQueryClient(); - - useEffect(() => { - setTitle(initialTitle); - }, [noteId]); - - const handleBlur = () => { - if (title !== initialTitle) { - api.patch("/notes/" + noteId, { title: title }).then(() => { - queryClient.invalidateQueries({ queryKey: ["notes"] }); - }); - } - }; - - return ( - setTitle(e.target.value)} - onBlur={handleBlur} - className="flex h-9 w-full rounded-md border-0 bg-transparent px-0 py-1 text-lg font-semibold text-base shadow-none transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50" - /> - ); -}); - -// Isolated title input component - memo prevents re-render when parent re-renders -const NoteTitleInput = memo(function NoteTitleInput({ noteId, initialTitle }: { noteId: string; initialTitle: string }) { - const [title, setTitle] = useState(initialTitle); - const queryClient = useQueryClient(); const inputRef = useRef(null); + const queryClient = useQueryClient(); useEffect(() => { - setTitle(initialTitle); - inputRef.current?.focus(); + if (inputRef.current) { + inputRef.current.value = initialTitle; + } }, [noteId]); const handleBlur = () => { - if (title !== initialTitle) { - api.patch("/notes/" + noteId, { title: title }).then(() => { - queryClient.invalidateQueries({ queryKey: ["notes"] }); - }); + if (inputRef.current && noteId) { + const newTitle = inputRef.current.value; + if (newTitle !== initialTitle) { + api.patch("/notes/" + noteId, { title: newTitle }).then(() => { + queryClient.invalidateQueries({ queryKey: ["notes"] }); + }); + } } }; return ( setTitle(e.target.value)} + defaultValue={initialTitle} onBlur={handleBlur} className="flex h-9 w-full rounded-md border-0 bg-transparent px-0 py-1 text-lg font-semibold text-base shadow-none transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50" /> @@ -111,9 +85,6 @@ function NotesPage() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); const [selectedNote, setSelectedNote] = useState(null); - const [editorContent, setEditorContent] = useState(""); - const saveTimerRef = useRef | null>(null); - const selectedNoteRef = useRef(null); const [showBacklinks, setShowBacklinks] = useState(false); const [showVersions, setShowVersions] = useState(false); @@ -131,7 +102,6 @@ function NotesPage() { onSuccess: (note) => { queryClient.invalidateQueries({ queryKey: ["notes"] }); setSelectedNote(note); - setEditorContent(""); }, }); @@ -152,27 +122,13 @@ function NotesPage() { try { const detail = await api.get("/notes/" + note.id); setSelectedNote(detail); - selectedNoteRef.current = detail; - setEditorContent(detail.content || ""); } catch { setSelectedNote(note); - selectedNoteRef.current = note; - setEditorContent(note.content || ""); } setShowBacklinks(false); setShowVersions(false); }; - const handleContentChange = useCallback((html: string) => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - saveTimerRef.current = setTimeout(() => { - const note = selectedNoteRef.current; - if (note) { - updateMutation.mutate({ id: note.id, data: { content: html } }); - } - }, 500); - }, [updateMutation]); - return (
{/* Left pane - note list */} @@ -180,7 +136,7 @@ function NotesPage() {
- setSearch(e.target.value)} className="pl-8" tabIndex={-1} /> + setSearch(e.target.value)} className="pl-8" />
@@ -223,13 +179,7 @@ function NotesPage() { {selectedNote ? ( <>
- {selectedNote && ( - - )} +
- + { if (selectedNote) { updateMutation.mutate({ id: selectedNote.id, data: { content: html } }); } }} />
{/* Backlinks section */} {showBacklinks && selectedNote.backlinks && selectedNote.backlinks.length > 0 && ( diff --git a/fix_cleanup.py b/fix_cleanup.py new file mode 100644 index 0000000..64ad242 --- /dev/null +++ b/fix_cleanup.py @@ -0,0 +1,45 @@ +import sys + +with open(sys.argv[1], 'r') as f: + content = f.read() + +# Remove all setEditorContent calls +content = content.replace(' setEditorContent("");\n', '', 1) +content = content.replace(' setEditorContent(detail.content || "");\n', '', 1) +content = content.replace(' setEditorContent(note.content || "");\n', '', 1) + +# Remove editorContent state +content = content.replace(' const [editorContent, setEditorContent] = useState("");\n', '', 1) + +# Remove saveTimerRef +content = content.replace(' const saveTimerRef = useRef | null>(null);\n', '', 1) + +# Remove selectedNoteRef +content = content.replace(' const selectedNoteRef = useRef(null);\n', '', 1) + +# Remove selectedNoteRef.current assignments +content = content.replace(' selectedNoteRef.current = detail;\n', '', 1) +content = content.replace(' selectedNoteRef.current = note;\n', '', 1) +content = content.replace(' selectedNoteRef.current = note;\n', '', 1) + +# Remove handleContentChange +old = ''' const handleContentChange = useCallback((html: string) => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + const note = selectedNoteRef.current; + if (note) { + updateMutation.mutate({ id: note.id, data: { content: html } }); + } + }, 500); + }, [updateMutation]);''' + +content = content.replace(old, '', 1) + +# Update NoteEditor usage +old_editor = ' ' +new_editor = ' { if (selectedNote) { updateMutation.mutate({ id: selectedNote.id, data: { content: html } }); } }} />' +content = content.replace(old_editor, new_editor, 1) + +with open(sys.argv[1], 'w') as f: + f.write(content) +print('Done')