able to drag edges of zones
This commit is contained in:
@ -40,6 +40,10 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
|
|||||||
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
|
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
|
||||||
const addCutRange = useEditorStore((s) => s.addCutRange);
|
const addCutRange = useEditorStore((s) => s.addCutRange);
|
||||||
const addMuteRange = useEditorStore((s) => s.addMuteRange);
|
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<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
const audioBufferRef = useRef<AudioBuffer | null>(null);
|
const audioBufferRef = useRef<AudioBuffer | null>(null);
|
||||||
@ -53,6 +57,10 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
|
|||||||
const selectionStartRef = useRef<number | null>(null);
|
const selectionStartRef = useRef<number | null>(null);
|
||||||
const [selectionStart, setSelectionStart] = useState<number | null>(null);
|
const [selectionStart, setSelectionStart] = useState<number | null>(null);
|
||||||
const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
|
const [selectionEnd, setSelectionEnd] = useState<number | null>(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<string>('crosshair');
|
||||||
|
const editingZoneRef = useRef<{type: 'cut' | 'mute', id: string, edge: 'start' | 'end' | 'move'} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!videoUrl || !videoPath) return;
|
if (!videoUrl || !videoPath) return;
|
||||||
@ -216,16 +224,50 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
|
|||||||
for (const range of cutRanges) {
|
for (const range of cutRanges) {
|
||||||
const x1 = (range.start - scroll) * pxPerSec;
|
const x1 = (range.start - scroll) * pxPerSec;
|
||||||
const x2 = (range.end - 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);
|
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)
|
// Draw mute ranges (blue overlays)
|
||||||
for (const range of muteRanges) {
|
for (const range of muteRanges) {
|
||||||
const x1 = (range.start - scroll) * pxPerSec;
|
const x1 = (range.start - scroll) * pxPerSec;
|
||||||
const x2 = (range.end - 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);
|
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)
|
// 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.lineTo(x, mid + max * amp);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
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
|
// Keep the ref in sync with the latest drawStaticWaveform closure
|
||||||
useEffect(() => {
|
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));
|
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<HTMLCanvasElement>) => {
|
||||||
|
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(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
e.preventDefault();
|
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) {
|
if (cutMode || muteMode) {
|
||||||
// Range selection mode
|
// Range selection mode
|
||||||
const startTime = clientXToTime(e.clientX);
|
const startTime = clientXToTime(e.clientX);
|
||||||
@ -458,9 +689,35 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
|
|||||||
window.addEventListener('mouseup', onUp);
|
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) {
|
if (!videoUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center text-editor-text-muted text-xs">
|
<div className="w-full h-full flex items-center justify-center text-editor-text-muted text-xs">
|
||||||
@ -489,8 +746,10 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
|
|||||||
<canvas ref={waveCanvasRef} className="absolute inset-0 w-full h-full" />
|
<canvas ref={waveCanvasRef} className="absolute inset-0 w-full h-full" />
|
||||||
<canvas
|
<canvas
|
||||||
ref={headCanvasRef}
|
ref={headCanvasRef}
|
||||||
className={`absolute inset-0 w-full h-full ${isDragging ? 'cursor-grabbing' : 'cursor-crosshair'}`}
|
className="absolute inset-0 w-full h-full"
|
||||||
|
style={{ cursor: isDragging ? 'grabbing' : hoverCursor }}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -45,6 +45,8 @@ interface EditorActions {
|
|||||||
restoreRange: (rangeId: string) => void;
|
restoreRange: (rangeId: string) => void;
|
||||||
addCutRange: (start: number, end: number) => void;
|
addCutRange: (start: number, end: number) => void;
|
||||||
addMuteRange: (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;
|
removeCutRange: (id: string) => void;
|
||||||
removeMuteRange: (id: string) => void;
|
removeMuteRange: (id: string) => void;
|
||||||
setTranscribing: (active: boolean, progress?: number, status?: string) => void;
|
setTranscribing: (active: boolean, progress?: number, status?: string) => void;
|
||||||
@ -204,6 +206,24 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
set({ muteRanges: [...muteRanges, newRange] });
|
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) => {
|
removeCutRange: (id) => {
|
||||||
const { cutRanges } = get();
|
const { cutRanges } = get();
|
||||||
set({ cutRanges: cutRanges.filter((r) => r.id !== id) });
|
set({ cutRanges: cutRanges.filter((r) => r.id !== id) });
|
||||||
|
|||||||
Reference in New Issue
Block a user