@hyperframes/studio 0.4.14 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>HyperFrames Studio</title>
7
- <script type="module" crossorigin src="/assets/index-Dcn0cnE7.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-DhO5JhHF.css">
7
+ <script type="module" crossorigin src="/assets/index-RzXlAX2g.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-CVm-zeM9.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "codemirror": "^6.0.1",
34
34
  "motion": "^12.38.0",
35
- "@hyperframes/player": "0.4.14",
36
- "@hyperframes/core": "0.4.14"
35
+ "@hyperframes/core": "0.4.15",
36
+ "@hyperframes/player": "0.4.15"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "^19.0.0",
@@ -47,7 +47,7 @@
47
47
  "vite": "^6.4.2",
48
48
  "vitest": "^3.2.4",
49
49
  "zustand": "^5.0.0",
50
- "@hyperframes/producer": "0.4.14"
50
+ "@hyperframes/producer": "0.4.15"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "^18.0.0 || ^19.0.0",
package/src/App.tsx CHANGED
@@ -27,6 +27,13 @@ import {
27
27
  getNextTimelineZoomPercent,
28
28
  getTimelineZoomPercent,
29
29
  } from "./player/components/timelineZoom";
30
+ import {
31
+ TIMELINE_TOGGLE_SHORTCUT_LABEL,
32
+ getTimelineEditorHintDismissed,
33
+ getTimelineToggleTitle,
34
+ setTimelineEditorHintDismissed,
35
+ shouldHandleTimelineToggleHotkey,
36
+ } from "./utils/timelineDiscovery";
30
37
 
31
38
  interface EditingFile {
32
39
  path: string;
@@ -196,7 +203,11 @@ export function StudioApp() {
196
203
  const [globalDragOver, setGlobalDragOver] = useState(false);
197
204
  const [uploadToast, setUploadToast] = useState<string | null>(null);
198
205
  const [timelineVisible, setTimelineVisible] = useState(true);
206
+ const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
207
+ getTimelineEditorHintDismissed,
208
+ );
199
209
  const dragCounterRef = useRef(0);
210
+ const previewHotkeyWindowRef = useRef<Window | null>(null);
200
211
  const panelDragRef = useRef<{
201
212
  side: "left" | "right";
202
213
  startX: number;
@@ -224,6 +235,51 @@ export function StudioApp() {
224
235
  () => getTimelineZoomPercent(zoomMode, manualZoomPercent),
225
236
  [zoomMode, manualZoomPercent],
226
237
  );
238
+ const toggleTimelineVisibility = useCallback(() => {
239
+ setTimelineVisible((visible) => !visible);
240
+ }, []);
241
+ const dismissTimelineEditorHint = useCallback(() => {
242
+ setTimelineEditorHintState(true);
243
+ setTimelineEditorHintDismissed(true);
244
+ }, []);
245
+ const handleTimelineToggleHotkey = useCallback(
246
+ (event: KeyboardEvent) => {
247
+ if (!shouldHandleTimelineToggleHotkey(event)) return;
248
+ event.preventDefault();
249
+ toggleTimelineVisibility();
250
+ },
251
+ [toggleTimelineVisibility],
252
+ );
253
+
254
+ useMountEffect(() => {
255
+ window.addEventListener("keydown", handleTimelineToggleHotkey);
256
+ return () => {
257
+ window.removeEventListener("keydown", handleTimelineToggleHotkey);
258
+ };
259
+ });
260
+
261
+ const syncPreviewTimelineHotkey = useCallback(
262
+ (iframe: HTMLIFrameElement | null) => {
263
+ const nextWindow = iframe?.contentWindow ?? null;
264
+ if (previewHotkeyWindowRef.current === nextWindow) return;
265
+ if (previewHotkeyWindowRef.current) {
266
+ previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
267
+ }
268
+ previewHotkeyWindowRef.current = nextWindow;
269
+ nextWindow?.addEventListener("keydown", handleTimelineToggleHotkey);
270
+ },
271
+ [handleTimelineToggleHotkey],
272
+ );
273
+
274
+ useEffect(
275
+ () => () => {
276
+ if (previewHotkeyWindowRef.current) {
277
+ previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
278
+ previewHotkeyWindowRef.current = null;
279
+ }
280
+ },
281
+ [handleTimelineToggleHotkey],
282
+ );
227
283
 
228
284
  const renderClipContent = useCallback(
229
285
  (el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
@@ -323,48 +379,75 @@ export function StudioApp() {
323
379
  [compIdToSrc, activePreviewUrl, effectiveTimelineDuration],
324
380
  );
325
381
  const timelineToolbar = (
326
- <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800/40 bg-neutral-950/96">
327
- <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
328
- Timeline
329
- </div>
330
- <div className="flex items-center gap-1">
331
- <button
332
- type="button"
333
- onClick={() => setZoomMode("fit")}
334
- className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
335
- zoomMode === "fit"
336
- ? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
337
- : "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
338
- }`}
339
- title="Fit timeline to width"
340
- >
341
- Fit
342
- </button>
343
- <button
344
- type="button"
345
- onClick={() => {
346
- setZoomMode("manual");
347
- setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent));
348
- }}
349
- className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
350
- title="Zoom out"
351
- >
352
- -
353
- </button>
354
- <div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
355
- {`${displayedTimelineZoomPercent}%`}
382
+ <div className="border-b border-neutral-800/40 bg-neutral-950/96">
383
+ {timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && (
384
+ <div className="px-3 pt-3">
385
+ <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">
386
+ <div className="min-w-0">
387
+ <div className="text-[11px] font-semibold text-neutral-100">Timeline editor</div>
388
+ <p className="mt-1 text-[11px] leading-5 text-neutral-300">
389
+ Drag clips to move timing, and drag clip edges to resize them when handles are
390
+ available. Hide the panel anytime and bring it back with{" "}
391
+ <span className="font-mono text-[10px] text-studio-accent">
392
+ {TIMELINE_TOGGLE_SHORTCUT_LABEL}
393
+ </span>
394
+ .
395
+ </p>
396
+ </div>
397
+ <button
398
+ type="button"
399
+ onClick={dismissTimelineEditorHint}
400
+ 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"
401
+ >
402
+ Dismiss
403
+ </button>
404
+ </div>
405
+ </div>
406
+ )}
407
+
408
+ <div className="flex items-center justify-between px-3 py-2">
409
+ <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
410
+ Timeline
411
+ </div>
412
+ <div className="flex items-center gap-1">
413
+ <button
414
+ type="button"
415
+ onClick={() => setZoomMode("fit")}
416
+ className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
417
+ zoomMode === "fit"
418
+ ? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
419
+ : "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
420
+ }`}
421
+ title="Fit timeline to width"
422
+ >
423
+ Fit
424
+ </button>
425
+ <button
426
+ type="button"
427
+ onClick={() => {
428
+ setZoomMode("manual");
429
+ setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent));
430
+ }}
431
+ className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
432
+ title="Zoom out"
433
+ >
434
+ -
435
+ </button>
436
+ <div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
437
+ {`${displayedTimelineZoomPercent}%`}
438
+ </div>
439
+ <button
440
+ type="button"
441
+ onClick={() => {
442
+ setZoomMode("manual");
443
+ setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
444
+ }}
445
+ className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
446
+ title="Zoom in"
447
+ >
448
+ +
449
+ </button>
356
450
  </div>
357
- <button
358
- type="button"
359
- onClick={() => {
360
- setZoomMode("manual");
361
- setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
362
- }}
363
- className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
364
- title="Zoom in"
365
- >
366
- +
367
- </button>
368
451
  </div>
369
452
  </div>
370
453
  );
@@ -948,13 +1031,15 @@ export function StudioApp() {
948
1031
  </svg>
949
1032
  </button>
950
1033
  <button
951
- onClick={() => setTimelineVisible((v) => !v)}
952
- className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
1034
+ type="button"
1035
+ onClick={toggleTimelineVisibility}
1036
+ className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
953
1037
  timelineVisible
954
1038
  ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
955
- : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
1039
+ : "text-neutral-300 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-800"
956
1040
  }`}
957
- title={timelineVisible ? "Hide timeline" : "Show timeline"}
1041
+ title={getTimelineToggleTitle(timelineVisible)}
1042
+ aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
958
1043
  >
959
1044
  <svg
960
1045
  width="14"
@@ -969,6 +1054,7 @@ export function StudioApp() {
969
1054
  <line x1="3" y1="9" x2="21" y2="9" />
970
1055
  <line x1="3" y1="5" x2="21" y2="5" />
971
1056
  </svg>
1057
+ <span>Timeline</span>
972
1058
  </button>
973
1059
  <button
974
1060
  onClick={() => setRightCollapsed((v) => !v)}
@@ -1079,6 +1165,7 @@ export function StudioApp() {
1079
1165
  }}
1080
1166
  onIframeRef={(iframe) => {
1081
1167
  previewIframeRef.current = iframe;
1168
+ syncPreviewTimelineHotkey(iframe);
1082
1169
  consoleErrorsRef.current = [];
1083
1170
  setConsoleErrors(null);
1084
1171
  if (!iframe) return;
@@ -1143,7 +1230,7 @@ export function StudioApp() {
1143
1230
  ) : undefined
1144
1231
  }
1145
1232
  timelineVisible={timelineVisible}
1146
- onToggleTimeline={() => setTimelineVisible((v) => !v)}
1233
+ onToggleTimeline={toggleTimelineVisibility}
1147
1234
  />
1148
1235
  </div>
1149
1236
 
@@ -1,5 +1,9 @@
1
1
  import { useRef, useState, useCallback, useEffect, memo } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import {
4
+ TIMELINE_TOGGLE_SHORTCUT_LABEL,
5
+ getTimelineToggleTitle,
6
+ } from "../../utils/timelineDiscovery";
3
7
  import { formatTime } from "../lib/time";
4
8
  import { usePlayerStore, liveTime } from "../store/playerStore";
5
9
 
@@ -328,13 +332,15 @@ export const PlayerControls = memo(function PlayerControls({
328
332
  {/* Timeline toggle */}
329
333
  {onToggleTimeline !== undefined && (
330
334
  <button
335
+ type="button"
331
336
  onClick={onToggleTimeline}
332
- className={`w-7 h-7 flex items-center justify-center rounded-md border transition-colors ${
337
+ className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
333
338
  timelineVisible
334
339
  ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
335
- : "border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
340
+ : "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
336
341
  }`}
337
- title={timelineVisible ? "Hide timeline" : "Show timeline"}
342
+ title={getTimelineToggleTitle(Boolean(timelineVisible))}
343
+ aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
338
344
  >
339
345
  <svg
340
346
  width="13"
@@ -349,6 +355,10 @@ export const PlayerControls = memo(function PlayerControls({
349
355
  <line x1="3" y1="9" x2="21" y2="9" />
350
356
  <line x1="3" y1="5" x2="21" y2="5" />
351
357
  </svg>
358
+ <span>Timeline</span>
359
+ <span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
360
+ {TIMELINE_TOGGLE_SHORTCUT_LABEL}
361
+ </span>
352
362
  </button>
353
363
  )}
354
364
  </div>
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ TIMELINE_TOGGLE_SHORTCUT_LABEL,
4
+ getTimelineToggleTitle,
5
+ shouldHandleTimelineToggleHotkey,
6
+ } from "./timelineDiscovery";
7
+
8
+ describe("shouldHandleTimelineToggleHotkey", () => {
9
+ it("accepts Shift+T when focus is not inside an editor", () => {
10
+ expect(
11
+ shouldHandleTimelineToggleHotkey({
12
+ key: "T",
13
+ shiftKey: true,
14
+ metaKey: false,
15
+ ctrlKey: false,
16
+ altKey: false,
17
+ target: {
18
+ tagName: "DIV",
19
+ isContentEditable: false,
20
+ closest: () => null,
21
+ },
22
+ } as KeyboardEvent),
23
+ ).toBe(true);
24
+ });
25
+
26
+ it("ignores the shortcut inside text inputs", () => {
27
+ expect(
28
+ shouldHandleTimelineToggleHotkey({
29
+ key: "t",
30
+ shiftKey: true,
31
+ metaKey: false,
32
+ ctrlKey: false,
33
+ altKey: false,
34
+ target: {
35
+ tagName: "TEXTAREA",
36
+ isContentEditable: false,
37
+ closest: () => null,
38
+ },
39
+ } as KeyboardEvent),
40
+ ).toBe(false);
41
+ });
42
+
43
+ it("ignores the shortcut inside contenteditable editors", () => {
44
+ expect(
45
+ shouldHandleTimelineToggleHotkey({
46
+ key: "t",
47
+ shiftKey: true,
48
+ metaKey: false,
49
+ ctrlKey: false,
50
+ altKey: false,
51
+ target: {
52
+ tagName: "DIV",
53
+ isContentEditable: true,
54
+ closest: () => null,
55
+ },
56
+ } as KeyboardEvent),
57
+ ).toBe(false);
58
+ });
59
+
60
+ it("requires Shift without other modifiers", () => {
61
+ expect(
62
+ shouldHandleTimelineToggleHotkey({
63
+ key: "t",
64
+ shiftKey: false,
65
+ metaKey: false,
66
+ ctrlKey: false,
67
+ altKey: false,
68
+ target: null,
69
+ } as KeyboardEvent),
70
+ ).toBe(false);
71
+
72
+ expect(
73
+ shouldHandleTimelineToggleHotkey({
74
+ key: "t",
75
+ shiftKey: true,
76
+ metaKey: true,
77
+ ctrlKey: false,
78
+ altKey: false,
79
+ target: null,
80
+ } as KeyboardEvent),
81
+ ).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe("getTimelineToggleTitle", () => {
86
+ it("includes the shortcut in both show and hide titles", () => {
87
+ expect(getTimelineToggleTitle(true)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL);
88
+ expect(getTimelineToggleTitle(false)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL);
89
+ });
90
+ });
@@ -0,0 +1,57 @@
1
+ export const TIMELINE_TOGGLE_SHORTCUT_LABEL = "Shift+T";
2
+ const TIMELINE_EDITOR_HINT_STORAGE_KEY = "hf-studio-timeline-editor-hint-dismissed";
3
+
4
+ type TimelineToggleHotkeyEvent = Pick<
5
+ KeyboardEvent,
6
+ "key" | "shiftKey" | "metaKey" | "ctrlKey" | "altKey" | "target"
7
+ >;
8
+
9
+ interface EditableTargetLike {
10
+ tagName?: string;
11
+ isContentEditable?: boolean;
12
+ closest?: (selector: string) => unknown;
13
+ getAttribute?: (name: string) => string | null;
14
+ }
15
+
16
+ function isEditableTarget(target: EventTarget | null): boolean {
17
+ if (!target || typeof target !== "object") return false;
18
+
19
+ const element = target as EditableTargetLike;
20
+ const tagName = element.tagName?.toLowerCase();
21
+ if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
22
+ if (element.isContentEditable) return true;
23
+
24
+ const role = element.getAttribute?.("role");
25
+ if (role === "textbox" || role === "searchbox" || role === "combobox") return true;
26
+
27
+ return Boolean(
28
+ element.closest?.(
29
+ "input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor",
30
+ ),
31
+ );
32
+ }
33
+
34
+ export function shouldHandleTimelineToggleHotkey(event: TimelineToggleHotkeyEvent): boolean {
35
+ if (event.metaKey || event.ctrlKey || event.altKey) return false;
36
+ if (!event.shiftKey) return false;
37
+ if (event.key.toLowerCase() !== "t") return false;
38
+ return !isEditableTarget(event.target);
39
+ }
40
+
41
+ export function getTimelineToggleTitle(timelineVisible: boolean): string {
42
+ return `${timelineVisible ? "Hide" : "Show"} timeline editor (${TIMELINE_TOGGLE_SHORTCUT_LABEL})`;
43
+ }
44
+
45
+ export function getTimelineEditorHintDismissed(): boolean {
46
+ if (typeof window === "undefined") return false;
47
+ return window.localStorage.getItem(TIMELINE_EDITOR_HINT_STORAGE_KEY) === "1";
48
+ }
49
+
50
+ export function setTimelineEditorHintDismissed(dismissed: boolean): void {
51
+ if (typeof window === "undefined") return;
52
+ if (dismissed) {
53
+ window.localStorage.setItem(TIMELINE_EDITOR_HINT_STORAGE_KEY, "1");
54
+ return;
55
+ }
56
+ window.localStorage.removeItem(TIMELINE_EDITOR_HINT_STORAGE_KEY);
57
+ }