Skip to content
Draft
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 @@ -90,6 +90,7 @@ const val ASSET_PATH_INDEX = "/assets/index.html"
* - If `dependencies` is provided, the editor loads immediately (fast path)
* - If `dependencies` is null, dependencies are fetched asynchronously before loading
*/
@Suppress("LargeClass")
class GutenbergView : FrameLayout {
private val webView: WebView
private var isEditorLoaded = false
Expand Down Expand Up @@ -119,7 +120,11 @@ class GutenbergView : FrameLayout {
private var autocompleterTriggeredListener: AutocompleterTriggeredListener? = null
private var modalDialogStateListener: ModalDialogStateListener? = null
private var networkRequestListener: NetworkRequestListener? = null
private var saveAvailabilityListener: SaveAvailabilityListener? = null
private var latestContentProvider: LatestContentProvider? = null
private var latestPostProvider: LatestPostProvider? = null
private var savePostListener: SavePostListener? = null
private val pendingSaveCallbacks = Collections.synchronizedMap(mutableMapOf<String, SavePostCallback>())

/**
* Stores the contextId from the most recent openMediaLibrary call
Expand Down Expand Up @@ -180,10 +185,22 @@ class GutenbergView : FrameLayout {
networkRequestListener = listener
}

fun setSaveAvailabilityListener(listener: SaveAvailabilityListener?) {
saveAvailabilityListener = listener
}

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

fun setLatestPostProvider(provider: LatestPostProvider?) {
latestPostProvider = provider
}

fun setSavePostListener(listener: SavePostListener?) {
savePostListener = listener
}

fun setOnFileChooserRequestedListener(listener: (Intent, Int) -> Unit) {
onFileChooserRequested = listener
}
Expand Down Expand Up @@ -666,6 +683,18 @@ class GutenbergView : FrameLayout {
fun onNetworkRequest(request: RecordedNetworkRequest)
}

data class SaveAvailabilityState(
val isDirty: Boolean,
val isSaveable: Boolean,
val isSavingLocked: Boolean,
val isSaving: Boolean,
val isAutosaving: Boolean
)

interface SaveAvailabilityListener {
fun onSaveAvailabilityChanged(state: SaveAvailabilityState)
}

/**
* Provides the latest persisted content for recovery after WebView refresh.
*
Expand All @@ -689,6 +718,44 @@ class GutenbergView : FrameLayout {
val content: String
)

/**
* Pre-save hook: receives the current post and returns a modified version.
*
* When the editor's `savePost()` is called, it passes the full entity record
* to this provider. The host may inspect and modify any fields — for example,
* updating categories or tags that were changed in native UI — and return the
* modified post as a JSON string.
*/
interface LatestPostProvider {
/**
* Called with the current post entity as a JSON string. Return a JSON string
* of modified fields to merge into the entity, or null for no modifications.
*
* @param postJson The current entity record as a JSON string.
*/
fun getLatestPost(postJson: String): String?
}

/**
* Callback for the `savePost()` operation.
*/
interface SavePostCallback {
fun onSuccess()
fun onFailure(error: String)
}

/**
* Listener notified when the editor finishes saving a post or encounters an error.
*
* Unlike [SavePostCallback] (which is per-call), this listener is set once and fires
* for every save operation, making it suitable for analytics, logging, or UI updates
* that should always react to save results.
*/
interface SavePostListener {
fun onPostSaved()
fun onPostSaveFailed(error: 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 @@ -719,6 +786,50 @@ class GutenbergView : FrameLayout {
}
}

/**
* Saves the post via the editor's built-in save mechanism.
*
* This triggers the full Gutenberg save lifecycle including plugin side-effects.
* The native host should show its own save feedback — the editor's built-in
* save notice is suppressed automatically.
*
* @param callback Called on the main thread when the save completes or fails.
*/
fun savePost(callback: SavePostCallback) {
if (!isEditorLoaded) {
callback.onFailure("Editor not ready")
return
}
val callbackId = java.util.UUID.randomUUID().toString()
pendingSaveCallbacks[callbackId] = callback
handler.post {
webView.evaluateJavascript("""
(async function() {
try {
await editor.savePost();
editorDelegate.onSavePostComplete('$callbackId', true, '');
} catch (e) {
editorDelegate.onSavePostComplete('$callbackId', false, e.message || 'Unknown error');
}
})();
""".trimIndent(), null)
}
}

@JavascriptInterface
fun onSavePostComplete(callbackId: String, success: Boolean, errorMessage: String) {
val callback = pendingSaveCallbacks.remove(callbackId) ?: return
handler.post {
if (success) {
callback.onSuccess()
savePostListener?.onPostSaved()
} else {
callback.onFailure(errorMessage)
savePostListener?.onPostSaveFailed(errorMessage)
}
}
}

fun undo() {
handler.post {
webView.evaluateJavascript("editor.undo();", null)
Expand Down Expand Up @@ -786,6 +897,16 @@ class GutenbergView : FrameLayout {
historyChangeListener?.onHistoryChanged(hasUndo, hasRedo)
}

@JavascriptInterface
@Suppress("MaxLineLength")
fun onSaveAvailabilityChanged(isDirty: Boolean, isSaveable: Boolean, isSavingLocked: Boolean, isSaving: Boolean, isAutosaving: Boolean) {
handler.post {
saveAvailabilityListener?.onSaveAvailabilityChanged(
SaveAvailabilityState(isDirty, isSaveable, isSavingLocked, isSaving, isAutosaving)
)
}
}

@JavascriptInterface
fun onEditorFeaturedImageChanged(mediaID: Long) {
featuredImageChangeListener?.onFeaturedImageChanged(mediaID)
Expand Down Expand Up @@ -928,6 +1049,21 @@ class GutenbergView : FrameLayout {
}
}

/**
* Pre-save hook called by JavaScript with the current entity record.
*
* The host app receives the full post via [LatestPostProvider] and may return
* a modified version. The returned JSON is merged into the entity record as
* edits before the save PUT is sent.
*
* @param postJson The current entity record as a JSON string.
* @return JSON string with modified post fields, or null if no modifications needed.
*/
@JavascriptInterface
fun hydratePost(postJson: String): String? {
return latestPostProvider?.getLatestPost(postJson)
}

fun resetFilePathCallback() {
filePathCallback = null
}
Expand Down Expand Up @@ -1025,6 +1161,12 @@ class GutenbergView : FrameLayout {
networkRequestListener = null
requestInterceptor = DefaultGutenbergRequestInterceptor()
latestContentProvider = null
latestPostProvider = null
// Drain pending save callbacks to prevent leaks
for (callback in pendingSaveCallbacks.values) {
callback.onFailure("Editor detached")
}
pendingSaveCallbacks.clear()
handler.removeCallbacksAndMessages(null)
webView.destroy()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ data class GBKitGlobal(
*/
@Serializable
data class Post(
/** The post ID, or -1 for new posts. */
val id: Int, // TODO: Instead of the `-1` trick, this should just be `null` for new posts
/** The post ID, or `-1` for new posts. */
val id: Int,
/** The post type (e.g., `post`, `page`). */
val type: String,
/** The REST API base path for this post type (e.g., `posts`, `pages`). */
Expand Down Expand Up @@ -99,7 +99,7 @@ data class GBKitGlobal(
configuration: EditorConfiguration,
dependencies: EditorDependencies?
): GBKitGlobal {
val postId = (configuration.postId?.toInt() ?: -1).takeIf({ it != 0 })
val postId = configuration.postId?.toInt()?.takeIf { it > 0 } ?: -1

return GBKitGlobal(
siteURL = configuration.siteURL.ifEmpty { null },
Expand All @@ -112,7 +112,7 @@ data class GBKitGlobal(
hideTitle = configuration.hideTitle,
locale = configuration.locale ?: "en",
post = Post(
id = postId ?: -1,
id = postId,
type = configuration.postType.postType,
restBase = configuration.postType.restBase,
restNamespace = configuration.postType.restNamespace,
Expand Down
Loading
Loading