diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index 1dd0d9b..71600a2 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -10,6 +10,8 @@ interface AnnotationOverlayProps { onPositionChange: (id: string, position: { x: number; y: number }) => void; onSizeChange: (id: string, size: { width: number; height: number }) => void; onClick: (id: string) => void; + zIndex: number; + isSelectedBoost: boolean; // Boost z-index when selected for easy editing } export function AnnotationOverlay({ @@ -20,6 +22,8 @@ export function AnnotationOverlay({ onPositionChange, onSizeChange, onClick, + zIndex, + isSelectedBoost, }: AnnotationOverlayProps) { const x = (annotation.position.x / 100) * containerWidth; const y = (annotation.position.y / 100) * containerHeight; @@ -123,7 +127,7 @@ export function AnnotationOverlay({ isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent" )} style={{ - zIndex: 9999, + zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top pointerEvents: isSelected ? 'auto' : 'none', border: isSelected ? '2px solid rgba(52, 178, 123, 0.8)' : 'none', backgroundColor: isSelected ? 'rgba(52, 178, 123, 0.1)' : 'transparent', diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index bbeafb6..037770e 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -62,6 +62,7 @@ export default function VideoEditor() { const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextAnnotationIdRef = useRef(1); + const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order const exporterRef = useRef(null); // Helper to convert file path to proper file:// URL @@ -245,6 +246,7 @@ export default function VideoEditor() { const handleAnnotationAdded = useCallback((span: Span) => { const id = `annotation-${nextAnnotationIdRef.current++}`; + const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order const newRegion: AnnotationRegion = { id, startMs: Math.round(span.start), @@ -254,6 +256,7 @@ export default function VideoEditor() { position: { ...DEFAULT_ANNOTATION_POSITION }, size: { ...DEFAULT_ANNOTATION_SIZE }, style: { ...DEFAULT_ANNOTATION_STYLE }, + zIndex, }; console.log('Annotation region added:', newRegion); setAnnotationRegions((prev) => [...prev, newRegion]); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index f12e79b..bb51f34 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -803,7 +803,27 @@ const VideoPlayback = forwardRef(({ const timeMs = Math.round(currentTime * 1000); return timeMs >= annotation.startMs && timeMs <= annotation.endMs; }); - return filtered.map((annotation) => ( + + // Sort by z-index (lowest to highest) so higher z-index renders on top + const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex); + + // Handle click-through cycling: when clicking same annotation, cycle to next + const handleAnnotationClick = (clickedId: string) => { + if (!onSelectAnnotation) return; + + // If clicking on already selected annotation and there are multiple overlapping + if (clickedId === selectedAnnotationId && sorted.length > 1) { + // Find current index and cycle to next + const currentIndex = sorted.findIndex(a => a.id === clickedId); + const nextIndex = (currentIndex + 1) % sorted.length; + onSelectAnnotation(sorted[nextIndex].id); + } else { + // First click or clicking different annotation + onSelectAnnotation(clickedId); + } + }; + + return sorted.map((annotation) => ( (({ containerHeight={overlayRef.current?.clientHeight || 600} onPositionChange={(id, position) => onAnnotationPositionChange?.(id, position)} onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)} - onClick={(id) => onSelectAnnotation?.(id)} + onClick={handleAnnotationClick} + zIndex={annotation.zIndex} + isSelectedBoost={annotation.id === selectedAnnotationId} /> )); })()} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 07760e6..568388c 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -543,6 +543,10 @@ export default function TimelineEditor({ const isTrimItem = trimRegions.some(r => r.id === excludeId); const isAnnotationItem = annotationRegions.some(r => r.id === excludeId); + if (isAnnotationItem) { + return false; + } + // Helper to check overlap against a specific set of regions const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => { return regions.some((region) => { @@ -564,9 +568,6 @@ export default function TimelineEditor({ return checkOverlap(trimRegions); } - if (isAnnotationItem) { - return checkOverlap(annotationRegions); - } return false; }, [zoomRegions, trimRegions, annotationRegions]); @@ -640,22 +641,12 @@ export default function TimelineEditor({ return; } + // Multiple annotations can exist at the same timestamp const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - const sorted = [...annotationRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find(region => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place annotation here", { - description: "Annotation already exists at this location or not enough space available.", - }); - return; - } - - const actualDuration = Math.min(1000, gapToNext); - onAnnotationAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, annotationRegions, onAnnotationAdded]); + const endPos = Math.min(startPos + defaultDuration, totalMs); + + onAnnotationAdded({ start: startPos, end: endPos }); + }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -675,6 +666,30 @@ export default function TimelineEditor({ if (e.key === 'a' || e.key === 'A') { handleAddAnnotation(); } + + // Tab: Cycle through overlapping annotations at current time + if (e.key === 'Tab' && annotationRegions.length > 0) { + const currentTimeMs = Math.round(currentTime * 1000); + const overlapping = annotationRegions + .filter(a => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs) + .sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index + + if (overlapping.length > 0) { + e.preventDefault(); + + if (!selectedAnnotationId || !overlapping.some(a => a.id === selectedAnnotationId)) { + onSelectAnnotation?.(overlapping[0].id); + } else { + // Cycle to next annotation + const currentIndex = overlapping.findIndex(a => a.id === selectedAnnotationId); + const nextIndex = e.shiftKey + ? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward + : (currentIndex + 1) % overlapping.length; // Tab = forward + onSelectAnnotation?.(overlapping[nextIndex].id); + } + } + } + if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) { if (selectedKeyframeId) { deleteSelectedKeyframe(); @@ -689,7 +704,7 @@ export default function TimelineEditor({ }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId]); + }, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation]); const clampedRange = useMemo(() => { if (totalMs === 0) { @@ -720,13 +735,27 @@ export default function TimelineEditor({ variant: 'trim', })); - const annotations: TimelineRenderItem[] = annotationRegions.map((region, index) => ({ - id: region.id, - rowId: ANNOTATION_ROW_ID, - span: { start: region.startMs, end: region.endMs }, - label: `Note ${index + 1}`, - variant: 'annotation', - })); + const annotations: TimelineRenderItem[] = annotationRegions.map((region) => { + let label: string; + + if (region.type === 'text') { + // Show text preview + const preview = region.content.trim() || 'Empty text'; + label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview; + } else if (region.type === 'image') { + label = '🖼️ Image'; + } else { + label = 'Annotation'; + } + + return { + id: region.id, + rowId: ANNOTATION_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label, + variant: 'annotation', + }; + }); return [...zooms, ...trims, ...annotations]; }, [zoomRegions, trimRegions, annotationRegions]); diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 3054d32..d1868b1 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -53,6 +53,7 @@ export interface AnnotationRegion { position: AnnotationPosition; size: AnnotationSize; style: AnnotationTextStyle; + zIndex: number; } export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {