From 4e34ef22bf6ce89ce76f71623bc96ada71f07462 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 13 Jan 2026 21:19:31 +0800 Subject: [PATCH] fix: improve editor auto-scroll and Safari IME handling (#5469) - Use `textarea-caret` for precise cursor position calculation instead of line approximation - Update `scrollToCursor` to scroll to the actual cursor position - Fix Safari double-enter issue with IME in list completion --- .../components/MemoEditor/Editor/index.tsx | 23 +++++++++++++++--- .../MemoEditor/Editor/useListCompletion.ts | 24 ++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 84ad32c2f..1b66b9bb6 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -1,4 +1,5 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"; +import getCaretCoordinates from "textarea-caret"; import { cn } from "@/lib/utils"; import { EDITOR_HEIGHT } from "../constants"; import type { EditorProps } from "../types"; @@ -74,9 +75,12 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward getEditor: () => editorRef.current, focus: () => editorRef.current?.focus(), scrollToCursor: () => { - if (editorRef.current) { - editorRef.current.scrollTop = editorRef.current.scrollHeight; - } + const editor = editorRef.current; + if (!editor) return; + + const caret = getCaretCoordinates(editor, editor.selectionEnd); + // Scroll to center cursor vertically + editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2); }, insertText: (content = "", prefix = "", suffix = "") => { const editor = editorRef.current; @@ -148,6 +152,19 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward if (editorRef.current) { handleContentChangeCallback(editorRef.current.value); updateEditorHeight(); + + // Auto-scroll to keep cursor visible when typing + // See: https://github.com/usememos/memos/issues/5469 + const editor = editorRef.current; + const caret = getCaretCoordinates(editor, editor.selectionEnd); + const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 24; + + // Scroll if cursor is near or beyond bottom edge (within 2 lines) + const viewportBottom = editor.scrollTop + editor.clientHeight; + if (caret.top + lineHeight * 2 > viewportBottom) { + // Scroll to center cursor vertically + editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2); + } } }, [handleContentChangeCallback, updateEditorHeight]); diff --git a/web/src/components/MemoEditor/Editor/useListCompletion.ts b/web/src/components/MemoEditor/Editor/useListCompletion.ts index 69c2d329e..d25258321 100644 --- a/web/src/components/MemoEditor/Editor/useListCompletion.ts +++ b/web/src/components/MemoEditor/Editor/useListCompletion.ts @@ -24,15 +24,30 @@ export function useListCompletion({ editorRef, editorActions, isInIME }: UseList const editorActionsRef = useRef(editorActions); editorActionsRef.current = editorActions; + // Track when composition ends to handle Safari race condition + // Safari fires keydown(Enter) immediately after compositionend, while Chrome doesn't + // See: https://github.com/usememos/memos/issues/5469 + const lastCompositionEndRef = useRef(0); + useEffect(() => { const editor = editorRef.current; if (!editor) return; + const handleCompositionEnd = () => { + lastCompositionEndRef.current = Date.now(); + }; + const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Enter" || isInIMERef.current || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) { return; } + // Safari fix: Ignore Enter key within 100ms of composition end + // This prevents double-enter behavior when confirming IME input in lists + if (Date.now() - lastCompositionEndRef.current < 100) { + return; + } + const actions = editorActionsRef.current; const cursorPosition = actions.getCursorPosition(); const contentBeforeCursor = actions.getContent().substring(0, cursorPosition); @@ -51,10 +66,17 @@ export function useListCompletion({ editorRef, editorActions, isInIME }: UseList } else { const continuation = generateListContinuation(listInfo); actions.insertText("\n" + continuation); + + // Auto-scroll to keep cursor visible after inserting list item + setTimeout(() => actions.scrollToCursor(), 0); } }; + editor.addEventListener("compositionend", handleCompositionEnd); editor.addEventListener("keydown", handleKeyDown); - return () => editor.removeEventListener("keydown", handleKeyDown); + return () => { + editor.removeEventListener("compositionend", handleCompositionEnd); + editor.removeEventListener("keydown", handleKeyDown); + }; }, []); }