From 004e49906c05dd327c4909cd14ff85c142090a0b Mon Sep 17 00:00:00 2001 From: Evgenii Kozlov Date: Fri, 16 Oct 2020 14:31:01 +0300 Subject: [PATCH] Feature/search on page. First iteration (#998) --- .../anytypeio/anytype/ui/page/PageFragment.kt | 24 +++- app/src/main/res/layout/fragment_page.xml | 9 ++ .../core_ui/common/SearchHighlightSpan.kt | 10 ++ .../features/editor/holders/other/Title.kt | 51 +++++-- .../features/editor/holders/text/Text.kt | 13 +- .../core_ui/features/page/BlockAdapter.kt | 6 +- .../core_ui/features/page/BlockView.kt | 57 ++++++-- .../features/page/BlockViewDiffUtil.kt | 7 + .../core_ui/features/page/BlockViewExt.kt | 125 ++++++++++++++++++ .../core_ui/features/page/TextBlockHolder.kt | 44 ++++-- .../features/page/models/TextBlockHelper.kt | 1 - .../anytype/core_ui/menu/DocumentPopUpMenu.kt | 4 +- .../anytype/core_ui/menu/ProfilePopUpMenu.kt | 4 +- .../core_ui/reactive/ViewClickedFlow.kt | 34 +++++ .../core_ui/state/ControlPanelState.kt | 14 +- .../widgets/toolbar/SearchToolbarWidget.kt | 55 ++++++++ .../src/main/res/drawable/ic_doc_search.xml | 14 ++ .../res/drawable/ic_doc_search_delete.xml | 10 ++ .../res/drawable/ic_search_next_result.xml | 10 ++ .../drawable/ic_search_previous_result.xml | 10 ++ .../res/drawable/rectangle_doc_search.xml | 6 + .../widget_doc_search_engine_toolbar.xml | 93 +++++++++++++ core-ui/src/main/res/menu/menu_page.xml | 3 + core-ui/src/main/res/values/strings.xml | 6 + .../anytype/core_ui/BlockAdapterTest.kt | 22 +-- .../anytype/core_ui/BlockViewDiffUtilTest.kt | 34 +++++ .../anytype/core_ui/HeaderBlockTest.kt | 2 +- .../anytype/core_ui/HighlightingBlockTest.kt | 2 +- .../editor/BlockAdapterCursorBindingTest.kt | 2 +- .../features/editor/BlockAdapterTestSetup.kt | 2 +- .../anytype/domain/page/EditorMode.kt | 2 +- .../presentation/page/ControlPanelMachine.kt | 28 +++- .../presentation/page/PageViewModel.kt | 48 ++++++- .../page/search/DocumentSearchEngine.kt | 12 ++ .../presentation/page/PageViewModelTest.kt | 62 --------- .../page/editor/EditorTitleTest.kt | 57 ++++++++ .../page/search/DocumentSearchEngineTest.kt | 93 +++++++++++++ sample/src/main/AndroidManifest.xml | 6 +- .../sample/search/SearchOnPageActivity.kt | 36 +++++ .../sample/search/SearchOnPageAdapter.kt | 36 +++++ .../res/layout/activity_search_on_page.xml | 35 +++++ 41 files changed, 957 insertions(+), 132 deletions(-) create mode 100644 core-ui/src/main/java/com/anytypeio/anytype/core_ui/common/SearchHighlightSpan.kt create mode 100644 core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockViewExt.kt create mode 100644 core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/toolbar/SearchToolbarWidget.kt create mode 100644 core-ui/src/main/res/drawable/ic_doc_search.xml create mode 100644 core-ui/src/main/res/drawable/ic_doc_search_delete.xml create mode 100644 core-ui/src/main/res/drawable/ic_search_next_result.xml create mode 100644 core-ui/src/main/res/drawable/ic_search_previous_result.xml create mode 100644 core-ui/src/main/res/drawable/rectangle_doc_search.xml create mode 100644 core-ui/src/main/res/layout/widget_doc_search_engine_toolbar.xml create mode 100644 presentation/src/main/java/com/anytypeio/anytype/presentation/page/search/DocumentSearchEngine.kt create mode 100644 presentation/src/test/java/com/anytypeio/anytype/presentation/page/search/DocumentSearchEngineTest.kt create mode 100644 sample/src/main/java/com/anytypeio/anytype/sample/search/SearchOnPageActivity.kt create mode 100644 sample/src/main/java/com/anytypeio/anytype/sample/search/SearchOnPageAdapter.kt create mode 100644 sample/src/main/res/layout/activity_search_on_page.xml diff --git a/app/src/main/java/com/anytypeio/anytype/ui/page/PageFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/page/PageFragment.kt index 0867f8c611..1b76ddedf1 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/page/PageFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/page/PageFragment.kt @@ -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) { diff --git a/app/src/main/res/layout/fragment_page.xml b/app/src/main/res/layout/fragment_page.xml index 965df9d316..c9e99b0d8b 100644 --- a/app/src/main/res/layout/fragment_page.xml +++ b/app/src/main/res/layout/fragment_page.xml @@ -50,6 +50,15 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + () + 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) + } } } } diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/editor/holders/text/Text.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/editor/holders/text/Text.kt index 19f8334d3f..8de62e5cc1 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/editor/holders/text/Text.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/editor/holders/text/Text.kt @@ -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, diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockAdapter.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockAdapter.kt index 99337e3c15..a7621619f2 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockAdapter.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockAdapter.kt @@ -74,7 +74,7 @@ class BlockAdapter( private var blocks: List, 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 ) diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockView.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockView.kt index 438b863430..983df0413b 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockView.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockView.kt @@ -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 + 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = emptySet(), + override val target: @RawValue IntRange = IntRange.EMPTY, + ) : BlockView.Title(), Searchable { override fun getViewType() = HOLDER_PROFILE_TITLE } diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockViewDiffUtil.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockViewDiffUtil.kt index 48632b6f70..7c5f2efe62 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockViewDiffUtil.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockViewDiffUtil.kt @@ -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 } } \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockViewExt.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockViewExt.kt new file mode 100644 index 0000000000..fc8482cff6 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/BlockViewExt.kt @@ -0,0 +1,125 @@ +package com.anytypeio.anytype.core_ui.features.page + +fun List.toReadMode(): List = 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.toEditMode(): List = 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.clearSearchHighlights(): List = 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.highlight(highlighter: (String) -> Set) = 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 + } +} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/TextBlockHolder.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/TextBlockHolder.kt index 9acd6867cf..45acba0bb4 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/TextBlockHolder.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/TextBlockHolder.kt @@ -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() + 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() } } diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/models/TextBlockHelper.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/models/TextBlockHelper.kt index fab56821f2..6d361930a7 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/models/TextBlockHelper.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/page/models/TextBlockHelper.kt @@ -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 diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/menu/DocumentPopUpMenu.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/menu/DocumentPopUpMenu.kt index 723d825844..d7ea6816da 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/menu/DocumentPopUpMenu.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/menu/DocumentPopUpMenu.kt @@ -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 diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/menu/ProfilePopUpMenu.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/menu/ProfilePopUpMenu.kt index 9da1836965..5d0c39a385 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/menu/ProfilePopUpMenu.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/menu/ProfilePopUpMenu.kt @@ -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 diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/reactive/ViewClickedFlow.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/reactive/ViewClickedFlow.kt index 1b2c450f9f..e27c9915db 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/reactive/ViewClickedFlow.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/reactive/ViewClickedFlow.kt @@ -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 = callbackFlow { awaitClose { setOnClickListener(null) } }.conflate() +@CheckResult +@OptIn(ExperimentalCoroutinesApi::class) +fun EditText.textChanges(): Flow = callbackFlow { + 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 = callbackFlow { + 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 SendChannel.safeOffer(value: E): Boolean { return runCatching { offer(value) }.getOrDefault(false) diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/state/ControlPanelState.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/state/ControlPanelState.kt index 42ec9ff031..479d31295c 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/state/ControlPanelState.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/state/ControlPanelState.kt @@ -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 ) ) } diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/toolbar/SearchToolbarWidget.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/toolbar/SearchToolbarWidget.kt new file mode 100644 index 0000000000..9cfcfb6376 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/toolbar/SearchToolbarWidget.kt @@ -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 { + 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 +} \ No newline at end of file diff --git a/core-ui/src/main/res/drawable/ic_doc_search.xml b/core-ui/src/main/res/drawable/ic_doc_search.xml new file mode 100644 index 0000000000..faeb956afd --- /dev/null +++ b/core-ui/src/main/res/drawable/ic_doc_search.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core-ui/src/main/res/drawable/ic_doc_search_delete.xml b/core-ui/src/main/res/drawable/ic_doc_search_delete.xml new file mode 100644 index 0000000000..010dda8425 --- /dev/null +++ b/core-ui/src/main/res/drawable/ic_doc_search_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/core-ui/src/main/res/drawable/ic_search_next_result.xml b/core-ui/src/main/res/drawable/ic_search_next_result.xml new file mode 100644 index 0000000000..0e599626f2 --- /dev/null +++ b/core-ui/src/main/res/drawable/ic_search_next_result.xml @@ -0,0 +1,10 @@ + + + diff --git a/core-ui/src/main/res/drawable/ic_search_previous_result.xml b/core-ui/src/main/res/drawable/ic_search_previous_result.xml new file mode 100644 index 0000000000..6602f6a94e --- /dev/null +++ b/core-ui/src/main/res/drawable/ic_search_previous_result.xml @@ -0,0 +1,10 @@ + + + diff --git a/core-ui/src/main/res/drawable/rectangle_doc_search.xml b/core-ui/src/main/res/drawable/rectangle_doc_search.xml new file mode 100644 index 0000000000..ee97351fe7 --- /dev/null +++ b/core-ui/src/main/res/drawable/rectangle_doc_search.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core-ui/src/main/res/layout/widget_doc_search_engine_toolbar.xml b/core-ui/src/main/res/layout/widget_doc_search_engine_toolbar.xml new file mode 100644 index 0000000000..1cd8e91c3a --- /dev/null +++ b/core-ui/src/main/res/layout/widget_doc_search_engine_toolbar.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core-ui/src/main/res/menu/menu_page.xml b/core-ui/src/main/res/menu/menu_page.xml index dc75891358..de7394fddb 100644 --- a/core-ui/src/main/res/menu/menu_page.xml +++ b/core-ui/src/main/res/menu/menu_page.xml @@ -12,4 +12,7 @@ + \ No newline at end of file diff --git a/core-ui/src/main/res/values/strings.xml b/core-ui/src/main/res/values/strings.xml index cea3d55e70..7f1c9351fa 100644 --- a/core-ui/src/main/res/values/strings.xml +++ b/core-ui/src/main/res/values/strings.xml @@ -26,6 +26,10 @@ Bookmark image Toggle icon Turn into arrow + Document previous search result icon + Document next search result icon + Document search icon + Document clear search icon Turn into Add block @@ -213,6 +217,7 @@ Redo Copy Select + Search Choose emoji logo_transition @@ -227,6 +232,7 @@ Move Create new page Select pages + Search Select pages %d page selected diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockAdapterTest.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockAdapterTest.kt index 651be3c1bf..d52bbf1668 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockAdapterTest.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockAdapterTest.kt @@ -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() + val events = mutableListOf() 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, 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 = {}, diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockViewDiffUtilTest.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockViewDiffUtilTest.kt index f83b116e95..d5f9d355d3 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockViewDiffUtilTest.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/BlockViewDiffUtilTest.kt @@ -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 + ) + } } \ No newline at end of file diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HeaderBlockTest.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HeaderBlockTest.kt index 6310f9b13e..afddf9f6d9 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HeaderBlockTest.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HeaderBlockTest.kt @@ -371,7 +371,7 @@ class HeaderBlockTest { onTogglePlaceholderClicked = {}, onToggleClicked = {}, onTextBlockTextChanged = {}, - onTitleTextChanged = onTitleTextChanged, + onTitleBlockTextChanged = {}, onContextMenuStyleClick = {}, onTitleTextInputClicked = {}, onClickListener = {}, diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HighlightingBlockTest.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HighlightingBlockTest.kt index 900245a73b..b51a612cc0 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HighlightingBlockTest.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/HighlightingBlockTest.kt @@ -107,7 +107,7 @@ class HighlightingBlockTest { onTogglePlaceholderClicked = {}, onToggleClicked = {}, onTextBlockTextChanged = {}, - onTitleTextChanged = onTitleTextChanged, + onTitleBlockTextChanged = {}, onContextMenuStyleClick = {}, onTitleTextInputClicked = {}, onClickListener = {}, diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterCursorBindingTest.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterCursorBindingTest.kt index 0229d8387d..569c055b67 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterCursorBindingTest.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterCursorBindingTest.kt @@ -371,7 +371,7 @@ class BlockAdapterCursorBindingTest { onTogglePlaceholderClicked = {}, onToggleClicked = {}, onTextBlockTextChanged = {}, - onTitleTextChanged = onTitleTextChanged, + onTitleBlockTextChanged = {}, onContextMenuStyleClick = {}, onTitleTextInputClicked = {}, onClickListener = {}, diff --git a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterTestSetup.kt b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterTestSetup.kt index 9ca4cf2219..6c6635d5ff 100644 --- a/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterTestSetup.kt +++ b/core-ui/src/test/java/com/anytypeio/anytype/core_ui/features/editor/BlockAdapterTestSetup.kt @@ -42,7 +42,7 @@ open class BlockAdapterTestSetup { onTogglePlaceholderClicked = {}, onToggleClicked = onToggleClicked, onTextBlockTextChanged = onTextBlockTextChanged, - onTitleTextChanged = onTitleTextChanged, + onTitleBlockTextChanged = {}, onContextMenuStyleClick = {}, onTitleTextInputClicked = {}, onClickListener = {}, diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/page/EditorMode.kt b/domain/src/main/java/com/anytypeio/anytype/domain/page/EditorMode.kt index ba4a42c57f..d2dbb22269 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/page/EditorMode.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/page/EditorMode.kt @@ -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 } \ No newline at end of file diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/page/ControlPanelMachine.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/page/ControlPanelMachine.kt index 7b23f33f2e..28e10ddf70 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/page/ControlPanelMachine.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/page/ControlPanelMachine.kt @@ -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) } diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/page/PageViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/page/PageViewModel.kt index 0aeb3faa29..0b6201e0b7 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/page/PageViewModel.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/page/PageViewModel.kt @@ -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 diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/page/search/DocumentSearchEngine.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/page/search/DocumentSearchEngine.kt new file mode 100644 index 0000000000..7a04db496e --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/page/search/DocumentSearchEngine.kt @@ -0,0 +1,12 @@ +package com.anytypeio.anytype.presentation.page.search + +import java.util.regex.Pattern + +fun String.search(pattern: Pattern): Set { + val result = mutableSetOf() + val matcher = pattern.matcher(this) + while (matcher.find()) { + result.add(matcher.start()..matcher.end()) + } + return result +} \ No newline at end of file diff --git a/presentation/src/test/java/com/anytypeio/anytype/presentation/page/PageViewModelTest.kt b/presentation/src/test/java/com/anytypeio/anytype/presentation/page/PageViewModelTest.kt index 8703704441..d32c2b204e 100644 --- a/presentation/src/test/java/com/anytypeio/anytype/presentation/page/PageViewModelTest.kt +++ b/presentation/src/test/java/com/anytypeio/anytype/presentation/page/PageViewModelTest.kt @@ -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> = 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`() { diff --git a/presentation/src/test/java/com/anytypeio/anytype/presentation/page/editor/EditorTitleTest.kt b/presentation/src/test/java/com/anytypeio/anytype/presentation/page/editor/EditorTitleTest.kt index 3ce6185f39..5acfe06578 100644 --- a/presentation/src/test/java/com/anytypeio/anytype/presentation/page/editor/EditorTitleTest.kt +++ b/presentation/src/test/java/com/anytypeio/anytype/presentation/page/editor/EditorTitleTest.kt @@ -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() + ) + ) + } + } } \ No newline at end of file diff --git a/presentation/src/test/java/com/anytypeio/anytype/presentation/page/search/DocumentSearchEngineTest.kt b/presentation/src/test/java/com/anytypeio/anytype/presentation/page/search/DocumentSearchEngineTest.kt new file mode 100644 index 0000000000..76ed486f04 --- /dev/null +++ b/presentation/src/test/java/com/anytypeio/anytype/presentation/page/search/DocumentSearchEngineTest.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index f83febf038..d44746ceed 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -19,12 +19,16 @@ android:theme="@style/AppTheme" android:usesCleartextTraffic="true" tools:ignore="GoogleAppIndexingWarning"> - + + + diff --git a/sample/src/main/java/com/anytypeio/anytype/sample/search/SearchOnPageActivity.kt b/sample/src/main/java/com/anytypeio/anytype/sample/search/SearchOnPageActivity.kt new file mode 100644 index 0000000000..b21557702c --- /dev/null +++ b/sample/src/main/java/com/anytypeio/anytype/sample/search/SearchOnPageActivity.kt @@ -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().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 -> + + } + } +} \ No newline at end of file diff --git a/sample/src/main/java/com/anytypeio/anytype/sample/search/SearchOnPageAdapter.kt b/sample/src/main/java/com/anytypeio/anytype/sample/search/SearchOnPageAdapter.kt new file mode 100644 index 0000000000..88b28317b8 --- /dev/null +++ b/sample/src/main/java/com/anytypeio/anytype/sample/search/SearchOnPageAdapter.kt @@ -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 +) : AbstractAdapter(items) { + + override fun update(update: List) { + this.items = update + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AbstractHolder { + 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(view) { + override fun bind(item: Item) { + itemView.input.setText(item.txt) + } + } +} \ No newline at end of file diff --git a/sample/src/main/res/layout/activity_search_on_page.xml b/sample/src/main/res/layout/activity_search_on_page.xml new file mode 100644 index 0000000000..f092b552d2 --- /dev/null +++ b/sample/src/main/res/layout/activity_search_on_page.xml @@ -0,0 +1,35 @@ + + + + + + + + \ No newline at end of file