From 6d68b9da11bce2c3a7bca01833ddcaece645178e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Oct 2026 15:15:58 -0400 Subject: [PATCH 1/4] fix: autofocus only new, empty posts Autofocus was meant for new posts, as in WP Admin, but checked only for empty content, so it also raised the keyboard on existing posts with empty content and on local drafts with only a title. It now matches the web editor's rule for new posts: no server ID and an empty title and content. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- .../org/wordpress/gutenberg/GutenbergView.kt | 4 ++-- .../gutenberg/model/EditorConfiguration.kt | 9 +++++++++ .../model/EditorConfigurationTest.kt | 19 +++++++++++++++++++ .../Sources/EditorViewController.swift | 14 +++++++------- .../Sources/Model/EditorConfiguration.swift | 9 +++++++++ .../Model/EditorConfigurationTests.swift | 19 +++++++++++++++++++ 6 files changed, 65 insertions(+), 9 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 37dbfa0e8..7d717fadb 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -104,7 +104,7 @@ class GutenbergView : FrameLayout { * Whether opening the editor has already placed the caret in its content. * * Unlike [didFireEditorLoaded], this survives a reload. Autofocus decides - * from the content the editor was opened with, which a reload can replace + * from the post the editor was opened with, which a reload can replace * with newer content from the host, so repeating it would pop the keyboard * over a restored post. */ @@ -1071,7 +1071,7 @@ class GutenbergView : FrameLayout { this.didFireEditorLoaded = true showReadyPhase() - if (!hasAutofocused && configuration.content.isEmpty()) { + if (!hasAutofocused && configuration.isNewEmptyPost) { hasAutofocused = true // Focus the editor content diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index d4f949912..c3f75635f 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -59,6 +59,15 @@ data class EditorConfiguration( UUID.randomUUID().toString() } } + + /** + * Whether the editor opens a new post with no title or content, the post + * WP Admin and the web editor focus on load. A missing or zero ID means the + * server doesn't have the post yet. + */ + internal val isNewEmptyPost: Boolean + get() = (postId ?: 0u) == 0u && title.isBlank() && content.isBlank() + companion object { @JvmStatic fun builder(siteURL: String, siteApiRoot: String, postType: PostTypeDetails = PostTypeDetails.post): Builder = Builder(siteURL, siteApiRoot, postType = postType) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index 960d918c4..34a07f65c 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -1007,4 +1007,23 @@ class EditorConfigurationTest { assertTrue(config.enableNativeBlockInserter) assertTrue(config.enableInserterMediaStrip) } + + // MARK: - isNewEmptyPost Tests + + @Test + fun `isNewEmptyPost is true for a post without an ID, title, or content`() { + assertTrue(builder().build().isNewEmptyPost) + assertTrue(builder().setTitle(" ").setContent("\n").build().isNewEmptyPost) + } + + @Test + fun `isNewEmptyPost is false for a post the server has`() { + assertFalse(builder().setPostId(123u).build().isNewEmptyPost) + } + + @Test + fun `isNewEmptyPost is false for a local draft with a title or content`() { + assertFalse(builder().setTitle("Trip notes").build().isNewEmptyPost) + assertFalse(builder().setContent("

Day 1

").build().isNewEmptyPost) + } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 1df5c5ba4..64f7efbb2 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -103,7 +103,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Whether opening the editor has already placed the caret in its content. /// /// Autofocus belongs to opening the editor, not to the reload after a crash: - /// ``focus(force:)`` decides from the content the editor was opened with, + /// ``focus(force:)`` decides from the post the editor was opened with, /// which a reload can replace with newer content from the host, so repeating /// it would raise the keyboard over a restored post. private var hasAutofocused = false @@ -786,17 +786,17 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro evaluate("editor.appendTextAtCursor(decodeURIComponent('\(escapedText)'));") } - /// Sets focus to the editor if the content is empty. + /// Sets focus to the editor if it opened a new, empty post. /// /// This method programmatically focuses the editor, placing the cursor in the content area - /// so the user can begin typing immediately. Focus is only applied when the editor is displaying - /// empty content to avoid disrupting existing content editing. + /// so the user can begin typing immediately. Like WP Admin, focus is only applied to a new post + /// with no title or content, to avoid disrupting existing posts and local drafts. /// - /// Use the `force` parameter to apply focus even when there is content in the editor. + /// Use the `force` parameter to apply focus to any post. /// - /// - Parameter force: When true, applies focus even when there is content in the editor. + /// - Parameter force: When true, applies focus even when the post isn't new and empty. public func focus(force: Bool = false) { - if force || configuration.content.isEmpty { + if force || configuration.isNewEmptyPost { // iOS 27 no longer makes the web view first responder for script // focus, leaving the page without input focus and the keyboard hidden. webView.becomeFirstResponder() diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index 69661facd..654848f7b 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -155,6 +155,15 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { var escapedContent: String { content.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! } + + /// Whether the editor opens a new post with no title or content, the post + /// WP Admin and the web editor focus on load. A missing or non-positive ID + /// means the server doesn't have the post yet. + var isNewEmptyPost: Bool { + (postID ?? 0) <= 0 + && title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } } /// A builder for constructing `EditorConfiguration` instances. diff --git a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift index a497a0dda..5e27132fb 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift @@ -375,6 +375,25 @@ struct EditorConfigurationTests: MakesTestFixtures { static let testSiteURL = URL(string: "https://example.com")! static let testApiRoot = URL(string: "https://example.com/wp-json")! + // MARK: - isNewEmptyPost Tests + @Test("isNewEmptyPost is true for a post without an ID, title, or content") + func isNewEmptyPostForNewPost() { + #expect(makeConfigurationBuilder().build().isNewEmptyPost) + #expect(makeConfigurationBuilder().setPostID(-1).build().isNewEmptyPost) + #expect(makeConfigurationBuilder().setTitle(" ").setContent("\n").build().isNewEmptyPost) + } + + @Test("isNewEmptyPost is false for a post the server has") + func isNewEmptyPostForServerPost() { + #expect(!makeConfigurationBuilder().setPostID(123).build().isNewEmptyPost) + } + + @Test("isNewEmptyPost is false for a local draft with a title or content") + func isNewEmptyPostForLocalDraft() { + #expect(!makeConfigurationBuilder().setTitle("Trip notes").build().isNewEmptyPost) + #expect(!makeConfigurationBuilder().setContent("

Day 1

").build().isNewEmptyPost) + } + // MARK: - siteId Derivation Tests @Test("siteId extracts host from siteURL") func siteIdExtractsHost() { From c2057bffe827b5a2125bd9670a4af7f18afe8790 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Oct 2026 15:32:52 -0400 Subject: [PATCH 2/4] fix(android): read the post ID for autofocus as the web editor does GBKitGlobal sends the web editor postId.toInt(), so a negative ID a host converted with toUInt() reaches it as a new post. Autofocus read the same value as a server ID, so the two disagreed about whether the post was new. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNDBX7RNXn3NsfDuV1PkNV --- .../org/wordpress/gutenberg/model/EditorConfiguration.kt | 7 ++++--- .../wordpress/gutenberg/model/EditorConfigurationTest.kt | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index c3f75635f..a6cc2c25c 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -62,11 +62,12 @@ data class EditorConfiguration( /** * Whether the editor opens a new post with no title or content, the post - * WP Admin and the web editor focus on load. A missing or zero ID means the - * server doesn't have the post yet. + * WP Admin and the web editor focus on load. A missing or non-positive ID, + * read as [GBKitGlobal] sends it to the web editor, means the server + * doesn't have the post yet. */ internal val isNewEmptyPost: Boolean - get() = (postId ?: 0u) == 0u && title.isBlank() && content.isBlank() + get() = (postId?.toInt() ?: 0) <= 0 && title.isBlank() && content.isBlank() companion object { @JvmStatic diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index 34a07f65c..42b598b40 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -1016,6 +1016,12 @@ class EditorConfigurationTest { assertTrue(builder().setTitle(" ").setContent("\n").build().isNewEmptyPost) } + @Test + fun `isNewEmptyPost is true for a negative ID converted to UInt`() { + // GBKitGlobal sends the web editor `postId.toInt()`, which reads this as -1. + assertTrue(builder().setPostId((-1L).toUInt()).build().isNewEmptyPost) + } + @Test fun `isNewEmptyPost is false for a post the server has`() { assertFalse(builder().setPostId(123u).build().isNewEmptyPost) From 660e5c0bef20cee4cd62824308383ff16fba0680 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 23 Oct 2026 10:48:47 -0400 Subject: [PATCH 3/4] refactor(android): share the server post ID between autofocus and the web editor Autofocus and GBKitGlobal each converted postId on their own, so they could disagree about whether a post is new. One property now decides, with null for a new post as the Post.id TODO intends; only the wire encoding still uses -1. Co-Authored-By: Claude Opus 5.5 --- .../gutenberg/model/EditorConfiguration.kt | 14 ++++++++++---- .../org/wordpress/gutenberg/model/GBKitGlobal.kt | 4 +--- .../gutenberg/model/EditorConfigurationTest.kt | 2 +- .../wordpress/gutenberg/model/GBKitGlobalTest.kt | 7 +++++++ 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index a6cc2c25c..674dbd4a4 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -60,14 +60,20 @@ data class EditorConfiguration( } } + /** + * The post's server ID as the web editor reads it, or null for a new post + * the server doesn't have yet. A negative ID a host converted with + * `toUInt()` reads as new. + */ + internal val serverPostId: Int? + get() = postId?.toInt()?.takeIf { it > 0 } + /** * Whether the editor opens a new post with no title or content, the post - * WP Admin and the web editor focus on load. A missing or non-positive ID, - * read as [GBKitGlobal] sends it to the web editor, means the server - * doesn't have the post yet. + * WP Admin and the web editor focus on load. */ internal val isNewEmptyPost: Boolean - get() = (postId?.toInt() ?: 0) <= 0 && title.isBlank() && content.isBlank() + get() = serverPostId == null && title.isBlank() && content.isBlank() companion object { @JvmStatic diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index 76c051dbc..e0eff8474 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -107,8 +107,6 @@ data class GBKitGlobal( nativeUploadPort: Int? = null, nativeUploadToken: String? = null ): GBKitGlobal { - val postId = (configuration.postId?.toInt() ?: -1).takeIf({ it != 0 }) - return GBKitGlobal( siteURL = configuration.siteURL.ifEmpty { null }, siteApiRoot = configuration.siteApiRoot.ifEmpty { null }, @@ -121,7 +119,7 @@ data class GBKitGlobal( hideTitle = configuration.hideTitle, locale = configuration.locale ?: "en", post = Post( - id = postId ?: -1, + id = configuration.serverPostId ?: -1, type = configuration.postType.postType, restBase = configuration.postType.restBase, restNamespace = configuration.postType.restNamespace, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index 42b598b40..e05a04a7a 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -1018,7 +1018,7 @@ class EditorConfigurationTest { @Test fun `isNewEmptyPost is true for a negative ID converted to UInt`() { - // GBKitGlobal sends the web editor `postId.toInt()`, which reads this as -1. + // `toInt()` reads this as -1, which the web editor treats as a new post. assertTrue(builder().setPostId((-1L).toUInt()).build().isNewEmptyPost) } diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt index c1f4b50e2..2957b207b 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt @@ -243,6 +243,13 @@ class GBKitGlobalTest { assertEquals(-1, global.post.id) } + @Test + fun `maps negative postID converted to UInt to negative one`() { + val configuration = makeConfiguration(postId = (-5L).toUInt()) + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals(-1, global.post.id) + } + @Test fun `maps title with percent encoding`() { val configuration = makeConfiguration(title = "Hello World") From ac09603c1a319d367a30d91ab44c34a34b7c1053 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 23 Oct 2026 10:49:15 -0400 Subject: [PATCH 4/4] docs: describe the autofocus rule without comparing it to WP Admin WP Admin focuses a clean auto-draft, not a post with no ID and a blank title and content, so the comparison misled readers about which posts autofocus targets. Co-Authored-By: Claude Opus 5.5 --- .../org/wordpress/gutenberg/model/EditorConfiguration.kt | 4 ++-- ios/Sources/GutenbergKit/Sources/EditorViewController.swift | 4 ++-- .../GutenbergKit/Sources/Model/EditorConfiguration.swift | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 674dbd4a4..1ac8fe5ab 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -69,8 +69,8 @@ data class EditorConfiguration( get() = postId?.toInt()?.takeIf { it > 0 } /** - * Whether the editor opens a new post with no title or content, the post - * WP Admin and the web editor focus on load. + * Whether the editor opens a new post with a blank title and content, the + * only post autofocus targets. */ internal val isNewEmptyPost: Boolean get() = serverPostId == null && title.isBlank() && content.isBlank() diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 64f7efbb2..dd663ad08 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -789,8 +789,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Sets focus to the editor if it opened a new, empty post. /// /// This method programmatically focuses the editor, placing the cursor in the content area - /// so the user can begin typing immediately. Like WP Admin, focus is only applied to a new post - /// with no title or content, to avoid disrupting existing posts and local drafts. + /// so the user can begin typing immediately. Focus is only applied to a new post with a blank + /// title and content, to avoid disrupting existing posts and local drafts. /// /// Use the `force` parameter to apply focus to any post. /// diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index 654848f7b..2efec16fb 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -156,9 +156,9 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { content.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! } - /// Whether the editor opens a new post with no title or content, the post - /// WP Admin and the web editor focus on load. A missing or non-positive ID - /// means the server doesn't have the post yet. + /// Whether the editor opens a new post with a blank title and content, the + /// only post autofocus targets. A missing or non-positive ID means the server + /// doesn't have the post yet. var isNewEmptyPost: Bool { (postID ?? 0) <= 0 && title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty