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
179 changes: 133 additions & 46 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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>
);
Expand Down Expand Up @@ -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"
Expand All @@ -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)}
Expand Down Expand Up @@ -1079,6 +1165,7 @@ export function StudioApp() {
}}
onIframeRef={(iframe) => {
previewIframeRef.current = iframe;
syncPreviewTimelineHotkey(iframe);
consoleErrorsRef.current = [];
setConsoleErrors(null);
if (!iframe) return;
Expand Down Expand Up @@ -1143,7 +1230,7 @@ export function StudioApp() {
) : undefined
}
timelineVisible={timelineVisible}
onToggleTimeline={() => setTimelineVisible((v) => !v)}
onToggleTimeline={toggleTimelineVisibility}
/>
</div>

Expand Down
16 changes: 13 additions & 3 deletions packages/studio/src/player/components/PlayerControls.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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"
Expand All @@ -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>
Expand Down
90 changes: 90 additions & 0 deletions packages/studio/src/utils/timelineDiscovery.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading