一个功能相对完善的前端 Emoji

一个功能相对完善的前端 Emoji 评论发送功能。这个实现将包括:

  1. 一个文本输入区域(使用 contenteditable div 以便更好地支持图文混排)。
  2. 一个 Emoji 选择器触发按钮。
  3. 一个弹出式 Emoji 选择面板,包含分类和搜索功能。
  4. 将选中的 Emoji 插入到输入区域的光标位置。
  5. 最近使用 Emoji 的功能。
  6. 使用原生 Unicode Emoji,但也方便扩展为图片 Emoji。

要达到严格的 1000 行 纯逻辑 代码可能有些困难,因为核心功能并不需要那么多代码,除非加入大量 Emoji 数据或非常复杂的 UI 逻辑。但我会尽可能地提供详细、结构清晰、注释丰富的代码,并包含 HTML、CSS 和 JavaScript,力求接近目标并覆盖完善的功能点。

整体结构

  • HTML (index.html) : 定义评论输入框、Emoji 按钮、Emoji 选择器面板的基本结构。
  • CSS (style.css) : 美化界面,控制 Emoji 选择器的布局、显示/隐藏、滚动条等。
  • JavaScript (emoji-comment.js) : 实现核心逻辑,包括 Emoji 数据、面板渲染、事件处理、Emoji 插入、搜索、最近使用等。

HTML (index.html)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Emoji 评论功能</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>

<div class="comment-container">
    <h2>发表评论</h2>
    <div class="comment-input-area">
        <!-- 使用 contenteditable div 作为输入框 -->
        <div id="comment-editor" class="comment-editor" contenteditable="true" placeholder="请输入评论内容..."></div>
        <div class="comment-controls">
            <!-- Emoji 选择器触发按钮 -->
            <button id="emoji-picker-trigger" class="emoji-trigger-button" aria-label="选择表情">
                😊 <!-- 可以用 SVG 图标代替 -->
            </button>
            <button id="submit-comment" class="submit-button">发送</button>
        </div>
    </div>

    <!-- Emoji 选择器面板 (默认隐藏) -->
    <div id="emoji-picker" class="emoji-picker" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="emoji-picker-title">
        <div class="emoji-picker-header">
            <h3 id="emoji-picker-title">选择表情</h3>
            <input type="text" id="emoji-search-input" class="emoji-search-input" placeholder="搜索表情...">
            <button id="emoji-picker-close" class="emoji-picker-close-button" aria-label="关闭表情选择器">&times;</button>
        </div>
        <div class="emoji-picker-content">
            <!-- 分类导航 (可选) -->
            <div id="emoji-categories" class="emoji-categories">
                <!-- 分类按钮会动态生成 -->
            </div>
            <!-- Emoji 显示区域 -->
            <div id="emoji-list" class="emoji-list" role="grid" aria-label="表情列表">
                <!-- Emoji 会动态生成 -->
            </div>
        </div>
        <div class="emoji-picker-footer">
            <!-- 最近使用区域 -->
            <h4>最近使用</h4>
            <div id="recent-emojis" class="recent-emojis" role="grid" aria-label="最近使用的表情">
                <!-- 最近使用的 Emoji 会动态生成 -->
            </div>
        </div>
    </div>
</div>

<script src="emoji-data.js"></script> <!-- 引入 Emoji 数据 -->
<script src="emoji-comment.js"></script> <!-- 引入核心逻辑 -->

</body>
</html>

CSS (style.css)

css 复制代码
/* style.css */
body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
    margin: 20px;
    background-color: #f4f5f7;
    color: #172b4d;
    line-height: 1.5;
}

.comment-container {
    max-width: 600px;
    margin: 40px auto;
    background-color: #ffffff;
    padding: 20px 30px;
    border-radius: 8px;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
    position: relative; /* 为了 Emoji 选择器的定位 */
}

h2 {
    margin-top: 0;
    color: #091e42;
    border-bottom: 1px solid #dfe1e6;
    padding-bottom: 10px;
    margin-bottom: 20px;
}

.comment-input-area {
    position: relative; /* 包含编辑器和控制按钮 */
}

.comment-editor {
    min-height: 80px;
    max-height: 200px; /* 限制最大高度,超出可滚动 */
    overflow-y: auto;
    border: 1px solid #dfe1e6;
    border-radius: 4px;
    padding: 10px;
    font-size: 14px;
    line-height: 1.6;
    background-color: #fafbfc;
    color: #172b4d;
    transition: border-color 0.2s ease-in-out, background-color 0.2s ease-in-out;
    outline: none; /* 移除默认的蓝色外边框 */
    white-space: pre-wrap; /* 保留空白符序列 */
    word-wrap: break-word; /* 允许长单词或 URL 换行 */
}

.comment-editor:focus {
    border-color: #4c9aff;
    background-color: #ffffff;
    box-shadow: 0 0 0 1px #4c9aff; /* 模拟 focus 效果 */
}

/* 实现 placeholder 效果 for contenteditable */
.comment-editor:empty::before {
    content: attr(placeholder);
    color: #a5adba;
    pointer-events: none; /* 允许点击穿透 */
    display: block; /* 或者 inline-block */
}

.comment-controls {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 10px;
}

.emoji-trigger-button {
    background: none;
    border: none;
    font-size: 24px; /* Emoji 大小 */
    cursor: pointer;
    padding: 5px;
    border-radius: 50%;
    transition: background-color 0.2s ease;
    line-height: 1; /* 防止按钮过高 */
}

.emoji-trigger-button:hover {
    background-color: #ebecf0;
}

.submit-button {
    background-color: #0052cc;
    color: white;
    border: none;
    padding: 8px 16px;
    border-radius: 4px;
    cursor: pointer;
    font-size: 14px;
    font-weight: 500;
    transition: background-color 0.2s ease;
}

.submit-button:hover {
    background-color: #0065ff;
}

.submit-button:active {
    background-color: #0747a6;
}

/* --- Emoji Picker Styles --- */
.emoji-picker {
    position: absolute;
    bottom: 55px; /* 放置在触发按钮上方 */
    left: 0;
    width: 320px;
    max-height: 400px; /* 限制最大高度 */
    background-color: #ffffff;
    border: 1px solid #dfe1e6;
    border-radius: 6px;
    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
    z-index: 1000;
    display: flex; /* 使用 flex 布局 */
    flex-direction: column; /* 垂直排列 */
    overflow: hidden; /* 防止内容溢出圆角 */
    transition: opacity 0.1s ease-out, transform 0.1s ease-out;
    opacity: 1;
    transform: translateY(0);
}

/* 隐藏时的状态 (可以配合 JS 添加/移除类来控制) */
.emoji-picker.hidden {
    opacity: 0;
    transform: translateY(10px);
    pointer-events: none;
    display: none; /* 彻底隐藏 */
}

.emoji-picker-header {
    padding: 10px 15px;
    border-bottom: 1px solid #dfe1e6;
    display: flex;
    align-items: center;
    background-color: #f4f5f7; /* 头部背景色 */
}

.emoji-picker-header h3 {
    margin: 0;
    font-size: 16px;
    color: #42526e;
    flex-grow: 1; /* 占据剩余空间 */
}

.emoji-search-input {
    padding: 5px 8px;
    border: 1px solid #c1c7d0;
    border-radius: 3px;
    font-size: 13px;
    margin-right: 10px; /* 与关闭按钮间距 */
    width: 120px; /* 限制宽度 */
    transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.emoji-search-input:focus {
    border-color: #4c9aff;
    box-shadow: 0 0 0 1px #4c9aff;
    outline: none;
}

.emoji-picker-close-button {
    background: none;
    border: none;
    font-size: 20px;
    color: #6b778c;
    cursor: pointer;
    padding: 0 5px;
    line-height: 1;
}
.emoji-picker-close-button:hover {
    color: #42526e;
}

.emoji-picker-content {
    flex-grow: 1; /* 占据剩余空间 */
    overflow-y: auto; /* 内容超出时滚动 */
    display: flex;
    flex-direction: column;
}

.emoji-categories {
    display: flex;
    padding: 8px 15px;
    border-bottom: 1px solid #dfe1e6;
    background-color: #fafbfc;
    flex-wrap: wrap; /* 分类多时换行 */
}

.emoji-category-button {
    background: none;
    border: none;
    padding: 5px 8px;
    margin-right: 5px;
    margin-bottom: 5px; /* 换行时底部间距 */
    font-size: 18px; /* 用图标代表分类 */
    cursor: pointer;
    border-radius: 4px;
    transition: background-color 0.2s ease;
}

.emoji-category-button:hover {
    background-color: #ebecf0;
}

.emoji-category-button.active {
    background-color: #deebff; /* 选中分类的背景色 */
    color: #0052cc;
}

.emoji-list {
    padding: 10px 15px;
    display: grid; /* 使用 Grid 布局 */
    grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); /* 自动填充,最小 32px */
    gap: 8px; /* Emoji 之间的间距 */
    flex-grow: 1; /* 占据内容区域剩余空间 */
}

.emoji-list-section {
    margin-bottom: 15px;
}

.emoji-list-section h4 {
    margin: 0 0 8px 0;
    font-size: 14px;
    color: #5e6c84;
    font-weight: 600;
    grid-column: 1 / -1; /* 标题占满整行 */
}

.emoji-button {
    background: none;
    border: none;
    font-size: 24px; /* Emoji 字符大小 */
    padding: 4px;
    cursor: pointer;
    border-radius: 4px;
    transition: background-color 0.1s ease;
    text-align: center;
    line-height: 1.2;
}

.emoji-button:hover {
    background-color: #ebecf0;
}

.emoji-button:focus {
    outline: none;
    box-shadow: 0 0 0 2px #4c9aff; /* 添加 focus 效果 */
}

.emoji-picker-footer {
    padding: 10px 15px;
    border-top: 1px solid #dfe1e6;
    background-color: #f4f5f7;
}

.emoji-picker-footer h4 {
    margin: 0 0 8px 0;
    font-size: 14px;
    color: #5e6c84;
    font-weight: 600;
}

.recent-emojis {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
    gap: 8px;
    min-height: 30px; /* 即使没有最近表情也占位 */
}

/* 响应式调整 (简单示例) */
@media (max-width: 400px) {
    .emoji-picker {
        width: 90%; /* 移动端宽度调整 */
        bottom: 60px; /* 调整位置 */
        left: 5%;
        max-height: 300px;
    }
    .emoji-search-input {
        width: 100px;
    }
    .emoji-button {
        font-size: 20px;
    }
    .emoji-list {
        grid-template-columns: repeat(auto-fill, minmax(28px, 1fr));
        gap: 5px;
    }
    .recent-emojis {
        grid-template-columns: repeat(auto-fill, minmax(28px, 1fr));
        gap: 5px;
    }
}

JavaScript Emoji 数据 (emoji-data.js)

为了代码简洁,这里只包含少量 Emoji 数据作为示例。实际应用中,你会需要一个更完整的 Emoji 列表,可以从 emoji-mart-data 或其他库获取。

JavaScript 复制代码
// emoji-data.js
// 简化的 Emoji 数据结构
// 实际应用中,这个列表会非常庞大,并可能包含关键字用于搜索
const emojiData = {
  categories: [
    { id: 'recent', name: '最近使用', icon: '🕒' }, // 特殊分类
    { id: 'people', name: '笑脸与人物', icon: '😀' },
    { id: 'nature', name: '动物与自然', icon: '🌿' },
    { id: 'food', name: '食物与饮料', icon: '🍔' },
    { id: 'activity', name: '活动', icon: '⚽' },
    { id: 'travel', name: '旅行与地点', icon: '✈️' },
    { id: 'objects', name: '物品', icon: '💡' },
    { id: 'symbols', name: '符号', icon: '❤️' },
    { id: 'flags', name: '旗帜', icon: '🏳️' },
  ],
  emojis: {
    people: [
      { e: '😀', n: ['grinning face', '笑脸'] },
      { e: '😃', n: ['grinning face with big eyes', '大眼笑脸'] },
      { e: '😄', n: ['grinning face with smiling eyes', '眯眼笑脸'] },
      { e: '😁', n: ['beaming face with smiling eyes', '露齿笑脸'] },
      { e: '😆', n: ['grinning squinting face', '斜眼笑脸'] },
      { e: '😅', n: ['grinning face with sweat', '汗笑脸'] },
      { e: '🤣', n: ['rolling on the floor laughing', '笑哭'] },
      { e: '😂', n: ['face with tears of joy', '喜极而泣'] },
      { e: '🙂', n: ['slightly smiling face', '微笑'] },
      { e: '🙃', n: ['upside-down face', '倒脸'] },
      { e: '😉', n: ['winking face', '眨眼'] },
      { e: '😊', n: ['smiling face with smiling eyes', '开心'] },
      { e: '😇', n: ['smiling face with halo', '天使'] },
      // ... 更多人物表情
    ],
    nature: [
      { e: '🐶', n: ['dog face', '狗脸'] },
      { e: '🐱', n: ['cat face', '猫脸'] },
      { e: '🐭', n: ['mouse face', '鼠脸'] },
      { e: '🐹', n: ['hamster face', '仓鼠脸'] },
      { e: '🐰', n: ['rabbit face', '兔脸'] },
      { e: '🦊', n: ['fox face', '狐狸脸'] },
      { e: '🐻', n: ['bear face', '熊脸'] },
      { e: '🐼', n: ['panda face', '熊猫脸'] },
      { e: '🌿', n: ['herb', '草药'] },
      { e: '🌸', n: ['cherry blossom', '樱花'] },
      { e: '☀️', n: ['sun', '太阳'] },
      { e: '🌙', n: ['crescent moon', '月亮'] },
      // ... 更多自然表情
    ],
    food: [
      { e: '🍎', n: ['red apple', '苹果'] },
      { e: '🍔', n: ['hamburger', '汉堡'] },
      { e: '🍕', n: ['pizza', '披萨'] },
      { e: '🍟', n: ['french fries', '薯条'] },
      { e: '🍦', n: ['soft ice cream', '冰淇淋'] },
      { e: '🍩', n: ['doughnut', '甜甜圈'] },
      // ... 更多食物表情
    ],
    // ... 其他分类的 Emoji 数据
    activity: [ { e: '⚽', n: ['soccer ball', '足球'] }, { e: '🏀', n: ['basketball', '篮球'] } ],
    travel: [ { e: '✈️', n: ['airplane', '飞机'] }, { e: '🚗', n: ['car', '汽车'] } ],
    objects: [ { e: '💡', n: ['light bulb', '灯泡'] }, { e: '💻', n: ['laptop', '笔记本电脑'] } ],
    symbols: [ { e: '❤️', n: ['red heart', '红心'] }, { e: '❓', n: ['question mark', '问号'] } ],
    flags: [ { e: '🏳️', n: ['white flag', '白旗'] }, { e: '🇨🇳', n: ['flag China', '中国国旗'] } ],
  }
};

// 用于存储最近使用的 Emoji (实际应用中会持久化到 localStorage)
let recentEmojisList = [];
const MAX_RECENT_EMOJIS = 16; // 最多显示多少个最近使用的 Emoji

JavaScript 核心逻辑 (emoji-comment.js)

JavaScript 复制代码
// emoji-comment.js

/**
 * Emoji 评论功能模块
 */
const EmojiComment = (() => {
    // --- DOM 元素引用 ---
    const commentEditor = document.getElementById('comment-editor');
    const emojiPickerTrigger = document.getElementById('emoji-picker-trigger');
    const emojiPicker = document.getElementById('emoji-picker');
    const emojiPickerClose = document.getElementById('emoji-picker-close');
    const emojiSearchInput = document.getElementById('emoji-search-input');
    const emojiCategoriesContainer = document.getElementById('emoji-categories');
    const emojiListContainer = document.getElementById('emoji-list');
    const recentEmojisContainer = document.getElementById('recent-emojis');
    const submitButton = document.getElementById('submit-comment');

    // --- 状态变量 ---
    let isPickerOpen = false;
    let currentSelection = null; // 用于保存和恢复光标位置
    let currentCategory = emojiData.categories[1].id; // 默认显示第一个真实分类

    // --- 常量 ---
    const RECENT_EMOJI_STORAGE_KEY = 'recentEmojis';

    // --- 初始化 ---
    function init() {
        console.log('Initializing Emoji Comment Module...');
        loadRecentEmojis(); // 加载最近使用的 Emoji
        renderCategories(); // 渲染分类导航
        renderEmojis(currentCategory); // 渲染默认分类的 Emoji
        renderRecentEmojis(); // 渲染最近使用的 Emoji 区域
        attachEventListeners(); // 绑定事件监听器
        console.log('Emoji Comment Module Initialized.');
    }

    // --- 事件监听器绑定 ---
    function attachEventListeners() {
        // 点击触发按钮,打开/关闭选择器
        emojiPickerTrigger.addEventListener('click', togglePicker);

        // 点击关闭按钮,关闭选择器
        emojiPickerClose.addEventListener('click', closePicker);

        // 点击选择器外部区域,关闭选择器
        document.addEventListener('click', handleClickOutside);

        // 搜索框输入事件
        emojiSearchInput.addEventListener('input', handleSearch);

        // 评论编辑器获取焦点时,保存光标位置 (用于确保插入位置正确)
        commentEditor.addEventListener('focus', saveSelection);
        commentEditor.addEventListener('blur', saveSelection); // 失去焦点也保存一下
        commentEditor.addEventListener('keyup', saveSelection); // 按键释放
        commentEditor.addEventListener('mouseup', saveSelection); // 鼠标抬起

        // 点击发送按钮 (示例)
        submitButton.addEventListener('click', handleSubmit);

        // (事件委托) 点击 Emoji 列表中的 Emoji 按钮
        emojiListContainer.addEventListener('click', handleEmojiButtonClick);

        // (事件委托) 点击分类按钮
        emojiCategoriesContainer.addEventListener('click', handleCategoryClick);

         // (事件委托) 点击最近使用 Emoji 按钮
        recentEmojisContainer.addEventListener('click', handleEmojiButtonClick);
    }

    // --- Emoji 选择器显隐控制 ---
    function togglePicker(event) {
        event.stopPropagation(); // 阻止事件冒泡到 document
        if (isPickerOpen) {
            closePicker();
        } else {
            openPicker();
        }
    }

    function openPicker() {
        if (isPickerOpen) return;
        console.log('Opening emoji picker...');
        // 恢复光标,确保插入位置是最后一次编辑的位置
        restoreSelection();
        emojiPicker.style.display = 'flex'; // 或者 'block',取决于 CSS 设计
        // 使用 requestAnimationFrame 确保 display 生效后再加 class 触发过渡
        requestAnimationFrame(() => {
            emojiPicker.classList.remove('hidden');
        });
        isPickerOpen = true;
        emojiPickerTrigger.setAttribute('aria-expanded', 'true');
        // 可选:打开时聚焦到搜索框
        // emojiSearchInput.focus();
    }

    function closePicker() {
        if (!isPickerOpen) return;
        console.log('Closing emoji picker...');
        emojiPicker.classList.add('hidden');
        // 等待 CSS 过渡动画完成再设置 display: none
        // 注意:过渡时间需要与 CSS 中的 transition-duration 匹配
        setTimeout(() => {
             // 再次检查是否仍然是关闭状态 (防止快速开关导致的问题)
             if (!isPickerOpen) {
                 emojiPicker.style.display = 'none';
             }
        }, 150); // 假设 CSS 过渡时间为 0.1s (100ms),稍加延迟
        isPickerOpen = false;
        emojiPickerTrigger.setAttribute('aria-expanded', 'false');
        // 关闭时将焦点移回编辑器
        commentEditor.focus();
    }

    // 处理点击选择器外部区域的逻辑
    function handleClickOutside(event) {
        if (isPickerOpen && !emojiPicker.contains(event.target) && event.target !== emojiPickerTrigger) {
            closePicker();
        }
    }

    // --- 渲染函数 ---

    // 渲染分类导航按钮
    function renderCategories() {
        emojiCategoriesContainer.innerHTML = ''; // 清空现有分类
        emojiData.categories.forEach(category => {
            // 不渲染"最近使用"分类按钮,它在 footer 单独处理
            if (category.id === 'recent') return;

            const button = document.createElement('button');
            button.className = 'emoji-category-button';
            button.textContent = category.icon; // 使用图标作为按钮内容
            button.title = category.name; // tooltip 显示分类名
            button.setAttribute('data-category-id', category.id);
            button.setAttribute('role', 'tab');
            button.setAttribute('aria-controls', `emoji-list-section-${category.id}`);
            button.setAttribute('aria-selected', category.id === currentCategory ? 'true' : 'false');
            if (category.id === currentCategory) {
                button.classList.add('active');
            }
            emojiCategoriesContainer.appendChild(button);
        });
    }

    // 渲染指定分类或搜索结果的 Emoji
    // filterFn: 可选的过滤函数,用于搜索
    function renderEmojis(categoryId, filterFn = null) {
        console.log(`Rendering emojis for category: ${categoryId}, Filter applied: ${!!filterFn}`);
        emojiListContainer.innerHTML = ''; // 清空现有列表
        emojiListContainer.scrollTop = 0; // 切换分类时滚动到顶部

        if (filterFn) {
            // --- 搜索模式 ---
            const searchResults = [];
            // 遍历所有分类进行搜索
            for (const catId in emojiData.emojis) {
                emojiData.emojis[catId].forEach(emojiInfo => {
                    if (filterFn(emojiInfo)) {
                        searchResults.push(emojiInfo);
                    }
                });
            }

            if (searchResults.length === 0) {
                emojiListContainer.innerHTML = '<p style="text-align: center; color: #6b778c; grid-column: 1 / -1;">未找到匹配的表情</p>';
            } else {
                // 直接渲染搜索结果,不显示分类标题
                searchResults.forEach(emojiInfo => {
                    emojiListContainer.appendChild(createEmojiButton(emojiInfo));
                });
            }
        } else {
             // --- 分类浏览模式 ---
             // 找到当前分类的数据
             const categoryData = emojiData.emojis[categoryId];
             if (!categoryData) {
                 console.warn(`Category data not found for ID: ${categoryId}`);
                 emojiListContainer.innerHTML = '<p style="text-align: center; color: #6b778c; grid-column: 1 / -1;">无法加载表情</p>';
                 return;
             }

             // (可选) 创建分类标题,如果需要在一个滚动区域显示所有分类的话
             // const categoryTitle = document.createElement('h4');
             // categoryTitle.textContent = emojiData.categories.find(c => c.id === categoryId)?.name || '表情';
             // categoryTitle.id = `emoji-list-section-${categoryId}`;
             // emojiListContainer.appendChild(categoryTitle);

             // 渲染该分类下的所有 Emoji
             categoryData.forEach(emojiInfo => {
                 emojiListContainer.appendChild(createEmojiButton(emojiInfo));
             });
        }
    }

    // 创建单个 Emoji 按钮元素
    function createEmojiButton(emojiInfo) {
        const button = document.createElement('button');
        button.className = 'emoji-button';
        button.textContent = emojiInfo.e; // 显示 Emoji 字符
        button.setAttribute('data-emoji', emojiInfo.e);
        button.setAttribute('aria-label', emojiInfo.n ? emojiInfo.n.join(', ') : 'emoji'); // ARIA 标签
        button.setAttribute('role', 'gridcell'); // 角色
        button.title = emojiInfo.n ? emojiInfo.n[0] : ''; // 鼠标悬停提示(英文名)
        return button;
    }

    // 渲染最近使用的 Emoji
    function renderRecentEmojis() {
        console.log('Rendering recent emojis:', recentEmojisList);
        recentEmojisContainer.innerHTML = ''; // 清空
        if (recentEmojisList.length === 0) {
            recentEmojisContainer.innerHTML = '<span style="font-size: 12px; color: #97a0af;">还没有最近使用的表情</span>';
        } else {
            recentEmojisList.forEach(emoji => {
                // 从完整数据中查找 emoji 信息(为了获取名称等)
                let emojiInfo = null;
                outerLoop:
                for (const categoryId in emojiData.emojis) {
                    for (const item of emojiData.emojis[categoryId]) {
                        if (item.e === emoji) {
                            emojiInfo = item;
                            break outerLoop;
                        }
                    }
                }
                // 如果在数据中找不到(理论上不应发生),则创建一个基础信息
                if (!emojiInfo) {
                    emojiInfo = { e: emoji, n: [emoji] };
                }
                recentEmojisContainer.appendChild(createEmojiButton(emojiInfo));
            });
        }
    }

    // --- 事件处理函数 ---

    // 处理分类按钮点击
    function handleCategoryClick(event) {
        const target = event.target.closest('.emoji-category-button');
        if (!target) return; // 没有点中按钮

        const categoryId = target.getAttribute('data-category-id');
        if (categoryId && categoryId !== currentCategory) {
            console.log('Category changed to:', categoryId);
            currentCategory = categoryId;
            // 更新按钮激活状态
            const buttons = emojiCategoriesContainer.querySelectorAll('.emoji-category-button');
            buttons.forEach(btn => {
                const btnCatId = btn.getAttribute('data-category-id');
                if (btnCatId === categoryId) {
                    btn.classList.add('active');
                    btn.setAttribute('aria-selected', 'true');
                } else {
                    btn.classList.remove('active');
                    btn.setAttribute('aria-selected', 'false');
                }
            });
            // 清空搜索框并重新渲染
            emojiSearchInput.value = '';
            renderEmojis(categoryId);
        }
    }

    // 处理 Emoji 按钮点击 (包括主列表和最近使用列表)
    function handleEmojiButtonClick(event) {
        const target = event.target.closest('.emoji-button');
        if (!target) return; // 没有点中按钮

        const emoji = target.getAttribute('data-emoji');
        if (emoji) {
            console.log('Emoji selected:', emoji);
            insertEmoji(emoji);
            addRecentEmoji(emoji); // 添加到最近使用
            // 可选:点击后关闭选择器
            // closePicker();
        }
    }

    // 处理搜索输入
    function handleSearch(event) {
        const searchTerm = event.target.value.trim().toLowerCase();
        console.log('Searching for:', searchTerm);

        if (searchTerm === '') {
            // 如果搜索词为空,显示当前选中的分类
            renderEmojis(currentCategory);
        } else {
            // 定义过滤函数
            const filterFn = (emojiInfo) => {
                // 检查 Emoji 字符本身或其名称/关键字数组是否包含搜索词
                return emojiInfo.e.includes(searchTerm) ||
                       (emojiInfo.n && emojiInfo.n.some(name => name.toLowerCase().includes(searchTerm)));
            };
            // 使用过滤函数渲染 Emoji 列表
            renderEmojis(null, filterFn); // 第一个参数传 null 表示搜索模式
        }
    }

    // 处理评论提交 (示例)
    function handleSubmit() {
        // 获取 contenteditable 的内容
        // 注意:innerText 会丢失换行符,textContent 可能包含多余空格,innerHTML 包含 HTML 标签
        // 通常需要进行清理或转换为纯文本/Markdown
        const contentHTML = commentEditor.innerHTML;
        const contentText = commentEditor.innerText; // 或者更复杂的转换逻辑

        if (contentText.trim() === '') {
            alert('评论内容不能为空!');
            return;
        }

        console.log('Submitting comment (HTML):', contentHTML);
        console.log('Submitting comment (Text):', contentText);
        alert(`发送评论:${contentText}`);

        // 清空编辑器
        commentEditor.innerHTML = '';
        // 触发 input 事件,让 placeholder 重新显示 (如果需要)
        commentEditor.dispatchEvent(new Event('input', { bubbles: true }));
    }

    // --- Emoji 插入逻辑 (核心) ---
    function insertEmoji(emoji) {
        commentEditor.focus(); // 确保编辑器获得焦点

        // 优先使用恢复的选择区,如果无效则获取当前选择区
        let selection = window.getSelection();
        if (currentSelection) {
            try {
                selection.removeAllRanges(); // 清除当前可能存在的范围
                selection.addRange(currentSelection); // 尝试恢复保存的范围
            } catch (e) {
                console.warn("Could not restore selection range, using current selection.", e);
                // 如果恢复失败,就用当前的 selection
                if (!selection.rangeCount) {
                    // 如果连当前 selection 都没有 range,创建一个默认的
                    const range = document.createRange();
                    range.selectNodeContents(commentEditor);
                    range.collapse(false); // 折叠到末尾
                    selection.removeAllRanges();
                    selection.addRange(range);
                }
            }
        } else if (!selection.rangeCount) {
             // 如果没有保存的,也没有当前的,创建一个默认的 range 放在末尾
             const range = document.createRange();
             range.selectNodeContents(commentEditor);
             range.collapse(false); // false 表示折叠到末尾
             selection.removeAllRanges();
             selection.addRange(range);
        }

        const range = selection.getRangeAt(0);
        range.deleteContents(); // 删除当前选中的内容(如果有)

        // 创建一个包含 Emoji 的文本节点
        const emojiNode = document.createTextNode(emoji);

        // 插入 Emoji 节点
        range.insertNode(emojiNode);

        // 将光标移动到插入的 Emoji 之后
        range.setStartAfter(emojiNode);
        range.collapse(true); // 折叠范围,变为光标

        // 清除并重新设置选择区,确保光标位置更新
        selection.removeAllRanges();
        selection.addRange(range);

        // 更新保存的选择区,以便下次打开 picker 时能恢复
        saveSelection();

        // 手动触发 input 事件,如果外部有监听 input 事件的逻辑 (例如字数统计)
        commentEditor.dispatchEvent(new Event('input', { bubbles: true }));
    }

    // --- 光标位置管理 ---
    function saveSelection() {
        const selection = window.getSelection();
        // 只有当焦点在编辑器内时才保存
        if (selection.rangeCount > 0 && commentEditor.contains(selection.anchorNode)) {
            currentSelection = selection.getRangeAt(0).cloneRange(); // 保存 Range 对象
             console.log('Selection saved.');
        } else {
             // console.log('Selection not saved (editor not focused or no range).');
             // currentSelection = null; // 可以选择在失去焦点时清除保存的位置
        }
    }

    function restoreSelection() {
        if (currentSelection) {
            commentEditor.focus(); // 必须先聚焦才能设置 selection
            const selection = window.getSelection();
            selection.removeAllRanges();
            try {
                 selection.addRange(currentSelection);
                 console.log('Selection restored.');
            } catch (e) {
                console.warn("Failed to restore selection range:", e);
                // 如果恢复失败,将光标置于末尾
                const range = document.createRange();
                range.selectNodeContents(commentEditor);
                range.collapse(false);
                selection.addRange(range);
                currentSelection = range.cloneRange(); // 更新保存的位置
            }
        } else {
            // 如果没有保存的位置,默认将光标置于末尾
            commentEditor.focus();
            const range = document.createRange();
            range.selectNodeContents(commentEditor);
            range.collapse(false); // false 表示折叠到末尾
            const selection = window.getSelection();
            selection.removeAllRanges();
            selection.addRange(range);
            currentSelection = range.cloneRange(); // 保存这个默认位置
            console.log('No selection saved, cursor placed at the end.');
        }
    }


    // --- 最近使用 Emoji 管理 ---
    function addRecentEmoji(emoji) {
        // 去重:如果已存在,先移除
        const index = recentEmojisList.indexOf(emoji);
        if (index > -1) {
            recentEmojisList.splice(index, 1);
        }

        // 添加到列表开头
        recentEmojisList.unshift(emoji);

        // 保持列表长度不超过最大值
        if (recentEmojisList.length > MAX_RECENT_EMOJIS) {
            recentEmojisList = recentEmojisList.slice(0, MAX_RECENT_EMOJIS);
        }

        saveRecentEmojis(); // 持久化存储
        renderRecentEmojis(); // 重新渲染最近使用区域
    }

    function loadRecentEmojis() {
        try {
            const storedEmojis = localStorage.getItem(RECENT_EMOJI_STORAGE_KEY);
            if (storedEmojis) {
                recentEmojisList = JSON.parse(storedEmojis);
                console.log('Recent emojis loaded from localStorage:', recentEmojisList);
            } else {
                recentEmojisList = [];
                 console.log('No recent emojis found in localStorage.');
            }
        } catch (error) {
            console.error('Error loading recent emojis from localStorage:', error);
            recentEmojisList = []; // 出错时重置
        }
    }

    function saveRecentEmojis() {
        try {
            localStorage.setItem(RECENT_EMOJI_STORAGE_KEY, JSON.stringify(recentEmojisList));
            console.log('Recent emojis saved to localStorage.');
        } catch (error) {
            console.error('Error saving recent emojis to localStorage:', error);
        }
    }

    // --- 公开接口 ---
    return {
        init: init, // 暴露初始化方法
        // 可以根据需要暴露其他方法,例如:
        // getContent: () => commentEditor.innerHTML,
        // clearContent: () => { commentEditor.innerHTML = ''; }
    };
})();

// --- 初始化 Emoji 评论模块 ---
// 确保 DOM 加载完成后执行
document.addEventListener('DOMContentLoaded', () => {
    EmojiComment.init();
});

代码讲解

  1. HTML (index.html) :

    • 使用 <div contenteditable="true"> 作为输入框 (comment-editor),这允许富文本编辑,原生支持将 Emoji 显示为字符(或图片,如果浏览器支持或 CSS 处理)。设置了 placeholder 属性,通过 CSS 的 ::before 伪元素模拟占位符效果。
    • 包含一个 Emoji 触发按钮 (emoji-picker-trigger) 和一个发送按钮 (submit-comment)。
    • Emoji 选择器 (emoji-picker) 默认 display: none;,包含头部(标题、搜索框、关闭按钮)、内容区(分类导航 emoji-categories 和 Emoji 列表 emoji-list)和底部(最近使用 recent-emojis)。使用了 ARIA 属性增强可访问性。
  2. CSS (style.css) :

    • 提供了基本的样式,美化输入框、按钮和选择器面板。
    • .comment-editor:empty::before 用于在 contenteditable 为空时显示 placeholder 内容。
    • .emoji-picker 使用 position: absolute; 定位在触发按钮附近,display: flex; flex-direction: column; 实现内部元素的垂直布局。
    • .emoji-picker.hidden 类用于配合 JS 控制显示/隐藏,并添加了简单的淡入淡出和位移动画 (opacity, transform, transition)。
    • .emoji-picker-content 设置 overflow-y: auto; 使 Emoji 列表在内容过多时可以滚动。
    • .emoji-list.recent-emojis 使用 display: grid;grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); 创建自适应的网格布局,让 Emoji 按钮能自动填充可用空间。
    • .emoji-category-button.active 高亮显示当前选中的分类。
    • .emoji-button:hover:focus 提供了交互反馈。
    • 包含简单的 @media 查询,为窄屏幕做了一些基础的响应式调整。
  3. JavaScript Emoji 数据 (emoji-data.js) :

    • emojiData 对象包含 categories (分类信息,含 ID、名称、图标) 和 emojis (按分类 ID 组织的 Emoji 列表)。
    • 每个 Emoji 对象包含 e (Emoji 字符) 和 n (名称/关键字数组,用于搜索和 aria-label)。
    • recentEmojisList 数组用于存储最近使用的 Emoji 字符。
    • MAX_RECENT_EMOJIS 控制最近使用列表的最大长度。
    • 注意: 这是一个极简的数据集,实际应用需要更全面的数据。
  4. JavaScript 核心逻辑 (emoji-comment.js) :

    • 模块化 : 使用 IIFE (立即调用函数表达式) 创建 EmojiComment 模块,避免污染全局作用域。

    • DOM 引用和状态 : 缓存了需要操作的 DOM 元素的引用,并维护了 isPickerOpencurrentSelection (光标位置) 和 currentCategory 等状态。

    • init() : 初始化函数,负责加载最近使用、渲染初始界面、绑定事件监听器。

    • 事件监听:

      • 触发按钮 (emojiPickerTrigger) 控制 togglePicker
      • 关闭按钮 (emojiPickerClose) 调用 closePicker
      • document 监听 click 事件,通过 handleClickOutside 判断是否点击了选择器外部,如果是则关闭。
      • 搜索框 (emojiSearchInput) 监听 input 事件,调用 handleSearch
      • 编辑器 (commentEditor) 监听 focus, blur, keyup, mouseup 来调用 saveSelection 保存光标位置。
      • 发送按钮 (submitButton) 调用 handleSubmit (示例)。
      • 使用事件委托 在父容器 (emojiListContainer, emojiCategoriesContainer, recentEmojisContainer) 上监听点击事件,通过 event.target.closest() 判断具体点击的按钮,提高性能。
    • 选择器显隐 (togglePicker, openPicker, closePicker) : 控制 emojiPicker 的显示和隐藏,包括添加/移除 .hidden 类以触发 CSS 动画,并管理 aria-expanded 属性。openPicker 时会尝试 restoreSelectionclosePicker 会将焦点移回编辑器。

    • 渲染函数 (renderCategories, renderEmojis, createEmojiButton, renderRecentEmojis) :

      • renderCategories: 动态创建分类按钮。
      • renderEmojis: 核心渲染逻辑,根据传入的 categoryIdfilterFn (搜索函数) 动态生成 Emoji 按钮并填充到 emojiListContainer。支持分类浏览和搜索结果两种模式。
      • createEmojiButton: 创建单个 Emoji 按钮 DOM 元素,设置内容、data-emoji 属性、aria-labeltitle
      • renderRecentEmojis: 渲染底部最近使用的 Emoji 区域。
    • 事件处理函数 (handleCategoryClick, handleEmojiButtonClick, handleSearch, handleSubmit) :

      • handleCategoryClick: 处理分类切换逻辑,更新 currentCategory,高亮按钮,重新渲染 Emoji 列表。
      • handleEmojiButtonClick: 处理点击 Emoji 按钮(包括主列表和最近列表),获取 data-emoji 值,调用 insertEmoji 插入,并调用 addRecentEmoji 更新最近使用列表。
      • handleSearch: 根据搜索词动态过滤 Emoji 并重新渲染列表。
      • handleSubmit: 获取编辑器内容并处理提交(这里仅作 alert 演示)。获取 contenteditable 内容有多种方式 (innerHTML, innerText, textContent),需要根据需求选择和处理。
    • Emoji 插入 (insertEmoji) :

      • 这是最核心也最复杂 的部分,因为涉及到操作浏览器的 SelectionRange API 来精确控制 contenteditable div 中的光标和内容。
      • 获取焦点 (commentEditor.focus())。
      • 尝试恢复之前保存的 currentSelection,如果失败或没有保存,则获取当前的选择区,或创建一个默认在末尾的选择区。
      • 获取 Range 对象 (selection.getRangeAt(0))。
      • range.deleteContents(): 删除当前选中的文本(如果有)。
      • document.createTextNode(emoji): 创建包含 Emoji 的文本节点。注意: 直接插入字符串可能导致 HTML 注入风险,创建文本节点更安全。
      • range.insertNode(emojiNode): 在光标位置插入 Emoji 节点。
      • range.setStartAfter(emojiNode)range.collapse(true): 将光标移动到刚插入的 Emoji 之后。
      • selection.removeAllRanges(); selection.addRange(range);: 更新浏览器的选择区状态。
      • saveSelection(): 更新保存的光标位置。
      • commentEditor.dispatchEvent(new Event('input')): 手动触发 input 事件,兼容可能存在的外部监听逻辑(如字数统计)。
    • 光标管理 (saveSelection, restoreSelection) :

      • saveSelection: 在编辑器获得焦点或内容变化时,获取当前的 Range 对象并克隆 (cloneRange()) 保存到 currentSelection。只在焦点在编辑器内时保存。
      • restoreSelection: 在需要时(如打开 Picker 或插入 Emoji 前),将保存的 Range 对象应用回 Selection API,恢复光标位置。包含错误处理,以防 Range 失效。
    • 最近使用 (addRecentEmoji, loadRecentEmojis, saveRecentEmojis) :

      • addRecentEmoji: 将新使用的 Emoji 添加到 recentEmojisList 的开头,去重并限制最大数量,然后调用 saveRecentEmojisrenderRecentEmojis
      • loadRecentEmojis: 从 localStorage 加载数据。
      • saveRecentEmojis: 将 recentEmojisList 保存到 localStorage
    • 公开接口 : 模块返回一个包含 init 方法的对象,外部可以通过 EmojiComment.init() 来启动。

    • 初始化调用 : 使用 DOMContentLoaded 事件确保在 DOM 完全加载和解析后才执行 EmojiComment.init()

进一步的增强方向

  1. 完整的 Emoji 数据 : 集成一个完整的 Emoji 数据源,如 emoji-mart-data
  2. 图片 Emoji (如 Twemoji) : 修改 createEmojiButtoninsertEmoji 逻辑,不再插入文本节点,而是插入 <img> 标签,src 指向对应的 Emoji 图片。这需要一套 Emoji 图片资源,并可能需要更复杂的 contenteditable 处理。
  3. 肤色选择器: 在支持肤色变体的 Emoji 旁边添加一个小的触发器,弹出肤色选项。
  4. 性能优化: 对于非常大的 Emoji 列表,考虑使用虚拟滚动技术来渲染选择器列表,只渲染视口内可见的 Emoji,提高性能。
  5. 更复杂的 contenteditable 处理 : 处理换行符 (<br> vs \n)、粘贴内容清理、撤销/重做支持等 contenteditable 的常见问题。可以考虑使用成熟的富文本编辑器库(如 Quill, Slate.js, TipTap)来简化开发,但这就偏离了"从头实现"的初衷。
  6. 键盘导航: 为 Emoji 选择器添加完整的键盘导航支持(方向键移动、Enter 选择、Esc 关闭)。
  7. 更精细的样式和动画: 添加更平滑的过渡动画、加载状态指示等。
  8. 框架集成: 将此逻辑封装成 React/Vue/Angular 组件。
相关推荐
艾小逗3 小时前
vue3中的effectScope有什么作用,如何使用?如何自动清理
前端·javascript·vue.js
小小小小宇5 小时前
手写 zustand
前端
Hamm6 小时前
用装饰器和ElementPlus,我们在NPM发布了这个好用的表格组件包
前端·vue.js·typescript
小小小小宇7 小时前
前端国际化看这一篇就够了
前端
大G哥7 小时前
PHP标签+注释+html混写+变量
android·开发语言·前端·html·php
whoarethenext7 小时前
html初识
前端·html
m0_627827527 小时前
vue中 vue.config.js反向代理
前端
Java&Develop7 小时前
onloyoffice历史版本功能实现,版本恢复功能,编辑器功能实现 springboot+vue2
前端·spring boot·编辑器
白泽talk7 小时前
2个小时1w字| React & Golang 全栈微服务实战
前端·后端·微服务