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

引言

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

效果演示

image-20250608125649464

image-20250608125901481

技术选型

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

功能概览

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

页面结构

工具栏区域

富文本编辑器的工具栏(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 获取链接地址,最后调用命令插入链接。对于 bolditalicunderline 这些命令,根据当前文本格式状态,动态切换按钮的激活样式。

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

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>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值