IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    关于 React Native 的 WebView 编辑器问题记录

    静かな森发表于 2024-01-19 08:38:15
    love 0
    该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/programming/react-native-webView-editor-issue

    本篇文章主要解决两个问题:

    • WebView 编辑器 Toolbar 的吸附键盘
    • WebView 编辑器焦点元素被虚拟键盘遮挡
    这个内容只能在原文中查看哦

    背景

    最近在写 React Native,需要实现一个文本编辑器。而现在成熟的文本编辑器都是 Web 的,在原生或者是 React Native 这类跨端的框架成熟的富文本编辑器都是比较少见的。所以我们使用 Web + React Native WebView 去实现这个组件。

    在需求中,我们主要实现这样的布局。上面是整个编辑器,底下是 AccessoryView + Keyboard。

    思考过程

    AccessoryView

    AccessoryView 在 React Native 提供了相应的组件。组件叫 InputAccessoryView 但是有个局限是这个组件只适用于 React Native 的 TextInput。在 WebView 中无法使用。

    使用 Web 实现

    • 在 Web 中,可以使用 VisualViewport 去监听虚拟键盘是否被唤出,并且获取到虚拟键盘的宽高。然后去定位工具栏的位置。

    弊端:不是实时的状态。无法立即获取到键盘的高度。

    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)
      }
    • 可以在 React Native 中通过 Keyboard 事件传递给 Web Keyboard 的宽高,然后在 Web 控制工具栏的位置。

    弊端:事件回调不实时,相比前者好些。

      Keyboard.addListener('keyboardWillChangeFrame', (e) => {
          console.log('键盘高度变化到', e.endCoordinates.height)
    })

    以上的方案:在键盘唤出时无法贴合键盘,动画过度无法衔接。如果编辑器容器能滚动的话,位置不好计算。并且 JS 动画卡。

    后者既然要借助 RN 感觉是没有意义了。后来甚至想魔改 WebView 来实现原生的 AccessoryView。属于钻进死胡同了。

    RN 实现

    UI 绘制与架桥

    后来看到了 react-native-pell-rich-editor 这个库,学习了一下源码。发现是 Toolbar 就是用 React Native 绘制,然后用 Bridge 和 Web Editor 通信。

    这确实是个好办法,但是当初没想到在 WebView 中唤起的键盘如何在 RN 中被识别然后让 Toolbar 贴边。事实是我想多了,原来用 KeyboardAvoidingView 就可以了,KeyboardAvoidingView 也能识别 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>
      )
    }

    之后我们用 RN 绘制 Toolbar 然后与 Web 通信。这里我们可以用 FlatList 的特征 keyboardShouldPersistTaps="always" 来实现,点击 Toolbar 时,键盘不会消失。

       <FlatList
              horizontal
              keyboardShouldPersistTaps="always" // 点击 action 键盘不消失
              keyExtractor={(item) => item.action}
              data={toolbarData}
              alwaysBounceHorizontal={false}
              showsHorizontalScrollIndicator={false}
              renderItem={({ item }) => (
                         // impl
              )}
    />

    之后我们需要架桥,这里为了更好的 TypeScript。我在 Web 和 RN 两侧分别进行声明接口。

    在 RN 侧:

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

    定义编辑器的操作。

    在 Web 侧进行实现:

    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
    }

    在 RN 侧定义 action 列表:

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

    过度衔接

    现在再做一下当键盘消失的时候,工具栏也要消失。这里我们可以做一个动画衔接。由于不是原生的 AccessoryView 所以是无法与整个键盘的动画融合的。我这里用了一个两段动画。

    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 是 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>

    效果如下:

    对了上图还实现了 Done 的按钮。可以用于 Dismiss Keyboard。这里实现有点耍小聪明。

    <>
      {/* 由于 不能直接 dimiss webview 的 keyboard, 用一个 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>
    </>

    焦点与触底遮挡问题

    在 WebView 中,没有现成的 KeyboardAvoidView 可供使用。那么在长内容的编辑场景下,编辑区在键盘范围内,键盘唤出导致编辑内容被遮挡。

    现在我们要处理这个问题。在开始之前,下面的图解可以更好的帮助理解。

    这时候有两种情况,我们需要处理一种。

    前者不需要处理。但是需要判断当前焦点元素是属于前者还是后者。

    后者的处理思路是,计算变化后视窗高度,和焦点元素坐标是否在被遮挡范围内。

    计算过程是这样的。

    如果是前者,那么焦点元素的 getBoundingClientRect().y + rect.height < currentWindowHeight。

    如果说后者,则需要计算整个滚动容器需要上面滚动多少距离。

    这个距离,可以通过焦点元素的 y 减去当前视窗高度。图中的绿线减去蓝线的距离。

    0119162608

    代码参考如下:

     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 是一个 padding,可自定义高度
                behavior: 'smooth',
              })
            }
        }
    
      return () => {
        window.onresize = null
      }

    效果如下:

    看完了?说点什么呢



沪ICP备19023445号-2号
友情链接