Quill中可视化编辑公式,并在RN中渲染

3,713 阅读5分钟

前言

前一段时间有一个需求,在富文本中可视化编辑公式,并在RN中渲染。我当时一脸懵逼,这要怎么实现。小小的一个需求,我就只知道如何使用现成的富文本。经过几天的努力,终于实现了,虽然还是使用库,但是谁叫我能缝合呢。(大部分的公式和方程式是没有问题的)。

后面有完整代码地址

先来看一下实现的效果

动画.gif

正文

要想实现这个功能我们可以分为这几个步骤

  1. 富文本如何渲染数学公式和化学方程式
  2. RN如何渲染富文本的html
  3. RN如何渲染公式
  4. 如何可视化编辑公式
  5. 如何将可视化编辑公式和富文本结合

接下来我们来一一实现

富文本如何渲染数学公式和化学方程式

富文本我们这里使用quillQuill - 官网地址

在他的官网我们可以看到,他自己就支持公式,我们只需要在toolbar中配置formula就可以了。 image.png

简单简单,直接动手。

image.png

保存的瞬间

image.png

研究了一会,我才明白官网中(requires KaTeX)具体是什么意思,我们需要在项目中引入katex,将下面这个放入项目中就可以了。

<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js" integrity="sha512-LQNxIMR5rXv7o+b1l8+N1EZMfhG7iFZ9HhnbJkTp4zjNr5Wvst75AqUeFDxeRUa7l5vEDyUiAip//r+EFLLCyA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

可以了

image.png

开启F12看一下他是如何渲染的,他主要是靠katex-html渲染的,katex-mathml删了都没有事。

image.png

这个katex-html很多啊,我不能把这个全部扔给后端吧,后来我试了一下,把这个给到富文本的value他就可以渲染出来。

image.png

image.png

那就好办了,我们只要使用在dom结构里找到classql-formula的元素,然后将他的children去掉就可以了,为什么将data-value中的\变为\\,后面再解释。

code.png

数学公式解决了,看一下化学方程式,大问题。

image.png

原来是我们还需要mhchem扩展,我们可以安装mhchemParser这个库

mhchemParser 是一个用于解析化学方程式和化学式的库。它通常用于在网页或文档中以美观和格式化的方式显示化学相关的信息

我们需要解析这个化学方程式

\\ce{SO4^2- + Ba^2+ -> BaSO4 v}

解析后

{\\mathrm{SO}{\\vphantom{A}}_{\\smash[t]{4}}{\\vphantom{A}}^{2-} {}+{} \\mathrm{Ba}{\\vphantom{A}}^{2+} {}\\mathrel{\\longrightarrow}{} \\mathrm{BaSO}{\\vphantom{A}}_{\\smash[t]{4}} \\downarrow{} }

解决

image.png

还有一个问题\ce的嵌套,mhchemParser不会自己去处理嵌套问题,需要我们去处理,用一个函数来插入公式并解决\ce的嵌套。

123.png

RN如何渲染富文本的html

我们可以借助react-native-render-html这个库帮我们实现,看这个名字我知道稳了。果然它没辜负这个名字。当然了,想直接这样渲染出来是不可能的。还需要我们处理一下。继续往下看吧。

<p><span class="ql-formula" data-value="\\left ( \\frac{a}{b}\\right )^{n}= \\frac{a^{n}}{b^{n}}"></span></p>

RN如何渲染公式

这个试了很多库react-native-math-viewreact-native-mathjax-html-to-svgreact-native-mathjax等等,但是都有不同程度的问题(有些符号渲染不出)。研究了这几个库的源码,有两种做法

  1. 自己做转化,将符号变为标签或者是svg
  2. 使用webView,加载MathJax将符号转化为标签或者svg

第二种还是比较靠谱的MathJax,因为这个库一直在更新,那为什么RN的第三方库使用MathJax库的渲染公式还是渲染有缺陷呢?,因为他所使用的MathJax版本太低。

知道原理后就简单了,我们自己写一个新版本的MathJax方案就可以了

先去下一个源文件,放入安卓资源文件夹中。 image.png

编写html代码

//html.ts
export const html = (content: string, fontSize: number = 17,backgroundColor='#fff') => {
  return `
  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <style>
      p{
        margin: 3px 0px 3px 0px;
      }

      table{
        border-collapse: collapse;
        width: 100%;
      }
      th,td{
        border:1px solid black;
        padding:0px 10px;
      }

      .ql-align-center{
        display: flex;
        justify-content: center;
      }

      .ql-align-right{
        display: flex;
        justify-content: flex-end;
      }

      body {
        font-size: ${fontSize}px;
        overflow: hidden; /* 禁用滚动 */
        margin: 0;
        padding: 0;
        background-color:${backgroundColor};
      }

      .ql-formula{
        display: inline-block;
      }
      
      #html {
        margin: 0;
        background-color:#F8F8F8;

        overflow-y: hidden; 
        overflow-x: hidden;
        word-break: break-all;
      }
    </style>
    //引入MathJax
    <script src="file:///android_asset/custom/tex-svg-full.min.js"> </script>
    <script type="text/javascript">
      window.onload = function() {
        //观察大下变化
        const observer = new ResizeObserver(entries => {
          for (let entry of entries) {
            let html = document.getElementById('html');
            let scrollHeight = html.scrollHeight;
            window.ReactNativeWebView.postMessage(JSON.stringify({ height: scrollHeight }));		
          }
        });
        observer.observe(html); 
      }
    </script>
  </head>

  <body>
    <div id="html">
      ${content}
    </div>
  </body>
</html>
`
} 

使用WebView渲染Html

import React, { useEffect, useMemo, useState } from 'react';
import { WebView } from 'react-native-webview';
import { html } from './html';
import { Text, View, Animated } from 'react-native';
import { scaleSize } from '@/utils/size';

interface Props {
  htmlStr: string;
  fontSize?: number;
  backgroundColor?: string;
  handleComplete?: () => void;
}

export default function Index(props: Props) {
  const {
    htmlStr,
    fontSize = 16,
    backgroundColor = '#fff',
    handleComplete = () => {},
  } = props;
  const [height, setHeight] = useState(1);

  const renderHtml = useMemo(() => {
    let result = htmlStr;

    //表格去除属性
    result = result.replace(/<table[^>]*>/g, '<table>');
    result = result.replace(/<tr[^>]*>/g, '<tr>');
    result = result.replace(/<td[^>]*>/g, '<td>');
    result = result.replace(/<colgroup>[\s\S]*?<\/colgroup>/g, '');
    result = result.replace(/  +/g, match => {
      return '&nbsp;'.repeat(match.length);
    });
    result = result.replace(/\t+/g, match => {
      return '&nbsp;&nbsp;&nbsp;&nbsp;'.repeat(match.length);
    });

    //处理公式
    //匹配出公式所有class="ql-formula"的span标签
    const formulaArr = result.match(
      /<span class="ql-formula"[^>]*>(.*?)<\/span>/g,
    );
    if (formulaArr) {
      // 遍历每个匹配的标签
      formulaArr.forEach(formula => {
        // 提取 data-value 属性
        const dataValueMatch = formula.match(/data-value="([^"]*)"/);
        const dataValue = dataValueMatch ? dataValueMatch[1] : '';
        // 替换 span 标签内容为 data-value 的值
        result = result.replace(
          formula,
          `<span class="ql-formula" data-value="${dataValue}">$$$$ ${dataValue} $$$$</span>`,
        );
      });
    }
    return result;
  }, [htmlStr]);

  let resultHtml = useMemo(() => {
    return html(renderHtml, fontSize, backgroundColor);
  }, [renderHtml, fontSize, backgroundColor]);


  return (
    <View
      style={{
        width: '100%',
        height: height,
        opacity: height === 1 ? 0 : 1,
      }}>
      <WebView
        style={{
          flex: 1,
        }}
        scrollEnabled={false}
        allowFileAccess={true}
        allowFileAccessFromFileURLs={true}
        allowUniversalAccessFromFileURLs={true}
        source={{ html: resultHtml }}
        onMessage={event => {
          let height = JSON.parse(event.nativeEvent.data).height;
          setHeight(height);
          handleComplete();
        }}
      />
    </View>
  );
}

这就是在RN中渲染的结果

image.png

在web中如何显示

  1. 引入mathJax文件

image.png

  1. 编写渲染组件
//index.tsx
/** 将含公式的html字符串转换为React可渲染的组件 */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Frame } from './style';
import { simplifyHtmlForApp } from '@/utils/quill';

interface Props {
  value: string;
  /** 字体大小*/
  fontSize?: number;
  /** 是否单行显示*/
  singleLine?: boolean;
  /** 单行显示时的最大宽度*/
  maxWidth?: number;
}

export default function Index(props: Props) {
  const { value, fontSize = 17, singleLine = false, maxWidth = 400 } = props;
  const domRef = useRef<HTMLDivElement>(null);
  const [isEllipsis, setIsEllipsis] = useState(false);

  const renderHtml = useCallback(() => {
    let result = value;
    result = simplifyHtmlForApp(result);
    //表格去除属性
    result = result.replace(/<table[^>]*>/g, '<table>');
    result = result.replace(/<tr[^>]*>/g, '<tr>');
    result = result.replace(/<td[^>]*>/g, '<td>');
    result = result.replace(/<colgroup>[\s\S]*?<\/colgroup>/g, '');
    result = result.replace(/  +/g, match => {
      return '&nbsp;'.repeat(match.length);
    });
    result = result.replace(/\t+/g, match => {
      return '&nbsp;&nbsp;&nbsp;&nbsp;'.repeat(match.length);
    });

    if (singleLine) {
      //替换img
      result = result.replace(/<img[^>]*>/g, '[picture]');
      //替换表格
      result = result.replace(
        /<table[^>]*>([\s\S]*?)<\/table>/g,
        '<p style="display:inline-flex;align-items:center;">[table]</p>'
      );
    }

    //处理公式
    //匹配出公式所有class="ql-formula"的span标签
    const formulaArr = result.match(/<span class="ql-formula"[^>]*>(.*?)<\/span>/g);
    if (formulaArr) {
      // 遍历每个匹配的标签
      formulaArr.forEach(formula => {
        // 提取 data-value 属性
        const dataValueMatch = formula.match(/data-value="([^"]*)"/);
        let dataValue = dataValueMatch ? dataValueMatch[1] : '';

        // 替换 span 标签内容为 data-value 的值
        result = result.replace(
          formula,
          `<span class="ql-formula" data-value="${dataValue}">$$$$ ${dataValue} $$$$</span>`
        );
      });
    }

    if (domRef.current) {
      domRef.current.innerHTML = result;
    }

    return result;
  }, [value, singleLine]);

  useEffect(() => {
    renderHtml();
    if (window.MathJax) {
      window.MathJax.typeset();
    }
  }, [value]);

  useEffect(() => {
    if (domRef.current && singleLine) {
      const observer = new ResizeObserver(entries => {
        for (let entry of entries) {
          if (entry.contentRect.width >= maxWidth - 13) {
            setIsEllipsis(true);
          }
        }
      });

      observer.observe(domRef.current);

      return () => {
        observer.disconnect();
      };
    }
  }, []);

  return (
    <Frame
      style={{
        fontSize: `${fontSize}px`,
        whiteSpace: singleLine ? 'nowrap' : 'normal',
        display: 'flex',
        alignItems: 'center',
      }}
    >
      <div
        ref={domRef}
        style={{
          display: singleLine ? 'inline-flex' : 'block',
          maxWidth: singleLine ? `${maxWidth - 13}px` : 'none',
          overflow: 'hidden',
        }}
      ></div>
      {singleLine && isEllipsis && (
        <div
          style={{
            marginLeft: '5px',
            display: 'inline-flex',
          }}
        >
          ...
        </div>
      )}
    </Frame>
  );
}

//style.ts
import { colors } from "@/theme";
import styled from "styled-components";

export const Frame = styled.div`
  position:relative;
  width:100%;
  mjx-container[jax="SVG"][display="true"]{
    display:inline-block ;
  }

  code{
    background-color: #e8e8e8;
    padding: 2px 5px;
    border-radius: 5px;
    border: 1px solid #ccc;
  }

  .ql-table-cell-inner{
    display:none;
  }

  table{
    border-collapse: collapse;
    width: 100%;
  }
  th,td{
    border:1px solid black;
    padding:2px 10px;
  }

  .ql-align-center{
    display: flex;
    justify-content: center;
  }

  .ql-align-right{
    display: flex;
    justify-content: flex-end;
  }
  p{
    margin:3px 0px;
  }
`

如何可视化编辑公式

这个我们可以使用mathlive帮我们实现,官网-- CortexJS - Scientific Computing and Math for the Web。 这样就结束了,简单。

如何将可视化编辑公式和富文本结合

我们要在富文本的toolbar上添加一个按钮,点击按钮出现一个弹窗,在弹窗中嵌入mathlive让用户可以可视化编辑公式,最后点击确定,将公式插入富文本中。

我们需要编写一个quillmodule

编写一个module并注册

import ReactQuill, { Quill } from 'react-quill-new';
import formula from '../../assets/imgs/formula.svg?raw';
const icons = Quill.import('ui/icons');
icons['customButton'] = formula;

class CustomButtonModule {
  quill: any;
  options: any;
  showDialog = () => {};
  constructor(quill: any, options: any) {
    this.quill = quill;
    this.options = options;

    const toolbar = this.quill.getModule('toolbar');
    //获取toolbar的html
    const toolbarHtml = toolbar.container;
    let buttonOne = toolbarHtml.querySelector('.ql-customButton');
    buttonOne.addEventListener('click', this.handleClick.bind(this));
  }

  /** 点击按钮出现弹窗*/
  handleClick() {
    this.showDialog();
  }
}
Quill.register('modules/customButton', CustomButtonModule);

在初始化的时候,给我们自定义的模块按钮绑定点击后发生的事,这样点击toolbar中按钮的时候就可以出现弹窗了。 code.png

接下来只要在弹窗点击确定按钮时,将公式插入到富文本中就可以了。

code.png

再来一个点击公式编辑的功能。

修改.ql-formula的样式

image.png

在富文本内容发生变化的时候,给每一个公式添加上点击事件

123.png

结束

结尾

感兴趣的可以去试试。

仓库地址: function-realization: 实现一些有趣的功能 (gitee.com)