banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

Record of Issues with the WebView Editor in React Native

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.

image

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>
  )
}

image

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:

image

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.

image

Now we need to address this issue. Before we start, the diagram below can help better understand.

image

At this point, there are two situations we need to handle one of them.

image

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.

image

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:

image

This article was updated synchronously to xLog by Mix Space. The original link is https://innei.in/posts/programming/react-native-webView-editor-issue

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.