使用 HTML + JavaScript 实现自定义富文本编辑器开发实践(附完整代码)

引言

在Web开发中,富文本编辑器是内容管理系统、博客平台和各种Web应用中不可或缺的组件。本文将详细介绍如何使用原生HTML、CSS和JavaScript创建一个自定义的富文本编辑器,包括基本的文本格式化功能、图片上传支持和字符计数等实用特性。

效果演示

技术选型

  • HTML5:构建页面结构和基础功能
  • CSS3:实现现代美观的界面样式
  • 原生JavaScript:处理交互逻辑和核心功能
  • ContentEditable API:实现富文本编辑核心功能

功能概览

  1. 文本格式化(加粗、斜体、下划线)
  2. 列表支持(有序列表、无序列表)
  3. 对齐方式(左对齐、居中对齐、右对齐)
  4. 链接插入
  5. 图片上传与预览
  6. 字符计数
  7. 键盘快捷键支持
  8. 按钮状态同步

页面结构

工具栏区域

富文本编辑器的工具栏(toolbar),包含以下功能按钮:加粗、斜体、下划线、无序列表、有序列表、左对齐、居中对齐、右对齐、插入链接、插入图片。

每个按钮都带有对应的SVG图标和提示文字,并为后续实现编辑器功能预留了命令标识(data-command属性)。

html 复制代码
<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" 使该区域可编辑,用户可以直接在此输入和格式化文本。

html 复制代码
<div id="editor" class="editor-content" contenteditable="true"></div>

状态栏区域

富文本编辑器的状态栏区域,实时显示用户输入的字符数量,监听上传进度的动态变化,增强交互体验。

html 复制代码
<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 获取链接地址,最后调用命令插入链接。对于 bolditalicunderline 这些命令,根据当前文本格式状态,动态切换按钮的激活样式。

每次操作后重新将焦点放回编辑区域,确保用户可以继续输入或编辑。

js 复制代码
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 模拟上传成功,成功后将占位符替换为真实图片,失败则移除占位符并提示错误。

js 复制代码
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:插入链接

在执行格式化命令后,自动更新工具栏中对应按钮的激活状态。

javascript 复制代码
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调用
  • 可以扩展更多编辑功能,如表格、代码块等
  • 添加撤销/重做功能
  • 实现自动保存功能

完整代码

html 复制代码
<!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>
相关推荐
WeiXiao_Hyy27 分钟前
成为 Top 1% 的工程师
java·开发语言·javascript·经验分享·后端
吃杠碰小鸡44 分钟前
高中数学-数列-导数证明
前端·数学·算法
kingwebo'sZone1 小时前
C#使用Aspose.Words把 word转成图片
前端·c#·word
xjt_09011 小时前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农1 小时前
Vue 2.3
前端·javascript·vue.js
夜郎king2 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳2 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵3 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星3 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_3 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js