@hyperframes/studio 0.6.31 → 0.6.33

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.
@@ -3,20 +3,23 @@ import {
3
3
  useState,
4
4
  useCallback,
5
5
  useImperativeHandle,
6
+ useRef,
6
7
  forwardRef,
7
8
  type ReactNode,
8
9
  } from "react";
9
10
  import { CompositionsTab } from "./CompositionsTab";
10
11
  import { AssetsTab } from "./AssetsTab";
11
12
  import { trackStudioEvent } from "../../utils/studioTelemetry";
12
- import { BlocksTab } from "./BlocksTab";
13
+ import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
13
14
  import { FileTree } from "../editor/FileTree";
14
15
  import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
16
+ import { Tooltip } from "../ui";
15
17
 
16
18
  export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
17
19
 
18
20
  export interface LeftSidebarHandle {
19
21
  selectTab: (tab: SidebarTab) => void;
22
+ getTab: () => SidebarTab;
20
23
  }
21
24
 
22
25
  const STORAGE_KEY = "hf-studio-sidebar-tab";
@@ -53,6 +56,7 @@ interface LeftSidebarProps {
53
56
  linting?: boolean;
54
57
  onToggleCollapse?: () => void;
55
58
  onAddBlock?: (blockName: string) => void;
59
+ onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
56
60
  takeoverContent?: ReactNode;
57
61
  }
58
62
 
@@ -82,11 +86,14 @@ export const LeftSidebar = memo(
82
86
  linting,
83
87
  onToggleCollapse,
84
88
  onAddBlock,
89
+ onPreviewBlock,
85
90
  takeoverContent,
86
91
  },
87
92
  ref,
88
93
  ) {
89
94
  const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
95
+ const tabRef = useRef(tab);
96
+ tabRef.current = tab;
90
97
 
91
98
  const selectTab = useCallback((t: SidebarTab) => {
92
99
  setTab(t);
@@ -94,7 +101,9 @@ export const LeftSidebar = memo(
94
101
  trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
95
102
  }, []);
96
103
 
97
- useImperativeHandle(ref, () => ({ selectTab }), [selectTab]);
104
+ const getTab = useCallback(() => tabRef.current, []);
105
+
106
+ useImperativeHandle(ref, () => ({ selectTab, getTab }), [selectTab, getTab]);
98
107
 
99
108
  return (
100
109
  <div
@@ -116,51 +125,59 @@ export const LeftSidebar = memo(
116
125
  : "1fr 1fr 1fr",
117
126
  }}
118
127
  >
119
- <button
120
- type="button"
121
- onClick={() => selectTab("code")}
122
- className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
123
- tab === "code"
124
- ? "bg-neutral-800 text-white"
125
- : "text-neutral-500 hover:text-neutral-200"
126
- }`}
127
- >
128
- Code
129
- </button>
130
- <button
131
- type="button"
132
- onClick={() => selectTab("compositions")}
133
- className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
134
- tab === "compositions"
135
- ? "bg-neutral-800 text-white"
136
- : "text-neutral-500 hover:text-neutral-200"
137
- }`}
138
- >
139
- Comps
140
- </button>
141
- <button
142
- type="button"
143
- onClick={() => selectTab("assets")}
144
- className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
145
- tab === "assets"
146
- ? "bg-neutral-800 text-white"
147
- : "text-neutral-500 hover:text-neutral-200"
148
- }`}
149
- >
150
- Assets
151
- </button>
152
- {STUDIO_BLOCKS_PANEL_ENABLED && (
128
+ <Tooltip label="Source code editor" side="bottom">
153
129
  <button
154
130
  type="button"
155
- onClick={() => selectTab("blocks")}
131
+ onClick={() => selectTab("code")}
156
132
  className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
157
- tab === "blocks"
133
+ tab === "code"
158
134
  ? "bg-neutral-800 text-white"
159
135
  : "text-neutral-500 hover:text-neutral-200"
160
136
  }`}
161
137
  >
162
- Blocks
138
+ Code
163
139
  </button>
140
+ </Tooltip>
141
+ <Tooltip label="Compositions and sub-compositions" side="bottom">
142
+ <button
143
+ type="button"
144
+ onClick={() => selectTab("compositions")}
145
+ className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
146
+ tab === "compositions"
147
+ ? "bg-neutral-800 text-white"
148
+ : "text-neutral-500 hover:text-neutral-200"
149
+ }`}
150
+ >
151
+ Comps
152
+ </button>
153
+ </Tooltip>
154
+ <Tooltip label="Videos, images, audio, fonts" side="bottom">
155
+ <button
156
+ type="button"
157
+ onClick={() => selectTab("assets")}
158
+ className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
159
+ tab === "assets"
160
+ ? "bg-neutral-800 text-white"
161
+ : "text-neutral-500 hover:text-neutral-200"
162
+ }`}
163
+ >
164
+ Assets
165
+ </button>
166
+ </Tooltip>
167
+ {STUDIO_BLOCKS_PANEL_ENABLED && (
168
+ <Tooltip label="Browse blocks and components" side="bottom">
169
+ <button
170
+ type="button"
171
+ onClick={() => selectTab("blocks")}
172
+ className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
173
+ tab === "blocks"
174
+ ? "bg-neutral-800 text-white"
175
+ : "text-neutral-500 hover:text-neutral-200"
176
+ }`}
177
+ >
178
+ Catalog
179
+ </button>
180
+ </Tooltip>
164
181
  )}
165
182
  </div>
166
183
  {onToggleCollapse && (
@@ -238,8 +255,8 @@ export const LeftSidebar = memo(
238
255
  </div>
239
256
  )}
240
257
 
241
- {STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && onAddBlock && (
242
- <BlocksTab onAddBlock={onAddBlock} />
258
+ {STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && (
259
+ <BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
243
260
  )}
244
261
 
245
262
  {/* Lint button pinned at the bottom */}
@@ -0,0 +1,63 @@
1
+ import { useState, useRef, useCallback, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+
4
+ interface TooltipProps {
5
+ label: string;
6
+ children: ReactNode;
7
+ delay?: number;
8
+ side?: "top" | "bottom";
9
+ }
10
+
11
+ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) {
12
+ const [visible, setVisible] = useState(false);
13
+ const [pos, setPos] = useState({ x: 0, y: 0 });
14
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
15
+ const triggerRef = useRef<HTMLSpanElement>(null);
16
+
17
+ const show = useCallback(() => {
18
+ timerRef.current = setTimeout(() => {
19
+ const el = triggerRef.current;
20
+ if (!el) return;
21
+ const child = el.firstElementChild as HTMLElement | null;
22
+ const rect = (child ?? el).getBoundingClientRect();
23
+ if (rect.width === 0 && rect.height === 0) return;
24
+ setPos({
25
+ x: rect.left + rect.width / 2,
26
+ y: side === "top" ? rect.top - 6 : rect.bottom + 6,
27
+ });
28
+ setVisible(true);
29
+ }, delay);
30
+ }, [delay, side]);
31
+
32
+ const hide = useCallback(() => {
33
+ if (timerRef.current) {
34
+ clearTimeout(timerRef.current);
35
+ timerRef.current = null;
36
+ }
37
+ setVisible(false);
38
+ }, []);
39
+
40
+ return (
41
+ <>
42
+ <span ref={triggerRef} onPointerEnter={show} onPointerLeave={hide} className="contents">
43
+ {children}
44
+ </span>
45
+ {visible &&
46
+ createPortal(
47
+ <div
48
+ className="fixed z-[200] pointer-events-none"
49
+ style={{
50
+ left: pos.x,
51
+ top: pos.y,
52
+ transform: side === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
53
+ }}
54
+ >
55
+ <div className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg">
56
+ {label}
57
+ </div>
58
+ </div>,
59
+ document.body,
60
+ )}
61
+ </>
62
+ );
63
+ }
@@ -2,3 +2,4 @@
2
2
  export { Button, IconButton } from "./Button";
3
3
  export { HyperframesLoader, StatusFrame } from "./HyperframesLoader";
4
4
  export type { HyperframesLoaderProps } from "./HyperframesLoader";
5
+ export { Tooltip } from "./Tooltip";
@@ -56,7 +56,11 @@ export function useBlockCatalog() {
56
56
  if (search.trim()) {
57
57
  const q = search.toLowerCase();
58
58
  result = result.filter(
59
- (b) => b.title.toLowerCase().includes(q) || b.description.toLowerCase().includes(q),
59
+ (b) =>
60
+ b.title.toLowerCase().includes(q) ||
61
+ b.description.toLowerCase().includes(q) ||
62
+ b.category.toLowerCase().includes(q) ||
63
+ b.tags?.some((t) => t.toLowerCase().includes(q)),
60
64
  );
61
65
  }
62
66
  return result;
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect } from "react";
1
+ import { useCallback, useEffect, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
4
4
  import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
@@ -56,6 +56,7 @@ export interface UseDomEditSessionParams {
56
56
  setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
57
57
  openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
58
58
  selectSidebarTab?: (tab: SidebarTab) => void;
59
+ getSidebarTab?: () => SidebarTab;
59
60
  }
60
61
 
61
62
  // ── Hook ──
@@ -93,6 +94,7 @@ export function useDomEditSession({
93
94
  setRefreshKey: _setRefreshKey,
94
95
  openSourceForSelection,
95
96
  selectSidebarTab,
97
+ getSidebarTab,
96
98
  }: UseDomEditSessionParams) {
97
99
  void _setRefreshKey;
98
100
 
@@ -281,6 +283,22 @@ export function useDomEditSession({
281
283
  applyStudioManualEditsToPreviewRef,
282
284
  ]);
283
285
 
286
+ // Auto-reveal source when an element is selected while the Code tab is active.
287
+ // Use a ref for the callback so the effect only fires on selection changes,
288
+ // not when openSourceForSelection is recreated due to editingFile content updates.
289
+ const openSourceRef = useRef(openSourceForSelection);
290
+ openSourceRef.current = openSourceForSelection;
291
+ useEffect(() => {
292
+ if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
293
+ if (!domEditSelection.sourceFile) return;
294
+ if (getSidebarTab() !== "code") return;
295
+ openSourceRef.current(domEditSelection.sourceFile, {
296
+ id: domEditSelection.id,
297
+ selector: domEditSelection.selector,
298
+ selectorIndex: domEditSelection.selectorIndex,
299
+ });
300
+ }, [domEditSelection, getSidebarTab]);
301
+
284
302
  return {
285
303
  // State
286
304
  domEditSelection,
@@ -122,6 +122,9 @@ export function useFileManager({
122
122
  const handleFileSelect = useCallback((path: string) => {
123
123
  const pid = projectIdRef.current;
124
124
  if (!pid) return;
125
+ revealAbortRef.current?.abort();
126
+ revealAbortRef.current = null;
127
+ revealRequestIdRef.current++;
125
128
  // Skip fetching binary content for media files — just set the path for preview
126
129
  if (isMediaFile(path)) {
127
130
  setEditingFile({ path, content: null });
package/src/main.tsx CHANGED
@@ -23,6 +23,12 @@ function errorProps(value: unknown): {
23
23
  }
24
24
 
25
25
  window.addEventListener("error", (event) => {
26
+ if (event.message?.includes("ResizeObserver loop")) {
27
+ event.stopImmediatePropagation();
28
+ event.preventDefault();
29
+ return;
30
+ }
31
+
26
32
  trackStudioEvent("unhandled_error", {
27
33
  ...errorProps(event.error),
28
34
  error_message: event.message,