From 7d8d37c5bf1d5739b6f0bae4f1c21faf4ae1da3b Mon Sep 17 00:00:00 2001 From: vojtasmrcek Date: Mon, 9 Dec 2024 15:55:30 +0100 Subject: [PATCH] Implement placeholders in Compose --- media-placeholders/build.gradle | 10 + .../placeholders/ComposePlaceholderAdapter.kt | 14 + .../placeholders/ComposePlaceholderManager.kt | 672 ++++++++++++++++++ .../placeholders/ImageWithCaptionAdapter.kt | 2 +- .../aztec/placeholders/PlaceholderManager.kt | 583 +-------------- .../placeholders/ViewPlaceholderAdapter.kt | 24 + .../placeholders/ViewPlaceholderManager.kt | 561 +++++++++++++++ .../aztec/placeholders/PlaceholderTest.kt | 4 +- 8 files changed, 1298 insertions(+), 572 deletions(-) create mode 100644 media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderAdapter.kt create mode 100644 media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt create mode 100644 media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ViewPlaceholderAdapter.kt create mode 100644 media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ViewPlaceholderManager.kt diff --git a/media-placeholders/build.gradle b/media-placeholders/build.gradle index 35a19c3d3..ea3c12a76 100644 --- a/media-placeholders/build.gradle +++ b/media-placeholders/build.gradle @@ -32,6 +32,12 @@ android { includeAndroidResources = true } } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.14" + } } dependencies { @@ -41,6 +47,10 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinCoroutinesVersion" testImplementation "junit:junit:$jUnitVersion" testImplementation "org.robolectric:robolectric:$robolectricVersion" + implementation 'androidx.compose.material3:material3:1.3.1' + implementation 'androidx.compose.foundation:foundation:1.7.5' + implementation 'androidx.compose.ui:ui:1.7.5' + implementation 'androidx.compose.ui:ui-tooling-preview:1.7.5' } dependencyAnalysis { diff --git a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderAdapter.kt b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderAdapter.kt new file mode 100644 index 000000000..cef9ee788 --- /dev/null +++ b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderAdapter.kt @@ -0,0 +1,14 @@ +package org.wordpress.aztec.placeholders + +import androidx.compose.runtime.Composable +import org.wordpress.aztec.AztecAttributes + +interface ComposePlaceholderAdapter : PlaceholderManager.PlaceholderAdapter { + @Composable + fun Placeholder( + placeholderUuid: String, + attrs: AztecAttributes, + width: Int, + height: Int, + ) {} +} diff --git a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt new file mode 100644 index 000000000..11b940bfd --- /dev/null +++ b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt @@ -0,0 +1,672 @@ +package org.wordpress.aztec.placeholders + +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.text.Editable +import android.text.Layout +import android.text.Spanned +import android.util.Log +import android.view.View +import android.view.ViewTreeObserver +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.zIndex +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.wordpress.aztec.AztecAttributes +import org.wordpress.aztec.AztecContentChangeWatcher +import org.wordpress.aztec.AztecText +import org.wordpress.aztec.Constants +import org.wordpress.aztec.Html +import org.wordpress.aztec.plugins.html2visual.IHtmlPreprocessor +import org.wordpress.aztec.plugins.html2visual.IHtmlTagHandler +import org.wordpress.aztec.spans.AztecMediaClickableSpan +import org.xml.sax.Attributes +import java.lang.ref.WeakReference +import java.util.UUID +import kotlin.coroutines.CoroutineContext + +/** + * This class handles the "Placeholders". Placeholders are custom spans which are drawn in the Aztec text and the user + * can interact with them in a similar way as with other media. These spans are invisible and are used by this class + * as a place we can draw over with custom views. The custom views are placed in the `FrameLayout` which contains the + * Aztec text item and are shifted up and down if anything above them changes (for example if the user adds a new line + * before the placeholder). + */ +class ComposePlaceholderManager( + private val aztecText: AztecText, + private val htmlTag: String = DEFAULT_HTML_TAG, + private val generateUuid: () -> String = { + UUID.randomUUID().toString() + } +) : PlaceholderManager, + AztecContentChangeWatcher.AztecTextChangeObserver, + IHtmlTagHandler, + Html.MediaCallback, + AztecText.OnMediaDeletedListener, + AztecText.OnVisibilityChangeListener, + CoroutineScope, + IHtmlPreprocessor { + private val adapters = mutableMapOf() + private val positionToIdMutex = Mutex() + private val positionToId = mutableSetOf() + private val job = Job() + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + job + private val composeViewState = MutableStateFlow(emptyMap()) + + init { + aztecText.setOnVisibilityChangeListener(this) + aztecText.mediaCallback = this + aztecText.contentChangeWatcher.registerObserver(this) + } + + data class ComposeView( + val uuid: String, + val width: Int, + val height: Int, + val topMargin: Int, + val leftMargin: Int, + val visible: Boolean, + val adapterKey: String, + val attrs: AztecAttributes + ) + + @Composable + fun Draw() { + val density = LocalDensity.current + + val values = composeViewState.collectAsState().value.values.filter { it.visible }.sortedBy { it.topMargin } + + LaunchedEffect(values.size) { + Log.d("vojta", "Drawing placeholders: ${values.size}") + } + values.forEach { composeView -> + LaunchedEffect(composeView.uuid) { + Log.d("vojta", "Item: ${composeView.uuid}") + } + Box( + Modifier + .zIndex(9f) + .padding( + top = with(density) { composeView.topMargin.toDp() }, + start = with(density) { composeView.leftMargin.toDp() }, + ) + .width( + with(density) { composeView.width.toDp() } + ) + .height( + with(density) { composeView.height.toDp() } + ) + ) { + key(composeView.uuid, composeView.width, composeView.height) { + adapters[composeView.adapterKey]?.Placeholder( + composeView.uuid, + composeView.attrs, + composeView.width, + composeView.height, + ) + } + } + } + } + + fun onDestroy() { + positionToId.forEach { + composeViewState.value = composeViewState.value.let { state -> + val mutableState = state.toMutableMap() + mutableState.remove(it.uuid) + mutableState + } + } + positionToId.clear() + aztecText.contentChangeWatcher.unregisterObserver(this) + adapters.values.forEach { it.onDestroy() } + adapters.clear() + job.cancel() + } + + /** + * Register a custom adapter to draw a custom view over a placeholder. + */ + fun registerAdapter(placeholderAdapter: ComposePlaceholderAdapter) { + adapters[placeholderAdapter.type] = placeholderAdapter + } + + /** + * Call this method to manually insert a new item into the aztec text. There has to be a adapter associated with the + * item type. + * @param type placeholder type + * @param attributes other attributes passed to the view. For example a `src` for an image. + */ + override suspend fun insertItem(type: String, vararg attributes: Pair) { + val adapter = adapters[type] + ?: throw IllegalArgumentException("Adapter for inserted type not found. Register it with `registerAdapter` method") + val attrs = getAttributesForMedia(type, attributes) + val drawable = buildPlaceholderDrawable(adapter, attrs) + aztecText.insertMediaSpan( + AztecPlaceholderSpan( + drawable, 0, attrs, + this, aztecText, WeakReference(adapter), TAG = htmlTag + ) + ) + insertContentOverSpanWithId(attrs.getValue(UUID_ATTRIBUTE)) + } + + /** + * Call this method to insert an item with an option to merge it with the previous item. This could be used to + * build a gallery of images on adding a new image. + * @param type placeholder type + * @param shouldMergeItem this method should return true when the previous type is compatible and should be updated + * @param updateItem function to update current parameters with new params + */ + override suspend fun insertOrUpdateItem( + type: String, + shouldMergeItem: (currentItemType: String) -> Boolean, + updateItem: ( + currentAttributes: Map?, + currentType: String?, + placeAtStart: Boolean + ) -> Map + ) { + val targetItem = getTargetItem() + val targetSpan = targetItem?.span + val currentType = targetSpan?.attributes?.getValue(TYPE_ATTRIBUTE) + if (currentType != null) { + if (shouldMergeItem(currentType)) { + updateSpan(type, targetItem.span, targetItem.placeAtStart, updateItem, currentType) + } else { + val (newLinePosition, targetSelection) = if (targetItem.placeAtStart) { + targetItem.spanStart to targetItem.spanStart + } else { + targetItem.spanEnd to targetItem.spanEnd + 2 + } + aztecText.text.insert(newLinePosition, Constants.NEWLINE_STRING) + aztecText.setSelection(targetSelection) + insertItem(type, *updateItem(null, null, false).toList().toTypedArray()) + } + } else { + insertItem(type, *updateItem(null, null, false).toList().toTypedArray()) + } + } + + private suspend fun updateSpan( + type: String, + targetSpan: AztecPlaceholderSpan, + placeAtStart: Boolean, + updateItem: (currentAttributes: Map, currentType: String, placeAtStart: Boolean) -> Map, + currentType: String + ) { + val adapter = adapters[type] + ?: throw IllegalArgumentException("Adapter for inserted type not found. Register it with `registerAdapter` method") + val currentAttributes = mutableMapOf() + val uuid = targetSpan.attributes.getValue(UUID_ATTRIBUTE) + for (i in 0 until targetSpan.attributes.length) { + val name = targetSpan.attributes.getQName(i) + val value = targetSpan.attributes.getValue(name) + currentAttributes[name] = value + } + val updatedAttributes = updateItem(currentAttributes, currentType, placeAtStart) + val attrs = AztecAttributes().apply { + updatedAttributes.forEach { (key, value) -> + setValue(key, value) + } + } + attrs.setValue(UUID_ATTRIBUTE, uuid) + attrs.setValue(TYPE_ATTRIBUTE, type) + val drawable = buildPlaceholderDrawable(adapter, attrs) + val span = AztecPlaceholderSpan( + drawable, 0, attrs, + this, aztecText, WeakReference(adapter), TAG = htmlTag + ) + aztecText.replaceMediaSpan(span) { attributes -> + attributes.getValue(UUID_ATTRIBUTE) == uuid + } + insertContentOverSpanWithId(uuid) + } + + /** + * Use this function to either update or remove an item. The decision whether to remove or update will be made + * based upon the results of the parameter functions. An example of usage is a gallery of images. If the user wants + * to remove one image in the gallery, they would call this method. If the removed image is one of many, they might + * want to update the current parameters instead of removing the entire gallery. However, if the removed image is + * the last one in the gallery, they will probably want to remove the entire gallery. + * @param uuid UUID of the span we want to remove or update + * @param shouldUpdateItem This function should return true if the span can be updated, false if it should be removed + * @param updateItem Function that updates the selected item + */ + override suspend fun removeOrUpdate( + uuid: String, + shouldUpdateItem: (Attributes) -> Boolean, + updateItem: (currentAttributes: Map) -> Map + ): Boolean { + val currentItem = + aztecText.editableText.getSpans(0, aztecText.length(), AztecPlaceholderSpan::class.java) + .find { + it.attributes.getValue(UUID_ATTRIBUTE) == uuid + } ?: return false + if (shouldUpdateItem(currentItem.attributes)) { + val type = currentItem.attributes.getValue(TYPE_ATTRIBUTE) + val selectionStart = aztecText.selectionStart + val selectionEnd = aztecText.selectionEnd + aztecText.setSelection(aztecText.editableText.getSpanStart(currentItem)) + updateSpan(type, currentItem, updateItem = { attributes, _, _ -> + updateItem(attributes) + }, placeAtStart = false, currentType = type) + aztecText.setSelection(selectionStart, selectionEnd) + } else { + removeItem(uuid) + } + return true + } + + private data class TargetItem( + val span: AztecPlaceholderSpan, + val placeAtStart: Boolean, + val spanStart: Int, + val spanEnd: Int + ) + + private fun getTargetItem(): TargetItem? { + if (aztecText.length() == 0) { + return null + } + val limitLength = aztecText.length() - 1 + val selectionStart = aztecText.selectionStart + val selectionStartMinusOne = (selectionStart - 1).coerceIn(0, limitLength) + val selectionStartMinusTwo = (selectionStart - 2).coerceIn(0, limitLength) + val selectionEnd = aztecText.selectionEnd + val selectionEndPlusOne = (selectionStart + 1).coerceIn(0, limitLength) + val selectionEndPlusTwo = (selectionStart + 2).coerceIn(0, limitLength) + val editableText = aztecText.editableText + var placeAtStart = false + val (from, to) = if (editableText[selectionStartMinusOne] == Constants.IMG_CHAR) { + selectionStartMinusOne to selectionStart + } else if (editableText[selectionStartMinusOne] == '\n' && editableText[selectionStartMinusTwo] == Constants.IMG_CHAR) { + selectionStartMinusTwo to selectionStart + } else if (editableText[selectionEndPlusOne] == Constants.IMG_CHAR) { + placeAtStart = true + selectionEndPlusOne to (selectionEndPlusOne + 1).coerceIn(0, limitLength) + } else if (editableText[selectionEndPlusOne] == '\n' && editableText[selectionEndPlusTwo] == Constants.IMG_CHAR) { + placeAtStart = true + selectionEndPlusTwo to (selectionEndPlusTwo + 1).coerceIn(0, limitLength) + } else { + selectionStart to selectionEnd + } + return editableText.getSpans( + from, + to, + AztecPlaceholderSpan::class.java + ).map { + TargetItem( + it, + placeAtStart, + editableText.getSpanStart(it), + editableText.getSpanEnd(it) + ) + }.lastOrNull() + } + + /** + * Call this method to remove a placeholder from both the AztecText and the overlaying layer programmatically. + * @param predicate determines whether a span should be removed + */ + override fun removeItem(predicate: (Attributes) -> Boolean) { + aztecText.removeMedia { predicate(it) } + } + + /** + * Call this method to remove a placeholder from both the AztecText and the overlaying layer programmatically. + * @param uuid of the removed item + */ + override fun removeItem(uuid: String) { + aztecText.removeMedia { it.getValue(UUID_ATTRIBUTE) == uuid } + } + + private suspend fun buildPlaceholderDrawable( + adapter: ComposePlaceholderAdapter, + attrs: AztecAttributes + ): Drawable { + val drawable = ContextCompat.getDrawable(aztecText.context, android.R.color.transparent)!! + val editorWidth = if (aztecText.width > 0) { + aztecText.width - aztecText.paddingStart - aztecText.paddingEnd + } else aztecText.maxImagesWidth + drawable.setBounds( + 0, + 0, + adapter.calculateWidth(attrs, editorWidth), + adapter.calculateHeight(attrs, editorWidth) + ) + return drawable + } + + /** + * Call this method to reload all the placeholders + */ + suspend fun reloadAllPlaceholders() { +// Log.d("vojta", "Reloading placeholders") + val tempPositionToId = positionToId.toList() + tempPositionToId.forEach { placeholder -> + val isValid = positionToIdMutex.withLock { + positionToId.contains(placeholder) + } + if (isValid) { + insertContentOverSpanWithId(placeholder.uuid) + } + } + } + + /** + * Call this method to relaod a placeholder with UUID + */ + suspend fun refreshWithUuid(uuid: String) { + insertContentOverSpanWithId(uuid) + } + + private suspend fun insertContentOverSpanWithId(uuid: String) { + var aztecAttributes: AztecAttributes? = null + val predicate = object : AztecText.AttributePredicate { + override fun matches(attrs: Attributes): Boolean { + val match = attrs.getValue(UUID_ATTRIBUTE) == uuid + if (match) { + aztecAttributes = attrs as AztecAttributes + } + return match + } + } + val targetPosition = aztecText.getElementPosition(predicate) ?: return + insertInPosition(aztecAttributes ?: return, targetPosition) + } + + private suspend fun insertInPosition(attrs: AztecAttributes, targetPosition: Int) { + if (!validateAttributes(attrs)) { + return + } + val uuid = attrs.getValue(UUID_ATTRIBUTE) + val type = attrs.getValue(TYPE_ATTRIBUTE) + // At this point we can get to a race condition where the aztec text layout is not yet initialized. + // We want to wait a bit and make sure it's properly loaded. + var counter = 0 + while (aztecText.layout == null && counter < 10) { + delay(50) + counter += 1 + } + val textViewLayout: Layout = aztecText.layout ?: return + val parentTextViewRect = Rect() + val targetLineOffset = textViewLayout.getLineForOffset(targetPosition) + textViewLayout.getLineBounds(targetLineOffset, parentTextViewRect) + + val parentTextViewLocation = intArrayOf(0, 0) + aztecText.getLocationOnScreen(parentTextViewLocation) + val parentTextViewTopAndBottomOffset = aztecText.scrollY + aztecText.compoundPaddingTop + + val adapter = adapters[type]!! + val windowWidth = parentTextViewRect.right - parentTextViewRect.left - EDITOR_INNER_PADDING + val height = adapter.calculateHeight(attrs, windowWidth) + parentTextViewRect.top += parentTextViewTopAndBottomOffset + parentTextViewRect.bottom = parentTextViewRect.top + height + + val box = composeViewState.value[uuid] + val newWidth = adapter.calculateWidth(attrs, windowWidth) - EDITOR_INNER_PADDING + val newHeight = height - EDITOR_INNER_PADDING + val padding = 10 + val newLeftPadding = parentTextViewRect.left + padding + aztecText.paddingStart + val newTopPadding = parentTextViewRect.top + padding +// Log.d("vojta", "Current top padding: ${box?.topMargin}") +// Log.d("vojta", "New top padding: ${newTopPadding}") + box?.let { existingView -> + val widthSame = existingView.width == newWidth + val heightSame = existingView.height == newHeight + val topMarginSame = existingView.topMargin == newTopPadding + val leftMarginSame = existingView.leftMargin == newLeftPadding + if (widthSame && heightSame && topMarginSame && leftMarginSame) { + return + } + positionToIdMutex.withLock { + positionToId.removeAll { + it.uuid == uuid + } + } + } + + composeViewState.value = composeViewState.value.let { state -> + val mutableState = state.toMutableMap() + mutableState[uuid] = ComposeView( + uuid = uuid, + width = newWidth, + height = newHeight, + topMargin = newTopPadding, + leftMargin = newLeftPadding, + visible = true, + adapterKey = adapter.type, + attrs = attrs + ) + mutableState + } + + positionToIdMutex.withLock { + positionToId.add(Placeholder(targetPosition, uuid)) + } + } + + private fun validateAttributes(attributes: AztecAttributes): Boolean { + return attributes.hasAttribute(UUID_ATTRIBUTE) && + attributes.hasAttribute(TYPE_ATTRIBUTE) && + adapters[attributes.getValue(TYPE_ATTRIBUTE)] != null + } + + private fun getAttributesForMedia( + type: String, + attributes: Array> + ): AztecAttributes { + val attrs = AztecAttributes() + attrs.setValue(UUID_ATTRIBUTE, generateUuid()) + attrs.setValue(TYPE_ATTRIBUTE, type) + attributes.forEach { + attrs.setValue(it.first, it.second) + } + return attrs + } + + /** + * Called when the aztec text content changes. + */ + override fun onContentChanged() { + launch { + reloadAllPlaceholders() + } + } + + /** + * Called when any media is deleted. We use this method to remove the custom views if the placeholder is deleted. + */ + override fun onMediaDeleted(attrs: AztecAttributes) { + if (validateAttributes(attrs)) { + val uuid = attrs.getValue(UUID_ATTRIBUTE) + val adapter = adapters[attrs.getValue(TYPE_ATTRIBUTE)] + adapter?.onPlaceholderDeleted(uuid) + launch { + positionToIdMutex.withLock { + positionToId.removeAll { it.uuid == uuid } + } + } + composeViewState.value = composeViewState.value.let { state -> + val mutableState = state.toMutableMap() + mutableState.remove(uuid) + mutableState + } + } + } + + /** + * Called before media is deleted. There is a delay between user deleting a media and when the media is actually is + * confirmed. That's why we first hide the media and we delete it when `onMediaDeleted` is actually called. + */ + override fun beforeMediaDeleted(attrs: AztecAttributes) { + if (validateAttributes(attrs)) { + val uuid = attrs.getValue(UUID_ATTRIBUTE) + composeViewState.value = composeViewState.value.let { state -> + val mutableState = state.toMutableMap() + mutableState.remove(uuid) + mutableState + } + } + } + + override fun canHandleTag(tag: String): Boolean { + return tag == htmlTag + } + + /** + * This method handled a `placeholder` tag found in the HTML. It creates a placeholder and inserts a view over it. + */ + override fun handleTag( + opening: Boolean, + tag: String, + output: Editable, + attributes: Attributes, + nestingLevel: Int + ): Boolean { + if (opening) { + val type = attributes.getValue(TYPE_ATTRIBUTE) + attributes.getValue(UUID_ATTRIBUTE)?.also { uuid -> + composeViewState.value = composeViewState.value.let { state -> + val mutableState = state.toMutableMap() + mutableState.remove(uuid) + mutableState + } + } + val adapter = adapters[type] ?: return false + val aztecAttributes = AztecAttributes(attributes) + aztecAttributes.setValue(UUID_ATTRIBUTE, generateUuid()) + val drawable = runBlocking { buildPlaceholderDrawable(adapter, aztecAttributes) } + val span = AztecPlaceholderSpan( + drawable = drawable, + nestingLevel = nestingLevel, + attributes = aztecAttributes, + onMediaDeletedListener = this, + adapter = WeakReference(adapter), + TAG = htmlTag + ) + val clickableSpan = AztecMediaClickableSpan(span) + val position = output.length + output.setSpan(span, position, position, Spanned.SPAN_MARK_MARK) + output.setSpan(clickableSpan, position, position, Spanned.SPAN_MARK_MARK) + output.append(Constants.IMG_CHAR) + output.setSpan(clickableSpan, position, output.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + output.setSpan(span, position, output.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + span.applyInlineStyleAttributes(output, position, output.length) + } + return tag == htmlTag + } + + override fun mediaLoadingStarted() { + val spans = aztecText.editableText.getSpans( + 0, + aztecText.editableText.length, + AztecPlaceholderSpan::class.java + ) + + if (spans == null || spans.isEmpty()) { + return + } + aztecText.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener) + aztecText.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener) + } + + private val globalLayoutListener = object : ViewTreeObserver.OnGlobalLayoutListener { + private var job: Job? = null + override fun onGlobalLayout() { + if (job?.isActive == true) { + return + } + aztecText.viewTreeObserver.removeOnGlobalLayoutListener(this) + job = redrawViews() + } + } + + fun redrawViews(): Job? { + val spans = aztecText.editableText.getSpans( + 0, + aztecText.editableText.length, + AztecPlaceholderSpan::class.java + ) + + if (spans == null || spans.isEmpty()) { + return null + } + return launch { + clearAllViews() + spans.forEach { + val type = it.attributes.getValue(TYPE_ATTRIBUTE) + val adapter = adapters[type] ?: return@forEach + it.drawable = buildPlaceholderDrawable(adapter, it.attributes) + aztecText.refreshText(false) + insertInPosition(it.attributes, aztecText.editableText.getSpanStart(it)) + } + } + } + + private suspend fun clearAllViews() { + positionToIdMutex.withLock { + for (placeholder in positionToId) { + composeViewState.value = composeViewState.value.let { state -> + val mutableState = state.toMutableMap() + mutableState.remove(placeholder.uuid) + mutableState + } + } + positionToId.clear() + } + } + + override fun onVisibility(visibility: Int) { + launch { + positionToIdMutex.withLock { + for (placeholder in positionToId) { + composeViewState.value = composeViewState.value.let { state -> + val mutableState = state.toMutableMap() + mutableState[placeholder.uuid]?.copy( + visible = View.VISIBLE == visibility + )?.let { + mutableState[placeholder.uuid] = it + } + mutableState + } + } + } + } + } + + data class Placeholder(val elementPosition: Int, val uuid: String) + + companion object { + private const val DEFAULT_HTML_TAG = "placeholder" + private const val UUID_ATTRIBUTE = "uuid" + private const val TYPE_ATTRIBUTE = "type" + private const val EDITOR_INNER_PADDING = 20 + } + + override fun beforeHtmlProcessed(source: String): String { + runBlocking { + clearAllViews() + } + return source + } +} diff --git a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ImageWithCaptionAdapter.kt b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ImageWithCaptionAdapter.kt index cae1957d0..92bafaf70 100644 --- a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ImageWithCaptionAdapter.kt +++ b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ImageWithCaptionAdapter.kt @@ -16,7 +16,7 @@ import org.wordpress.aztec.placeholders.PlaceholderManager.PlaceholderAdapter.Pr */ class ImageWithCaptionAdapter( override val type: String = "image_with_caption" -) : PlaceholderManager.PlaceholderAdapter { +) : ViewPlaceholderAdapter { private val media = mutableMapOf() suspend override fun getHeight(attrs: AztecAttributes): Proportion { return Proportion.Ratio(0.5f) diff --git a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/PlaceholderManager.kt b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/PlaceholderManager.kt index baf5d4007..e9e512dbb 100644 --- a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/PlaceholderManager.kt +++ b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/PlaceholderManager.kt @@ -1,570 +1,30 @@ package org.wordpress.aztec.placeholders import android.content.Context -import android.graphics.Color -import android.graphics.Rect -import android.graphics.drawable.Drawable -import android.text.Editable -import android.text.Layout -import android.text.Spanned import android.view.MotionEvent import android.view.View -import android.view.ViewTreeObserver -import android.widget.FrameLayout -import androidx.core.content.ContextCompat -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import androidx.compose.runtime.Composable import org.wordpress.aztec.AztecAttributes -import org.wordpress.aztec.AztecContentChangeWatcher -import org.wordpress.aztec.AztecText -import org.wordpress.aztec.Constants -import org.wordpress.aztec.Html -import org.wordpress.aztec.plugins.html2visual.IHtmlPreprocessor -import org.wordpress.aztec.plugins.html2visual.IHtmlTagHandler -import org.wordpress.aztec.spans.AztecMediaClickableSpan import org.xml.sax.Attributes -import java.lang.ref.WeakReference -import java.util.UUID -import kotlin.coroutines.CoroutineContext import kotlin.math.min -/** - * This class handles the "Placeholders". Placeholders are custom spans which are drawn in the Aztec text and the user - * can interact with them in a similar way as with other media. These spans are invisible and are used by this class - * as a place we can draw over with custom views. The custom views are placed in the `FrameLayout` which contains the - * Aztec text item and are shifted up and down if anything above them changes (for example if the user adds a new line - * before the placeholder). - */ -class PlaceholderManager( - private val aztecText: AztecText, - private val container: FrameLayout, - private val htmlTag: String = DEFAULT_HTML_TAG, - private val generateUuid: () -> String = { - UUID.randomUUID().toString() - } -) : AztecContentChangeWatcher.AztecTextChangeObserver, - IHtmlTagHandler, - Html.MediaCallback, - AztecText.OnMediaDeletedListener, - AztecText.OnVisibilityChangeListener, - CoroutineScope, - IHtmlPreprocessor { - private val adapters = mutableMapOf() - private val positionToIdMutex = Mutex() - private val positionToId = mutableSetOf() - private val job = Job() - override val coroutineContext: CoroutineContext - get() = Dispatchers.Main + job - - init { - aztecText.setOnVisibilityChangeListener(this) - aztecText.mediaCallback = this - aztecText.contentChangeWatcher.registerObserver(this) - } - - fun onDestroy() { - positionToId.forEach { - container.findViewWithTag(it.uuid)?.let { placeholder -> - container.removeView(placeholder) - } - } - positionToId.clear() - aztecText.contentChangeWatcher.unregisterObserver(this) - adapters.values.forEach { it.onDestroy() } - adapters.clear() - job.cancel() - } - - /** - * Register a custom adapter to draw a custom view over a placeholder. - */ - fun registerAdapter(placeholderAdapter: PlaceholderAdapter) { - adapters[placeholderAdapter.type] = placeholderAdapter - } - - /** - * Call this method to manually insert a new item into the aztec text. There has to be a adapter associated with the - * item type. - * @param type placeholder type - * @param attributes other attributes passed to the view. For example a `src` for an image. - */ - suspend fun insertItem(type: String, vararg attributes: Pair) { - val adapter = adapters[type] - ?: throw IllegalArgumentException("Adapter for inserted type not found. Register it with `registerAdapter` method") - val attrs = getAttributesForMedia(type, attributes) - val drawable = buildPlaceholderDrawable(adapter, attrs) - aztecText.insertMediaSpan(AztecPlaceholderSpan(drawable, 0, attrs, - this, aztecText, WeakReference(adapter), TAG = htmlTag)) - insertContentOverSpanWithId(attrs.getValue(UUID_ATTRIBUTE)) - } - - /** - * Call this method to insert an item with an option to merge it with the previous item. This could be used to - * build a gallery of images on adding a new image. - * @param type placeholder type - * @param shouldMergeItem this method should return true when the previous type is compatible and should be updated - * @param updateItem function to update current parameters with new params - */ +interface PlaceholderManager { + fun removeItem(uuid: String) + fun removeItem(predicate: (Attributes) -> Boolean) + suspend fun removeOrUpdate(uuid: String, shouldUpdateItem: (Attributes) -> Boolean, updateItem: (currentAttributes: Map) -> Map): Boolean suspend fun insertOrUpdateItem( - type: String, - shouldMergeItem: (currentItemType: String) -> Boolean = { true }, - updateItem: ( - currentAttributes: Map?, - currentType: String?, - placeAtStart: Boolean - ) -> Map - ) { - val targetItem = getTargetItem() - val targetSpan = targetItem?.span - val currentType = targetSpan?.attributes?.getValue(TYPE_ATTRIBUTE) - if (currentType != null) { - if (shouldMergeItem(currentType)) { - updateSpan(type, targetItem.span, targetItem.placeAtStart, updateItem, currentType) - } else { - val (newLinePosition, targetSelection) = if (targetItem.placeAtStart) { - targetItem.spanStart to targetItem.spanStart - } else { - targetItem.spanEnd to targetItem.spanEnd + 2 - } - aztecText.text.insert(newLinePosition, Constants.NEWLINE_STRING) - aztecText.setSelection(targetSelection) - insertItem(type, *updateItem(null, null, false).toList().toTypedArray()) - } - } else { - insertItem(type, *updateItem(null, null, false).toList().toTypedArray()) - } - } - - private suspend fun updateSpan( - type: String, - targetSpan: AztecPlaceholderSpan, - placeAtStart: Boolean, - updateItem: (currentAttributes: Map, currentType: String, placeAtStart: Boolean) -> Map, - currentType: String - ) { - val adapter = adapters[type] - ?: throw IllegalArgumentException("Adapter for inserted type not found. Register it with `registerAdapter` method") - val currentAttributes = mutableMapOf() - val uuid = targetSpan.attributes.getValue(UUID_ATTRIBUTE) - for (i in 0 until targetSpan.attributes.length) { - val name = targetSpan.attributes.getQName(i) - val value = targetSpan.attributes.getValue(name) - currentAttributes[name] = value - } - val updatedAttributes = updateItem(currentAttributes, currentType, placeAtStart) - val attrs = AztecAttributes().apply { - updatedAttributes.forEach { (key, value) -> - setValue(key, value) - } - } - attrs.setValue(UUID_ATTRIBUTE, uuid) - attrs.setValue(TYPE_ATTRIBUTE, type) - val drawable = buildPlaceholderDrawable(adapter, attrs) - val span = AztecPlaceholderSpan(drawable, 0, attrs, - this, aztecText, WeakReference(adapter), TAG = htmlTag) - aztecText.replaceMediaSpan(span) { attributes -> - attributes.getValue(UUID_ATTRIBUTE) == uuid - } - insertContentOverSpanWithId(uuid) - } - - /** - * Use this function to either update or remove an item. The decision whether to remove or update will be made - * based upon the results of the parameter functions. An example of usage is a gallery of images. If the user wants - * to remove one image in the gallery, they would call this method. If the removed image is one of many, they might - * want to update the current parameters instead of removing the entire gallery. However, if the removed image is - * the last one in the gallery, they will probably want to remove the entire gallery. - * @param uuid UUID of the span we want to remove or update - * @param shouldUpdateItem This function should return true if the span can be updated, false if it should be removed - * @param updateItem Function that updates the selected item - */ - suspend fun removeOrUpdate(uuid: String, shouldUpdateItem: (Attributes) -> Boolean, updateItem: (currentAttributes: Map) -> Map): Boolean { - val currentItem = aztecText.editableText.getSpans(0, aztecText.length(), AztecPlaceholderSpan::class.java).find { - it.attributes.getValue(UUID_ATTRIBUTE) == uuid - } ?: return false - if (shouldUpdateItem(currentItem.attributes)) { - val type = currentItem.attributes.getValue(TYPE_ATTRIBUTE) - val selectionStart = aztecText.selectionStart - val selectionEnd = aztecText.selectionEnd - aztecText.setSelection(aztecText.editableText.getSpanStart(currentItem)) - updateSpan(type, currentItem, updateItem = { attributes, _, _ -> - updateItem(attributes) - }, placeAtStart = false, currentType = type) - aztecText.setSelection(selectionStart, selectionEnd) - } else { - removeItem(uuid) - } - return true - } - - private data class TargetItem(val span: AztecPlaceholderSpan, val placeAtStart: Boolean, val spanStart: Int, val spanEnd: Int) - - private fun getTargetItem(): TargetItem? { - if (aztecText.length() == 0) { - return null - } - val limitLength = aztecText.length() - 1 - val selectionStart = aztecText.selectionStart - val selectionStartMinusOne = (selectionStart - 1).coerceIn(0, limitLength) - val selectionStartMinusTwo = (selectionStart - 2).coerceIn(0, limitLength) - val selectionEnd = aztecText.selectionEnd - val selectionEndPlusOne = (selectionStart + 1).coerceIn(0, limitLength) - val selectionEndPlusTwo = (selectionStart + 2).coerceIn(0, limitLength) - val editableText = aztecText.editableText - var placeAtStart = false - val (from, to) = if (editableText[selectionStartMinusOne] == Constants.IMG_CHAR) { - selectionStartMinusOne to selectionStart - } else if (editableText[selectionStartMinusOne] == '\n' && editableText[selectionStartMinusTwo] == Constants.IMG_CHAR) { - selectionStartMinusTwo to selectionStart - } else if (editableText[selectionEndPlusOne] == Constants.IMG_CHAR) { - placeAtStart = true - selectionEndPlusOne to (selectionEndPlusOne + 1).coerceIn(0, limitLength) - } else if (editableText[selectionEndPlusOne] == '\n' && editableText[selectionEndPlusTwo] == Constants.IMG_CHAR) { - placeAtStart = true - selectionEndPlusTwo to (selectionEndPlusTwo + 1).coerceIn(0, limitLength) - } else { - selectionStart to selectionEnd - } - return editableText.getSpans( - from, - to, - AztecPlaceholderSpan::class.java - ).map { TargetItem(it, placeAtStart, editableText.getSpanStart(it), editableText.getSpanEnd(it)) }.lastOrNull() - } - - /** - * Call this method to remove a placeholder from both the AztecText and the overlaying layer programmatically. - * @param predicate determines whether a span should be removed - */ - fun removeItem(predicate: (Attributes) -> Boolean) { - aztecText.removeMedia { predicate(it) } - } - - /** - * Call this method to remove a placeholder from both the AztecText and the overlaying layer programmatically. - * @param uuid of the removed item - */ - fun removeItem(uuid: String) { - aztecText.removeMedia { it.getValue(UUID_ATTRIBUTE) == uuid } - } - - private suspend fun buildPlaceholderDrawable(adapter: PlaceholderAdapter, attrs: AztecAttributes): Drawable { - val drawable = ContextCompat.getDrawable(aztecText.context, android.R.color.transparent)!! - val editorWidth = if (aztecText.width > 0) { - aztecText.width - aztecText.paddingStart - aztecText.paddingEnd - } else aztecText.maxImagesWidth - drawable.setBounds(0, 0, adapter.calculateWidth(attrs, editorWidth), adapter.calculateHeight(attrs, editorWidth)) - return drawable - } - - /** - * Call this method to reload all the placeholders - */ - suspend fun reloadAllPlaceholders() { - val tempPositionToId = positionToId.toList() - tempPositionToId.forEach { placeholder -> - val isValid = positionToIdMutex.withLock { - positionToId.contains(placeholder) - } - if (isValid) { - insertContentOverSpanWithId(placeholder.uuid) - } - } - } - - /** - * Call this method to relaod a placeholder with UUID - */ - suspend fun refreshWithUuid(uuid: String) { - insertContentOverSpanWithId(uuid) - } - - private suspend fun insertContentOverSpanWithId(uuid: String) { - var aztecAttributes: AztecAttributes? = null - val predicate = object : AztecText.AttributePredicate { - override fun matches(attrs: Attributes): Boolean { - val match = attrs.getValue(UUID_ATTRIBUTE) == uuid - if (match) { - aztecAttributes = attrs as AztecAttributes - } - return match - } - } - val targetPosition = aztecText.getElementPosition(predicate) ?: return - insertInPosition(aztecAttributes ?: return, targetPosition) - } - - private suspend fun insertInPosition(attrs: AztecAttributes, targetPosition: Int) { - if (!validateAttributes(attrs)) { - return - } - val uuid = attrs.getValue(UUID_ATTRIBUTE) - val type = attrs.getValue(TYPE_ATTRIBUTE) - // At this point we can get to a race condition where the aztec text layout is not yet initialized. - // We want to wait a bit and make sure it's properly loaded. - var counter = 0 - while (aztecText.layout == null && counter < 10) { - delay(50) - counter += 1 - } - val textViewLayout: Layout = aztecText.layout ?: return - val parentTextViewRect = Rect() - val targetLineOffset = textViewLayout.getLineForOffset(targetPosition) - textViewLayout.getLineBounds(targetLineOffset, parentTextViewRect) - - val parentTextViewLocation = intArrayOf(0, 0) - aztecText.getLocationOnScreen(parentTextViewLocation) - val parentTextViewTopAndBottomOffset = aztecText.scrollY + aztecText.compoundPaddingTop - - val adapter = adapters[type]!! - val windowWidth = parentTextViewRect.right - parentTextViewRect.left - EDITOR_INNER_PADDING - val height = adapter.calculateHeight(attrs, windowWidth) - parentTextViewRect.top += parentTextViewTopAndBottomOffset - parentTextViewRect.bottom = parentTextViewRect.top + height - - var box = container.findViewWithTag(uuid)?.apply { - id = uuid.hashCode() - } - val newWidth = adapter.calculateWidth(attrs, windowWidth) - EDITOR_INNER_PADDING - val newHeight = height - EDITOR_INNER_PADDING - val padding = 10 - val newLeftPadding = parentTextViewRect.left + padding + aztecText.paddingStart - val newTopPadding = parentTextViewRect.top + padding - box?.let { existingView -> - val currentParams = existingView.layoutParams as FrameLayout.LayoutParams - val widthSame = currentParams.width == newWidth - val heightSame = currentParams.height == newHeight - val topMarginSame = currentParams.topMargin == newTopPadding - val leftMarginSame = currentParams.leftMargin == newLeftPadding - if (widthSame && heightSame && topMarginSame && leftMarginSame) { - return - } - container.removeView(box) - positionToIdMutex.withLock { - positionToId.removeAll { - it.uuid == uuid - } - } - } - box = adapter.createView(container.context, uuid, attrs) - - box.id = uuid.hashCode() - box.setBackgroundColor(Color.TRANSPARENT) - box.setOnTouchListener(adapter) - box.tag = uuid - box.layoutParams = FrameLayout.LayoutParams( - newWidth, - newHeight - ).apply { - leftMargin = newLeftPadding - topMargin = newTopPadding - } - - positionToIdMutex.withLock { - positionToId.add(Placeholder(targetPosition, uuid)) - } - if (box.parent == null) { - container.addView(box) - } - adapter.onViewCreated(box, uuid) - } - - private fun validateAttributes(attributes: AztecAttributes): Boolean { - return attributes.hasAttribute(UUID_ATTRIBUTE) && - attributes.hasAttribute(TYPE_ATTRIBUTE) && - adapters[attributes.getValue(TYPE_ATTRIBUTE)] != null - } - - private fun getAttributesForMedia(type: String, attributes: Array>): AztecAttributes { - val attrs = AztecAttributes() - attrs.setValue(UUID_ATTRIBUTE, generateUuid()) - attrs.setValue(TYPE_ATTRIBUTE, type) - attributes.forEach { - attrs.setValue(it.first, it.second) - } - return attrs - } - - /** - * Called when the aztec text content changes. - */ - override fun onContentChanged() { - launch { - reloadAllPlaceholders() - } - } - - /** - * Called when any media is deleted. We use this method to remove the custom views if the placeholder is deleted. - */ - override fun onMediaDeleted(attrs: AztecAttributes) { - if (validateAttributes(attrs)) { - val uuid = attrs.getValue(UUID_ATTRIBUTE) - val adapter = adapters[attrs.getValue(TYPE_ATTRIBUTE)] - adapter?.onPlaceholderDeleted(uuid) - launch { - positionToIdMutex.withLock { - positionToId.removeAll { it.uuid == uuid } - } - } - container.findViewWithTag(uuid)?.let { - it.visibility = View.GONE - container.removeView(it) - } - } - } - - /** - * Called before media is deleted. There is a delay between user deleting a media and when the media is actually is - * confirmed. That's why we first hide the media and we delete it when `onMediaDeleted` is actually called. - */ - override fun beforeMediaDeleted(attrs: AztecAttributes) { - if (validateAttributes(attrs)) { - val uuid = attrs.getValue(UUID_ATTRIBUTE) - container.findViewWithTag(uuid)?.let { - it.visibility = View.GONE - } - } - } - - override fun canHandleTag(tag: String): Boolean { - return tag == htmlTag - } - - /** - * This method handled a `placeholder` tag found in the HTML. It creates a placeholder and inserts a view over it. - */ - override fun handleTag( - opening: Boolean, - tag: String, - output: Editable, - attributes: Attributes, - nestingLevel: Int - ): Boolean { - if (opening) { - val type = attributes.getValue(TYPE_ATTRIBUTE) - attributes.getValue(UUID_ATTRIBUTE)?.also { uuid -> - container.findViewWithTag(uuid)?.let { - it.visibility = View.GONE - container.removeView(it) - } - } - val adapter = adapters[type] ?: return false - val aztecAttributes = AztecAttributes(attributes) - aztecAttributes.setValue(UUID_ATTRIBUTE, generateUuid()) - val drawable = runBlocking { buildPlaceholderDrawable(adapter, aztecAttributes) } - val span = AztecPlaceholderSpan( - drawable = drawable, - nestingLevel = nestingLevel, - attributes = aztecAttributes, - onMediaDeletedListener = this, - adapter = WeakReference(adapter), - TAG = htmlTag - ) - val clickableSpan = AztecMediaClickableSpan(span) - val position = output.length - output.setSpan(span, position, position, Spanned.SPAN_MARK_MARK) - output.setSpan(clickableSpan, position, position, Spanned.SPAN_MARK_MARK) - output.append(Constants.IMG_CHAR) - output.setSpan(clickableSpan, position, output.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - output.setSpan(span, position, output.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - span.applyInlineStyleAttributes(output, position, output.length) - } - return tag == htmlTag - } - - override fun mediaLoadingStarted() { - val spans = aztecText.editableText.getSpans(0, aztecText.editableText.length, AztecPlaceholderSpan::class.java) - - if (spans == null || spans.isEmpty()) { - return - } - aztecText.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener) - aztecText.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener) - } - - private val globalLayoutListener = object : ViewTreeObserver.OnGlobalLayoutListener { - private var job: Job? = null - override fun onGlobalLayout() { - if (job?.isActive == true) { - return - } - aztecText.viewTreeObserver.removeOnGlobalLayoutListener(this) - job = redrawViews() - } - } - - fun redrawViews(): Job? { - val spans = aztecText.editableText.getSpans( - 0, - aztecText.editableText.length, - AztecPlaceholderSpan::class.java - ) - - if (spans == null || spans.isEmpty()) { - return null - } - return launch { - clearAllViews() - spans.forEach { - val type = it.attributes.getValue(TYPE_ATTRIBUTE) - val adapter = adapters[type] ?: return@forEach - it.drawable = buildPlaceholderDrawable(adapter, it.attributes) - aztecText.refreshText(false) - insertInPosition(it.attributes, aztecText.editableText.getSpanStart(it)) - } - } - } - - private suspend fun clearAllViews() { - positionToIdMutex.withLock { - for (placeholder in positionToId) { - container.findViewWithTag(placeholder.uuid)?.let { - it.visibility = View.GONE - container.removeView(it) - } - } - positionToId.clear() - } - } - - override fun onVisibility(visibility: Int) { - launch { - positionToIdMutex.withLock { - for (placeholder in positionToId) { - container.findViewWithTag(placeholder.uuid)?.visibility = visibility - } - } - } - } - + type: String, + shouldMergeItem: (currentItemType: String) -> Boolean = { true }, + updateItem: ( + currentAttributes: Map?, + currentType: String?, + placeAtStart: Boolean + ) -> Map + ) /** * A adapter for a custom view drawn over the placeholder in the Aztec text. */ interface PlaceholderAdapter : View.OnTouchListener { - /** - * Creates the view but it's called before the view is measured. If you need the actual width and height. Use - * the `onViewCreated` method where the view is already present in its correct size. - * @param context necessary to build custom views - * @param placeholderUuid the placeholder UUID - * @param attrs aztec attributes of the view - */ - suspend fun createView(context: Context, placeholderUuid: String, attrs: AztecAttributes): View - - /** - * Called after the view is measured. Use this method if you need the actual width and height of the view to - * draw your media. - * @param view the frame layout wrapping the custom view - * @param placeholderUuid the placeholder ID - */ - suspend fun onViewCreated(view: View, placeholderUuid: String) {} - /** * Called when the placeholder is deleted by the user. Use this method if you need to clear your data when the * item is deleted (for example delete an image in your DB). @@ -656,20 +116,5 @@ class PlaceholderManager( } } - data class Placeholder(val elementPosition: Int, val uuid: String) - - companion object { - private const val TAG = "PlaceholderManager" - private const val DEFAULT_HTML_TAG = "placeholder" - private const val UUID_ATTRIBUTE = "uuid" - private const val TYPE_ATTRIBUTE = "type" - private const val EDITOR_INNER_PADDING = 20 - } - - override fun beforeHtmlProcessed(source: String): String { - runBlocking { - clearAllViews() - } - return source - } + suspend fun insertItem(type: String, vararg attributes: Pair) } diff --git a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ViewPlaceholderAdapter.kt b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ViewPlaceholderAdapter.kt new file mode 100644 index 000000000..7dc926d92 --- /dev/null +++ b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ViewPlaceholderAdapter.kt @@ -0,0 +1,24 @@ +package org.wordpress.aztec.placeholders + +import android.content.Context +import android.view.View +import org.wordpress.aztec.AztecAttributes + +interface ViewPlaceholderAdapter : PlaceholderManager.PlaceholderAdapter { + /** + * Creates the view but it's called before the view is measured. If you need the actual width and height. Use + * the `onViewCreated` method where the view is already present in its correct size. + * @param context necessary to build custom views + * @param placeholderUuid the placeholder UUID + * @param attrs aztec attributes of the view + */ + suspend fun createView(context: Context, placeholderUuid: String, attrs: AztecAttributes): View + + /** + * Called after the view is measured. Use this method if you need the actual width and height of the view to + * draw your media. + * @param view the frame layout wrapping the custom view + * @param placeholderUuid the placeholder ID + */ + suspend fun onViewCreated(view: View, placeholderUuid: String) {} +} diff --git a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ViewPlaceholderManager.kt b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ViewPlaceholderManager.kt new file mode 100644 index 000000000..21d2a64b6 --- /dev/null +++ b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ViewPlaceholderManager.kt @@ -0,0 +1,561 @@ +package org.wordpress.aztec.placeholders + +import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.text.Editable +import android.text.Layout +import android.text.Spanned +import android.view.View +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.wordpress.aztec.AztecAttributes +import org.wordpress.aztec.AztecContentChangeWatcher +import org.wordpress.aztec.AztecText +import org.wordpress.aztec.Constants +import org.wordpress.aztec.Html +import org.wordpress.aztec.plugins.html2visual.IHtmlPreprocessor +import org.wordpress.aztec.plugins.html2visual.IHtmlTagHandler +import org.wordpress.aztec.spans.AztecMediaClickableSpan +import org.xml.sax.Attributes +import java.lang.ref.WeakReference +import java.util.UUID +import kotlin.coroutines.CoroutineContext + +/** + * This class handles the "Placeholders". Placeholders are custom spans which are drawn in the Aztec text and the user + * can interact with them in a similar way as with other media. These spans are invisible and are used by this class + * as a place we can draw over with custom views. The custom views are placed in the `FrameLayout` which contains the + * Aztec text item and are shifted up and down if anything above them changes (for example if the user adds a new line + * before the placeholder). + */ +class ViewPlaceholderManager( + private val aztecText: AztecText, + private val container: FrameLayout, + private val htmlTag: String = DEFAULT_HTML_TAG, + private val generateUuid: () -> String = { + UUID.randomUUID().toString() + } +) : PlaceholderManager, + AztecContentChangeWatcher.AztecTextChangeObserver, + IHtmlTagHandler, + Html.MediaCallback, + AztecText.OnMediaDeletedListener, + AztecText.OnVisibilityChangeListener, + CoroutineScope, + IHtmlPreprocessor { + private val adapters = mutableMapOf() + private val positionToIdMutex = Mutex() + private val positionToId = mutableSetOf() + private val job = Job() + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + job + + init { + aztecText.setOnVisibilityChangeListener(this) + aztecText.mediaCallback = this + aztecText.contentChangeWatcher.registerObserver(this) + } + + fun onDestroy() { + positionToId.forEach { + container.findViewWithTag(it.uuid)?.let { placeholder -> + container.removeView(placeholder) + } + } + positionToId.clear() + aztecText.contentChangeWatcher.unregisterObserver(this) + adapters.values.forEach { it.onDestroy() } + adapters.clear() + job.cancel() + } + + /** + * Register a custom adapter to draw a custom view over a placeholder. + */ + fun registerAdapter(placeholderAdapter: ViewPlaceholderAdapter) { + adapters[placeholderAdapter.type] = placeholderAdapter + } + + /** + * Call this method to manually insert a new item into the aztec text. There has to be a adapter associated with the + * item type. + * @param type placeholder type + * @param attributes other attributes passed to the view. For example a `src` for an image. + */ + override suspend fun insertItem(type: String, vararg attributes: Pair) { + val adapter = adapters[type] + ?: throw IllegalArgumentException("Adapter for inserted type not found. Register it with `registerAdapter` method") + val attrs = getAttributesForMedia(type, attributes) + val drawable = buildPlaceholderDrawable(adapter, attrs) + aztecText.insertMediaSpan(AztecPlaceholderSpan(drawable, 0, attrs, + this, aztecText, WeakReference(adapter), TAG = htmlTag)) + insertContentOverSpanWithId(attrs.getValue(UUID_ATTRIBUTE)) + } + + /** + * Call this method to insert an item with an option to merge it with the previous item. This could be used to + * build a gallery of images on adding a new image. + * @param type placeholder type + * @param shouldMergeItem this method should return true when the previous type is compatible and should be updated + * @param updateItem function to update current parameters with new params + */ + override suspend fun insertOrUpdateItem( + type: String, + shouldMergeItem: (currentItemType: String) -> Boolean, + updateItem: ( + currentAttributes: Map?, + currentType: String?, + placeAtStart: Boolean + ) -> Map + ) { + val targetItem = getTargetItem() + val targetSpan = targetItem?.span + val currentType = targetSpan?.attributes?.getValue(TYPE_ATTRIBUTE) + if (currentType != null) { + if (shouldMergeItem(currentType)) { + updateSpan(type, targetItem.span, targetItem.placeAtStart, updateItem, currentType) + } else { + val (newLinePosition, targetSelection) = if (targetItem.placeAtStart) { + targetItem.spanStart to targetItem.spanStart + } else { + targetItem.spanEnd to targetItem.spanEnd + 2 + } + aztecText.text.insert(newLinePosition, Constants.NEWLINE_STRING) + aztecText.setSelection(targetSelection) + insertItem(type, *updateItem(null, null, false).toList().toTypedArray()) + } + } else { + insertItem(type, *updateItem(null, null, false).toList().toTypedArray()) + } + } + + private suspend fun updateSpan( + type: String, + targetSpan: AztecPlaceholderSpan, + placeAtStart: Boolean, + updateItem: (currentAttributes: Map, currentType: String, placeAtStart: Boolean) -> Map, + currentType: String + ) { + val adapter = adapters[type] + ?: throw IllegalArgumentException("Adapter for inserted type not found. Register it with `registerAdapter` method") + val currentAttributes = mutableMapOf() + val uuid = targetSpan.attributes.getValue(UUID_ATTRIBUTE) + for (i in 0 until targetSpan.attributes.length) { + val name = targetSpan.attributes.getQName(i) + val value = targetSpan.attributes.getValue(name) + currentAttributes[name] = value + } + val updatedAttributes = updateItem(currentAttributes, currentType, placeAtStart) + val attrs = AztecAttributes().apply { + updatedAttributes.forEach { (key, value) -> + setValue(key, value) + } + } + attrs.setValue(UUID_ATTRIBUTE, uuid) + attrs.setValue(TYPE_ATTRIBUTE, type) + val drawable = buildPlaceholderDrawable(adapter, attrs) + val span = AztecPlaceholderSpan(drawable, 0, attrs, + this, aztecText, WeakReference(adapter), TAG = htmlTag) + aztecText.replaceMediaSpan(span) { attributes -> + attributes.getValue(UUID_ATTRIBUTE) == uuid + } + insertContentOverSpanWithId(uuid) + } + + /** + * Use this function to either update or remove an item. The decision whether to remove or update will be made + * based upon the results of the parameter functions. An example of usage is a gallery of images. If the user wants + * to remove one image in the gallery, they would call this method. If the removed image is one of many, they might + * want to update the current parameters instead of removing the entire gallery. However, if the removed image is + * the last one in the gallery, they will probably want to remove the entire gallery. + * @param uuid UUID of the span we want to remove or update + * @param shouldUpdateItem This function should return true if the span can be updated, false if it should be removed + * @param updateItem Function that updates the selected item + */ + override suspend fun removeOrUpdate(uuid: String, shouldUpdateItem: (Attributes) -> Boolean, updateItem: (currentAttributes: Map) -> Map): Boolean { + val currentItem = aztecText.editableText.getSpans(0, aztecText.length(), AztecPlaceholderSpan::class.java).find { + it.attributes.getValue(UUID_ATTRIBUTE) == uuid + } ?: return false + if (shouldUpdateItem(currentItem.attributes)) { + val type = currentItem.attributes.getValue(TYPE_ATTRIBUTE) + val selectionStart = aztecText.selectionStart + val selectionEnd = aztecText.selectionEnd + aztecText.setSelection(aztecText.editableText.getSpanStart(currentItem)) + updateSpan(type, currentItem, updateItem = { attributes, _, _ -> + updateItem(attributes) + }, placeAtStart = false, currentType = type) + aztecText.setSelection(selectionStart, selectionEnd) + } else { + removeItem(uuid) + } + return true + } + + private data class TargetItem(val span: AztecPlaceholderSpan, val placeAtStart: Boolean, val spanStart: Int, val spanEnd: Int) + + private fun getTargetItem(): TargetItem? { + if (aztecText.length() == 0) { + return null + } + val limitLength = aztecText.length() - 1 + val selectionStart = aztecText.selectionStart + val selectionStartMinusOne = (selectionStart - 1).coerceIn(0, limitLength) + val selectionStartMinusTwo = (selectionStart - 2).coerceIn(0, limitLength) + val selectionEnd = aztecText.selectionEnd + val selectionEndPlusOne = (selectionStart + 1).coerceIn(0, limitLength) + val selectionEndPlusTwo = (selectionStart + 2).coerceIn(0, limitLength) + val editableText = aztecText.editableText + var placeAtStart = false + val (from, to) = if (editableText[selectionStartMinusOne] == Constants.IMG_CHAR) { + selectionStartMinusOne to selectionStart + } else if (editableText[selectionStartMinusOne] == '\n' && editableText[selectionStartMinusTwo] == Constants.IMG_CHAR) { + selectionStartMinusTwo to selectionStart + } else if (editableText[selectionEndPlusOne] == Constants.IMG_CHAR) { + placeAtStart = true + selectionEndPlusOne to (selectionEndPlusOne + 1).coerceIn(0, limitLength) + } else if (editableText[selectionEndPlusOne] == '\n' && editableText[selectionEndPlusTwo] == Constants.IMG_CHAR) { + placeAtStart = true + selectionEndPlusTwo to (selectionEndPlusTwo + 1).coerceIn(0, limitLength) + } else { + selectionStart to selectionEnd + } + return editableText.getSpans( + from, + to, + AztecPlaceholderSpan::class.java + ).map { TargetItem(it, placeAtStart, editableText.getSpanStart(it), editableText.getSpanEnd(it)) }.lastOrNull() + } + + /** + * Call this method to remove a placeholder from both the AztecText and the overlaying layer programmatically. + * @param predicate determines whether a span should be removed + */ + override fun removeItem(predicate: (Attributes) -> Boolean) { + aztecText.removeMedia { predicate(it) } + } + + /** + * Call this method to remove a placeholder from both the AztecText and the overlaying layer programmatically. + * @param uuid of the removed item + */ + override fun removeItem(uuid: String) { + aztecText.removeMedia { it.getValue(UUID_ATTRIBUTE) == uuid } + } + + private suspend fun buildPlaceholderDrawable(adapter: ViewPlaceholderAdapter, attrs: AztecAttributes): Drawable { + val drawable = ContextCompat.getDrawable(aztecText.context, android.R.color.transparent)!! + val editorWidth = if (aztecText.width > 0) { + aztecText.width - aztecText.paddingStart - aztecText.paddingEnd + } else aztecText.maxImagesWidth + drawable.setBounds(0, 0, adapter.calculateWidth(attrs, editorWidth), adapter.calculateHeight(attrs, editorWidth)) + return drawable + } + + /** + * Call this method to reload all the placeholders + */ + suspend fun reloadAllPlaceholders() { + val tempPositionToId = positionToId.toList() + tempPositionToId.forEach { placeholder -> + val isValid = positionToIdMutex.withLock { + positionToId.contains(placeholder) + } + if (isValid) { + insertContentOverSpanWithId(placeholder.uuid) + } + } + } + + /** + * Call this method to relaod a placeholder with UUID + */ + suspend fun refreshWithUuid(uuid: String) { + insertContentOverSpanWithId(uuid) + } + + private suspend fun insertContentOverSpanWithId(uuid: String) { + var aztecAttributes: AztecAttributes? = null + val predicate = object : AztecText.AttributePredicate { + override fun matches(attrs: Attributes): Boolean { + val match = attrs.getValue(UUID_ATTRIBUTE) == uuid + if (match) { + aztecAttributes = attrs as AztecAttributes + } + return match + } + } + val targetPosition = aztecText.getElementPosition(predicate) ?: return + insertInPosition(aztecAttributes ?: return, targetPosition) + } + + private suspend fun insertInPosition(attrs: AztecAttributes, targetPosition: Int) { + if (!validateAttributes(attrs)) { + return + } + val uuid = attrs.getValue(UUID_ATTRIBUTE) + val type = attrs.getValue(TYPE_ATTRIBUTE) + // At this point we can get to a race condition where the aztec text layout is not yet initialized. + // We want to wait a bit and make sure it's properly loaded. + var counter = 0 + while (aztecText.layout == null && counter < 10) { + delay(50) + counter += 1 + } + val textViewLayout: Layout = aztecText.layout ?: return + val parentTextViewRect = Rect() + val targetLineOffset = textViewLayout.getLineForOffset(targetPosition) + textViewLayout.getLineBounds(targetLineOffset, parentTextViewRect) + + val parentTextViewLocation = intArrayOf(0, 0) + aztecText.getLocationOnScreen(parentTextViewLocation) + val parentTextViewTopAndBottomOffset = aztecText.scrollY + aztecText.compoundPaddingTop + + val adapter = adapters[type]!! + val windowWidth = parentTextViewRect.right - parentTextViewRect.left - EDITOR_INNER_PADDING + val height = adapter.calculateHeight(attrs, windowWidth) + parentTextViewRect.top += parentTextViewTopAndBottomOffset + parentTextViewRect.bottom = parentTextViewRect.top + height + + var box = container.findViewWithTag(uuid)?.apply { + id = uuid.hashCode() + } + val newWidth = adapter.calculateWidth(attrs, windowWidth) - EDITOR_INNER_PADDING + val newHeight = height - EDITOR_INNER_PADDING + val padding = 10 + val newLeftPadding = parentTextViewRect.left + padding + aztecText.paddingStart + val newTopPadding = parentTextViewRect.top + padding + box?.let { existingView -> + val currentParams = existingView.layoutParams as FrameLayout.LayoutParams + val widthSame = currentParams.width == newWidth + val heightSame = currentParams.height == newHeight + val topMarginSame = currentParams.topMargin == newTopPadding + val leftMarginSame = currentParams.leftMargin == newLeftPadding + if (widthSame && heightSame && topMarginSame && leftMarginSame) { + return + } + container.removeView(box) + positionToIdMutex.withLock { + positionToId.removeAll { + it.uuid == uuid + } + } + } + box = adapter.createView(container.context, uuid, attrs) + + box.id = uuid.hashCode() + box.setBackgroundColor(Color.TRANSPARENT) + box.setOnTouchListener(adapter) + box.tag = uuid + box.layoutParams = FrameLayout.LayoutParams( + newWidth, + newHeight + ).apply { + leftMargin = newLeftPadding + topMargin = newTopPadding + } + + positionToIdMutex.withLock { + positionToId.add(Placeholder(targetPosition, uuid)) + } + if (box.parent == null) { + container.addView(box) + } + adapter.onViewCreated(box, uuid) + } + + private fun validateAttributes(attributes: AztecAttributes): Boolean { + return attributes.hasAttribute(UUID_ATTRIBUTE) && + attributes.hasAttribute(TYPE_ATTRIBUTE) && + adapters[attributes.getValue(TYPE_ATTRIBUTE)] != null + } + + private fun getAttributesForMedia(type: String, attributes: Array>): AztecAttributes { + val attrs = AztecAttributes() + attrs.setValue(UUID_ATTRIBUTE, generateUuid()) + attrs.setValue(TYPE_ATTRIBUTE, type) + attributes.forEach { + attrs.setValue(it.first, it.second) + } + return attrs + } + + /** + * Called when the aztec text content changes. + */ + override fun onContentChanged() { + launch { + reloadAllPlaceholders() + } + } + + /** + * Called when any media is deleted. We use this method to remove the custom views if the placeholder is deleted. + */ + override fun onMediaDeleted(attrs: AztecAttributes) { + if (validateAttributes(attrs)) { + val uuid = attrs.getValue(UUID_ATTRIBUTE) + val adapter = adapters[attrs.getValue(TYPE_ATTRIBUTE)] + adapter?.onPlaceholderDeleted(uuid) + launch { + positionToIdMutex.withLock { + positionToId.removeAll { it.uuid == uuid } + } + } + container.findViewWithTag(uuid)?.let { + it.visibility = View.GONE + container.removeView(it) + } + } + } + + /** + * Called before media is deleted. There is a delay between user deleting a media and when the media is actually is + * confirmed. That's why we first hide the media and we delete it when `onMediaDeleted` is actually called. + */ + override fun beforeMediaDeleted(attrs: AztecAttributes) { + if (validateAttributes(attrs)) { + val uuid = attrs.getValue(UUID_ATTRIBUTE) + container.findViewWithTag(uuid)?.let { + it.visibility = View.GONE + } + } + } + + override fun canHandleTag(tag: String): Boolean { + return tag == htmlTag + } + + /** + * This method handled a `placeholder` tag found in the HTML. It creates a placeholder and inserts a view over it. + */ + override fun handleTag( + opening: Boolean, + tag: String, + output: Editable, + attributes: Attributes, + nestingLevel: Int + ): Boolean { + if (opening) { + val type = attributes.getValue(TYPE_ATTRIBUTE) + attributes.getValue(UUID_ATTRIBUTE)?.also { uuid -> + container.findViewWithTag(uuid)?.let { + it.visibility = View.GONE + container.removeView(it) + } + } + val adapter = adapters[type] ?: return false + val aztecAttributes = AztecAttributes(attributes) + aztecAttributes.setValue(UUID_ATTRIBUTE, generateUuid()) + val drawable = runBlocking { buildPlaceholderDrawable(adapter, aztecAttributes) } + val span = AztecPlaceholderSpan( + drawable = drawable, + nestingLevel = nestingLevel, + attributes = aztecAttributes, + onMediaDeletedListener = this, + adapter = WeakReference(adapter), + TAG = htmlTag + ) + val clickableSpan = AztecMediaClickableSpan(span) + val position = output.length + output.setSpan(span, position, position, Spanned.SPAN_MARK_MARK) + output.setSpan(clickableSpan, position, position, Spanned.SPAN_MARK_MARK) + output.append(Constants.IMG_CHAR) + output.setSpan(clickableSpan, position, output.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + output.setSpan(span, position, output.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + span.applyInlineStyleAttributes(output, position, output.length) + } + return tag == htmlTag + } + + override fun mediaLoadingStarted() { + val spans = aztecText.editableText.getSpans(0, aztecText.editableText.length, AztecPlaceholderSpan::class.java) + + if (spans == null || spans.isEmpty()) { + return + } + aztecText.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener) + aztecText.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener) + } + + private val globalLayoutListener = object : ViewTreeObserver.OnGlobalLayoutListener { + private var job: Job? = null + override fun onGlobalLayout() { + if (job?.isActive == true) { + return + } + aztecText.viewTreeObserver.removeOnGlobalLayoutListener(this) + job = redrawViews() + } + } + + fun redrawViews(): Job? { + val spans = aztecText.editableText.getSpans( + 0, + aztecText.editableText.length, + AztecPlaceholderSpan::class.java + ) + + if (spans == null || spans.isEmpty()) { + return null + } + return launch { + clearAllViews() + spans.forEach { + val type = it.attributes.getValue(TYPE_ATTRIBUTE) + val adapter = adapters[type] ?: return@forEach + it.drawable = buildPlaceholderDrawable(adapter, it.attributes) + aztecText.refreshText(false) + insertInPosition(it.attributes, aztecText.editableText.getSpanStart(it)) + } + } + } + + private suspend fun clearAllViews() { + positionToIdMutex.withLock { + for (placeholder in positionToId) { + container.findViewWithTag(placeholder.uuid)?.let { + it.visibility = View.GONE + container.removeView(it) + } + } + positionToId.clear() + } + } + + override fun onVisibility(visibility: Int) { + launch { + positionToIdMutex.withLock { + for (placeholder in positionToId) { + container.findViewWithTag(placeholder.uuid)?.visibility = visibility + } + } + } + } + + data class Placeholder(val elementPosition: Int, val uuid: String) + + companion object { + private const val TAG = "PlaceholderManager" + private const val DEFAULT_HTML_TAG = "placeholder" + private const val UUID_ATTRIBUTE = "uuid" + private const val TYPE_ATTRIBUTE = "type" + private const val EDITOR_INNER_PADDING = 20 + } + + override fun beforeHtmlProcessed(source: String): String { + runBlocking { + clearAllViews() + } + return source + } +} diff --git a/media-placeholders/src/test/java/org/wordpress/aztec/placeholders/PlaceholderTest.kt b/media-placeholders/src/test/java/org/wordpress/aztec/placeholders/PlaceholderTest.kt index a1efec8e3..f8c17d7c2 100644 --- a/media-placeholders/src/test/java/org/wordpress/aztec/placeholders/PlaceholderTest.kt +++ b/media-placeholders/src/test/java/org/wordpress/aztec/placeholders/PlaceholderTest.kt @@ -25,7 +25,7 @@ class PlaceholderTest { lateinit var menuListUnordered: MenuItem lateinit var sourceText: SourceViewEditText lateinit var toolbar: AztecToolbar - lateinit var placeholderManager: PlaceholderManager + lateinit var placeholderManager: ViewPlaceholderManager private val uuid1: String = "uuid1" private val uuid2: String = "uuid2" @@ -40,7 +40,7 @@ class PlaceholderTest { editText = AztecText(activity) container.addView(editText, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)) var counter = 0 - placeholderManager = PlaceholderManager(editText, container, generateUuid = { + placeholderManager = ViewPlaceholderManager(editText, container, generateUuid = { listOf(uuid1, uuid2)[counter++] }) placeholderManager.registerAdapter(ImageWithCaptionAdapter())