diff --git a/src/components/timeline/CategorySelector.tsx b/src/components/timeline/CategorySelector.tsx new file mode 100644 index 0000000..b22d506 --- /dev/null +++ b/src/components/timeline/CategorySelector.tsx @@ -0,0 +1,202 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import { useActiveCategoriesOnly } from '../../stores/useCategoryStore' + +interface CategorySelectorProps { + isOpen: boolean + position: { x: number; y: number } + currentName: string + onSelect: (categoryName: string) => void + onCancel: () => void +} + +export const CategorySelector: React.FC = ({ + isOpen, + position, + currentName, + onSelect, + onCancel +}) => { + const activeCategories = useActiveCategoriesOnly() + const [selectedIndex, setSelectedIndex] = React.useState(-1) + const dropdownRef = React.useRef(null) + + // 处理键盘导航 + const handleKeyDown = React.useCallback((e: KeyboardEvent) => { + if (!isOpen) return + + switch (e.key) { + case 'Escape': + e.preventDefault() + onCancel() + break + case 'ArrowDown': + e.preventDefault() + setSelectedIndex(prev => + prev < activeCategories.length ? prev + 1 : prev + ) + break + case 'ArrowUp': + e.preventDefault() + setSelectedIndex(prev => prev > -1 ? prev - 1 : prev) + break + case 'Enter': + e.preventDefault() + if (selectedIndex === -1) { + onSelect(currentName) // 保持原名称 + } else if (selectedIndex < activeCategories.length) { + onSelect(activeCategories[selectedIndex].title) + } + break + } + }, [isOpen, selectedIndex, activeCategories, currentName, onSelect, onCancel]) + + // 监听键盘事件 + React.useEffect(() => { + if (isOpen) { + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen, handleKeyDown]) + + // 点击外部关闭 + React.useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + onCancel() + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen, onCancel]) + + // 自动聚焦 + React.useEffect(() => { + if (isOpen && dropdownRef.current) { + dropdownRef.current.focus() + } + }, [isOpen]) + + if (!isOpen) return null + + // 计算下拉框位置,确保不超出视窗 + const adjustedPosition = React.useMemo(() => { + const maxHeight = 300 // 下拉框最大高度 + const padding = 10 + + let { x, y } = position + + // 检查是否超出右边界 + if (x + 250 > window.innerWidth) { + x = window.innerWidth - 250 - padding + } + + // 检查是否超出下边界 + if (y + maxHeight > window.innerHeight) { + y = position.y - maxHeight - padding + } + + return { x: Math.max(padding, x), y: Math.max(padding, y) } + }, [position]) + + const dropdown = ( +
+ {/* 头部 */} +
+
选择分类
+
当前: {currentName}
+
+ + {/* 选项列表 */} +
+ {/* 保持原名称选项 */} +
onSelect(currentName)} + onMouseEnter={() => setSelectedIndex(-1)} + > +
+
+
+
+ 保持原名称 +
+
+ {currentName} +
+
+
+
+ + {/* 分隔线 */} + {activeCategories.length > 0 && ( +
+ )} + + {/* 分类选项 */} + {activeCategories.length === 0 ? ( +
+ 暂无可用分类 +
+ ) : ( + activeCategories.map((category, index) => ( +
onSelect(category.title)} + onMouseEnter={() => setSelectedIndex(index)} + > +
+
+
+
+ {category.title} +
+ {category.ai_prompt && ( +
+ {category.ai_prompt} +
+ )} +
+
+
+ )) + )} +
+ + {/* 底部提示 */} +
+
+ ↑↓ 导航 • Enter 选择 • Esc 取消 +
+
+
+ ) + + // 使用 Portal 渲染到 body + return createPortal(dropdown, document.body) +} + +export default CategorySelector diff --git a/src/components/timeline/TrackTimeline.tsx b/src/components/timeline/TrackTimeline.tsx index 0c13917..9dffe41 100644 --- a/src/components/timeline/TrackTimeline.tsx +++ b/src/components/timeline/TrackTimeline.tsx @@ -1,7 +1,8 @@ import React from 'react' import { SegmentContextMenu } from './SegmentContextMenu' import { SegmentTooltip } from './SegmentTooltip' -import { useCategoriesData, useCategoryActions, useActiveCategoriesOnly } from '../../stores/useCategoryStore' +import { CategorySelector } from './CategorySelector' +import { useCategoryActions } from '../../stores/useCategoryStore' export interface TrackSegment { id: string @@ -40,7 +41,7 @@ export const TrackTimeline: React.FC = ({ onSegmentHover, onSegmentNameChange }) => { - const [editingSegmentId, setEditingSegmentId] = React.useState(null) + const [contextMenu, setContextMenu] = React.useState<{ isOpen: boolean position: { x: number; y: number } @@ -48,10 +49,14 @@ export const TrackTimeline: React.FC = ({ }>({ isOpen: false, position: { x: 0, y: 0 }, segment: null }) const [hoveredSegment, setHoveredSegment] = React.useState(null) const [mousePosition, setMousePosition] = React.useState({ x: 0, y: 0 }) + const [categorySelector, setCategorySelector] = React.useState<{ + isOpen: boolean + position: { x: number; y: number } + segmentId: string | null + currentName: string + }>({ isOpen: false, position: { x: 0, y: 0 }, segmentId: null, currentName: '' }) // 使用 Zustand store 管理分类数据 - const { loading: loadingCategories } = useCategoriesData() - const activeCategories = useActiveCategoriesOnly() const { loadCategories } = useCategoryActions() // 组件挂载时加载分类(如果还没有加载) @@ -96,9 +101,18 @@ export const TrackTimeline: React.FC = ({ return `${minutes}:${secs.padStart(5, '0')}` } - const handleSegmentDoubleClick = (segment: TrackSegment) => { - setEditingSegmentId(segment.id) - // Zustand store 会自动处理分类数据的加载和缓存 + const handleSegmentDoubleClick = (e: React.MouseEvent, segment: TrackSegment) => { + e.stopPropagation() + + // 获取片段的屏幕位置 + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + + setCategorySelector({ + isOpen: true, + position: { x: rect.left, y: rect.bottom + 5 }, + segmentId: segment.id, + currentName: segment.name + }) } const handleSegmentRightClick = (e: React.MouseEvent, segment: TrackSegment) => { @@ -112,11 +126,15 @@ export const TrackTimeline: React.FC = ({ }) } - const handleCategorySelect = (segmentId: string, categoryTitle: string) => { - if (onSegmentNameChange && categoryTitle) { - onSegmentNameChange(segmentId, categoryTitle) + const handleCategorySelect = (categoryTitle: string) => { + if (onSegmentNameChange && categorySelector.segmentId && categoryTitle) { + onSegmentNameChange(categorySelector.segmentId, categoryTitle) } - setEditingSegmentId(null) + setCategorySelector(prev => ({ ...prev, isOpen: false })) + } + + const handleCategorySelectorCancel = () => { + setCategorySelector(prev => ({ ...prev, isOpen: false })) } @@ -142,8 +160,16 @@ export const TrackTimeline: React.FC = ({ const handleContextMenuEdit = () => { if (contextMenu.segment) { - setEditingSegmentId(contextMenu.segment.id) - // Zustand store 会自动处理分类数据的加载和缓存 + // 获取右键菜单的位置作为分类选择器的位置 + setCategorySelector({ + isOpen: true, + position: contextMenu.position, + segmentId: contextMenu.segment.id, + currentName: contextMenu.segment.name + }) + + // 关闭右键菜单 + setContextMenu({ isOpen: false, position: { x: 0, y: 0 }, segment: null }) } } @@ -196,49 +222,16 @@ export const TrackTimeline: React.FC = ({ minWidth: '80px' }} onClick={() => onSegmentClick?.(segment)} - onDoubleClick={() => handleSegmentDoubleClick(segment)} + onDoubleClick={(e) => handleSegmentDoubleClick(e, segment)} onContextMenu={(e) => handleSegmentRightClick(e, segment)} onMouseEnter={(e) => handleSegmentMouseEnter(e, segment)} onMouseLeave={handleSegmentMouseLeave} onMouseMove={handleSegmentMouseMove} title={`${segment.name}\n类型: ${segment.type}\n开始: ${formatTime(segment.start_time)}\n结束: ${formatTime(segment.end_time)}\n时长: ${formatTime(segment.duration)}${segment.resource_path ? `\n资源: ${segment.resource_path}` : ''}`} > - {editingSegmentId === segment.id ? ( -
e.stopPropagation()}> - {loadingCategories ? ( -
- 加载分类中... -
- ) : ( - - )} -
- ) : ( -
- {segment.name} -
- )} +
+ {segment.name} +
) })} @@ -281,7 +274,16 @@ export const TrackTimeline: React.FC = ({ + + {/* Category Selector */} +
)