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 // 允许的图片文件扩展名

私有属性

属性类型说明
_containerHTMLElement \| null书签容器元素
_masonryMasonry \| nullMasonry 瀑布流实例
_loadedPageIndexnumber当前加载的页码
_hasMoreboolean是否还有更多数据
_isLoadingboolean是否正在加载
_loadingElementHTMLElement \| null加载提示元素
_endElementHTMLElement \| null加载完成提示元素
_selectedIndexnumber搜索结果选中索引
_searchDebounceTimerReturnType<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-card
  • columnWidth: .bookmark__grid-sizer
  • percentPosition: true(响应式)
  • gutter: 20px
  • transitionDuration: 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>

流程:

  1. 检查是否正在加载或已无更多数据
  2. 发送 AJAX 请求 /Bookmark/LoadMore?pageIndex=N
  3. 解析返回的 HTML 片段
  4. 追加到瀑布流中
  5. 触发 Masonry 重新布局
  6. 更新分页状态

工具方法

文件大小格式化

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:瀑布流布局库

最佳实践

  1. 防抖搜索:300ms 延迟减少 API 请求
  2. 内存管理:及时清理 URL.createObjectURL
  3. 安全性:HTML 转义防止 XSS
  4. 性能优化:
    • 使用 requestAnimationFrame 优化滚动
    • 图片加载后触发布局
  5. 用户体验:
    • 按钮禁用防止重复提交
    • 加载状态提示
    • 键盘导航支持

使用示例

// 页面加载时自动初始化
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>
评论加载中...