anytype-kotlin-wild/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/editor/TextBlockHolder.kt

489 lines
17 KiB
Kotlin

package com.anytypeio.anytype.core_ui.features.editor
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.Spannable
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.widget.TextView
import androidx.annotation.CallSuper
import com.anytypeio.anytype.core_models.ThemeColor
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.core_ui.common.CheckedCheckboxColorSpan
import com.anytypeio.anytype.core_ui.common.GhostEditorSelectionSpan
import com.anytypeio.anytype.core_ui.common.SearchHighlightSpan
import com.anytypeio.anytype.core_ui.common.SearchTargetHighlightSpan
import com.anytypeio.anytype.core_ui.common.setMarkup
import com.anytypeio.anytype.core_ui.common.toSpannable
import com.anytypeio.anytype.core_ui.extensions.applyMovementMethod
import com.anytypeio.anytype.core_ui.extensions.cursorYBottomCoordinate
import com.anytypeio.anytype.core_ui.extensions.resolveThemedTextColor
import com.anytypeio.anytype.core_ui.features.editor.holders.`interface`.TextHolder
import com.anytypeio.anytype.core_ui.tools.DefaultSpannableFactory
import com.anytypeio.anytype.core_ui.tools.MentionTextWatcher
import com.anytypeio.anytype.core_ui.tools.SlashTextWatcher
import com.anytypeio.anytype.core_ui.tools.SlashTextWatcherState
import com.anytypeio.anytype.core_ui.widgets.text.MentionSpan
import com.anytypeio.anytype.core_utils.clipboard.parseUrlFromClipboard
import com.anytypeio.anytype.core_utils.ext.removeSpans
import com.anytypeio.anytype.presentation.editor.editor.Markup
import com.anytypeio.anytype.presentation.editor.editor.listener.ListenerType
import com.anytypeio.anytype.presentation.editor.editor.mention.MentionEvent
import com.anytypeio.anytype.presentation.editor.editor.model.BlockView
import com.anytypeio.anytype.presentation.editor.editor.model.Checkable
import com.anytypeio.anytype.presentation.editor.editor.slash.SlashEvent
import timber.log.Timber
/**
* Provides contract and default implementation for text blocks' common behavior.
* @see [BlockView.Text]
*/
interface TextBlockHolder : TextHolder {
fun getDefaultTextColor(): Int
fun setup() {
with(content) {
setSpannableFactory(DefaultSpannableFactory())
setupCustomInsertionActionMode()
}
}
fun setupTableCell() {
with(content) {
setSpannableFactory(DefaultSpannableFactory())
setupCustomInsertionActionMode(isWithBookmark = false)
}
}
fun setBlockText(
text: String,
markup: Markup,
clicked: (ListenerType) -> Unit,
textColor: Int
) {
content.applyMovementMethod(markup)
when (markup.marks.isEmpty()) {
true -> content.setText(text)
false -> setBlockSpannableText(markup, clicked, textColor)
}
}
private fun setBlockSpannableText(
markup: Markup,
clicked: (ListenerType) -> Unit,
textColor: Int
) {
when (markup.marks.any { it is Markup.Mark.Mention || it is Markup.Mark.Object }) {
true -> setSpannableWithMention(markup, clicked, textColor)
false -> setSpannable(markup, textColor)
}
}
private fun setSpannable(markup: Markup, textColor: Int) {
content.setText(
markup.toSpannable(
textColor = textColor,
context = content.context,
underlineHeight = getUnderlineHeight()
),
TextView.BufferType.SPANNABLE
)
}
fun getMentionIconSize(): Int
fun getMentionIconPadding(): Int
fun getMentionCheckedIcon(): Drawable?
fun getMentionUncheckedIcon(): Drawable?
fun getMentionInitialsSize(): Float
private fun setSpannableWithMention(
markup: Markup,
clicked: (ListenerType) -> Unit,
textColor: Int
) {
content.dismissMentionWatchers()
with(content) {
setText(
markup.toSpannable(
textColor = textColor,
context = context,
mentionImageSize = getMentionIconSize(),
mentionImagePadding = getMentionIconPadding(),
mentionCheckedIcon = getMentionCheckedIcon(),
mentionUncheckedIcon = getMentionUncheckedIcon(),
click = { clicked(ListenerType.Mention(it)) },
onImageReady = { param -> refreshMentionSpan(param) },
mentionInitialsSize = getMentionInitialsSize(),
underlineHeight = getUnderlineHeight()
),
TextView.BufferType.SPANNABLE
)
}
}
@Deprecated("Pre-nested-styling legacy.")
fun setBackgroundColor(background: ThemeColor = ThemeColor.DEFAULT) {
// Do nothing.
}
fun setMarkup(markup: Markup, clicked: (ListenerType) -> Unit, textColor: Int) {
content.applyMovementMethod(markup)
with(content) {
text?.setMarkup(
markup = markup,
context = context,
mentionImageSize = getMentionIconSize(),
mentionImagePadding = getMentionIconPadding(),
click = { clicked(ListenerType.Mention(it)) },
onImageReady = { param -> refreshMentionSpan(param) },
textColor = textColor,
mentionCheckedIcon = getMentionCheckedIcon(),
mentionUncheckedIcon = getMentionUncheckedIcon(),
mentionInitialsSize = getMentionInitialsSize(),
underlineHeight = getUnderlineHeight()
)
}
}
fun applyCheckedCheckboxColorSpan(isChecked: Boolean) {
content.editableText.removeSpans<CheckedCheckboxColorSpan>()
if (isChecked) {
content.editableText.setSpan(
CheckedCheckboxColorSpan(),
0,
content.editableText.length,
Spannable.SPAN_INCLUSIVE_INCLUSIVE
)
}
}
fun applySearchHighlight(item: BlockView.Searchable) {
content.editableText.removeSpans<SearchHighlightSpan>()
content.editableText.removeSpans<SearchTargetHighlightSpan>()
item.searchFields.forEach { field ->
field.highlights.forEach { highlight ->
content.editableText.setSpan(
SearchHighlightSpan(),
highlight.first,
highlight.last,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
if (field.isTargeted) {
content.editableText.setSpan(
SearchTargetHighlightSpan(),
field.target.first,
field.target.last,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
fun applyGhostEditorSelection(item: BlockView.SupportGhostEditorSelection) {
content.editableText.removeSpans<GhostEditorSelectionSpan>()
item.ghostEditorSelection?.let { range ->
content.editableText.setSpan(
GhostEditorSelectionSpan(),
range.first,
range.last,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
fun refreshMentionSpan(param: String) {
content.text?.let { editable ->
val spans = editable.getSpans(
0,
editable.length,
MentionSpan::class.java
)
spans.forEach { span ->
if (span.param == param) {
editable.setSpan(
span,
editable.getSpanStart(span),
editable.getSpanEnd(span),
Markup.MENTION_SPANNABLE_FLAG
)
}
}
}
}
fun setupMentionWatcher(
onMentionEvent: ((MentionEvent) -> Unit),
itemProvider: () -> BlockView.Text?
) {
content.addTextChangedListener(
MentionTextWatcher { state ->
itemProvider().performInEditMode { item ->
when (state) {
is MentionTextWatcher.MentionTextWatcherState.Start -> {
onMentionEvent.invoke(
MentionEvent.MentionSuggestStart(
cursorCoordinate = content.cursorYBottomCoordinate(),
mentionStart = state.start
)
)
}
MentionTextWatcher.MentionTextWatcherState.Stop -> {
onMentionEvent.invoke(MentionEvent.MentionSuggestStop)
}
is MentionTextWatcher.MentionTextWatcherState.Text -> {
onMentionEvent.invoke(MentionEvent.MentionSuggestText(state.text))
}
}
}
}
)
}
fun setupSlashWatcher(
onSlashEvent: (SlashEvent) -> Unit,
itemProvider: () -> BlockView.Text?
) {
content.addTextChangedListener(
SlashTextWatcher { state ->
itemProvider().performInEditMode { item ->
when (state) {
is SlashTextWatcherState.Start -> onSlashEvent(
SlashEvent.Start(
slashStart = state.start,
cursorCoordinate = content.cursorYBottomCoordinate()
)
)
SlashTextWatcherState.Stop -> onSlashEvent(SlashEvent.Stop)
is SlashTextWatcherState.Filter -> onSlashEvent(
SlashEvent.Filter(
filter = state.text,
viewType = item.getViewType()
)
)
}
}
}
)
}
@CallSuper
fun processChangePayload(
payloads: List<BlockViewDiffUtil.Payload>,
item: BlockView,
clicked: (ListenerType) -> Unit,
) = payloads.forEach { payload ->
check(item is BlockView.Text)
Timber.d("Processing $payload for new view:\n$item")
if (payload.textChanged()) {
content.pauseSelectionWatcher {
content.pauseTextWatchers {
setBlockText(
text = item.text,
markup = item,
clicked = clicked,
textColor = resolveTextBlockThemedColor(item.color)
)
}
}
if (item is Checkable) {
applyCheckedCheckboxColorSpan(item.isChecked)
}
} else if (payload.markupChanged()) {
content.pauseTextWatchers {
setMarkup(item, clicked, resolveTextBlockThemedColor(item.color))
if (item is Checkable) {
applyCheckedCheckboxColorSpan(item.isChecked)
}
}
}
if (payload.isSearchHighlightChanged) {
applySearchHighlight(item)
}
if (payload.isGhostEditorSelectionChanged) {
applyGhostEditorSelection(item)
}
if (payload.textColorChanged()) {
setTextColor(resolveTextBlockThemedColor(item.color))
setMarkup(item, clicked, resolveTextBlockThemedColor(item.color))
}
if (payload.backgroundColorChanged()) {
setBackgroundColor(item.background)
}
if (payload.alignmentChanged()) {
item.alignment?.let { setAlignment(it) }
}
if (payload.readWriteModeChanged()) {
content.pauseTextWatchers {
if (item.mode == BlockView.Mode.EDIT) {
//content.selectionWatcher = { onSelectionChanged(item.id, it) }
content.pauseTextWatchers {
enableEditMode()
}
content.pauseTextWatchers {
content.applyMovementMethod(item)
}
} else {
enableReadMode()
}
}
}
if (payload.selectionChanged()) {
select(item)
}
if (payload.focusChanged()) {
setFocus(item)
}
try {
if (payload.isCursorChanged) {
item.cursor?.let {
content.setSelection(it)
}
}
} catch (e: Throwable) {
Timber.w(e, "Error while setting cursor from $item")
}
}
fun resolveTextBlockThemedColor(color: ThemeColor): Int {
return content.context.resolveThemedTextColor(color, getDefaultTextColor())
}
//region CONTEXT MENU
private fun setupCustomInsertionActionMode(isWithBookmark: Boolean = true) {
content.customInsertionActionModeCallback = object : ActionMode.Callback2() {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
return try {
if (getLink() != null) {
menu.addLink()
if (isWithBookmark) {
menu.add(0, R.id.menuBookmark, 3, R.string.bookmark)
}
menu.pasteToText()
}
true
} catch (e: Exception) {
false.also {
Timber.d(e, "Error while creating action mode")
}
}
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (getLink() != null) {
if (menu.findItem(android.R.id.textAssist) != null) {
menu.removeItem(android.R.id.textAssist)
return true
}
}
}
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menuBookmark -> {
content.clipboardInterceptor?.onBookmarkPasted(getLink().toString())
mode.finish()
true
}
R.id.menuLink -> {
val link = getLink().toString()
insertLinkContent(link)
content.clipboardInterceptor?.onLinkPasted(link)
mode.finish()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode) {}
}
content.customSelectionActionModeCallback = object : ActionMode.Callback2() {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
return try {
if (getLink() != null) {
menu.addLink()
menu.pasteToText()
}
return true
} catch (e: Exception) {
false.also {
Timber.d(e, "Error while creating action mode")
}
}
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menuLink -> {
content.clipboardInterceptor?.onLinkPasted(getLink().toString())
mode.finish()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode) {}
}
}
private fun getUnderlineHeight(): Float =
content.resources.getDimensionPixelSize(R.dimen.block_text_markup_underline_height)
.toFloat()
private fun Menu.addLink() {
add(
0,
R.id.menuLink,
1,
content.resources.getString(R.string.paste_link)
)
}
private fun Menu.pasteToText() = findItem(android.R.id.paste)?.setTitle(R.string.text)
private fun getLink() = content.context.parseUrlFromClipboard()
private fun insertLinkContent(paste: String) {
content.text?.insert(content.selectionStart, paste)
content.setSelection(
content.selectionStart - paste.length,
content.selectionStart
)
}
//endregion
}
fun BlockView.Text?.performInEditMode(block: (BlockView.Text) -> Unit) {
this?.let { item ->
if (item.mode == BlockView.Mode.EDIT) {
block(item)
}
}
}