import { escapeHtml, formatBytes } from '../common.js'; // 文件列表组件:列表(表格) + 网格双视图、列头/下拉排序、多选、缩略图。 // 移植自 source.js 的 renderBrowse / 批量选择逻辑,改造成表格与网格。 // 事件回调:onOpenDir(path) / onOpenFile(entry) / onSelectFile(entry) / onSelectionChange(set) function isVideoEntry(entry) { return String(entry.mime ?? '').toLowerCase().startsWith('video/') || entry.hints?.preview === 'video'; } function typeLabel(entry) { if (entry.type === 'dir') { return '文件夹'; } if (entry.hints?.preview) { return entry.hints.preview; } return (entry.mime || 'file').split('/').pop(); } function thumbInner(entry) { if (entry.type === 'dir') { return '📁'; } if (entry.thumbnail?.available) { return ``; } if (isVideoEntry(entry)) { return '🎬'; } if (String(entry.mime ?? '').startsWith('image/')) { return '🖼'; } return '📄'; } function compareEntries(a, b, sortKey, sortDir) { // 文件夹永远排在文件前面(与后端 sortEntries 一致) if (a.type !== b.type) { return a.type === 'dir' ? -1 : 1; } let result = 0; switch (sortKey) { case 'size': result = (Number(a.size) || 0) - (Number(b.size) || 0); break; case 'type': result = typeLabel(a).localeCompare(typeLabel(b), 'en'); break; case 'mtime': result = String(a.mtime ?? '').localeCompare(String(b.mtime ?? '')); break; case 'name': default: result = a.name.localeCompare(b.name, 'zh-Hans-CN'); break; } return sortDir === 'desc' ? -result : result; } export function createFileList(rootElement, handlers = {}) { const { onOpenDir, onOpenFile, onSelectFile, onSelectionChange } = handlers; const state = { entries: [], view: 'list', sortKey: 'name', sortDir: 'asc', filter: '', selected: new Set(), activeFileId: null }; function visibleEntries() { const filter = state.filter.trim().toLowerCase(); const filtered = filter ? state.entries.filter((entry) => entry.name.toLowerCase().includes(filter)) : state.entries.slice(); return filtered.sort((a, b) => compareEntries(a, b, state.sortKey, state.sortDir)); } function fileEntries() { return state.entries.filter((entry) => entry.type === 'file'); } function renderRows(entries) { return entries .map((entry) => { const selected = entry.type === 'file' && state.selected.has(entry.id); const active = entry.type === 'file' && entry.id === state.activeFileId; const rowClasses = [ 'explorer-row', entry.type === 'dir' ? 'is-dir' : 'is-file', selected ? 'selected' : '', active ? 'active' : '' ] .filter(Boolean) .join(' '); const checkboxCell = entry.type === 'file' ? `` : ''; const dataAttrs = entry.type === 'dir' ? `data-type="dir" data-path="${escapeHtml(entry.path)}"` : `data-type="file" data-file-id="${escapeHtml(entry.id)}"`; return ` ${checkboxCell}
${thumbInner(entry)}${isVideoEntry(entry) ? '' : ''} ${escapeHtml(entry.name)}
${entry.type === 'dir' ? '—' : formatBytes(entry.size)} ${escapeHtml(typeLabel(entry))} ${escapeHtml(entry.mtime ?? '—')} `; }) .join(''); } function renderCards(entries) { return entries .map((entry) => { const selected = entry.type === 'file' && state.selected.has(entry.id); const active = entry.type === 'file' && entry.id === state.activeFileId; const cardClasses = [ 'explorer-card', entry.type === 'dir' ? 'is-dir' : 'is-file', selected ? 'selected' : '', active ? 'active' : '' ] .filter(Boolean) .join(' '); const dataAttrs = entry.type === 'dir' ? `data-type="dir" data-path="${escapeHtml(entry.path)}"` : `data-type="file" data-file-id="${escapeHtml(entry.id)}"`; const checkbox = entry.type === 'file' ? `` : ''; return `
${checkbox}
${thumbInner(entry)}${isVideoEntry(entry) ? '' : ''}
${escapeHtml(entry.name)}
${entry.type === 'dir' ? '文件夹' : formatBytes(entry.size)}
`; }) .join(''); } function sortArrow(key) { if (state.sortKey !== key) { return ''; } return `${state.sortDir === 'asc' ? '▲' : '▼'}`; } function render() { const entries = visibleEntries(); if (state.entries.length === 0) { rootElement.innerHTML = '
这个目录目前没有内容。
'; return; } if (entries.length === 0) { rootElement.innerHTML = '
没有匹配当前搜索的文件。
'; return; } if (state.view === 'grid') { rootElement.innerHTML = `
${renderCards(entries)}
`; return; } rootElement.innerHTML = ` ${renderRows(entries)}
名称 ${sortArrow('name')} 大小 ${sortArrow('size')} 类型 ${sortArrow('type')} 修改时间 ${sortArrow('mtime')}
`; } function emitSelectionChange() { onSelectionChange?.(new Set(state.selected), fileEntries().length); } function toggleSelection(fileId, selected) { if (selected) { state.selected.add(fileId); } else { state.selected.delete(fileId); } render(); emitSelectionChange(); } // ── 事件委托 ── rootElement.addEventListener('click', (event) => { const checkbox = event.target.closest('.explorer-row-check'); if (checkbox instanceof HTMLInputElement) { event.stopPropagation(); toggleSelection(checkbox.dataset.fileId, checkbox.checked); return; } const sortable = event.target.closest('.explorer-sortable'); if (sortable instanceof HTMLElement) { const key = sortable.dataset.sort; if (state.sortKey === key) { state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc'; } else { state.sortKey = key; state.sortDir = 'asc'; } render(); return; } const item = event.target.closest('[data-type]'); if (!(item instanceof HTMLElement)) { return; } if (item.dataset.type === 'dir') { onOpenDir?.(item.dataset.path); return; } const entry = state.entries.find((candidate) => candidate.id === item.dataset.fileId); if (entry) { state.activeFileId = entry.id; render(); onSelectFile?.(entry); } }); rootElement.addEventListener('dblclick', (event) => { const item = event.target.closest('[data-type="file"]'); if (!(item instanceof HTMLElement)) { return; } const entry = state.entries.find((candidate) => candidate.id === item.dataset.fileId); if (entry) { onOpenFile?.(entry); } }); rootElement.addEventListener('contextmenu', (event) => { const item = event.target.closest('[data-type]'); if (!(item instanceof HTMLElement) || !handlers.onContextMenu) { return; } event.preventDefault(); const entry = item.dataset.type === 'file' ? state.entries.find((candidate) => candidate.id === item.dataset.fileId) : { type: 'dir', path: item.dataset.path }; handlers.onContextMenu(entry, { x: event.clientX, y: event.clientY }); }); return { setEntries(entries) { state.entries = Array.isArray(entries) ? entries : []; state.selected.clear(); state.activeFileId = null; render(); emitSelectionChange(); }, setView(view) { state.view = view === 'grid' ? 'grid' : 'list'; render(); }, setSort(key, dir) { state.sortKey = key; state.sortDir = dir; render(); }, setFilter(value) { state.filter = value ?? ''; render(); }, setActiveFile(fileId) { state.activeFileId = fileId; render(); }, selectAll(selected) { if (selected) { for (const entry of fileEntries()) { state.selected.add(entry.id); } } else { state.selected.clear(); } render(); emitSelectionChange(); }, clearSelection() { state.selected.clear(); state.activeFileId = null; render(); emitSelectionChange(); }, getSelectedIds() { return [...state.selected]; }, getFileCount() { return fileEntries().length; }, getEntries() { return state.entries.slice(); } }; }