import {dialog} from "./dialog.js";

export class BookmarkManager {
    constructor() {
        this.container = document.querySelector('#pjax-container>.bookmark');
        if (!this.container) {
            return;
        }
        this.init();
    }

    init() {
        this.bindEvents();
        this.initSearch();
        this.initForms();
        this.initMasonry();
    }

    bindEvents() {
        // Global clicks to close search results
        if (!window._bookmarkEventsBound) {
            document.addEventListener('click', (e) => {
                if (!e.target.closest('.bookmark__search')) {
                    const results = document.querySelector('.bookmark__search-results');
                    if (results) results.style.display = 'none';
                }
            });
            window._bookmarkEventsBound = true;
        }

    }

    initMasonry() {
        const grid = this.container.querySelector('.bookmark__grid');
        if (!grid || typeof Masonry === 'undefined') return;

        // 立即初始化 Masonry——先按占位图高度定位,避免单列闪烁
        this._masonry = new Masonry(grid, {
            // 瀑布流条目选择器
            itemSelector: '.bookmark-card',
            // 列宽基准元素
            columnWidth: '.bookmark__grid-sizer',
            // 百分比定位,适配响应式
            percentPosition: true,
            // 卡片水平间距(px)
            gutter: 20,
            // 卡片移动动画时长
            transitionDuration: '0.3s',
        });

        // 定位完成后立即显示网格,触发淡入效果
        grid.classList.add('bookmark__grid--ready');

        // 监听每张预览图的 load 事件:
        // LazyLoad 把 data-src 赋给 src 后,浏览器加载真实图片并触发 load
        // 此时卡片已展开至真实高度,调用 layout() 修正位置
        grid.querySelectorAll('.bookmark-card__preview').forEach(img => {
            img.addEventListener('load', () => {
                // 确保是真实图片(naturalWidth > 0)而非占位图才重算
                if (img.naturalWidth > 0) {
                    this._masonry.layout();
                }
            });
        });
    }

    initSearch() {
        const searchInput = document.querySelector('.bookmark__search-input');
        const searchResults = document.querySelector('.bookmark__search-results');
        const searchList = document.querySelector('.bookmark__search-list');
        const searchForm = document.querySelector('.bookmark__search-wrapper');

        if (!searchInput) return;

        // Handle Form Submit for GET
        if (searchForm) {
            searchForm.addEventListener('submit', (e) => {
                e.preventDefault();
                // Append categories
                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.jQuery && window.jQuery.pjax) {
                    window.jQuery.pjax({url: url.toString(), container: '#pjax-container'});
                } else {
                    window.location.href = url.toString();
                }
            });
        }

        let debounceTimer;
        let selectedIndex = -1;

        searchInput.addEventListener('input', (e) => {
            const value = e.target.value.trim();
            clearTimeout(debounceTimer);
            selectedIndex = -1;

            if (!value) {
                if (searchResults) searchResults.style.display = 'none';
                return;
            }

            debounceTimer = setTimeout(async () => {
                try {
                    const activeCategories = Array.from(document.querySelectorAll('.bookmark__category--active'))
                        .map(el => el.textContent.trim());

                    const query = new URLSearchParams({title: value});
                    activeCategories.forEach(c => query.append('categories', c));

                    const response = await fetch(`/bookmark/search?${query.toString()}`);
                    const result = await response.json();

                    if (result.success && result.data.length > 0 && searchList && searchResults) {
                        searchList.innerHTML = result.data
                            .map((item, index) => `<li class="bookmark__search-item" data-index="${index}">${this.escapeHtml(item)}</li>`)
                            .join('');
                        searchResults.style.display = 'block';
                    } else if (searchResults) {
                        searchResults.style.display = 'none';
                    }
                } catch (error) {
                    console.error('Search failed:', error);
                }
            }, 300);
        });

        // Keydown navigation
        searchInput.addEventListener('keydown', (e) => {
            const items = searchList ? searchList.querySelectorAll('.bookmark__search-item') : [];

            if (e.key === 'ArrowDown') {
                e.preventDefault();
                if (items.length > 0) {
                    selectedIndex = (selectedIndex + 1) % items.length;
                    this.updateSelection(items, selectedIndex, searchInput);
                }
            } else if (e.key === 'ArrowUp') {
                e.preventDefault();
                if (items.length > 0) {
                    selectedIndex = (selectedIndex - 1 + items.length) % items.length;
                    this.updateSelection(items, selectedIndex, searchInput);
                }
            } else if (e.key === 'Enter') {
                if (selectedIndex >= 0 && items[selectedIndex]) {
                    e.preventDefault();
                    searchInput.value = items[selectedIndex].textContent;
                    // Trigger search
                    this.performSearch(items[selectedIndex].textContent);
                    if (searchResults) searchResults.style.display = 'none';
                }
                // If no selection, let the form submit normally (handled by submit listener)
            }
        });

        if (searchList) {
            searchList.addEventListener('mousedown', (e) => {
                const item = e.target.closest('.bookmark__search-item');
                if (item) {
                    searchInput.value = item.textContent;
                    this.performSearch(item.textContent);
                }
            });
        }
    }

    updateSelection(items, index, input) {
        items.forEach(item => item.classList.remove('bookmark__search-item--active'));
        if (items[index]) {
            items[index].classList.add('bookmark__search-item--active');
            input.value = items[index].textContent;
            items[index].scrollIntoView({block: 'nearest'});
        }
    }

    performSearch(title) {
        const activeCategories = Array.from(document.querySelectorAll('.bookmark__category--active'))
            .map(el => el.textContent.trim());

        const searchForm = document.querySelector('.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.jQuery && window.jQuery.pjax) {
            window.jQuery.pjax({url: url.toString(), container: '#pjax-container'});
        } else {
            window.location.href = url.toString();
        }
    }

    initForms() {
        const form = document.querySelector('.bookmark-form');
        if (!form) return;
        if (form.dataset.bound) return;
        form.dataset.bound = "true";

        form.addEventListener('submit', async (e) => {
            e.preventDefault();
            const submitBtn = form.querySelector('.bookmark-form__submit');
            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) {
                    jQuery.pjax({url: '/bookmark.html', container: '#pjax-container'});
                } else {
                    await dialog.alert(result.msg || '提交失败');
                    submitBtn.disabled = false;
                    submitBtn.textContent = originalText;
                }
            } catch (error) {
                console.error(error);
                await dialog.alert('发生错误,请重试');
                submitBtn.disabled = false;
                submitBtn.textContent = originalText;
            }
        });

        const deleteBtn = document.querySelector('.bookmark-form__delete');
        if (deleteBtn) {
            deleteBtn.addEventListener('click', async () => {
                if (!await dialog.confirm('确定要删除这个书签吗?')) {
                    return;
                }
                const id = deleteBtn.dataset.id;
                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)) {
                        jQuery.pjax({url: '/bookmark.html', container: '#pjax-container'});
                    } else {
                        await dialog.alert(result.msg || '删除失败');
                    }
                } catch (error) {
                    console.error(error);
                    await dialog.alert('删除出错');
                }
            });
        }
    }

    escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

总体说明

  • 这是一个管理书签页面交互的前端类 BookmarkManager。它在页面上查找 #pjax-container>.bookmark 容器,存在则初始化搜索、表单处理、瀑布流布局等功能。
  • 依赖:dialog 模块(已通过 import 引入)、可选依赖 Masonry(瀑布流布局)、jQuery.pjax(用于局部刷新)、以及后端接口 /bookmark/search、表单 action、/bookmark/delete 等。

构造函数与初始化

  • constructor:查询并保存容器元素(#pjax-container>.bookmark),若不存在则直接返回(不初始化)。存在时调用 init()。
  • init:依次调用 bindEvents、initSearch、initForms、initMasonry 来绑定事件并初始化各功能模块。

全局事件(bindEvents)

  • 给 document 绑定一次点击事件(使用 window._bookmarkEventsBound 防止重复绑定)。
  • 点击页面任意位置时,如果点击目标不在 .bookmark__search 内,则将 .bookmark__search-results 隐藏(用于点击空白处关闭搜索结果)。

瀑布流布局(initMasonry)

  • 查找容器内的 .bookmark__grid,若存在且 Masonry 已加载则用 Masonry 初始化布局:
    • itemSelector、columnWidth、percentPosition、gutter、transitionDuration 等选项。
  • 初始化完毕后给 grid 添加 .bookmark__grid--ready 类以触发样式(如淡入)。
  • 为每个预览图 .bookmark-card__preview 绑定 load 事件:图片实际加载(naturalWidth > 0)后调用 this._masonry.layout() 重新计算布局,避免图片加载导致高度变化时错位。
  • 注释里提到结合 LazyLoad:lazy 把 data-src 赋给 src 后会触发 load。

搜索功能(initSearch、updateSelection、performSearch)

  • 找到搜索相关元素:输入框 .bookmark__search-input、结果容器 .bookmark__search-results、结果列表 .bookmark__search-list、包裹的表单 .bookmark__search-wrapper。
  • 表单提交(若存在)被拦截,拼接当前激活的分类参数,将 title 与 categories 加入 URL:
    • 若存在 jQuery.pjax 则用 pjax 局部刷新,否则跳转到生成的 URL。
  • 输入监听(带 300ms 防抖):
    • 按输入值向 /bookmark/search 发起 fetch 请求(GET),带 title 和 categories 参数。
    • 若返回成功并且有数据,则把结果渲染为 li 列表(使用 escapeHtml 转义文本)并显示结果。
    • 否则隐藏结果列表。
  • 键盘导航:
    • ArrowDown / ArrowUp:在结果项间循环选中,调用 updateSelection 更新高亮并把选中项内容写入输入框。
    • Enter:如果有选中项,阻止默认并使用选中项触发搜索(performSearch),并隐藏结果;如果无选中项则让表单的 submit 处理流程走(上面的 submit 拦截会处理)。
  • 鼠标选择:在结果列表上监听 mousedown,点击某项填充输入框并触发 performSearch。
  • performSearch:根据当前激活分类和给定 title 构造目标 URL(基于搜索表单 action 或默认为 /bookmark.html),然后用 pjax 或直接跳转打开该 URL。

表单提交与删除(initForms)

  • 查找 .bookmark-form,若不存在或已绑定(通过 data-bound 避免重复绑定)则返回。
  • 表单提交被拦截为 AJAX 提交(fetch POST,body 为 FormData):
    • 提交时禁用提交按钮并修改按钮文字为“提交中...”。
    • 请求返回 JSON 后:若成功,使用 jQuery.pjax 刷新到 /bookmark.html;否则用 dialog.alert 显示错误并恢复按钮状态。
    • 捕获网络或解析错误同样弹窗提示并恢复按钮。
  • 删除操作:如果页面有 .bookmark-form__delete 按钮,绑定点击事件:
    • 弹出确认框(dialog.confirm),若确认则用 POST 向 /bookmark/delete 发送包含 id 的 FormData。
    • 根据返回结果决定是否用 pjax 刷新或弹窗提示失败/错误。

工具函数

  • escapeHtml(text):通过创建元素并设置 textContent 来安全转义文本,返回 innerHTML,用于防止把未转义的用户输入直接插入 DOM(避免 XSS)。

实现细节与注意点

  • 使用了两种防重复绑定机制:全局 click 事件用 window._bookmarkEventsBound,表单绑定使用元素上的 dataset.bound。
  • 搜索结果渲染使用 innerHTML,但对每项调用了 escapeHtml 做了转义,减少 XSS 风险。
  • 图片加载时用 naturalWidth > 0 检查是否是真实图片,避免占位图误触发布局重算。
  • 依赖 jQuery.pjax 以实现无刷新局部刷新体验,但也兼容普通页面跳转。
  • 使用 async/await + try/catch 处理异步请求并在错误时记录和提示。

总体效果

  • 为书签页面提供搜索建议(含键盘/鼠标交互)、AJAX 表单提交与删除、以及带图片自适应的瀑布流卡片布局,并在多处提供用户友好的状态提示与错误处理。
评论加载中...