From 1705006b6b0b63baea818b33292ebc6844cff8bb Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 11 May 2026 02:42:42 +0800 Subject: [PATCH] fix(mobile): iOS PIN keyboard UX + Safari font line-height in EPUBs (#4120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppLockScreen: pad the lock screen bottom by the on-screen keyboard height tracked via visualViewport, so the flex-centered PIN sits above the keyboard on iOS WKWebView where dvh does not shrink. - AppLockScreen: skip stickyFocus on mobile. iOS will not pop the keyboard from a programmatic .focus(), so the cursor would blink with no input — wait for the user's tap instead. - PinInput: forward autoFocus to the input when autoFocus or stickyFocus is set, for more reliable mount-time focus. - style.ts: give legacy

...

its own block context so iOS Safari applies the inherited line-height. Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/components/PinInput.test.tsx | 33 +++++++++++++++++++ .../src/components/AppLockScreen.tsx | 26 +++++++++++++-- apps/readest-app/src/components/PinInput.tsx | 1 + apps/readest-app/src/utils/style.ts | 3 ++ 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 apps/readest-app/src/__tests__/components/PinInput.test.tsx diff --git a/apps/readest-app/src/__tests__/components/PinInput.test.tsx b/apps/readest-app/src/__tests__/components/PinInput.test.tsx new file mode 100644 index 00000000..e029c2a2 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/PinInput.test.tsx @@ -0,0 +1,33 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +import PinInput from '@/components/PinInput'; + +vi.mock('@/libs/crypto/applock', () => ({ + PIN_LENGTH: 4, +})); + +afterEach(cleanup); + +describe('PinInput', () => { + it('focuses the input on mount when autoFocus is true', async () => { + render( {}} ariaLabel='PIN code' autoFocus />); + + const input = screen.getByLabelText('PIN code'); + await vi.waitFor(() => expect(document.activeElement).toBe(input)); + }); + + it('focuses the input on mount when stickyFocus is true', async () => { + render( {}} ariaLabel='PIN code' stickyFocus />); + + const input = screen.getByLabelText('PIN code'); + await vi.waitFor(() => expect(document.activeElement).toBe(input)); + }); + + it('does not focus the input when neither autoFocus nor stickyFocus is set', () => { + render( {}} ariaLabel='PIN code' />); + + const input = screen.getByLabelText('PIN code'); + expect(document.activeElement).not.toBe(input); + }); +}); diff --git a/apps/readest-app/src/components/AppLockScreen.tsx b/apps/readest-app/src/components/AppLockScreen.tsx index dbada91d..0674b5a8 100644 --- a/apps/readest-app/src/components/AppLockScreen.tsx +++ b/apps/readest-app/src/components/AppLockScreen.tsx @@ -1,19 +1,40 @@ 'use client'; import clsx from 'clsx'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import PinInput from '@/components/PinInput'; +import { useEnv } from '@/context/EnvContext'; import { PIN_LENGTH, verifyPin } from '@/libs/crypto/applock'; import { useAppLockStore } from '@/store/appLockStore'; import { useTranslation } from '@/hooks/useTranslation'; export default function AppLockScreen() { const _ = useTranslation(); + const { appService } = useEnv(); const { pinHash, pinSalt, unlock } = useAppLockStore(); + const autoFocusEnabled = !appService?.isMobile; const [pin, setPin] = useState(''); const [error, setError] = useState(''); const [shaking, setShaking] = useState(false); + const [kbInset, setKbInset] = useState(0); + + useEffect(() => { + const vv = window.visualViewport; + if (!vv) return; + const update = () => { + const layoutH = document.documentElement.clientHeight; + const offset = layoutH - vv.height - vv.offsetTop; + setKbInset(offset > 1 ? offset : 0); + }; + vv.addEventListener('resize', update); + vv.addEventListener('scroll', update); + update(); + return () => { + vv.removeEventListener('resize', update); + vv.removeEventListener('scroll', update); + }; + }, []); // Avoid React state for the in-flight guard — `setVerifying(true)` // would re-trigger the effect, the cleanup would set `cancelled=true`, // and the resolve handler would short-circuit before clearing the @@ -50,6 +71,7 @@ export default function AppLockScreen() { return (
diff --git a/apps/readest-app/src/components/PinInput.tsx b/apps/readest-app/src/components/PinInput.tsx index 4f133aa9..f76e2f8f 100644 --- a/apps/readest-app/src/components/PinInput.tsx +++ b/apps/readest-app/src/components/PinInput.tsx @@ -105,6 +105,7 @@ const PinInput = forwardRef(function PinInput( onBlur={() => setFocused(false)} disabled={disabled} autoComplete={autoComplete} + autoFocus={autoFocus || stickyFocus} aria-label={ariaLabel} className='absolute inset-0 z-10 cursor-pointer opacity-0' /> diff --git a/apps/readest-app/src/utils/style.ts b/apps/readest-app/src/utils/style.ts index 5dc479fa..2e1cdf18 100644 --- a/apps/readest-app/src/utils/style.ts +++ b/apps/readest-app/src/utils/style.ts @@ -460,6 +460,9 @@ const getParagraphLayoutStyles = ( ${!vertical && overrideLayout ? `margin-top: ${paragraphMargin}em !important;` : ''} ${!vertical && overrideLayout ? `margin-bottom: ${paragraphMargin}em !important;` : ''} } + p > font:only-child { + display: flow-root; + } :lang(zh), :lang(ja), :lang(ko) { widows: 1;