This article mainly addresses two issues:
- The keyboard attachment of the WebView editor's Toolbar
- The focus element of the WebView editor being obscured by the virtual keyboard
Important
This article may only apply to React Native ~0.72
Background#
Recently, while writing React Native, I needed to implement a text editor. Currently, mature text editors are mostly web-based, and there are relatively few mature rich text editors in native or cross-platform frameworks like React Native. Therefore, we use Web + React Native WebView to implement this component.
In the requirements, we mainly implement a layout where the entire editor is at the top, and the AccessoryView + Keyboard is at the bottom.
Thought Process#
AccessoryView#
The AccessoryView provides a corresponding component in React Native. The component is called InputAccessoryView, but it has a limitation: this component is only applicable to React Native's TextInput and cannot be used in WebView.
Using Web Implementation#
- In the web, you can use VisualViewport to listen for whether the virtual keyboard is invoked and obtain the width and height of the virtual keyboard. Then, you can position the toolbar accordingly.
Disadvantage: It is not in real-time. The height of the keyboard cannot be obtained immediately.
useLayoutEffect(() => {
window.addEventListener('resize', () => {
detectKeyboard()
})
}, [])
const [keyboardHeight, setKeyboardHeight] = useState(0)
const timerRef = useRef<any>()
const detectKeyboard = () => {
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
if (!window.visualViewport) {
return
}
setKeyboardHeight(
window.innerHeight -
window.visualViewport.height +
window.visualViewport.offsetTop,
)
}, 300)
}
- You can pass the width and height of the keyboard from the Keyboard event in React Native to the Web to control the position of the toolbar.
Disadvantage: The event callback is not real-time, but better than the previous method.
Keyboard.addListener('keyboardWillChangeFrame', (e) => {
console.log('Keyboard height changed to', e.endCoordinates.height)
})
The above solutions: When the keyboard is invoked, it cannot fit the keyboard, and the animation transition cannot be connected. If the editor container can scroll, the position is difficult to calculate. Moreover, JS animations can be choppy.
Since the latter needs to rely on RN, it seems meaningless. Later, I even thought about modifying WebView to implement a native AccessoryView, which was a dead end.
RN Implementation#
UI Rendering and Bridging#
Later, I found the react-native-pell-rich-editor library, studied the source code, and found that the Toolbar is rendered using React Native, and then it communicates with the Web Editor through a Bridge.
This is indeed a good approach, but I initially didn't think about how to recognize the keyboard invoked in WebView in RN and make the Toolbar stick to the edge. The fact is that I overthought it; using KeyboardAvoidingView works, as KeyboardAvoidingView can also recognize the keyboard in WebView.
const Render = () => {
return (
<View className="flex-1">
<View className="mt-20 flex-1 bg-yellow-50">
<TiptapWebView />
</View>
<KeyboardAvoidingView behavior="padding">
<View className="h-12 bg-black" />
</KeyboardAvoidingView>
</View>
)
}
After that, we rendered the Toolbar with RN and communicated with the Web. Here, we can use the FlatList feature keyboardShouldPersistTaps="always"
to ensure that the keyboard does not disappear when clicking the Toolbar.
<FlatList
horizontal
keyboardShouldPersistTaps="always" // Keyboard does not disappear when clicking action
keyExtractor={(item) => item.action}
data={toolbarData}
alwaysBounceHorizontal={false}
showsHorizontalScrollIndicator={false}
renderItem={({ item }) => (
// impl
)}
/>
Next, we need to bridge; for better TypeScript, I declared interfaces on both the Web and RN sides.
On the RN side:
import WebView from 'react-native-webview'
export interface TiptapWebViewMethods {
blur(): void
bold(): void
italic(): void
underline(): void
strike(): void
}
export const callTiptapWebViewMethod = async (
webviewRef: React.RefObject<WebView>,
method: keyof TiptapWebViewMethods,
...args: any[]
) => {
if (webviewRef.current) {
const result = await webviewRef.current.injectJavaScript(`
window.tiptap.${method}(${JSON.stringify(args)})
`)
return result
}
}
Define the operations for the editor.
On the Web side, implement it:
import type { Editor } from '@tiptap/core'
declare const window: any
const FLAG_ONCE_KEY = Symbol()
export const registerGlobalMethods = (editor: Editor) => {
if (window[FLAG_ONCE_KEY]) return
window.tiptap = {
blur() {
editor.chain().blur().run()
},
bold() {
editor.chain().toggleBold().run()
},
italic() {
editor.chain().toggleItalic().run()
},
underline() {
editor.chain().toggleUnderline().run()
},
strike() {
editor.chain().toggleStrike().run()
},
}
window[FLAG_ONCE_KEY] = true
}
On the RN side, define the action list:
const toolbarItems = ({
editor,
}: {
editor: React.RefObject<WebView<unknown>>
}): ToolbarItem[] => [
{
action: 'bold',
onClick() {
callTiptapWebViewMethod(editor, 'bold')
},
icon: <Icon name="bold" size={24} />,
pr: 16,
},
{
action: 'italic',
onClick() {
callTiptapWebViewMethod(editor, 'italic')
},
icon: <Icon name="italic" size={24} />,
pr: 16,
},
{
action: 'hyphen',
onClick() {
callTiptapWebViewMethod(editor, 'strike')
},
icon: <Icon name="hyphen-s" size={24} />,
pr: 16,
},
{
action: 'underline',
onClick() {
callTiptapWebViewMethod(editor, 'underline')
},
icon: <Icon name="hyphen-u" size={24} />,
pr: 16,
},
{
action: 'photo',
onClick() {
// TODO
},
icon: <Icon name="photo" size={24} />,
pr: 16,
spacer: true,
},
]
// FlatList
const toolbarData = useMemo(
() =>
toolbarItems({
editor: webviewRef,
}),
[],
)
<FlatList
horizontal
keyboardShouldPersistTaps="always"
keyExtractor={(item) => item.action}
data={toolbarData}
alwaysBounceHorizontal={false}
showsHorizontalScrollIndicator={false}
renderItem={({ item }) => (
<>
<View className="h-full items-center justify-center">
<UnstyledButton onPress={item.onClick}>{item.icon}</UnstyledButton>
</View>
{!!item.pr && <View style={{ width: item.pr }} />}
{item.spacer && <View className="flex-shrink flex-grow" />}
</>
)}
/>
Transition Connection#
Now let's make the toolbar disappear when the keyboard disappears. Here we can create an animation connection. Since it is not a native AccessoryView, it cannot be integrated with the entire keyboard animation. I used a two-part animation here.
useLayoutEffect(() => {
const subscriptions = [] as EmitterSubscription[]
subscriptions.push(
Keyboard.addListener('keyboardWillShow', () => {
animatedTranslateYValue.setValue(0)
}),
Keyboard.addListener('keyboardWillHide', () => {
Animated.spring(animatedTranslateYValue, {
toValue: 44,
useNativeDriver: true,
bounciness: 0,
}).start()
callTiptapWebViewMethod(webviewRef, 'blur')
}),
bus.on(EventMap.showToolbar, () => {
animatedTranslateYValue.setValue(0)
}),
bus.on(EventMap.hideToolbar, () => {
animatedTranslateYValue.setValue(44) // 44 is the height of the toolbar
}),
)
return () => {
subscriptions.forEach((sub) => sub.remove())
}
}, [])
<Animated.View
className={cn(
'absolute bottom-0 left-0 right-0 h-[44] flex-row px-6',
className,
)}
style={{
backgroundColor: Colors.theme.hoverFill,
transform: [
{
translateY: animatedTranslateYValue,
},
],
}}
>
<FlatList
horizontal
keyboardShouldPersistTaps="always"
keyExtractor={(item) => item.action}
data={toolbarData}
alwaysBounceHorizontal={false}
showsHorizontalScrollIndicator={false}
renderItem={({ item }) => (
<>
<View className="h-full items-center justify-center">
<UnstyledButton onPress={item.onClick}>{item.icon}</UnstyledButton>
</View>
{!!item.pr && <View style={{ width: item.pr }} />}
{item.spacer && <View className="flex-shrink flex-grow" />}
</>
)}
/>
</View>
</Animated.View>
The effect is as follows:
By the way, the above image also implements a Done button, which can be used to dismiss the keyboard. The implementation here is a bit clever.
<>
{/* Since we cannot directly dismiss the keyboard of the webview, we simulate closing it with an RN input */}
<TextInput ref={fakeInputRef} className="hidden" />
<View className="h-full justify-center">
<UnstyledButton
onPress={() => {
requestAnimationFrame(() => {
fakeInputRef.current?.focus()
Keyboard.dismiss()
})
}}
>
<Text className="font-bold text-[#007AFF]">Done</Text>
</UnstyledButton>
</View>
</>
Focus and Bottom Obstruction Issue#
In WebView, there is no ready-made KeyboardAvoidView available. Therefore, in scenarios where the content is long, the editing area can be within the range of the keyboard, causing the editing content to be obscured when the keyboard is invoked.
Now we need to address this issue. Before we start, the diagram below can help better understand.
At this point, there are two situations we need to handle one of them.
The former does not need to be handled. However, we need to determine whether the current focused element belongs to the former or the latter.
The handling idea for the latter is to calculate the changed viewport height and whether the coordinates of the focused element are within the obscured range.
The calculation process is as follows.
If it is the former, then the focused element's getBoundingClientRect().y + rect.height < currentWindowHeight
.
If it is the latter, we need to calculate how much the entire scroll container needs to scroll up.
This distance can be calculated by subtracting the current viewport height from the focused element's y
. The distance of the green line in the diagram minus the blue line.
The code reference is as follows:
window.onresize = () => {
const editor = editorRef.current
const currentHeight = window.innerHeight
if (currentHeight < maxWindowHeight) {
let currentDom = editor?.view.domAtPos(editor.state.selection.from)
?.node as HTMLElement | Text
if (!currentDom) {
return
}
currentDom instanceof Text && (currentDom = currentDom.parentElement!)
const rect = (currentDom as HTMLElement).getBoundingClientRect()
const { y: currentNodeY, height: nodeHeight } = rect
if (currentHeight > currentNodeY + nodeHeight) return
const axleDelta = currentNodeY - currentHeight
wrapperRef.current?.scrollTo({
top: axleDelta + wrapperRef.current.scrollTop + nodeHeight + 50, // 50 is a padding, customizable height
behavior: 'smooth',
})
}
}
return () => {
window.onresize = null
}
The effect is as follows:
This article was updated synchronously to xLog by Mix Space. The original link is https://innei.in/posts/programming/react-native-webView-editor-issue