/**
* Comment UI Module
* Handles DOM manipulation and interactions
*/
import { CodeArea } from '../code-area.js';
import { updateTimeTags } from '../util.js';
export class CommentUI {
constructor(element) {
this.container = element || document.querySelector('.comment-section');
this.listContainer = this.container ? this.container.querySelector('.comment-list') : null;
this.drawer = null;
this.drawerOverlay = null;
// Icons
this.icons = {
loading: `<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.219-8.56"></path></svg>`,
send: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22,2 15,22 11,13 2,9 22,2"></polygon></svg>`,
error: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>`
};
}
/**
* Update submit button state
* @param {HTMLButtonElement} btn
* @param {'normal'|'loading'|'error'} state
* @param {string} [msg]
*/
setButtonState(btn, state, msg) {
if (!btn) return;
switch (state) {
case 'loading':
btn.disabled = true;
btn.innerHTML = `${this.icons.loading} 发送中...`;
btn.classList.add('btn-loading');
break;
case 'error':
btn.disabled = false;
btn.innerHTML = `${this.icons.error} ${msg || '重试'}`;
btn.classList.add('btn-error');
break;
case 'normal':
default:
btn.disabled = false;
btn.innerHTML = `${this.icons.send} 发送`;
btn.classList.remove('btn-loading', 'btn-error');
break;
}
}
/**
* Show skeleton loading state (replaces content temporarily)
*/
showListSkeleton() {
if (!this.container) return;
// Use the same skeleton structure as in _GlobalCommentPartial
const skeletonHtml = `
<div class="comment-section-skeleton">
<div class="comment-loading-spinner">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin">
<path d="M21 12a9 9 0 11-6.219-8.56"></path>
</svg>
<span>评论加载中...</span>
</div>
</div>
`;
const wrapper = this.container.parentElement;
if (wrapper) {
wrapper.innerHTML = skeletonHtml;
}
}
/**
* Show loading state for the comment list (Overlay)
*/
showListLoading() {
if (!this.listContainer) return;
// Create overlay if not exists
let overlay = this.listContainer.querySelector('.comment-list-loading');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'comment-list-loading';
overlay.innerHTML = `
<div class="comment-loading-spinner">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin">
<path d="M21 12a9 9 0 11-6.219-8.56"></path>
</svg>
<span>加载中...</span>
</div>
`;
// Ensure relative positioning on container
if (getComputedStyle(this.listContainer).position === 'static') {
this.listContainer.style.position = 'relative';
}
this.listContainer.appendChild(overlay);
}
overlay.style.display = 'flex';
this.listContainer.classList.add('is-loading');
}
/**
* Hide loading state for the comment list
*/
hideListLoading() {
if (!this.listContainer) return;
const overlay = this.listContainer.querySelector('.comment-list-loading');
if (overlay) {
overlay.style.display = 'none';
}
this.listContainer.classList.remove('is-loading');
}
/**
* Replace the comment list content
* @param {string} html
*/
updateList(html) {
if (this.listContainer) {
this.listContainer.innerHTML = html;
this.refreshPlugins();
this.listContainer.classList.remove('is-loading');
}
}
/**
* Append comments to the list (for load more)
* @param {string} html
*/
appendList(html) {
if (this.listContainer) {
const oldLoadMore = this.listContainer.querySelector('.comment-load-more');
if (oldLoadMore) oldLoadMore.remove();
const temp = document.createElement('div');
temp.innerHTML = html;
while (temp.firstChild) {
this.listContainer.appendChild(temp.firstChild);
}
this.refreshPlugins();
}
}
/**
* Update comment count
* @param {string} count
*/
updateCount(count) {
const countEl = this.container.querySelector('.comment-section__count');
if (countEl && count) {
countEl.textContent = count;
}
}
/**
* Refresh
*/
refreshPlugins() {
if (typeof CodeArea !== 'undefined') {
new CodeArea(this.container);
}
// Update time tags
updateTimeTags(this.container);
// Update lazy load images if LazyLoad is defined
if (typeof LazyLoad !== 'undefined') {
new LazyLoad({
container: this.container
});
}
}
/**
* Move reply form to a specific comment
* @param {HTMLElement} commentItem
* @param {string} replyId
*/
showReplyForm(commentItem, replyId) {
let form = this.container.querySelector('form.comment-form');
// 如果在 container 中找不到,尝试从抽屉中找(可能上次关闭时还没移回)
if (!form && this.drawer) {
form = this.drawer.querySelector('form.comment-form');
// 如果表单在抽屉中,先移回原位
if (form) {
const originalWrapper = this.container.querySelector('.comment-form-wrapper');
if (originalWrapper) {
originalWrapper.appendChild(form);
form.classList.remove('comment-form--reply');
}
}
}
if (!form) return;
// Detect mobile
const isMobile = window.innerWidth <= 768;
if (isMobile) {
this.showMobileReplyDrawer(commentItem, replyId, form);
} else {
this.showDesktopReplyForm(commentItem, replyId, form);
}
}
/**
* Show reply form in desktop mode (inline)
* @private
*/
showDesktopReplyForm(commentItem, replyId, form) {
// Reset form state
this.resetForm(form);
// Set reply ID
const replyInput = form.querySelector('input[name="replyId"]');
if (replyInput) replyInput.value = replyId;
// Show cancel button
const cancelBtn = form.querySelector('.comment-form__cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Move form
const targetBody = commentItem.querySelector('.comment-item__body');
const actionsEl = targetBody.querySelector('.comment-item__actions');
if (actionsEl) {
actionsEl.after(form);
} else {
targetBody.appendChild(form);
}
form.classList.add('comment-form--reply');
// Scroll to form
form.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Focus textarea
const textarea = form.querySelector('textarea');
if (textarea) textarea.focus();
}
/**
* Show reply form in mobile drawer
* @private
*/
showMobileReplyDrawer(commentItem, replyId, form) {
// Extract comment context
const avatar = commentItem.querySelector('.comment-avatar-img');
const author = commentItem.querySelector('.comment-item__author, .comment-item__user-info');
const content = commentItem.querySelector('.comment-item__content');
const contextData = {
avatarSrc: avatar ? avatar.src : '',
authorName: author ? author.textContent.trim() : '未知用户',
contentText: content ? content.textContent.trim().substring(0, 100) : ''
};
// Create drawer if not exists
if (!this.drawer) {
this.createMobileDrawer();
}
// Update context
this.updateDrawerContext(contextData);
// Reset form and set reply ID
this.resetForm(form);
const replyInput = form.querySelector('input[name="replyId"]');
if (replyInput) replyInput.value = replyId;
// Move form to drawer
const drawerBody = this.drawer.querySelector('.comment-reply-drawer__body');
if (drawerBody) {
drawerBody.innerHTML = '';
drawerBody.appendChild(form);
}
// Show drawer
this.openDrawer();
}
/**
* Create mobile drawer structure
* @private
*/
createMobileDrawer() {
// Create overlay
this.drawerOverlay = document.createElement('div');
this.drawerOverlay.className = 'comment-reply-drawer__overlay';
this.drawerOverlay.addEventListener('click', () => this.closeDrawer());
// Create drawer
this.drawer = document.createElement('div');
this.drawer.className = 'comment-reply-drawer';
this.drawer.innerHTML = `
<div class="comment-reply-drawer__header">
<span class="comment-reply-drawer__title">回复评论</span>
<button type="button" class="comment-reply-drawer__close" aria-label="关闭">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="comment-reply-drawer__context">
<div class="comment-reply-drawer__context-label">回复给</div>
<div class="comment-reply-drawer__context-item">
<img class="comment-reply-drawer__context-avatar" src="" alt="">
<div class="comment-reply-drawer__context-body">
<div class="comment-reply-drawer__context-author"></div>
<div class="comment-reply-drawer__context-text"></div>
</div>
</div>
</div>
<div class="comment-reply-drawer__body"></div>
`;
const closeBtn = this.drawer.querySelector('.comment-reply-drawer__close');
if (closeBtn) {
closeBtn.addEventListener('click', () => this.closeDrawer());
}
this.container.appendChild(this.drawerOverlay);
this.container.appendChild(this.drawer);
}
/**
* Update drawer context
* @private
*/
updateDrawerContext(data) {
if (!this.drawer) return;
const avatar = this.drawer.querySelector('.comment-reply-drawer__context-avatar');
const author = this.drawer.querySelector('.comment-reply-drawer__context-author');
const text = this.drawer.querySelector('.comment-reply-drawer__context-text');
if (avatar) avatar.src = data.avatarSrc;
if (author) author.textContent = data.authorName;
if (text) text.textContent = data.contentText;
}
/**
* Open drawer
* @private
*/
openDrawer() {
if (!this.drawer || !this.drawerOverlay) return;
// Prevent body scroll
document.body.style.overflow = 'hidden';
this.drawerOverlay.classList.add('comment-reply-drawer__overlay--visible');
requestAnimationFrame(() => {
this.drawer.classList.add('comment-reply-drawer--open');
});
// Focus textarea after animation
setTimeout(() => {
const textarea = this.drawer.querySelector('.comment-form__editor');
if (textarea) textarea.focus();
}, 350);
}
/**
* Close drawer
*/
closeDrawer() {
if (!this.drawer || !this.drawerOverlay) return;
// 立即移回表单到原位,避免时序问题
const form = this.drawer.querySelector('form.comment-form');
if (form) {
const originalWrapper = this.container.querySelector('.comment-form-wrapper');
if (originalWrapper) {
this.resetForm(form);
originalWrapper.appendChild(form);
form.classList.remove('comment-form--reply');
const cancelBtn = form.querySelector('.comment-form__cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
}
}
// 关闭抽屉 UI
this.drawer.classList.remove('comment-reply-drawer--open');
this.drawerOverlay.classList.remove('comment-reply-drawer__overlay--visible');
// Restore body scroll
document.body.style.overflow = '';
}
/**
* Reset form to original position
*/
resetReplyForm() {
const form = this.container.querySelector('form.comment-form') ||
(this.drawer && this.drawer.querySelector('form.comment-form'));
const originalWrapper = this.container.querySelector('.comment-form-wrapper');
if (form && originalWrapper) {
this.resetForm(form);
originalWrapper.appendChild(form);
form.classList.remove('comment-form--reply');
const cancelBtn = form.querySelector('.comment-form__cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
}
// Close drawer if open (避免循环调用)
if (this.drawer && this.drawer.classList.contains('comment-reply-drawer--open')) {
// 只关闭 UI,不再调用 resetReplyForm
this.drawer.classList.remove('comment-reply-drawer--open');
if (this.drawerOverlay) {
this.drawerOverlay.classList.remove('comment-reply-drawer__overlay--visible');
}
document.body.style.overflow = '';
}
}
resetForm(form) {
form.reset();
const replyInput = form.querySelector('input[name="replyId"]');
if (replyInput) replyInput.value = '';
this.setButtonState(form.querySelector('button[type="submit"]'), 'normal');
this.hideListLoading();
const textarea = form.querySelector('.comment-form__editor');
if (textarea) {
this.updateCharCount(textarea);
}
}
insertMarkdown(text) {
const form = this.container.querySelector('form.comment-form');
const textarea = form ? form.querySelector('textarea') : null;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
textarea.value = value.substring(0, start) + text + value.substring(end);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
this.updateCharCount(textarea);
}
}
/**
* Update character count display
* @param {HTMLTextAreaElement} textarea
*/
updateCharCount(textarea) {
const form = textarea.closest('.comment-form');
if (!form) return;
const counter = form.querySelector('.comment-form__char-count');
if (!counter) return;
const max = parseInt(textarea.getAttribute('maxlength')) || 1000;
const current = textarea.value.length;
counter.textContent = `${current}/${max}`;
counter.classList.remove(
'comment-form__char-count--warn',
'comment-form__char-count--limit'
);
if (current >= max) {
counter.classList.add('comment-form__char-count--limit');
} else if (current > max * 0.9) {
counter.classList.add('comment-form__char-count--warn');
}
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
这是一个用于处理评论系统UI交互的JavaScript模块,主要负责DOM操作和用户界面管理。让我详细解释一下这个代码的结构和功能:
主要功能概述
这个 CommentUI 类是一个评论系统的前端UI管理器,处理评论的显示、交互和状态管理。
核心组件
1. 构造函数和初始化
constructor(element) {
this.container = element || document.querySelector('.comment-section');
this.listContainer = this.container ? this.container.querySelector('.comment-list') : null;
this.drawer = null;
this.drawerOverlay = null;
// 定义了常用的SVG图标
this.icons = {
loading: `<svg class="animate-spin">...`, // 加载图标
send: `<svg>...`, // 发送图标
error: `<svg>...` // 错误图标
};
}
2. 按钮状态管理
setButtonState(btn, state, msg)
- 管理提交按钮的三种状态:正常、加载中、错误
- 动态更新按钮的图标、文本和CSS类
3. 加载状态显示
showListSkeleton(): 显示骨架屏加载状态showListLoading()/hideListLoading(): 显示/隐藏列表加载覆盖层
4. 评论列表管理
updateList(html): 替换整个评论列表内容appendList(html): 追加新评论(用于"加载更多"功能)updateCount(count): 更新评论数量显示
5. 插件刷新机制
refreshPlugins() {
if (typeof CodeArea !== 'undefined') {
new CodeArea(this.container);
}
updateTimeTags(this.container);
if (typeof LazyLoad !== 'undefined') {
new LazyLoad({ container: this.container });
}
}
在内容更新后重新初始化相关插件(代码高亮、时间标签、懒加载等)
响应式回复功能
这是这个类最复杂的部分,支持桌面端和移动端的不同交互方式:
桌面端回复 (showDesktopReplyForm)
- 将回复表单内联显示在目标评论下方
- 平滑滚动到表单位置
- 自动聚焦到文本区域
移动端回复 (showMobileReplyDrawer)
- 创建底部抽屉式界面
- 显示被回复评论的上下文信息
- 阻止背景滚动
- 提供关闭按钮和遮罩层交互
抽屉管理
createMobileDrawer() // 创建抽屉DOM结构
updateDrawerContext() // 更新上下文信息
openDrawer() // 打开抽屉
closeDrawer() // 关闭抽屉
表单管理
表单重置和移动
resetReplyForm() // 将表单重置并移回原位
resetForm(form) // 重置表单状态
Markdown支持
insertMarkdown(text) // 在光标位置插入Markdown文本
字符计数
updateCharCount(textarea) // 更新字符计数显示,包含警告状态
设计特点
- 响应式设计: 根据屏幕宽度自动切换桌面/移动端交互模式
- 状态管理: 完善的加载、错误、正常状态切换
- 用户体验: 平滑动画、自动聚焦、防止误操作
- 模块化: 职责清晰,易于维护和扩展
- 兼容性: 支持多种第三方插件的集成
这个类是一个典型的现代前端UI管理器,处理了评论系统中几乎所有的用户界面交互需求。
评论加载中...