diff --git a/frontend/src/components/WaveformTimeline.tsx b/frontend/src/components/WaveformTimeline.tsx index dbacdbc..e90bf44 100644 --- a/frontend/src/components/WaveformTimeline.tsx +++ b/frontend/src/components/WaveformTimeline.tsx @@ -40,6 +40,10 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole const setCurrentTime = useEditorStore((s) => s.setCurrentTime); const addCutRange = useEditorStore((s) => s.addCutRange); const addMuteRange = useEditorStore((s) => s.addMuteRange); + const updateCutRange = useEditorStore((s) => s.updateCutRange); + const updateMuteRange = useEditorStore((s) => s.updateMuteRange); + const removeCutRange = useEditorStore((s) => s.removeCutRange); + const removeMuteRange = useEditorStore((s) => s.removeMuteRange); const audioContextRef = useRef(null); const audioBufferRef = useRef(null); @@ -53,6 +57,10 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole const selectionStartRef = useRef(null); const [selectionStart, setSelectionStart] = useState(null); const [selectionEnd, setSelectionEnd] = useState(null); + const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute', id: string} | null>(null); + const [editingZone, setEditingZone] = useState<{type: 'cut' | 'mute', id: string, edge: 'start' | 'end' | 'move'} | null>(null); + const [hoverCursor, setHoverCursor] = useState('crosshair'); + const editingZoneRef = useRef<{type: 'cut' | 'mute', id: string, edge: 'start' | 'end' | 'move'} | null>(null); useEffect(() => { if (!videoUrl || !videoPath) return; @@ -216,16 +224,50 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole for (const range of cutRanges) { const x1 = (range.start - scroll) * pxPerSec; const x2 = (range.end - scroll) * pxPerSec; - ctx.fillStyle = 'rgba(239, 68, 68, 0.3)'; + const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id; + + ctx.fillStyle = isSelected ? 'rgba(239, 68, 68, 0.5)' : 'rgba(239, 68, 68, 0.3)'; ctx.fillRect(x1, waveTop, x2 - x1, waveH); + + if (isSelected) { + ctx.strokeStyle = '#ef4444'; + ctx.lineWidth = 2; + ctx.strokeRect(x1, waveTop, x2 - x1, waveH); + + // Draw resize handles + ctx.fillStyle = '#ef4444'; + ctx.beginPath(); + ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI); + ctx.fill(); + ctx.beginPath(); + ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI); + ctx.fill(); + } } // Draw mute ranges (blue overlays) for (const range of muteRanges) { const x1 = (range.start - scroll) * pxPerSec; const x2 = (range.end - scroll) * pxPerSec; - ctx.fillStyle = 'rgba(59, 130, 246, 0.3)'; + const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id; + + ctx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.3)'; ctx.fillRect(x1, waveTop, x2 - x1, waveH); + + if (isSelected) { + ctx.strokeStyle = '#3b82f6'; + ctx.lineWidth = 2; + ctx.strokeRect(x1, waveTop, x2 - x1, waveH); + + // Draw resize handles + ctx.fillStyle = '#3b82f6'; + ctx.beginPath(); + ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI); + ctx.fill(); + ctx.beginPath(); + ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI); + ctx.fill(); + } } // Draw selection overlay (when in cut/mute mode) @@ -264,7 +306,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole ctx.lineTo(x, mid + max * amp); } ctx.stroke(); - }, [deletedRanges, cutRanges, muteRanges, selectionStart, selectionEnd, cutMode, muteMode]); + }, [deletedRanges, cutRanges, muteRanges, selectionStart, selectionEnd, cutMode, muteMode, selectedZone]); // Keep the ref in sync with the latest drawStaticWaveform closure useEffect(() => { @@ -393,10 +435,199 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole return Math.max(0, Math.min(buffer.duration, scrollSecsRef.current + x / pxPerSec)); }, []); + const getZoneAtPosition = useCallback((clientX: number, clientY: number, forHover: boolean = false) => { + const buffer = audioBufferRef.current; + const canvas = waveCanvasRef.current; + if (!canvas || !buffer) return null; + + const rect = canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + const pxPerSec = (rect.width * zoomRef.current) / buffer.duration; + const scroll = scrollSecsRef.current; + const waveTop = RULER_H + 1; + const waveH = canvas.height - waveTop; + + // Check if click is in waveform area + if (y < waveTop || y > waveTop + waveH) return null; + + const clickTime = scroll + x / pxPerSec; + const handleSize = forHover ? 6 : 8; // Smaller hit area for hover, larger for click + + // Check cut ranges + for (const range of cutRanges) { + const rangeX1 = (range.start - scroll) * pxPerSec; + const rangeX2 = (range.end - scroll) * pxPerSec; + const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id; + + if (forHover && isSelected) { + // For hover on selected zones, check edges + if (Math.abs(x - rangeX1) <= handleSize) { + return { type: 'cut' as const, id: range.id, edge: 'start' as const }; + } + if (Math.abs(x - rangeX2) <= handleSize) { + return { type: 'cut' as const, id: range.id, edge: 'end' as const }; + } + } else if (!forHover) { + // For click detection, check handles and body + if (isSelected) { + // For selected zones, allow clicking on edges for resizing + if (Math.abs(x - rangeX1) <= handleSize) { + return { type: 'cut' as const, id: range.id, edge: 'start' as const }; + } + if (Math.abs(x - rangeX2) <= handleSize) { + return { type: 'cut' as const, id: range.id, edge: 'end' as const }; + } + } else { + // For unselected zones, check small handle circles + // Check start handle + if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { + return { type: 'cut' as const, id: range.id, edge: 'start' as const }; + } + // Check end handle + if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { + return { type: 'cut' as const, id: range.id, edge: 'end' as const }; + } + } + // Check range body + if (x >= rangeX1 && x <= rangeX2) { + return { type: 'cut' as const, id: range.id, edge: 'move' as const }; + } + } + } + + // Check mute ranges + for (const range of muteRanges) { + const rangeX1 = (range.start - scroll) * pxPerSec; + const rangeX2 = (range.end - scroll) * pxPerSec; + const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id; + + if (forHover && isSelected) { + // For hover on selected zones, check edges + if (Math.abs(x - rangeX1) <= handleSize) { + return { type: 'mute' as const, id: range.id, edge: 'start' as const }; + } + if (Math.abs(x - rangeX2) <= handleSize) { + return { type: 'mute' as const, id: range.id, edge: 'end' as const }; + } + } else if (!forHover) { + // For click detection, check handles and body + if (isSelected) { + // For selected zones, allow clicking on edges for resizing + if (Math.abs(x - rangeX1) <= handleSize) { + return { type: 'mute' as const, id: range.id, edge: 'start' as const }; + } + if (Math.abs(x - rangeX2) <= handleSize) { + return { type: 'mute' as const, id: range.id, edge: 'end' as const }; + } + } else { + // For unselected zones, check small handle circles + // Check start handle + if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { + return { type: 'mute' as const, id: range.id, edge: 'start' as const }; + } + // Check end handle + if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) { + return { type: 'mute' as const, id: range.id, edge: 'end' as const }; + } + } + // Check range body + if (x >= rangeX1 && x <= rangeX2) { + return { type: 'mute' as const, id: range.id, edge: 'move' as const }; + } + } + } + + return null; + }, [cutRanges, muteRanges, selectedZone]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (isDragging) return; // Don't change cursor while dragging + + const zoneHit = getZoneAtPosition(e.clientX, e.clientY, true); + if (zoneHit && selectedZone && zoneHit.id === selectedZone.id) { + if (zoneHit.edge === 'start' || zoneHit.edge === 'end') { + setHoverCursor('ew-resize'); + return; + } + } + setHoverCursor('crosshair'); + }, [isDragging, getZoneAtPosition, selectedZone]); + const handleMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); + // Check if clicking on a zone + const zoneHit = getZoneAtPosition(e.clientX, e.clientY); + if (zoneHit) { + if (zoneHit.edge === 'move') { + setSelectedZone({ type: zoneHit.type, id: zoneHit.id }); + } else { + setSelectedZone({ type: zoneHit.type, id: zoneHit.id }); + setEditingZone(zoneHit); + editingZoneRef.current = zoneHit; + } + isDraggingRef.current = true; + setIsDragging(true); + + const startTime = clientXToTime(e.clientX); + const originalRange = zoneHit.type === 'cut' + ? cutRanges.find(r => r.id === zoneHit.id) + : muteRanges.find(r => r.id === zoneHit.id); + + if (!originalRange) return; + + const onMove = (ev: MouseEvent) => { + if (!isDraggingRef.current || !editingZoneRef.current) return; + const currentTime = clientXToTime(ev.clientX); + const delta = currentTime - startTime; + const minZoneDuration = 0.05; + + let newStart = originalRange.start; + let newEnd = originalRange.end; + + if (editingZoneRef.current.edge === 'start') { + newStart = Math.max(0, Math.min(originalRange.end - minZoneDuration, originalRange.start + delta)); + newEnd = originalRange.end; // Keep end fixed when dragging start + } else if (editingZoneRef.current.edge === 'end') { + newStart = originalRange.start; // Keep start fixed when dragging end + newEnd = Math.min(duration, Math.max(newStart + minZoneDuration, originalRange.end + delta)); + } else if (editingZoneRef.current.edge === 'move') { + const zoneDuration = originalRange.end - originalRange.start; + const maxStart = Math.max(0, duration - zoneDuration); + newStart = Math.max(0, Math.min(maxStart, originalRange.start + delta)); + newEnd = newStart + zoneDuration; + } + + // Ensure valid range + if (newStart < newEnd) { + if (editingZoneRef.current.type === 'cut') { + updateCutRange(editingZoneRef.current.id, newStart, newEnd); + } else { + updateMuteRange(editingZoneRef.current.id, newStart, newEnd); + } + } + }; + + const onUp = () => { + isDraggingRef.current = false; + setIsDragging(false); + setEditingZone(null); + editingZoneRef.current = null; + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return; + } + + // Clear selection if clicking elsewhere + setSelectedZone(null); + setEditingZone(null); + if (cutMode || muteMode) { // Range selection mode const startTime = clientXToTime(e.clientX); @@ -458,9 +689,35 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole window.addEventListener('mouseup', onUp); } }, - [cutMode, muteMode, clientXToTime, seekToClientX, addCutRange, addMuteRange, selectionEnd], + [cutMode, muteMode, clientXToTime, seekToClientX, addCutRange, addMuteRange, selectionEnd, getZoneAtPosition], ); + // Handle keyboard shortcuts for zone editing + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setSelectedZone(null); + setEditingZone(null); + editingZoneRef.current = null; + } else if (e.key === 'Delete' || e.key === 'Backspace') { + if (selectedZone) { + e.preventDefault(); + if (selectedZone.type === 'cut') { + removeCutRange(selectedZone.id); + } else { + removeMuteRange(selectedZone.id); + } + setSelectedZone(null); + setEditingZone(null); + editingZoneRef.current = null; + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedZone, removeCutRange, removeMuteRange]); + if (!videoUrl) { return (
@@ -489,8 +746,10 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
diff --git a/frontend/src/store/editorStore.ts b/frontend/src/store/editorStore.ts index d1d8043..08e493d 100644 --- a/frontend/src/store/editorStore.ts +++ b/frontend/src/store/editorStore.ts @@ -45,6 +45,8 @@ interface EditorActions { restoreRange: (rangeId: string) => void; addCutRange: (start: number, end: number) => void; addMuteRange: (start: number, end: number) => void; + updateCutRange: (id: string, start: number, end: number) => void; + updateMuteRange: (id: string, start: number, end: number) => void; removeCutRange: (id: string) => void; removeMuteRange: (id: string) => void; setTranscribing: (active: boolean, progress?: number, status?: string) => void; @@ -204,6 +206,24 @@ export const useEditorStore = create()( set({ muteRanges: [...muteRanges, newRange] }); }, + updateCutRange: (id, start, end) => { + const { cutRanges } = get(); + set({ + cutRanges: cutRanges.map((r) => + r.id === id ? { ...r, start, end } : r + ), + }); + }, + + updateMuteRange: (id, start, end) => { + const { muteRanges } = get(); + set({ + muteRanges: muteRanges.map((r) => + r.id === id ? { ...r, start, end } : r + ), + }); + }, + removeCutRange: (id) => { const { cutRanges } = get(); set({ cutRanges: cutRanges.filter((r) => r.id !== id) });