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 表单提交与删除、以及带图片自适应的瀑布流卡片布局,并在多处提供用户友好的状态提示与错误处理。
评论加载中...