import { CodeArea } from '../CodeArea.ts';
import { util } from '../util.ts';
/**
* 按钮状态类型
*/
type ButtonState = 'normal' | 'loading' | 'error';
/**
* 抽屉上下文数据接口
*/
interface DrawerContext {
avatarSrc: string;
authorName: string;
contentText: string;
}
/**
* 图标集合接口
*/
interface IconCollection {
loading: string;
send: string;
error: string;
}
/**
* 声明全局 LazyLoad
*/
declare const LazyLoad: any;
/**
* 评论 UI 模块
* 负责 DOM 操作和交互处理
*/
export class CommentUI {
container: HTMLElement | null;
listContainer: HTMLElement | null;
drawer: HTMLElement | null;
drawerOverlay: HTMLElement | null;
icons: IconCollection;
/**
* 构造函数
* @param element 评论容器元素,默认查找 .comment-section
*/
constructor(element?: HTMLElement | null) {
this.container = element || document.querySelector<HTMLElement>('.comment-section');
this.listContainer = this.container
? this.container.querySelector<HTMLElement>('.comment-list')
: null;
this.drawer = null;
this.drawerOverlay = null;
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>`,
};
}
/**
* 更新提交按钮状态
* @param btn 按钮元素
* @param state 按钮状态:normal-正常、loading-加载中、error-错误
* @param msg 自定义消息文本
*/
public setButtonState(btn: HTMLButtonElement | null, state: ButtonState, msg?: string): void {
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;
}
}
/**
* 显示骨架屏加载状态
* 替换整个容器内容为加载动画
*/
public showListSkeleton(): void {
if (!this.container) {
return;
}
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;
}
}
/**
* 显示评论列表加载状态
* 在列表上方显示遮罩层和加载动画
*/
public showListLoading(): void {
if (!this.listContainer) {
return;
}
let overlay = this.listContainer.querySelector<HTMLElement>('.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>
`;
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');
}
/**
* 隐藏评论列表加载状态
*/
public hideListLoading(): void {
if (!this.listContainer) {
return;
}
const overlay = this.listContainer.querySelector<HTMLElement>('.comment-list-loading');
if (overlay) {
overlay.style.display = 'none';
}
this.listContainer.classList.remove('is-loading');
}
/**
* 替换评论列表内容
* @param html 新的HTML内容
*/
updateList(html: string): void {
if (this.listContainer) {
this.listContainer.innerHTML = html;
this.refreshPlugins();
this.listContainer.classList.remove('is-loading');
}
}
/**
* 追加评论到列表末尾
* 用于加载更多评论的场景
* @param html 要追加的HTML内容
*/
appendList(html: string): void {
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();
}
}
/**
* 更新评论数量显示
* @param count 评论数量字符串
*/
updateCount(count: string): void {
const countEl = this.container?.querySelector('.comment-section__count');
if (countEl && count) {
countEl.textContent = count;
}
}
/**
* 刷新插件
* 重新初始化代码高亮、时间标签、图片懒加载等插件
*/
refreshPlugins(): void {
if (this.container && typeof CodeArea !== 'undefined') {
new CodeArea(this.container);
}
if (this.container) {
util.updateTimeTags(this.container);
}
if (this.container && typeof LazyLoad !== 'undefined') {
new LazyLoad({
container: this.container,
});
}
}
/**
* 显示回复表单
* 根据设备类型选择桌面端内联显示或移动端抽屉显示
* @param commentItem 要回复的评论元素
* @param replyId 回复目标的评论ID
*/
showReplyForm(commentItem: HTMLElement, replyId: string): void {
let form = this.container?.querySelector<HTMLFormElement>('form.comment-form') || null;
if (!form && this.drawer) {
form = this.drawer.querySelector<HTMLFormElement>('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;
}
const isMobile = window.innerWidth <= 768;
if (isMobile) {
this.showMobileReplyDrawer(commentItem, replyId, form);
} else {
this.showDesktopReplyForm(commentItem, replyId, form);
}
}
/**
* 桌面端回复表单显示
* 将表单内联插入到目标评论下方
* @param commentItem 要回复的评论元素
* @param replyId 回复目标的评论ID
* @param form 表单元素
*/
private showDesktopReplyForm(
commentItem: HTMLElement,
replyId: string,
form: HTMLFormElement
): void {
this.resetForm(form);
const replyInput = form.querySelector<HTMLInputElement>('input[name="replyId"]');
if (replyInput) {
replyInput.value = replyId;
}
const cancelBtn = form.querySelector<HTMLElement>('.comment-form__cancel-btn');
if (cancelBtn) {
cancelBtn.style.display = 'inline-block';
}
const targetBody = commentItem.querySelector('.comment-item__body');
const actionsEl = targetBody?.querySelector('.comment-item__actions');
if (actionsEl) {
actionsEl.after(form);
} else if (targetBody) {
targetBody.appendChild(form);
}
form.classList.add('comment-form--reply');
form.scrollIntoView({ behavior: 'smooth', block: 'center' });
const textarea = form.querySelector<HTMLTextAreaElement>('textarea');
if (textarea) {
textarea.focus();
}
}
/**
* 移动端回复表单抽屉显示
* 在底部抽屉中显示回复表单和上下文信息
* @param commentItem 要回复的评论元素
* @param replyId 回复目标的评论ID
* @param form 表单元素
*/
private showMobileReplyDrawer(
commentItem: HTMLElement,
replyId: string,
form: HTMLFormElement
): void {
const avatar = commentItem.querySelector<HTMLImageElement>('.comment-avatar-img');
const author = commentItem.querySelector('.comment-item__author, .comment-item__user-info');
const content = commentItem.querySelector('.comment-item__content');
const contextData: DrawerContext = {
avatarSrc: avatar ? avatar.src : '',
authorName: author ? author.textContent?.trim() || '未知用户' : '未知用户',
contentText: content ? content.textContent?.trim().substring(0, 100) || '' : '',
};
if (!this.drawer) {
this.createMobileDrawer();
}
this.updateDrawerContext(contextData);
this.resetForm(form);
const replyInput = form.querySelector<HTMLInputElement>('input[name="replyId"]');
if (replyInput) {
replyInput.value = replyId;
}
const drawerBody = this.drawer?.querySelector('.comment-reply-drawer__body');
if (drawerBody) {
drawerBody.innerHTML = '';
drawerBody.appendChild(form);
}
this.openDrawer();
}
/**
* 创建移动端抽屉结构
*/
private createMobileDrawer(): void {
this.drawerOverlay = document.createElement('div');
this.drawerOverlay.className = 'comment-reply-drawer__overlay';
this.drawerOverlay.addEventListener('click', () => this.closeDrawer());
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);
}
/**
* 更新抽屉上下文信息
* @param data 上下文数据(头像、作者、内容)
*/
private updateDrawerContext(data: DrawerContext): void {
if (!this.drawer) {
return;
}
const avatar = this.drawer.querySelector<HTMLImageElement>(
'.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;
}
}
/**
* 打开抽屉
*/
private openDrawer(): void {
if (!this.drawer || !this.drawerOverlay) {
return;
}
document.body.style.overflow = 'hidden';
this.drawerOverlay.classList.add('comment-reply-drawer__overlay--visible');
requestAnimationFrame(() => {
this.drawer?.classList.add('comment-reply-drawer--open');
});
setTimeout(() => {
const textarea =
this.drawer?.querySelector<HTMLTextAreaElement>('.comment-form__editor');
if (textarea) {
textarea.focus();
}
}, 350);
}
/**
* 关闭抽屉
*/
closeDrawer(): void {
if (!this.drawer || !this.drawerOverlay) {
return;
}
const form = this.drawer.querySelector<HTMLFormElement>('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<HTMLElement>('.comment-form__cancel-btn');
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
}
}
this.drawer.classList.remove('comment-reply-drawer--open');
this.drawerOverlay.classList.remove('comment-reply-drawer__overlay--visible');
document.body.style.overflow = '';
}
/**
* 重置回复表单到原始位置
*/
resetReplyForm(): void {
const form =
this.container?.querySelector<HTMLFormElement>('form.comment-form') ||
this.drawer?.querySelector<HTMLFormElement>('form.comment-form') ||
null;
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<HTMLElement>('.comment-form__cancel-btn');
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
}
if (this.drawer && this.drawer.classList.contains('comment-reply-drawer--open')) {
this.drawer.classList.remove('comment-reply-drawer--open');
if (this.drawerOverlay) {
this.drawerOverlay.classList.remove('comment-reply-drawer__overlay--visible');
}
document.body.style.overflow = '';
}
}
/**
* 重置表单状态
* @param form 表单元素
*/
public resetForm(form: HTMLFormElement): void {
const profileInputs = {
nickname: form.querySelector<HTMLInputElement>('input[name="nickname"]')?.value ?? '',
email: form.querySelector<HTMLInputElement>('input[name="email"]')?.value ?? '',
site: form.querySelector<HTMLInputElement>('input[name="site"]')?.value ?? '',
};
form.reset();
const nicknameInput = form.querySelector<HTMLInputElement>('input[name="nickname"]');
if (nicknameInput) {
nicknameInput.value = profileInputs.nickname;
}
const emailInput = form.querySelector<HTMLInputElement>('input[name="email"]');
if (emailInput) {
emailInput.value = profileInputs.email;
}
const siteInput = form.querySelector<HTMLInputElement>('input[name="site"]');
if (siteInput) {
siteInput.value = profileInputs.site;
}
const replyInput = form.querySelector<HTMLInputElement>('input[name="replyId"]');
if (replyInput) {
replyInput.value = '';
}
this.setButtonState(
form.querySelector<HTMLButtonElement>('button[type="submit"]'),
'normal'
);
this.hideListLoading();
const textarea = form.querySelector<HTMLTextAreaElement>('.comment-form__editor');
if (textarea) {
this.updateCharCount(textarea);
}
}
/**
* 插入 Markdown 文本到编辑器
* @param text 要插入的Markdown文本
*/
public insertMarkdown(text: string): void {
const form = this.container?.querySelector<HTMLFormElement>('form.comment-form');
const textarea = form?.querySelector<HTMLTextAreaElement>('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);
}
}
/**
* 更新字符计数显示
* @param textarea 文本域元素
*/
public updateCharCount(textarea: HTMLTextAreaElement): void {
const form = textarea.closest<HTMLFormElement>('.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控制类,主要负责处理评论界面的DOM操作和用户交互。让我详细解释一下代码的功能:
类的主要职责
CommentUI 类是一个评论系统的UI层控制器,专门处理界面展示和用户交互逻辑。
核心功能模块
1. 初始化和配置
constructor(element?: HTMLElement | null)
- 初始化容器元素(默认查找
.comment-section) - 配置SVG图标集合(加载中、发送、错误状态图标)
- 设置相关DOM元素引用
2. 按钮状态管理
setButtonState(btn: HTMLButtonElement | null, state: ButtonState, msg?: string)
- 控制提交按钮的三种状态:正常、加载中、错误
- 动态更换图标和文字
- 管理按钮的可用/禁用状态
3. 加载状态展示
showListSkeleton(): 显示骨架屏加载动画showListLoading(): 在列表上显示加载遮罩hideListLoading(): 隐藏加载状态
4. 列表内容管理
updateList(html): 完全替换评论列表内容appendList(html): 追加新评论(用于分页加载)updateCount(count): 更新评论数量显示
5. 回复功能核心特性
响应式设计
- 桌面端: 内联显示回复表单在目标评论下方
- 移动端: 底部抽屉弹窗显示回复界面
桌面端回复 (showDesktopReplyForm)
private showDesktopReplyForm(commentItem, replyId, form)
- 将表单插入到被回复评论的下方
- 设置回复目标ID
- 平滑滚动到表单位置并自动聚焦
移动端回复 (showMobileReplyDrawer)
private showMobileReplyDrawer(commentItem, replyId, form)
- 提取被回复评论的上下文信息(头像、作者、内容预览)
- 在底部抽屉中显示完整的回复界面
- 提供更好的移动端用户体验
6. 抽屉组件管理
createMobileDrawer(): 动态创建抽屉DOM结构openDrawer()/closeDrawer(): 控制抽屉的显示/隐藏updateDrawerContext(): 更新抽屉中的上下文信息
7. 表单管理
resetForm(): 重置表单状态,保留用户个人信息resetReplyForm(): 将回复表单恢复到原始位置insertMarkdown(): 向编辑器插入Markdown文本updateCharCount(): 实时更新字符计数显示
8. 插件集成
refreshPlugins()
- 重新初始化代码高亮 (
CodeArea) - 更新时间标签显示 (
util.updateTimeTags) - 重新激活图片懒加载 (
LazyLoad)
设计亮点
1. 响应式交互设计
根据屏幕宽度自动选择最适合的回复界面展示方式:
- 桌面端:内联表单,上下文更清晰
- 移动端:抽屉弹窗,空间利用更充分
2. 状态管理完善
- 按钮状态可视化反馈
- 加载状态的多种展示形式
- 表单状态的智能重置
3. 用户体验优化
- 平滑滚动和自动聚焦
- 字符计数和限制提醒
- 上下文信息保持(回复时显示原评论信息)
4. 模块化架构
- 职责单一,专注UI控制
- 与业务逻辑解耦
- 易于测试和维护
这个类为评论系统提供了完整的UI交互控制层,特别在移动端适配和用户体验方面做了很多细致的优化。
AI 正在分析代码…
评论加载中...