diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 9fddb8713..2c0af7ee2 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -6,6 +6,7 @@ import { resolveTimelineAssetDrop, getTimelinePlayheadLeft, getTimelineScrollLeftForZoomTransition, + shouldShowTimelineShortcutHint, shouldHandleTimelineDeleteKey, shouldAutoScrollTimeline, } from "./Timeline"; @@ -165,6 +166,17 @@ describe("getTimelineCanvasHeight", () => { }); }); +describe("shouldShowTimelineShortcutHint", () => { + it("shows the hint when the timeline does not vertically overflow", () => { + expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true); + expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true); + }); + + it("hides the hint when timeline tracks need vertical scrolling", () => { + expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false); + }); +}); + describe("shouldHandleTimelineDeleteKey", () => { it("handles Delete and Backspace when focus is not in an editor", () => { expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index d016c19f0..cedce885d 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -35,7 +35,7 @@ const TRACK_H = 72; const RULER_H = 24; const CLIP_Y = 3; // vertical inset inside track const CLIP_HANDLE_W = 18; -const TIMELINE_SCROLL_BUFFER = 24; +const TIMELINE_SCROLL_BUFFER = 20; interface TrackVisualStyle extends TimelineTrackStyle { icon: ReactNode; @@ -140,6 +140,14 @@ export function getTimelineCanvasHeight(trackCount: number): number { return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER; } +export function shouldShowTimelineShortcutHint( + scrollHeight: number, + clientHeight: number, +): boolean { + if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true; + return scrollHeight - clientHeight <= 1; +} + export function shouldHandleTimelineDeleteKey(input: { key: string; metaKey?: boolean; @@ -348,30 +356,51 @@ export const Timeline = memo(function Timeline({ onDeleteElementRef.current = onDeleteElement; const suppressClickRef = useRef(false); const [showPopover, setShowPopover] = useState(false); + const [showShortcutHint, setShowShortcutHint] = useState(true); const [viewportWidth, setViewportWidth] = useState(0); const roRef = useRef(null); + const shortcutHintRafRef = useRef(0); + const syncShortcutHintVisibility = useCallback(() => { + const scroll = scrollRef.current; + setShowShortcutHint( + scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true, + ); + }, []); + const scheduleShortcutHintVisibilitySync = useCallback(() => { + if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current); + shortcutHintRafRef.current = requestAnimationFrame(() => { + shortcutHintRafRef.current = 0; + syncShortcutHintVisibility(); + }); + }, [syncShortcutHintVisibility]); // Callback ref: sets up ResizeObserver when the DOM element actually mounts. // useMountEffect can't work here because the component returns null on first // render (timelineReady=false), so containerRef.current is null when the // effect fires and the ResizeObserver is never created. - const setContainerRef = useCallback((el: HTMLDivElement | null) => { - if (roRef.current) { - roRef.current.disconnect(); - roRef.current = null; - } - containerRef.current = el; - if (!el) return; - setViewportWidth(el.clientWidth); - roRef.current = new ResizeObserver(([entry]) => { - setViewportWidth(entry.contentRect.width); - }); - roRef.current.observe(el); - }, []); + const setContainerRef = useCallback( + (el: HTMLDivElement | null) => { + if (roRef.current) { + roRef.current.disconnect(); + roRef.current = null; + } + containerRef.current = el; + if (!el) return; + setViewportWidth(el.clientWidth); + scheduleShortcutHintVisibilitySync(); + roRef.current = new ResizeObserver(([entry]) => { + setViewportWidth(entry.contentRect.width); + scheduleShortcutHintVisibilitySync(); + }); + roRef.current.observe(el); + }, + [scheduleShortcutHintVisibilitySync], + ); // Clean up ResizeObserver on unmount useMountEffect(() => () => { roRef.current?.disconnect(); + if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current); }); // Effective duration: max of store duration and the furthest element end. @@ -416,6 +445,7 @@ export const Timeline = memo(function Timeline({ } return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b); }, [draggedClip, trackOrder]); + const totalH = getTimelineCanvasHeight(displayTrackOrder.length); const selectedElement = useMemo( () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null, [elements, selectedElementId], @@ -923,6 +953,10 @@ export const Timeline = memo(function Timeline({ }, []); const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]); + useEffect(() => { + syncShortcutHintVisibility(); + }, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]); + const getPreviewElement = useCallback( (element: TimelineElement): TimelineElement => { if (resizingClip?.element.id === element.id) { @@ -1096,7 +1130,6 @@ export const Timeline = memo(function Timeline({ ); } - const totalH = getTimelineCanvasHeight(displayTrackOrder.length); const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = draggedClip?.started === true && draggedElement @@ -1170,7 +1203,7 @@ export const Timeline = memo(function Timeline({
- {/* Keyboard shortcut hint — always visible */} - {!showPopover && !rangeSelection && ( + {/* Keyboard shortcut hint */} + {showShortcutHint && !showPopover && !rangeSelection && (