diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index 2799031..29a0dc6 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo, useState } from "react"; import { Download, X } from "lucide-react"; import { useEditorStore } from "@/lib/store"; -import { formatTime, getCutRanges, getEditedDuration, getKeepRanges } from "@/lib/edits"; +import { formatTime, getEditedDuration, getEffectiveCuts, getKeepRanges } from "@/lib/edits"; import { exportAudio, exportVideo } from "@/lib/ffmpeg"; export default function ExportDialog() { @@ -13,6 +13,7 @@ export default function ExportDialog() { const mediaKind = useEditorStore((s) => s.mediaKind); const words = useEditorStore((s) => s.words); const duration = useEditorStore((s) => s.duration); + const cutAdjustments = useEditorStore((s) => s.cutAdjustments); const status = useEditorStore((s) => s.status); const setStatus = useEditorStore((s) => s.setStatus); const exportUrl = useEditorStore((s) => s.exportUrl); @@ -21,7 +22,10 @@ export default function ExportDialog() { const [progress, setProgress] = useState(0); const [error, setError] = useState(null); - const cuts = useMemo(() => getCutRanges(words, duration), [words, duration]); + const cuts = useMemo( + () => getEffectiveCuts(words, duration, cutAdjustments), + [words, duration, cutAdjustments] + ); const editedDuration = useMemo(() => getEditedDuration(cuts, duration), [cuts, duration]); const exporting = status === "exporting"; const isAudio = mediaKind === "audio"; diff --git a/components/MediaPreview.tsx b/components/MediaPreview.tsx index 7ba773a..6dfa10e 100644 --- a/components/MediaPreview.tsx +++ b/components/MediaPreview.tsx @@ -6,8 +6,8 @@ import { useEditorStore } from "@/lib/store"; import { cutRangeAt, formatTime, - getCutRanges, getEditedDuration, + getEffectiveCuts, originalToEdited, } from "@/lib/edits"; @@ -17,6 +17,7 @@ export default function MediaPreview() { const mediaKind = useEditorStore((s) => s.mediaKind); const words = useEditorStore((s) => s.words); const duration = useEditorStore((s) => s.duration); + const cutAdjustments = useEditorStore((s) => s.cutAdjustments); const playing = useEditorStore((s) => s.playing); const currentTime = useEditorStore((s) => s.currentTime); const setVideoEl = useEditorStore((s) => s.setVideoEl); @@ -26,7 +27,10 @@ export default function MediaPreview() { const mediaRef = useRef(null); const isAudio = mediaKind === "audio"; - const cuts = useMemo(() => getCutRanges(words, duration), [words, duration]); + const cuts = useMemo( + () => getEffectiveCuts(words, duration, cutAdjustments), + [words, duration, cutAdjustments] + ); const cutsRef = useRef(cuts); useEffect(() => { cutsRef.current = cuts; diff --git a/components/Timeline.tsx b/components/Timeline.tsx index c6874d5..d0d0eff 100644 --- a/components/Timeline.tsx +++ b/components/Timeline.tsx @@ -1,14 +1,29 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { Maximize2, ZoomIn, ZoomOut } from "lucide-react"; import { useEditorStore } from "@/lib/store"; -import { formatTime, getCutRanges } from "@/lib/edits"; +import { formatTime, getEffectiveCuts } from "@/lib/edits"; const RULER_H = 20; const LABELS_H = 20; const SAMPLE_RATE = 16000; const TICK_STEPS = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; +const MIN_ZOOM = 1; +const MAX_ZOOM = 256; +/** Wheel-zoom sensitivity (higher = faster zoom per scroll tick). */ +const ZOOM_SPEED = 0.0028; +/** Grab width (px) of a cut-edge handle's hit area. */ +const HANDLE_HIT = 14; +/** Smallest cut a drag may leave, so edges never invert. */ +const MIN_CUT = 0.03; export default function Timeline() { const audio = useEditorStore((s) => s.audio); @@ -16,8 +31,12 @@ export default function Timeline() { const duration = useEditorStore((s) => s.duration); const currentTime = useEditorStore((s) => s.currentTime); const playing = useEditorStore((s) => s.playing); + const cutAdjustments = useEditorStore((s) => s.cutAdjustments); - const cuts = useMemo(() => getCutRanges(words, duration), [words, duration]); + const cuts = useMemo( + () => getEffectiveCuts(words, duration, cutAdjustments), + [words, duration, cutAdjustments] + ); const outerRef = useRef(null); const scrollRef = useRef(null); @@ -32,6 +51,23 @@ export default function Timeline() { const pps = fitPps * zoom; const totalWidth = Math.max(width, duration * pps); + // Live mirrors for the imperative wheel/drag handlers (avoid stale closures). + const ppsRef = useRef(pps); + const zoomRef = useRef(zoom); + const widthRef = useRef(width); + const durationRef = useRef(duration); + const cutsRef = useRef(cuts); + useEffect(() => { + ppsRef.current = pps; + zoomRef.current = zoom; + widthRef.current = width; + durationRef.current = duration; + cutsRef.current = cuts; + }); + + // Scroll position to apply after a wheel-zoom re-renders the track width. + const pendingScrollRef = useRef(null); + useEffect(() => { const el = outerRef.current; if (!el) return; @@ -122,6 +158,51 @@ export default function Timeline() { } }, [currentTime, playing, pps, width]); + // Vertical wheel / pinch zooms (anchored at the pointer so the point under + // the cursor stays put); horizontal trackpad side-scroll pans the track by + // letting the browser scroll the overflow-x container natively. Registered + // as a non-passive listener so the zoom branch can preventDefault. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + if (durationRef.current <= 0) return; + // Horizontal intent → pan: don't preventDefault, let native scroll run. + if (!e.ctrlKey && Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; + e.preventDefault(); + const curZoom = zoomRef.current; + const curPps = ppsRef.current; + if (curPps <= 0) return; + const rect = el.getBoundingClientRect(); + const pointerX = e.clientX - rect.left; + const tAnchor = (el.scrollLeft + pointerX) / curPps; + const nextZoom = Math.min( + MAX_ZOOM, + Math.max(MIN_ZOOM, curZoom * Math.exp(-e.deltaY * ZOOM_SPEED)) + ); + if (nextZoom === curZoom) return; + const fit = + widthRef.current > 0 && durationRef.current > 0 + ? widthRef.current / durationRef.current + : 50; + const nextPps = fit * nextZoom; + pendingScrollRef.current = Math.max(0, tAnchor * nextPps - pointerX); + setZoom(nextZoom); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, []); + + // Apply the anchor-preserving scroll once the wheel-zoom has re-rendered the + // (wider/narrower) track. Runs before paint to avoid a visible jump. + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el || pendingScrollRef.current == null) return; + el.scrollLeft = pendingScrollRef.current; + pendingScrollRef.current = null; + setScrollLeft(el.scrollLeft); + }, [zoom]); + const seekFromPointer = useCallback( (clientX: number) => { const el = scrollRef.current; @@ -153,6 +234,74 @@ export default function Timeline() { [seekFromPointer] ); + // --- Cut-edge handle dragging ------------------------------------------ + const dragRef = useRef<{ key: number; edge: "start" | "end"; pushed: boolean } | null>( + null + ); + + const timeFromClientX = useCallback((clientX: number) => { + const el = scrollRef.current; + if (!el || ppsRef.current <= 0) return 0; + const rect = el.getBoundingClientRect(); + return (clientX - rect.left + el.scrollLeft) / ppsRef.current; + }, []); + + const onHandleDown = useCallback( + (e: React.PointerEvent, key: number, edge: "start" | "end") => { + e.stopPropagation(); + e.preventDefault(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + dragRef.current = { key, edge, pushed: false }; + }, + [] + ); + + const onHandleMove = useCallback( + (e: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + e.stopPropagation(); + const list = cutsRef.current; + const idx = list.findIndex((c) => c.key === drag.key); + if (idx === -1) return; + const cut = list[idx]; + const prev = list[idx - 1]; + const next = list[idx + 1]; + let t = timeFromClientX(e.clientX); + if (drag.edge === "start") { + const lo = prev ? prev.end : 0; + const hi = Math.max(lo, cut.end - MIN_CUT); + t = Math.min(Math.max(t, lo), hi); + } else { + const hi = next ? next.start : durationRef.current; + const lo = Math.min(hi, cut.start + MIN_CUT); + t = Math.min(Math.max(t, lo), hi); + } + const { adjustCut, videoEl, setCurrentTime } = useEditorStore.getState(); + adjustCut(drag.key, drag.edge, t, !drag.pushed); + drag.pushed = true; + // Scrub the preview to the edge for precise, audible/visible trimming. + if (videoEl) videoEl.currentTime = t; + setCurrentTime(t); + }, + [timeFromClientX] + ); + + const onHandleUp = useCallback((e: React.PointerEvent) => { + if (!dragRef.current) return; + dragRef.current = null; + const el = e.currentTarget as HTMLElement; + if (el.hasPointerCapture?.(e.pointerId)) el.releasePointerCapture(e.pointerId); + }, []); + + const onHandleReset = useCallback( + (e: React.MouseEvent, key: number) => { + e.stopPropagation(); + useEditorStore.getState().resetCutAdjust(key); + }, + [] + ); + // Word labels for the visible window (only when zoomed in enough to read). const visibleWords = useMemo(() => { if (pps < 18) return []; @@ -161,7 +310,16 @@ export default function Timeline() { return words.filter((w) => w.end >= t0 && w.start <= t1); }, [words, pps, scrollLeft, width]); + // Only mount handles for cuts intersecting the viewport (bounds DOM nodes). + const visibleCuts = useMemo(() => { + if (width === 0) return cuts; + const t0 = scrollLeft / pps - 1; + const t1 = (scrollLeft + width) / pps + 1; + return cuts.filter((c) => c.end >= t0 && c.start <= t1); + }, [cuts, pps, scrollLeft, width]); + const playheadX = currentTime * pps - scrollLeft; + const handleTop = RULER_H; return (