From fd324f4ae6eb6620150a6e7ae7c300d3a7a0edfe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 21:47:24 +0000 Subject: [PATCH 1/4] Pause transcript auto-scroll when the user scrolls manually Auto-follow still keeps the active word in view during playback, but wheel/touch and the custom scroll rail now unfollow so manual browsing is not overridden. A Follow playhead control (and clicking a word) resumes sync. Co-authored-by: Wassim Gharbi --- components/TranscriptPanel.tsx | 64 ++++++++++++++++++++++-- components/TranscriptScrollIndicator.tsx | 6 ++- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index e9bcbdb..5f7deb4 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -5,6 +5,7 @@ import { ArrowUpFromLine, Eye, EyeOff, + LocateFixed, Merge, Pencil, RotateCcw, @@ -143,6 +144,9 @@ export default function TranscriptPanel() { const containerRef = useRef(null); const scrollRef = useRef(null); const importInputRef = useRef(null); + // Playhead follow: auto-scroll while true; manual scroll turns it off. + const followPlayheadRef = useRef(true); + const [followPlayhead, setFollowPlayhead] = useState(true); const [correcting, setCorrecting] = useState<{ ids: number[]; top: number; @@ -171,6 +175,26 @@ export default function TranscriptPanel() { freezeSelectionRef, }); + const unfollowPlayhead = useCallback(() => { + if (!followPlayheadRef.current) return; + followPlayheadRef.current = false; + setFollowPlayhead(false); + }, []); + + const resumeFollowPlayhead = useCallback(() => { + followPlayheadRef.current = true; + setFollowPlayhead(true); + }, []); + + // Clicking a word seeks — resume following so playback stays in view. + const onWordClick = useCallback( + (word: Word, el: HTMLElement) => { + resumeFollowPlayhead(); + handleWordClick(word, el); + }, + [handleWordClick, resumeFollowPlayhead] + ); + const turns = useMemo(() => groupWordsBySpeaker(words), [words]); const deletedCount = useMemo(() => cutOutIds.size, [cutOutIds]); @@ -316,12 +340,27 @@ export default function TranscriptPanel() { return () => document.removeEventListener("keydown", handler); }, [selection, assigningSpeaker, correcting, openSpeakerAssign]); - // Keep the active word in view during playback. + // User scroll gestures pause playhead follow (not programmatic scrollIntoView). + // Native scrollbar is hidden — wheel/touch cover trackpads; the custom rail + // reports via onUserScroll. Avoid pointerdown here so word clicks don't unfollow. + useEffect(() => { + const scroller = scrollRef.current; + if (!scroller) return; + const onIntent = () => unfollowPlayhead(); + scroller.addEventListener("wheel", onIntent, { passive: true }); + scroller.addEventListener("touchmove", onIntent, { passive: true }); + return () => { + scroller.removeEventListener("wheel", onIntent); + scroller.removeEventListener("touchmove", onIntent); + }; + }, [unfollowPlayhead]); + + // Keep the active word in view during playback while following. useEffect(() => { - if (!playing || activeWordId < 0) return; + if (!playing || !followPlayhead || activeWordId < 0) return; const el = containerRef.current?.querySelector(`[data-wid="${activeWordId}"]`); el?.scrollIntoView({ block: "nearest" }); - }, [activeWordId, playing]); + }, [activeWordId, playing, followPlayhead]); const busy = status === "preparing" || status === "transcribing"; @@ -470,7 +509,7 @@ export default function TranscriptPanel() { word={w} cutOut={cutOutIds.has(w.id)} active={w.id === activeWordId} - onClick={handleWordClick} + onClick={onWordClick} /> ); @@ -574,7 +613,22 @@ export default function TranscriptPanel() { {/* Gradient overlay — must match the transcript panel surface */}
- + {!followPlayhead && playing && activeWordId >= 0 && ( + + )} + ); } diff --git a/components/TranscriptScrollIndicator.tsx b/components/TranscriptScrollIndicator.tsx index fcf2c03..b9ba89e 100644 --- a/components/TranscriptScrollIndicator.tsx +++ b/components/TranscriptScrollIndicator.tsx @@ -184,9 +184,12 @@ function topAt(anchors: Anchor[], time: number): number { export default function TranscriptScrollIndicator({ scrollRef, contentRef, + onUserScroll, }: { scrollRef: RefObject; contentRef: RefObject; + /** Fired when the user drags or clicks the rail (not programmatic scroll). */ + onUserScroll?: () => void; }) { const words = useEditorStore((s) => s.words); const starts = useMemo(() => { @@ -371,6 +374,7 @@ export default function TranscriptScrollIndicator({ const thumb = thumbRef.current; if (!scroller || !rail || !thumb) return; e.preventDefault(); + onUserScroll?.(); const railRect = rail.getBoundingClientRect(); const thumbRect = thumb.getBoundingClientRect(); @@ -403,7 +407,7 @@ export default function TranscriptScrollIndicator({ window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); }, - [scrollRef, bump] + [scrollRef, bump, onUserScroll] ); return ( From 343f7894e115cb5e9f4b00f0e38f3d7877dfb1fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:30:49 +0000 Subject: [PATCH 2/4] Point Follow playhead icon up or down toward the active word Use an arrow that matches whether the playhead is above or below the current transcript viewport so the control cues the jump direction. Co-authored-by: Wassim Gharbi --- components/TranscriptPanel.tsx | 38 +++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index 5f7deb4..fb8ba62 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -2,10 +2,11 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + ArrowDown, + ArrowUp, ArrowUpFromLine, Eye, EyeOff, - LocateFixed, Merge, Pencil, RotateCcw, @@ -147,6 +148,8 @@ export default function TranscriptPanel() { // Playhead follow: auto-scroll while true; manual scroll turns it off. const followPlayheadRef = useRef(true); const [followPlayhead, setFollowPlayhead] = useState(true); + /** Which way to jump to the playhead when unfollowed. */ + const [followDirection, setFollowDirection] = useState<"up" | "down">("down"); const [correcting, setCorrecting] = useState<{ ids: number[]; top: number; @@ -175,11 +178,29 @@ export default function TranscriptPanel() { freezeSelectionRef, }); + const updateFollowDirection = useCallback(() => { + const scroller = scrollRef.current; + if (!scroller) return; + const { words, currentTime } = useEditorStore.getState(); + const activeId = findActiveWordId(words, currentTime); + if (activeId < 0) return; + const el = containerRef.current?.querySelector(`[data-wid="${activeId}"]`); + if (!el) return; + const wordRect = el.getBoundingClientRect(); + const scrollerRect = scroller.getBoundingClientRect(); + // Header overlays the top of the scroller (pt-10 / scroll-pt-10). + const viewTop = scrollerRect.top + 40; + const viewBottom = scrollerRect.bottom; + if (wordRect.bottom < viewTop) setFollowDirection("up"); + else if (wordRect.top > viewBottom) setFollowDirection("down"); + }, []); + const unfollowPlayhead = useCallback(() => { if (!followPlayheadRef.current) return; followPlayheadRef.current = false; setFollowPlayhead(false); - }, []); + updateFollowDirection(); + }, [updateFollowDirection]); const resumeFollowPlayhead = useCallback(() => { followPlayheadRef.current = true; @@ -362,6 +383,17 @@ export default function TranscriptPanel() { el?.scrollIntoView({ block: "nearest" }); }, [activeWordId, playing, followPlayhead]); + // Keep the Follow button arrow pointed toward the active word. + useEffect(() => { + if (followPlayhead || !playing || activeWordId < 0) return; + updateFollowDirection(); + const scroller = scrollRef.current; + if (!scroller) return; + const onScroll = () => updateFollowDirection(); + scroller.addEventListener("scroll", onScroll, { passive: true }); + return () => scroller.removeEventListener("scroll", onScroll); + }, [followPlayhead, playing, activeWordId, updateFollowDirection]); + const busy = status === "preparing" || status === "transcribing"; return ( @@ -620,7 +652,7 @@ export default function TranscriptPanel() { title="Scroll with the playhead" className="absolute bottom-5 left-1/2 z-20 flex -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full border border-zinc-200 bg-white/95 px-3 py-1.5 text-xs font-medium text-zinc-700 shadow-md shadow-zinc-900/10 backdrop-blur-sm transition hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/95 dark:text-zinc-200 dark:shadow-black/30 dark:hover:bg-zinc-700" > - + {followDirection === "up" ? : } Follow playhead )} From 8c8ff241b26f558fdcd09bc1fd956c1ab96e0eb1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:37:19 +0000 Subject: [PATCH 3/4] Show Follow playhead only when the active word is offscreen Wheel over a non-scrollable transcript no longer toggles follow UI. Unfollow waits until a user scroll actually leaves the playhead, and the chip only appears while the active word is out of view. Co-authored-by: Wassim Gharbi --- components/TranscriptPanel.tsx | 105 ++++++++++++++++------- components/TranscriptScrollIndicator.tsx | 2 +- 2 files changed, 75 insertions(+), 32 deletions(-) diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index fb8ba62..aeff243 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -147,7 +147,11 @@ export default function TranscriptPanel() { const importInputRef = useRef(null); // Playhead follow: auto-scroll while true; manual scroll turns it off. const followPlayheadRef = useRef(true); + /** Set by wheel/touch/rail before the matching scroll event. */ + const userScrollGestureRef = useRef(false); const [followPlayhead, setFollowPlayhead] = useState(true); + /** Playhead outside the readable viewport — gates the Follow control. */ + const [playheadOffscreen, setPlayheadOffscreen] = useState(false); /** Which way to jump to the playhead when unfollowed. */ const [followDirection, setFollowDirection] = useState<"up" | "down">("down"); const [correcting, setCorrecting] = useState<{ @@ -178,33 +182,57 @@ export default function TranscriptPanel() { freezeSelectionRef, }); - const updateFollowDirection = useCallback(() => { + /** Measure whether the active word sits above/below the readable pane. */ + const measurePlayheadAnchor = useCallback(() => { const scroller = scrollRef.current; - if (!scroller) return; + if (!scroller) return { offscreen: false as const }; const { words, currentTime } = useEditorStore.getState(); const activeId = findActiveWordId(words, currentTime); - if (activeId < 0) return; + if (activeId < 0) { + setPlayheadOffscreen(false); + return { offscreen: false as const }; + } const el = containerRef.current?.querySelector(`[data-wid="${activeId}"]`); - if (!el) return; + if (!el) { + setPlayheadOffscreen(false); + return { offscreen: false as const }; + } const wordRect = el.getBoundingClientRect(); const scrollerRect = scroller.getBoundingClientRect(); // Header overlays the top of the scroller (pt-10 / scroll-pt-10). const viewTop = scrollerRect.top + 40; const viewBottom = scrollerRect.bottom; - if (wordRect.bottom < viewTop) setFollowDirection("up"); - else if (wordRect.top > viewBottom) setFollowDirection("down"); + if (wordRect.bottom < viewTop) { + setPlayheadOffscreen(true); + setFollowDirection("up"); + return { offscreen: true as const, direction: "up" as const }; + } + if (wordRect.top > viewBottom) { + setPlayheadOffscreen(true); + setFollowDirection("down"); + return { offscreen: true as const, direction: "down" as const }; + } + setPlayheadOffscreen(false); + return { offscreen: false as const }; }, []); - const unfollowPlayhead = useCallback(() => { - if (!followPlayheadRef.current) return; - followPlayheadRef.current = false; - setFollowPlayhead(false); - updateFollowDirection(); - }, [updateFollowDirection]); + /** Rail / gesture hint — actual unfollow waits until scroll leaves the word. */ + const userScrollGestureTimerRef = useRef(0); + const markUserScrollGesture = useCallback(() => { + const scroller = scrollRef.current; + if (!scroller || scroller.scrollHeight <= scroller.clientHeight + 1) return; + userScrollGestureRef.current = true; + // Keep the gesture armed across momentum / rail-drag scroll bursts. + window.clearTimeout(userScrollGestureTimerRef.current); + userScrollGestureTimerRef.current = window.setTimeout(() => { + userScrollGestureRef.current = false; + }, 150); + }, []); const resumeFollowPlayhead = useCallback(() => { followPlayheadRef.current = true; setFollowPlayhead(true); + setPlayheadOffscreen(false); }, []); // Clicking a word seeks — resume following so playback stays in view. @@ -361,20 +389,33 @@ export default function TranscriptPanel() { return () => document.removeEventListener("keydown", handler); }, [selection, assigningSpeaker, correcting, openSpeakerAssign]); - // User scroll gestures pause playhead follow (not programmatic scrollIntoView). - // Native scrollbar is hidden — wheel/touch cover trackpads; the custom rail - // reports via onUserScroll. Avoid pointerdown here so word clicks don't unfollow. + // User scroll pauses follow only once the active word actually leaves view. + // Wheel over a non-scrollable pane is ignored (no Follow chip). useEffect(() => { const scroller = scrollRef.current; if (!scroller) return; - const onIntent = () => unfollowPlayhead(); - scroller.addEventListener("wheel", onIntent, { passive: true }); - scroller.addEventListener("touchmove", onIntent, { passive: true }); + const onGesture = () => markUserScrollGesture(); + const onScroll = () => { + if (!playing) return; + const { offscreen } = measurePlayheadAnchor(); + if (userScrollGestureRef.current && offscreen) { + followPlayheadRef.current = false; + setFollowPlayhead(false); + return; + } + // Scrolled back onto the playhead (or programmatic nearest kept it in view). + if (!offscreen && !followPlayheadRef.current) resumeFollowPlayhead(); + }; + scroller.addEventListener("wheel", onGesture, { passive: true }); + scroller.addEventListener("touchmove", onGesture, { passive: true }); + scroller.addEventListener("scroll", onScroll, { passive: true }); return () => { - scroller.removeEventListener("wheel", onIntent); - scroller.removeEventListener("touchmove", onIntent); + scroller.removeEventListener("wheel", onGesture); + scroller.removeEventListener("touchmove", onGesture); + scroller.removeEventListener("scroll", onScroll); + window.clearTimeout(userScrollGestureTimerRef.current); }; - }, [unfollowPlayhead]); + }, [markUserScrollGesture, measurePlayheadAnchor, resumeFollowPlayhead, playing]); // Keep the active word in view during playback while following. useEffect(() => { @@ -383,16 +424,18 @@ export default function TranscriptPanel() { el?.scrollIntoView({ block: "nearest" }); }, [activeWordId, playing, followPlayhead]); - // Keep the Follow button arrow pointed toward the active word. + // Refresh offscreen/direction as the playhead advances while unfollowed. useEffect(() => { if (followPlayhead || !playing || activeWordId < 0) return; - updateFollowDirection(); - const scroller = scrollRef.current; - if (!scroller) return; - const onScroll = () => updateFollowDirection(); - scroller.addEventListener("scroll", onScroll, { passive: true }); - return () => scroller.removeEventListener("scroll", onScroll); - }, [followPlayhead, playing, activeWordId, updateFollowDirection]); + const { offscreen } = measurePlayheadAnchor(); + if (!offscreen) resumeFollowPlayhead(); + }, [ + followPlayhead, + playing, + activeWordId, + measurePlayheadAnchor, + resumeFollowPlayhead, + ]); const busy = status === "preparing" || status === "transcribing"; @@ -645,7 +688,7 @@ export default function TranscriptPanel() {
{/* Gradient overlay — must match the transcript panel surface */}
- {!followPlayhead && playing && activeWordId >= 0 && ( + {!followPlayhead && playheadOffscreen && playing && activeWordId >= 0 && (
{/* Gradient overlay — must match the transcript panel surface */}
- {!followPlayhead && playheadOffscreen && playing && activeWordId >= 0 && ( + {showFollowControl && (