Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/studio/src/player/components/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
resolveTimelineAssetDrop,
getTimelinePlayheadLeft,
getTimelineScrollLeftForZoomTransition,
shouldShowTimelineShortcutHint,
shouldHandleTimelineDeleteKey,
shouldAutoScrollTimeline,
} from "./Timeline";
Expand Down Expand Up @@ -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);
Expand Down
69 changes: 51 additions & 18 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<ResizeObserver | null>(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.
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1170,7 +1203,7 @@ export const Timeline = memo(function Timeline({
<div
ref={setContainerRef}
aria-label="Timeline"
className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
style={{
touchAction: "pan-x pan-y",
background: theme.shellBackground,
Expand Down Expand Up @@ -1509,8 +1542,8 @@ export const Timeline = memo(function Timeline({
</div>
</div>

{/* Keyboard shortcut hint — always visible */}
{!showPopover && !rangeSelection && (
{/* Keyboard shortcut hint */}
{showShortcutHint && !showPopover && !rangeSelection && (
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
Expand Down
Loading