import { uiFeedbackService, ToastType } from './UiFeedbackService.ts';
import { logger } from './ConsoleLogger.ts';
import Masonry from 'masonry-layout';
/**
* 书签管理器
* 负责书签的搜索、分类、瀑布流布局和无限滚动加载
*/
export class BookmarkManager {
private static readonly ALLOWED_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'image/x-icon',
'image/vnd.microsoft.icon',
]);
private static readonly ALLOWED_IMAGE_EXTENSIONS = new Set([
'jpg',
'jpeg',
'png',
'webp',
'gif',
'ico',
]);
private readonly _container: HTMLElement | null;
private _masonry: Masonry | null = null;
private _loadedPageIndex: number = 1;
private _hasMore: boolean = true;
private _isLoading: boolean = false;
private readonly _loadingElement: HTMLElement | null = null;
private readonly _endElement: HTMLElement | null = null;
private _selectedIndex: number = -1;
private _searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
public constructor() {
this._container = document.querySelector<HTMLElement>('#pjax-container>.bookmark');
if (!this._container) {
return;
}
this._loadingElement = document.querySelector<HTMLElement>('.bookmark__loading');
this._endElement = document.querySelector<HTMLElement>('.bookmark__end');
if (this._loadingElement) {
this._loadingElement.style.display = 'none';
}
this.init();
}
/**
* 初始化书签管理器的各个功能模块
*/
private init(): void {
this.bindEvents();
this.initSearch();
this.initForms();
this.initMasonry();
}
/**
* 绑定全局事件
* 点击搜索框外部时关闭搜索结果
*/
private bindEvents(): void {
if (!(window as any)._bookmarkEventsBound) {
document.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement;
if (!target.closest('.bookmark__search')) {
const results = document.querySelector<HTMLElement>(
'.bookmark__search-results'
);
if (results) {
results.style.display = 'none';
}
}
});
(window as any)._bookmarkEventsBound = true;
}
}
/**
* 初始化搜索功能
* 包含防抖搜索、键盘导航、自动完成等功能
*/
private initSearch(): void {
const searchInput = document.querySelector<HTMLInputElement>('.bookmark__search-input');
const searchResults = document.querySelector<HTMLElement>('.bookmark__search-results');
const searchList = document.querySelector<HTMLElement>('.bookmark__search-list');
const searchForm = document.querySelector<HTMLFormElement>('.bookmark__search-wrapper');
if (!searchInput) {
return;
}
if (searchForm) {
this.initSearchForm(searchForm, searchInput);
}
searchInput.addEventListener('input', (e: Event) => {
const target = e.target as HTMLInputElement;
const value = target.value.trim();
if (this._searchDebounceTimer) {
clearTimeout(this._searchDebounceTimer);
}
this._selectedIndex = -1;
if (!value) {
if (searchResults) {
searchResults.style.display = 'none';
}
return;
}
this._searchDebounceTimer = setTimeout(async () => {
await this.performAutoComplete(value, searchList, searchResults);
}, 300);
});
searchInput.addEventListener('keydown', (e: KeyboardEvent) => {
this.handleSearchKeydown(e, searchInput, searchList, searchResults);
});
if (searchList) {
searchList.addEventListener('mousedown', (e: Event) => {
const element = e.target as HTMLElement;
const item = element?.closest('.bookmark__search-item');
if (item) {
searchInput.value = element.dataset.text || '';
this.performSearch(element.dataset.text || '');
}
});
}
}
/**
* 初始化搜索表单提交
*/
private initSearchForm(searchForm: HTMLFormElement, searchInput: HTMLInputElement): void {
searchForm.addEventListener('submit', (e: Event) => {
e.preventDefault();
const activeCategories = Array.from(
document.querySelectorAll('.bookmark__category--active')
).map((el) => el.textContent?.trim() || '');
const url = new URL(searchForm.action, window.location.origin);
url.searchParams.set('title', searchInput.value);
activeCategories.forEach((c) => url.searchParams.append('categories', c));
if ((window as any).jQuery && (window as any).jQuery.pjax) {
(window as any).jQuery.pjax({
url: url.toString(),
container: '#pjax-container',
});
} else {
window.location.href = url.toString();
}
});
}
/**
* 处理搜索框的键盘导航
*/
private handleSearchKeydown(
e: KeyboardEvent,
searchInput: HTMLInputElement,
searchList: HTMLElement | null,
searchResults: HTMLElement | null
): void {
const items = searchList
? searchList.querySelectorAll<HTMLElement>('.bookmark__search-item')
: [];
if (e.key === 'ArrowDown') {
e.preventDefault();
if (items.length > 0) {
this._selectedIndex = (this._selectedIndex + 1) % items.length;
this.updateSelection(items, this._selectedIndex, searchInput);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (items.length > 0) {
this._selectedIndex = (this._selectedIndex - 1 + items.length) % items.length;
this.updateSelection(items, this._selectedIndex, searchInput);
}
} else if (e.key === 'Enter') {
if (this._selectedIndex >= 0 && items[this._selectedIndex]) {
e.preventDefault();
searchInput.value = items[this._selectedIndex].dataset.text || '';
this.performSearch(items[this._selectedIndex].dataset.text || '');
if (searchResults) {
searchResults.style.display = 'none';
}
}
}
}
/**
* 执行自动完成搜索
*/
private async performAutoComplete(
query: string,
searchList: HTMLElement | null,
searchResults: HTMLElement | null
): Promise<void> {
try {
const activeCategories = Array.from(
document.querySelectorAll('.bookmark__category--active')
).map((el) => el.textContent?.trim() || '');
const queryParams = new URLSearchParams({ title: query });
activeCategories.forEach((c) => queryParams.append('categories', c));
const response = await fetch(`/bookmark/search?${queryParams.toString()}`);
const result = await response.json();
if (result.success && result.data.length > 0 && searchList && searchResults) {
searchList.innerHTML = result.data
.map(
(item: string, index: number) =>
`<li class="bookmark__search-item" data-index="${index}" data-text="${this.escapeHtml(item)}">
${this.escapeHtml(item)}
</li>`
)
.join('');
searchResults.style.display = 'block';
} else if (searchResults) {
searchResults.style.display = 'none';
}
} catch (error) {
logger.outPutError('搜索书签失败', error);
}
}
/**
* 更新搜索结果的选中状态
*/
private updateSelection(
items: NodeListOf<HTMLElement> | HTMLElement[],
index: number,
input: HTMLInputElement
): void {
for (const item of items) {
item.classList.remove('bookmark__search-item--active');
}
if (items[index]) {
items[index].classList.add('bookmark__search-item--active');
input.value = items[index].dataset.text || '';
items[index].scrollIntoView({ block: 'nearest' });
}
}
/**
* 执行搜索并跳转
*/
private performSearch(title: string): void {
const activeCategories = Array.from(
document.querySelectorAll('.bookmark__category--active')
).map((el) => el.textContent?.trim() || '');
const searchForm = document.querySelector<HTMLFormElement>('.bookmark__search-wrapper');
const action = searchForm ? searchForm.action : '/bookmark.html';
const url = new URL(action, window.location.origin);
if (title) {
url.searchParams.set('title', title);
}
activeCategories.forEach((c) => url.searchParams.append('categories', c));
if ((window as any).jQuery && (window as any).jQuery.pjax) {
(window as any).jQuery.pjax({
url: url.toString(),
container: '#pjax-container',
});
} else {
window.location.href = url.toString();
}
}
/**
* HTML 转义,防止 XSS 攻击
*/
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 初始化表单功能
* 包括书签的提交、删除和标签筛选
*/
private initForms(): void {
this.initBookmarkForm();
this.initBookmarkUploaders();
this.initFilterForm();
this.initTagLinks();
}
/**
* 初始化书签表单的提交和删除功能
*/
private initBookmarkForm(): void {
const form = document.querySelector<HTMLFormElement>('.bookmark-form');
if (!form) {
return;
}
if ((form as any).dataset.bound) {
return;
}
(form as any).dataset.bound = 'true';
form.addEventListener('submit', async (e: Event) => {
e.preventDefault();
await this.handleFormSubmit(form);
});
const deleteBtn = document.querySelector<HTMLButtonElement>('.bookmark-form__delete');
if (deleteBtn) {
deleteBtn.addEventListener('click', async () => {
await this.handleBookmarkDelete(deleteBtn);
});
}
}
/**
* 处理书签表单提交
*/
private async handleFormSubmit(form: HTMLFormElement): Promise<void> {
const submitBtn = form.querySelector<HTMLButtonElement>('.bookmark-form__submit');
if (!submitBtn) {
return;
}
const originalText = submitBtn.textContent || '';
if (submitBtn.disabled) {
return;
}
try {
submitBtn.disabled = true;
submitBtn.textContent = '提交中...';
const formData = new FormData(form);
const response = await fetch(form.action, {
method: 'POST',
body: formData,
});
const result = await response.json().catch(() => ({
success: false,
msg: 'Unknown error',
}));
if (result.success) {
if ((window as any).jQuery && (window as any).jQuery.pjax) {
(window as any).jQuery.pjax({
url: '/bookmark.html',
container: '#pjax-container',
});
} else {
window.location.href = '/bookmark.html';
}
} else {
await uiFeedbackService.alert(result.msg || '提交失败');
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
} catch (error) {
logger.outPutError('提交书签失败', error);
await uiFeedbackService.alert('发生错误,请重试');
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
}
private initBookmarkUploaders(): void {
const uploaders = document.querySelectorAll<HTMLElement>('[data-bookmark-uploader]');
uploaders.forEach((uploader) => {
if (uploader.dataset.bound === 'true') {
return;
}
uploader.dataset.bound = 'true';
this.initBookmarkUploader(uploader);
});
}
private initBookmarkUploader(uploader: HTMLElement): void {
const dropzone = uploader.querySelector<HTMLElement>('.bookmark-uploader__dropzone');
const fileInput = uploader.querySelector<HTMLInputElement>('.bookmark-uploader__file');
const selectButton = uploader.querySelector<HTMLButtonElement>(
'.bookmark-uploader__select'
);
const clearButton = uploader.querySelector<HTMLButtonElement>('.bookmark-uploader__clear');
const preview = uploader.querySelector<HTMLElement>('.bookmark-uploader__preview');
const meta = uploader.querySelector<HTMLElement>('.bookmark-uploader__meta');
const maxSize = Number.parseInt(uploader.dataset.maxSize || '0', 10);
if (!dropzone || !fileInput || !preview || !meta) {
return;
}
const openPicker = (): void => {
fileInput.click();
};
const handleFile = (file: File | null): void => {
if (file === null) {
return;
}
const validationError = this.getBookmarkFileValidationError(file, maxSize);
if (validationError !== null) {
fileInput.value = '';
clearButton?.setAttribute('hidden', '');
this.renderUploaderPlaceholder(preview);
meta.textContent = validationError;
meta.classList.add('bookmark-uploader__meta--error');
return;
}
this.renderUploaderPreview(preview, file);
meta.textContent = `${file.name} · ${this.formatFileSize(file.size)},上传文件将优先于 URL`;
meta.classList.remove('bookmark-uploader__meta--error');
clearButton?.removeAttribute('hidden');
};
selectButton?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
openPicker();
});
dropzone.addEventListener('click', openPicker);
dropzone.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openPicker();
}
});
fileInput.addEventListener('change', () => {
handleFile(fileInput.files?.item(0) ?? null);
});
clearButton?.addEventListener('click', () => {
fileInput.value = '';
this.renderUploaderPlaceholder(preview);
meta.textContent = '';
meta.classList.remove('bookmark-uploader__meta--error');
clearButton.setAttribute('hidden', '');
});
dropzone.addEventListener('dragenter', (event: DragEvent) => {
event.preventDefault();
dropzone.classList.add('bookmark-uploader__dropzone--dragover');
});
dropzone.addEventListener('dragover', (event: DragEvent) => {
event.preventDefault();
dropzone.classList.add('bookmark-uploader__dropzone--dragover');
});
dropzone.addEventListener('dragleave', (event: DragEvent) => {
if (!dropzone.contains(event.relatedTarget as Node | null)) {
dropzone.classList.remove('bookmark-uploader__dropzone--dragover');
}
});
dropzone.addEventListener('drop', (event: DragEvent) => {
event.preventDefault();
dropzone.classList.remove('bookmark-uploader__dropzone--dragover');
const file = event.dataTransfer?.files.item(0) ?? null;
if (file === null) {
return;
}
const transfer = new DataTransfer();
transfer.items.add(file);
fileInput.files = transfer.files;
handleFile(file);
});
}
private getBookmarkFileValidationError(file: File, maxSize: number): string | null {
if (maxSize > 0 && file.size > maxSize) {
return `文件大小不能超过 ${this.formatFileSize(maxSize)}`;
}
const extension = file.name.split('.').pop()?.toLowerCase() ?? '';
if (!BookmarkManager.ALLOWED_IMAGE_EXTENSIONS.has(extension)) {
return '仅支持 JPG、PNG、WebP、GIF、ICO 格式';
}
if (file.type !== '' && !BookmarkManager.ALLOWED_IMAGE_TYPES.has(file.type)) {
return '文件格式与内容类型不匹配';
}
return null;
}
private renderUploaderPreview(preview: HTMLElement, file: File): void {
this.revokeUploaderPreviewUrl(preview);
const url = URL.createObjectURL(file);
preview.dataset.objectUrl = url;
preview.replaceChildren();
const image = document.createElement('img');
image.src = url;
image.alt = '';
preview.appendChild(image);
}
private renderUploaderPlaceholder(preview: HTMLElement): void {
this.revokeUploaderPreviewUrl(preview);
preview.replaceChildren();
const icon = document.createElement('i');
icon.className = preview.classList.contains('bookmark-uploader__preview--icon')
? 'fa fa-image'
: 'fa fa-picture-o';
preview.appendChild(icon);
}
private revokeUploaderPreviewUrl(preview: HTMLElement): void {
const objectUrl = preview.dataset.objectUrl;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
delete preview.dataset.objectUrl;
}
}
private formatFileSize(size: number): string {
if (size >= 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
return `${Math.max(1, Math.round(size / 1024))} KB`;
}
/**
* 处理书签删除
*/
private async handleBookmarkDelete(deleteBtn: HTMLButtonElement): Promise<void> {
const confirmed = await uiFeedbackService.confirm('确定要删除这个书签吗?');
if (!confirmed) {
return;
}
const id = deleteBtn.dataset.id;
if (!id) {
return;
}
try {
const formData = new FormData();
formData.append('id', id);
const response = await fetch('/bookmark/delete', {
method: 'POST',
body: formData,
});
const result = await response.json().catch(() => ({
success: response.ok,
}));
if (result.success || (result.success === undefined && response.ok)) {
if ((window as any).jQuery && (window as any).jQuery.pjax) {
(window as any).jQuery.pjax({
url: '/bookmark.html',
container: '#pjax-container',
});
} else {
window.location.href = '/bookmark.html';
}
} else {
await uiFeedbackService.alert(result.msg || '删除失败');
}
} catch (error) {
logger.outPutError('删除书签失败', error);
await uiFeedbackService.alert('删除出错');
}
}
/**
* 初始化筛选表单
* 下拉框变化时自动提交表单
*/
private initFilterForm(): void {
const filterForm = document.querySelector<HTMLFormElement>('#bookmark-filter-form');
if (!filterForm) {
return;
}
const selectBoxes = filterForm.querySelectorAll<HTMLSelectElement>('select');
selectBoxes.forEach((select) => {
select.addEventListener('change', () => {
filterForm.submit();
});
});
}
/**
* 初始化标签链接的点击事件
* 点击标签时自动筛选
*/
private initTagLinks(): void {
const filterForm = document.querySelector<HTMLFormElement>('#bookmark-filter-form');
const tagLinks = document.querySelectorAll<HTMLElement>('.bookmark-tag');
tagLinks.forEach((link) => {
link.addEventListener('click', (e: Event) => {
e.preventDefault();
const target = e.currentTarget as HTMLElement;
const tag = target.dataset.tag;
if (!tag) {
return;
}
if (filterForm) {
const tagSelect = filterForm.querySelector<HTMLSelectElement>('[name="tag"]');
if (tagSelect) {
const tagOption = Array.from(tagSelect.options).find(
(opt) => opt.value === tag
);
if (tagOption) {
tagOption.selected = true;
filterForm.submit();
}
}
} else {
window.location.href = `/Bookmark?tag=${encodeURIComponent(tag)}`;
}
});
});
}
/**
* 初始化瀑布流布局
* 使用 Masonry 库进行响应式瀑布流布局,并启用无限滚动加载
*/
private initMasonry(): void {
const grid = this._container?.querySelector<HTMLElement>('.bookmark__grid');
if (!grid || typeof Masonry === 'undefined') {
return;
}
const masonryOptions = {
itemSelector: '.bookmark-card',
columnWidth: '.bookmark__grid-sizer',
percentPosition: true,
gutter: 20,
transitionDuration: '0.3s',
};
this._masonry = new Masonry(grid, masonryOptions);
grid.classList.add('bookmark__grid--ready');
const images = grid.querySelectorAll<HTMLImageElement>('.bookmark-card__preview');
images.forEach((img) => {
img.addEventListener('load', () => {
if (img.naturalWidth > 0 && this._masonry) {
this._masonry.layout();
}
});
});
this._hasMore = grid.dataset.hasMore === 'true';
this._loadedPageIndex = parseInt(grid.dataset.pageIndex || '1', 10);
this.setupScrollListener();
}
/**
* 设置滚动监听器
* 实现无限滚动加载功能
*/
private setupScrollListener(): void {
(window as any)._bookmarkActiveManager = this;
if ((window as any)._bookmarkScrollListenerBound) {
return;
}
let ticking = false;
const checkScroll = async () => {
const manager = (window as any)._bookmarkActiveManager as BookmarkManager | undefined;
if (!manager) {
ticking = false;
return;
}
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const threshold = 500;
const distanceToBottom = documentHeight - (scrollTop + windowHeight);
if (distanceToBottom < threshold && manager._hasMore && !manager._isLoading) {
await manager.loadMoreBookmarks();
}
ticking = false;
};
const scrollHandler = () => {
if (!ticking) {
window.requestAnimationFrame(checkScroll);
ticking = true;
}
};
window.addEventListener('scroll', scrollHandler, { passive: true });
(window as any)._bookmarkScrollListenerBound = true;
}
/**
* 加载更多书签
* 通过 AJAX 请求下一页数据并追加到瀑布流中
*/
private async loadMoreBookmarks(): Promise<void> {
if (this._isLoading || !this._hasMore) {
return;
}
this._isLoading = true;
if (this._loadingElement) {
this._loadingElement.style.display = 'flex';
}
try {
const nextPage = this._loadedPageIndex + 1;
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('pageIndex', nextPage.toString());
const url = `/Bookmark/LoadMore?${urlParams.toString()}`;
const response = await fetch(url);
if (!response.ok) {
logger.outPutError('Failed to load more bookmarks');
}
const html = await response.text();
if (html.trim() === '') {
this._hasMore = false;
this.showEndMessage();
return;
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
const newCards = tempDiv.querySelectorAll<HTMLElement>('.bookmark-card');
if (newCards.length === 0) {
this._hasMore = false;
this.showEndMessage();
return;
}
const grid = document.querySelector<HTMLElement>('.bookmark__grid');
if (!grid) {
return;
}
const cardElements: HTMLElement[] = [];
newCards.forEach((card) => {
grid.appendChild(card);
cardElements.push(card);
});
if (this._masonry) {
this._masonry.appended(cardElements);
const images =
tempDiv.querySelectorAll<HTMLImageElement>('.bookmark-card__preview');
if (images.length > 0) {
images.forEach((img) => {
img.addEventListener('load', () => {
if (img.naturalWidth > 0 && this._masonry) {
this._masonry.layout();
}
});
});
}
this._masonry.layout();
}
this._loadedPageIndex = nextPage;
const pageSize = 12;
if (newCards.length < pageSize) {
this._hasMore = false;
this.showEndMessage();
}
} catch (error) {
logger.outPutError('加载书签失败', error);
uiFeedbackService.toast('加载失败,请刷新页面重试', ToastType.Error);
} finally {
this._isLoading = false;
if (this._loadingElement && this._hasMore) {
this._loadingElement.style.display = 'none';
}
}
}
/**
* 显示加载完成提示
*/
private showEndMessage(): void {
if (this._loadingElement) {
this._loadingElement.style.display = 'none';
}
if (this._endElement) {
this._endElement.style.display = 'flex';
}
}
}
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
代码说明文档
概述
这是一个**书签管理器(BookmarkManager)**的 TypeScript 实现,主要用于 Web 应用中管理和展示书签。该类提供了以下核心功能:
- 📌 书签的搜索与自动完成
- 🏷️ 分类筛选
- 🖼️ 瀑布流布局(使用 Masonry)
- ♾️ 无限滚动加载
- 📤 图片上传与预览
- ✏️ 书签的添加、编辑、删除
类结构
静态常量
ALLOWED_IMAGE_TYPES // 允许的图片 MIME 类型
ALLOWED_IMAGE_EXTENSIONS // 允许的图片文件扩展名
私有属性
| 属性 | 类型 | 说明 |
|---|---|---|
_container | HTMLElement \| null | 书签容器元素 |
_masonry | Masonry \| null | Masonry 瀑布流实例 |
_loadedPageIndex | number | 当前加载的页码 |
_hasMore | boolean | 是否还有更多数据 |
_isLoading | boolean | 是否正在加载 |
_loadingElement | HTMLElement \| null | 加载提示元素 |
_endElement | HTMLElement \| null | 加载完成提示元素 |
_selectedIndex | number | 搜索结果选中索引 |
_searchDebounceTimer | ReturnType<typeof setTimeout> \| null | 搜索防抖定时器 |
核心功能模块
1. 初始化(init)
private init(): void
初始化所有功能模块:
- 绑定全局事件
- 初始化搜索功能
- 初始化表单功能
- 初始化瀑布流布局
2. 搜索功能
2.1 自动完成搜索
private async performAutoComplete(query, searchList, searchResults): Promise<void>
- 使用 300ms 防抖延迟
- 发送 AJAX 请求到
/bookmark/search - 包含当前选中的分类筛选
- 动态显示搜索建议列表
2.2 键盘导航
private handleSearchKeydown(e, searchInput, searchList, searchResults): void
支持的快捷键:
ArrowDown/ArrowUp:上下选择Enter:确认选择并搜索
2.3 XSS 防护
private escapeHtml(text: string): string
对用户输入进行 HTML 转义,防止 XSS 攻击。
3. 表单管理
3.1 书签表单提交
private async handleFormSubmit(form: HTMLFormElement): Promise<void>
- 防止重复提交(按钮禁用)
- 使用 FormData 提交
- 成功后通过 PJAX 或直接跳转刷新页面
3.2 书签删除
private async handleBookmarkDelete(deleteBtn): Promise<void>
- 弹出确认对话框
- 发送 DELETE 请求到
/bookmark/delete - 删除成功后刷新页面
4. 图片上传器
4.1 文件验证
private getBookmarkFileValidationError(file, maxSize): string | null
检查项:
- ✅ 文件大小限制
- ✅ 文件扩展名(jpg, png, webp, gif, ico)
- ✅ MIME 类型匹配
4.2 拖放上传
支持:
- 点击选择文件
- 拖放文件到上传区
- 实时预览
- 清除已选文件
4.3 预览管理
private renderUploaderPreview(preview, file): void
- 使用
URL.createObjectURL创建预览 - 自动清理旧的对象 URL(防止内存泄漏)
- 显示文件名和大小
5. 瀑布流布局
5.1 Masonry 初始化
private initMasonry(): void
配置项:
itemSelector:.bookmark-cardcolumnWidth:.bookmark__grid-sizerpercentPosition: true(响应式)gutter: 20pxtransitionDuration: 0.3s
5.2 图片加载处理
监听所有图片的 load 事件,加载完成后重新计算布局:
img.addEventListener('load', () => {
if (img.naturalWidth > 0 && this._masonry) {
this._masonry.layout();
}
});
6. 无限滚动
6.1 滚动监听
private setupScrollListener(): void
- 使用
requestAnimationFrame优化性能 - 距离底部 500px 时触发加载
- 全局单例监听器(防止重复绑定)
6.2 加载更多数据
private async loadMoreBookmarks(): Promise<void>
流程:
- 检查是否正在加载或已无更多数据
- 发送 AJAX 请求
/Bookmark/LoadMore?pageIndex=N - 解析返回的 HTML 片段
- 追加到瀑布流中
- 触发 Masonry 重新布局
- 更新分页状态
工具方法
文件大小格式化
private formatFileSize(size: number): string
- 大于 1MB 显示为
X.X MB - 小于 1MB 显示为
X KB(最小 1KB)
HTML 转义
private escapeHtml(text: string): string
使用 div.textContent 进行安全转义。
集成点
1. PJAX 支持
代码中多处检测 jQuery PJAX:
if ((window as any).jQuery && (window as any).jQuery.pjax) {
(window as any).jQuery.pjax({...});
} else {
window.location.href = url;
}
2. 全局单例模式
使用全局变量防止事件重复绑定:
(window as any)._bookmarkEventsBound
(window as any)._bookmarkScrollListenerBound
(window as any)._bookmarkActiveManager
依赖服务
uiFeedbackService:用户反馈(Toast、Alert、Confirm)logger:错误日志记录Masonry:瀑布流布局库
最佳实践
- 防抖搜索:300ms 延迟减少 API 请求
- 内存管理:及时清理
URL.createObjectURL - 安全性:HTML 转义防止 XSS
- 性能优化:
- 使用
requestAnimationFrame优化滚动 - 图片加载后触发布局
- 使用
- 用户体验:
- 按钮禁用防止重复提交
- 加载状态提示
- 键盘导航支持
使用示例
// 页面加载时自动初始化
const bookmarkManager = new BookmarkManager();
容器 HTML 结构应包含:
<div id="pjax-container">
<div class="bookmark">
<div class="bookmark__grid" data-has-more="true" data-page-index="1">
<!-- 书签卡片 -->
</div>
<div class="bookmark__loading">加载中...</div>
<div class="bookmark__end">已加载全部</div>
</div>
</div>
AI 正在分析代码…
评论加载中...