Skip to content

Commit c7395d6

Browse files
committed
refactor: revise iOS keyboard handling and caret visibility in editor
- Enhanced caret visibility by ensuring it scrolls into view after keyboard animation. - Implemented multiple scroll attempts for reliability during keyboard transitions. - Updated viewport height management in _app.tsx to address iOS auto-scroll issues. - Refined mobile layout styles to prevent scroll glitches and improve user experience.
1 parent 576b873 commit c7395d6

File tree

4 files changed

+193
-132
lines changed

4 files changed

+193
-132
lines changed

packages/webapp/src/components/TipTap/plugins/scrollCaretIntoViewPlugin.ts

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,84 @@ import type { EditorView } from '@tiptap/pm/view'
55
/**
66
* iOS Safari Caret Visibility Plugin
77
*
8-
* Simplified version - the CSS layout now handles keyboard viewport changes.
9-
* This plugin only ensures the caret is scrolled into view within the editor
10-
* container after keyboard animation completes.
8+
* Ensures the caret is scrolled into view within the editor container
9+
* after keyboard animation completes. Handles the case where tapping
10+
* on the bottom half of the screen causes the caret to be hidden.
1111
*/
1212

1313
const MOBILE_BREAKPOINT = 768
14-
const KEYBOARD_ANIMATION_DELAY = 400 // Wait for iOS keyboard animation
14+
// Timing for iOS keyboard animation + layout settling
15+
const SCROLL_DELAYS = [100, 300, 400] // Multiple attempts for reliability
16+
const SCROLL_MARGIN = 100 // Extra margin above caret for better visibility
1517

1618
const isMobile = (): boolean => {
1719
if (typeof window === 'undefined') return false
1820
return window.innerWidth <= MOBILE_BREAKPOINT
1921
}
2022

23+
/**
24+
* Get the scroll container (editorWrapper)
25+
*/
26+
const getScrollContainer = (view: EditorView): HTMLElement | null => {
27+
// Find the editorWrapper - it's the scrollable parent of the editor
28+
let el: HTMLElement | null = view.dom as HTMLElement
29+
while (el) {
30+
if (el.classList.contains('editorWrapper')) {
31+
return el
32+
}
33+
el = el.parentElement
34+
}
35+
return null
36+
}
37+
38+
/**
39+
* Scroll the caret into view within the editor container
40+
*/
2141
const scrollCaretIntoView = (view: EditorView): void => {
22-
// Only scroll within the editor container, not the window
23-
// The editorWrapper handles its own scrolling
24-
view.dispatch(view.state.tr.scrollIntoView())
42+
try {
43+
// First, use ProseMirror's built-in scroll
44+
view.dispatch(view.state.tr.scrollIntoView())
45+
46+
// Then, ensure the caret is actually visible in the scroll container
47+
const scrollContainer = getScrollContainer(view)
48+
if (!scrollContainer) return
49+
50+
// Get caret position from selection
51+
const { from } = view.state.selection
52+
const coords = view.coordsAtPos(from)
53+
if (!coords) return
54+
55+
const containerRect = scrollContainer.getBoundingClientRect()
56+
const caretTop = coords.top
57+
const caretBottom = coords.bottom
58+
59+
// Check if caret is below visible area
60+
if (caretBottom > containerRect.bottom - SCROLL_MARGIN) {
61+
// Scroll down to show caret with margin
62+
const scrollAmount = caretBottom - containerRect.bottom + SCROLL_MARGIN
63+
scrollContainer.scrollBy({ top: scrollAmount, behavior: 'smooth' })
64+
}
65+
// Check if caret is above visible area
66+
else if (caretTop < containerRect.top + SCROLL_MARGIN) {
67+
// Scroll up to show caret with margin
68+
const scrollAmount = caretTop - containerRect.top - SCROLL_MARGIN
69+
scrollContainer.scrollBy({ top: scrollAmount, behavior: 'smooth' })
70+
}
71+
} catch {
72+
// View might be destroyed, ignore
73+
}
2574
}
2675

2776
const handleFocus = (view: EditorView): void => {
2877
if (!isMobile()) return
2978

30-
// Wait for keyboard animation to complete, then scroll caret into view
31-
setTimeout(() => {
32-
scrollCaretIntoView(view)
33-
}, KEYBOARD_ANIMATION_DELAY)
79+
// Multiple scroll attempts at different timings for reliability
80+
// This handles various iOS keyboard animation timings
81+
SCROLL_DELAYS.forEach((delay) => {
82+
setTimeout(() => {
83+
scrollCaretIntoView(view)
84+
}, delay)
85+
})
3486
}
3587

3688
export const createScrollCaretIntoViewPlugin = () => {
Lines changed: 32 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,63 @@
11
import { useEffect, useRef } from 'react'
22
import { useStore } from '@stores'
33

4+
/**
5+
* Tracks virtual keyboard state for mobile devices.
6+
* Uses 300ms debounce to wait for full iOS keyboard animation completion.
7+
*
8+
* CRITICAL: This hook only tracks state - layout is handled by CSS + _app.tsx
9+
*/
410
const useVirtualKeyboard = () => {
511
const { setKeyboardOpen, setKeyboardHeight, setVirtualKeyboardState } = useStore((state) => state)
612
const previousIsOpenRef = useRef<boolean | null>(null)
7-
const previousKeyboardHeightRef = useRef<number>(0)
8-
const transitionTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
13+
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
914

1015
useEffect(() => {
1116
const visualViewport = window.visualViewport
12-
1317
if (!visualViewport) return
1418

19+
// Threshold to consider keyboard "open" (accounts for browser chrome variations)
20+
const KEYBOARD_THRESHOLD = 100
21+
// iOS keyboard animation takes ~300-400ms
22+
const DEBOUNCE_MS = 300
23+
1524
const handleViewportChange = () => {
1625
const windowHeight = window.innerHeight
1726
const viewportHeight = visualViewport.height
18-
const keyboardHeight = windowHeight - viewportHeight
19-
const isKeyboardOpen = keyboardHeight > 0
20-
const previousIsOpen = previousIsOpenRef.current
21-
const previousKeyboardHeight = previousKeyboardHeightRef.current
22-
23-
setKeyboardOpen(isKeyboardOpen)
24-
setKeyboardHeight(keyboardHeight)
25-
26-
// Clear any existing transition timeout
27-
if (transitionTimeoutRef.current) {
28-
clearTimeout(transitionTimeoutRef.current)
29-
}
27+
const keyboardHeight = Math.max(0, windowHeight - viewportHeight)
28+
const isKeyboardOpen = keyboardHeight > KEYBOARD_THRESHOLD
3029

31-
// Determine state based on current vs previous
32-
if (previousIsOpen === null) {
33-
// Initial state - no transition
34-
setVirtualKeyboardState(isKeyboardOpen ? 'open' : 'closed')
35-
} else if (previousIsOpen === false && isKeyboardOpen === true) {
36-
// Keyboard is opening
37-
setVirtualKeyboardState('opening')
38-
transitionTimeoutRef.current = setTimeout(() => {
39-
setVirtualKeyboardState('open')
40-
}, 300) // Typical keyboard animation duration
41-
} else if (
42-
previousIsOpen === true &&
43-
keyboardHeight < previousKeyboardHeight &&
44-
keyboardHeight > 50
45-
) {
46-
// Keyboard is starting to close (height decreasing but still substantial)
47-
setVirtualKeyboardState('closing')
48-
transitionTimeoutRef.current = setTimeout(() => {
49-
setVirtualKeyboardState('closed')
50-
}, 300)
51-
} else if (previousIsOpen === true && isKeyboardOpen === false) {
52-
// Keyboard has fully closed (fallback case)
53-
setVirtualKeyboardState('closed')
54-
} else if (
55-
isKeyboardOpen &&
56-
previousKeyboardHeight > 0 &&
57-
keyboardHeight > previousKeyboardHeight
58-
) {
59-
// Keyboard height is increasing while already open - ensure we're in 'open' state
60-
setVirtualKeyboardState('open')
61-
} else if (!isKeyboardOpen && previousIsOpen === true) {
62-
// Keyboard is fully closed
63-
setVirtualKeyboardState('closed')
30+
// Debounce ALL state changes to prevent rapid updates during animation
31+
if (debounceTimerRef.current) {
32+
clearTimeout(debounceTimerRef.current)
6433
}
6534

66-
previousIsOpenRef.current = isKeyboardOpen
67-
previousKeyboardHeightRef.current = keyboardHeight
35+
debounceTimerRef.current = setTimeout(() => {
36+
const previousIsOpen = previousIsOpenRef.current
37+
38+
// Only update if state actually changed
39+
if (previousIsOpen !== isKeyboardOpen) {
40+
setKeyboardHeight(keyboardHeight)
41+
setKeyboardOpen(isKeyboardOpen)
42+
setVirtualKeyboardState(isKeyboardOpen ? 'open' : 'closed')
43+
previousIsOpenRef.current = isKeyboardOpen
44+
}
45+
}, DEBOUNCE_MS)
6846
}
6947

7048
// Initial check
7149
handleViewportChange()
7250

73-
// Listen for viewport changes
51+
// Only listen to resize events - scroll events cause glitches!
7452
visualViewport.addEventListener('resize', handleViewportChange)
75-
visualViewport.addEventListener('scroll', handleViewportChange)
7653

7754
return () => {
7855
visualViewport.removeEventListener('resize', handleViewportChange)
79-
visualViewport.removeEventListener('scroll', handleViewportChange)
80-
if (transitionTimeoutRef.current) {
81-
clearTimeout(transitionTimeoutRef.current)
56+
if (debounceTimerRef.current) {
57+
clearTimeout(debounceTimerRef.current)
8258
}
8359
}
84-
}, [])
60+
}, [setKeyboardOpen, setKeyboardHeight, setVirtualKeyboardState])
8561
}
8662

8763
export default useVirtualKeyboard

packages/webapp/src/pages/_app.tsx

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -66,39 +66,70 @@ export default function MyApp({ Component, pageProps }: any) {
6666
// Silently fail - cleanup is best-effort
6767
})
6868

69-
// Viewport height correction for iOS Safari keyboard handling
70-
let prevHeight: number | undefined
71-
let prevOffsetTop: number | undefined
69+
// iOS Safari keyboard viewport fix
70+
// Two key behaviors to handle:
71+
// 1. Height changes when keyboard opens/closes
72+
// 2. iOS auto-scrolls when focusing elements in bottom half of screen
7273
const doc = document.documentElement
74+
let lastHeight = window.visualViewport?.height ?? window.innerHeight
75+
let rafId: number | null = null
76+
77+
// Set initial height
78+
const vv = window.visualViewport
79+
if (vv) {
80+
doc.style.setProperty('--visual-viewport-height', `${vv.height}px`)
81+
doc.style.setProperty('--vh', `${vv.height * 0.01}px`)
82+
}
7383

74-
function updateViewportHeight() {
75-
const vv = window.visualViewport
76-
// Get visual viewport height (excludes keyboard on iOS/Android)
77-
const height = vv?.height ?? window.innerHeight
78-
// On iOS, visual viewport can scroll when keyboard opens
79-
const offsetTop = vv?.offsetTop ?? 0
84+
// Update height immediately using rAF for smooth rendering
85+
function handleViewportResize() {
86+
if (rafId) cancelAnimationFrame(rafId)
87+
88+
rafId = requestAnimationFrame(() => {
89+
const vv = window.visualViewport
90+
if (!vv) return
8091

81-
if (height === prevHeight && offsetTop === prevOffsetTop) return
82-
prevHeight = height
83-
prevOffsetTop = offsetTop
92+
const height = vv.height
8493

85-
requestAnimationFrame(() => {
86-
// --visual-viewport-height: actual pixel value for mobile layouts
94+
// Skip micro-updates (less than 50px change might be just toolbar hiding)
95+
if (Math.abs(height - lastHeight) < 50) return
96+
97+
lastHeight = height
8798
doc.style.setProperty('--visual-viewport-height', `${height}px`)
88-
// --visual-viewport-offset-top: how much visual viewport has scrolled
89-
doc.style.setProperty('--visual-viewport-offset-top', `${offsetTop}px`)
90-
// --vh: 1% of viewport for calc() usage (legacy)
9199
doc.style.setProperty('--vh', `${height * 0.01}px`)
92100
})
93101
}
94102

95-
// Initial set
96-
updateViewportHeight()
103+
// CRITICAL: When iOS auto-scrolls to show focused element, reset scroll
104+
// This prevents the "off-screen" issue when tapping bottom half
105+
let scrollResetTimeout: ReturnType<typeof setTimeout> | null = null
97106

98-
// Listen for changes
99-
window.addEventListener('resize', updateViewportHeight)
100-
window.visualViewport?.addEventListener('resize', updateViewportHeight)
101-
window.visualViewport?.addEventListener('scroll', updateViewportHeight)
107+
function handleViewportScroll() {
108+
const vv = window.visualViewport
109+
if (!vv || vv.offsetTop === 0) return
110+
111+
// Clear any pending reset
112+
if (scrollResetTimeout) clearTimeout(scrollResetTimeout)
113+
114+
// Debounce the scroll reset to let iOS finish its animation
115+
scrollResetTimeout = setTimeout(() => {
116+
// Double-check offsetTop is still non-zero
117+
if (window.visualViewport && window.visualViewport.offsetTop > 0) {
118+
// Reset window scroll - our fixed container will realign
119+
window.scrollTo(0, 0)
120+
}
121+
}, 100)
122+
}
123+
124+
window.visualViewport?.addEventListener('resize', handleViewportResize)
125+
window.visualViewport?.addEventListener('scroll', handleViewportScroll)
126+
127+
return () => {
128+
if (rafId) cancelAnimationFrame(rafId)
129+
if (scrollResetTimeout) clearTimeout(scrollResetTimeout)
130+
window.visualViewport?.removeEventListener('resize', handleViewportResize)
131+
window.visualViewport?.removeEventListener('scroll', handleViewportScroll)
132+
}
102133
}
103134
}, [])
104135

0 commit comments

Comments
 (0)