react-native 富文本编辑器实现

今年做了自己的第一款独立应用,日记类的app(Android,Ios可下载体验),核心就是富文本编辑器,技术选项做了很久,最后选了lexical,优势是可扩展,可实现很多自定义的功能。下面分享一下我的经验(ps:顺便求个职)。

准备工作

由于富文本编辑器在webview里面使用,所以我们需要先安装以下包

  • react-native-webview
  • react-native-vector-icons 图标库
  • react-native-render-html 渲染html

富文本编辑器

这里只是简单带一下,感兴趣的话后面再出个详细的lexical教程

lexical本身是可以不依赖框架使用的,但是官方的很多示例都在react环境下使用,最初我也使用该方式,但是由于引入react以后,虽然可以使功能更加丰富,也带来了加载速度慢的问题,后期还是选择原生开发的方式。

lexical的核心是nodes+plugin,node用来做渲染层,plugin用来处理一些交互逻辑或者事件,以我自己实现的imageNode举例

javascript 复制代码
import { $applyNodeReplacement, createEditor, DecoratorNode } from 'lexical'
import { postAppMessage } from '../../utils/utils'

function convertImageElement(domNode) {
  if (domNode instanceof HTMLImageElement) {
    const { alt: altText, src, width, height, className } = domNode
    const node = $createImageNode({ altText, height, src, width, size: className })
    return { node }
  }
  return null
}

export class ImageNode extends DecoratorNode {
  // 这里都是自己定义的属性,需要在创建image节点的时候传入,并方便在整个节点的范围内使用
  __src
  __altText
  __width
  __height
  __maxWidth
  __showCaption
  __caption
  __captionsEnabled
  __size
  __uuid
  
  
  constructor(src, altText, maxWidth, width, height, showCaption, caption, captionsEnabled, key, size, uuid) {
    super(key)
    this.__src = src
    this.__altText = altText
    this.__maxWidth = maxWidth
    this.__width = width || '100%'
    this.__height = height || 'auto'
    this.__showCaption = showCaption || false
    this.__caption = caption || createEditor()
    this.__captionsEnabled = captionsEnabled || captionsEnabled === undefined
    this.__size = size || 'large'
    this.__uuid = uuid

    // 这里是我的业务逻辑,webview中无法使用本地图片,所以会通过base64方式传入图片的url
    if (uuid && src !== uuid) {
      postAppMessage({
        type: 'GET_IMAGE',
        data: uuid,
      })
    }
  }

  static getType() {
    return 'image'
  }

  // clone节点使用,需要把初始化的值都传入,以免出现生成错误的问题
  static clone(node) {
    return new ImageNode(node.__src, node.__altText, node.__maxWidth, node.__width, node.__height, node.__showCaption, node.__caption, node.__captionsEnabled, node.__key, node.__size, node.__uuid)
  }

  // 导入导出时使用,如果不需要lexical的导入导出,可不实现
  static importJSON(serializedNode) {
    const { altText, height, width, maxWidth, caption, src, showCaption, uuid } = serializedNode
    const node = $createImageNode({
      altText,
      height,
      maxWidth,
      showCaption,
      src: uuid,
      width,
      uuid,
    })
    const nestedEditor = node.__caption
    const editorState = nestedEditor.parseEditorState(caption.editorState)
    if (!editorState.isEmpty()) {
      nestedEditor.setEditorState(editorState)
    }
    return node
  }

  exportDOM() {
    const element = document.createElement('img')
    element.setAttribute('src', this.__src)
    element.setAttribute('alt', this.__altText)
    element.setAttribute('width', this.__width.toString())
    element.setAttribute('height', this.__height.toString())
    element.setAttribute('class', this.__size)
    element.setAttribute('uuid', this.__uuid)
    return { element }
  }

  static importDOM() {
    return {
      img: node => ({
        conversion: convertImageElement,
        priority: 0,
      }),
    }
  }


  exportJSON() {
    return {
      altText: this.getAltText(),
      caption: this.__caption.toJSON(),
      height: this.__height === 'inherit' ? 0 : this.__height,
      maxWidth: this.__maxWidth,
      showCaption: this.__showCaption,
      src: this.__uuid,
      type: 'image',
      version: 1,
      width: this.__width === 'inherit' ? 0 : this.__width,
      class: this.__size,
      uuid: this.__uuid,
    }
  }

  // 支持外接修改图片的宽高
  setWidthAndHeight(width, height) {
    const writable = this.getWritable()
    writable.__width = width
    writable.__height = height
  }

  // 支持更新src
  setImageSrc(uuid, src) {
    const writable = this.getWritable()
    if (uuid == writable.__uuid) {
      writable.__src = src
    }
  }

  // 设置图片的标题,app内暂未开放该功能
  setShowCaption(showCaption) {
    const writable = this.getWritable()
    writable.__showCaption = showCaption
  }

  // view 层,需要创建dom节点并return出去
  createDOM(config) {
    const span = document.createElement('span')
    const theme = config.theme
    const className = theme.image
    if (className !== undefined) {
      span.className = className
    }
    const image = document.createElement('img')
    image.src = this.__src
    image.style.width = this.__width
    image.style.height = this.__height
    image.className = this.__size
    image.id = this.__uuid
    span.appendChild(image)

    return span
  }

  // 更新样式
  updateDOM(prevNode, dom, config) {
    dom.style.width = this.__width
    dom.style.height = this.__height
    return dom
  }

  getSrc() {
    return this.__src
  }

  getSize() {
    return this.__size
  }

  setSize(size) {
    const writable = this.getWritable()
    writable.__size = size

    postAppMessage({
      type: 'LOG',
      data: `setSize ${size}`,
    })
  }

  getAltText() {
    return this.__altText
  }
}

// 创建一个图片节点
export function $createImageNode({ altText, height = 'auto', maxWidth = '100%', captionsEnabled, src, width = '100%', showCaption, caption, key, size = 'large', uuid }) {
  return $applyNodeReplacement(new ImageNode(src, altText, maxWidth, width, height, showCaption, caption, captionsEnabled, key, size, uuid))
}

// 判断传入的节点是否为图片节点
export function $isImageNode(node) {
  return node instanceof ImageNode
}

一个自定义节点需要使用class的形式声明并且继承DecoratorNode,它上面本身包含了自定义节点的所有方法和函数,只需要补充对应的方法即可。

在上面的代码中,createDOM方法对应ui层,需要按照传入的数据去创建dom结构。

工具栏

由于在手机端使用富文本,工具栏需要贴着键盘,并且需要调用rn的部分功能,所以工具栏不能做到webview中。

我的实现方式是在rn端写好对应的ui结构,点击事件调用webview中暴露的方式来实现交互。

jsx 复制代码
const CreateDiary = props => {
  const editorRef = useRef(null)
  const editorBoxRef = useRef(null)

  return (
    <Layout ref={layoutRef} backgroundColor={getLayoutBackground(form?.color)}>
      <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : ''} style={Style.container}>
        <SafeAreaView style={Style.safeAreaView}>
          <View ref={editorBoxRef}>
            <Editor
              ref={editorRef}
              initialFocus={true}
              data={form}
              scrollRef={scrollRef}
              onChange={(attr, value) => {
               
              }}
              toolbarProps={{
                onPressAddImage: onImagePicker,
                onPressTemplate: onTemplatePicker,
                onImageDelete: onImageDelete,
              }}
              onFocus={setFocus}
              onCursorPosition={handleCursorPosition}
            />
          </View>

          <EditorToolbar
            focus={focus}
            richText={editorRef}
            editorBoxRef={editorBoxRef}
            data={form}
            onFocus={setFocus}
            toolbarProps={{
              onPressAddImage: onImagePicker,
              onPressTemplate: onTemplatePicker,
              onPressTags: onTagsPicker,
              onImageDelete: onImageDelete,
            }}
            onChange={(attr, value) => {
              
            }}
          />
        </SafeAreaView>
      </KeyboardAvoidingView>
    </Layout>
  )
}

RN端和Webview的通信

这块是相对比较重要的东西了,首先在react-native-webview中提供了两种通信的方式,postMessage和onMessage。

  • postMessage 可以通过调用 webview 的 ref 上的 postMessage 方法向 webview 中发送消息

  • onMessage webview 中通过 postMessage 向rn端发送消息

react-native端需要做的事情

当我们在工具栏点击了某个按钮的时候,会通过 postMessage 发送个消息过去,webview 需要知道我们到底要做什么事情,所以需要先设置好模型,比如设置字体的大小。

js 复制代码
export const actions = {
  undo: 'undo',
  redo: 'redo',
  paragraph: 'paragraph',
  headingH1: 'h1',
  headingH2: 'h2',
  headingH3: 'h3',
  headingH4: 'h4',
  headingH5: 'h5',
  headingH6: 'h6',
  fontSize: 'fontSize',
  bullet: 'bullet',
  check: 'check',
  number: 'number',
  quote: 'quote',
  codeBlock: 'codeBlock',
  topic: 'topic',
  file: 'file',
  image: 'image',
  bold: 'bold',
  italic: 'italic',
  underline: 'underline',
  strikethrough: 'strikethrough',
  code: 'code',
  link: 'link',
  color: 'color',
  background: 'background',
  textLeft: 'textLeft',
  textCenter: 'textCenter',
  textRight: 'textRight',
  justify: 'justify',
  outdent: 'outdent',
  indent: 'indent',
  clear: 'clear',
  blur: 'blur',
  mic: 'mic',
  sticky: 'sticky',
};

const RichTextEditor = React.forwardRef((props, ref) => {

  const [height, setHeight] = useState(initialHeight);
  const webviewBridgeRef = useRef(null);
  const unmountRef = useRef(false);
 

  const onMessage = event => {
    try {
      const message = JSON.parse(event.nativeEvent.data);
      const data = message.data;
      switch (message.type) {
        case 'LOG':
          console.log('FROM EDIT:', data);
          break;
        case 'DELETE_IMAGE':
          toolbarProps.onImageDelete(data);
          console.log('gxs --> % onMessage % data:\n', data);
          break;
        case 'INIT':
          console.log('INIT data', props.data);
          if (!props.data) return;

          if (props.data.describeJson) {
            setContentJSON(props.data.describeJson);
          } else if (props.data.describe) {
            setContentHTML(props.data.describe);
          }
          break;
        default:
          onMessage?.(message);
          break;
      }
    } catch (e) {}
  };


  const sendAction = (action, data, options) => {
    const jsonString = JSON.stringify({
      type: action,
      data: data || {},
      options: options || {},
    });

    if (!unmountRef.current && webviewBridgeRef.current) {
      webviewBridgeRef.current.postMessage(jsonString);
    }
  };

  const insertImage = attributes => {
    sendAction(actions.image, attributes);
  };


  return (
    <View style={[{height: height}]} onLayout={onViewLayout}>
      <WebView
        useWebKit={true}
        scrollEnabled={false}
        // 隐藏键盘附件视图
        hideKeyboardAccessoryView={true}
        // 键盘显示需要用户操作
        keyboardDisplayRequiresUserAction={false}
        // 嵌套滚动已启用
        nestedScrollEnabled={false}
        style={[styles.webview, {height}]}
        ref={webviewBridgeRef}
        onMessage={onMessage}
        bounces={false}
        javaScriptEnabled={true}
        source={{
          uri: 'http://localhost:5173/',
        }}
        onLoad={handleLoad}
      />
    </View>
  );
});

export default RichTextEditor;

所以我们会有上面 sendAction 方法去调用,参数是执行的动作类型以及具体的参数。 在 webview 中也通过相同的方式通过 onMessage 向 rn 端传递对应的事件。

webview 中需要做的事情

在 webview 中我们可以实现一个 MessagePlugin 的插件,用来做rn的事件和 lexical 通信

js 复制代码
export const MessagePlugin = editor => {
  const Actions = {
    // 缩进
    indent: () => {
      editor.update(() => {
        editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
      })
    },
    // 清空
    clear: () => {
      editor.update(() => {
        editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined)
      })
    },
    // 失焦
    blur: () => {
      editor.blur()
      postAppMessage({ type: 'CONTENT_BLUR' })
    },
    focus: () => {
      editor.update(() => {
        editor.focus()
        const contentEditableElement = document.getElementById('ContentEditable__root')
        contentEditableElement.focus()

        postAppMessage({ type: 'CONTENT_FOCUSED' })
      })
    },
  }


  postAppMessage({
    type: 'INIT',
    data: '',
  })

  const message = function (event) {

    if (event && event.data && event.data !== undefined) {
      if (!event.data) {
        event.data = '{}'
      }
      var dataStr = event.data && typeof event.data === 'string' ? event.data : '{}'
      var msgData = {}
      var action = ''

      try {
        msgData = JSON.parse(dataStr)
        action = Actions[msgData?.type]
      } catch (error) {
        console.log(error)
        throw error
      }
      if (action) {
        action(msgData.data)
      }
    }
  }
  
  document.addEventListener('message', message, false)
  window.addEventListener('message', message, false)

  // 动态调整webview的高度
  const resizeObserver = new ResizeObserver(() => {
    postAppMessage({
      type: 'OFFSET_HEIGHT',
      data: rootElement.clientHeight + 50,
    })
  })

  // 开始观察目标元素的大小变化
  resizeObserver.observe(rootElement)
}

webview 中需要做的事情很多,比如焦点、编辑器的高度,以及对应的生命周期,按照自己的需求接收 rn 事件和回调

这样就实现了一个基础的react-native的富文本编辑器。

如何渲染富文本及自定义节点

rn端渲染富文本我使用的是 react-native-render-html ,好处是可以针对标签去做处理,还可以定制渲染的样式。

jsx 复制代码
import RenderHtml from 'react-native-render-html';

export const HtmlView = React.memo(() => {
    const richText = useRef(null);

    const {width: ScreenWidth, height: ScreenHeight} = useWindowDimensions();

    const system = useSelector(state => state.system);

    return (
      <View style={{}}>
        <RenderHtml
          enableExperimentalBRCollapsing
          renderersProps={{
            ul: {
              markerBoxStyle: {
                width: 0,
                height: 0,
                opacity: 0,
              },
              markerTextStyle: {},
            },
          }}
          contentWidth={width || ScreenWidth}
          tagsStyles={{
            h1: {
              ...Typography['text50BO'],
              color: Colors.$textDefault,
              marginBottom: 10,
              marginTop: 5,
            },
          }}
          classesStyles={{
            br: {
              height: 10,
            },
            PlaygroundEditorTheme__ltr: {
              textAlign: 'left',
            },
            PlaygroundEditorTheme__rtl: {
              textAlign: 'right',
            },
          }}
          baseStyle={{
            ...Typography[fontSize || defaultFontSize],
            lineHeight:
              Typography[fontSize || defaultFontSize].lineHeight +
              (lineHeight || 0),
            letterSpacing: letterSpacing || 0,
            color: Colors.$textDefault,
            opacity: 1,
          }}
          source={{
            html: newValue,
          }}
          renderers={{
            img: prop => {
              const imageData = getRenderImage(
                prop.tnode.init.domNode.attribs.uuid,
              );
              return (
                <View style={{}}>
                  <FastImage
                    resizeMode="contain"
                    style={{
                      width: '100%',
                      borderRadius: 10,
                      aspectRatio: imageData.width / imageData.height,
                    }}
                    source={{
                      uri: imageData.uri,
                    }}
                  />
                </View>
              );
            },
            code: ({TDefaultRenderer, tnode, style, ...prop}) => {
              return (
                <Text>
                  <Text
                    style={{
                      backgroundColor: Colors.cardBackground,
                      borderRadius: 10,
                    }}>
                    <TDefaultRenderer {...prop} style={style} tnode={tnode} />{' '}
                  </Text>{' '}
                </Text>
              );
            },
          }}
          enableExperimentalMarginCollapsing={true}
          backgroundColor={Colors.transparent}
          renderChild={option => {
            console.log('option', option);
          }}
        />
      </View>
    );
  },
);

我针对对应的 html 标签和 classname 进行了一些列的主题颜色尺寸定义,以更好的跟app的主题相配合

总结

最近公司裁员失业了,宅在家里使劲投简历面试,奈何一个offer都没有,索性给自己找点事情干,本来想学一学鸿蒙顺便做个移植,结果鸿蒙那边的开发体验实在够呛,看后面有没有出来一些组件库之类的东西再继续。

这篇文章其实写的并不细,因为涉及到的细节非常多,本文当个引子,后面继续更新其他的。

相关推荐
deli0071 小时前
32K 上下文塞满后,AI 最先忘掉什么?120 轮对话实测
前端
deli0072 小时前
手机拍灯为什么有一条条黑纹?PWM 240Hz 实测
前端
wangruofeng7 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng7 小时前
给前端开发者的 100 个设计参考网站
前端·设计
星栈10 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技10 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户479492835691510 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马12 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng13 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding14 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js