mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix: improve studio timeline discoverability (#431)
This commit is contained in:
+133
-46
@@ -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<string | null>(null);
|
||||
const [timelineVisible, setTimelineVisible] = useState(true);
|
||||
const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
|
||||
getTimelineEditorHintDismissed,
|
||||
);
|
||||
const dragCounterRef = useRef(0);
|
||||
const previewHotkeyWindowRef = useRef<Window | null>(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 = (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800/40 bg-neutral-950/96">
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
||||
Timeline
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomMode("fit")}
|
||||
className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
|
||||
zoomMode === "fit"
|
||||
? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
|
||||
: "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
|
||||
}`}
|
||||
title="Fit timeline to width"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setZoomMode("manual");
|
||||
setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent));
|
||||
}}
|
||||
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||
title="Zoom out"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
|
||||
{`${displayedTimelineZoomPercent}%`}
|
||||
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
|
||||
{timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && (
|
||||
<div className="px-3 pt-3">
|
||||
<div className="flex items-start justify-between gap-3 rounded-xl border border-studio-accent/20 bg-studio-accent/[0.07] px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold text-neutral-100">Timeline editor</div>
|
||||
<p className="mt-1 text-[11px] leading-5 text-neutral-300">
|
||||
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{" "}
|
||||
<span className="font-mono text-[10px] text-studio-accent">
|
||||
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissTimelineEditorHint}
|
||||
className="flex-shrink-0 rounded-md border border-neutral-700 px-2 py-1 text-[10px] font-medium text-neutral-300 transition-colors hover:border-neutral-500 hover:text-neutral-100"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
||||
Timeline
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomMode("fit")}
|
||||
className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
|
||||
zoomMode === "fit"
|
||||
? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
|
||||
: "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
|
||||
}`}
|
||||
title="Fit timeline to width"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setZoomMode("manual");
|
||||
setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent));
|
||||
}}
|
||||
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||
title="Zoom out"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
|
||||
{`${displayedTimelineZoomPercent}%`}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setZoomMode("manual");
|
||||
setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
|
||||
}}
|
||||
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setZoomMode("manual");
|
||||
setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
|
||||
}}
|
||||
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -948,13 +1031,15 @@ export function StudioApp() {
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTimelineVisible((v) => !v)}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
type="button"
|
||||
onClick={toggleTimelineVisibility}
|
||||
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
||||
timelineVisible
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
|
||||
: "text-neutral-300 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-800"
|
||||
}`}
|
||||
title={timelineVisible ? "Hide timeline" : "Show timeline"}
|
||||
title={getTimelineToggleTitle(timelineVisible)}
|
||||
aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
@@ -969,6 +1054,7 @@ export function StudioApp() {
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="3" y1="5" x2="21" y2="5" />
|
||||
</svg>
|
||||
<span>Timeline</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRightCollapsed((v) => !v)}
|
||||
@@ -1079,6 +1165,7 @@ export function StudioApp() {
|
||||
}}
|
||||
onIframeRef={(iframe) => {
|
||||
previewIframeRef.current = iframe;
|
||||
syncPreviewTimelineHotkey(iframe);
|
||||
consoleErrorsRef.current = [];
|
||||
setConsoleErrors(null);
|
||||
if (!iframe) return;
|
||||
@@ -1143,7 +1230,7 @@ export function StudioApp() {
|
||||
) : undefined
|
||||
}
|
||||
timelineVisible={timelineVisible}
|
||||
onToggleTimeline={() => setTimelineVisible((v) => !v)}
|
||||
onToggleTimeline={toggleTimelineVisibility}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTimeline}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
|
||||
timelineVisible
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
|
||||
: "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
||||
}`}
|
||||
title={timelineVisible ? "Hide timeline" : "Show timeline"}
|
||||
title={getTimelineToggleTitle(Boolean(timelineVisible))}
|
||||
aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
@@ -349,6 +355,10 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="3" y1="5" x2="21" y2="5" />
|
||||
</svg>
|
||||
<span>Timeline</span>
|
||||
<span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
|
||||
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user