前言
本文主要介绍如何将 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(/"/g, '"')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/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 项目中。该编辑器涵盖了从基本的文字格式调整到复杂的内容布局(如图片上传、列表排版、分割线插入等),以及实用的编辑操作(如撤回、重做、保存草稿等)等多种功能,能够满足不同用户在多种场景下的编辑需求。这不仅丰富了项目的交互性,也为用户提供了更加便捷、高效的内容创作体验。