Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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 @@ -108,6 +108,7 @@ class GutenbergView : WebView {
private var modalDialogStateListener: ModalDialogStateListener? = null
private var networkRequestListener: NetworkRequestListener? = null
private var loadingListener: EditorLoadingListener? = null
private var latestContentProvider: LatestContentProvider? = null

/**
* Stores the contextId from the most recent openMediaLibrary call
Expand Down Expand Up @@ -158,6 +159,10 @@ class GutenbergView : WebView {
networkRequestListener = listener
}

fun setLatestContentProvider(provider: LatestContentProvider?) {
latestContentProvider = provider
}

fun setOnFileChooserRequestedListener(listener: (Intent, Int) -> Unit) {
onFileChooserRequested = listener
}
Expand Down Expand Up @@ -511,6 +516,29 @@ class GutenbergView : WebView {
fun onNetworkRequest(request: RecordedNetworkRequest)
}

/**
* Provides the latest persisted content for recovery after WebView refresh.
*
* When the WebView reinitializes (e.g., due to OS memory pressure or page refresh),
* the editor requests the latest content from this provider. The host app should
* return the most recently persisted title and content from autosave.
*/
interface LatestContentProvider {
/**
* Returns the most recently persisted title and content from autosave.
* @return LatestContent if available, null if no persisted content exists.
*/
fun getLatestContent(): LatestContent?
}

/**
* Represents persisted editor content for recovery.
*/
data class LatestContent(
val title: String,
val content: String
)

fun getTitleAndContent(originalContent: CharSequence, callback: TitleAndContentCallback, completeComposition: Boolean = false) {
if (!isEditorLoaded) {
Log.e("GutenbergView", "You can't change the editor content until it has loaded")
Expand Down Expand Up @@ -731,6 +759,28 @@ class GutenbergView : WebView {
}
}

/**
* Called by JavaScript to request the latest persisted content.
*
* This method is invoked during editor initialization to recover content
* after WebView refresh. The host app provides content via [LatestContentProvider].
*
* @return JSON string with title and content fields, or null if unavailable.
*/
@JavascriptInterface
fun requestLatestContent(): String? {
val content = latestContentProvider?.getLatestContent() ?: return null
return try {
JSONObject().apply {
put("title", content.title)
put("content", content.content)
}.toString()
} catch (e: JSONException) {
Log.e("GutenbergView", "Failed to serialize latest content", e)
null
}
}

fun resetFilePathCallback() {
filePathCallback = null
}
Expand Down Expand Up @@ -814,6 +864,7 @@ class GutenbergView : WebView {
modalDialogStateListener = null
networkRequestListener = null
requestInterceptor = DefaultGutenbergRequestInterceptor()
latestContentProvider = null
handler.removeCallbacksAndMessages(null)
this.destroy()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ data class EditorConfiguration(
val content: String,
val postId: Int?,
val postType: String,
val postStatus: String,
val themeStyles: Boolean,
val plugins: Boolean,
val hideTitle: Boolean,
Expand Down Expand Up @@ -57,6 +58,7 @@ data class EditorConfiguration(
private var title: String = ""
private var content: String = ""
private var postId: Int? = null
private var postStatus: String = "draft"
private var themeStyles: Boolean = false
private var plugins: Boolean = false
private var hideTitle: Boolean = false
Expand All @@ -76,6 +78,7 @@ data class EditorConfiguration(
fun setContent(content: String) = apply { this.content = content }
fun setPostId(postId: Int?) = apply { this.postId = postId }
fun setPostType(postType: String) = apply { this.postType = postType }
fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus }
fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles }
fun setPlugins(plugins: Boolean) = apply { this.plugins = plugins }
fun setHideTitle(hideTitle: Boolean) = apply { this.hideTitle = hideTitle }
Expand All @@ -98,6 +101,7 @@ data class EditorConfiguration(
content = content,
postId = postId,
postType = postType,
postStatus = postStatus,
themeStyles = themeStyles,
plugins = plugins,
hideTitle = hideTitle,
Expand Down Expand Up @@ -126,6 +130,7 @@ data class EditorConfiguration(
.setTitle(title)
.setContent(content)
.setPostId(postId)
.setPostStatus(postStatus)
.setThemeStyles(themeStyles)
.setPlugins(plugins)
.setHideTitle(hideTitle)
Expand All @@ -151,6 +156,7 @@ data class EditorConfiguration(
if (content != other.content) return false
if (postId != other.postId) return false
if (postType != other.postType) return false
if (postStatus != other.postStatus) return false
if (themeStyles != other.themeStyles) return false
if (plugins != other.plugins) return false
if (hideTitle != other.hideTitle) return false
Expand All @@ -177,6 +183,7 @@ data class EditorConfiguration(
result = 31 * result + content.hashCode()
result = 31 * result + (postId ?: 0)
result = 31 * result + postType.hashCode()
result = 31 * result + postStatus.hashCode()
result = 31 * result + themeStyles.hashCode()
result = 31 * result + plugins.hashCode()
result = 31 * result + hideTitle.hashCode()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ data class GBKitGlobal(
data class Post(
/** The post ID, or -1 for new posts. */
val id: Int,
/** The post type (e.g., `post`, `page`). */
val type: String,
/** The post status (e.g., `draft`, `publish`, `pending`). */
val status: String,
/** The post title (URL-encoded). */
val title: String,
/** The post content (URL-encoded Gutenberg block markup). */
Expand Down Expand Up @@ -100,6 +104,8 @@ data class GBKitGlobal(
locale = configuration.locale ?: "en",
post = Post(
id = configuration.postId ?: -1,
type = configuration.postType,
status = configuration.postStatus ?: "draft",
title = configuration.title.encodeForEditor(),
content = configuration.content.encodeForEditor()
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class EditorConfigurationBuilderTest {
assertEquals("", config.content)
assertNull(config.postId)
assertEquals(TEST_POST_TYPE, config.postType)
assertEquals("draft", config.postStatus)
assertFalse(config.themeStyles)
assertFalse(config.plugins)
assertFalse(config.hideTitle)
Expand Down Expand Up @@ -96,6 +97,15 @@ class EditorConfigurationBuilderTest {
assertEquals("page", config.postType)
}

@Test
fun `setPostStatus updates postStatus`() {
val config = builder()
.setPostStatus("publish")
.build()

assertEquals("publish", config.postStatus)
}

@Test
fun `setThemeStyles updates themeStyles`() {
val config = builder()
Expand Down Expand Up @@ -292,6 +302,7 @@ class EditorConfigurationBuilderTest {
.setContent("<p>Round trip content</p>")
.setPostId(999)
.setPostType("page")
.setPostStatus("draft")
.setThemeStyles(true)
.setPlugins(true)
.setHideTitle(true)
Expand Down Expand Up @@ -368,6 +379,7 @@ class EditorConfigurationBuilderTest {
val original = builder()
.setPostId(123)
.setPostType("post")
.setPostStatus("publish")
.setEditorSettings("""{"test":true}""")
.setEditorAssetsEndpoint("https://example.com/assets")
.build()
Expand All @@ -376,6 +388,7 @@ class EditorConfigurationBuilderTest {

assertEquals(123, rebuilt.postId)
assertEquals("post", rebuilt.postType)
assertEquals("publish", rebuilt.postStatus)
assertEquals("""{"test":true}""", rebuilt.editorSettings)
assertEquals("https://example.com/assets", rebuilt.editorAssetsEndpoint)
}
Expand Down Expand Up @@ -540,6 +553,19 @@ class EditorConfigurationTest {
assertNotEquals(config1, config2)
}

@Test
fun `Configurations with different postStatus are not equal`() {
val config1 = builder()
.setPostStatus("draft")
.build()

val config2 = builder()
.setPostStatus("publish")
.build()

assertNotEquals(config1, config2)
}

@Test
fun `Configurations with different themeStyles are not equal`() {
val config1 = builder()
Expand Down Expand Up @@ -820,6 +846,7 @@ class EditorConfigurationTest {
.setContent("Test Content")
.setPostId(123)
.setPostType("post")
.setPostStatus("publish")
.setThemeStyles(true)
.setPlugins(true)
.setHideTitle(false)
Expand All @@ -842,6 +869,7 @@ class EditorConfigurationTest {
assertEquals("Test Content", config.content)
assertEquals(123, config.postId)
assertEquals("post", config.postType)
assertEquals("publish", config.postStatus)
assertTrue(config.themeStyles)
assertTrue(config.plugins)
assertFalse(config.hideTitle)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,13 @@ fun EditorScreen(
loadingError = error.message ?: "Unknown error"
}
})
// Demo app has no persistence layer, so return null.
// In a real app, return the persisted title and content from autosave.
setLatestContentProvider(object : GutenbergView.LatestContentProvider {
override fun getLatestContent(): GutenbergView.LatestContent? {
return null
}
})
onGutenbergViewCreated(this)
}
},
Expand Down
6 changes: 6 additions & 0 deletions ios/Demo-iOS/Sources/Views/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ private struct _EditorView: UIViewControllerRepresentable {
print(" Response Body: \(responseBody.prefix(200))...")
}
}

func editorDidRequestLatestContent(_ controller: EditorViewController) -> (title: String, content: String)? {
// Demo app has no persistence layer, so return nil.
// In a real app, return the persisted title and content from autosave.
return nil
}
}
}

Expand Down
44 changes: 43 additions & 1 deletion ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
// Set-up communications with the editor.
config.userContentController.add(controller, name: "editorDelegate")

// Register async message handler for content recovery requests.
// This allows JavaScript to request the latest persisted content from the native host.
config.userContentController.addScriptMessageHandler(controller, contentWorld: .page, name: "requestLatestContent")

// This is important so they user can't select anything but text across blocks.
config.selectionGranularity = .character

Expand Down Expand Up @@ -659,6 +663,14 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
}
}

fileprivate func controllerDidRequestLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)? {
return delegate?.editorDidRequestLatestContent(self)
}

fileprivate func controllerWebContentProcessDidTerminate(_ controller: GutenbergEditorController) {
webView.reload()
}

// MARK: - Loading Complete: Editor Ready

/// Called when the editor JavaScript emits the `onEditorLoaded` message.
Expand Down Expand Up @@ -714,10 +726,12 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
@MainActor
private protocol GutenbergEditorControllerDelegate: AnyObject {
func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage)
func controllerDidRequestLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)?
func controllerWebContentProcessDidTerminate(_ controller: GutenbergEditorController)
}

/// Hiding the conformances, and breaking retain cycles.
private final class GutenbergEditorController: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
private final class GutenbergEditorController: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKScriptMessageHandlerWithReply {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL WKScriptMessageHandlerWithReply – its' really nice.

weak var delegate: GutenbergEditorControllerDelegate?
let configuration: EditorConfiguration
private let editorURL: URL?
Expand All @@ -728,6 +742,27 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W
super.init()
}

// MARK: - WKScriptMessageHandlerWithReply

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) async -> (Any?, String?) {
guard message.name == "requestLatestContent" else {
return (nil, "Unknown message handler: \(message.name)")
}

let content = await MainActor.run {
delegate?.controllerDidRequestLatestContent(self)
}

guard let content else {
return (nil, nil) // No content available - not an error
}

return ([
"title": content.title,
"content": content.content
] as [String: String], nil)
}

// MARK: - WKNavigationDelegate

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
Expand Down Expand Up @@ -756,6 +791,13 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W
return .allow
}

func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
NSLog("webViewWebContentProcessDidTerminate: reloading editor")
MainActor.assumeIsolated {
delegate?.controllerWebContentProcessDidTerminate(self)
}
}

// MARK: - WKScriptMessageHandler

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ public protocol EditorViewControllerDelegate: AnyObject {
///
/// - parameter request: The network request details including URL, headers, body, response, and timing.
func editor(_ viewController: EditorViewController, didLogNetworkRequest request: RecordedNetworkRequest)

/// Provides the latest persisted content for recovery after WebView refresh.
///
/// Called when the WebView requests content during initialization. The host app should return
/// the most recently persisted title and content from autosave. This allows content recovery
/// when the WebView is re-initialized (e.g., due to OS memory pressure or page refresh).
///
/// Note: The values in `EditorConfiguration.title` and `EditorConfiguration.content` are "initial values"
/// injected at WebView load time. After a WebView refresh, these may be stale. This delegate method
/// allows the host app to provide fresher content from its autosave mechanism.
///
/// - Returns: A tuple of (title, content), or nil if no persisted content is available.
func editorDidRequestLatestContent(_ controller: EditorViewController) -> (title: String, content: String)?
}

#endif
Expand Down
Loading
Loading