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

Feature/search on page. First iteration (#998)

This commit is contained in:
Evgenii Kozlov 2020-10-16 14:31:01 +03:00 committed by GitHub
parent 4233273717
commit 004e49906c
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 957 additions and 132 deletions

View file

@ -151,11 +151,6 @@ open class PageFragment :
private val pageAdapter by lazy {
BlockAdapter(
blocks = mutableListOf(),
onTitleTextChanged = { editable ->
vm.onTitleTextChanged(
text = editable.toString()
)
},
onTextChanged = { id, editable ->
vm.onTextChanged(
id = id,
@ -164,6 +159,7 @@ open class PageFragment :
)
},
onTextBlockTextChanged = vm::onTextBlockTextChanged,
onTitleBlockTextChanged = vm::onTitleBlockTextChanged,
onSelectionChanged = vm::onSelectionChanged,
onCheckboxClicked = vm::onCheckboxClicked,
onFocusChanged = vm::onBlockFocusChanged,
@ -480,6 +476,10 @@ open class PageFragment :
vm.onCloseBlockStyleToolbarClicked()
}
}
lifecycleScope.launch {
searchToolbar.events().collect { vm.onSearchToolbarEvent(it) }
}
}
private fun onApplyScrollAndMoveClicked() {
@ -695,7 +695,8 @@ open class PageFragment :
onArchiveClicked = vm::onArchiveThisPageClicked,
onRedoClicked = vm::onActionRedoClicked,
onUndoClicked = vm::onActionUndoClicked,
onEnterMultiSelect = vm::onEnterMultiSelectModeClicked
onEnterMultiSelect = vm::onEnterMultiSelectModeClicked,
onSearchClicked = vm::onEnterSearchModeClicked
).show()
}
is Command.OpenProfileMenu -> {
@ -704,7 +705,8 @@ open class PageFragment :
view = topToolbar.menu,
onRedoClicked = vm::onActionRedoClicked,
onUndoClicked = vm::onActionUndoClicked,
onEnterMultiSelect = vm::onEnterMultiSelectModeClicked
onEnterMultiSelect = vm::onEnterMultiSelectModeClicked,
onSearchClicked = vm::onEnterSearchModeClicked
).show()
}
is Command.OpenFullScreenImage -> {
@ -895,6 +897,14 @@ open class PageFragment :
recycler.removeItemDecoration(footerMentionDecorator)
}
}
state.searchToolbar.apply {
if (isVisible) {
searchToolbar.visible()
} else {
searchToolbar.gone()
}
}
}
private fun showMentionToolbar(state: ControlPanelState.Toolbar.MentionToolbar) {

View file

@ -50,6 +50,15 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.anytypeio.anytype.core_ui.widgets.toolbar.SearchToolbarWidget
android:id="@+id/searchToolbar"
android:layout_width="0dp"
android:visibility="gone"
android:layout_height="@dimen/default_toolbar_height"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="0dp"

View file

@ -0,0 +1,10 @@
package com.anytypeio.anytype.core_ui.common
import android.graphics.Color
import android.text.style.BackgroundColorSpan
class SearchHighlightSpan(color: Int = COLOR) : BackgroundColorSpan(color) {
companion object {
val COLOR = Color.parseColor("#332AA7EE")
}
}

View file

@ -2,6 +2,7 @@ package com.anytypeio.anytype.core_ui.features.editor.holders.other
import android.content.res.ColorStateList
import android.text.Editable
import android.text.Spannable
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
@ -9,6 +10,7 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.postDelayed
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.core_ui.common.SearchHighlightSpan
import com.anytypeio.anytype.core_ui.extensions.avatarColor
import com.anytypeio.anytype.core_ui.features.editor.holders.`interface`.TextHolder
import com.anytypeio.anytype.core_ui.features.page.BlockView
@ -19,6 +21,7 @@ import com.anytypeio.anytype.core_ui.tools.DefaultTextWatcher
import com.anytypeio.anytype.core_ui.widgets.text.TextInputWidget
import com.anytypeio.anytype.core_utils.ext.firstDigitByHash
import com.anytypeio.anytype.core_utils.ext.imm
import com.anytypeio.anytype.core_utils.ext.removeSpans
import com.anytypeio.anytype.core_utils.ext.visible
import com.anytypeio.anytype.emojifier.Emojifier
import com.bumptech.glide.Glide
@ -62,6 +65,18 @@ sealed class Title(view: View) : BlockViewHolder(view), TextHolder {
}
}
fun applySearchHighlights(item: BlockView.Searchable) {
content.editableText.removeSpans<SearchHighlightSpan>()
item.highlights.forEach { highlight ->
content.editableText.setSpan(
SearchHighlightSpan(),
highlight.first,
highlight.last,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
open fun setImage(item: BlockView.Title) {
item.image?.let { url ->
image.visible()
@ -101,10 +116,12 @@ sealed class Title(view: View) : BlockViewHolder(view), TextHolder {
focus(item.isFocused)
}
if (payload.readWriteModeChanged()) {
if (item.mode == BlockView.Mode.EDIT)
enableEditMode()
else
enableReadMode()
content.pauseTextWatchers {
if (item.mode == BlockView.Mode.EDIT)
enableEditMode()
else
enableReadMode()
}
}
}
}
@ -133,16 +150,20 @@ sealed class Title(view: View) : BlockViewHolder(view), TextHolder {
fun bind(
item: BlockView.Title.Document,
onTitleTextChanged: (Editable) -> Unit,
onTitleTextChanged: (BlockView.Title) -> Unit,
onFocusChanged: (String, Boolean) -> Unit,
onPageIconClicked: () -> Unit
) {
super.bind(
item = item,
onTitleTextChanged = onTitleTextChanged,
onTitleTextChanged = {
item.text = it.toString()
onTitleTextChanged(item)
},
onFocusChanged = onFocusChanged
)
setEmoji(item)
applySearchHighlights(item)
if (item.mode == BlockView.Mode.EDIT) {
icon.setOnClickListener { onPageIconClicked() }
}
@ -159,6 +180,9 @@ sealed class Title(view: View) : BlockViewHolder(view), TextHolder {
setEmoji(item)
setImage(item)
}
if (payload.isSearchHighlightChanged) {
applySearchHighlights(item)
}
}
}
}
@ -229,12 +253,20 @@ sealed class Title(view: View) : BlockViewHolder(view), TextHolder {
fun bind(
item: BlockView.Title.Profile,
onTitleTextChanged: (Editable) -> Unit,
onTitleTextChanged: (BlockView.Title) -> Unit,
onFocusChanged: (String, Boolean) -> Unit,
onProfileIconClicked: () -> Unit
) {
Timber.d("Binding profile title view: $item")
super.bind(item, onTitleTextChanged, onFocusChanged)
super.bind(
item = item,
onFocusChanged = onFocusChanged,
onTitleTextChanged = {
item.text = it.toString()
onTitleTextChanged(item)
},
)
applySearchHighlights(item)
if (item.mode == BlockView.Mode.EDIT) {
icon.setOnClickListener { onProfileIconClicked() }
}
@ -276,6 +308,9 @@ sealed class Title(view: View) : BlockViewHolder(view), TextHolder {
if (payload.isTitleIconChanged) {
setImage(item)
}
if (payload.isSearchHighlightChanged) {
applySearchHighlights(item)
}
}
}
}

View file

@ -2,9 +2,11 @@ package com.anytypeio.anytype.core_ui.features.editor.holders.text
import android.os.Build
import android.text.Editable
import android.text.Spannable
import android.view.View
import android.view.inputmethod.InputMethodManager
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.core_ui.common.SearchHighlightSpan
import com.anytypeio.anytype.core_ui.common.getBlockTextColor
import com.anytypeio.anytype.core_ui.extensions.applyMovementMethod
import com.anytypeio.anytype.core_ui.extensions.color
@ -120,6 +122,16 @@ abstract class Text(
clicked = clicked,
textColor = item.getBlockTextColor()
)
if (item is BlockView.Searchable) {
item.highlights.forEach { highlight ->
content.editableText.setSpan(
SearchHighlightSpan(),
highlight.first,
highlight.last,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
private fun setStyle(item: BlockView.TextBlockProps) {
@ -141,7 +153,6 @@ abstract class Text(
) {
content.apply {
setOnLongClickListener(
EditorLongClickListener(
t = item.id,

View file

@ -74,7 +74,7 @@ class BlockAdapter(
private var blocks: List<BlockView>,
private val onTextBlockTextChanged: (BlockView.Text) -> Unit,
private val onTextChanged: (String, Editable) -> Unit,
private val onTitleTextChanged: (Editable) -> Unit,
private val onTitleBlockTextChanged: (BlockView.Title) -> Unit,
private val onTitleTextInputClicked: () -> Unit,
private val onSelectionChanged: (String, IntRange) -> Unit,
private val onCheckboxClicked: (BlockView.Text.Checkbox) -> Unit,
@ -764,7 +764,7 @@ class BlockAdapter(
holder.apply {
bind(
item = blocks[position] as BlockView.Title.Document,
onTitleTextChanged = onTitleTextChanged,
onTitleTextChanged = onTitleBlockTextChanged,
onFocusChanged = onFocusChanged,
onPageIconClicked = onPageIconClicked
)
@ -793,7 +793,7 @@ class BlockAdapter(
holder.apply {
bind(
item = blocks[position] as BlockView.Title.Profile,
onTitleTextChanged = onTitleTextChanged,
onTitleTextChanged = onTitleBlockTextChanged,
onFocusChanged = onFocusChanged,
onProfileIconClicked = onProfileIconClicked
)

View file

@ -35,6 +35,7 @@ import com.anytypeio.anytype.core_ui.features.page.BlockViewHolder.Companion.HOL
import com.anytypeio.anytype.core_ui.features.page.BlockViewHolder.Companion.HOLDER_VIDEO_PLACEHOLDER
import com.anytypeio.anytype.core_ui.features.page.BlockViewHolder.Companion.HOLDER_VIDEO_UPLOAD
import kotlinx.android.parcel.Parcelize
import kotlinx.android.parcel.RawValue
/**
* UI-models for different types of blocks.
@ -105,6 +106,16 @@ sealed class BlockView : ViewType, Parcelable {
val cursor: Int?
}
/**
* Views implementing this interface are supposed to highlight search results.
* @property highlights search results that meet query.
* @property target currently selected search result
*/
interface Searchable {
val highlights: Set<IntRange>
val target: IntRange
}
interface TextBlockProps :
Markup,
Focusable,
@ -117,7 +128,7 @@ sealed class BlockView : ViewType, Parcelable {
val id: String
}
sealed class Text : BlockView(), TextBlockProps {
sealed class Text : BlockView(), TextBlockProps, Searchable {
// Dynamic properties (expected to be synchronised with framework widget)
@ -153,7 +164,9 @@ sealed class BlockView : ViewType, Parcelable {
override val mode: Mode = Mode.EDIT,
override val isSelected: Boolean = false,
override val alignment: Alignment? = null,
override val cursor: Int? = null
override val cursor: Int? = null,
override val highlights: @RawValue Set<IntRange> = emptySet(),
override val target: @RawValue IntRange = IntRange.EMPTY
) : Text() {
override fun getViewType() = HOLDER_PARAGRAPH
override val body: String get() = text
@ -180,7 +193,9 @@ sealed class BlockView : ViewType, Parcelable {
override val mode: Mode = Mode.EDIT,
override val isSelected: Boolean = false,
override val alignment: Alignment? = null,
override val cursor: Int? = null
override val cursor: Int? = null,
override val highlights: @RawValue Set<IntRange> = emptySet(),
override val target: @RawValue IntRange = IntRange.EMPTY
) : Header() {
override fun getViewType() = HOLDER_HEADER_ONE
override val body: String get() = text
@ -205,7 +220,9 @@ sealed class BlockView : ViewType, Parcelable {
override val mode: Mode = Mode.EDIT,
override val isSelected: Boolean = false,
override val alignment: Alignment? = null,
override val cursor: Int? = null
override val cursor: Int? = null,
override val highlights: @RawValue Set<IntRange> = emptySet(),
override val target: @RawValue IntRange = IntRange.EMPTY
) : Header() {
override fun getViewType() = HOLDER_HEADER_TWO
override val body: String get() = text
@ -230,7 +247,9 @@ sealed class BlockView : ViewType, Parcelable {
override val mode: Mode = Mode.EDIT,
override val isSelected: Boolean = false,
override val alignment: Alignment? = null,
override val cursor: Int? = null
override val cursor: Int? = null,
override val highlights: @RawValue Set<IntRange> = emptySet(),
override val target: @RawValue IntRange = IntRange.EMPTY
) : Header() {
override fun getViewType() = HOLDER_HEADER_THREE
override val body: String get() = text
@ -255,7 +274,9 @@ sealed class BlockView : ViewType, Parcelable {
override val mode: Mode = Mode.EDIT,
override val isSelected: Boolean = false,
override val cursor: Int? = null,
override val alignment: Alignment? = null
override val alignment: Alignment? = null,
override val highlights: @RawValue Set<IntRange> = emptySet(),
override val target: @RawValue IntRange = IntRange.EMPTY
) : Text() {
override fun getViewType() = HOLDER_HIGHLIGHT
override val body: String get() = text
@ -280,7 +301,9 @@ sealed class BlockView : ViewType, Parcelable {
override val mode: Mode = Mode.EDIT,
override val isSelected: Boolean = false,
override val cursor: Int? = null,
override val alignment: Alignment? = null
override val alignment: Alignment? = null,
override val highlights: @RawValue Set<IntRange> = emptySet(),
override val target: @RawValue IntRange = IntRange.EMPTY
) : Text(), Checkable {
override fun getViewType() = HOLDER_CHECKBOX
override val body: String get() = text
@ -305,7 +328,9 @@ sealed class BlockView : ViewType, Parcelable {
override val mode: Mode = Mode.EDIT,
override val isSelected: Boolean = false,
override val cursor: Int? = null,
override val alignment: Alignment? = null
override val alignment: Alignment? = null,
override val highlights: @RawValue Set<IntRange> = emptySet(),
override val target: @RawValue IntRange = IntRange.EMPTY
) : Text() {
override fun getViewType() = HOLDER_BULLET
override val body: String get() = text
@ -331,6 +356,8 @@ sealed class BlockView : ViewType, Parcelable {
override val isSelected: Boolean = false,
override val cursor: Int? = null,
override val alignment: Alignment? = null,
override val highlights: @RawValue Set<IntRange> = emptySet(),
override val target: @RawValue IntRange = IntRange.EMPTY,
val number: Int
) : Text() {
override fun getViewType() = HOLDER_NUMBERED
@ -357,6 +384,8 @@ sealed class BlockView : ViewType, Parcelable {
override val isSelected: Boolean = false,
override val cursor: Int? = null,
override val alignment: Alignment? = null,
override val highlights: @RawValue Set<IntRange> = emptySet(),
override val target: @RawValue IntRange = IntRange.EMPTY,
val toggled: Boolean = false,
val isEmpty: Boolean = false
) : Text() {
@ -383,8 +412,10 @@ sealed class BlockView : ViewType, Parcelable {
val emoji: String? = null,
override val image: String? = null,
override val mode: Mode = Mode.EDIT,
override val cursor: Int? = null
) : BlockView.Title() {
override val cursor: Int? = null,
override val highlights: @RawValue Set<IntRange> = emptySet(),
override val target: @RawValue IntRange = IntRange.EMPTY,
) : BlockView.Title(), Searchable {
override fun getViewType() = HOLDER_TITLE
}
@ -401,8 +432,10 @@ sealed class BlockView : ViewType, Parcelable {
override var text: String?,
override val image: String? = null,
override val mode: Mode = Mode.EDIT,
override val cursor: Int? = null
) : BlockView.Title() {
override val cursor: Int? = null,
override val highlights: @RawValue Set<IntRange> = emptySet(),
override val target: @RawValue IntRange = IntRange.EMPTY,
) : BlockView.Title(), Searchable {
override fun getViewType() = HOLDER_PROFILE_TITLE
}

View file

@ -105,6 +105,11 @@ class BlockViewDiffUtil(
changes.add(ALIGNMENT_CHANGED)
}
if (newBlock is BlockView.Searchable && oldBlock is BlockView.Searchable) {
if (newBlock.highlights != oldBlock.highlights)
changes.add(SEARCH_HIGHLIGHT_CHANGED)
}
return if (changes.isNotEmpty())
Payload(changes).also { Timber.d("Returning payload: $it") }
else
@ -128,6 +133,7 @@ class BlockViewDiffUtil(
val isModeChanged: Boolean get() = changes.contains(READ_WRITE_MODE_CHANGED)
val isSelectionChanged: Boolean get() = changes.contains(SELECTION_CHANGED)
val isTitleIconChanged: Boolean get() = changes.contains(TITLE_ICON_CHANGED)
val isSearchHighlightChanged: Boolean get() = changes.contains(SEARCH_HIGHLIGHT_CHANGED)
val isAlignmentChanged: Boolean get() = changes.contains(ALIGNMENT_CHANGED)
fun markupChanged() = changes.contains(MARKUP_CHANGED)
@ -154,5 +160,6 @@ class BlockViewDiffUtil(
const val ALIGNMENT_CHANGED = 11
const val CURSOR_CHANGED = 12
const val TITLE_ICON_CHANGED = 13
const val SEARCH_HIGHLIGHT_CHANGED = 14
}
}

View file

@ -0,0 +1,125 @@
package com.anytypeio.anytype.core_ui.features.page
fun List<BlockView>.toReadMode(): List<BlockView> = map { view ->
when (view) {
is BlockView.Text.Paragraph -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Text.Checkbox -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Text.Bulleted -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Text.Numbered -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Text.Highlight -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Text.Header.One -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Text.Header.Two -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Text.Header.Three -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Text.Toggle -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Title.Document -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Title.Profile -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Title.Archive -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Code -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Error.File -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Error.Video -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Error.Picture -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Error.Bookmark -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Upload.File -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Upload.Video -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Upload.Picture -> view.copy(mode = BlockView.Mode.READ)
is BlockView.MediaPlaceholder.File -> view.copy(mode = BlockView.Mode.READ)
is BlockView.MediaPlaceholder.Video -> view.copy(mode = BlockView.Mode.READ)
is BlockView.MediaPlaceholder.Bookmark -> view.copy(mode = BlockView.Mode.READ)
is BlockView.MediaPlaceholder.Picture -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Media.File -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Media.Video -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Media.Bookmark -> view.copy(mode = BlockView.Mode.READ)
is BlockView.Media.Picture -> view.copy(mode = BlockView.Mode.READ)
else -> view
}
}
fun List<BlockView>.toEditMode(): List<BlockView> = map { view ->
when (view) {
is BlockView.Text.Paragraph -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Text.Checkbox -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Text.Bulleted -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Text.Numbered -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Text.Highlight -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Text.Header.One -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Text.Header.Two -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Text.Header.Three -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Text.Toggle -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Title.Document -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Title.Profile -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Title.Archive -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Code -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Error.File -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Error.Video -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Error.Picture -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Error.Bookmark -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Upload.File -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Upload.Video -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Upload.Picture -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.MediaPlaceholder.File -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.MediaPlaceholder.Video -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.MediaPlaceholder.Bookmark -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.MediaPlaceholder.Picture -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Media.File -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Media.Video -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Media.Bookmark -> view.copy(mode = BlockView.Mode.EDIT)
is BlockView.Media.Picture -> view.copy(mode = BlockView.Mode.EDIT)
else -> view
}
}
fun List<BlockView>.clearSearchHighlights(): List<BlockView> = map { view ->
when (view) {
is BlockView.Text.Paragraph -> view.copy(highlights = emptySet())
is BlockView.Text.Numbered -> view.copy(highlights = emptySet())
is BlockView.Text.Bulleted -> view.copy(highlights = emptySet())
is BlockView.Text.Checkbox -> view.copy(highlights = emptySet())
is BlockView.Text.Toggle -> view.copy(highlights = emptySet())
is BlockView.Text.Header.One -> view.copy(highlights = emptySet())
is BlockView.Text.Header.Two -> view.copy(highlights = emptySet())
is BlockView.Text.Header.Three -> view.copy(highlights = emptySet())
is BlockView.Text.Highlight -> view.copy(highlights = emptySet())
is BlockView.Title.Document -> view.copy(highlights = emptySet())
is BlockView.Title.Profile -> view.copy(highlights = emptySet())
else -> view
}
}
fun List<BlockView>.highlight(highlighter: (String) -> Set<IntRange>) = map { view ->
when (view) {
is BlockView.Text.Paragraph -> {
view.copy(highlights = highlighter(view.text))
}
is BlockView.Text.Numbered -> {
view.copy(highlights = highlighter(view.text))
}
is BlockView.Text.Bulleted -> {
view.copy(highlights = highlighter(view.text))
}
is BlockView.Text.Checkbox -> {
view.copy(highlights = highlighter(view.text))
}
is BlockView.Text.Toggle -> {
view.copy(highlights = highlighter(view.text))
}
is BlockView.Text.Header.One -> {
view.copy(highlights = highlighter(view.text))
}
is BlockView.Text.Header.Two -> {
view.copy(highlights = highlighter(view.text))
}
is BlockView.Text.Header.Three -> {
view.copy(highlights = highlighter(view.text))
}
is BlockView.Text.Highlight -> {
view.copy(highlights = highlighter(view.text))
}
is BlockView.Title.Document -> {
view.copy(highlights = highlighter(view.text ?: ""))
}
is BlockView.Title.Profile -> {
view.copy(highlights = highlighter(view.text ?: ""))
}
else -> view
}
}

View file

@ -1,12 +1,13 @@
package com.anytypeio.anytype.core_ui.features.page
import android.text.Editable
import android.text.Spannable
import android.widget.TextView
import com.anytypeio.anytype.core_ui.common.*
import com.anytypeio.anytype.core_ui.extensions.applyMovementMethod
import com.anytypeio.anytype.core_ui.extensions.cursorYBottomCoordinate
import com.anytypeio.anytype.core_ui.extensions.preserveSelection
import com.anytypeio.anytype.core_ui.extensions.range
import com.anytypeio.anytype.core_ui.extensions.applyMovementMethod
import com.anytypeio.anytype.core_ui.features.editor.holders.`interface`.TextHolder
import com.anytypeio.anytype.core_ui.menu.EditorContextMenu
import com.anytypeio.anytype.core_ui.tools.DefaultSpannableFactory
@ -14,6 +15,7 @@ import com.anytypeio.anytype.core_ui.tools.DefaultTextWatcher
import com.anytypeio.anytype.core_ui.tools.MentionTextWatcher
import com.anytypeio.anytype.core_ui.widgets.text.MentionSpan
import com.anytypeio.anytype.core_utils.ext.hideKeyboard
import com.anytypeio.anytype.core_utils.ext.removeSpans
import timber.log.Timber
/**
@ -193,7 +195,21 @@ interface TextBlockHolder : TextHolder {
)
}
} else if (payload.markupChanged()) {
setMarkup(item, clicked, item.getBlockTextColor())
content.pauseTextWatchers { setMarkup(item, clicked, item.getBlockTextColor()) }
}
if (payload.isSearchHighlightChanged) {
if (item is BlockView.Searchable) {
content.editableText.removeSpans<SearchHighlightSpan>()
item.highlights.forEach { highlight ->
content.editableText.setSpan(
SearchHighlightSpan(),
highlight.first,
highlight.last,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
if (payload.textColorChanged()) {
@ -210,19 +226,21 @@ interface TextBlockHolder : TextHolder {
}
if (payload.readWriteModeChanged()) {
if (item.mode == BlockView.Mode.EDIT) {
content.clearTextWatchers()
setupTextWatcher(item) { id, editable ->
item.apply {
text = editable.toString()
marks = editable.marks()
content.pauseTextWatchers {
if (item.mode == BlockView.Mode.EDIT) {
content.clearTextWatchers()
setupTextWatcher(item) { id, editable ->
item.apply {
text = editable.toString()
marks = editable.marks()
}
onTextChanged(item)
}
onTextChanged(item)
content.selectionWatcher = { onSelectionChanged(item.id, it) }
enableEditMode()
} else {
enableReadMode()
}
content.selectionWatcher = { onSelectionChanged(item.id, it) }
enableEditMode()
} else {
enableReadMode()
}
}

View file

@ -9,7 +9,6 @@ import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.core_ui.common.Markup
import com.anytypeio.anytype.core_ui.common.Span
import com.anytypeio.anytype.core_ui.common.ThemeColor
import com.anytypeio.anytype.core_ui.common.setMarkup
import com.anytypeio.anytype.core_ui.extensions.drawable
import com.anytypeio.anytype.core_ui.widgets.text.MentionSpan
import com.anytypeio.anytype.core_ui.widgets.text.TextInputWidget

View file

@ -11,7 +11,8 @@ class DocumentPopUpMenu(
onArchiveClicked: () -> Unit,
onRedoClicked: () -> Unit,
onUndoClicked: () -> Unit,
onEnterMultiSelect: () -> Unit
onEnterMultiSelect: () -> Unit,
onSearchClicked: () -> Unit
) : PopupMenu(context, view) {
init {
@ -22,6 +23,7 @@ class DocumentPopUpMenu(
R.id.undo -> onUndoClicked()
R.id.redo -> onRedoClicked()
R.id.select -> onEnterMultiSelect()
R.id.search -> onSearchClicked()
else -> throw IllegalStateException("Unexpected menu item: $item")
}
true

View file

@ -10,7 +10,8 @@ class ProfilePopUpMenu(
view: View,
onRedoClicked: () -> Unit,
onUndoClicked: () -> Unit,
onEnterMultiSelect: () -> Unit
onEnterMultiSelect: () -> Unit,
onSearchClicked: () -> Unit
) : PopupMenu(context, view) {
init {
@ -20,6 +21,7 @@ class ProfilePopUpMenu(
R.id.undo -> onUndoClicked()
R.id.redo -> onRedoClicked()
R.id.select -> onEnterMultiSelect()
R.id.search -> onSearchClicked()
else -> throw IllegalStateException("Unexpected menu item: $item")
}
true

View file

@ -1,7 +1,11 @@
package com.anytypeio.anytype.core_ui.reactive
import android.os.Looper
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.widget.EditText
import androidx.annotation.CheckResult
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.awaitClose
@ -19,6 +23,36 @@ fun View.clicks(): Flow<Unit> = callbackFlow<Unit> {
awaitClose { setOnClickListener(null) }
}.conflate()
@CheckResult
@OptIn(ExperimentalCoroutinesApi::class)
fun EditText.textChanges(): Flow<CharSequence> = callbackFlow<CharSequence> {
checkMainThread()
val listener = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
override fun afterTextChanged(s: Editable) = Unit
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
safeOffer(s)
}
}
addTextChangedListener(listener)
awaitClose { removeTextChangedListener(listener) }
}.conflate()
@CheckResult
@OptIn(ExperimentalCoroutinesApi::class)
fun EditText.afterTextChanges(): Flow<CharSequence> = callbackFlow<CharSequence> {
checkMainThread()
val listener = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable) {
safeOffer(s.toString())
}
}
addTextChangedListener(listener)
awaitClose { removeTextChangedListener(listener) }
}.conflate()
@UseExperimental(ExperimentalCoroutinesApi::class)
fun <E> SendChannel<E>.safeOffer(value: E): Boolean {
return runCatching { offer(value) }.getOrDefault(false)

View file

@ -3,7 +3,6 @@ package com.anytypeio.anytype.core_ui.state
import com.anytypeio.anytype.core_ui.common.Alignment
import com.anytypeio.anytype.core_ui.common.Markup
import com.anytypeio.anytype.core_ui.features.page.styling.StylingMode
import com.anytypeio.anytype.core_ui.features.page.styling.StylingType
import com.anytypeio.anytype.core_ui.model.StyleConfig
import com.anytypeio.anytype.core_ui.widgets.toolbar.adapter.Mention
@ -18,7 +17,8 @@ data class ControlPanelState(
val mainToolbar: Toolbar.Main,
val stylingToolbar: Toolbar.Styling,
val multiSelect: Toolbar.MultiSelect,
val mentionToolbar: Toolbar.MentionToolbar
val mentionToolbar: Toolbar.MentionToolbar,
val searchToolbar: Toolbar.SearchToolbar = Toolbar.SearchToolbar(isVisible = false)
) {
sealed class Toolbar {
@ -154,6 +154,13 @@ data class ControlPanelState(
)
}
}
/**
* Search toolbar.
*/
data class SearchToolbar(
override val isVisible: Boolean
) : Toolbar()
}
/**
@ -196,6 +203,9 @@ data class ControlPanelState(
updateList = false,
mentionFrom = null,
mentions = emptyList()
),
searchToolbar = Toolbar.SearchToolbar(
isVisible = false
)
)
}

View file

@ -0,0 +1,55 @@
package com.anytypeio.anytype.core_ui.widgets.toolbar
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.constraintlayout.widget.ConstraintLayout
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.core_ui.reactive.afterTextChanges
import com.anytypeio.anytype.core_ui.reactive.clicks
import kotlinx.android.synthetic.main.widget_doc_search_engine_toolbar.view.*
import kotlinx.coroutines.flow.*
class SearchToolbarWidget : ConstraintLayout {
constructor(
context: Context
) : this(context, null)
constructor(
context: Context,
attrs: AttributeSet?
) : this(context, attrs, 0)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int
) : super(context, attrs, defStyleAttr) {
inflate()
}
fun events(): Flow<Event> {
val next = docSearchNextSearchResult.clicks().map { Event.Next }
val previous = docSearchPreviousSearchResult.clicks().map { Event.Previous }
val cancel = docSearchCancelButton.clicks().map { Event.Cancel }
val clear = docSearchClearIcon.clicks().onEach { docSearchInputField.setText("") }
.map { Event.Clear }
val queries = docSearchInputField.afterTextChanges().map { Event.Query(it.toString()) }
return flowOf(cancel, clear, queries, next, previous).flattenMerge()
}
private fun inflate() {
LayoutInflater.from(context).inflate(R.layout.widget_doc_search_engine_toolbar, this)
}
sealed class Event {
object Clear : Event()
object Cancel : Event()
object Next : Event()
object Previous : Event()
data class Query(val query: String) : Event()
}
companion object
}

View file

@ -0,0 +1,14 @@
<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="M10.5,15.5C13.2614,15.5 15.5,13.2614 15.5,10.5C15.5,7.7386 13.2614,5.5 10.5,5.5C7.7386,5.5 5.5,7.7386 5.5,10.5C5.5,13.2614 7.7386,15.5 10.5,15.5ZM10.5,17C14.0899,17 17,14.0899 17,10.5C17,6.9102 14.0899,4 10.5,4C6.9102,4 4,6.9102 4,10.5C4,14.0899 6.9102,17 10.5,17Z"
android:fillColor="#ACA996"
android:fillType="evenOdd" />
<path
android:pathData="M14.2929,14.2929C14.6834,13.9024 15.3166,13.9024 15.7071,14.2929L19.7071,18.2929C20.0976,18.6834 20.0976,19.3166 19.7071,19.7071C19.3166,20.0976 18.6834,20.0976 18.2929,19.7071L14.2929,15.7071C13.9024,15.3166 13.9024,14.6834 14.2929,14.2929Z"
android:fillColor="#ACA996"
android:fillType="evenOdd" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="25dp"
android:height="24dp"
android:viewportWidth="25"
android:viewportHeight="24">
<path
android:pathData="M22.022,12C22.022,17.5228 17.5448,22 12.022,22C6.4991,22 2.022,17.5228 2.022,12C2.022,6.4771 6.4991,2 12.022,2C17.5448,2 22.022,6.4771 22.022,12ZM16.5876,7.4343C16.9,7.7467 16.9,8.2532 16.5876,8.5656L13.1533,11.9999L16.5877,15.4343C16.9001,15.7467 16.9001,16.2532 16.5877,16.5656C16.2753,16.8781 15.7688,16.8781 15.4563,16.5656L12.022,13.1313L8.5876,16.5656C8.2752,16.8781 7.7687,16.8781 7.4562,16.5656C7.1438,16.2532 7.1438,15.7467 7.4562,15.4343L10.8906,11.9999L7.4563,8.5656C7.1439,8.2532 7.1439,7.7467 7.4563,7.4343C7.7688,7.1219 8.2753,7.1219 8.5877,7.4343L12.022,10.8685L15.4562,7.4343C15.7687,7.1219 16.2752,7.1219 16.5876,7.4343Z"
android:fillColor="#ACA996"
android:fillType="evenOdd" />
</vector>

View file

@ -0,0 +1,10 @@
<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="M20.7071,8.2929C20.3166,7.9023 19.6834,7.9023 19.2929,8.2929L12,15.5858L4.7071,8.2929C4.3166,7.9023 3.6834,7.9023 3.2929,8.2929C2.9024,8.6834 2.9024,9.3166 3.2929,9.7071L12,18.4142L20.7071,9.7071C21.0976,9.3166 21.0976,8.6834 20.7071,8.2929Z"
android:fillColor="#ACA996"
android:fillType="evenOdd" />
</vector>

View file

@ -0,0 +1,10 @@
<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="M3.2929,15.7071C3.6834,16.0977 4.3166,16.0977 4.7071,15.7071L12,8.4142L19.2929,15.7071C19.6834,16.0977 20.3166,16.0977 20.7071,15.7071C21.0976,15.3166 21.0976,14.6834 20.7071,14.2929L12,5.5858L3.2929,14.2929C2.9024,14.6834 2.9024,15.3166 3.2929,15.7071Z"
android:fillColor="#ACA996"
android:fillType="evenOdd" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="8dp" />
<solid android:color="#F3F2EC" />
</shape>

View file

@ -0,0 +1,93 @@
<?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"
android:layout_width="match_parent"
android:background="@color/white"
android:layout_height="52dp">
<ImageView
android:id="@+id/docSearchPreviousSearchResult"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:contentDescription="@string/content_description_document_previous_search_result_icon"
android:src="@drawable/ic_search_previous_result"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/docSearchNextSearchResult"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:contentDescription="@string/content_description_document_next_search_result_icon"
android:src="@drawable/ic_search_next_result"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/docSearchPreviousSearchResult"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/docSearchCancelButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@string/cancel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/docSearchRectangle"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:background="@drawable/rectangle_doc_search"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/docSearchCancelButton"
app:layout_constraintStart_toEndOf="@+id/docSearchNextSearchResult"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/docSearchSearchIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:contentDescription="@string/content_description_document_search_icon"
android:src="@drawable/ic_doc_search"
app:layout_constraintBottom_toBottomOf="@+id/docSearchRectangle"
app:layout_constraintStart_toStartOf="@+id/docSearchRectangle"
app:layout_constraintTop_toTopOf="@+id/docSearchRectangle" />
<ImageView
android:id="@+id/docSearchClearIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:contentDescription="@string/content_description_document_clear_search_icon"
android:src="@drawable/ic_doc_search_delete"
app:layout_constraintBottom_toBottomOf="@+id/docSearchRectangle"
app:layout_constraintEnd_toEndOf="@+id/docSearchRectangle"
app:layout_constraintTop_toTopOf="@+id/docSearchRectangle" />
<EditText
android:id="@+id/docSearchInputField"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:background="@null"
android:maxLines="1"
android:singleLine="true"
android:fontFamily="@font/inter_regular"
android:hint="@string/your_search_query"
android:textSize="15sp"
app:layout_constraintBottom_toBottomOf="@+id/docSearchRectangle"
app:layout_constraintEnd_toStartOf="@+id/docSearchClearIcon"
app:layout_constraintStart_toEndOf="@+id/docSearchSearchIcon"
app:layout_constraintTop_toTopOf="@+id/docSearchRectangle" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -12,4 +12,7 @@
<item
android:id="@+id/select"
android:title="@string/select" />
<item
android:id="@+id/search"
android:title="@string/search" />
</menu>

View file

@ -26,6 +26,10 @@
<string name="content_description_bookmark_image">Bookmark image</string>
<string name="content_description_toggle_icon">Toggle icon</string>
<string name="content_description_turn_into_arrow">Turn into arrow</string>
<string name="content_description_document_previous_search_result_icon">Document previous search result icon</string>
<string name="content_description_document_next_search_result_icon">Document next search result icon</string>
<string name="content_description_document_search_icon">Document search icon</string>
<string name="content_description_document_clear_search_icon">Document clear search icon</string>
<string name="option_toolbar_header_turn_into">Turn into</string>
<string name="option_toolbar_header_add_block">Add block</string>
@ -213,6 +217,7 @@
<string name="redo">Redo</string>
<string name="copy">Copy</string>
<string name="select">Select</string>
<string name="search">Search</string>
<string name="choose_emoji">Choose emoji</string>
<string name="logo_transition">logo_transition</string>
@ -227,6 +232,7 @@
<string name="move">Move</string>
<string name="mention_suggester_new_page">Create new page</string>
<string name="widget_archive_select_pages">Select pages</string>
<string name="your_search_query">Search</string>
<plurals name="page_selected">
<item quantity="zero">Select pages</item>
<item quantity="one">%d page selected</item>

View file

@ -5,7 +5,6 @@ import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.text.Editable
import android.text.InputType
import android.view.inputmethod.EditorInfo
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.marginLeft
import androidx.recyclerview.widget.LinearLayoutManager
@ -28,7 +27,9 @@ import com.anytypeio.anytype.core_ui.features.editor.holders.text.*
import com.anytypeio.anytype.core_ui.features.editor.holders.upload.FileUpload
import com.anytypeio.anytype.core_ui.features.editor.holders.upload.PictureUpload
import com.anytypeio.anytype.core_ui.features.editor.holders.upload.VideoUpload
import com.anytypeio.anytype.core_ui.features.page.*
import com.anytypeio.anytype.core_ui.features.page.BlockAdapter
import com.anytypeio.anytype.core_ui.features.page.BlockView
import com.anytypeio.anytype.core_ui.features.page.BlockViewDiffUtil
import com.anytypeio.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.BACKGROUND_COLOR_CHANGED
import com.anytypeio.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.CURSOR_CHANGED
import com.anytypeio.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.FOCUS_CHANGED
@ -36,6 +37,7 @@ import com.anytypeio.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.R
import com.anytypeio.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.SELECTION_CHANGED
import com.anytypeio.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.TEXT_CHANGED
import com.anytypeio.anytype.core_ui.features.page.BlockViewDiffUtil.Companion.TEXT_COLOR_CHANGED
import com.anytypeio.anytype.core_ui.features.page.BlockViewHolder
import com.anytypeio.anytype.core_ui.tools.ClipboardInterceptor
import com.anytypeio.anytype.core_utils.ext.dimen
import com.anytypeio.anytype.core_utils.ext.hexColorCode
@ -1495,7 +1497,7 @@ class BlockAdapterTest {
// Setup
val events = mutableListOf<Editable>()
val events = mutableListOf<BlockView.Title>()
val title = BlockView.Title.Document(
text = MockDataFactory.randomString(),
@ -1507,9 +1509,7 @@ class BlockAdapterTest {
val adapter = buildAdapter(
views = views,
onTitleTextChanged = { editable ->
events.add(editable)
}
onTitleBlockTextChanged = { events.add(it) }
)
val recycler = RecyclerView(context).apply {
@ -1526,9 +1526,11 @@ class BlockAdapterTest {
assertTrue { events.isEmpty() }
holder.content.setText(MockDataFactory.randomString())
val changed = MockDataFactory.randomString()
assertTrue { events.size == 1 && events.first() == holder.content.text }
holder.content.setText(changed)
assertTrue { events.size == 1 && events.first() == title.copy(text = changed) }
}
/**
@ -3346,7 +3348,7 @@ class BlockAdapterTest {
views: List<BlockView>,
onSplitLineEnterClicked: (String, Editable, IntRange) -> Unit = { _, _, _ -> },
onFocusChanged: (String, Boolean) -> Unit = { _, _ -> },
onTitleTextChanged: (Editable) -> Unit = {},
onTitleBlockTextChanged: (BlockView.Title) -> Unit = {},
onTextChanged: (String, Editable) -> Unit = { _, _ -> }
): BlockAdapter {
return BlockAdapter(
@ -3364,7 +3366,7 @@ class BlockAdapterTest {
onTogglePlaceholderClicked = {},
onToggleClicked = {},
onTextBlockTextChanged = {},
onTitleTextChanged = onTitleTextChanged,
onTitleBlockTextChanged = onTitleBlockTextChanged,
onContextMenuStyleClick = {},
onTitleTextInputClicked = {},
onClickListener = {},

View file

@ -816,4 +816,38 @@ class BlockViewDiffUtilTest {
actual = payload
)
}
@Test
fun `should detect search highlight changes in paragraph block`() {
val index = 0
val id = MockDataFactory.randomUuid()
val oldBlock = BlockView.Text.Paragraph(
id = id,
highlights = emptySet(),
text = MockDataFactory.randomString()
)
val newBlock: BlockView = oldBlock.copy(
highlights = setOf(0..1)
)
val old = listOf(oldBlock)
val new = listOf(newBlock)
val diff = BlockViewDiffUtil(old = old, new = new)
val payload = diff.getChangePayload(index, index)
val expected = Payload(
changes = listOf(BlockViewDiffUtil.SEARCH_HIGHLIGHT_CHANGED)
)
assertEquals(
expected = expected,
actual = payload
)
}
}

View file

@ -371,7 +371,7 @@ class HeaderBlockTest {
onTogglePlaceholderClicked = {},
onToggleClicked = {},
onTextBlockTextChanged = {},
onTitleTextChanged = onTitleTextChanged,
onTitleBlockTextChanged = {},
onContextMenuStyleClick = {},
onTitleTextInputClicked = {},
onClickListener = {},

View file

@ -107,7 +107,7 @@ class HighlightingBlockTest {
onTogglePlaceholderClicked = {},
onToggleClicked = {},
onTextBlockTextChanged = {},
onTitleTextChanged = onTitleTextChanged,
onTitleBlockTextChanged = {},
onContextMenuStyleClick = {},
onTitleTextInputClicked = {},
onClickListener = {},

View file

@ -371,7 +371,7 @@ class BlockAdapterCursorBindingTest {
onTogglePlaceholderClicked = {},
onToggleClicked = {},
onTextBlockTextChanged = {},
onTitleTextChanged = onTitleTextChanged,
onTitleBlockTextChanged = {},
onContextMenuStyleClick = {},
onTitleTextInputClicked = {},
onClickListener = {},

View file

@ -42,7 +42,7 @@ open class BlockAdapterTestSetup {
onTogglePlaceholderClicked = {},
onToggleClicked = onToggleClicked,
onTextBlockTextChanged = onTextBlockTextChanged,
onTitleTextChanged = onTitleTextChanged,
onTitleBlockTextChanged = {},
onContextMenuStyleClick = {},
onTitleTextInputClicked = {},
onClickListener = {},

View file

@ -1,5 +1,5 @@
package com.anytypeio.anytype.domain.page
enum class EditorMode {
EDITING, MULTI_SELECT, SCROLL_AND_MOVE, ACTION_MODE
EDITING, MULTI_SELECT, SCROLL_AND_MOVE, ACTION_MODE, SEARCH
}

View file

@ -124,9 +124,16 @@ sealed class ControlPanelMachine {
val style: Block.Content.Text.Style
) : Event()
data class OnBlockActionToolbarStyleClicked(val target: Block,
val focused: Boolean,
val selection: IntRange?) : Event()
data class OnBlockActionToolbarStyleClicked(
val target: Block,
val focused: Boolean,
val selection: IntRange?
) : Event()
sealed class SearchToolbar : Event() {
object OnEnterSearchMode : SearchToolbar()
object OnExitSearchMode : SearchToolbar()
}
/**
* Styling-toolbar-related events
@ -299,6 +306,21 @@ sealed class ControlPanelMachine {
is Event.MultiSelect -> {
handleMultiSelectEvent(event, state)
}
is Event.SearchToolbar.OnEnterSearchMode -> state.copy(
searchToolbar = Toolbar.SearchToolbar(isVisible = true),
mainToolbar = Toolbar.Main(isVisible = false),
multiSelect = Toolbar.MultiSelect(
isVisible = false,
isScrollAndMoveEnabled = false,
count = 0
),
stylingToolbar = Toolbar.Styling.reset(),
navigationToolbar = Toolbar.Navigation(isVisible = false)
)
is Event.SearchToolbar.OnExitSearchMode -> state.copy(
searchToolbar = state.searchToolbar.copy(isVisible = false),
navigationToolbar = state.navigationToolbar.copy(isVisible = true)
)
is Event.SAM -> {
handleScrollAndMoveEvent(event, state)
}

View file

@ -36,6 +36,7 @@ import com.anytypeio.anytype.core_ui.features.page.styling.StylingMode
import com.anytypeio.anytype.core_ui.model.UiBlock
import com.anytypeio.anytype.core_ui.state.ControlPanelState
import com.anytypeio.anytype.core_ui.widgets.ActionItemType
import com.anytypeio.anytype.core_ui.widgets.toolbar.SearchToolbarWidget
import com.anytypeio.anytype.core_ui.widgets.toolbar.adapter.Mention
import com.anytypeio.anytype.core_ui.widgets.toolbar.adapter.MentionAdapter
import com.anytypeio.anytype.core_ui.widgets.toolbar.adapter.getMentionName
@ -73,6 +74,7 @@ import com.anytypeio.anytype.presentation.page.editor.*
import com.anytypeio.anytype.presentation.page.model.TextUpdate
import com.anytypeio.anytype.presentation.page.render.BlockViewRenderer
import com.anytypeio.anytype.presentation.page.render.DefaultBlockViewRenderer
import com.anytypeio.anytype.presentation.page.search.search
import com.anytypeio.anytype.presentation.page.selection.SelectionStateHolder
import com.anytypeio.anytype.presentation.page.toggle.ToggleStateHolder
import com.anytypeio.anytype.presentation.util.Bridge
@ -82,6 +84,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.regex.Pattern
class PageViewModel(
private val openPage: OpenPage,
@ -648,8 +651,15 @@ class PageViewModel(
viewModelScope.launch { orchestrator.proxies.changes.send(update) }
}
fun onTitleTextChanged(text: String) {
viewModelScope.launch { titleChannel.send(text) }
fun onTitleBlockTextChanged(view: BlockView.Title) {
val new = views.map { if (it.id == view.id) view else it }
val update = TextUpdate.Default(
target = view.id,
text = view.text ?: EMPTY_TEXT,
markup = emptyList()
)
viewModelScope.launch { orchestrator.stores.views.update(new) }
viewModelScope.launch { orchestrator.proxies.changes.send(update) }
}
fun onTextBlockTextChanged(
@ -1331,6 +1341,39 @@ class PageViewModel(
}
}
fun onEnterSearchModeClicked() {
mode = EditorMode.SEARCH
viewModelScope.launch { orchestrator.stores.views.update(views.toReadMode()) }
viewModelScope.launch { renderCommand.send(Unit) }
viewModelScope.launch { controlPanelInteractor.onEvent(ControlPanelMachine.Event.SearchToolbar.OnEnterSearchMode) }
}
fun onSearchToolbarEvent(event: SearchToolbarWidget.Event) {
when (event) {
is SearchToolbarWidget.Event.Query -> {
val query = event.query.trim()
val update = if (query.isEmpty()) {
views.clearSearchHighlights()
} else {
val flags = Pattern.MULTILINE or Pattern.CASE_INSENSITIVE
val escaped = Pattern.quote(query)
val pattern = Pattern.compile(escaped, flags)
views.highlight { txt -> txt.search(pattern) }
}
viewModelScope.launch { orchestrator.stores.views.update(update) }
viewModelScope.launch { renderCommand.send(Unit) }
}
is SearchToolbarWidget.Event.Cancel -> {
mode = EditorMode.EDITING
val update = views.clearSearchHighlights().toEditMode()
viewModelScope.launch { orchestrator.stores.views.update(update) }
viewModelScope.launch { renderCommand.send(Unit) }
controlPanelInteractor.onEvent(ControlPanelMachine.Event.SearchToolbar.OnExitSearchMode)
}
else -> _toasts.offer("not implemented")
}
}
fun onAddTextBlockClicked(style: Content.Text.Style) {
val target = blocks.first { it.id == orchestrator.stores.focus.current().id }
@ -2768,6 +2811,7 @@ class PageViewModel(
}
companion object {
const val EMPTY_TEXT = ""
const val EMPTY_CONTEXT = ""
const val EMPTY_FOCUS_ID = ""
const val TEXT_CHANGES_DEBOUNCE_DURATION = 500L

View file

@ -0,0 +1,12 @@
package com.anytypeio.anytype.presentation.page.search
import java.util.regex.Pattern
fun String.search(pattern: Pattern): Set<IntRange> {
val result = mutableSetOf<IntRange>()
val matcher = pattern.matcher(this)
while (matcher.find()) {
result.add(matcher.start()..matcher.end())
}
return result
}

View file

@ -3377,68 +3377,6 @@ open class PageViewModelTest {
}
}
@Test
fun `should start updating title on title-text-changed event with delay`() {
// SETUP
val root = MockDataFactory.randomUuid()
val title = MockBlockFactory.makeTitleBlock()
val page = listOf(
Block(
id = root,
fields = Block.Fields.empty(),
content = Block.Content.Smart(Block.Content.Smart.Type.PAGE),
children = listOf(title.id)
),
title
)
val flow: Flow<List<Event.Command>> = flow {
delay(100)
emit(
listOf(
Event.Command.ShowBlock(
root = root,
blocks = page,
context = root
)
)
)
}
stubObserveEvents(flow)
stubOpenPage()
buildViewModel()
stubUpdateTitle()
vm.onStart(root)
coroutineTestRule.advanceTime(100)
// TESTING
val update = MockDataFactory.randomString()
vm.onTitleTextChanged(update)
runBlockingTest {
verify(updateTitle, never()).invoke(
params = any()
)
}
coroutineTestRule.advanceTime(PageViewModel.TEXT_CHANGES_DEBOUNCE_DURATION)
runBlockingTest {
verify(updateTitle, times(1)).invoke(
params = any()
)
}
}
@Test
fun `should enter multi-select mode and select blocks`() {

View file

@ -2,12 +2,16 @@ package com.anytypeio.anytype.presentation.page.editor
import MockDataFactory
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.anytypeio.anytype.core_ui.features.page.BlockView
import com.anytypeio.anytype.core_ui.state.ControlPanelState
import com.anytypeio.anytype.domain.block.interactor.UpdateText
import com.anytypeio.anytype.domain.block.model.Block
import com.anytypeio.anytype.domain.event.interactor.InterceptEvents
import com.anytypeio.anytype.presentation.page.PageViewModel
import com.anytypeio.anytype.presentation.util.CoroutinesTestRule
import com.jraska.livedata.test
import com.nhaarman.mockitokotlin2.verifyBlocking
import com.nhaarman.mockitokotlin2.verifyZeroInteractions
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runBlockingTest
@ -168,4 +172,57 @@ class EditorTitleTest : EditorPresentationTestSetup() {
}
}
@Test
fun `should start updating title on title-text-changed event with delay`() {
// SETUP
val page = listOf(
Block(
id = root,
fields = Block.Fields.empty(),
content = Block.Content.Smart(Block.Content.Smart.Type.PAGE),
children = listOf(header.id)
),
header,
title
)
stubInterceptEvents()
stubOpenDocument(page)
stubUpdateText()
val vm = buildViewModel()
vm.onStart(root)
// TESTING
val update = MockDataFactory.randomString()
vm.onTitleBlockTextChanged(
BlockView.Title.Document(
id = title.id,
text = update
)
)
verifyZeroInteractions(updateTitle)
verifyZeroInteractions(updateText)
coroutineTestRule.advanceTime(PageViewModel.TEXT_CHANGES_DEBOUNCE_DURATION)
verifyZeroInteractions(updateTitle)
verifyBlocking(updateText) {
invoke(
UpdateText.Params(
context = root,
text = update,
target = title.id,
marks = emptyList()
)
)
}
}
}

View file

@ -0,0 +1,93 @@
package com.anytypeio.anytype.presentation.page.search
import org.junit.Test
import java.util.regex.Pattern
import kotlin.test.assertEquals
class DocumentSearchEngineTest {
@Test
fun `should find nothing`() {
val text = "FooBarFoo"
val query = "Far"
val pattern = Pattern.compile(query, Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
val result = text.search(pattern)
assertEquals(
expected = setOf(),
actual = result
)
}
@Test
fun `should find 'foo' twice at the start and at the end`() {
val text = "FooBarFoo"
val query = "Foo"
val pattern = Pattern.compile(query, Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
val result = text.search(pattern)
assertEquals(
expected = setOf(0..3, 6..9),
actual = result
)
}
@Test
fun `should find 'bar' once in the middle`() {
val text = "FooBarFoo"
val query = "Bar"
val pattern = Pattern.compile(query, Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
val result = text.search(pattern)
assertEquals(
expected = setOf(3..6),
actual = result
)
}
@Test
fun `should find 'bar' surrounded by empty spaces only once in the middle`() {
val text = "Foo Bar Foo"
val query = "Bar"
val pattern = Pattern.compile(query, Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
val result = text.search(pattern)
assertEquals(
expected = setOf(4..7),
actual = result
)
}
@Test
fun `should find 'f' in all words case-insensitive`() {
val text = "Five fast flying machines"
val query = "f"
val pattern = Pattern.compile(query, Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
val result = text.search(pattern)
assertEquals(
expected = setOf(0..1, 5..6, 10..11),
actual = result
)
}
}

View file

@ -19,12 +19,16 @@
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning">
<activity android:name=".DisabledAnimationActivity">
<activity
android:name=".search.SearchOnPageActivity"
android:windowSoftInputMode="stateHidden|adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".DisabledAnimationActivity" />
<activity android:name=".ScrollAndMove" />
<activity android:name=".StyleActivity" />
<activity android:name=".MainActivity" />

View file

@ -0,0 +1,36 @@
package com.anytypeio.anytype.sample.search
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.doOnTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import com.anytypeio.anytype.sample.R
import kotlinx.android.synthetic.main.activity_search_on_page.*
class SearchOnPageActivity : AppCompatActivity(R.layout.activity_search_on_page) {
private val items = mutableListOf<SearchOnPageAdapter.Item>().apply {
repeat(10) {
add(
SearchOnPageAdapter.Item(
id = it,
txt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
)
)
}
}
private val mockAdapter = SearchOnPageAdapter(
items = items
)
override fun onStart() {
super.onStart()
recycler.apply {
layoutManager = LinearLayoutManager(context)
adapter = mockAdapter
}
search.doOnTextChanged { text, start, before, count ->
}
}
}

View file

@ -0,0 +1,36 @@
package com.anytypeio.anytype.sample.search
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.anytypeio.anytype.sample.R
import com.anytypeio.anytype.sample.adapter.AbstractAdapter
import com.anytypeio.anytype.sample.adapter.AbstractHolder
import kotlinx.android.synthetic.main.item_editable.view.*
class SearchOnPageAdapter(
private var items: List<Item>
) : AbstractAdapter<SearchOnPageAdapter.Item>(items) {
override fun update(update: List<Item>) {
this.items = update
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AbstractHolder<Item> {
val inflater = LayoutInflater.from(parent.context)
val view = inflater.inflate(R.layout.item_editable, parent, false)
return ItemViewHolder(view)
}
data class Item(
val id: Int,
val txt: String
)
class ItemViewHolder(view: View) : AbstractHolder<Item>(view) {
override fun bind(item: Item) {
itemView.input.setText(item.txt)
}
}
}

View file

@ -0,0 +1,35 @@
<?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="match_parent"
tools:context=".search.SearchOnPageActivity">
<EditText
android:fontFamily="monospace"
android:id="@+id/search"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:fontFamily="monospace"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/search" />
</androidx.constraintlayout.widget.ConstraintLayout>