From ad8abb0bb92730ee2c52dc3ac69c04a2ae40629f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 23:06:37 -0400 Subject: [PATCH] fix: improve studio timeline discoverability --- packages/studio/src/App.tsx | 179 +++++++++++++----- .../src/player/components/PlayerControls.tsx | 16 +- .../src/utils/timelineDiscovery.test.ts | 90 +++++++++ .../studio/src/utils/timelineDiscovery.ts | 57 ++++++ 4 files changed, 293 insertions(+), 49 deletions(-) create mode 100644 packages/studio/src/utils/timelineDiscovery.test.ts create mode 100644 packages/studio/src/utils/timelineDiscovery.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index e3b036b45..2a3dc9174 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -27,6 +27,13 @@ import { getNextTimelineZoomPercent, getTimelineZoomPercent, } from "./player/components/timelineZoom"; +import { + TIMELINE_TOGGLE_SHORTCUT_LABEL, + getTimelineEditorHintDismissed, + getTimelineToggleTitle, + setTimelineEditorHintDismissed, + shouldHandleTimelineToggleHotkey, +} from "./utils/timelineDiscovery"; interface EditingFile { path: string; @@ -196,7 +203,11 @@ export function StudioApp() { const [globalDragOver, setGlobalDragOver] = useState(false); const [uploadToast, setUploadToast] = useState(null); const [timelineVisible, setTimelineVisible] = useState(true); + const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState( + getTimelineEditorHintDismissed, + ); const dragCounterRef = useRef(0); + const previewHotkeyWindowRef = useRef(null); const panelDragRef = useRef<{ side: "left" | "right"; startX: number; @@ -224,6 +235,51 @@ export function StudioApp() { () => getTimelineZoomPercent(zoomMode, manualZoomPercent), [zoomMode, manualZoomPercent], ); + const toggleTimelineVisibility = useCallback(() => { + setTimelineVisible((visible) => !visible); + }, []); + const dismissTimelineEditorHint = useCallback(() => { + setTimelineEditorHintState(true); + setTimelineEditorHintDismissed(true); + }, []); + const handleTimelineToggleHotkey = useCallback( + (event: KeyboardEvent) => { + if (!shouldHandleTimelineToggleHotkey(event)) return; + event.preventDefault(); + toggleTimelineVisibility(); + }, + [toggleTimelineVisibility], + ); + + useMountEffect(() => { + window.addEventListener("keydown", handleTimelineToggleHotkey); + return () => { + window.removeEventListener("keydown", handleTimelineToggleHotkey); + }; + }); + + const syncPreviewTimelineHotkey = useCallback( + (iframe: HTMLIFrameElement | null) => { + const nextWindow = iframe?.contentWindow ?? null; + if (previewHotkeyWindowRef.current === nextWindow) return; + if (previewHotkeyWindowRef.current) { + previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey); + } + previewHotkeyWindowRef.current = nextWindow; + nextWindow?.addEventListener("keydown", handleTimelineToggleHotkey); + }, + [handleTimelineToggleHotkey], + ); + + useEffect( + () => () => { + if (previewHotkeyWindowRef.current) { + previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey); + previewHotkeyWindowRef.current = null; + } + }, + [handleTimelineToggleHotkey], + ); const renderClipContent = useCallback( (el: TimelineElement, style: { clip: string; label: string }): ReactNode => { @@ -323,48 +379,75 @@ export function StudioApp() { [compIdToSrc, activePreviewUrl, effectiveTimelineDuration], ); const timelineToolbar = ( -
-
- Timeline -
-
- - -
- {`${displayedTimelineZoomPercent}%`} +
+ {timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && ( +
+
+
+
Timeline editor
+

+ Drag clips to move timing, and drag clip edges to resize them when handles are + available. Hide the panel anytime and bring it back with{" "} + + {TIMELINE_TOGGLE_SHORTCUT_LABEL} + + . +

+
+ +
+
+ )} + +
+
+ Timeline +
+
+ + +
+ {`${displayedTimelineZoomPercent}%`} +
+
-
); @@ -948,13 +1031,15 @@ export function StudioApp() {
diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index 909da925d..911b84f5a 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -1,5 +1,9 @@ import { useRef, useState, useCallback, useEffect, memo } from "react"; import { useMountEffect } from "../../hooks/useMountEffect"; +import { + TIMELINE_TOGGLE_SHORTCUT_LABEL, + getTimelineToggleTitle, +} from "../../utils/timelineDiscovery"; import { formatTime } from "../lib/time"; import { usePlayerStore, liveTime } from "../store/playerStore"; @@ -328,13 +332,15 @@ export const PlayerControls = memo(function PlayerControls({ {/* Timeline toggle */} {onToggleTimeline !== undefined && ( )}
diff --git a/packages/studio/src/utils/timelineDiscovery.test.ts b/packages/studio/src/utils/timelineDiscovery.test.ts new file mode 100644 index 000000000..5174cb1d0 --- /dev/null +++ b/packages/studio/src/utils/timelineDiscovery.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + TIMELINE_TOGGLE_SHORTCUT_LABEL, + getTimelineToggleTitle, + shouldHandleTimelineToggleHotkey, +} from "./timelineDiscovery"; + +describe("shouldHandleTimelineToggleHotkey", () => { + it("accepts Shift+T when focus is not inside an editor", () => { + expect( + shouldHandleTimelineToggleHotkey({ + key: "T", + shiftKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + target: { + tagName: "DIV", + isContentEditable: false, + closest: () => null, + }, + } as KeyboardEvent), + ).toBe(true); + }); + + it("ignores the shortcut inside text inputs", () => { + expect( + shouldHandleTimelineToggleHotkey({ + key: "t", + shiftKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + target: { + tagName: "TEXTAREA", + isContentEditable: false, + closest: () => null, + }, + } as KeyboardEvent), + ).toBe(false); + }); + + it("ignores the shortcut inside contenteditable editors", () => { + expect( + shouldHandleTimelineToggleHotkey({ + key: "t", + shiftKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + target: { + tagName: "DIV", + isContentEditable: true, + closest: () => null, + }, + } as KeyboardEvent), + ).toBe(false); + }); + + it("requires Shift without other modifiers", () => { + expect( + shouldHandleTimelineToggleHotkey({ + key: "t", + shiftKey: false, + metaKey: false, + ctrlKey: false, + altKey: false, + target: null, + } as KeyboardEvent), + ).toBe(false); + + expect( + shouldHandleTimelineToggleHotkey({ + key: "t", + shiftKey: true, + metaKey: true, + ctrlKey: false, + altKey: false, + target: null, + } as KeyboardEvent), + ).toBe(false); + }); +}); + +describe("getTimelineToggleTitle", () => { + it("includes the shortcut in both show and hide titles", () => { + expect(getTimelineToggleTitle(true)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL); + expect(getTimelineToggleTitle(false)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL); + }); +}); diff --git a/packages/studio/src/utils/timelineDiscovery.ts b/packages/studio/src/utils/timelineDiscovery.ts new file mode 100644 index 000000000..1860e226a --- /dev/null +++ b/packages/studio/src/utils/timelineDiscovery.ts @@ -0,0 +1,57 @@ +export const TIMELINE_TOGGLE_SHORTCUT_LABEL = "Shift+T"; +const TIMELINE_EDITOR_HINT_STORAGE_KEY = "hf-studio-timeline-editor-hint-dismissed"; + +type TimelineToggleHotkeyEvent = Pick< + KeyboardEvent, + "key" | "shiftKey" | "metaKey" | "ctrlKey" | "altKey" | "target" +>; + +interface EditableTargetLike { + tagName?: string; + isContentEditable?: boolean; + closest?: (selector: string) => unknown; + getAttribute?: (name: string) => string | null; +} + +function isEditableTarget(target: EventTarget | null): boolean { + if (!target || typeof target !== "object") return false; + + const element = target as EditableTargetLike; + const tagName = element.tagName?.toLowerCase(); + if (tagName === "input" || tagName === "textarea" || tagName === "select") return true; + if (element.isContentEditable) return true; + + const role = element.getAttribute?.("role"); + if (role === "textbox" || role === "searchbox" || role === "combobox") return true; + + return Boolean( + element.closest?.( + "input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor", + ), + ); +} + +export function shouldHandleTimelineToggleHotkey(event: TimelineToggleHotkeyEvent): boolean { + if (event.metaKey || event.ctrlKey || event.altKey) return false; + if (!event.shiftKey) return false; + if (event.key.toLowerCase() !== "t") return false; + return !isEditableTarget(event.target); +} + +export function getTimelineToggleTitle(timelineVisible: boolean): string { + return `${timelineVisible ? "Hide" : "Show"} timeline editor (${TIMELINE_TOGGLE_SHORTCUT_LABEL})`; +} + +export function getTimelineEditorHintDismissed(): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(TIMELINE_EDITOR_HINT_STORAGE_KEY) === "1"; +} + +export function setTimelineEditorHintDismissed(dismissed: boolean): void { + if (typeof window === "undefined") return; + if (dismissed) { + window.localStorage.setItem(TIMELINE_EDITOR_HINT_STORAGE_KEY, "1"); + return; + } + window.localStorage.removeItem(TIMELINE_EDITOR_HINT_STORAGE_KEY); +}