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

Editor | Feature | Slash widget, actions and alignment (#1454)

* slash actions + alignment

* turn off ci

* alignment click

* fix delete action

* on take target id

* ci off

* fixes

* tests

* fixes

* copy action + tests

* action paste + tests

* fixes

* fix move to + tests

* add block extension

* past and copy fixes

* pr fixes

* ci on

* ci
This commit is contained in:
Konstantin Ivanov 2021-05-10 14:23:52 +03:00 committed by GitHub
parent 16babf5264
commit ea15716610
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1330 additions and 100 deletions

View file

@ -1,7 +1,7 @@
on:
pull_request:
# add "synchronize" in "types", in order to trigger workflow for pull request commit(s) pushes.
types: [open,synchronize]
types: [synchronize]
branches: [develop]
name: Run debug unit tests
jobs:

View file

@ -0,0 +1,76 @@
package com.anytypeio.anytype.core_ui.features.page.slash
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.core_ui.features.page.slash.holders.ActionMenuHolder
import com.anytypeio.anytype.core_ui.features.page.slash.holders.SubheaderMenuHolder
import com.anytypeio.anytype.presentation.page.editor.slash.SlashItem
import kotlinx.android.synthetic.main.item_slash_widget_subheader.view.*
class SlashActionsAdapter(
private var items: List<SlashItem>,
private val clicks: (SlashItem) -> Unit,
private val clickBack: () -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
fun update(items: List<SlashItem>) {
this.items = items
notifyDataSetChanged()
}
fun clear() {
val size = items.size
if (size > 0) {
items = listOf()
notifyItemRangeRemoved(0, size)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
R.layout.item_slash_widget_style -> {
ActionMenuHolder(
view = inflater.inflate(viewType, parent, false)
).apply {
itemView.setOnClickListener {
clicks(items[bindingAdapterPosition])
}
}
}
R.layout.item_slash_widget_subheader -> {
SubheaderMenuHolder(
view = inflater.inflate(viewType, parent, false)
).apply {
itemView.flBack.setOnClickListener {
clickBack.invoke()
}
}
}
else -> throw IllegalArgumentException("Wrong viewtype:$viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is ActionMenuHolder -> {
val item = items[position] as SlashItem.Actions
holder.bind(item)
}
is SubheaderMenuHolder -> {
val item = items[position] as SlashItem.Subheader
holder.bind(item)
}
}
}
override fun getItemViewType(position: Int): Int = when (val item = items[position]) {
is SlashItem.Actions -> R.layout.item_slash_widget_style
is SlashItem.Subheader -> R.layout.item_slash_widget_subheader
else -> throw IllegalArgumentException("Wrong item type:$item")
}
override fun getItemCount(): Int = items.size
}

View file

@ -7,6 +7,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.core_ui.features.page.slash.holders.SlashAlignmentAdapter
import com.anytypeio.anytype.core_ui.tools.SlashHelper
import com.anytypeio.anytype.presentation.page.editor.slash.SlashCommand
import com.anytypeio.anytype.presentation.page.editor.slash.SlashItem
@ -73,13 +74,31 @@ class SlashWidget @JvmOverloads constructor(
)
}
private val actionsAdapter by lazy {
SlashActionsAdapter(
items = listOf(),
clicks = { _clickEvents.offer(it) },
clickBack = { _backEvent.offer(true) }
)
}
private val alignAdapter by lazy {
SlashAlignmentAdapter(
items = listOf(),
clicks = { _clickEvents.offer(it) },
clickBack = { _backEvent.offer(true) }
)
}
private val concatAdapter = ConcatAdapter(
mainAdapter,
styleAdapter,
mediaAdapter,
objectTypesAdapter,
relationsAdapter,
otherAdapter
otherAdapter,
actionsAdapter,
alignAdapter
)
init {
@ -106,6 +125,8 @@ class SlashWidget @JvmOverloads constructor(
objectTypesAdapter.clear()
relationsAdapter.clear()
otherAdapter.clear()
actionsAdapter.clear()
alignAdapter.clear()
}
is SlashCommand.ShowStyleItems -> {
styleAdapter.update(command.items)
@ -116,6 +137,8 @@ class SlashWidget @JvmOverloads constructor(
objectTypesAdapter.clear()
relationsAdapter.clear()
otherAdapter.clear()
actionsAdapter.clear()
alignAdapter.clear()
}
is SlashCommand.ShowMediaItems -> {
mediaAdapter.update(command.items)
@ -126,6 +149,8 @@ class SlashWidget @JvmOverloads constructor(
objectTypesAdapter.clear()
relationsAdapter.clear()
otherAdapter.clear()
actionsAdapter.clear()
alignAdapter.clear()
}
is SlashCommand.ShowRelations -> {
relationsAdapter.update(command.relations)
@ -136,6 +161,8 @@ class SlashWidget @JvmOverloads constructor(
mediaAdapter.clear()
objectTypesAdapter.clear()
otherAdapter.clear()
actionsAdapter.clear()
alignAdapter.clear()
}
is SlashCommand.ShowObjectTypes -> {
objectTypesAdapter.update(command.items)
@ -146,15 +173,44 @@ class SlashWidget @JvmOverloads constructor(
mediaAdapter.clear()
relationsAdapter.clear()
otherAdapter.clear()
actionsAdapter.clear()
alignAdapter.clear()
}
is SlashCommand.ShowOtherItems -> {
otherAdapter.update(command.items)
rvSlash.smoothScrollToPosition(0)
mainAdapter.clear()
styleAdapter.clear()
mediaAdapter.clear()
objectTypesAdapter.clear()
relationsAdapter.clear()
actionsAdapter.clear()
alignAdapter.clear()
}
is SlashCommand.ShowActionItems -> {
actionsAdapter.update(command.items)
rvSlash.smoothScrollToPosition(0)
mainAdapter.clear()
styleAdapter.clear()
mediaAdapter.clear()
objectTypesAdapter.clear()
relationsAdapter.clear()
otherAdapter.clear()
alignAdapter.clear()
}
is SlashCommand.ShowAlignmentItems -> {
alignAdapter.update(command.items)
rvSlash.smoothScrollToPosition(0)
mainAdapter.clear()
styleAdapter.clear()
mediaAdapter.clear()
objectTypesAdapter.clear()
relationsAdapter.clear()
otherAdapter.clear()
actionsAdapter.clear()
}
is SlashCommand.FilterItems -> {
val filter = command.filter.removePrefix(SLASH_PREFIX)

View file

@ -0,0 +1,51 @@
package com.anytypeio.anytype.core_ui.features.page.slash.holders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.core_utils.ext.gone
import com.anytypeio.anytype.presentation.page.editor.slash.SlashItem
import kotlinx.android.synthetic.main.item_slash_widget_style.view.*
class ActionMenuHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(item: SlashItem.Actions) = with(itemView) {
when (item) {
SlashItem.Actions.CleanStyle -> {
tvTitle.setText(R.string.slash_widget_actions_clean_style)
ivIcon.setImageResource(R.drawable.ic_slash_actions_clean_style)
tvSubtitle.gone()
}
SlashItem.Actions.Copy -> {
tvTitle.setText(R.string.slash_widget_actions_copy)
ivIcon.setImageResource(R.drawable.ic_slash_actions_copy)
tvSubtitle.gone()
}
SlashItem.Actions.Delete -> {
tvTitle.setText(R.string.slash_widget_actions_delete)
ivIcon.setImageResource(R.drawable.ic_slash_actions_delete)
tvSubtitle.gone()
}
SlashItem.Actions.Duplicate -> {
tvTitle.setText(R.string.slash_widget_actions_duplicate)
ivIcon.setImageResource(R.drawable.ic_slash_actions_duplicate)
tvSubtitle.gone()
}
SlashItem.Actions.Move -> {
tvTitle.setText(R.string.slash_widget_actions_move)
ivIcon.setImageResource(R.drawable.ic_slash_actions_move)
tvSubtitle.gone()
}
SlashItem.Actions.MoveTo -> {
tvTitle.setText(R.string.slash_widget_actions_moveto)
ivIcon.setImageResource(R.drawable.ic_slash_actions_move_to)
tvSubtitle.gone()
}
SlashItem.Actions.Paste -> {
tvTitle.setText(R.string.slash_widget_actions_paste)
ivIcon.setImageResource(R.drawable.ic_slash_actions_clean_style)
tvSubtitle.gone()
}
}
}
}

View file

@ -0,0 +1,31 @@
package com.anytypeio.anytype.core_ui.features.page.slash.holders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.core_utils.ext.gone
import com.anytypeio.anytype.presentation.page.editor.slash.SlashItem
import kotlinx.android.synthetic.main.item_slash_widget_style.view.*
class AlignMenuHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(item: SlashItem.Alignment) = with(itemView) {
when (item) {
SlashItem.Alignment.Left -> {
tvTitle.setText(R.string.slash_widget_align_left)
ivIcon.setImageResource(R.drawable.ic_slash_align_left)
tvSubtitle.gone()
}
SlashItem.Alignment.Center -> {
tvTitle.setText(R.string.slash_widget_align_center)
ivIcon.setImageResource(R.drawable.ic_slash_align_center)
tvSubtitle.gone()
}
SlashItem.Alignment.Right -> {
tvTitle.setText(R.string.slash_widget_align_right)
ivIcon.setImageResource(R.drawable.ic_slash_align_right)
tvSubtitle.gone()
}
}
}
}

View file

@ -0,0 +1,74 @@
package com.anytypeio.anytype.core_ui.features.page.slash.holders
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.presentation.page.editor.slash.SlashItem
import kotlinx.android.synthetic.main.item_slash_widget_subheader.view.*
class SlashAlignmentAdapter(
private var items: List<SlashItem>,
private val clicks: (SlashItem) -> Unit,
private val clickBack: () -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
fun update(items: List<SlashItem>) {
this.items = items
notifyDataSetChanged()
}
fun clear() {
val size = items.size
if (size > 0) {
items = listOf()
notifyItemRangeRemoved(0, size)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
R.layout.item_slash_widget_style -> {
AlignMenuHolder(
view = inflater.inflate(viewType, parent, false)
).apply {
itemView.setOnClickListener {
clicks(items[bindingAdapterPosition])
}
}
}
R.layout.item_slash_widget_subheader -> {
SubheaderMenuHolder(
view = inflater.inflate(viewType, parent, false)
).apply {
itemView.flBack.setOnClickListener {
clickBack.invoke()
}
}
}
else -> throw IllegalArgumentException("Wrong viewtype:$viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is AlignMenuHolder -> {
val item = items[position] as SlashItem.Alignment
holder.bind(item)
}
is SubheaderMenuHolder -> {
val item = items[position] as SlashItem.Subheader
holder.bind(item)
}
}
}
override fun getItemViewType(position: Int): Int = when (val item = items[position]) {
is SlashItem.Alignment -> R.layout.item_slash_widget_style
is SlashItem.Subheader -> R.layout.item_slash_widget_subheader
else -> throw IllegalArgumentException("Wrong item type:$item")
}
override fun getItemCount(): Int = items.size
}

View file

@ -39,6 +39,38 @@ class SubheaderMenuHolder(view: View) : RecyclerView.ViewHolder(view) {
flBack.visible()
R.string.slash_widget_main_other
}
SlashItem.Subheader.Actions -> {
flBack.invisible()
R.string.slash_widget_main_actions
}
SlashItem.Subheader.ActionsWithBack -> {
flBack.visible()
R.string.slash_widget_main_actions
}
SlashItem.Subheader.Alignment -> {
flBack.invisible()
R.string.slash_widget_main_alignment
}
SlashItem.Subheader.AlignmentWithBack -> {
flBack.visible()
R.string.slash_widget_main_alignment
}
SlashItem.Subheader.Color -> {
flBack.invisible()
R.string.slash_widget_main_color
}
SlashItem.Subheader.ColorWithBack -> {
flBack.visible()
R.string.slash_widget_main_color
}
SlashItem.Subheader.Background -> {
flBack.invisible()
R.string.slash_widget_main_background
}
SlashItem.Subheader.BackgroundWithBack -> {
flBack.visible()
R.string.slash_widget_main_background
}
}
subheader.setText(text)
}

View file

@ -54,6 +54,7 @@ ext {
robolectric_latest_version = '4.5.1'
junit_version = '4.12'
kluent_version = '1.14'
timber_junit = '1.0.1'
coroutine_testing_version = '1.4.3'
live_data_testing_version = '1.1.0'
mockito_kotlin_version = '3.2.0'
@ -147,7 +148,8 @@ ext {
liveDataTesting: "com.jraska.livedata:testing-ktx:$live_data_testing_version",
coroutineTesting: "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutine_testing_version",
assertj: "com.squareup.assertj:assertj-android:1.0.0",
androidXTestCore: "androidx.test:core:$androidx_test_core_version"
androidXTestCore: "androidx.test:core:$androidx_test_core_version",
timberJUnit: "net.lachlanmckee:timber-junit-rule:$timber_junit"
]
acceptanceTesting = [

View file

@ -52,4 +52,8 @@ class Paste(
val focus: Id,
val range: IntRange
)
companion object {
val DEFAULT_RANGE = IntRange(0, 0)
}
}

View file

@ -81,4 +81,5 @@ dependencies {
testImplementation unitTestDependencies.liveDataTesting
testImplementation unitTestDependencies.archCoreTesting
testImplementation unitTestDependencies.androidXTestCore
testImplementation unitTestDependencies.timberJUnit
}

View file

@ -615,6 +615,12 @@ sealed class ControlPanelMachine {
cursorCoordinate = event.cursorCoordinate,
updateList = false,
items = emptyList()
),
mainToolbar = state.mainToolbar.copy(
isVisible = false
),
navigationToolbar = state.navigationToolbar.copy(
isVisible = false
)
)
}
@ -752,7 +758,8 @@ sealed class ControlPanelMachine {
),
navigationToolbar = state.navigationToolbar.copy(
isVisible = false
)
),
slashWidget = Toolbar.SlashWidget.reset()
)
is Event.SAM.OnApply -> {
if (state.multiSelect.isQuickScrollAndMoveMode) {

View file

@ -37,6 +37,8 @@ import com.anytypeio.anytype.domain.block.interactor.RemoveLinkMark
import com.anytypeio.anytype.domain.block.interactor.UpdateLinkMarks
import com.anytypeio.anytype.domain.block.interactor.UpdateText
import com.anytypeio.anytype.domain.block.interactor.sets.GetObjectTypes
import com.anytypeio.anytype.domain.clipboard.Copy
import com.anytypeio.anytype.domain.clipboard.Paste
import com.anytypeio.anytype.domain.cover.RemoveDocCover
import com.anytypeio.anytype.domain.cover.SetDocCoverImage
import com.anytypeio.anytype.domain.editor.Editor
@ -1237,19 +1239,26 @@ class PageViewModel(
private fun onBlockAlignmentActionClicked(alignment: Alignment) {
controlPanelViewState.value?.stylingToolbar?.target?.id?.let { id ->
viewModelScope.launch {
orchestrator.proxies.intents.send(
Intent.Text.Align(
context = context,
target = id,
alignment = when (alignment) {
Alignment.START -> Block.Align.AlignLeft
Alignment.CENTER -> Block.Align.AlignCenter
Alignment.END -> Block.Align.AlignRight
}
)
proceedWithAlignmentUpdate(
id = id,
alignment = when (alignment) {
Alignment.START -> Block.Align.AlignLeft
Alignment.CENTER -> Block.Align.AlignCenter
Alignment.END -> Block.Align.AlignRight
}
)
}
}
private fun proceedWithAlignmentUpdate(id: String, alignment: Block.Align) {
viewModelScope.launch {
orchestrator.proxies.intents.send(
Intent.Text.Align(
context = context,
target = id,
alignment = alignment
)
}
)
}
}
@ -1405,30 +1414,7 @@ class PageViewModel(
ActionItemType.MoveTo -> {
onExitActionMode()
dispatch(Command.PopBackStack)
viewModelScope.sendEvent(
analytics = analytics,
eventName = EventsDictionary.SCREEN_MOVE_TO
)
val excluded = mutableListOf<Id>()
val target = blocks.find { it.id == id }
if (target != null) {
(target.content as? Content.Link)?.let { content ->
excluded.add(content.target)
}
}
navigate(
EventWrapper(
AppNavigation.Command.OpenMoveToScreen(
context = context,
targets = listOf(id),
excluded = excluded
)
)
)
proceedWithMoveTo(id)
}
ActionItemType.Style -> {
val textSelection = orchestrator.stores.textSelection.current()
@ -1475,6 +1461,33 @@ class PageViewModel(
}
}
private fun proceedWithMoveTo(id: String) {
viewModelScope.sendEvent(
analytics = analytics,
eventName = EventsDictionary.SCREEN_MOVE_TO
)
val excluded = mutableListOf<Id>()
val target = blocks.find { it.id == id }
if (target != null) {
(target.content as? Content.Link)?.let { content ->
excluded.add(content.target)
}
}
navigate(
EventWrapper(
AppNavigation.Command.OpenMoveToScreen(
context = context,
targets = listOf(id),
excluded = excluded
)
)
)
}
private fun proceedWithUnlinking(target: String) {
val position = views.indexOfFirst { it.id == target }
@ -2963,7 +2976,7 @@ class PageViewModel(
}
fun onCopy(
range: IntRange
range: IntRange?
) {
viewModelScope.launch {
orchestrator.proxies.intents.send(
@ -3599,9 +3612,18 @@ class PageViewModel(
//region SLASH WIDGET
fun onSlashItemClicked(item: SlashItem) {
val target = orchestrator.stores.focus.current()
if (!target.isEmpty) {
proceedWithSlashItem(item, target.id)
} else {
Timber.e("Slash Widget Error, target is empty")
}
}
private fun proceedWithSlashItem(item: SlashItem, targetId: Id) {
when (item) {
is SlashItem.Main.Style -> {
val items = listOf(SlashItem.Subheader.StyleWithBack) + SlashExtensions.getSlashItems()
val items = listOf(SlashItem.Subheader.StyleWithBack) + SlashExtensions.getStyleItems()
onSlashCommand(SlashCommand.ShowStyleItems(items))
}
is SlashItem.Main.Media -> {
@ -3643,8 +3665,16 @@ class PageViewModel(
val items = listOf(SlashItem.Subheader.OtherWithBack) + SlashExtensions.getOtherItems()
onSlashCommand(SlashCommand.ShowOtherItems(items))
}
is SlashItem.Main.Actions -> {
val items = listOf(SlashItem.Subheader.ActionsWithBack) + SlashExtensions.getActionItems()
onSlashCommand(SlashCommand.ShowActionItems(items))
}
is SlashItem.Main.Alignment -> {
val items = listOf(SlashItem.Subheader.AlignmentWithBack) + SlashExtensions.getAlignmentItems()
onSlashCommand(SlashCommand.ShowAlignmentItems(items))
}
is SlashItem.Style.Type -> {
onSlashStyleTypeItemClicked(item)
onSlashStyleTypeItemClicked(item, targetId)
}
is SlashItem.Media -> {
onSlashMediaItemClicked(item)
@ -3661,8 +3691,14 @@ class PageViewModel(
controlPanelInteractor.onEvent(ControlPanelMachine.Event.Slash.OnStop)
addDividerBlock(style = Content.Divider.Style.DOTS)
}
is SlashItem.Actions -> {
onSlashActionItemClicked(item, targetId)
}
is SlashItem.Alignment -> {
onSlashAlignmentItemClicked(item, targetId)
}
else -> {
Timber.d("PRESSED ON SLAH ITEM : $item")
Timber.d("PRESSED ON SLASH ITEM : $item")
}
}
}
@ -3692,26 +3728,82 @@ class PageViewModel(
}
}
private fun onSlashStyleTypeItemClicked(item: SlashItem.Style.Type) {
val target = _focus.value
if (target != null) {
viewModelScope.launch {
orchestrator.stores.focus.update(
Editor.Focus(
id = target,
cursor = Editor.Cursor.End
)
private fun onSlashStyleTypeItemClicked(item: SlashItem.Style.Type, targetId: Id) {
viewModelScope.launch {
orchestrator.stores.focus.update(
Editor.Focus(
id = targetId,
cursor = Editor.Cursor.End
)
}
val uiBlock = item.convertToUiBlock()
controlPanelInteractor.onEvent(ControlPanelMachine.Event.Slash.OnStop)
onTurnIntoBlockClicked(
target = target,
uiBlock = uiBlock
)
} else {
Timber.e("Error while style item clicked, target is null")
}
val uiBlock = item.convertToUiBlock()
controlPanelInteractor.onEvent(ControlPanelMachine.Event.Slash.OnStop)
onTurnIntoBlockClicked(
target = targetId,
uiBlock = uiBlock
)
}
private fun onSlashActionItemClicked(item: SlashItem.Actions, targetId: Id) {
when (item) {
SlashItem.Actions.CleanStyle -> {
controlPanelInteractor.onEvent(ControlPanelMachine.Event.Slash.OnStop)
proceedWithClearStyle(targetId)
}
SlashItem.Actions.Copy -> {
controlPanelInteractor.onEvent(ControlPanelMachine.Event.Slash.OnStop)
onCopy(range = null)
}
SlashItem.Actions.Paste -> {
val range = orchestrator.stores.textSelection.current().selection ?: Paste.DEFAULT_RANGE
controlPanelInteractor.onEvent(ControlPanelMachine.Event.Slash.OnStop)
onPaste(range = range)
}
SlashItem.Actions.Delete -> {
controlPanelInteractor.onEvent(ControlPanelMachine.Event.Slash.OnStop)
proceedWithUnlinking(targetId)
}
SlashItem.Actions.Duplicate -> {
controlPanelInteractor.onEvent(ControlPanelMachine.Event.Slash.OnStop)
duplicateBlock(targetId)
}
SlashItem.Actions.Move -> {
mode = EditorMode.SAM
viewModelScope.launch { orchestrator.stores.focus.update(Editor.Focus.empty()) }
viewModelScope.launch { refresh() }
proceedWithSAMQuickStartSelection(targetId)
controlPanelInteractor.onEvent(ControlPanelMachine.Event.SAM.OnQuickStart(1))
}
SlashItem.Actions.MoveTo -> {
onHideKeyboardClicked()
proceedWithMoveTo(targetId)
}
}
}
private fun proceedWithClearStyle(targetId: Id) {
val targetBlock = blocks.first { it.id == targetId }
proceedWithUpdatingText(
Intent.Text.UpdateText(
context = context,
target = targetId,
text = targetBlock.content.asText().text,
marks = emptyList()
)
)
}
private fun onSlashAlignmentItemClicked(item: SlashItem.Alignment, targetId: Id) {
val alignment = when (item) {
SlashItem.Alignment.Center -> Block.Align.AlignCenter
SlashItem.Alignment.Left -> Block.Align.AlignLeft
SlashItem.Alignment.Right -> Block.Align.AlignRight
}
proceedWithAlignmentUpdate(
id = targetId,
alignment = alignment
)
}
private fun onSlashCommand(command: SlashCommand) {

View file

@ -54,10 +54,10 @@ data class ControlPanelState(
* @property isVisible defines whether the toolbar is visible or not
*/
data class Styling(
override val isVisible: Boolean,
val target: Target? = null,
val config: StyleConfig? = null,
val props: Props? = null,
override val isVisible: Boolean,
val mode: StylingMode? = null
) : Toolbar() {

View file

@ -40,7 +40,7 @@ object SlashExtensions {
SlashItem.Main.Background,
)
fun getSlashItems() = listOf(
fun getStyleItems() = listOf(
SlashItem.Style.Type.Text,
SlashItem.Style.Type.Title,
SlashItem.Style.Type.Heading,
@ -69,4 +69,20 @@ object SlashExtensions {
SlashItem.Other.Line,
SlashItem.Other.Dots
)
fun getActionItems() = listOf(
SlashItem.Actions.Delete,
SlashItem.Actions.Duplicate,
SlashItem.Actions.Copy,
SlashItem.Actions.Paste,
SlashItem.Actions.Move,
SlashItem.Actions.MoveTo,
SlashItem.Actions.CleanStyle
)
fun getAlignmentItems() = listOf(
SlashItem.Alignment.Left,
SlashItem.Alignment.Center,
SlashItem.Alignment.Right
)
}

View file

@ -12,6 +12,10 @@ sealed class SlashCommand {
data class ShowOtherItems(val items: List<SlashItem>) : SlashCommand()
data class ShowRelations(val relations: List<RelationListViewModel.Model>): SlashCommand()
data class ShowObjectTypes(val items: List<SlashItem>): SlashCommand()
data class ShowActionItems(val items: List<SlashItem>) : SlashCommand()
data class ShowAlignmentItems(val items: List<SlashItem>) : SlashCommand()
data class ShowColorItems(val items: List<SlashItem>) : SlashCommand()
data class ShowBackgroundItems(val items: List<SlashItem>) : SlashCommand()
}
sealed class SlashItem {
@ -25,6 +29,14 @@ sealed class SlashItem {
object ObjectType: Subheader()
object Other: Subheader()
object OtherWithBack: Subheader()
object Actions: Subheader()
object ActionsWithBack: Subheader()
object Alignment: Subheader()
object AlignmentWithBack: Subheader()
object Color: Subheader()
object ColorWithBack: Subheader()
object Background: Subheader()
object BackgroundWithBack: Subheader()
}
//endregion

View file

@ -184,6 +184,8 @@ open class EditorPresentationTestSetup {
private lateinit var updateDetail: UpdateDetail
open lateinit var orchestrator: Orchestrator
open fun buildViewModel(urlBuilder: UrlBuilder = builder): PageViewModel {
val storage = Editor.Storage()
@ -193,6 +195,44 @@ open class EditorPresentationTestSetup {
)
updateDetail = UpdateDetail(repo)
orchestrator = Orchestrator(
createBlock = createBlock,
replaceBlock = replaceBlock,
updateTextColor = updateTextColor,
duplicateBlock = duplicateBlock,
downloadFile = downloadFile,
undo = undo,
redo = redo,
updateTitle = updateTitle,
updateText = updateText,
updateCheckbox = updateCheckbox,
updateTextStyle = updateTextStyle,
updateBackgroundColor = updateBackgroundColor,
mergeBlocks = mergeBlocks,
uploadBlock = uploadBlock,
splitBlock = splitBlock,
unlinkBlocks = unlinkBlocks,
updateDivider = updateDivider,
memory = memory,
stores = storage,
proxies = proxies,
textInteractor = Interactor.TextInteractor(
proxies = proxies,
stores = storage,
matcher = DefaultPatternMatcher()
),
updateAlignment = updateAlignment,
setupBookmark = setupBookmark,
paste = paste,
copy = copy,
move = move,
turnIntoDocument = turnIntoDocument,
analytics = analytics,
updateFields = updateFields,
setRelationKey = setRelationKey,
turnIntoStyle = turnIntoStyle
)
return PageViewModel(
getListPages = getListPages,
openPage = openPage,
@ -215,43 +255,7 @@ open class EditorPresentationTestSetup {
createDocument = createDocument,
createNewDocument = createNewDocument,
analytics = analytics,
orchestrator = Orchestrator(
createBlock = createBlock,
replaceBlock = replaceBlock,
updateTextColor = updateTextColor,
duplicateBlock = duplicateBlock,
downloadFile = downloadFile,
undo = undo,
redo = redo,
updateTitle = updateTitle,
updateText = updateText,
updateCheckbox = updateCheckbox,
updateTextStyle = updateTextStyle,
updateBackgroundColor = updateBackgroundColor,
mergeBlocks = mergeBlocks,
uploadBlock = uploadBlock,
splitBlock = splitBlock,
unlinkBlocks = unlinkBlocks,
updateDivider = updateDivider,
memory = memory,
stores = storage,
proxies = proxies,
textInteractor = Interactor.TextInteractor(
proxies = proxies,
stores = storage,
matcher = DefaultPatternMatcher()
),
updateAlignment = updateAlignment,
setupBookmark = setupBookmark,
paste = paste,
copy = copy,
move = move,
turnIntoDocument = turnIntoDocument,
analytics = analytics,
updateFields = updateFields,
setRelationKey = setRelationKey,
turnIntoStyle = turnIntoStyle
),
orchestrator = orchestrator,
dispatcher = Dispatcher.Default(),
removeDocCover = removeDocCover,
setDocCoverImage = setDocCoverImage,

View file

@ -0,0 +1,665 @@
package com.anytypeio.anytype.presentation.page.editor
import MockDataFactory
import android.util.Log
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.anytypeio.anytype.core_models.Block
import com.anytypeio.anytype.domain.block.interactor.DuplicateBlock
import com.anytypeio.anytype.domain.block.interactor.UnlinkBlocks
import com.anytypeio.anytype.domain.block.interactor.UpdateText
import com.anytypeio.anytype.domain.clipboard.Copy
import com.anytypeio.anytype.domain.clipboard.Paste
import com.anytypeio.anytype.presentation.MockTypicalDocumentFactory
import com.anytypeio.anytype.presentation.navigation.AppNavigation
import com.anytypeio.anytype.presentation.page.editor.control.ControlPanelState
import com.anytypeio.anytype.presentation.page.editor.slash.SlashEvent
import com.anytypeio.anytype.presentation.page.editor.slash.SlashItem
import com.anytypeio.anytype.presentation.util.CoroutinesTestRule
import com.jraska.livedata.test
import net.lachlanmckee.timberjunit.TimberTestRule
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.times
import org.mockito.kotlin.verifyBlocking
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
class EditorSlashWidgetActionsTest : EditorPresentationTestSetup() {
@get:Rule
val rule = InstantTaskExecutorRule()
@get:Rule
val coroutineTestRule = CoroutinesTestRule()
@get:Rule
val timberTestRule: TimberTestRule = TimberTestRule.builder()
.minPriority(Log.DEBUG)
.showThread(true)
.showTimestamp(false)
.onlyLogWhenTestFails(true)
.build()
@Before
fun setup() {
MockitoAnnotations.initMocks(this)
}
//region {Action DELETE}
@Test
fun `should not hide slash widget when action delete happened`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.Delete)
val state = vm.controlPanelViewState.value
assertNotNull(state)
assertFalse(state.slashWidget.isVisible)
}
@Test
fun `should send unlinkBlocks UseCase when action Delete happened`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.Delete)
val params = UnlinkBlocks.Params(
context = root,
targets = listOf(block.id)
)
verifyBlocking(unlinkBlocks, times(1)) { invoke(params) }
}
@Test
fun `should not triggered unlinkBlocks UseCase when no blocks in focus`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.Delete)
val params = UnlinkBlocks.Params(
context = root,
targets = listOf(block.id)
)
verifyBlocking(unlinkBlocks, times(0)) { invoke(params) }
}
//endregion
//region {Action DUPLICATE}
@Test
fun `should hide slash widget after action Duplicate`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.Duplicate)
val stateAfter = vm.controlPanelViewState.value
assertNotNull(stateAfter)
assertFalse(stateAfter.slashWidget.isVisible)
assertFalse(stateAfter.navigationToolbar.isVisible)
assertFalse(stateAfter.mainToolbar.isVisible)
val params = DuplicateBlock.Params(
context = root,
original = block.id
)
verifyBlocking(duplicateBlock, times(1)) { invoke(params) }
}
@Test
fun `should invoke duplicateBlock UseCase after action Duplicate`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.Duplicate)
val params = DuplicateBlock.Params(
context = root,
original = block.id
)
verifyBlocking(duplicateBlock, times(1)) { invoke(params) }
}
//endregion
//region {Action COPY}
@Test
fun `should hide slash widget after action Copy`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.Copy)
val state = vm.controlPanelViewState.value
assertNotNull(state)
assertFalse(state.slashWidget.isVisible)
}
@Test
fun `should send Copy UseCase with null range after action Copy`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.Copy)
val params = Copy.Params(
context = root,
range = null,
blocks = listOf(block)
)
verifyBlocking(copy, times(1)) { invoke(params) }
}
//endregion
//region {Action PASTE}
@Test
fun `should hide slash widget after action Paste`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.Paste)
val state = vm.controlPanelViewState.value
assertNotNull(state)
assertFalse(state.slashWidget.isVisible)
}
@Test
fun `should send Paste UseCase with selection range after action Paste`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSelectionChanged(
id = block.id,
selection = IntRange(3, 3)
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.Paste)
val focus = vm.focus.value
assertNotNull(focus)
val params = Paste.Params(
context = root,
range = IntRange(3, 3),
focus = focus
)
verifyBlocking(paste, times(1)) { invoke(params) }
}
//endregion
//region {Action MOVE}
@Test
fun `should hide slash widget after action Move`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.Move)
val state = vm.controlPanelViewState.value
assertNotNull(state)
assertFalse(state.slashWidget.isVisible)
}
@Test
fun `should enter mode SAM after action Move`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.Move)
val state = vm.controlPanelViewState.value
assertNotNull(state)
val expected = ControlPanelState.Toolbar.MultiSelect(
isVisible = true,
isScrollAndMoveEnabled = true,
isQuickScrollAndMoveMode = true,
count = 1
)
assertEquals(expected, state.multiSelect)
}
@Test
fun `should clear focus after action Move`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
val focusBefore = orchestrator.stores.focus.current()
assertEquals(block.id, focusBefore.id)
vm.onSlashItemClicked(SlashItem.Actions.Move)
val focusAfter = orchestrator.stores.focus.current()
assertEquals("", focusAfter.id)
}
//endregion
//region {Action MOVE TO}
@Test
fun `should hide slash widget and navigate to move to screen after move to action`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.MoveTo)
val state = vm.controlPanelViewState.value
assertNotNull(state)
assertFalse(state.slashWidget.isVisible)
val expected = AppNavigation.Command.OpenMoveToScreen(
context = root,
targets = listOf(block.id),
excluded = listOf()
)
vm.navigation
.test()
.assertHasValue()
.assertValue { event ->
(event.peekContent() as AppNavigation.Command.OpenMoveToScreen).let { result ->
result == expected
}
}
}
//endregion
//region {Action CLEAN STYLE}
@Test
fun `should hide slash widget after action Clean Style`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.CleanStyle)
val state = vm.controlPanelViewState.value
assertNotNull(state)
assertFalse(state.slashWidget.isVisible)
}
@Test
fun `should send UpdateText UseCase after action Clean Style`() {
val header = MockTypicalDocumentFactory.header
val title = MockTypicalDocumentFactory.title
val block = Block(
id = MockDataFactory.randomUuid(),
fields = Block.Fields.empty(),
children = emptyList(),
content = Block.Content.Text(
text = MockDataFactory.randomString(),
marks = listOf(
Block.Content.Text.Mark(
range = IntRange(0, 5),
type = Block.Content.Text.Mark.Type.BOLD,
param = null
),
Block.Content.Text.Mark(
range = IntRange(3, 10),
type = Block.Content.Text.Mark.Type.ITALIC,
param = null
)
),
style = Block.Content.Text.Style.NUMBERED
)
)
val page = Block(
id = root,
fields = Block.Fields(emptyMap()),
content = Block.Content.Smart(
type = Block.Content.Smart.Type.PAGE
),
children = listOf(header.id, block.id)
)
val doc = listOf(
page,
header,
title,
block
)
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
vm.onSlashItemClicked(SlashItem.Actions.CleanStyle)
val params = UpdateText.Params(
context = root,
target = block.id,
text = block.content.asText().text,
marks = emptyList()
)
verifyBlocking(updateText, times(1)) { invoke(params) }
}
//endregion
}

View file

@ -0,0 +1,107 @@
package com.anytypeio.anytype.presentation.page.editor
import android.util.Log
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.anytypeio.anytype.presentation.MockTypicalDocumentFactory
import com.anytypeio.anytype.presentation.page.editor.slash.SlashEvent
import com.anytypeio.anytype.presentation.util.CoroutinesTestRule
import net.lachlanmckee.timberjunit.TimberTestRule
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.MockitoAnnotations
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class EditorSlashWidgetShowHideTest : EditorPresentationTestSetup() {
@get:Rule
val rule = InstantTaskExecutorRule()
@get:Rule
val coroutineTestRule = CoroutinesTestRule()
@get:Rule
val timberTestRule: TimberTestRule = TimberTestRule.builder()
.minPriority(Log.DEBUG)
.showThread(true)
.showTimestamp(false)
.onlyLogWhenTestFails(true)
.build()
@Before
fun setup() {
MockitoAnnotations.initMocks(this)
}
@Test
fun `should show slash widget when slash event start happened`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
val state = vm.controlPanelViewState.value
assertNotNull(state)
assertTrue(state.slashWidget.isVisible)
}
@Test
fun `should hide other toolbars when slash event start happened`() {
val doc = MockTypicalDocumentFactory.page(root)
val block = MockTypicalDocumentFactory.a
stubInterceptEvents()
stubOpenDocument(document = doc)
val vm = buildViewModel()
vm.onStart(root)
vm.apply {
onBlockFocusChanged(
id = block.id,
hasFocus = true
)
onSlashEvent(
SlashEvent.Start(
cursorCoordinate = 100,
slashStart = 0
)
)
}
// TESTING
val state = vm.controlPanelViewState.value
assertNotNull(state)
assertFalse(state.mainToolbar.isVisible)
assertFalse(state.navigationToolbar.isVisible)
assertFalse(state.mentionToolbar.isVisible)
assertFalse(state.multiSelect.isVisible)
assertFalse(state.searchToolbar.isVisible)
assertFalse(state.stylingToolbar.isVisible)
}
}