| import * as d3 from 'd3'; |
| import { AnalysisData, TextAnalysisAPI } from '../api/GLTR_API'; |
| import { createPathNavigator, PathNavigator } from './pathNavigator'; |
| import { createMenuButton } from './itemMenu'; |
| import { showMoveDialog, showRenameDialog, showDeleteConfirm, showCreateFolderDialog } from './folderOperations'; |
| import { showAlertDialog } from './dialog'; |
| import { createToast } from './toast'; |
| |
| import { tr } from '../lang/i18n-lite'; |
| import URLHandler from '../utils/URLHandler'; |
| import { createMultiSelect, type MultiSelect } from './demoMultiSelect'; |
| import { normalizeFullPath } from '../utils/pathUtils'; |
| import { isValidDemoFormat } from '../utils/localFileUtils'; |
| import { ServerStorage } from '../storage/demoStorage'; |
| import { DemoStorageController } from '../controllers/demoStorageController'; |
|
|
| export type DemoManagerOptions = { |
| api: TextAnalysisAPI; |
| enableDemo: boolean; |
| containerSelector: string; |
| loaderSelector: string; |
| refreshSelector: string; |
| onDemoLoaded: (data: AnalysisData, disableAnimation: boolean, isNewDemo?: boolean, path?: string) => void; |
| onTextPrefill?: (text: string) => void; |
| onDemoLoading?: (loading: boolean) => void; |
| onRefreshStart?: () => void; |
| onRefreshEnd?: () => void; |
| forceMultiSelect?: boolean; |
| disableFolderOperations?: boolean; |
| disableClickLoad?: boolean; |
| onSelectionChange?: (selectedCount: number) => void; |
| }; |
|
|
| export type DemoManager = { |
| refresh: () => Promise<void>; |
| highlightDemo: (fullPath: string | null) => void; |
| navigateToDemoAndHighlight: (fullPath: string) => Promise<void>; |
| loadDemoByPath: (fullPath: string) => Promise<boolean>; |
| getSelectedPaths: () => string[]; |
| }; |
|
|
| type DemoItem = { |
| type: 'folder' | 'file'; |
| name: string; |
| path: string; |
| }; |
|
|
| |
| |
| |
| |
| export { isValidDemoFormat as isValidAnalyzeResponse } from '../utils/localFileUtils'; |
|
|
| export function initDemoManager(options: DemoManagerOptions): DemoManager { |
| const { |
| api, |
| enableDemo, |
| containerSelector, |
| loaderSelector, |
| refreshSelector, |
| onDemoLoaded, |
| onTextPrefill, |
| onDemoLoading, |
| onRefreshStart, |
| onRefreshEnd, |
| forceMultiSelect = false, |
| disableFolderOperations = false, |
| disableClickLoad = false, |
| onSelectionChange, |
| } = options; |
|
|
| if (!enableDemo) { |
| d3.selectAll('.demo').remove(); |
| return { |
| refresh: () => Promise.resolve(), |
| highlightDemo: () => {}, |
| navigateToDemoAndHighlight: () => Promise.resolve(), |
| loadDemoByPath: () => Promise.resolve(false), |
| getSelectedPaths: () => [], |
| }; |
| } |
|
|
| const container = d3.select(containerSelector); |
| const loader = d3.select(loaderSelector); |
| const refreshBtn = d3.select(refreshSelector); |
|
|
| |
| let currentPath: string = '/'; |
| let pathNavigator: PathNavigator | null = null; |
|
|
| |
| let pathNavContainer: d3.Selection<HTMLDivElement, any, any, any> | null = null; |
| const containerNode = container.node() as HTMLElement | null; |
| if (containerNode && containerNode.parentElement) { |
| pathNavContainer = d3.select(containerNode.parentElement) |
| .insert('div', () => containerNode) |
| .attr('class', 'demo-path-nav-container'); |
| } |
|
|
| |
| if (pathNavContainer) { |
| pathNavigator = createPathNavigator( |
| pathNavContainer, |
| currentPath, |
| (newPath: string) => { |
| currentPath = newPath; |
| pathNavigator?.update(newPath); |
| fetchDemoList().catch(err => { |
| console.error('刷新demo列表失败:', err); |
| }); |
| }, |
| disableFolderOperations ? undefined : () => { |
| |
| showCreateFolderDialog(async (folderName: string) => { |
| try { |
| setListLoading(true); |
| const result = await api.create_folder(currentPath, folderName); |
| |
| if (result.success) { |
| await fetchDemoList(); |
| } else { |
| showAlertDialog(tr('Error'), tr(result.message || 'Failed to create folder')); |
| } |
| } catch (err) { |
| console.error('创建文件夹失败:', err); |
| showAlertDialog(tr('Error'), tr('Failed to create folder, please check console for details.')); |
| } finally { |
| setListLoading(false); |
| } |
| }); |
| } |
| ); |
| |
| } |
|
|
| |
|
|
| let activeDemoFullPath: string | null = null; |
| let lastLoadedDemoPath: string | null = null; |
|
|
| const applyActiveState = () => { |
| const buttons = container.selectAll<HTMLDivElement, DemoItem>('.demoBtn, .demo-folder-btn'); |
| |
| buttons.classed('demo-selected', d => { |
| return d.type === 'file' && normalizeFullPath(d.path) === activeDemoFullPath; |
| }); |
| |
| |
| if (activeDemoFullPath) { |
| const selectedButton = buttons.filter(d => { |
| return d.type === 'file' && normalizeFullPath(d.path) === activeDemoFullPath; |
| }); |
| |
| if (!selectedButton.empty()) { |
| const buttonNode = selectedButton.node() as HTMLElement | null; |
| |
| if (buttonNode) { |
| |
| requestAnimationFrame(() => { |
| requestAnimationFrame(() => { |
| |
| |
| |
| buttonNode.scrollIntoView({ |
| behavior: 'smooth', |
| block: 'nearest', |
| inline: 'nearest' |
| }); |
| }); |
| }); |
| } |
| } |
| } |
| }; |
|
|
| const getItemFullPath = (item: DemoItem): string | null => { |
| return normalizeFullPath(item.path); |
| }; |
|
|
| |
| let multiSelect: MultiSelect | null = null; |
| const toastController = createToast('#toast'); |
| const showToast = toastController.show; |
| |
| |
| const initMultiSelect = () => { |
| if (multiSelect) return; |
| |
| if (!pathNavContainer) return; |
| |
| multiSelect = createMultiSelect({ |
| container, |
| api, |
| getItemFullPath, |
| getCurrentPath: () => currentPath, |
| setLoading: setListLoading, |
| fetchDemoList, |
| showToast, |
| showAlertDialog, |
| onModeChange: () => { |
| |
| const items = container.selectAll<HTMLDivElement, DemoItem>('.demo-item').data(); |
| renderItems(items); |
| }, |
| initialMultiSelectMode: forceMultiSelect, |
| disableModeToggle: forceMultiSelect, |
| onSelectionChange, |
| }); |
| |
| |
| |
| const navWrapper = pathNavContainer.select('.demo-path-nav-wrapper'); |
| if (!navWrapper.empty()) { |
| multiSelect.initUI(navWrapper); |
| } |
| }; |
|
|
| const highlightDemo = (fullPath: string | null) => { |
| activeDemoFullPath = normalizeFullPath(fullPath); |
| applyActiveState(); |
| }; |
|
|
| |
| |
| |
| const extractFolderPath = (fullPath: string): string => { |
| const pathParts = fullPath.split('/').filter(p => p); |
| if (pathParts.length <= 1) { |
| return '/'; |
| } |
| return '/' + pathParts.slice(0, -1).join('/'); |
| }; |
|
|
| |
| const navigateToFolder = async (targetFolderPath: string): Promise<void> => { |
| if (currentPath !== targetFolderPath) { |
| currentPath = targetFolderPath; |
| if (pathNavigator) { |
| pathNavigator.update(targetFolderPath); |
| } |
| await fetchDemoList(); |
| } |
| }; |
|
|
| |
| const navigateToDemoAndHighlight = async (fullPath: string): Promise<void> => { |
| const normalizedPath = normalizeFullPath(fullPath); |
| if (!normalizedPath) { |
| return; |
| } |
|
|
| try { |
| const targetFolderPath = extractFolderPath(normalizedPath); |
| await navigateToFolder(targetFolderPath); |
| highlightDemo(normalizedPath); |
| } catch (error) { |
| console.error('导航到demo失败:', error); |
| } |
| }; |
|
|
| const setActiveDemo = (fullPath: string | null) => { |
| highlightDemo(fullPath); |
| |
| |
| if (activeDemoFullPath) { |
| URLHandler.updateURLParam('demo', activeDemoFullPath, false); |
| } else { |
| |
| const currentParams = URLHandler.parameters; |
| delete currentParams['demo']; |
| URLHandler.updateUrl(currentParams, false); |
| } |
| }; |
|
|
| |
| |
| const setListLoading = (loading: boolean) => { |
| loader.style('display', loading ? null : 'none'); |
| }; |
|
|
| const disableDemoButtons = (disabled: boolean) => { |
| container.selectAll('.demoBtn, .demo-folder-btn') |
| .style('opacity', disabled ? '0.5' : '1') |
| .style('pointer-events', disabled ? 'none' : null) |
| .style('cursor', disabled ? 'not-allowed' : 'pointer'); |
| }; |
|
|
| |
| const serverStorageController = new DemoStorageController( |
| new ServerStorage(api), |
| { |
| setLoading: (loading: boolean) => { |
| onDemoLoading?.(loading); |
| disableDemoButtons(loading); |
| }, |
| showToast: (message, type) => { |
| |
| if (type === 'error') { |
| showAlertDialog(tr('Error'), message); |
| } |
| }, |
| showSuccessToast: false |
| } |
| ); |
|
|
| const fetchDemoList = async () => { |
| disableDemoButtons(true); |
| setListLoading(true); |
| onRefreshStart?.(); |
| try { |
| const result = await api.list_demos(currentPath); |
| |
| renderItems(result.items || []); |
| if (pathNavigator) { |
| pathNavigator.update(result.path || currentPath); |
| currentPath = result.path || currentPath; |
| } |
| } finally { |
| setListLoading(false); |
| disableDemoButtons(false); |
| onRefreshEnd?.(); |
| } |
| }; |
|
|
| const handleMoveItem = async (item: DemoItem) => { |
| try { |
| setListLoading(true); |
| const foldersResult = await api.list_all_folders(); |
| const folders = foldersResult.folders || []; |
| |
| |
| const excludePath = item.path; |
| |
| const filteredFolders = folders.filter(f => { |
| if (f === excludePath) return false; |
| if (excludePath && f.startsWith(excludePath + '/')) return false; |
| return true; |
| }); |
|
|
| showMoveDialog(filteredFolders, currentPath, async (targetPath: string) => { |
| try { |
| setListLoading(true); |
| 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) { |
| await fetchDemoList(); |
| } else { |
| showAlertDialog(tr('Error'), tr(result.message || 'Move failed')); |
| } |
| } catch (err) { |
| console.error('移动失败:', err); |
| showAlertDialog(tr('Error'), tr('Move failed, please check console for details.')); |
| } finally { |
| setListLoading(false); |
| } |
| }); |
| } catch (err) { |
| console.error('获取文件夹列表失败:', err); |
| showAlertDialog(tr('Error'), tr('Failed to get folder list, please check console for details.')); |
| } finally { |
| setListLoading(false); |
| } |
| }; |
|
|
| const handleRenameItem = async (item: DemoItem) => { |
| showRenameDialog(item.name, async (newName: string) => { |
| try { |
| setListLoading(true); |
| let result; |
| if (item.type === 'file') { |
| result = await api.rename_demo(item.path, newName); |
| } else { |
| result = await api.rename_folder(item.path, newName); |
| } |
| |
| if (result.success) { |
| await fetchDemoList(); |
| } else { |
| showAlertDialog(tr('Error'), tr(result.message || 'Rename failed')); |
| } |
| } catch (err) { |
| console.error('重命名失败:', err); |
| showAlertDialog(tr('Error'), tr('Rename failed, please check console for details.')); |
| } finally { |
| setListLoading(false); |
| } |
| }); |
| }; |
|
|
| const handleDeleteItem = async (item: DemoItem) => { |
| showDeleteConfirm(item.name, item.type, async () => { |
| try { |
| setListLoading(true); |
| let result; |
| if (item.type === 'file') { |
| result = await api.delete_demo(item.path); |
| } else { |
| result = await api.delete_folder(item.path); |
| } |
| |
| if (result.success) { |
| await fetchDemoList(); |
| } else { |
| showAlertDialog(tr('Error'), tr(result.message || 'Delete failed')); |
| } |
| } catch (err) { |
| console.error('删除失败:', err); |
| showAlertDialog(tr('Error'), tr('Delete failed, please check console for details.')); |
| } finally { |
| setListLoading(false); |
| } |
| }); |
| }; |
|
|
| const handleFolderClick = (folderPath: string) => { |
| currentPath = folderPath; |
| if (pathNavigator) { |
| pathNavigator.update(folderPath); |
| } |
| fetchDemoList().catch(err => { |
| console.error('刷新demo列表失败:', err); |
| }); |
| }; |
|
|
| const renderItems = (items: DemoItem[]) => { |
| |
| const demoItems = container.selectAll<HTMLDivElement, DemoItem>('.demo-item') |
| .data(items, (d: DemoItem) => d.path) |
| .join('div') |
| .attr('class', 'demo-item'); |
|
|
| |
| if (multiSelect && multiSelect.shouldShowCheckbox()) { |
| const checkboxes = demoItems.selectAll<HTMLInputElement, DemoItem>('.demo-checkbox-inline') |
| .data(d => [d]) |
| .join('input') |
| .attr('type', 'checkbox') |
| .attr('class', 'demo-checkbox-inline') |
| .property('checked', d => multiSelect.isItemSelected(d)) |
| .property('disabled', d => d.type === 'folder'); |
| } else { |
| |
| demoItems.selectAll('.demo-checkbox-inline').remove(); |
| } |
|
|
| |
| const buttons = demoItems.selectAll<HTMLDivElement, DemoItem>('.demoBtn, .demo-folder-btn') |
| .data(d => [d]) |
| .join('div') |
| .attr('class', d => d.type === 'folder' ? 'demo-folder-btn' : 'demoBtn') |
| .style('opacity', '1') |
| .style('pointer-events', null) |
| .style('cursor', 'pointer') |
| .html(d => { |
| |
| if (d.type === 'folder') { |
| const folderIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px; opacity: 0.7;"><path d="M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-6l-2-2H5a2 2 0 0 0-2 2z"/></svg>'; |
| return folderIcon + d.name; |
| } |
| return d.name; |
| }); |
|
|
| |
| |
| |
| if (!forceMultiSelect && !disableFolderOperations) { |
| const menuContainers = demoItems.selectAll<HTMLDivElement, DemoItem>('.demo-menu-container') |
| .data(d => [d]) |
| .join('div') |
| .attr('class', 'demo-menu-container') |
| .style('flex-shrink', '0') |
| .html(''); |
|
|
| |
| const menuMap = new Map<string, ReturnType<typeof createMenuButton>['menu']>(); |
|
|
| menuContainers.each(function(item) { |
| const menuContainer = d3.select(this); |
| const { button, menu } = createMenuButton( |
| item, |
| disableFolderOperations ? () => {} : () => handleMoveItem(item), |
| disableFolderOperations ? () => {} : () => handleRenameItem(item), |
| disableFolderOperations ? () => {} : () => handleDeleteItem(item) |
| ); |
| const containerNode = menuContainer.node() as HTMLElement | null; |
| const buttonNode = button.node() as HTMLElement | null; |
| if (containerNode && buttonNode) { |
| containerNode.appendChild(buttonNode); |
| } |
| |
| const key = item.path || ''; |
| menuMap.set(key, menu); |
| }); |
|
|
| |
| demoItems |
| .on('mouseenter', function(event, item) { |
| const key = item.path || ''; |
| const menu = menuMap.get(key); |
| if (menu) { |
| menu.showButton(); |
| } |
| }) |
| .on('mouseleave', function(event, item) { |
| const key = item.path || ''; |
| const menu = menuMap.get(key); |
| if (menu) { |
| menu.hideButton(); |
| } |
| }); |
| } else { |
| |
| demoItems.selectAll('.demo-menu-container').remove(); |
| } |
|
|
| buttons.on('click', function(event, item) { |
| if (item.type === 'folder') { |
| handleFolderClick(item.path); |
| } else if (item.type === 'file') { |
| |
| if (disableClickLoad && multiSelect && multiSelect.shouldShowCheckbox()) { |
| const demoItem = d3.select(this.parentElement); |
| const checkbox = demoItem.select<HTMLInputElement>('.demo-checkbox-inline'); |
| const checkboxNode = checkbox.node(); |
| if (checkboxNode && !checkboxNode.disabled) { |
| checkboxNode.checked = !checkboxNode.checked; |
| |
| checkbox.dispatch('change'); |
| } |
| } else { |
| loadDemoFile(item); |
| } |
| } |
| }); |
| |
| |
| if (multiSelect && multiSelect.shouldShowCheckbox()) { |
| demoItems.selectAll<HTMLInputElement, DemoItem>('.demo-checkbox-inline') |
| .on('change', function(event, d) { |
| |
| if (d && event.target instanceof HTMLInputElement && multiSelect) { |
| multiSelect.syncSelectionFromCheckbox(d, event.target); |
| } |
| }); |
| } |
|
|
| |
| |
|
|
| applyActiveState(); |
| |
| |
| if (multiSelect && multiSelect.shouldShowCheckbox()) { |
| multiSelect.syncCheckboxFromSelection(); |
| |
| multiSelect.updateBar(); |
| } |
| }; |
|
|
| const loadDemoFile = async (item: DemoItem) => { |
| if (!item.path || !item.path.trim()) { |
| showAlertDialog(tr('Error'), tr('Cannot find corresponding demo file path, unable to load.')); |
| return; |
| } |
| |
| |
| const data = await serverStorageController.load(item.path); |
| if (!data) { |
| |
| return; |
| } |
| |
| |
| |
| const demoPath = item.path; |
| const normalizedPath = normalizeFullPath(demoPath); |
| const isNewDemo = normalizedPath !== lastLoadedDemoPath; |
| if (isNewDemo) { |
| lastLoadedDemoPath = normalizedPath; |
| } |
| |
| onTextPrefill?.(data.request.text); |
| onDemoLoaded(data, true, isNewDemo, demoPath); |
| setActiveDemo(demoPath); |
| }; |
|
|
| |
| const loadDemoByPath = async (fullPath: string): Promise<boolean> => { |
| const normalizedPath = normalizeFullPath(fullPath); |
| if (!normalizedPath) { |
| return false; |
| } |
|
|
| try { |
| |
| const targetFolderPath = extractFolderPath(normalizedPath); |
| await navigateToFolder(targetFolderPath); |
|
|
| |
| const result = await api.list_demos(currentPath); |
| const items = result.items || []; |
| const targetItem = items.find((item: DemoItem) => { |
| if (item.type !== 'file') { |
| return false; |
| } |
| |
| const normalizedItemPath = normalizeFullPath(item.path); |
| return normalizedItemPath === normalizedPath; |
| }); |
|
|
| if (targetItem && targetItem.type === 'file') { |
| await loadDemoFile(targetItem); |
| return true; |
| } |
|
|
| return false; |
| } catch (err) { |
| console.error('根据路径加载demo失败:', err); |
| return false; |
| } |
| }; |
|
|
| refreshBtn.on('click', () => { |
| fetchDemoList().catch(err => { |
| console.error('刷新demo列表失败:', err); |
| showAlertDialog(tr('Error'), tr('Failed to refresh demo list, please check console for details.')); |
| }); |
| }); |
|
|
| |
| initMultiSelect(); |
|
|
| |
| fetchDemoList().catch(err => { |
| console.error('加载demo列表失败:', err); |
| showAlertDialog(tr('Error'), tr('Failed to refresh demo list, please check console for details.')); |
| }); |
|
|
| return { |
| refresh: fetchDemoList, |
| highlightDemo: highlightDemo, |
| navigateToDemoAndHighlight: navigateToDemoAndHighlight, |
| loadDemoByPath: loadDemoByPath, |
| getSelectedPaths: () => multiSelect ? multiSelect.getSelectedPaths() : [], |
| }; |
| } |
|
|