1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 13:57:10 +09:00

#574 Mentions suggester (#609)

* #574: mention suggests widget

* #574: add footer decorator

* #574: suggester widget

* #574: hide mention suggester widget

* #574: fixes

* #574: tests

* #574: show suggests

* #574: fix tests

* #574: marks adjust method

* #574: mention events

* #574: suggest widget fixes

* #574: render new mention

* #574: fixes

* #574: render mention

* #574: fixes + tests

* #574: create new page from suggester

* #574: suggester icon

* #574: design fixes

* #574: fixes

* #574: fixes

* #574: fixes

* #574: fix tests

* #574: fixes

* #574: fixes

* #574: fixes

* #574: fixes

* #574: fix position + empty emoji

* #574: cursor position

* #574: fix mention filter text

* #574: code style

* #574: fix test

* #574: pr fixes

* #574: start tests

* #574: fixes

* #574: tests off
This commit is contained in:
Konstantin Ivanov 2020-08-04 20:24:56 +03:00 committed by GitHub
parent badf961d5b
commit 832fc6d315
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1434 additions and 150 deletions

View file

@ -20,6 +20,7 @@ import com.agileburo.anytype.domain.icon.DocumentEmojiIconProvider
import com.agileburo.anytype.domain.misc.UrlBuilder
import com.agileburo.anytype.domain.page.*
import com.agileburo.anytype.domain.page.bookmark.SetupBookmark
import com.agileburo.anytype.domain.page.navigation.GetListPages
import com.agileburo.anytype.mocking.MockDataFactory
import com.agileburo.anytype.presentation.page.DocumentExternalEventReducer
import com.agileburo.anytype.presentation.page.Editor
@ -73,6 +74,8 @@ open class EditorTestSetup {
@Mock
lateinit var unlinkBlocks: UnlinkBlocks
@Mock
lateinit var getListPages: GetListPages
@Mock
lateinit var duplicateBlock: DuplicateBlock
@Mock
lateinit var updateTextStyle: UpdateTextStyle
@ -164,6 +167,7 @@ open class EditorTestSetup {
counter = Counter.Default(),
toggleStateHolder = ToggleStateHolder.Default()
),
getListPages = getListPages,
interactor = Orchestrator(
createBlock = createBlock,
splitBlock = splitBlock,

View file

@ -16,6 +16,7 @@ import com.agileburo.anytype.domain.icon.DocumentEmojiIconProvider
import com.agileburo.anytype.domain.misc.UrlBuilder
import com.agileburo.anytype.domain.page.*
import com.agileburo.anytype.domain.page.bookmark.SetupBookmark
import com.agileburo.anytype.domain.page.navigation.GetListPages
import com.agileburo.anytype.presentation.page.DocumentExternalEventReducer
import com.agileburo.anytype.presentation.page.Editor
import com.agileburo.anytype.presentation.page.PageViewModelFactory
@ -61,7 +62,8 @@ object PageModule {
urlBuilder: UrlBuilder,
renderer: DefaultBlockViewRenderer,
archiveDocument: ArchiveDocument,
interactor: Orchestrator
interactor: Orchestrator,
getListPages: GetListPages
): PageViewModelFactory = PageViewModelFactory(
openPage = openPage,
closePage = closePage,
@ -74,9 +76,15 @@ object PageModule {
urlBuilder = urlBuilder,
renderer = renderer,
archiveDocument = archiveDocument,
interactor = interactor
interactor = interactor,
getListPages = getListPages
)
@JvmStatic
@PerScreen
@Provides
fun getListPages(repo: BlockRepository): GetListPages = GetListPages(repo = repo)
@JvmStatic
@Provides
@PerScreen

View file

@ -4,6 +4,7 @@ import android.text.Editable
import android.text.Spanned
import com.agileburo.anytype.core_ui.common.Span
import com.agileburo.anytype.core_ui.common.ThemeColor
import com.agileburo.anytype.core_ui.widgets.text.MentionSpan
import com.agileburo.anytype.domain.block.model.Block.Content.Text.Mark
import com.agileburo.anytype.domain.ext.overlap
import com.agileburo.anytype.domain.misc.Overlap
@ -47,6 +48,11 @@ fun Editable.extractMarks(): List<Mark> = getSpans(0, length, Span::class.java).
type = Mark.Type.LINK,
param = span.url
)
is MentionSpan -> Mark(
range = getSpanStart(span)..getSpanEnd(span),
type = Mark.Type.MENTION,
param = span.param
)
else -> null
}
}

View file

@ -12,6 +12,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.animation.DecelerateInterpolator
import android.view.animation.LinearInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.Button
import android.widget.FrameLayout
@ -20,9 +21,13 @@ import android.widget.TextView
import androidx.activity.addCallback
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.animation.doOnEnd
import androidx.core.animation.doOnStart
import androidx.core.view.get
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
@ -30,6 +35,8 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.ChangeBounds
import androidx.transition.Fade
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import com.agileburo.anytype.BuildConfig
import com.agileburo.anytype.R
import com.agileburo.anytype.core_ui.common.Alignment
@ -50,10 +57,12 @@ import com.agileburo.anytype.core_ui.reactive.clicks
import com.agileburo.anytype.core_ui.state.ControlPanelState
import com.agileburo.anytype.core_ui.tools.ClipboardInterceptor
import com.agileburo.anytype.core_ui.tools.FirstItemInvisibilityDetector
import com.agileburo.anytype.core_ui.tools.MentionFooterItemDecorator
import com.agileburo.anytype.core_ui.tools.OutsideClickDetector
import com.agileburo.anytype.core_ui.widgets.ActionItemType
import com.agileburo.anytype.core_utils.common.EventWrapper
import com.agileburo.anytype.core_utils.ext.*
import com.agileburo.anytype.core_utils.ext.PopupExtensions.calculateRectInWindow
import com.agileburo.anytype.di.common.componentManager
import com.agileburo.anytype.domain.block.model.Block.Content.Text
import com.agileburo.anytype.domain.common.Id
@ -129,6 +138,10 @@ open class PageFragment :
)
}
private val footerMentionDecorator by lazy {
MentionFooterItemDecorator(screen = screen)
}
private val vm by lazy {
ViewModelProviders
.of(this, factory)
@ -198,7 +211,8 @@ open class PageFragment :
onTitleTextInputClicked = vm::onTitleTextInputClicked,
onClickListener = vm::onClickListener,
clipboardInterceptor = this,
anytypeContextMenuListener = anytypeContextMenuListener
anytypeContextMenuListener = anytypeContextMenuListener,
onMentionEvent = vm::onMentionEvent
)
}
@ -448,6 +462,11 @@ open class PageFragment :
vm.onBackButtonPressed()
}.launchIn(lifecycleScope)
mentionSuggesterToolbar.setupClicks(
mentionClick = vm::onMentionSuggestClick,
newPageClick = vm::onAddMentionNewPageClicked
)
lifecycleScope.launch {
styleToolbar.events.collect { event ->
when (event) {
@ -843,6 +862,60 @@ open class PageFragment :
recycler.updatePadding(bottom = dimen(R.dimen.default_toolbar_height))
}
}
state.mentionToolbar.apply {
if (isVisible) {
if (!mentionSuggesterToolbar.isVisible) {
showMentionToolbar(this)
}
if (updateList) {
mentionSuggesterToolbar.addItems(mentions)
}
mentionFilter?.let {
mentionSuggesterToolbar.updateFilter(it)
}
} else {
mentionSuggesterToolbar.invisible()
recycler.removeItemDecoration(footerMentionDecorator)
}
}
}
private fun showMentionToolbar(state: ControlPanelState.Toolbar.MentionToolbar) {
state.cursorCoordinate?.let { cursorCoordinate ->
val parentBottom = calculateRectInWindow(recycler).bottom
val toolbarHeight = mentionSuggesterToolbar.getMentionSuggesterWidgetMinHeight()
val minPosY = parentBottom - toolbarHeight
if (minPosY <= cursorCoordinate) {
val scrollY = (parentBottom - minPosY) - (parentBottom - cursorCoordinate)
recycler.addItemDecoration(footerMentionDecorator)
recycler.post {
recycler.smoothScrollBy(0, scrollY)
}
}
mentionSuggesterToolbar.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = toolbarHeight
}
val set = ConstraintSet().apply {
clone(sheet)
setVisibility(R.id.mentionSuggesterToolbar, View.VISIBLE)
connect(
R.id.mentionSuggesterToolbar,
ConstraintSet.BOTTOM,
R.id.sheet,
ConstraintSet.BOTTOM
)
}
val transitionSet = TransitionSet().apply {
addTransition(ChangeBounds())
duration = SHOW_MENTION_TRANSITION_DURATION
interpolator = LinearInterpolator()
ordering = TransitionSet.ORDERING_TOGETHER
}
TransitionManager.beginDelayedTransition(sheet, transitionSet)
set.applyTo(sheet)
}
}
private fun enterScrollAndMove() {
@ -1118,6 +1191,7 @@ open class PageFragment :
const val FAB_SHOW_ANIMATION_START_DELAY = 250L
const val FAB_SHOW_ANIMATION_DURATION = 100L
const val SHOW_MENTION_TRANSITION_DURATION = 150L
const val SELECT_BUTTON_SHOW_ANIMATION_DURATION = 200L
const val SELECT_BUTTON_HIDE_ANIMATION_DURATION = 200L
const val SELECT_BUTTON_ANIMATION_PROPERTY = "translationY"

View file

@ -101,6 +101,16 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.agileburo.anytype.core_ui.widgets.toolbar.MentionToolbar
android:id="@+id/mentionSuggesterToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.agileburo.anytype.core_ui.widgets.toolbar.PageBottomToolbar

View file

@ -134,7 +134,8 @@ fun Markup.toSpannable(
imagePadding = mentionImagePadding,
//todo Setting up default drawable, will be fixed in feature
mResourceId = R.drawable.ic_mention_deafult,
bitmap = null
bitmap = null,
param = mark.param
),
mark.from,
mark.to,
@ -152,7 +153,11 @@ fun Markup.toSpannable(
Markup.DEFAULT_SPANNABLE_FLAG
)
} else {
Timber.e("Get Mention span without param!")
if (context == null) {
Timber.e("Context for MentionSpan is null!")
} else {
Timber.e("Get MentionSpan without param!")
}
}
}
}

View file

@ -3,10 +3,14 @@ package com.agileburo.anytype.core_ui.extensions
import android.content.Context
import android.content.res.ColorStateList
import android.view.View
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import com.agileburo.anytype.core_ui.R
import com.agileburo.anytype.core_ui.widgets.text.TextInputWidget
import com.agileburo.anytype.core_utils.ext.PopupExtensions
import com.agileburo.anytype.core_utils.ext.PopupExtensions.calculateRectInWindow
fun Context.toast(
msg: CharSequence,
@ -42,4 +46,16 @@ fun LinearLayout.addVerticalDivider(
}
)
fun EditText.cursorYBottomCoordinate(): Int {
with(this.layout) {
val pos = selectionStart
val line = getLineForOffset(pos)
val baseLine = getLineBaseline(line)
val ascent = getLineAscent(line)
val rect = calculateRectInWindow(this@cursorYBottomCoordinate)
return baseLine + ascent + rect.bottom - scrollY
}
}
fun TextView.range() : IntRange = selectionStart..selectionEnd

View file

@ -74,7 +74,8 @@ class BlockAdapter(
private val onToggleClicked: (String) -> Unit,
private val onMarkupActionClicked: (Markup.Type, IntRange) -> Unit,
private val clipboardInterceptor: ClipboardInterceptor,
private val anytypeContextMenuListener: ((AnytypeContextMenuEvent) -> Unit)? = null
private val anytypeContextMenuListener: ((AnytypeContextMenuEvent) -> Unit)? = null,
private val onMentionEvent: (MentionEvent) -> Unit
) : RecyclerView.Adapter<BlockViewHolder>() {
val views: List<BlockView> get() = blocks
@ -414,7 +415,8 @@ class BlockAdapter(
payloads = payloads.typeOf(),
item = blocks[position],
onTextChanged = onParagraphTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
clicked = onClickListener
)
}
is BlockViewHolder.Bulleted -> {
@ -422,7 +424,8 @@ class BlockAdapter(
payloads = payloads.typeOf(),
item = blocks[position],
onTextChanged = onParagraphTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
clicked = onClickListener
)
}
is BlockViewHolder.Checkbox -> {
@ -430,7 +433,8 @@ class BlockAdapter(
payloads = payloads.typeOf(),
item = blocks[position],
onTextChanged = onParagraphTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
clicked = onClickListener
)
}
is BlockViewHolder.Title -> {
@ -450,7 +454,8 @@ class BlockAdapter(
payloads = payloads.typeOf(),
item = blocks[position],
onTextChanged = onParagraphTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
clicked = onClickListener
)
}
is BlockViewHolder.HeaderOne -> {
@ -458,7 +463,8 @@ class BlockAdapter(
payloads = payloads.typeOf(),
item = blocks[position],
onTextChanged = onParagraphTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
clicked = onClickListener
)
}
is BlockViewHolder.HeaderTwo -> {
@ -466,7 +472,8 @@ class BlockAdapter(
payloads = payloads.typeOf(),
item = blocks[position],
onTextChanged = onParagraphTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
clicked = onClickListener
)
}
is BlockViewHolder.HeaderThree -> {
@ -474,7 +481,8 @@ class BlockAdapter(
payloads = payloads.typeOf(),
item = blocks[position],
onTextChanged = onParagraphTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
clicked = onClickListener
)
}
is BlockViewHolder.Toggle -> {
@ -482,7 +490,8 @@ class BlockAdapter(
payloads = payloads.typeOf(),
item = blocks[position],
onTextChanged = onParagraphTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
clicked = onClickListener
)
}
is BlockViewHolder.Highlight -> {
@ -490,7 +499,8 @@ class BlockAdapter(
payloads = payloads.typeOf(),
item = blocks[position],
onTextChanged = onParagraphTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
clicked = onClickListener
)
}
is BlockViewHolder.File -> {
@ -594,7 +604,8 @@ class BlockAdapter(
payloads = payloads.typeOf(),
item = blocks[position],
onTextChanged = onParagraphTextChanged,
onSelectionChanged = onSelectionChanged
onSelectionChanged = onSelectionChanged,
clicked = onClickListener
)
}
else -> throw IllegalStateException("Unexpected view holder: $holder")
@ -610,7 +621,8 @@ class BlockAdapter(
onTextChanged = onParagraphTextChanged,
onSelectionChanged = onSelectionChanged,
onFocusChanged = onFocusChanged,
clicked = onClickListener
clicked = onClickListener,
onMentionEvent = onMentionEvent
)
}
is BlockViewHolder.Title -> {

View file

@ -107,7 +107,8 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
onTextChanged: (String, Editable) -> Unit,
onSelectionChanged: (String, IntRange) -> Unit,
onFocusChanged: (String, Boolean) -> Unit,
clicked: (ListenerType) -> Unit
clicked: (ListenerType) -> Unit,
onMentionEvent: (MentionEvent) -> Unit
) {
indentize(item)
@ -141,7 +142,11 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
setFocus(item)
if (item.isFocused) setCursor(item)
setupTextWatcher(onTextChanged, item)
setupTextWatcher(
onTextChanged = onTextChanged,
onMentionEvent = onMentionEvent,
item = item
)
content.setOnFocusChangeListener { _, focused ->
item.isFocused = focused
@ -1116,9 +1121,10 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
payloads: List<Payload>,
item: BlockView,
onTextChanged: (String, Editable) -> Unit,
onSelectionChanged: (String, IntRange) -> Unit
onSelectionChanged: (String, IntRange) -> Unit,
clicked: (ListenerType) -> Unit
) {
super.processChangePayload(payloads, item, onTextChanged, onSelectionChanged)
super.processChangePayload(payloads, item, onTextChanged, onSelectionChanged, clicked)
payloads.forEach { payload ->
if (payload.changes.contains(NUMBER_CHANGED))
number.text = "${(item as BlockView.Numbered).number}"
@ -1275,10 +1281,11 @@ sealed class BlockViewHolder(view: View) : RecyclerView.ViewHolder(view) {
payloads: List<Payload>,
item: BlockView,
onTextChanged: (String, Editable) -> Unit,
onSelectionChanged: (String, IntRange) -> Unit
onSelectionChanged: (String, IntRange) -> Unit,
clicked: (ListenerType) -> Unit
) {
check(item is BlockView.Toggle) { "Expected a toggle block, but was: $item" }
super.processChangePayload(payloads, item, onTextChanged, onSelectionChanged)
super.processChangePayload(payloads, item, onTextChanged, onSelectionChanged, clicked)
payloads.forEach { payload ->
if (payload.changes.contains(TOGGLE_EMPTY_STATE_CHANGED))
placeholder.isVisible = item.isEmpty

View file

@ -2,6 +2,6 @@ package com.agileburo.anytype.core_ui.features.page
sealed class MentionEvent {
data class MentionSuggestText(val text: CharSequence) : MentionEvent()
object MentionSuggestStart : MentionEvent()
data class MentionSuggestStart(val cursorCoordinate : Int, val mentionStart: Int) : MentionEvent()
object MentionSuggestStop : MentionEvent()
}

View file

@ -7,6 +7,7 @@ import android.view.View
import android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT
import android.widget.TextView
import com.agileburo.anytype.core_ui.common.*
import com.agileburo.anytype.core_ui.extensions.cursorYBottomCoordinate
import com.agileburo.anytype.core_ui.extensions.preserveSelection
import com.agileburo.anytype.core_ui.extensions.range
import com.agileburo.anytype.core_ui.menu.AnytypeContextMenuEvent
@ -71,6 +72,7 @@ interface TextHolder {
fun getMentionImageSizeAndPadding(): Pair<Int, Int>
private fun setSpannableWithMention(markup: Markup, clicked: (ListenerType) -> Unit) {
content.dismissMentionWatchers()
content.movementMethod = LinkMovementMethod.getInstance()
with(content) {
val sizes = getMentionImageSizeAndPadding()
@ -161,7 +163,12 @@ interface TextHolder {
}
fun setCursor(item: BlockView.Cursor) {
item.cursor?.let { content.setSelection(it) }
item.cursor?.let {
val length = content.text?.length ?: 0
if (it in 0..length) {
content.setSelection(it)
}
}
}
fun setAlignment(alignment: Alignment) {
@ -181,7 +188,8 @@ interface TextHolder {
fun setupTextWatcher(
onTextChanged: (String, Editable) -> Unit,
item: BlockView
item: BlockView,
onMentionEvent: ((MentionEvent) -> Unit)? = null
) {
content.addTextChangedListener(
DefaultTextWatcher { text ->
@ -189,8 +197,22 @@ interface TextHolder {
}
)
content.addTextChangedListener(
MentionTextWatcher{
Timber.d("get mention event : $it")
MentionTextWatcher{state ->
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))
}
}
}
)
}
@ -215,7 +237,8 @@ interface TextHolder {
payloads: List<BlockViewDiffUtil.Payload>,
item: BlockView,
onTextChanged: (String, Editable) -> Unit,
onSelectionChanged: (String, IntRange) -> Unit
onSelectionChanged: (String, IntRange) -> Unit,
clicked: (ListenerType) -> Unit
) = payloads.forEach { payload ->
Timber.d("Processing $payload for new view:\n$item")
@ -224,10 +247,19 @@ interface TextHolder {
if (payload.textChanged()) {
content.pauseTextWatchers {
if (item is Markup)
content.setText(item.toSpannable(), TextView.BufferType.SPANNABLE)
else
content.setText(item.text)
when (item) {
is BlockView.Paragraph -> {
setBlockText(text = item.text, markup = item, clicked = clicked)
}
else -> {
if (item is Markup)
content.setText(item.toSpannable(), TextView.BufferType.SPANNABLE)
else
content.setText(item.text)
}
}
}
} else if (payload.markupChanged()) {
if (item is Markup) setMarkup(item)

View file

@ -4,6 +4,7 @@ import com.agileburo.anytype.core_ui.common.Alignment
import com.agileburo.anytype.core_ui.common.Markup
import com.agileburo.anytype.core_ui.features.page.styling.StylingMode
import com.agileburo.anytype.core_ui.features.page.styling.StylingType
import com.agileburo.anytype.core_ui.widgets.toolbar.adapter.Mention
/**
* Control panels are UI-elements that allow user to interact with blocks on a page.
@ -16,7 +17,8 @@ data class ControlPanelState(
val focus: Focus? = null,
val mainToolbar: Toolbar.Main,
val stylingToolbar: Toolbar.Styling,
val multiSelect: Toolbar.MultiSelect
val multiSelect: Toolbar.MultiSelect,
val mentionToolbar: Toolbar.MentionToolbar
) {
sealed class Toolbar {
@ -103,6 +105,23 @@ data class ControlPanelState(
val isScrollAndMoveEnabled: Boolean = false,
val count: Int = 0
) : Toolbar()
/**
* Toolbar with list of mentions and add new page item.
* @property isVisible defines whether the toolbar is visible or not
* @property mentionFrom first position of the mentionFilter in text
* @property mentionFilter sequence of symbol @ and characters, using for filtering mentions
* @property cursorCoordinate y coordinate bottom of the cursor, using for define top border of the toolbar
* @property mentions list of all mentions
*/
data class MentionToolbar(
override val isVisible: Boolean,
val mentionFrom: Int?,
val mentionFilter: String?,
val cursorCoordinate: Int?,
val updateList: Boolean = false,
val mentions: List<Mention> = emptyList()
) : Toolbar()
}
/**
@ -136,6 +155,14 @@ data class ControlPanelState(
type = null,
mode = null
),
mentionToolbar = Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
updateList = false,
mentionFrom = null,
mentions = emptyList()
),
focus = null
)
}

View file

@ -0,0 +1,24 @@
package com.agileburo.anytype.core_ui.tools
import android.graphics.Point
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class MentionFooterItemDecorator(private val screen: Point) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
parent.adapter?.itemCount?.let { size ->
when (parent.getChildAdapterPosition(view)) {
size - 1 -> {
outRect.bottom = screen.y / 2
}
}
}
}
}

View file

@ -28,18 +28,11 @@ object MentionHelper {
mentionPosition != NO_MENTION_POSITION && start <= mentionPosition && after < count
/**
* return subsequence from [startIndex] to [predicate] or end of sequence with limit [takeNumber]
* return subsequence from [startIndex] to end of sequence with limit [takeNumber]
*/
fun getSubSequenceBeforePredicate(
fun getSubSequenceFromStartWithLimit(
s: CharSequence,
predicate: Char,
startIndex: Int,
takeNumber: Int
): CharSequence =
s.indexOf(predicate, startIndex = startIndex).let { pos ->
if (pos != -1)
s.subSequence(startIndex = startIndex, endIndex = pos).take(takeNumber)
else
s.subSequence(startIndex = startIndex, endIndex = s.length).take(takeNumber)
}
): CharSequence = s.subSequence(startIndex = startIndex, endIndex = s.length).take(takeNumber)
}

View file

@ -3,7 +3,7 @@ package com.agileburo.anytype.core_ui.tools
import android.text.Editable
import android.text.TextWatcher
import com.agileburo.anytype.core_ui.features.page.MentionEvent
import com.agileburo.anytype.core_ui.tools.MentionHelper.getSubSequenceBeforePredicate
import com.agileburo.anytype.core_ui.tools.MentionHelper.getSubSequenceFromStartWithLimit
import com.agileburo.anytype.core_ui.tools.MentionHelper.isMentionDeleted
import com.agileburo.anytype.core_ui.tools.MentionHelper.isMentionSuggestTriggered
import timber.log.Timber
@ -32,7 +32,7 @@ class DefaultTextWatcher(val onTextChanged: (Editable) -> Unit) : TextWatcher {
}
class MentionTextWatcher(
private val onMentionEvent: (MentionEvent) -> Unit
private val onMentionEvent: (MentionTextWatcherState) -> Unit
) : TextWatcher {
private var mentionCharPosition = NO_MENTION_POSITION
@ -47,6 +47,11 @@ class MentionTextWatcher(
interceptMentionTriggered(text = s, start = start, count = count)
}
fun onDismiss() {
Timber.d("Dismiss Mention Watcher $this")
mentionCharPosition = NO_MENTION_POSITION
}
/**
* If char @ on position [mentionCharPosition] was deleted then send MentionStop event
*/
@ -58,34 +63,40 @@ class MentionTextWatcher(
mentionPosition = mentionCharPosition
)
) {
Timber.d("interceptMentionDeleted $this")
mentionCharPosition = NO_MENTION_POSITION
onMentionEvent(MentionEvent.MentionSuggestStop)
onMentionEvent(MentionTextWatcherState.Stop)
}
}
/**
* Check for new added char @ and start [MentionEvent.MentionSuggestStart] event
* Send all text in range [mentionCharPosition]..[first space or text end] with limit [MENTION_SNIPPET_MAX_LENGTH]
* Send all text in range [mentionCharPosition]..[text end] with limit [MENTION_SNIPPET_MAX_LENGTH]
*/
private fun interceptMentionTriggered(text: CharSequence, start: Int, count: Int) {
if (isMentionSuggestTriggered(text, start, count)) {
Timber.d("interceptMentionStarted text:$text, start:$start")
mentionCharPosition = start
onMentionEvent(MentionEvent.MentionSuggestStart)
onMentionEvent(MentionTextWatcherState.Start(mentionCharPosition))
}
if (mentionCharPosition != NO_MENTION_POSITION) {
onMentionEvent(
MentionEvent.MentionSuggestText(
getSubSequenceBeforePredicate(
s = text,
startIndex = mentionCharPosition,
takeNumber = MENTION_SNIPPET_MAX_LENGTH,
predicate = SPACE_CHAR
)
)
)
getSubSequenceFromStartWithLimit(
s = text,
startIndex = mentionCharPosition,
takeNumber = MENTION_SNIPPET_MAX_LENGTH
).let { subSequence ->
onMentionEvent(MentionTextWatcherState.Text(subSequence))
Timber.d("interceptMentionText text:$text, subSequence:$subSequence")
}
}
}
sealed class MentionTextWatcherState {
data class Start(val start: Int) : MentionTextWatcherState()
object Stop : MentionTextWatcherState()
data class Text(val text: CharSequence) : MentionTextWatcherState()
}
companion object {
const val SPACE_CHAR = ' '
const val MENTION_CHAR = '@'

View file

@ -8,6 +8,7 @@ import android.graphics.Rect
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.text.style.DynamicDrawableSpan
import com.agileburo.anytype.core_ui.common.Span
import java.lang.ref.WeakReference
class MentionSpan constructor(
@ -15,8 +16,9 @@ class MentionSpan constructor(
private var mResourceId: Int = 0,
private var bitmap: Bitmap? = null,
private var imageSize: Int,
private var imagePadding: Int
) : DynamicDrawableSpan() {
private var imagePadding: Int,
val param: String
) : DynamicDrawableSpan(), Span {
private val endPaddingPx = 4
private var mDrawable: Drawable? = null

View file

@ -14,6 +14,7 @@ import androidx.appcompat.widget.AppCompatEditText
import androidx.core.graphics.withTranslation
import com.agileburo.anytype.core_ui.tools.ClipboardInterceptor
import com.agileburo.anytype.core_ui.tools.DefaultTextWatcher
import com.agileburo.anytype.core_ui.tools.MentionTextWatcher
import com.agileburo.anytype.core_ui.widgets.text.highlight.HighlightAttributeReader
import com.agileburo.anytype.core_ui.widgets.text.highlight.HighlightDrawer
import com.agileburo.anytype.core_utils.ext.multilineIme
@ -99,6 +100,10 @@ class TextInputWidget : AppCompatEditText {
watchers.clear()
}
fun dismissMentionWatchers() {
watchers.filterIsInstance(MentionTextWatcher::class.java).forEach { it.onDismiss() }
}
fun pauseTextWatchers(block: () -> Unit) = synchronized(this) {
lockTextWatchers()
block()

View file

@ -0,0 +1,68 @@
package com.agileburo.anytype.core_ui.widgets.toolbar
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.LinearLayoutManager
import com.agileburo.anytype.core_ui.R
import com.agileburo.anytype.core_ui.widgets.toolbar.adapter.Mention
import com.agileburo.anytype.core_ui.widgets.toolbar.adapter.MentionAdapter
import kotlinx.android.synthetic.main.widget_mention_menu.view.*
class MentionToolbar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private var mentionClick: ((Mention, String) -> Unit)? = null
private var newPageClick: (() -> Unit)? = null
init {
LayoutInflater
.from(context)
.inflate(R.layout.widget_mention_menu, this)
setup(context)
}
fun setupClicks(mentionClick: (Mention, String) -> Unit, newPageClick: () -> Unit) {
this.mentionClick = mentionClick
this.newPageClick = newPageClick
}
private fun setup(context: Context) {
with(recyclerView) {
val lm = LinearLayoutManager(context)
layoutManager = lm
adapter = MentionAdapter(
data = arrayListOf(),
clicked = { mention, filter ->
mentionClick?.invoke(mention, filter)
},
newClicked = {
newPageClick?.invoke()
}
)
}
}
fun addItems(items: List<Mention>) {
(recyclerView.adapter as? MentionAdapter)?.setData(items)
}
fun updateFilter(filter: String) {
(recyclerView.adapter as? MentionAdapter)?.updateFilter(filter)
}
fun getMentionSuggesterWidgetMinHeight() = with(context.resources) {
getDimensionPixelSize(R.dimen.mention_suggester_item_height) * MIN_VISIBLE_ITEMS +
getDimensionPixelSize(R.dimen.mention_list_padding_top) +
getDimensionPixelSize(R.dimen.mention_list_padding_bottom) +
getDimensionPixelSize(R.dimen.mention_divider_height)
}
companion object {
const val MIN_VISIBLE_ITEMS = 4
}
}

View file

@ -0,0 +1,139 @@
package com.agileburo.anytype.core_ui.widgets.toolbar.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.agileburo.anytype.core_ui.R
import com.agileburo.anytype.emojifier.Emojifier
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import kotlinx.android.synthetic.main.item_mention.view.*
class MentionAdapter(
private var data: ArrayList<Mention>,
private var mentionFilter: String = "",
private val clicked: (Mention, String) -> Unit,
private val newClicked: () -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
fun setData(mentions: List<Mention>) {
if (mentions.isEmpty()) {
data.clear()
} else {
data.clear()
data.addAll(mentions)
notifyDataSetChanged()
}
}
fun updateFilter(filter: String) {
mentionFilter = filter
notifyDataSetChanged()
}
/**
* Filter all mentions by mentionFilter without symbol @
*
*/
private fun getFilteredData(): List<Mention> =
data.filterBy(mentionFilter.removePrefix(MENTION_PREFIX))
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_NEW_PAGE -> NewPageViewHolder(
inflater.inflate(
R.layout.item_mention_new_page,
parent,
false
)
)
TYPE_MENTION -> MentionViewHolder(
inflater.inflate(
R.layout.item_mention,
parent,
false
)
)
else -> throw RuntimeException("Wrong viewType")
}
}
override fun getItemCount(): Int = getFilteredData().size + 1
override fun getItemViewType(position: Int): Int = when (position) {
POSITION_NEW_PAGE -> TYPE_NEW_PAGE
else -> TYPE_MENTION
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder.itemViewType) {
TYPE_NEW_PAGE -> (holder as NewPageViewHolder).bind(newClicked)
TYPE_MENTION -> (holder as MentionViewHolder).bind(
getFilteredData()[position - 1],
clicked,
mentionFilter
)
}
}
class NewPageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(newClicked: () -> Unit) {
itemView.setOnClickListener { newClicked() }
}
}
class MentionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val tvTitle: TextView = itemView.text
private val image: ImageView = itemView.image
fun bind(item: Mention, clicked: (Mention, String) -> Unit, filter: String) {
itemView.setOnClickListener { clicked(item, filter) }
tvTitle.text = item.title
when {
!item.emoji.isNullOrBlank() -> {
Glide
.with(image)
.load(Emojifier.uri(item.emoji))
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(image)
}
!item.image.isNullOrBlank() -> {
Glide
.with(image)
.load(item.image)
.centerInside()
.circleCrop()
.into(image)
}
else -> {
image.setImageResource(R.drawable.ic_block_empty_page)
}
}
}
}
companion object {
const val MENTION_PREFIX = "@"
const val POSITION_NEW_PAGE = 0
const val TYPE_NEW_PAGE = 1
const val TYPE_MENTION = 2
}
}
data class Mention(
val id: String,
val title: String,
val emoji: String?,
val image: String?
)
fun List<Mention>.filterBy(text: String): List<Mention> =
if (text.isNotEmpty()) filter { it.isContainsText(text) } else this
fun Mention.isContainsText(text: String): Boolean = title.contains(text, true)

View file

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,12m-9.25,-0a9.25,9.25 0,1 0,18.5 -0a9.25,9.25 0,1 0,-18.5 -0"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#ACA996"/>
<path
android:pathData="M7.75,12.75L16.25,12.75A0.75,0.75 0,0 0,17 12L17,12A0.75,0.75 0,0 0,16.25 11.25L7.75,11.25A0.75,0.75 0,0 0,7 12L7,12A0.75,0.75 0,0 0,7.75 12.75z"
android:fillColor="#ACA996"/>
<path
android:pathData="M11.25,7.75L11.25,16.25A0.75,0.75 0,0 0,12 17L12,17A0.75,0.75 0,0 0,12.75 16.25L12.75,7.75A0.75,0.75 0,0 0,12 7L12,7A0.75,0.75 0,0 0,11.25 7.75z"
android:fillColor="#ACA996"/>
</vector>

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="25dp"
android:viewportWidth="24"
android:viewportHeight="25">
<path
android:pathData="M13.5,2.625H12V8.125C12,9.2296 12.8954,10.125 14,10.125H19.5V8.625H14C13.7239,8.625 13.5,8.4011 13.5,8.125V2.625Z"
android:fillColor="#ACA996"/>
<path
android:pathData="M4.75,4.125C4.75,3.4346 5.3096,2.875 6,2.875H13.5858C13.6521,2.875 13.7157,2.9013 13.7626,2.9482L14.2894,2.4214L13.7626,2.9482L19.1768,8.3624C19.2237,8.4093 19.25,8.4729 19.25,8.5392V20.125C19.25,20.8154 18.6904,21.375 18,21.375H6C5.3096,21.375 4.75,20.8154 4.75,20.125V4.125Z"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#ACA996"/>
</vector>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="@dimen/mention_suggester_item_height">
<ImageView
android:id="@+id/image"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:contentDescription="@string/content_desc_mention_suggester_icon"
android:scaleType="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_add_block_below" />
<TextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:fontFamily="@font/inter_regular"
android:maxLines="1"
android:textColor="#2C2B27"
android:textSize="15sp"
tools:text="Is your feature request related to a problem? Please describe."
app:layout_constraintBottom_toBottomOf="@+id/image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/image"
app:layout_constraintTop_toTopOf="@+id/image" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="@dimen/mention_suggester_item_height">
<ImageView
android:id="@+id/image"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:contentDescription="@string/content_desc_mention_suggester_icon"
android:scaleType="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_add_block_below" />
<TextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:fontFamily="@font/inter_regular"
android:maxLines="1"
android:text="@string/mention_suggester_new_page"
android:textColor="#2C2B27"
android:textSize="15sp"
app:layout_constraintBottom_toBottomOf="@+id/image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/image"
app:layout_constraintTop_toTopOf="@+id/image"
tools:text="Create new page" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
android:clickable="true"
android:focusable="true">
<View
android:id="@+id/divider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:background="#DFDDD0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/divider"
tools:itemCount="3"
tools:listitem="@layout/item_mention" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -118,4 +118,10 @@
<dimen name="mention_span_image_padding_header_two">5dp</dimen>
<dimen name="mention_span_image_padding_header_one">6dp</dimen>
<dimen name="mention_suggester_item_height">40dp</dimen>
<dimen name="mention_list_padding_top">4dp</dimen>
<dimen name="mention_list_padding_bottom">5dp</dimen>
<dimen name="mention_suggester_padding_top">10dp</dimen>
<dimen name="mention_divider_height">1dp</dimen>
</resources>

View file

@ -215,6 +215,8 @@
<string name="content_desc_page_toolbar_search">Search pages</string>
<string name="content_desc_page_toolbar_navigation">Navigation through pages</string>
<string name="content_desc_page_toolbar_add_doc">Add new page</string>
<string name="content_desc_mention_suggester_icon">Mention suggester icon</string>
<string name="move">Move</string>
<string name="mention_suggester_new_page">Create new page</string>
</resources>

View file

@ -197,7 +197,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -249,7 +250,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -302,7 +304,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -365,7 +368,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -532,7 +536,8 @@ class BlockAdapterTest {
),
item = focused,
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -632,7 +637,8 @@ class BlockAdapterTest {
),
item = updated,
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -1527,7 +1533,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -1580,7 +1587,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -1843,7 +1851,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -1989,7 +1998,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -2352,7 +2362,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -2385,7 +2396,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -2418,7 +2430,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -2521,7 +2534,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -2621,7 +2635,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -2767,7 +2782,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -2913,7 +2929,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -3062,7 +3079,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -3216,7 +3234,8 @@ class BlockAdapterTest {
)
),
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -3455,7 +3474,8 @@ class BlockAdapterTest {
onMarkupActionClicked = { _, _ -> },
onTitleTextInputClicked = {},
onClickListener = {},
clipboardInterceptor = clipboardInterceptor
clipboardInterceptor = clipboardInterceptor,
onMentionEvent = {}
)
}
}

View file

@ -224,7 +224,8 @@ class HeaderBlockTest {
),
item = updated,
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -279,7 +280,8 @@ class HeaderBlockTest {
),
item = updated,
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -334,7 +336,8 @@ class HeaderBlockTest {
),
item = updated,
onSelectionChanged = { _, _ -> },
onTextChanged = { _, _ -> }
onTextChanged = { _, _ -> },
clicked = {}
)
assertEquals(
@ -372,7 +375,8 @@ class HeaderBlockTest {
onMarkupActionClicked = { _, _ -> },
onTitleTextInputClicked = {},
onClickListener = {},
clipboardInterceptor = clipboardInterceptor
clipboardInterceptor = clipboardInterceptor,
onMentionEvent = {}
)
}
}

View file

@ -114,7 +114,8 @@ class HighlightingBlockTest {
onMarkupActionClicked = { _, _ -> },
onTitleTextInputClicked = {},
onClickListener = {},
clipboardInterceptor = clipboardInterceptor
clipboardInterceptor = clipboardInterceptor,
onMentionEvent = {}
)
}
}

View file

@ -378,7 +378,8 @@ class BlockAdapterCursorBindingTest {
onMarkupActionClicked = { _, _ -> },
onTitleTextInputClicked = {},
onClickListener = {},
clipboardInterceptor = clipboardInterceptor
clipboardInterceptor = clipboardInterceptor,
onMentionEvent = {}
)
}
}

View file

@ -1,6 +1,6 @@
package com.agileburo.anytype.core_ui.tools
import com.agileburo.anytype.core_ui.tools.MentionHelper.getSubSequenceBeforePredicate
import com.agileburo.anytype.core_ui.tools.MentionHelper.getSubSequenceFromStartWithLimit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@ -8,22 +8,19 @@ import kotlin.test.assertTrue
class MentionHelperTest {
private val PREDICATE_CHAR = ' '
@Test
fun `should find mention in the middle of the block`() {
val text = "Before @mention after"
val startIndex = 7
val result = getSubSequenceBeforePredicate(
val result = getSubSequenceFromStartWithLimit(
s = text,
startIndex = startIndex,
takeNumber = 20,
predicate = PREDICATE_CHAR
takeNumber = 20
)
val expected = "@mention"
val expected = "@mention after"
assertEquals(expected = expected, actual = result)
}
@ -34,11 +31,10 @@ class MentionHelperTest {
val text = "Before @mentionAndBiggerThenEleven after"
val startIndex = 7
val result = getSubSequenceBeforePredicate(
val result = getSubSequenceFromStartWithLimit(
s = text,
startIndex = startIndex,
takeNumber = 11,
predicate = PREDICATE_CHAR
takeNumber = 11
)
val expected = "@mentionAnd"
@ -52,14 +48,13 @@ class MentionHelperTest {
val text = "@mention after"
val startIndex = 0
val result = getSubSequenceBeforePredicate(
val result = getSubSequenceFromStartWithLimit(
s = text,
startIndex = startIndex,
takeNumber = 20,
predicate = PREDICATE_CHAR
takeNumber = 20
)
val expected = "@mention"
val expected = "@mention after"
assertEquals(expected = expected, actual = result)
}
@ -70,11 +65,10 @@ class MentionHelperTest {
val text = "Before @mentionRealBigWord"
val startIndex = 7
val result = getSubSequenceBeforePredicate(
val result = getSubSequenceFromStartWithLimit(
s = text,
startIndex = startIndex,
takeNumber = 11,
predicate = PREDICATE_CHAR
takeNumber = 11
)
val expected = "@mentionRea"
@ -88,11 +82,10 @@ class MentionHelperTest {
val text = ""
val startIndex = 0
val result = getSubSequenceBeforePredicate(
val result = getSubSequenceFromStartWithLimit(
s = text,
startIndex = startIndex,
takeNumber = 11,
predicate = PREDICATE_CHAR
takeNumber = 11
)
val expected = ""
@ -106,11 +99,10 @@ class MentionHelperTest {
val text = "@"
val startIndex = 0
val result = getSubSequenceBeforePredicate(
val result = getSubSequenceFromStartWithLimit(
s = text,
startIndex = startIndex,
takeNumber = 11,
predicate = PREDICATE_CHAR
takeNumber = 11
)
val expected = "@"
@ -118,35 +110,16 @@ class MentionHelperTest {
assertEquals(expected = expected, actual = result)
}
@Test
fun `should find first word in text`() {
val text = "text without mention"
val startIndex = 0
val result = getSubSequenceBeforePredicate(
s = text,
startIndex = startIndex,
takeNumber = 11,
predicate = PREDICATE_CHAR
)
val expected = "text"
assertEquals(expected = expected, actual = result)
}
@Test(expected = IllegalArgumentException::class)
fun `should throw error when take number is negative`() {
val text = "text without mention"
val startIndex = 0
val result = getSubSequenceBeforePredicate(
val result = getSubSequenceFromStartWithLimit(
s = text,
startIndex = startIndex,
takeNumber = -11,
predicate = PREDICATE_CHAR
takeNumber = -11
)
val expected = "text"
@ -160,11 +133,10 @@ class MentionHelperTest {
val text = "text without mention"
val startIndex = 100
val result = getSubSequenceBeforePredicate(
val result = getSubSequenceFromStartWithLimit(
s = text,
startIndex = startIndex,
takeNumber = 0,
predicate = PREDICATE_CHAR
takeNumber = 0
)
val expected = "text"

View file

@ -1,5 +1,6 @@
package com.agileburo.anytype.core_utils.ext
import android.app.Activity
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
@ -197,4 +198,12 @@ fun Fragment.screen(): Point {
val p = Point()
display.getSize(p)
return p
}
fun Activity.screen(): Point {
val wm = (getSystemService(Context.WINDOW_SERVICE) as WindowManager)
val display = wm.defaultDisplay
val p = Point()
display.getSize(p)
return p
}

View file

@ -4,7 +4,7 @@ import com.agileburo.anytype.domain.base.BaseUseCase
import com.agileburo.anytype.domain.base.Either
import com.agileburo.anytype.domain.block.repo.BlockRepository
class GetListPages(private val repo: BlockRepository) :
open class GetListPages(private val repo: BlockRepository) :
BaseUseCase<GetListPages.Response, Unit>() {
override suspend fun run(params: Unit): Either<Throwable, Response> = safe {

View file

@ -122,6 +122,7 @@ fun BlockEntity.Content.Text.Mark.mark(): Mark = when (type) {
Mark.newBuilder()
.setType(Block.Content.Text.Mark.Type.Mention)
.setRange(range.range())
.setParam(param)
.build()
}
else -> throw IllegalStateException("Unsupported mark type: ${type.name}")

View file

@ -5,6 +5,7 @@ import com.agileburo.anytype.core_ui.common.Markup
import com.agileburo.anytype.core_ui.features.navigation.PageLinkView
import com.agileburo.anytype.core_ui.features.page.BlockView
import com.agileburo.anytype.core_ui.model.UiBlock
import com.agileburo.anytype.core_ui.widgets.toolbar.adapter.Mention
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.config.DebugSettings
import com.agileburo.anytype.domain.dashboard.model.HomeDashboard
@ -254,4 +255,16 @@ fun Block.Fields.toImageView(urlBuilder: UrlBuilder): String? = this.iconImage.l
fun Block.Fields.toEmojiView(): String? = this.iconEmoji.let { emoji ->
if (emoji.isNullOrBlank()) null else emoji
}
}
fun PageInfo.toMentionView() = Mention(
id = id,
title = fields.getName(),
image = fields.iconImage,
emoji = fields.iconEmoji
)
fun Block.Fields.getName(): String =
this.name.let { name ->
if (name.isNullOrBlank()) "Untitled" else name
}

View file

@ -7,6 +7,7 @@ import com.agileburo.anytype.core_ui.features.page.styling.StylingMode
import com.agileburo.anytype.core_ui.features.page.styling.StylingType
import com.agileburo.anytype.core_ui.state.ControlPanelState
import com.agileburo.anytype.core_ui.state.ControlPanelState.Toolbar
import com.agileburo.anytype.core_ui.widgets.toolbar.adapter.Mention
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.ext.content
import com.agileburo.anytype.domain.ext.overlap
@ -151,6 +152,12 @@ sealed class ControlPanelMachine {
object OnEnterScrollAndMoveModeClicked : Event()
data class OnRefresh(val target: Block?) : Event()
data class OnShowMentionToolbar(val cursorCoordinate: Int, val mentionFrom: Int) : Event()
data class OnMentionFilterText(val text: String) : Event()
data class OnGetMentionsList(val mentions: List<Mention>) : Event()
object OnMentionClicked : Event()
object OnHideMentionToolbar : Event()
}
/**
@ -163,6 +170,24 @@ sealed class ControlPanelMachine {
var selection: IntRange? = null
private fun onSelectionChangedMentionState(
state: Toolbar.MentionToolbar,
start: Int
): Toolbar.MentionToolbar {
val from = state.mentionFrom
return if (state.isVisible && from != null && start < from) {
state.copy(
isVisible = false,
cursorCoordinate = null,
mentionFrom = null,
updateList = false,
mentionFilter = null,
mentions = emptyList())
} else {
state.copy()
}
}
override val function: suspend (ControlPanelState, Event) -> ControlPanelState
get() = { state, event ->
reduce(
@ -223,16 +248,29 @@ sealed class ControlPanelMachine {
color = color,
background = background
)
),
mentionToolbar = onSelectionChangedMentionState(
state = state.mentionToolbar,
start = event.selection.first
)
)
} else {
state.copy()
state.copy(
mentionToolbar = onSelectionChangedMentionState(
state = state.mentionToolbar,
start = event.selection.first
)
)
}
}
else -> {
state.copy(
mainToolbar = state.mainToolbar.copy(
isVisible = (!state.multiSelect.isVisible && event.selection.first == event.selection.last)
),
mentionToolbar = onSelectionChangedMentionState(
state = state.mentionToolbar,
start = event.selection.first
)
)
}
@ -602,7 +640,15 @@ sealed class ControlPanelMachine {
)
is Event.OnFocusChanged -> {
when {
state.multiSelect.isVisible -> state.copy()
state.multiSelect.isVisible -> state.copy(
mentionToolbar = state.mentionToolbar.copy(
isVisible = false,
cursorCoordinate = null,
mentionFrom = null,
mentionFilter = null,
mentions = emptyList()
)
)
!state.mainToolbar.isVisible -> state.copy(
mainToolbar = state.mainToolbar.copy(
isVisible = true
@ -615,6 +661,13 @@ sealed class ControlPanelMachine {
type = ControlPanelState.Focus.Type.valueOf(
value = event.style.name
)
),
mentionToolbar = state.mentionToolbar.copy(
isVisible = false,
cursorCoordinate = null,
mentionFrom = null,
mentionFilter = null,
mentions = emptyList()
)
)
else -> {
@ -627,6 +680,13 @@ sealed class ControlPanelMachine {
),
stylingToolbar = state.stylingToolbar.copy(
isVisible = false
),
mentionToolbar = state.mentionToolbar.copy(
isVisible = false,
cursorCoordinate = null,
mentionFrom = null,
mentionFilter = null,
mentions = emptyList()
)
)
}
@ -653,6 +713,47 @@ sealed class ControlPanelMachine {
isScrollAndMoveEnabled = false
)
)
is Event.OnShowMentionToolbar -> state.copy(
mentionToolbar = state.mentionToolbar.copy(
isVisible = true,
cursorCoordinate = event.cursorCoordinate,
mentionFilter = "",
updateList = false,
mentionFrom = event.mentionFrom
)
)
is Event.OnHideMentionToolbar -> state.copy(
mentionToolbar = state.mentionToolbar.copy(
isVisible = false,
cursorCoordinate = null,
updateList = true,
mentionFrom = null,
mentionFilter = null,
mentions = emptyList()
)
)
is Event.OnGetMentionsList -> state.copy(
mentionToolbar = state.mentionToolbar.copy(
mentions = event.mentions,
updateList = true
)
)
is Event.OnMentionClicked -> state.copy(
mentionToolbar = state.mentionToolbar.copy(
isVisible = false,
cursorCoordinate = null,
mentionFrom = null,
updateList = true,
mentionFilter = null,
mentions = emptyList()
)
)
is Event.OnMentionFilterText -> state.copy(
mentionToolbar = state.mentionToolbar.copy(
mentionFilter = event.text,
updateList = false
)
)
}
fun target(block: Block): Toolbar.Styling.Target {

View file

@ -6,16 +6,14 @@ import androidx.lifecycle.viewModelScope
import com.agileburo.anytype.core_ui.common.Alignment
import com.agileburo.anytype.core_ui.common.Markup
import com.agileburo.anytype.core_ui.extensions.updateSelection
import com.agileburo.anytype.core_ui.features.page.BlockDimensions
import com.agileburo.anytype.core_ui.features.page.BlockView
import com.agileburo.anytype.core_ui.features.page.ListenerType
import com.agileburo.anytype.core_ui.features.page.TurnIntoActionReceiver
import com.agileburo.anytype.core_ui.features.page.*
import com.agileburo.anytype.core_ui.features.page.scrollandmove.ScrollAndMoveTargetDescriptor.Companion.END_RANGE
import com.agileburo.anytype.core_ui.features.page.scrollandmove.ScrollAndMoveTargetDescriptor.Companion.INNER_RANGE
import com.agileburo.anytype.core_ui.features.page.scrollandmove.ScrollAndMoveTargetDescriptor.Companion.START_RANGE
import com.agileburo.anytype.core_ui.model.UiBlock
import com.agileburo.anytype.core_ui.state.ControlPanelState
import com.agileburo.anytype.core_ui.widgets.ActionItemType
import com.agileburo.anytype.core_ui.widgets.toolbar.adapter.Mention
import com.agileburo.anytype.core_utils.common.EventWrapper
import com.agileburo.anytype.core_utils.ext.*
import com.agileburo.anytype.core_utils.ui.ViewStateViewModel
@ -30,14 +28,17 @@ import com.agileburo.anytype.domain.editor.Editor
import com.agileburo.anytype.domain.event.interactor.InterceptEvents
import com.agileburo.anytype.domain.event.model.Event
import com.agileburo.anytype.domain.event.model.Payload
import com.agileburo.anytype.domain.ext.addMention
import com.agileburo.anytype.domain.ext.asMap
import com.agileburo.anytype.domain.ext.content
import com.agileburo.anytype.domain.ext.textStyle
import com.agileburo.anytype.domain.misc.UrlBuilder
import com.agileburo.anytype.domain.page.*
import com.agileburo.anytype.domain.page.navigation.GetListPages
import com.agileburo.anytype.presentation.common.StateReducer
import com.agileburo.anytype.presentation.common.SupportCommand
import com.agileburo.anytype.presentation.mapper.style
import com.agileburo.anytype.presentation.mapper.toMentionView
import com.agileburo.anytype.presentation.navigation.AppNavigation
import com.agileburo.anytype.presentation.navigation.SupportNavigation
import com.agileburo.anytype.presentation.page.ControlPanelMachine.Interactor
@ -65,7 +66,8 @@ class PageViewModel(
private val reducer: StateReducer<List<Block>, Event>,
private val urlBuilder: UrlBuilder,
private val renderer: DefaultBlockViewRenderer,
private val orchestrator: Orchestrator
private val orchestrator: Orchestrator,
private val getListPages: GetListPages
) : ViewStateViewModel<ViewState>(),
SupportNavigation<EventWrapper<AppNavigation.Command>>,
SupportCommand<Command>,
@ -108,6 +110,11 @@ class PageViewModel(
*/
private var mediaBlockId = ""
/**
* Current position of last mentionFilter or -1 if none
*/
private var mentionFrom = -1
override val navigation = MutableLiveData<EventWrapper<AppNavigation.Command>>()
override val commands = MutableLiveData<EventWrapper<Command>>()
@ -1837,6 +1844,93 @@ class PageViewModel(
navigation.postValue(EventWrapper(AppNavigation.Command.OpenPageSearch))
}
fun onMentionEvent(mentionEvent: MentionEvent) {
when (mentionEvent) {
is MentionEvent.MentionSuggestText -> {
controlPanelInteractor.onEvent(
ControlPanelMachine.Event.OnMentionFilterText(
text = mentionEvent.text.toString()
)
)
}
is MentionEvent.MentionSuggestStart -> {
mentionFrom = mentionEvent.mentionStart
controlPanelInteractor.onEvent(
ControlPanelMachine.Event.OnShowMentionToolbar(
cursorCoordinate = mentionEvent.cursorCoordinate,
mentionFrom = mentionEvent.mentionStart
)
)
viewModelScope.launch {
getListPages.invoke(Unit).proceed(
failure = { it.timber() },
success = { response ->
controlPanelInteractor.onEvent(
ControlPanelMachine.Event.OnGetMentionsList(
mentions = response.listPages.map { it.toMentionView() }
)
)
}
)
}
}
MentionEvent.MentionSuggestStop -> {
mentionFrom = -1
controlPanelInteractor.onEvent(
ControlPanelMachine.Event.OnHideMentionToolbar
)
}
}
}
fun onAddMentionNewPageClicked() {
onAddNewPageClicked()
}
fun onMentionSuggestClick(mention: Mention, mentionTrigger: String) {
Timber.d("onAddMentionClicked, suggest:$mention, from:$mentionFrom")
controlPanelInteractor.onEvent(ControlPanelMachine.Event.OnMentionClicked)
val target = blocks.first { it.id == focus.value }
val new = target.addMention(
mentionText = mention.title,
mentionId = mention.id,
from = mentionFrom,
mentionTrigger = mentionTrigger
)
blocks = blocks.map { block ->
if (block.id != target.id)
block
else
new
}
viewModelScope.launch {
val position = mentionFrom + mention.title.length + 1
orchestrator.stores.focus.update(
t = Editor.Focus(
id = new.id,
cursor = Editor.Cursor.Range(IntRange(position, position))
)
)
refresh()
}
viewModelScope.launch {
proceedWithUpdatingText(
intent = Intent.Text.UpdateText(
context = context,
target = new.id,
text = new.content<Content.Text>().text,
marks = new.content<Content.Text>().marks
)
)
}
}
private fun onMultiSelectModeBlockClicked() {
controlPanelInteractor.onEvent(
ControlPanelMachine.Event.OnMultiSelectModeBlockClick(

View file

@ -4,12 +4,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.agileburo.anytype.domain.block.interactor.RemoveLinkMark
import com.agileburo.anytype.domain.block.interactor.UpdateLinkMarks
import com.agileburo.anytype.domain.block.interactor.UploadBlock
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.domain.event.interactor.InterceptEvents
import com.agileburo.anytype.domain.event.model.Event
import com.agileburo.anytype.domain.misc.UrlBuilder
import com.agileburo.anytype.domain.page.*
import com.agileburo.anytype.domain.page.navigation.GetListPages
import com.agileburo.anytype.presentation.common.StateReducer
import com.agileburo.anytype.presentation.page.editor.Orchestrator
import com.agileburo.anytype.presentation.page.render.DefaultBlockViewRenderer
@ -26,7 +26,8 @@ open class PageViewModelFactory(
private val documentEventReducer: StateReducer<List<Block>, Event>,
private val urlBuilder: UrlBuilder,
private val renderer: DefaultBlockViewRenderer,
private val interactor: Orchestrator
private val interactor: Orchestrator,
private val getListPages: GetListPages
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
@ -43,7 +44,8 @@ open class PageViewModelFactory(
urlBuilder = urlBuilder,
renderer = renderer,
createDocument = createDocument,
orchestrator = interactor
orchestrator = interactor,
getListPages = getListPages
) as T
}
}

View file

@ -50,6 +50,223 @@ class ControlPanelStateReducerTest {
)
}
@Test
fun `state should hide mentions when cursor before mentions start and widget is visible`() {
val given = ControlPanelState(
focus = ControlPanelState.Focus(
id = MockDataFactory.randomUuid(),
type = ControlPanelState.Focus.Type.P
),
mainToolbar = ControlPanelState.Toolbar.Main(
isVisible = true
),
stylingToolbar = ControlPanelState.Toolbar.Styling(
isVisible = false,
mode = null,
type = null
),
multiSelect = ControlPanelState.Toolbar.MultiSelect(
isVisible = false
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = true,
cursorCoordinate = 333,
mentionFilter = "start",
mentionFrom = 10
)
)
val event = ControlPanelMachine.Event.OnSelectionChanged(
selection = IntRange(9,9)
)
val actual = runBlocking {
reducer.reduce(
state = given,
event = event
)
}
val expected = given.copy(
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
assertEquals(
expected = expected,
actual = actual
)
}
@Test
fun `state should not hide mentions when cursor after mention start`() {
val given = ControlPanelState(
focus = ControlPanelState.Focus(
id = MockDataFactory.randomUuid(),
type = ControlPanelState.Focus.Type.P
),
mainToolbar = ControlPanelState.Toolbar.Main(
isVisible = true
),
stylingToolbar = ControlPanelState.Toolbar.Styling(
isVisible = false,
mode = null,
type = null
),
multiSelect = ControlPanelState.Toolbar.MultiSelect(
isVisible = false
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = true,
cursorCoordinate = 333,
mentionFilter = "start",
mentionFrom = 10
)
)
val event = ControlPanelMachine.Event.OnSelectionChanged(
selection = IntRange(11,11)
)
val actual = runBlocking {
reducer.reduce(
state = given,
event = event
)
}
val expected = given.copy(
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = true,
cursorCoordinate = 333,
mentionFilter = "start",
mentionFrom = 10
)
)
assertEquals(
expected = expected,
actual = actual
)
}
@Test
fun `state should hide mentions after focus changed`() {
val given = ControlPanelState(
focus = ControlPanelState.Focus(
id = MockDataFactory.randomUuid(),
type = ControlPanelState.Focus.Type.P
),
mainToolbar = ControlPanelState.Toolbar.Main(
isVisible = true
),
stylingToolbar = ControlPanelState.Toolbar.Styling(
isVisible = false,
mode = null,
type = null
),
multiSelect = ControlPanelState.Toolbar.MultiSelect(
isVisible = false
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = true,
cursorCoordinate = 333,
mentionFilter = "start",
mentionFrom = 10
)
)
val event = ControlPanelMachine.Event.OnFocusChanged(
id = MockDataFactory.randomUuid(),
style = Block.Content.Text.Style.P
)
val actual = runBlocking {
reducer.reduce(
state = given,
event = event
)
}
val expected = given.copy(
focus = ControlPanelState.Focus(
id = event.id,
type = ControlPanelState.Focus.Type.P
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
assertEquals(
expected = expected,
actual = actual
)
}
@Test
fun `state should hide mentions after selection chaged`() {
val given = ControlPanelState(
focus = ControlPanelState.Focus(
id = MockDataFactory.randomUuid(),
type = ControlPanelState.Focus.Type.P
),
mainToolbar = ControlPanelState.Toolbar.Main(
isVisible = true
),
stylingToolbar = ControlPanelState.Toolbar.Styling(
isVisible = false,
mode = null,
type = null
),
multiSelect = ControlPanelState.Toolbar.MultiSelect(
isVisible = false
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = true,
cursorCoordinate = 333,
mentionFilter = "start",
mentionFrom = 10
)
)
val event = ControlPanelMachine.Event.OnSelectionChanged(
selection = IntRange(8, 8)
)
val actual = runBlocking {
reducer.reduce(
state = given,
event = event
)
}
val expected = given.copy(
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
assertEquals(
expected = expected,
actual = actual
)
}
@Test
fun `state should have only focus changed`() {
@ -68,6 +285,12 @@ class ControlPanelStateReducerTest {
),
multiSelect = ControlPanelState.Toolbar.MultiSelect(
isVisible = false
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
@ -114,6 +337,12 @@ class ControlPanelStateReducerTest {
),
multiSelect = ControlPanelState.Toolbar.MultiSelect(
isVisible = false
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
@ -152,6 +381,12 @@ class ControlPanelStateReducerTest {
),
multiSelect = ControlPanelState.Toolbar.MultiSelect(
isVisible = false
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
@ -200,6 +435,12 @@ class ControlPanelStateReducerTest {
multiSelect = ControlPanelState.Toolbar.MultiSelect(
isVisible = false,
count = 2
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
@ -219,6 +460,12 @@ class ControlPanelStateReducerTest {
multiSelect = ControlPanelState.Toolbar.MultiSelect(
isVisible = true,
count = 0
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
@ -256,6 +503,12 @@ class ControlPanelStateReducerTest {
multiSelect = ControlPanelState.Toolbar.MultiSelect(
isVisible = true,
count = 0
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
@ -275,6 +528,12 @@ class ControlPanelStateReducerTest {
multiSelect = ControlPanelState.Toolbar.MultiSelect(
isVisible = true,
count = 3
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)

View file

@ -24,6 +24,7 @@ import com.agileburo.anytype.domain.ext.content
import com.agileburo.anytype.domain.misc.UrlBuilder
import com.agileburo.anytype.domain.page.*
import com.agileburo.anytype.domain.page.bookmark.SetupBookmark
import com.agileburo.anytype.domain.page.navigation.GetListPages
import com.agileburo.anytype.presentation.MockBlockFactory
import com.agileburo.anytype.presentation.navigation.AppNavigation
import com.agileburo.anytype.presentation.page.editor.Command
@ -100,6 +101,9 @@ class PageViewModelTest {
@Mock
lateinit var splitBlock: SplitBlock
@Mock
lateinit var getListPages: GetListPages
@Mock
lateinit var createPage: CreatePage
@ -4318,6 +4322,7 @@ class PageViewModelTest {
)
vm = PageViewModel(
getListPages = getListPages,
openPage = openPage,
closePage = closePage,
createPage = createPage,

View file

@ -0,0 +1,171 @@
package com.agileburo.anytype.presentation.page.editor
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.agileburo.anytype.core_ui.common.Markup
import com.agileburo.anytype.core_ui.features.page.BlockView
import com.agileburo.anytype.core_ui.features.page.MentionEvent
import com.agileburo.anytype.core_ui.widgets.toolbar.adapter.Mention
import com.agileburo.anytype.domain.block.model.Block
import com.agileburo.anytype.presentation.page.PageViewModel
import com.agileburo.anytype.presentation.util.CoroutinesTestRule
import com.jraska.livedata.test
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.MockitoAnnotations
class EditorMentionTest : EditorPresentationTestSetup() {
@get:Rule
val rule = InstantTaskExecutorRule()
@get:Rule
val coroutineTestRule = CoroutinesTestRule()
@Before
fun setup() {
MockitoAnnotations.initMocks(this)
}
@Test
fun `should update text with cursor position`() {
val mentionTrigger = "@a"
val from = 11
val givenText = "page about $mentionTrigger music"
val mentionText = "Avant-Garde Jazz"
val mentionHash = "ryew78yfhiuwehudc"
val a = Block(
id = "dfhkshfjkhsdjhfjkhsjkd",
fields = Block.Fields.empty(),
children = emptyList(),
content = Block.Content.Text(
text = givenText,
marks = listOf(
Block.Content.Text.Mark(
range = IntRange(
start = 0,
endInclusive = 3
),
type = Block.Content.Text.Mark.Type.BOLD
),
Block.Content.Text.Mark(
range = IntRange(
start = 5,
endInclusive = 9
),
type = Block.Content.Text.Mark.Type.ITALIC
),
Block.Content.Text.Mark(
range = IntRange(
start = 14,
endInclusive = 18
),
type = Block.Content.Text.Mark.Type.STRIKETHROUGH
)
),
style = Block.Content.Text.Style.P
)
)
val page = Block(
id = root,
fields = Block.Fields(emptyMap()),
content = Block.Content.Smart(
type = Block.Content.Smart.Type.PAGE
),
children = listOf(a.id)
)
val document = listOf(page, a)
stubOpenDocument(document)
stubObserveEvents()
val vm = buildViewModel()
vm.open(root)
vm.apply {
onBlockFocusChanged(
id = a.id,
hasFocus = true
)
onSelectionChanged(
id = a.id,
selection = IntRange(12, 12)
)
onMentionEvent(
MentionEvent.MentionSuggestStart(
cursorCoordinate = 500,
mentionStart = from
)
)
onMentionSuggestClick(
mention = Mention(
id = mentionHash,
emoji = null,
image = null,
title = mentionText
),
mentionTrigger = mentionTrigger
)
}
vm.state.test().apply {
assertValue(
ViewState.Success(
blocks = listOf(
BlockView.Title(
id = root,
isFocused = false,
text = null,
mode = BlockView.Mode.EDIT
),
BlockView.Paragraph(
id = a.id,
cursor = 28,
isSelected = false,
isFocused = true,
marks = listOf(
Markup.Mark(
from = 0,
to = 3,
type = Markup.Type.BOLD
),
Markup.Mark(
from = 5,
to = 9,
type = Markup.Type.ITALIC
),
Markup.Mark(
from = 29,
to = 33,
type = Markup.Type.STRIKETHROUGH
),
Markup.Mark(
from = from,
to = from + mentionText.length,
type = Markup.Type.MENTION,
param = mentionHash
)
),
backgroundColor = null,
color = null,
indent = 0,
text = "page about Avant-Garde Jazz music",
mode = BlockView.Mode.EDIT
)
)
)
)
}
clearPendingCoroutines()
}
private fun clearPendingCoroutines() {
coroutineTestRule.advanceTime(PageViewModel.TEXT_CHANGES_DEBOUNCE_DURATION)
}
}

View file

@ -109,6 +109,12 @@ class EditorMultiSelectModeTest : EditorPresentationTestSetup() {
isVisible = true,
isScrollAndMoveEnabled = false,
count = 0
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
)
@ -205,6 +211,12 @@ class EditorMultiSelectModeTest : EditorPresentationTestSetup() {
isVisible = true,
isScrollAndMoveEnabled = false,
count = 0
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
)

View file

@ -17,6 +17,7 @@ import com.agileburo.anytype.domain.event.model.Payload
import com.agileburo.anytype.domain.misc.UrlBuilder
import com.agileburo.anytype.domain.page.*
import com.agileburo.anytype.domain.page.bookmark.SetupBookmark
import com.agileburo.anytype.domain.page.navigation.GetListPages
import com.agileburo.anytype.presentation.page.DocumentExternalEventReducer
import com.agileburo.anytype.presentation.page.Editor
import com.agileburo.anytype.presentation.page.PageViewModel
@ -49,6 +50,9 @@ open class EditorPresentationTestSetup {
@Mock
lateinit var updateText: UpdateText
@Mock
lateinit var getListPages: GetListPages
@Mock
lateinit var updateCheckbox: UpdateCheckbox
@ -138,6 +142,7 @@ open class EditorPresentationTestSetup {
)
return PageViewModel(
getListPages = getListPages,
openPage = openPage,
closePage = closePage,
createPage = createPage,

View file

@ -114,6 +114,12 @@ class EditorScrollAndMoveTest : EditorPresentationTestSetup() {
isVisible = true,
isScrollAndMoveEnabled = true,
count = 1
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
)
@ -206,6 +212,12 @@ class EditorScrollAndMoveTest : EditorPresentationTestSetup() {
isVisible = true,
isScrollAndMoveEnabled = false,
count = 1
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
)
@ -302,6 +314,12 @@ class EditorScrollAndMoveTest : EditorPresentationTestSetup() {
isVisible = true,
isScrollAndMoveEnabled = false,
count = 0
),
mentionToolbar = ControlPanelState.Toolbar.MentionToolbar(
isVisible = false,
cursorCoordinate = null,
mentionFilter = null,
mentionFrom = null
)
)
)

View file

@ -37,7 +37,8 @@ class SpanActivity : AppCompatActivity() {
R.drawable.ic_baseline_4k_24,
resource,
16.px,
0
0,
""
),
8,
9,
@ -65,7 +66,8 @@ class SpanActivity : AppCompatActivity() {
context = this,
mResourceId = R.drawable.ic_mention_deafult,
imageSize = 16.px,
imagePadding = 0
imagePadding = 0,
param = ""
),
0,
6,
@ -77,7 +79,7 @@ class SpanActivity : AppCompatActivity() {
val spannableString2 = SpannableString("Строка с маркапом и болдом")
spannableString2.setSpan(
MentionSpan(this, R.drawable.ic_baseline_4k_24, null, 16.px, 0),
MentionSpan(this, R.drawable.ic_baseline_4k_24, null, 16.px, 0, ""),
9,
10,
0
@ -86,7 +88,7 @@ class SpanActivity : AppCompatActivity() {
val spannableString3 = SpannableString("Строка с маркапом и болдом")
spannableString3.setSpan(
MentionSpan(this, R.drawable.ic_baseline_4k_24, null, 16.px, 0),
MentionSpan(this, R.drawable.ic_baseline_4k_24, null, 16.px, 0, ""),
9,
17,
0