| 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; |
| fetchDemoList: () => Promise<void>; |
| showToast: (message: string, type: 'success' | 'error') => void; |
| showAlertDialog: (title: string, message: string) => void; |
| onModeChange: () => void; |
| initialMultiSelectMode?: boolean; |
| disableModeToggle?: boolean; |
| 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[] = []; |
|
|
| |
| 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; |
|
|
| |
| const syncSelectionFromCheckbox = (item: DemoItem, checkboxNode: HTMLInputElement) => { |
| |
| if (checkboxNode.disabled) return; |
|
|
| const itemPath = getItemFullPath(item); |
| if (itemPath === null) return; |
|
|
| |
| if (checkboxNode.checked) { |
| selectedDemos.push(itemPath); |
| } else { |
| const index = selectedDemos.indexOf(itemPath); |
| if (index !== -1) { |
| selectedDemos.splice(index, 1); |
| } |
| } |
|
|
| updateBar(); |
| }; |
|
|
| |
| 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; |
| |
| |
| |
| const selectableCheckboxes: HTMLInputElement[] = []; |
| container.selectAll<HTMLInputElement, DemoItem>('.demo-checkbox-inline').each(function() { |
| const checkbox = this; |
| if (!checkbox.disabled) { |
| selectableCheckboxes.push(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); |
| } |
| }; |
|
|
| |
| const initUI = ( |
| navWrapperParam: d3.Selection<d3.BaseType, any, any, any> |
| ) => { |
| |
| navWrapper = navWrapperParam; |
| |
| |
| const createFolderBtn = navWrapper.select(`button[title="${tr('New folder')}"]`); |
| |
| |
| |
| 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') |
| .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', () => { |
| |
| 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); |
| } |
| |
| |
| 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, |
| }; |
| } |
|
|
|
|