引言
在Web开发中,富文本编辑器是内容管理系统、博客平台和各种Web应用中不可或缺的组件。本文将详细介绍如何使用原生HTML、CSS和JavaScript创建一个自定义的富文本编辑器,包括基本的文本格式化功能、图片上传支持和字符计数等实用特性。
效果演示
技术选型
- HTML5:构建页面结构和基础功能
- CSS3:实现现代美观的界面样式
- 原生JavaScript:处理交互逻辑和核心功能
- ContentEditable API:实现富文本编辑核心功能
功能概览
- 文本格式化(加粗、斜体、下划线)
- 列表支持(有序列表、无序列表)
- 对齐方式(左对齐、居中对齐、右对齐)
- 链接插入
- 图片上传与预览
- 字符计数
- 键盘快捷键支持
- 按钮状态同步
页面结构
工具栏区域
富文本编辑器的工具栏(toolbar),包含以下功能按钮:加粗、斜体、下划线、无序列表、有序列表、左对齐、居中对齐、右对齐、插入链接、插入图片。
每个按钮都带有对应的SVG图标和提示文字,并为后续实现编辑器功能预留了命令标识(data-command属性)。
<div class="toolbar">
<div class="toolbar-group">
<button data-command="bold" title="加粗 (Ctrl+B)">
<svg>...</svg>
</button>
<button data-command="italic" title="斜体 (Ctrl+I)">
<svg>...</svg>
</button>
<button data-command="underline" title="下划线 (Ctrl+U)">
<svg>...</svg>
</button>
</div>
<div class="toolbar-group">
<button data-command="insertUnorderedList" title="无序列表">
<svg>...</svg>
</button>
<button data-command="insertOrderedList" title="有序列表">
<svg>...</svg>
</button>
</div>
<div class="toolbar-group">
<button data-command="justifyLeft" title="左对齐">
<svg>...</svg>
</button>
<button data-command="justifyCenter" title="居中对齐">
<svg>...</svg>
</button>
<button data-command="justifyRight" title="右对齐">
<svg>...</svg>
</button>
</div>
<div class="toolbar-group">
<button data-command="createLink" title="插入链接 (Ctrl+K)">
<svg>...</svg>
</button>
<button id="insertImage" title="插入图片">
<svg>...</svg>
</button>
<input type="file" id="imageUpload" accept="image/*">
</div>
</div>
编辑区域
富文本编辑器的主要编辑区域,contenteditable="true"
使该区域可编辑,用户可以直接在此输入和格式化文本。
<div id="editor" class="editor-content" contenteditable="true"></div>
状态栏区域
富文本编辑器的状态栏区域,实时显示用户输入的字符数量,监听上传进度的动态变化,增强交互体验。
<div class="status-bar">
<span id="charCount">0 字符</span>
<div class="upload-progress" id="uploadProgress">
<div class="upload-progress-bar" id="uploadProgressBar"></div>
</div>
</div>
核心功能实现
文本样式编辑
为工具栏中所有带有 data-command 属性的按钮添加了点击事件监听器,根据点击按钮的 data-command 值,调用 document.execCommand() 执行对应的编辑命令(如加粗、斜体、插入链接等)。
其中,对 createLink
命令单独处理,先检查是否有选中文本,再通过 prompt 获取链接地址,最后调用命令插入链接。对于 bold
、italic
和 underline
这些命令,根据当前文本格式状态,动态切换按钮的激活样式。
每次操作后重新将焦点放回编辑区域,确保用户可以继续输入或编辑。
document.querySelectorAll('.toolbar button[data-command]').forEach(button => {
button.addEventListener('click', function() {
const command = this.getAttribute('data-command');
// 处理特殊命令
if (command === 'createLink') {
const selection = window.getSelection();
if (selection.toString().length === 0) {
alert('请先选择要添加链接的文本');
return;
}
const url = prompt('输入链接地址:', 'https://');
if (url) {
document.execCommand(command, false, url);
}
} else {
document.execCommand(command, false, null);
// 切换按钮激活状态
if (['bold', 'italic', 'underline'].includes(command)) {
const isActive = document.queryCommandState(command);
this.classList.toggle('active', isActive);
}
}
editor.focus();
});
});
图片上传功能
点击【插入图片】按钮会触发隐藏的文件选择框弹出,选择图片文件后,进行格式和大小验证。
图片上传时,插入一个占位符显示“上传中”状态,显示上传进度条并模拟上传过程,使用 FileReader 将图片转换为 Data URL 模拟上传成功,成功后将占位符替换为真实图片,失败则移除占位符并提示错误。
insertImageBtn.addEventListener('click', function() {
imageUpload.click();
});
imageUpload.addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
// 验证文件类型
if (!file.type.match('image.*')) {
alert('请选择有效的图片文件 (JPEG, PNG, GIF等)');
return;
}
// 验证文件大小 (限制为5MB)
if (file.size > 5 * 1024 * 1024) {
alert('图片大小不能超过5MB');
return;
}
// 插入占位符
const placeholderId = 'img-' + Date.now();
const placeholderHtml = `<span id="${placeholderId}" style="color: #666; background: #f5f5f5; padding: 2px 4px; border-radius: 3px;">[上传图片: ${file.name}]</span>`;
insertAtCursor(placeholderHtml);
// 显示上传进度
uploadProgress.style.display = 'block';
uploadProgressBar.style.width = '0%';
// 上传图片
uploadImage(file,v(progress) => {
// 更新进度条
uploadProgressBar.style.width = `${progress}%`;
}, (imageUrl) => {
// 上传成功,替换占位符
const placeholder = document.getElementById(placeholderId);
if (placeholder) {
const img = document.createElement('img');
img.src = imageUrl;
img.alt = file.name;
img.style.maxWidth = '100%';
img.style.borderRadius = '4px';
placeholder.replaceWith(img);
}
// 隐藏进度条
uploadProgress.style.display = 'none';
}, (error) => {
// 上传失败,移除占位符
const placeholder = document.getElementById(placeholderId);
if (placeholder) {
placeholder.remove();
}
alert('图片上传失败: ' + error.message);
// 隐藏进度条
uploadProgress.style.display = 'none';
});
// 重置文件输入,允许重复上传同一文件
e.target.value = '';
});
快捷键支持
监听 keydown 事件,实现以下常用快捷键:
- Ctrl+B:加粗
- Ctrl+I:斜体
- Ctrl+U:下划线
- Ctrl+K:插入链接
在执行格式化命令后,自动更新工具栏中对应按钮的激活状态。
editor.addEventListener('keydown', function(e) {
// Ctrl+B - 加粗
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
document.execCommand('bold', false, null);
document.querySelector('[data-command="bold"]').classList.toggle('active', document.queryCommandState('bold'));
}
// Ctrl+I - 斜体
if (e.ctrlKey && e.key === 'i') {
e.preventDefault();
document.execCommand('italic', false, null);
document.querySelector('[data-command="italic"]').classList.toggle('active', document.queryCommandState('italic'));
}
// Ctrl+U - 下划线
if (e.ctrlKey && e.key === 'u') {
e.preventDefault();
document.execCommand('underline', false, null);
document.querySelector('[data-command="underline"]').classList.toggle('active', document.queryCommandState('underline'));
}
// Ctrl+K - 插入链接
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
const selection = window.getSelection();
if (selection.toString().length > 0) {
document.querySelector('[data-command="createLink"]').click();
}
}
});
扩展建议
- 添加图片大小调整功能
- 将
uploadImage
函数替换为实际的后端API调用 - 可以扩展更多编辑功能,如表格、代码块等
- 添加撤销/重做功能
- 实现自动保存功能
完整代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>自定义富文本编辑器</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
padding: 20px;
background-color: #f9f9f9;
}
.editor-container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.toolbar {
display: flex;
flex-wrap: wrap;
padding: 8px;
background: #f5f7fa;
border-bottom: 1px solid #e1e4e8;
gap: 4px;
}
.toolbar-group {
display: flex;
border-right: 1px solid #e1e4e8;
padding-right: 8px;
margin-right: 8px;
}
.toolbar-group:last-child {
border-right: none;
margin-right: 0;
padding-right: 0;
}
.toolbar button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: white;
border: 1px solid #d1d5da;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.toolbar button:hover {
background: #f0f3f6;
border-color: #c8d1d9;
}
.toolbar button.active {
background: #e7ebee;
border-color: #b9bec3;
}
.toolbar button svg {
width: 18px;
height: 18px;
fill: #24292e;
}
.editor-content {
min-height: 300px;
padding: 16px;
outline: none;
line-height: 1.6;
}
.editor-content:focus {
box-shadow: inset 0 0 0 1px #0366d6;
}
.editor-content img {
max-width: 100%;
height: auto;
margin: 8px 0;
border-radius: 4px;
}
.editor-content a {
color: #0366d6;
text-decoration: none;
}
.editor-content a:hover {
text-decoration: underline;
}
.editor-content ul,
.editor-content ol {
padding-left: 2em;
margin: 8px 0;
}
#imageUpload {
display: none;
}
.status-bar {
padding: 8px 16px;
background: #f5f7fa;
border-top: 1px solid #e1e4e8;
font-size: 12px;
color: #586069;
display: flex;
justify-content: space-between;
}
.upload-progress {
display: none;
width: 100%;
height: 4px;
background: #e1e4e8;
margin-top: 8px;
border-radius: 2px;
overflow: hidden;
}
.upload-progress-bar {
height: 100%;
background: #28a745;
width: 0%;
transition: width 0.3s;
}
</style>
</head>
<body>
<div class="editor-container">
<div class="toolbar">
<div class="toolbar-group">
<button data-command="bold" title="加粗 (Ctrl+B)">
<svg viewBox="0 0 24 24">
<path d="M15.6 11.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 7.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/>
</svg>
</button>
<button data-command="italic" title="斜体 (Ctrl+I)">
<svg viewBox="0 0 24 24">
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/>
</svg>
</button>
<button data-command="underline" title="下划线 (Ctrl+U)">
<svg viewBox="0 0 24 24">
<path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z"/>
</svg>
</button>
</div>
<div class="toolbar-group">
<button data-command="insertUnorderedList" title="无序列表">
<svg viewBox="0 0 24 24">
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/>
</svg>
</button>
<button data-command="insertOrderedList" title="有序列表">
<svg viewBox="0 0 24 24">
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"/>
</svg>
</button>
</div>
<div class="toolbar-group">
<button data-command="justifyLeft" title="左对齐">
<svg viewBox="0 0 24 24">
<path d="M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z"/>
</svg>
</button>
<button data-command="justifyCenter" title="居中对齐">
<svg viewBox="0 0 24 24">
<path d="M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z"/>
</svg>
</button>
<button data-command="justifyRight" title="右对齐">
<svg viewBox="0 0 24 24">
<path d="M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z"/>
</svg>
</button>
</div>
<div class="toolbar-group">
<button data-command="createLink" title="插入链接 (Ctrl+K)">
<svg viewBox="0 0 24 24">
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
</svg>
</button>
<button id="insertImage" title="插入图片">
<svg viewBox="0 0 24 24">
<path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z"/>
</svg>
</button>
<input type="file" id="imageUpload" accept="image/*">
</div>
</div>
<div id="editor" class="editor-content" contenteditable="true"></div>
<div class="status-bar">
<span id="charCount">0 字符</span>
<div class="upload-progress" id="uploadProgress">
<div class="upload-progress-bar" id="uploadProgressBar"></div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const editor = document.getElementById('editor');
const imageUpload = document.getElementById('imageUpload');
const insertImageBtn = document.getElementById('insertImage');
const charCount = document.getElementById('charCount');
const uploadProgress = document.getElementById('uploadProgress');
const uploadProgressBar = document.getElementById('uploadProgressBar');
// 更新字符计数
function updateCharCount() {
const text = editor.innerText;
charCount.textContent = `${text.length} 字符`;
}
// 初始化字符计数
updateCharCount();
editor.addEventListener('input', updateCharCount);
// 工具栏按钮功能
document.querySelectorAll('.toolbar button[data-command]').forEach(button => {
button.addEventListener('click', function() {
const command = this.getAttribute('data-command');
// 处理特殊命令
if (command === 'createLink') {
const selection = window.getSelection();
if (selection.toString().length === 0) {
alert('请先选择要添加链接的文本');
return;
}
const url = prompt('输入链接地址:', 'https://');
if (url) {
document.execCommand(command, false, url);
}
} else {
document.execCommand(command, false, null);
// 切换按钮激活状态
if (['bold', 'italic', 'underline'].includes(command)) {
const isActive = document.queryCommandState(command);
this.classList.toggle('active', isActive);
}
}
editor.focus();
});
});
// 图片上传功能
insertImageBtn.addEventListener('click', function() {
imageUpload.click();
});
imageUpload.addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
// 验证文件类型
if (!file.type.match('image.*')) {
alert('请选择有效的图片文件 (JPEG, PNG, GIF等)');
return;
}
// 验证文件大小 (限制为5MB)
if (file.size > 5 * 1024 * 1024) {
alert('图片大小不能超过5MB');
return;
}
// 插入占位符
const placeholderId = 'img-' + Date.now();
const placeholderHtml = `<span id="${placeholderId}" style="color: #666; background: #f5f5f5; padding: 2px 4px; border-radius: 3px;">[上传图片: ${file.name}]</span>`;
insertAtCursor(placeholderHtml);
// 显示上传进度
uploadProgress.style.display = 'block';
uploadProgressBar.style.width = '0%';
// 上传图片
uploadImage(file,
(progress) => {
// 更新进度条
uploadProgressBar.style.width = `${progress}%`;
},
(imageUrl) => {
// 上传成功,替换占位符
const placeholder = document.getElementById(placeholderId);
if (placeholder) {
const img = document.createElement('img');
img.src = imageUrl;
img.alt = file.name;
img.style.maxWidth = '100%';
img.style.borderRadius = '4px';
placeholder.replaceWith(img);
}
// 隐藏进度条
uploadProgress.style.display = 'none';
},
(error) => {
// 上传失败,移除占位符
const placeholder = document.getElementById(placeholderId);
if (placeholder) {
placeholder.remove();
}
alert('图片上传失败: ' + error.message);
// 隐藏进度条
uploadProgress.style.display = 'none';
}
);
// 重置文件输入,允许重复上传同一文件
e.target.value = '';
});
// 图片上传函数 (模拟实现)
function uploadImage(file, onProgress, onSuccess, onError) {
// 模拟上传过程
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 10;
if (progress > 100) progress = 100;
onProgress(progress);
if (progress === 100) {
clearInterval(interval);
// 模拟上传成功,返回Data URL
const reader = new FileReader();
reader.onload = (e) => {
// 模拟延迟
setTimeout(() => {
onSuccess(e.target.result);
}, 300);
};
reader.onerror = () => {
onError(new Error('文件读取失败'));
};
reader.readAsDataURL(file);
}
}, 100);
}
// 在光标位置插入内容
function insertAtCursor(html) {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const div = document.createElement('div');
div.innerHTML = html;
const frag = document.createDocumentFragment();
while (div.firstChild) {
frag.appendChild(div.firstChild);
}
range.insertNode(frag);
// 移动光标到插入内容之后
const newRange = document.createRange();
newRange.setStartAfter(frag.lastChild || frag);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
} else {
editor.innerHTML += html;
}
editor.focus();
}
// 添加键盘快捷键
editor.addEventListener('keydown', function(e) {
// Ctrl+B - 加粗
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
document.execCommand('bold', false, null);
document.querySelector('[data-command="bold"]').classList.toggle('active', document.queryCommandState('bold'));
}
// Ctrl+I - 斜体
if (e.ctrlKey && e.key === 'i') {
e.preventDefault();
document.execCommand('italic', false, null);
document.querySelector('[data-command="italic"]').classList.toggle('active', document.queryCommandState('italic'));
}
// Ctrl+U - 下划线
if (e.ctrlKey && e.key === 'u') {
e.preventDefault();
document.execCommand('underline', false, null);
document.querySelector('[data-command="underline"]').classList.toggle('active', document.queryCommandState('underline'));
}
// Ctrl+K - 插入链接
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
const selection = window.getSelection();
if (selection.toString().length > 0) {
document.querySelector('[data-command="createLink"]').click();
}
}
});
// 初始化按钮状态
function initButtonStates() {
document.querySelector('[data-command="bold"]').classList.toggle('active', document.queryCommandState('bold'));
document.querySelector('[data-command="italic"]').classList.toggle('active', document.queryCommandState('italic'));
document.querySelector('[data-command="underline"]').classList.toggle('active', document.queryCommandState('underline'));
}
// 监听选择变化更新按钮状态
document.addEventListener('selectionchange', function() {
initButtonStates();
});
// 初始化按钮状态
initButtonStates();
});
</script>
</body>
</html>