mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
fix(android): keep inverted chat still during text selection
React Native decomposes Android list inversion into a 180-degree rotation with positive scaleY, so the existing visibility-scroll guard never recognized the inverted viewport. Inspect the composed matrix and reproduce the actual list transform through the native view manager in regression tests.
This commit is contained in:
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
Keep assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view.
|
Keep assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view.
|
||||||
|
|
||||||
|
## Android inverted chat
|
||||||
|
|
||||||
|
Keep selection visibility handling in `modules/paseo-scroll`. Android's visibility
|
||||||
|
rectangles ignore list inversion. React Native decomposes Android's `scale: -1`
|
||||||
|
into rotation with positive `scaleY`, so test the composed matrix when deciding
|
||||||
|
whether those requests should move the viewport. Native tests must apply the
|
||||||
|
list's transform through React Native's view manager; assigning `scaleY` directly
|
||||||
|
misses that decomposition.
|
||||||
|
|
||||||
## Diagrams in lists and quotes
|
## Diagrams in lists and quotes
|
||||||
|
|
||||||
Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment.
|
Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment.
|
||||||
|
|||||||
+22
-1
@@ -8,6 +8,9 @@ import android.widget.FrameLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.facebook.react.bridge.JavaOnlyArray
|
||||||
|
import com.facebook.react.bridge.JavaOnlyMap
|
||||||
|
import com.facebook.react.uimanager.ReactStylesDiffMap
|
||||||
import com.facebook.react.bridge.BridgeReactContext
|
import com.facebook.react.bridge.BridgeReactContext
|
||||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||||
import com.facebook.react.uimanager.ThemedReactContext
|
import com.facebook.react.uimanager.ThemedReactContext
|
||||||
@@ -47,16 +50,34 @@ class ScrollSelectionTest {
|
|||||||
assertNotEquals(before, scroll.scrollY)
|
assertNotEquals(before, scroll.scrollY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun directVerticalMirroringKeepsSelectionStill() = withScroll(inverted = false) { scroll, text ->
|
||||||
|
scroll.scaleY = -1f
|
||||||
|
val before = scroll.scrollY
|
||||||
|
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
|
||||||
|
assertEquals(before, scroll.scrollY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun horizontalMirroringStillRevealsSelectedText() = withScroll(inverted = false) { scroll, text ->
|
||||||
|
scroll.scaleX = -1f
|
||||||
|
val before = scroll.scrollY
|
||||||
|
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
|
||||||
|
assertNotEquals(before, scroll.scrollY)
|
||||||
|
}
|
||||||
|
|
||||||
private fun withScroll(inverted: Boolean, check: (ReactScrollView, TextView) -> Unit) {
|
private fun withScroll(inverted: Boolean, check: (ReactScrollView, TextView) -> Unit) {
|
||||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||||
instrumentation.runOnMainSync {
|
instrumentation.runOnMainSync {
|
||||||
val appContext = instrumentation.targetContext
|
val appContext = instrumentation.targetContext
|
||||||
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||||
|
com.facebook.react.uimanager.DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||||
val reactContext = BridgeReactContext(appContext)
|
val reactContext = BridgeReactContext(appContext)
|
||||||
val context = ThemedReactContext(reactContext, appContext)
|
val context = ThemedReactContext(reactContext, appContext)
|
||||||
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||||
val scroll = manager.createViewInstance(context)
|
val scroll = manager.createViewInstance(context)
|
||||||
scroll.scaleY = if (inverted) -1f else 1f
|
// Exercise VirtualizedList's Android transform through RN's real decomposition.
|
||||||
|
if (inverted) manager.updateProperties(scroll, ReactStylesDiffMap(JavaOnlyMap.of(
|
||||||
|
"transform", JavaOnlyArray.of(JavaOnlyMap.of("scale", -1.0))
|
||||||
|
)))
|
||||||
val content = FrameLayout(context)
|
val content = FrameLayout(context)
|
||||||
val text = TextView(context).apply {
|
val text = TextView(context).apply {
|
||||||
setText("Selectable timeline text", TextView.BufferType.SPANNABLE)
|
setText("Selectable timeline text", TextView.BufferType.SPANNABLE)
|
||||||
|
|||||||
+12
-5
@@ -1,5 +1,6 @@
|
|||||||
package sh.paseo.scroll
|
package sh.paseo.scroll
|
||||||
|
|
||||||
|
import android.graphics.Matrix
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import com.facebook.react.ReactPackage
|
import com.facebook.react.ReactPackage
|
||||||
import com.facebook.react.ViewManagerOnDemandReactPackage
|
import com.facebook.react.ViewManagerOnDemandReactPackage
|
||||||
@@ -27,9 +28,15 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
||||||
// Both RN focus handling and Android selection visibility requests use this calculation.
|
private val transformValues = FloatArray(9)
|
||||||
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
|
||||||
// movement to user gestures and explicit scroll commands.
|
// Android VirtualizedList uses scale: -1, which RN decomposes into a 180-degree
|
||||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
|
// rotation with positive scaleY. Inspect the composed transform so both that
|
||||||
if (scaleY < 0) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
// inversion and a direct scaleY: -1 suppress untransformed visibility requests.
|
||||||
|
// Keep focus/selection bookkeeping; gestures and explicit commands still scroll.
|
||||||
|
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
|
||||||
|
matrix.getValues(transformValues)
|
||||||
|
return if (transformValues[Matrix.MSCALE_Y] < 0) 0
|
||||||
|
else super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user