mirror of
https://github.com/anyproto/anytype-kotlin.git
synced 2025-06-08 13:57:10 +09:00
Focus the last empty block when tapping on empty space (#936)
This commit is contained in:
parent
8a2cd1eacc
commit
770cca74d1
9 changed files with 349 additions and 159 deletions
|
@ -12,6 +12,7 @@
|
|||
|
||||
### Fixes & tech 🚒
|
||||
|
||||
* Should focus the last empty text block when clicking under document's blocks (#935)
|
||||
* Clicking on empty space before document is loaded should not crash application (#930)
|
||||
* Focusing on start may crash application by some users (#931)
|
||||
|
||||
|
|
|
@ -4,5 +4,5 @@ package com.anytypeio.anytype.core_ui.common
|
|||
* Defines a view that can be focused.
|
||||
*/
|
||||
interface Focusable {
|
||||
val isFocused: Boolean
|
||||
var isFocused: Boolean
|
||||
}
|
|
@ -65,7 +65,10 @@ abstract class Text(
|
|||
}
|
||||
|
||||
content.apply {
|
||||
setOnFocusChangeListener { _, hasFocus -> onFocusChanged(item.id, hasFocus) }
|
||||
setOnFocusChangeListener { _, hasFocus ->
|
||||
item.isFocused = hasFocus
|
||||
onFocusChanged(item.id, hasFocus)
|
||||
}
|
||||
setOnClickListener { onTextInputClicked(item.id) }
|
||||
enableEnterKeyDetector(
|
||||
onSplitLineEnterClicked = { range ->
|
||||
|
|
|
@ -377,7 +377,7 @@ sealed class BlockView : ViewType, Parcelable {
|
|||
@Parcelize
|
||||
data class Document(
|
||||
override val id: String,
|
||||
override val isFocused: Boolean,
|
||||
override var isFocused: Boolean,
|
||||
override var text: String?,
|
||||
val emoji: String? = null,
|
||||
override val image: String? = null,
|
||||
|
@ -396,7 +396,7 @@ sealed class BlockView : ViewType, Parcelable {
|
|||
@Parcelize
|
||||
data class Profile(
|
||||
override val id: String,
|
||||
override val isFocused: Boolean,
|
||||
override var isFocused: Boolean,
|
||||
override var text: String?,
|
||||
override val image: String? = null,
|
||||
override val mode: Mode = Mode.EDIT,
|
||||
|
@ -414,7 +414,7 @@ sealed class BlockView : ViewType, Parcelable {
|
|||
@Parcelize
|
||||
data class Archive(
|
||||
override val id: String,
|
||||
override val isFocused: Boolean = false,
|
||||
override var isFocused: Boolean = false,
|
||||
override var text: String?,
|
||||
override val image: String? = null,
|
||||
override val mode: Mode = Mode.READ,
|
||||
|
|
|
@ -59,8 +59,11 @@ class BlockViewDiffUtil(
|
|||
}
|
||||
|
||||
if (newBlock is Focusable && oldBlock is Focusable) {
|
||||
if (newBlock.isFocused != oldBlock.isFocused)
|
||||
if (newBlock.isFocused != oldBlock.isFocused) {
|
||||
changes.add(FOCUS_CHANGED)
|
||||
Timber.d("Focus changed!")
|
||||
} else
|
||||
Timber.d("Focus hasn't changed")
|
||||
}
|
||||
|
||||
if (newBlock is BlockView.Cursor && oldBlock is BlockView.Cursor) {
|
||||
|
|
|
@ -1840,6 +1840,16 @@ class PageViewModel(
|
|||
when {
|
||||
content.style == Content.Text.Style.TITLE -> addNewBlockAtTheEnd()
|
||||
content.text.isNotEmpty() -> addNewBlockAtTheEnd()
|
||||
content.text.isEmpty() -> {
|
||||
val stores = orchestrator.stores
|
||||
if (stores.focus.current().isEmpty) {
|
||||
val focus = Editor.Focus(id = last.id, cursor = null)
|
||||
viewModelScope.launch { orchestrator.stores.focus.update(focus) }
|
||||
viewModelScope.launch { refresh() }
|
||||
} else {
|
||||
Timber.d("Outside click is ignored because focus is not empty")
|
||||
}
|
||||
}
|
||||
else -> Timber.d("Outside-click has been ignored.")
|
||||
}
|
||||
}
|
||||
|
@ -1865,7 +1875,7 @@ class PageViewModel(
|
|||
fun onHideKeyboardClicked() {
|
||||
controlPanelInteractor.onEvent(ControlPanelMachine.Event.OnClearFocusClicked)
|
||||
viewModelScope.launch { orchestrator.stores.focus.update(Editor.Focus.empty()) }
|
||||
viewModelScope.launch { renderCommand.send(Unit) }
|
||||
viewModelScope.launch { refresh() }
|
||||
}
|
||||
|
||||
private fun proceedWithClearingFocus() {
|
||||
|
|
|
@ -1868,57 +1868,6 @@ open class PageViewModelTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create a new paragraph on outside-clicked event if page contains only title and icon`() {
|
||||
|
||||
val root = MockDataFactory.randomUuid()
|
||||
val child = MockDataFactory.randomUuid()
|
||||
val page = MockBlockFactory.makeOnePageWithOneTextBlock(
|
||||
root = root,
|
||||
child = child,
|
||||
style = Block.Content.Text.Style.TITLE
|
||||
)
|
||||
|
||||
val flow: Flow<List<Event.Command>> = flow {
|
||||
delay(100)
|
||||
emit(
|
||||
listOf(
|
||||
Event.Command.ShowBlock(
|
||||
root = root,
|
||||
blocks = page,
|
||||
context = root
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
stubObserveEvents(flow)
|
||||
stubOpenPage()
|
||||
stubCreateBlock(root)
|
||||
buildViewModel()
|
||||
|
||||
vm.onStart(root)
|
||||
|
||||
coroutineTestRule.advanceTime(100)
|
||||
|
||||
vm.onOutsideClicked()
|
||||
|
||||
runBlockingTest {
|
||||
verify(createBlock, times(1)).invoke(
|
||||
params = eq(
|
||||
CreateBlock.Params(
|
||||
context = root,
|
||||
target = "",
|
||||
position = Position.INNER,
|
||||
prototype = Block.Prototype.Text(
|
||||
style = Block.Content.Text.Style.P
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should start updating text style of the focused block on turn-into-option-clicked event`() {
|
||||
|
||||
|
@ -1978,52 +1927,6 @@ open class PageViewModelTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should clear focus internally and re-render on hide-keyboard event`() {
|
||||
|
||||
val root = MockDataFactory.randomUuid()
|
||||
val child = MockDataFactory.randomUuid()
|
||||
val page = MockBlockFactory.makeOnePageWithOneTextBlock(
|
||||
root = root,
|
||||
child = child,
|
||||
style = Block.Content.Text.Style.TITLE
|
||||
)
|
||||
|
||||
val flow: Flow<List<Event.Command>> = flow {
|
||||
delay(100)
|
||||
emit(
|
||||
listOf(
|
||||
Event.Command.ShowBlock(
|
||||
root = root,
|
||||
blocks = page,
|
||||
context = root
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
stubObserveEvents(flow)
|
||||
stubOpenPage()
|
||||
buildViewModel()
|
||||
|
||||
vm.onStart(root)
|
||||
|
||||
coroutineTestRule.advanceTime(100)
|
||||
|
||||
val testObserver = vm.focus.test()
|
||||
|
||||
vm.onBlockFocusChanged(
|
||||
id = child,
|
||||
hasFocus = true
|
||||
)
|
||||
|
||||
testObserver.assertValue(child)
|
||||
|
||||
vm.onHideKeyboardClicked()
|
||||
|
||||
testObserver.assertValue(PageViewModel.EMPTY_FOCUS_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should start updating the target block's color on color-toolbar-option-selected event`() {
|
||||
|
||||
|
@ -2212,61 +2115,6 @@ open class PageViewModelTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create a new paragraph on outside-clicked event if the last block is a link block`() {
|
||||
|
||||
val root = MockDataFactory.randomUuid()
|
||||
val firstChild = MockDataFactory.randomUuid()
|
||||
val secondChild = MockDataFactory.randomUuid()
|
||||
|
||||
val page = MockBlockFactory.makeOnePageWithTitleAndOnePageLinkBlock(
|
||||
rootId = root,
|
||||
titleBlockId = firstChild,
|
||||
pageBlockId = secondChild
|
||||
)
|
||||
|
||||
val startDelay = 100L
|
||||
|
||||
val flow: Flow<List<Event.Command>> = flow {
|
||||
delay(startDelay)
|
||||
emit(
|
||||
listOf(
|
||||
Event.Command.ShowBlock(
|
||||
root = root,
|
||||
blocks = page,
|
||||
context = root
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
stubObserveEvents(flow)
|
||||
stubOpenPage()
|
||||
stubCreateBlock(root)
|
||||
buildViewModel()
|
||||
|
||||
vm.onStart(root)
|
||||
|
||||
coroutineTestRule.advanceTime(startDelay)
|
||||
|
||||
vm.onOutsideClicked()
|
||||
|
||||
runBlockingTest {
|
||||
verify(createBlock, times(1)).invoke(
|
||||
params = eq(
|
||||
CreateBlock.Params(
|
||||
target = "",
|
||||
context = root,
|
||||
position = Position.INNER,
|
||||
prototype = Block.Prototype.Text(
|
||||
style = Block.Content.Text.Style.P
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should send update text style intent when is list and empty`() {
|
||||
// SETUP
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
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.domain.block.interactor.CreateBlock
|
||||
import com.anytypeio.anytype.domain.block.model.Block
|
||||
import com.anytypeio.anytype.domain.block.model.Position
|
||||
import com.anytypeio.anytype.presentation.MockBlockFactory
|
||||
import com.anytypeio.anytype.presentation.util.CoroutinesTestRule
|
||||
import com.jraska.livedata.test
|
||||
import com.nhaarman.mockitokotlin2.eq
|
||||
import com.nhaarman.mockitokotlin2.times
|
||||
import com.nhaarman.mockitokotlin2.verifyBlocking
|
||||
import com.nhaarman.mockitokotlin2.verifyZeroInteractions
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
|
@ -27,4 +37,155 @@ class EditorEmptySpaceInteractionTest : EditorPresentationTestSetup() {
|
|||
vm.onOutsideClicked()
|
||||
verifyZeroInteractions(createBlock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create a new paragraph on outside-clicked event if page contains only title and icon`() {
|
||||
|
||||
// SETUP
|
||||
|
||||
val child = MockDataFactory.randomUuid()
|
||||
val page = MockBlockFactory.makeOnePageWithOneTextBlock(
|
||||
root = root,
|
||||
child = child,
|
||||
style = Block.Content.Text.Style.TITLE
|
||||
)
|
||||
|
||||
stubInterceptEvents()
|
||||
stubOpenDocument(page)
|
||||
stubCreateBlock(root)
|
||||
|
||||
val vm = buildViewModel()
|
||||
|
||||
vm.onStart(root)
|
||||
|
||||
// TESTING
|
||||
|
||||
vm.onOutsideClicked()
|
||||
|
||||
verifyBlocking(createBlock, times(1)) {
|
||||
invoke(
|
||||
params = eq(
|
||||
CreateBlock.Params(
|
||||
context = root,
|
||||
target = "",
|
||||
position = Position.INNER,
|
||||
prototype = Block.Prototype.Text(
|
||||
style = Block.Content.Text.Style.P
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create a new paragraph on outside-clicked event if the last block is a link block`() {
|
||||
|
||||
// SETUP
|
||||
|
||||
val firstChild = MockDataFactory.randomUuid()
|
||||
val secondChild = MockDataFactory.randomUuid()
|
||||
|
||||
val page = MockBlockFactory.makeOnePageWithTitleAndOnePageLinkBlock(
|
||||
rootId = root,
|
||||
titleBlockId = firstChild,
|
||||
pageBlockId = secondChild
|
||||
)
|
||||
|
||||
stubInterceptEvents()
|
||||
stubOpenDocument(page)
|
||||
stubCreateBlock(root)
|
||||
|
||||
val vm = buildViewModel()
|
||||
|
||||
vm.onStart(root)
|
||||
|
||||
// TESTING
|
||||
|
||||
vm.onOutsideClicked()
|
||||
|
||||
verifyBlocking(createBlock, times(1)) {
|
||||
invoke(
|
||||
params = eq(
|
||||
CreateBlock.Params(
|
||||
target = "",
|
||||
context = root,
|
||||
position = Position.INNER,
|
||||
prototype = Block.Prototype.Text(
|
||||
style = Block.Content.Text.Style.P
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not create a new paragraph but focus the last empty block`() {
|
||||
|
||||
// SETUP
|
||||
|
||||
val pic = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
content = Block.Content.File(
|
||||
type = Block.Content.File.Type.IMAGE,
|
||||
state = Block.Content.File.State.DONE
|
||||
),
|
||||
fields = Block.Fields.empty(),
|
||||
children = emptyList()
|
||||
)
|
||||
|
||||
val txt = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields(emptyMap()),
|
||||
content = Block.Content.Text(
|
||||
text = "",
|
||||
marks = emptyList(),
|
||||
style = Block.Content.Text.Style.values().random()
|
||||
),
|
||||
children = emptyList()
|
||||
)
|
||||
|
||||
val doc = listOf(
|
||||
Block(
|
||||
id = root,
|
||||
fields = Block.Fields(emptyMap()),
|
||||
content = Block.Content.Page(
|
||||
style = Block.Content.Page.Style.SET
|
||||
),
|
||||
children = listOf(pic.id, txt.id)
|
||||
),
|
||||
pic,
|
||||
txt
|
||||
)
|
||||
|
||||
stubInterceptEvents()
|
||||
stubOpenDocument(document = doc)
|
||||
|
||||
val vm = buildViewModel()
|
||||
|
||||
// TESTING
|
||||
|
||||
vm.onStart(root)
|
||||
|
||||
// Checking that no text block is focused
|
||||
|
||||
vm.state.test().assertValue { value ->
|
||||
check(value is ViewState.Success)
|
||||
value.blocks.none { it is BlockView.Text && it.isFocused }
|
||||
}
|
||||
|
||||
vm.onOutsideClicked()
|
||||
|
||||
verifyZeroInteractions(createBlock)
|
||||
|
||||
// Checking that the last text block is focused and has empty text
|
||||
|
||||
vm.state.test().assertValue { value ->
|
||||
check(value is ViewState.Success)
|
||||
val last = value.blocks.last()
|
||||
check(last is BlockView.Text)
|
||||
last.text.isEmpty() && last.isFocused
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
package com.anytypeio.anytype.presentation.page.editor
|
||||
|
||||
import MockDataFactory
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import com.anytypeio.anytype.core_ui.common.Focusable
|
||||
import com.anytypeio.anytype.domain.block.model.Block
|
||||
import com.anytypeio.anytype.presentation.page.PageViewModel
|
||||
import com.anytypeio.anytype.presentation.util.CoroutinesTestRule
|
||||
import com.jraska.livedata.test
|
||||
import com.nhaarman.mockitokotlin2.verifyZeroInteractions
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.mockito.MockitoAnnotations
|
||||
|
||||
class EditorFocusTest : EditorPresentationTestSetup() {
|
||||
|
||||
@get:Rule
|
||||
val rule = InstantTaskExecutorRule()
|
||||
|
||||
@get:Rule
|
||||
val coroutineTestRule = CoroutinesTestRule()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
MockitoAnnotations.initMocks(this)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should clear focus internally and re-render on hide-keyboard event`() {
|
||||
|
||||
// SETUP
|
||||
|
||||
val block = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields(emptyMap()),
|
||||
content = Block.Content.Text(
|
||||
text = MockDataFactory.randomString(),
|
||||
marks = emptyList(),
|
||||
style = Block.Content.Text.Style.values().random()
|
||||
),
|
||||
children = emptyList()
|
||||
)
|
||||
|
||||
val page = listOf(
|
||||
Block(
|
||||
id = root,
|
||||
fields = Block.Fields(emptyMap()),
|
||||
content = Block.Content.Page(
|
||||
style = Block.Content.Page.Style.SET
|
||||
),
|
||||
children = listOf(block.id)
|
||||
),
|
||||
block
|
||||
)
|
||||
|
||||
stubInterceptEvents()
|
||||
stubOpenDocument(page)
|
||||
|
||||
val vm = buildViewModel()
|
||||
|
||||
// TESTING
|
||||
|
||||
vm.onStart(root)
|
||||
|
||||
val testViewStateObserver = vm.state.test()
|
||||
|
||||
val testFocusObserver = vm.focus.test()
|
||||
|
||||
testViewStateObserver.assertValue { value ->
|
||||
check(value is ViewState.Success)
|
||||
val last = value.blocks.last()
|
||||
check(last is Focusable)
|
||||
!last.isFocused
|
||||
}
|
||||
|
||||
vm.onBlockFocusChanged(
|
||||
id = block.id,
|
||||
hasFocus = true
|
||||
)
|
||||
|
||||
testFocusObserver.assertValue(block.id)
|
||||
|
||||
vm.onHideKeyboardClicked()
|
||||
|
||||
testFocusObserver.assertValue(PageViewModel.EMPTY_FOCUS_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should update views on hide-keyboard event`() {
|
||||
|
||||
// SETUP
|
||||
|
||||
val block = Block(
|
||||
id = MockDataFactory.randomUuid(),
|
||||
fields = Block.Fields(emptyMap()),
|
||||
content = Block.Content.Text(
|
||||
text = "",
|
||||
marks = emptyList(),
|
||||
style = Block.Content.Text.Style.values().random()
|
||||
),
|
||||
children = emptyList()
|
||||
)
|
||||
|
||||
val page = listOf(
|
||||
Block(
|
||||
id = root,
|
||||
fields = Block.Fields(emptyMap()),
|
||||
content = Block.Content.Page(
|
||||
style = Block.Content.Page.Style.SET
|
||||
),
|
||||
children = listOf(block.id)
|
||||
),
|
||||
block
|
||||
)
|
||||
|
||||
stubInterceptEvents()
|
||||
stubOpenDocument(page)
|
||||
|
||||
val vm = buildViewModel()
|
||||
|
||||
// TESTING
|
||||
|
||||
vm.onStart(root)
|
||||
|
||||
vm.state.test().apply {
|
||||
assertValue { value ->
|
||||
check(value is ViewState.Success)
|
||||
val last = value.blocks.last()
|
||||
check(last is Focusable)
|
||||
!last.isFocused
|
||||
}
|
||||
}
|
||||
|
||||
vm.onBlockFocusChanged(
|
||||
id = block.id,
|
||||
hasFocus = true
|
||||
)
|
||||
|
||||
vm.onHideKeyboardClicked()
|
||||
|
||||
vm.state.test().apply {
|
||||
assertValue { value ->
|
||||
check(value is ViewState.Success)
|
||||
val last = value.blocks.last()
|
||||
check(last is Focusable)
|
||||
!last.isFocused
|
||||
}
|
||||
}
|
||||
|
||||
vm.onOutsideClicked()
|
||||
|
||||
vm.state.test().apply {
|
||||
assertValue { value ->
|
||||
check(value is ViewState.Success)
|
||||
val last = value.blocks.last()
|
||||
check(last is Focusable)
|
||||
last.isFocused
|
||||
}
|
||||
}
|
||||
|
||||
verifyZeroInteractions(createBlock)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue