Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("<p>Day 1</p>").build().isNewEmptyPost)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 7 additions & 7 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("<p>Day 1</p>").build().isNewEmptyPost)
}

// MARK: - siteId Derivation Tests
@Test("siteId extracts host from siteURL")
func siteIdExtractsHost() {
Expand Down
Loading