Fix active tab scrolling and normalize editor keyboard inset

- Ensure the active session tab is scrolled into view immediately when
  its layout finishes to prevent it from remaining hidden off-screen.
- Round, clamp, and validate WebView visualViewport bottom measurements
  to avoid rendering inconsistencies from fractional or invalid insets.
- Trigger an immediate keyboard inset report on script initialization.
This commit is contained in:
Jinjing
2026-06-12 10:43:20 -07:00
parent d87da84b29
commit 26dde97181
6 changed files with 35 additions and 15 deletions
@@ -3933,6 +3933,9 @@ export default function SessionScreen() {
onLayout={(e) => {
const { x, width } = e.nativeEvent.layout
tabLayoutsRef.current.set(t.id, { x, width })
if (t.id === activeSessionTabIdRef.current) {
scrollActiveTabIntoView(t.id, false)
}
}}
onPress={() => switchSessionTab(t)}
onLongPress={() => {
@@ -19,6 +19,7 @@ import {
} from 'lucide-react-native'
import WebView, { type WebViewMessageEvent } from 'react-native-webview'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script'
import {
buildMobileRichMarkdownEditorHtml,
escapeInjectedJavaScriptString
@@ -198,7 +199,10 @@ function MobileRichMarkdownEditorInner({
return
}
if (editorMessage.type === 'keyboardInset' && typeof editorMessage.bottom === 'number') {
onKeyboardInsetChange?.(editorMessage.bottom)
const bottom = normalizeMobileRichMarkdownKeyboardInset(editorMessage.bottom)
if (bottom !== null) {
onKeyboardInsetChange?.(bottom)
}
}
},
[applyContent, applyEditable, content, editable, onChange, onKeyboardInsetChange]
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script'
describe('normalizeMobileRichMarkdownKeyboardInset', () => {
it('rounds finite inset measurements for native layout', () => {
expect(normalizeMobileRichMarkdownKeyboardInset(42.6)).toBe(43)
})
it('clamps negative inset measurements to zero', () => {
expect(normalizeMobileRichMarkdownKeyboardInset(-8)).toBe(0)
})
it('rejects non-finite inset measurements', () => {
expect(normalizeMobileRichMarkdownKeyboardInset(Number.NaN)).toBeNull()
expect(normalizeMobileRichMarkdownKeyboardInset(Number.POSITIVE_INFINITY)).toBeNull()
})
})
@@ -2,6 +2,13 @@
// Native Keyboard events are unreliable while focus lives in the editor
// WebView, so measure the covered region directly from visualViewport and let
// RN lift its native Save/Discard bar above it.
export function normalizeMobileRichMarkdownKeyboardInset(value: number): number | null {
if (!Number.isFinite(value)) {
return null
}
return Math.max(0, Math.round(value))
}
export const MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT = `
var lastInset = -1;
function reportKeyboardInset() {
@@ -17,4 +24,5 @@ export const MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT = `
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', reportKeyboardInset);
window.visualViewport.addEventListener('scroll', reportKeyboardInset);
reportKeyboardInset();
}`
@@ -28,7 +28,6 @@ describe('resolveTabStripScrollOffset', () => {
})
it('scrolls right to reveal a tab off the right edge', () => {
// tabEnd = 640 + 128 = 768; needs offset 768 + 12 - 360 = 420.
expect(
resolveTabStripScrollOffset({
tabX: 640,
+2 -13
View File
@@ -1,24 +1,15 @@
export type TabStripScrollInput = {
/** Left edge of the active tab within the scroll content. */
tabX: number
/** Width of the active tab. */
tabWidth: number
/** Visible width of the horizontal tab strip. */
viewportWidth: number
/** Total scrollable content width. */
contentWidth: number
/** Current horizontal scroll offset. */
currentOffset: number
/** Margin to keep between the tab and the viewport edge when revealing. */
margin?: number
}
/**
* Resolve the horizontal offset that brings the active tab fully into view.
*
* Returns the current offset unchanged when the tab is already visible so we
* never scroll on redundant active-tab updates; otherwise reveals the tab with
* the nearest minimal scroll, clamped to the content bounds.
* Keep active-tab reveal deterministic across async RN layout events without
* nudging the strip when the tab is already visible.
*/
export function resolveTabStripScrollOffset({
tabX,
@@ -40,10 +31,8 @@ export function resolveTabStripScrollOffset({
let nextOffset = currentOffset
if (tabStart < visibleStart + margin) {
// Off the left edge: align the tab start with the left margin.
nextOffset = tabStart - margin
} else if (tabEnd > visibleEnd - margin) {
// Off the right edge: align the tab end with the right margin.
nextOffset = tabEnd + margin - viewportWidth
}