InfoLens / client /src /ts /ui /demoMultiSelect.ts
dqy08's picture
initial beta release
494c9e4
import * as d3 from 'd3';
import { TextAnalysisAPI } from '../api/GLTR_API';
import { showMoveDialog } from './folderOperations';
import { showAlertDialog } from './dialog';
// 国际化
import { tr } from '../lang/i18n-lite';
export type DemoItem = {
type: 'folder' | 'file';
name: string;
path: string;
};
export type MultiSelectOptions = {
container: d3.Selection<d3.BaseType, any, any, any>;
api: TextAnalysisAPI;
getItemFullPath: (item: DemoItem) => string | null;
getCurrentPath: () => string;
setLoading: (loading: boolean) => void; // 实际是 setListLoading,保持参数名兼容
fetchDemoList: () => Promise<void>;
showToast: (message: string, type: 'success' | 'error') => void;
showAlertDialog: (title: string, message: string) => void;
onModeChange: () => void; // 当模式切换时,需要重新渲染列表
initialMultiSelectMode?: boolean; // 初始多选模式状态,默认为 false
disableModeToggle?: boolean; // 禁用模式切换按钮(只读选择场景),默认为 false
onSelectionChange?: (selectedCount: number) => void; // 选择数量变化时的回调
};
export type MultiSelect = {
isMultiSelectMode: () => boolean;
isItemSelected: (item: DemoItem) => boolean;
shouldShowCheckbox: () => boolean;
syncSelectionFromCheckbox: (item: DemoItem, checkboxNode: HTMLInputElement) => void;
syncCheckboxFromSelection: () => void;
selectAllItems: (items: DemoItem[]) => void;
clearSelection: () => void;
toggleMode: () => void;
updateBar: () => void;
initUI: (navWrapper: d3.Selection<d3.BaseType, any, any, any>) => void;
getSelectedPaths: () => string[]; // 获取所有选中项的路径
};
export function createMultiSelect(options: MultiSelectOptions): MultiSelect {
const {
container,
api,
getItemFullPath,
getCurrentPath,
setLoading,
fetchDemoList,
showToast,
showAlertDialog,
onModeChange,
initialMultiSelectMode = false,
disableModeToggle = false,
onSelectionChange,
} = options;
// 多选模式状态(使用配置的初始值)
let multiSelectMode: boolean = initialMultiSelectMode;
const selectedDemos: string[] = []; // 存储选中项的完整路径(按选择顺序)
// UI元素
let multiSelectBar: d3.Selection<HTMLDivElement, any, any, any> | null = null;
let multiSelectToggleBtn: d3.Selection<HTMLButtonElement, any, any, any> | null = null;
let multiSelectCount: d3.Selection<HTMLSpanElement, any, any, any> | null = null;
let navWrapper: d3.Selection<d3.BaseType, any, any, any> | null = null;
// 从复选框同步状态到 selectedDemos
const syncSelectionFromCheckbox = (item: DemoItem, checkboxNode: HTMLInputElement) => {
// 如果复选框被禁用,不更新选择状态
if (checkboxNode.disabled) return;
const itemPath = getItemFullPath(item);
if (itemPath === null) return;
// 直接从复选框读取状态,同步到 selectedDemos
if (checkboxNode.checked) {
selectedDemos.push(itemPath);
} else {
const index = selectedDemos.indexOf(itemPath);
if (index !== -1) {
selectedDemos.splice(index, 1);
}
}
updateBar();
};
// 同步 selectedDemos 到复选框状态(用于全选/清空操作)
const syncCheckboxFromSelection = () => {
const demoItems = container.selectAll<HTMLDivElement, DemoItem>('.demo-item');
demoItems.each(function(d) {
const demoItem = d3.select(this);
const checkbox = demoItem.select<HTMLInputElement>('.demo-checkbox-inline');
if (!checkbox.empty()) {
const itemPath = getItemFullPath(d);
const shouldBeChecked = itemPath !== null && selectedDemos.includes(itemPath);
const checkboxNode = checkbox.node();
if (checkboxNode) {
checkboxNode.checked = shouldBeChecked;
}
}
});
};
const selectAllItems = (items: DemoItem[]) => {
items.forEach(item => {
const itemPath = getItemFullPath(item);
if (itemPath !== null) {
selectedDemos.push(itemPath);
}
});
syncCheckboxFromSelection(); // 同步到复选框
updateBar();
};
const clearSelection = () => {
selectedDemos.length = 0;
syncCheckboxFromSelection(); // 同步到复选框
updateBar();
};
const toggleMode = () => {
multiSelectMode = !multiSelectMode;
// 退出多选模式时清空选择状态
if (!multiSelectMode) {
clearSelection();
}
updateBar();
updateToggleBtn();
onModeChange(); // 通知主模块重新渲染列表
};
const updateBar = () => {
if (!multiSelectBar || !multiSelectCount) return;
if (multiSelectMode) {
multiSelectBar.style('display', 'flex');
const selectedCount = selectedDemos.length;
// 基于checkbox原生状态判断全选按钮是否可用
// 获取当前目录下所有未禁用的checkbox(未禁用的都是文件类型)
const selectableCheckboxes: HTMLInputElement[] = [];
container.selectAll<HTMLInputElement, DemoItem>('.demo-checkbox-inline').each(function() {
const checkbox = this;
if (!checkbox.disabled) {
selectableCheckboxes.push(checkbox);
}
});
// 检查是否所有可选择的checkbox都已选中
const allSelected = selectableCheckboxes.length > 0 &&
selectableCheckboxes.every(checkbox => checkbox.checked);
const hasUnselected = !allSelected; // 有未选中项时全选按钮可用
multiSelectCount.text(selectedCount > 0 ? tr('Selected {count}').replace('{count}', String(selectedCount)) : tr('No selection'));
// 更新所有按钮的可用状态
multiSelectBar.selectAll('.refresh-btn').each(function() {
const btn = d3.select(this);
const action = btn.attr('data-action');
let isActive = false;
if (action === 'select-all') {
isActive = hasUnselected; // 有未选中项时可用
} else if (action === 'clear' || action === 'delete' || action === 'move') {
isActive = selectedCount > 0; // 有选中项时可用
}
btn.classed('inactive', !isActive);
});
// 通知外部选择数量已变化(用于更新弹窗确定按钮等)
if (onSelectionChange) {
onSelectionChange(selectedCount);
}
} else {
multiSelectBar.style('display', 'none');
}
};
const updateToggleBtn = () => {
if (multiSelectToggleBtn) {
if (multiSelectMode) {
// 多选模式:显示选中图标
multiSelectToggleBtn
.attr('title', tr('Exit multi-select mode'))
.text('☑');
} else {
// 非多选模式:显示未选中图标
multiSelectToggleBtn
.attr('title', tr('Multi-select mode'))
.text('☐');
}
}
};
const handleBatchDelete = async () => {
const selectedItems: DemoItem[] = [];
const currentPath = getCurrentPath();
const result = await api.list_demos(currentPath);
const allItems = result.items || [];
allItems.forEach((item: DemoItem) => {
const itemPath = getItemFullPath(item);
if (itemPath !== null && selectedDemos.includes(itemPath)) {
selectedItems.push(item);
}
});
if (selectedItems.length === 0) {
showAlertDialog(tr('Info'), tr('Please select items to delete first'));
return;
}
const itemNames = selectedItems.map(item => item.name).join('\n');
const confirmMessage = tr('Are you sure you want to delete the following {count} items?').replace('{count}', String(selectedItems.length)) + '\n\n' + itemNames;
if (!confirm(confirmMessage)) {
return;
}
try {
setLoading(true);
let successCount = 0;
let failCount = 0;
const errors: string[] = [];
for (const item of selectedItems) {
try {
let result;
if (item.type === 'file') {
result = await api.delete_demo(item.path);
} else {
result = await api.delete_folder(item.path);
}
if (result.success) {
successCount++;
} else {
failCount++;
errors.push(`${item.name}: ${tr(result.message || 'Delete failed')}`);
}
} catch (err) {
failCount++;
errors.push(`${item.name}: ${err instanceof Error ? tr(err.message) : tr('Delete failed')}`);
}
}
// 刷新列表
await fetchDemoList();
// 清空选择
clearSelection();
// 显示结果
if (failCount === 0) {
showToast(tr('Successfully deleted {count} items').replace('{count}', String(successCount)), 'success');
} else {
const errorMsg = errors.length > 0 ? `\n\n${tr('Failed items:')}\n${errors.slice(0, 5).join('\n')}${errors.length > 5 ? `\n${tr('... and {count} more items failed').replace('{count}', String(errors.length - 5))}` : ''}` : '';
const message = tr('Successfully deleted {success} items, failed {fail} items')
.replace('{success}', String(successCount))
.replace('{fail}', String(failCount)) + errorMsg;
showAlertDialog(tr('Partial success'), message);
}
} catch (err) {
console.error('批量删除失败:', err);
showAlertDialog(tr('Error'), tr('Batch delete failed, please check console for details.'));
} finally {
setLoading(false);
}
};
const handleBatchMove = async () => {
const selectedItems: DemoItem[] = [];
const currentPath = getCurrentPath();
const result = await api.list_demos(currentPath);
const allItems = result.items || [];
allItems.forEach((item: DemoItem) => {
const itemPath = getItemFullPath(item);
if (itemPath !== null && selectedDemos.includes(itemPath)) {
selectedItems.push(item);
}
});
if (selectedItems.length === 0) {
showAlertDialog(tr('Info'), tr('Please select items to move first'));
return;
}
try {
setLoading(true);
const foldersResult = await api.list_all_folders();
const folders = foldersResult.folders || [];
// 排除所有选中项所在的路径及其子路径
const excludePaths = new Set<string>();
selectedItems.forEach(item => {
const excludePath = item.path;
if (excludePath) {
excludePaths.add(excludePath);
}
});
const filteredFolders = folders.filter(f => {
if (excludePaths.has(f)) return false;
for (const excludePath of excludePaths) {
if (f.startsWith(excludePath + '/')) return false;
}
return true;
});
showMoveDialog(filteredFolders, currentPath, async (targetPath: string) => {
try {
setLoading(true);
let successCount = 0;
let failCount = 0;
const errors: string[] = [];
for (const item of selectedItems) {
try {
let result;
if (item.type === 'file') {
result = await api.move_demo(item.path, targetPath);
} else {
result = await api.move_folder(item.path, targetPath);
}
if (result.success) {
successCount++;
} else {
failCount++;
errors.push(`${item.name}: ${tr(result.message || 'Move failed')}`);
}
} catch (err) {
failCount++;
errors.push(`${item.name}: ${err instanceof Error ? tr(err.message) : tr('Move failed')}`);
}
}
// 刷新列表
await fetchDemoList();
// 清空选择
clearSelection();
// 显示结果
if (failCount === 0) {
showToast(tr('Successfully moved {count} items').replace('{count}', String(successCount)), 'success');
} else {
const errorMsg = errors.length > 0 ? `\n\n${tr('Failed items:')}\n${errors.slice(0, 5).join('\n')}${errors.length > 5 ? `\n${tr('... and {count} more items failed').replace('{count}', String(errors.length - 5))}` : ''}` : '';
const message = tr('Successfully moved {success} items, failed {fail} items')
.replace('{success}', String(successCount))
.replace('{fail}', String(failCount)) + errorMsg;
showAlertDialog(tr('Partial success'), message);
}
} catch (err) {
console.error('批量移动失败:', err);
showAlertDialog(tr('Error'), tr('Batch move failed, please check console for details.'));
} finally {
setLoading(false);
}
});
} catch (err) {
console.error('获取文件夹列表失败:', err);
showAlertDialog(tr('Error'), tr('Failed to get folder list, please check console for details.'));
} finally {
setLoading(false);
}
};
// 初始化UI
const initUI = (
navWrapperParam: d3.Selection<d3.BaseType, any, any, any>
) => {
// 保存navWrapper引用,用于宽度检测
navWrapper = navWrapperParam;
// 在内部查找 createFolderBtn
const createFolderBtn = navWrapper.select(`button[title="${tr('New folder')}"]`);
// 创建多选模式控制栏
// 如果有新建文件夹按钮,则在它之前插入;否则直接放在 navWrapper 末尾
if (!createFolderBtn.empty()) {
const createFolderBtnNode = createFolderBtn.node();
if (createFolderBtnNode && createFolderBtnNode instanceof HTMLElement) {
multiSelectBar = d3.select(createFolderBtnNode.parentElement)
.insert('div', () => createFolderBtnNode)
.attr('class', 'demo-multiselect-bar-center')
.style('display', 'none')
.style('flex-shrink', '0');
}
} else {
multiSelectBar = navWrapper.append('div')
.attr('class', 'demo-multiselect-bar-center')
.style('display', 'none')
.style('flex-shrink', '0');
}
// 在多选控制栏的右边添加多选切换按钮(如果未禁用)
if (!disableModeToggle && !createFolderBtn.empty()) {
const createFolderBtnNode = createFolderBtn.node();
if (createFolderBtnNode && createFolderBtnNode instanceof HTMLElement) {
multiSelectToggleBtn = d3.select(createFolderBtnNode.parentElement)
.insert('button', () => createFolderBtnNode)
.attr('class', 'refresh-btn')
.attr('title', tr('Multi-select mode'))
.text('☐') // 初始状态:未选中
.style('flex-shrink', '0')
.on('click', toggleMode);
// 初始化按钮状态
updateToggleBtn();
}
}
// 初始化多选模式控制栏(按钮样式与新建文件夹按钮一致)
if (multiSelectBar) {
// 不再在控制栏中显示退出按钮,使用固定的切换按钮
multiSelectCount = multiSelectBar.append('span')
.attr('class', 'multiselect-count')
.style('font-size', '9pt')
.style('color', 'var(--text-muted)')
.style('margin-left', '0px')
.style('margin-right', '6px') // 减少30%:8px → 6px
.text(tr('No selection'));
multiSelectBar.append('button')
.attr('class', 'refresh-btn')
.attr('data-action', 'select-all')
.attr('title', tr('Select all'))
.text(tr('Select all'))
.on('click', () => {
// 获取所有demo项,但只选择未被禁用的项
const allItems: DemoItem[] = [];
container.selectAll<HTMLDivElement, DemoItem>('.demo-item').each(function(d) {
const demoItem = d3.select(this);
const checkbox = demoItem.select<HTMLInputElement>('.demo-checkbox-inline');
if (!checkbox.empty()) {
const checkboxNode = checkbox.node();
// 只添加未被禁用的项
if (checkboxNode && !checkboxNode.disabled) {
allItems.push(d);
}
}
});
selectAllItems(allItems);
});
multiSelectBar.append('button')
.attr('class', 'refresh-btn')
.attr('data-action', 'clear')
.attr('title', tr('Clear'))
.text(tr('Clear'))
.on('click', clearSelection);
// 只在非只读模式下显示删除和移动按钮
if (!disableModeToggle) {
multiSelectBar.append('button')
.attr('class', 'refresh-btn')
.attr('data-action', 'delete')
.attr('title', tr('Delete'))
.text(tr('Delete'))
.on('click', handleBatchDelete);
multiSelectBar.append('button')
.attr('class', 'refresh-btn')
.attr('data-action', 'move')
.attr('title', tr('Move'))
.text(tr('Move'))
.on('click', handleBatchMove);
}
// 初始化控制栏显示状态(在 forceMultiSelect 模式下需要显示)
updateBar();
}
};
const getSelectedPaths = (): string[] => {
return [...selectedDemos];
};
return {
isMultiSelectMode: () => multiSelectMode,
isItemSelected: (item: DemoItem) => {
const itemPath = getItemFullPath(item);
return itemPath !== null && selectedDemos.includes(itemPath);
},
shouldShowCheckbox: () => multiSelectMode,
syncSelectionFromCheckbox,
syncCheckboxFromSelection,
selectAllItems,
clearSelection,
toggleMode,
updateBar,
initUI,
getSelectedPaths,
};
}