React Native项目实战:巧用Quill打造专属编辑器

前言

本文主要介绍如何将 Quill 富文本库集成到 React Native 项目中,实现一个类似今日头条发布的富文本编辑器,涵盖多种实用功能,以满足不同场景下的编辑需求。

功能介绍

该编辑器具备以下功能:

  • 上传图片
  • 文字格式设置(加粗、斜体、下划线、删除线等)
  • 文字颜色选择
  • 对齐方式调整(左对齐、居中对齐、右对齐、两端对齐)
  • 标题格式选择
  • 列表(无序列表、有序列表)
  • 插入分割线
  • 表情包插入
  • 撤回、重做操作
  • 存储草稿

开始

Quill 配置

javascript 复制代码
import React, { useRef, useEffect, useState,useCallback} from 'react';
import {
    StyleSheet,
    Text,
    View,
    TextInput,
    TouchableOpacity,
    Keyboard,
    TouchableWithoutFeedback,
    Dimensions,StatusBar,Platform,
    Image,
  } from 'react-native';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
// 注册自定义Blot - 分割线
const BlockEmbed = Quill.import('blots/block/embed');
class DividerBlot extends BlockEmbed {
  static create() {
    const node = super.create();
    node.setAttribute('style', 'border-top: 1px solid #ccc; margin: 1em 0;');
    return node;
  }
}
DividerBlot.blotName = 'divider';
DividerBlot.tagName = 'hr';
Quill.register('formats/divider', DividerBlot);
// 工具栏按钮组件
const ToolbarButton = ({ icon, active, onClick, tooltip, children }) => (
  <button 
    className={`toolbar-button ${active ? 'active' : ''}`}
    onClick={onClick}
    title={tooltip}
  >
    {icon || children}
  </button>
);
// 工具栏分组组件
const ToolbarGroup = ({ children, label }) => (
  <div className="toolbar-group">
    {label && <div className="toolbar-group-label">{label}</div>}
    <div className="toolbar-group-content">
      {children}
    </div>
  </div>
);
//主编辑器组件
const ModularQuillEditor = ({ placeholder = '请输入正文', onChange ,switchHeaderTypeFn,initialContent,initialTitle}) => {
//配置
// 初始化 Quill 编辑器
  useEffect(() => {
    if (editorRef.current) {
      const initializeQuill = () => {
        // 初始化Quill编辑器
        quillRef.current = new Quill(editorRef.current, {
          modules: {
            toolbar: false, // 禁用默认工具栏
            history: {
              delay: 1000,
              maxStack: 100,
              userOnly: true
            }
          },
          theme: 'snow',
          placeholder: placeholder,
          bounds: editorRef.current
        });
        
        // 设置初始内容
        if (initialContent) {
          try {
            // 处理图片URL
            const processedContent = initialContent.replace(
              /<img[^>]+src="([^">]+)"/g,
              (match, src) => {
                // 如果图片URL是相对路径,转换为绝对路径
                if (src.startsWith('/')) {
                  return match.replace(src, `${process.env.REACT_APP_API_URL}${src}`);
                }
                return match;
              }
            );
        
            // 处理文本内容,移除多余的引号和转义字符
            const cleanContent = processedContent
              // 移除HTML实体编码
              .replace(/&quot;/g, '"')
              .replace(/&amp;/g, '&')
              .replace(/&lt;/g, '<')
              .replace(/&gt;/g, '>')
              // 移除多余的转义字符
              .replace(/\\"/g, '"')
              .replace(/\\n/g, '\n')
              .replace(/\\t/g, '\t')
              // 移除首尾的引号
              .replace(/^"|"$/g, '')
              // 处理可能的JSON字符串
              .replace(/^\{.*\}$/, (match) => {
                try {
                  const parsed = JSON.parse(match);
                  return parsed.content || parsed;
                } catch (e) {
                  return match;
                }
              });
        
            // 设置编辑器内容
            quillRef.current.root.innerHTML = cleanContent;
        
            // 设置光标到内容末尾
            const length = quillRef.current.getLength();
            quillRef.current.setSelection(length - 1, 0);
        
            // 触发一次内容更新
            const html = quillRef.current.root.innerHTML;
            setContent(html);
            if (onChange) {
              onChange(html);
            }
          } catch (error) {
            console.error('Error processing initial content:', error);
            // 如果处理失败,使用原始内容
            quillRef.current.root.innerHTML = initialContent;
          }
        }
        // 监听选择变化更新格式状态
        quillRef.current.on('selection-change', function(range) {
          if (range) {
            setFormats(quillRef.current.getFormat(range));
          }
        });
        
        // 监听编辑器文本变化
        quillRef.current.on('text-change', function() {
          // 更新内容
          const html = quillRef.current.root.innerHTML;
          console.log(html)
          setContent(html);
          // sessionStorage.getItem("htmlTitleContent")
          switchHeaderTypeFn("nextStep", "",html);
          // 调用外部onChange
          if (onChange) {
            onChange(html);
          }
          
          // 更新格式状态
          const range = quillRef.current.getSelection();
          if (range) {
            setFormats(quillRef.current.getFormat(range));
          }
        });
        
        // 初始聚焦
        setTimeout(() => {
          quillRef.current.focus();
        }, 100);
        quillRef.current.root.style.color = '#000000';//初始化黑色
      };
        // 非 Safari 浏览器正常初始化
        initializeQuill();
    
    }
  }, [placeholder, initialContent, onChange, switchHeaderTypeFn]);
  //保存草稿
  useEffect(() => {
    return () => {
      // 从 ref 中获取最新的值
      const { inputValue: latestInputValue, content: latestContent } = latestValuesRef.current;
      
      // 只有当有内容时才保存
      if (latestInputValue || latestContent) {
        let DraftsArray = window.localStorage.getItem("DraftsList");
        if (DraftsArray) {
          let arr = JSON.parse(DraftsArray);
          arr.unshift({
            Title: latestInputValue,
            Content: latestContent,
            time: moment().format('YYYY-MM-DD HH:mm:ss')
          });
          window.localStorage.setItem("DraftsList", JSON.stringify(arr));
        } else {
          window.localStorage.setItem("DraftsList", JSON.stringify([{
            Title: latestInputValue,
            Content: latestContent,
            time: moment().format('YYYY-MM-DD HH:mm:ss')
          }]));
        }
      }
    };
  }, []); // 空依赖数组,只在组件卸载时执行
  // 撤销操作
  const handleUndo = () => {
    if (quillRef.current) {
      quillRef.current.history.undo();
    }
  };
  
  // 重做操作
  const handleRedo = () => {
    if (quillRef.current) {
      quillRef.current.history.redo();
    }
  };
  // 应用格式
  const applyFormat = (format, value) => {
    const quill = quillRef.current;
    if (!quill) return;
    
    const range = quill.getSelection(true); // true强制获取选择
    if (range) {
      if (value === undefined) {
        // 切换格式
        const currentValue = formats[format];
        value = !currentValue;
      }
      
      quill.format(format, value);
      
      // 立即更新格式状态,不等待selection-change事件
      setFormats(prev => ({
        ...prev,
        [format]: value
      }));

      // Safari 在应用格式后重新聚焦
      if (isIOSSafari()) {
        setTimeout(() => {
          quill.focus();
        }, 0);
      }
    } else {
      quill.focus();
    }
  };
   const { mutate: uploadImage, isPending } = useCommonUpload();//上传接口
  // 插入内容
  const insertContent = (type, value) => {
    // const CreatCommonUpload=useCommonUpload();
    const quill = quillRef.current;
    if (!quill) return;
    
    const range = quill.getSelection(true);
    
    switch (type) {
      case 'divider':
        quill.insertText(range.index, '\n', Quill.sources.USER);
        quill.insertEmbed(range.index + 1, 'divider', true, Quill.sources.USER);
        quill.setSelection(range.index + 2, 0, Quill.sources.SILENT);
        break;
    case 'image':
  // 创建一个隐藏的文件输入框
  const fileInput = document.createElement('input');
  fileInput.setAttribute('type', 'file');
  fileInput.setAttribute('accept', 'image/*');
  fileInput.style.display = 'none';
  document.body.appendChild(fileInput);
  
  // 监听文件选择事件
  fileInput.onchange = async (e) => {
    const file = e.target.files[0];
    
    if (file) {
      try {
        // 显示上传中提示
        const placeholderText = `[正在上传图片...]`;
        quill.insertText(range.index, placeholderText, {
          'color': '#999',
          'italic': true,
        });

        // 创建上传请求  
       const upImageLink= uploadImage(
          { file},
          {
            onSuccess: (res) => {
                console.log("Image---",res)
                quill.insertEmbed(range.index, 'image', res);
            //   setFormData({ ...formData, imageUrl: response.url });
            },
            onError: (error) => {
              console.error('Upload failed:', error);
            }
          }
        );
        // 发送上传请求
        // const response = await fetch('/gateway-api/sys/common/upload', {
        //   method: 'POST',
        //   body: formData,
        //   headers: {
        //     'Authorization': `Bearer ${localStorage.getItem('token')}`, // 添加token
        //   },
        // });
        
        

        // 删除占位符
        quill.deleteText(range.index, placeholderText.length);

        // if (result.code === 200 && result.data?.url) {
        //   // 上传成功,插入图片
          
          quill.setSelection(range.index + 1, 0);
          
          // 设置图片尺寸
          quill.formatText(range.index, 1, {
            'width': '300px',
            'height': 'auto'
          });
        // } else if (result.code === 401) {
        //   // token失效,显示错误信息
        //   quill.insertText(range.index, `[登录已过期,请重新登录]`, {
        //     'color': 'red',
        //     'italic': true,
        //   });
          
        //   // 可以在这里处理重新登录逻辑
        //   setTimeout(() => {
        //     window.location.href = '/login';
        //   }, 1500);
        // } else {
        //   // 其他错误
        //   throw new Error(result.message || '上传失败');
        // }
      } catch (error) {
        console.error('Upload error:', error);
        
        // 删除占位符
        quill.deleteText(range.index, placeholderText.length);
        
        // 显示错误信息
        quill.insertText(range.index, `[图片上传失败: ${error.message}]`, {
          'color': 'red',
          'italic': true,
        });
      }
    }
    
    // 移除临时创建的文件输入元素
    document.body.removeChild(fileInput);
  };
  
  // 触发文件选择对话框
  fileInput.click();
  break;
        
      case 'link':
        const linkUrl = prompt('输入链接URL:');
        if (linkUrl) {
          const linkText = quill.getText(range.index, range.length) || linkUrl;
          if (range.length > 0) {
            quill.formatText(range.index, range.length, 'link', linkUrl);
          } else {
            quill.insertText(range.index, linkText, { link: linkUrl });
            quill.setSelection(range.index + linkText.length, 0);
          }
        }
        break;
    }
  };
  //应用样式
  const titleListHandleClick=(item)=>{
    applyFormat('align', false);//清除样式
    setTitleId(prev=>prev === item?.id ? null : item?.id)
    applyFormat('header', item?.value);
    applyFormat('align', item?.align);
}  
  const [showEmojiPicker, setShowEmojiPicker] = useState(false);//控制表情包插件
  // 插入表情
  const insertEmoji = useCallback((emoji) => {
    if (quillRef.current) {
      const range = quillRef.current.getSelection(true);
      if (range) {
        quillRef.current.insertText(range.index, emoji);
        quillRef.current.setSelection(range.index + emoji.length, 0);
      }
    }
  }, []); 
  
//结构
return (<View style={{
      position:"relative",
      height:visibleHeight - 50,
      backgroundColor:"#fff",
      overflow: "hidden", // 添加overflow: hidden防止滚动
      width: "100%"
    }}>
    <div className="modular-editor" style={{
      position:"relative",
      backgroundColor:"#fff",
      height: "100%",
      overflow: "hidden", // 添加overflow: hidden防止滚动
      width: "100%"
    }}>
    //富文本工作区
    <div className="editor-container" style={{position:"relative",padding:"0 0 16px 0"}}>
                <div className="editor-content" ref={editorRef} style={{"borderColor":"1px solid rgba(0,0,0,0) !important",}}></div> 
             </div>
</div>
<style jsx>{`
//样式设置
`}
<style jsx global>{`
//全局样式设置
`}
//底部自定义导航
<View>
//根据需要布局
</View>
    </View>)
}

插入表情包

组件实现
ini 复制代码
import React, { useRef, useState,useEffect,useCallback,useMemo} from 'react';
import {  View, Text,TouchableOpacity, StyleSheet,ScrollView } from 'react-native';
//主要代码
const EmojiPicker=({ onSelect, onClose }) =>{
    // 首先定义表情包数据
    const emojiData = [
        {
        category: '表情',
        items: ['😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', 
                '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚']
        },
        {
        category: '动物',
        items: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯',
                '🦁', '🐮', '🐷', '🐸', '🐵', '🐔', '🐧', '🐦', '🐤', '🦆']
        },
        {
        category: '食物',
        items: ['🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒',
                '🍑', '🥭', '🍍', '🥥', '🥝', '🍅', '🍆', '🥑', '��', '��']
        }
    ];
  const [selectedCategory, setSelectedCategory] = useState(0);
  const [searchText, setSearchText] = useState('');
  
  // 过滤表情
  const filteredEmojis = useMemo(() => {
    if (!searchText) return emojiData[selectedCategory].items;
    return emojiData[selectedCategory].items.filter(emoji => 
      emoji.includes(searchText)
    );
  }, [selectedCategory, searchText]);

  return (
    <View style={styles.emojiPickerContainer}>
      {/* 搜索框 */}
      <View style={styles.searchContainer}>
        <TextInput
          style={styles.searchInput}
          placeholder="搜索表情"
          value={searchText}
          onChangeText={setSearchText}
        />
      </View> 

      {/* 分类选择 */}
      <ScrollView 
        horizontal 
        showsHorizontalScrollIndicator={false}
        style={styles.categoryContainer}
      >
        {emojiData.map((category, index) => (
          <TouchableOpacity
            key={category.category}
            style={[
              styles.categoryButton,
              selectedCategory === index && styles.selectedCategory
            ]}
            onPress={() => setSelectedCategory(index)}
          >
            <Text style={[
              styles.categoryText,
              selectedCategory === index && styles.selectedCategoryText
            ]}>
              {category.category}
            </Text>
          </TouchableOpacity>
        ))}
      </ScrollView>

      {/* 表情网格 */}
      <ScrollView style={styles.emojiGrid}>
        <View style={styles.emojiGridContainer}>
          {filteredEmojis.map((emoji, index) => (
            <TouchableOpacity
              key={index}
              style={styles.emojiItem}
              onPress={() => {
                onSelect(emoji);
                onClose();
              }}
            >
              <Text style={styles.emojiText}>{emoji}</Text>
            </TouchableOpacity>
          ))}
        </View>
      </ScrollView>

      {/* 关闭按钮 */}
      <TouchableOpacity 
        style={styles.closeButton}
        onPress={onClose}
      >
        <Text style={styles.closeButtonText}>关闭</Text>
      </TouchableOpacity>
    </View>
  );
}                
使用组件

在主编辑器中,我们通过以下方式使用 EmojiPicker 组件:

ini 复制代码
const [showEmojiPicker, setShowEmojiPicker] = useState(false);

const insertEmoji = useCallback((emoji) => {
    if (quillRef.current) {
        const range = quillRef.current.getSelection(true);
        if (range) {
            quillRef.current.insertText(range.index, emoji);
            quillRef.current.setSelection(range.index + emoji.length, 0);
        }
    }
}, []);

<EmojiPicker 
    onSelect={insertEmoji} 
    onClose={() => setShowEmojiPicker(false)}
/>

插入图片

typescript 复制代码
// 插入内容
  const insertContent = (type, value) => {
    // const CreatCommonUpload=useCommonUpload();
    const quill = quillRef.current;
    if (!quill) return;
    
    const range = quill.getSelection(true);
    
    switch (type) {
      case 'divider':
        quill.insertText(range.index, '\n', Quill.sources.USER);
        quill.insertEmbed(range.index + 1, 'divider', true, Quill.sources.USER);
        quill.setSelection(range.index + 2, 0, Quill.sources.SILENT);
        break;
    case 'image':
  // 创建一个隐藏的文件输入框
  const fileInput = document.createElement('input');
  fileInput.setAttribute('type', 'file');
  fileInput.setAttribute('accept', 'image/*');
  fileInput.style.display = 'none';
  document.body.appendChild(fileInput);
  
  // 监听文件选择事件
  fileInput.onchange = async (e) => {
    const file = e.target.files[0];
    
    if (file) {
      try {
        // 显示上传中提示
        const placeholderText = `[正在上传图片...]`;
        quill.insertText(range.index, placeholderText, {
          'color': '#999',
          'italic': true,
        });

        // 创建上传请求  
       const upImageLink= uploadImage(
          { file},
          {
            onSuccess: (res) => {
                console.log("Image---",res)
                quill.insertEmbed(range.index, 'image', res);
            //   setFormData({ ...formData, imageUrl: response.url });
            },
            onError: (error) => {
              console.error('Upload failed:', error);
            }
          }
        );
        // 发送上传请求
        // const response = await fetch('/gateway-api/sys/common/upload', {
        //   method: 'POST',
        //   body: formData,
        //   headers: {
        //     'Authorization': `Bearer ${localStorage.getItem('token')}`, // 添加token
        //   },
        // });
        
        

        // 删除占位符
        quill.deleteText(range.index, placeholderText.length);

        // if (result.code === 200 && result.data?.url) {
        //   // 上传成功,插入图片
          
          quill.setSelection(range.index + 1, 0);
          
          // 设置图片尺寸
          quill.formatText(range.index, 1, {
            'width': '300px',
            'height': 'auto'
          });
        // } else if (result.code === 401) {
        //   // token失效,显示错误信息
        //   quill.insertText(range.index, `[登录已过期,请重新登录]`, {
        //     'color': 'red',
        //     'italic': true,
        //   });
          
        //   // 可以在这里处理重新登录逻辑
        //   setTimeout(() => {
        //     window.location.href = '/login';
        //   }, 1500);
        // } else {
        //   // 其他错误
        //   throw new Error(result.message || '上传失败');
        // }
      } catch (error) {
        console.error('Upload error:', error);
        
        // 删除占位符
        quill.deleteText(range.index, placeholderText.length);
        
        // 显示错误信息
        quill.insertText(range.index, `[图片上传失败: ${error.message}]`, {
          'color': 'red',
          'italic': true,
        });
      }
    }
    
    // 移除临时创建的文件输入元素
    document.body.removeChild(fileInput);
  };
  
  // 触发文件选择对话框
  fileInput.click();
  break;
        
      case 'link':
        const linkUrl = prompt('输入链接URL:');
        if (linkUrl) {
          const linkText = quill.getText(range.index, range.length) || linkUrl;
          if (range.length > 0) {
            quill.formatText(range.index, range.length, 'link', linkUrl);
          } else {
            quill.insertText(range.index, linkText, { link: linkUrl });
            quill.setSelection(range.index + linkText.length, 0);
          }
        }
        break;
    }
  };

文字格式、大小设置

ini 复制代码
// 文字格式设置
 <ToolbarGroup label="">
                    <ToolbarButton 
                        icon="B" 
                        active={formats.bold} 
                        onClick={() => applyFormat('bold')}
                        tooltip="加粗"
                    />
                    <ToolbarButton 
                        icon="/" 
                        active={formats.italic} 
                        onClick={() => applyFormat('italic')}
                        tooltip="斜体"
                    />
                    <ToolbarButton 
                        icon="U" 
                        active={formats.underline} 
                        onClick={() => applyFormat('underline')}
                        tooltip="下划线"
                    />
                    <ToolbarButton 
                        icon="S" 
                        active={formats.strike} 
                        onClick={() => applyFormat('strike')}
                        tooltip="删除线"
                    />
                    </ToolbarGroup>
 //字体大小
  <ToolbarGroup>
                    <ToolbarButton     
                        icon="小" 
                        active={formats.size === 'small'} 
                        onClick={() => applyFormat('size','small')}
                        tooltip="小"
                    />
                    <ToolbarButton 
                        icon="标准" 
                        active={formats.size === false} 
                        onClick={() => applyFormat('size',false)}
                        tooltip="标准"
                    />
                    <ToolbarButton 
                        icon="大" 
                        active={formats.size === 'large'}  
                        onClick={() => applyFormat('size','large')}
                        tooltip="大"
                    />
                    <ToolbarButton 
                        icon="超大" 
                        active={formats.size === 'huge'}  
                        onClick={() => applyFormat('size','huge')}
                        tooltip="超大"
                    /> 
                    </ToolbarGroup>

文字颜色设置

javascript 复制代码
  const colorList=[
        {id:1,color:"rgba(0,0,0,1)"},
        {id:2,color:"rgba(214,137,43,1)"},
        {id:3,color:"rgba(173,189,72,1)"},
        {id:4,color:"rgba(30,176,47,1)"},
        {id:5,color:"rgba(23,144,191,1)"},
        {id:6,color:"rgba(179,31,184,1)"},
        {id:7,color:"rgba(199,16,16,1)"}]
<View>
//使用applyFormat的方式设置文字颜色
    colorList.map((item,index)=><TouchableOpacity onPress={(e)=>{applyFormat('color', item.color)}}>
                            <View style={[styles.colorRing,{backgroundColor:`${item.color}`}]}></View>
                        </TouchableOpacity>)
</View>

对齐方式

ini 复制代码
<ToolbarGroup label="">
                            <ToolbarButton 
                                // icon="⬅️"
                                children={<Image source={Images.leftJustifying} style={styles.icon}/>} 
                                active={!formats.align} 
                                onClick={() => applyFormat('align', false)}
                                tooltip="左对齐"
                            />
                            <ToolbarButton 
                                // icon="⇔" 
                                children={<Image source={Images.AlignBothEnds} style={styles.icon}/>} 
                                active={formats.align === 'justify'} 
                                onClick={() => applyFormat('align', 'justify')}
                                tooltip="两端对齐"
                            />
                            <ToolbarButton    
                                // icon="⬆️" 
                                children={<Image source={Images.centerAligned} style={styles.icon}/>} 
                                active={formats.align === 'center'} 
                                onClick={() => applyFormat('align', 'center')}
                                tooltip="居中"
                            />
                            <ToolbarButton 
                                // icon="➡️" 
                                children={<Image source={Images.rightAlignment} style={styles.icon}/>} 
                                active={formats.align === 'right'} 
                                onClick={() => applyFormat('align', 'right')}
                                tooltip="右对齐"
                            /> 
                            </ToolbarGroup>

标题格式

ini 复制代码
const titleList=[{id:1,text:"标题",value:1,align:"left"},{id:2,text:"标题",value:1,align:"left"},{id:3,text:"标题",value:1,align:"center"}] 
    const titleListHandleClick=(item)=>{
        applyFormat('align', false);//清除样式
        setTitleId(prev=>prev === item?.id ? null : item?.id)
        applyFormat('header', item?.value);
        applyFormat('align', item?.align);
    } 
//组件    
 <View style={{flexDirection: 'row',justifyContent:"space-between",}}>
                            {
                               titleList.map((item,index)=>{
                                if(item.id==1){
                                    return <TouchableOpacity onPress={()=>{titleListHandleClick(item)}}>
                                        <View style={{width:98,
                                        height: 46,
                                        borderRadius: 6,
                                        backgroundColor: titleId==item.id?"rgba(204, 204, 204, 1)":"rgba(245, 245, 245, 1)",
                                        flexDirection: 'row',justifyContent:"center",alignItems:"center"
                                        }}>
                                            <View style={{height:13,width:13,backgroundColor:"rgba(54,138,45,1)",borderRadius:"50%",marginRight:9}}></View>
                                            <Text style={{fontSize:20,fontWeight:400,}}>标题</Text>
                                        </View>
                                        </TouchableOpacity>
                                }else if(item.id==2){
                                    // applyFormat('header', item.value);applyFormat('align', 'left');
                                    return <TouchableOpacity onPress={()=>{titleListHandleClick(item);}}>
                                    <View style={{width:98,
                                        height: 46,
                                        borderRadius: 6,
                                        backgroundColor: titleId==item.id?"rgba(204, 204, 204, 1)":"rgba(245, 245, 245, 1)",
                                        flexDirection: 'row',justifyContent:"center",alignItems:"center"
                                        }}>
                                            <View style={{marginRight:7}}>
                                                <Text>01</Text>
                                                <View style={{backgroundColor:"rgba(rgba(54,138,45,1))",height:2,width:18}}></View>
                                            </View>
                                            <Text style={{fontSize:20,fontWeight:400,}}>标题</Text>
                                        </View>
                                        </TouchableOpacity>
                                }else{
                                    // applyFormat('header', item.value);applyFormat('align', 'center');
                                    return <TouchableOpacity onPress={()=>{titleListHandleClick(item);}}>
                                    <View style={{width:98,
                                        height: 46,
                                        borderRadius: 6,
                                        backgroundColor: titleId==item.id?"rgba(204, 204, 204, 1)":"rgba(245, 245, 245, 1)",
                                        flexDirection:"column",justifyContent:"center",alignItems:"center"
                                        }}>
                                            <Text style={{fontSize:20,fontWeight:400,}}>标题</Text>
                                            <View style={{backgroundColor:"rgba(rgba(54,138,45,1))",height:2,width:18}}></View>
                                        </View>
                                        </TouchableOpacity>
                                }
                               }) 
                            }
                            {/* <View style={{width:98,
                            height: 46,
                            borderRadius: 6,
                            backgroundColor: "rgba(245, 245, 245, 1)",
                            flexDirection: 'row',justifyContent:"center",alignItems:"center"
                            }}>
                                <View style={{height:13,width:13,backgroundColor:"rgba(54,138,45,1)",borderRadius:"50%",marginRight:9}}></View>
                                <Text style={{fontSize:20,fontWeight:400,}}>标题</Text>
                            </View>
                            <View style={{width:98,
                            height: 46,
                            borderRadius: 6,
                            backgroundColor: "rgba(245, 245, 245, 1)",
                            flexDirection: 'row',justifyContent:"center",alignItems:"center"
                            }}>
                                <View style={{marginRight:7}}>
                                    <Text>01</Text>
                                    <View style={{backgroundColor:"rgba(rgba(54,138,45,1))",height:2,width:18}}></View>
                                </View>
                                <Text style={{fontSize:20,fontWeight:400,}}>标题</Text>
                            </View>
                            <View style={{width:98,
                            height: 46,
                            borderRadius: 6,
                            backgroundColor: "rgba(245, 245, 245, 1)",
                            flexDirection:"column",justifyContent:"center",alignItems:"center"
                            }}>
                                <Text style={{fontSize:20,fontWeight:400,}}>标题</Text>
                                <View style={{backgroundColor:"rgba(rgba(54,138,45,1))",height:2,width:18}}></View>
                            </View> */}
                        </View>

列表

ini 复制代码
    //使用applyFormat的方法
    <ToolbarGroup label="">   
                            <ToolbarButton 
                                // icon="❏" 
                                children={<Image source={Images.disorder} style={styles.icon}/>}
                                active={formats.list === 'bullet'} 
                                onClick={() => applyFormat('list', formats.list === 'bullet' ? false : 'bullet')}
                                tooltip="无序列表"
                            />
                            <ToolbarButton 
                                // icon="1." 
                                children={<Image source={Images.order} style={styles.icon}/>}
                                active={formats.list === 'ordered'} 
                                onClick={() => applyFormat('list', formats.list === 'ordered' ? false : 'ordered')}
                                tooltip="有序列表"
                            />                     
                            </ToolbarGroup>

分割线

在编辑器中,分割线可以清晰地划分不同段落或内容区域,使文章结构更加清晰。我们通过以下代码实现了分割线的插入功能:

ini 复制代码
    //使用插入方法
    <ToolbarButton 
              onClick={() => insertContent('divider')}
              tooltip="插入分割线"
                            >
              <Image source={Images.Divider} style={styles.icon}/>
    </ToolbarButton>

点击工具栏上的分割线按钮时,调用 insertContent 函数,并传入 'divider' 类型。该函数会在当前光标位置插入一个自定义的分割线嵌入(Embed),其样式为一条水平线,用于分隔内容。

撤回

在编辑过程中,用户可能会误操作或需要修改之前的编辑内容。撤回功能可以让用户撤销最近一次的编辑操作,回到之前的状态。以下是实现撤回功能的代码:

ini 复制代码
    //撤回
    const handleUndo = () => {
        if (quillRef.current) {
          quillRef.current.history.undo();
        }
      };

重做

与撤回功能相对应,重做功能可以恢复之前被撤销的编辑操作。以下是实现重做功能的代码:

ini 复制代码
    // 重做操作
      const handleRedo = () => {
        if (quillRef.current) {
          quillRef.current.history.redo();
        }
      };

存草稿

在编辑器的使用过程中,为避免用户因意外退出或其他原因导致编辑内容丢失,我们实现了草稿保存功能。当用户离开编辑页面时,系统会自动将当前编辑的富文本内容存储到本地存储中。下次用户再次打开编辑器时,系统会优先加载本地存储中的草稿内容,方便用户继续编辑。这种自动保存机制极大地提升了用户体验,确保了编辑工作的连续性。

总结

通过上述详细的功能实现介绍,我们成功基于 Quill 富文本库打造了一个功能强大的自定义富文本编辑器,并将其集成到了 React Native 项目中。该编辑器涵盖了从基本的文字格式调整到复杂的内容布局(如图片上传、列表排版、分割线插入等),以及实用的编辑操作(如撤回、重做、保存草稿等)等多种功能,能够满足不同用户在多种场景下的编辑需求。这不仅丰富了项目的交互性,也为用户提供了更加便捷、高效的内容创作体验。

相关推荐
打小就很皮...1 小时前
简单实现Ajax基础应用
前端·javascript·ajax
wanhengidc2 小时前
服务器租用:高防CDN和加速CDN的区别
运维·服务器·前端
哆啦刘小洋3 小时前
HTML Day04
前端·html
再学一点就睡3 小时前
JSON Schema:禁锢的枷锁还是突破的阶梯?
前端·json
从零开始学习人工智能4 小时前
FastMCP:构建 MCP 服务器和客户端的高效 Python 框架
服务器·前端·网络
烛阴5 小时前
自动化测试、前后端mock数据量产利器:Chance.js深度教程
前端·javascript·后端
好好学习O(∩_∩)O5 小时前
QT6引入QMediaPlaylist类
前端·c++·ffmpeg·前端框架
敲代码的小吉米5 小时前
前端HTML contenteditable 属性使用指南
前端·html
testleaf5 小时前
React知识点梳理
前端·react.js·typescript
站在风口的猪11085 小时前
《前端面试题:HTML5、CSS3、ES6新特性》
前端·css3·html5