input: fix cursor jumping to position 0 after typing first character
When composing a new note, the cursor would jump in front of the first letter after typing it. This occurred because multiple SwiftUI view updates (text change, placeholder removal, height change) could cause the cursor position to be incorrectly restored. The fix explicitly tracks the cursor position after each text change by calling updateCursorPosition, ensuring the correct position is always used regardless of view update timing. Refactored textViewDidChange to use early return pattern for clarity. Added UI test to guard against cursor position regressions in the post composer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Changelog-Fixed: Fixed cursor jumping behind first letter when typing a new note Closes: https://github.com/damus-io/damus/pull/3473 Closes: https://github.com/damus-io/damus/issues/3461 Co-Authored-By: Claude Opus 4.5 Tested-by: William Casarin <jb55@jb55.com> Signed-off-by: alltheseas Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
committed by
Daniel D’Aquino
parent
f506f9cfe8
commit
4941b502d5
@@ -60,9 +60,19 @@ enum AppAccessibilityIdentifiers: String {
|
||||
|
||||
// MARK: Post composer
|
||||
// Prefix: `post_composer`
|
||||
|
||||
|
||||
/// The cancel post button
|
||||
case post_composer_cancel_button
|
||||
|
||||
/// The text view where the user types their note
|
||||
case post_composer_text_view
|
||||
|
||||
|
||||
// MARK: Post button (FAB)
|
||||
// Prefix: `post_button`
|
||||
|
||||
/// The floating action button to create a new post
|
||||
case post_button
|
||||
|
||||
// MARK: Main interface layout
|
||||
// Prefix: `main`
|
||||
|
||||
@@ -276,7 +276,7 @@ struct PostView: View {
|
||||
getFocusWordForMention: { word, range in
|
||||
focusWordAttributes = (word, range)
|
||||
self.newCursorIndex = nil
|
||||
},
|
||||
},
|
||||
updateCursorPosition: { newCursorIndex in
|
||||
self.newCursorIndex = newCursorIndex
|
||||
}
|
||||
@@ -284,6 +284,7 @@ struct PostView: View {
|
||||
.environmentObject(tagModel)
|
||||
.focused($focus)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.post_composer_text_view.rawValue)
|
||||
.onChange(of: post) { p in
|
||||
post_changed(post: p, media: uploadedMedias)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ func PostButton(action: @escaping () -> ()) -> some View {
|
||||
.foregroundColor(Color.white)
|
||||
}
|
||||
})
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.post_button.rawValue)
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ struct TextViewWrapper: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
// Handle one-time initial text suffix insertion (e.g., for replies)
|
||||
if let initialTextSuffix, !self.initialTextSuffixWasAdded {
|
||||
self.initialTextSuffixWasAdded = true
|
||||
var mutable = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
@@ -129,11 +130,19 @@ struct TextViewWrapper: UIViewRepresentable {
|
||||
DispatchQueue.main.async {
|
||||
self.updateCursorPosition(originalRange.location)
|
||||
}
|
||||
processFocusedWordForMention(textView: textView)
|
||||
return
|
||||
}
|
||||
else {
|
||||
attributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
}
|
||||
|
||||
attributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
processFocusedWordForMention(textView: textView)
|
||||
|
||||
// Fix for cursor jumping to position 0 after typing first character.
|
||||
// When text changes, multiple SwiftUI view updates can occur (text change,
|
||||
// placeholder removal, height change). The getFocusWordForMention callback
|
||||
// sets newCursorIndex to nil, forcing reliance on savedRange which may be
|
||||
// stale. Explicitly tracking cursor position here ensures correct restoration.
|
||||
updateCursorPosition(textView.selectedRange.location)
|
||||
}
|
||||
|
||||
private func processFocusedWordForMention(textView: UITextView) {
|
||||
|
||||
@@ -188,6 +188,43 @@ class damusUITests: XCTestCase {
|
||||
guard app.buttons[AID.sign_in_confirm_button.rawValue].tapIfExists(timeout: 5) else { throw DamusUITestError.timeout_waiting_for_element }
|
||||
}
|
||||
|
||||
/// Tests that typing in the post composer works correctly, specifically that
|
||||
/// the cursor position is maintained after typing each character.
|
||||
/// This guards against regressions like https://github.com/damus-io/damus/issues/3461
|
||||
/// where the cursor would jump to position 0 after typing the first character.
|
||||
func testPostComposerCursorPosition() throws {
|
||||
try self.loginIfNotAlready()
|
||||
|
||||
// Wait for main interface to load, then tap the post button (FAB)
|
||||
guard app.buttons[AID.post_button.rawValue].waitForExistence(timeout: 10) else {
|
||||
throw DamusUITestError.timeout_waiting_for_element
|
||||
}
|
||||
app.buttons[AID.post_button.rawValue].tap()
|
||||
|
||||
// Wait for the post composer text view to appear
|
||||
guard app.textViews[AID.post_composer_text_view.rawValue].waitForExistence(timeout: 5) else {
|
||||
throw DamusUITestError.timeout_waiting_for_element
|
||||
}
|
||||
|
||||
let textView = app.textViews[AID.post_composer_text_view.rawValue]
|
||||
textView.tap()
|
||||
|
||||
// Type a test string character by character
|
||||
// If the cursor jumps to position 0 after the first character,
|
||||
// the resulting text would be scrambled (e.g., "olleH" instead of "Hello")
|
||||
let testString = "Hello"
|
||||
textView.typeText(testString)
|
||||
|
||||
// Verify the text was typed correctly (not scrambled)
|
||||
let actualText = textView.value as? String ?? ""
|
||||
XCTAssertEqual(actualText, testString,
|
||||
"Text should be '\(testString)' but was '\(actualText)'. " +
|
||||
"This may indicate a cursor position bug.")
|
||||
|
||||
// Cancel the post to clean up
|
||||
app.buttons[AID.post_composer_cancel_button.rawValue].tap()
|
||||
}
|
||||
|
||||
enum DamusUITestError: Error {
|
||||
case timeout_waiting_for_element
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user