一个功能相对完善的前端 Emoji 评论发送功能。这个实现将包括:
- 一个文本输入区域(使用
contenteditable
div 以便更好地支持图文混排)。 - 一个 Emoji 选择器触发按钮。
- 一个弹出式 Emoji 选择面板,包含分类和搜索功能。
- 将选中的 Emoji 插入到输入区域的光标位置。
- 最近使用 Emoji 的功能。
- 使用原生 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="关闭表情选择器">×</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();
});
代码讲解
-
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 属性增强可访问性。
- 使用
-
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
查询,为窄屏幕做了一些基础的响应式调整。
-
JavaScript Emoji 数据 (
emoji-data.js
) :emojiData
对象包含categories
(分类信息,含 ID、名称、图标) 和emojis
(按分类 ID 组织的 Emoji 列表)。- 每个 Emoji 对象包含
e
(Emoji 字符) 和n
(名称/关键字数组,用于搜索和aria-label
)。 recentEmojisList
数组用于存储最近使用的 Emoji 字符。MAX_RECENT_EMOJIS
控制最近使用列表的最大长度。- 注意: 这是一个极简的数据集,实际应用需要更全面的数据。
-
JavaScript 核心逻辑 (
emoji-comment.js
) :-
模块化 : 使用 IIFE (立即调用函数表达式) 创建
EmojiComment
模块,避免污染全局作用域。 -
DOM 引用和状态 : 缓存了需要操作的 DOM 元素的引用,并维护了
isPickerOpen
、currentSelection
(光标位置) 和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
时会尝试restoreSelection
。closePicker
会将焦点移回编辑器。 -
渲染函数 (
renderCategories
,renderEmojis
,createEmojiButton
,renderRecentEmojis
) :renderCategories
: 动态创建分类按钮。renderEmojis
: 核心渲染逻辑,根据传入的categoryId
或filterFn
(搜索函数) 动态生成 Emoji 按钮并填充到emojiListContainer
。支持分类浏览和搜索结果两种模式。createEmojiButton
: 创建单个 Emoji 按钮 DOM 元素,设置内容、data-emoji
属性、aria-label
和title
。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
) :- 这是最核心也最复杂 的部分,因为涉及到操作浏览器的
Selection
和Range
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
的开头,去重并限制最大数量,然后调用saveRecentEmojis
和renderRecentEmojis
。loadRecentEmojis
: 从localStorage
加载数据。saveRecentEmojis
: 将recentEmojisList
保存到localStorage
。
-
公开接口 : 模块返回一个包含
init
方法的对象,外部可以通过EmojiComment.init()
来启动。 -
初始化调用 : 使用
DOMContentLoaded
事件确保在 DOM 完全加载和解析后才执行EmojiComment.init()
。
-
进一步的增强方向
- 完整的 Emoji 数据 : 集成一个完整的 Emoji 数据源,如
emoji-mart-data
。 - 图片 Emoji (如 Twemoji) : 修改
createEmojiButton
和insertEmoji
逻辑,不再插入文本节点,而是插入<img>
标签,src
指向对应的 Emoji 图片。这需要一套 Emoji 图片资源,并可能需要更复杂的contenteditable
处理。 - 肤色选择器: 在支持肤色变体的 Emoji 旁边添加一个小的触发器,弹出肤色选项。
- 性能优化: 对于非常大的 Emoji 列表,考虑使用虚拟滚动技术来渲染选择器列表,只渲染视口内可见的 Emoji,提高性能。
- 更复杂的
contenteditable
处理 : 处理换行符 (<br>
vs\n
)、粘贴内容清理、撤销/重做支持等contenteditable
的常见问题。可以考虑使用成熟的富文本编辑器库(如 Quill, Slate.js, TipTap)来简化开发,但这就偏离了"从头实现"的初衷。 - 键盘导航: 为 Emoji 选择器添加完整的键盘导航支持(方向键移动、Enter 选择、Esc 关闭)。
- 更精细的样式和动画: 添加更平滑的过渡动画、加载状态指示等。
- 框架集成: 将此逻辑封装成 React/Vue/Angular 组件。