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..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 @@ -59,6 +59,22 @@ data class EditorConfiguration( UUID.randomUUID().toString() } } + + /** + * 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 a blank title and content, the + * only post autofocus targets. + */ + internal val isNewEmptyPost: Boolean + get() = serverPostId == null && 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/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 960d918c4..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 @@ -1007,4 +1007,29 @@ 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 true for a negative ID converted to UInt`() { + // `toInt()` reads this as -1, which the web editor treats as a new post. + assertTrue(builder().setPostId((-1L).toUInt()).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/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") diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 1df5c5ba4..dd663ad08 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. 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 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..2efec16fb 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 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 + && 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() {