@hyperframes/studio 0.6.1 → 0.6.3

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.
@@ -0,0 +1,302 @@
1
+ import { memo, useState, useCallback, useEffect, useRef } from "react";
2
+ import {
3
+ collectDomEditLayerItems,
4
+ getDomEditLayerKey,
5
+ resolveDomEditSelection,
6
+ type DomEditLayerItem,
7
+ } from "./domEditing";
8
+ import { useStudioContext } from "../../contexts/StudioContext";
9
+ import { useDomEditContext } from "../../contexts/DomEditContext";
10
+ import { usePlayerStore } from "../../player";
11
+ import { findMatchingTimelineElementId } from "../../utils/studioHelpers";
12
+ import { Layers } from "../../icons/SystemIcons";
13
+
14
+ const TAG_ICONS: Record<string, string> = {
15
+ video: "Vi",
16
+ audio: "Au",
17
+ img: "Im",
18
+ svg: "Sv",
19
+ canvas: "Cn",
20
+ div: "Di",
21
+ section: "Se",
22
+ span: "Sp",
23
+ p: "P",
24
+ h1: "H1",
25
+ h2: "H2",
26
+ h3: "H3",
27
+ h4: "H4",
28
+ h5: "H5",
29
+ h6: "H6",
30
+ a: "A",
31
+ button: "Bt",
32
+ ul: "Ul",
33
+ ol: "Ol",
34
+ li: "Li",
35
+ style: "St",
36
+ template: "Te",
37
+ };
38
+
39
+ function getTagBadge(tagName: string): string {
40
+ return TAG_ICONS[tagName] ?? tagName.slice(0, 2).toUpperCase();
41
+ }
42
+
43
+ function isCompositionHost(el: HTMLElement): boolean {
44
+ return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
45
+ }
46
+
47
+ interface CollapsedState {
48
+ [key: string]: boolean;
49
+ }
50
+
51
+ export const LayersPanel = memo(function LayersPanel() {
52
+ const { previewIframeRef, activeCompPath, refreshKey, compositionLoading, timelineElements } =
53
+ useStudioContext();
54
+ const { domEditSelection, applyDomSelection, updateDomEditHoverSelection } = useDomEditContext();
55
+
56
+ const [layers, setLayers] = useState<DomEditLayerItem[]>([]);
57
+ const [collapsed, setCollapsed] = useState<CollapsedState>({});
58
+ const prevDocVersionRef = useRef(0);
59
+
60
+ const isMasterView = !activeCompPath || activeCompPath === "index.html";
61
+
62
+ const collectLayers = useCallback(() => {
63
+ const iframe = previewIframeRef.current;
64
+ if (!iframe) return;
65
+ let doc: Document | null = null;
66
+ try {
67
+ doc = iframe.contentDocument;
68
+ } catch {
69
+ return;
70
+ }
71
+ if (!doc) return;
72
+
73
+ const root =
74
+ doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
75
+ if (!root) return;
76
+
77
+ const items = collectDomEditLayerItems(root, {
78
+ activeCompositionPath: activeCompPath,
79
+ isMasterView,
80
+ });
81
+ setLayers(items);
82
+ }, [previewIframeRef, activeCompPath, isMasterView]);
83
+
84
+ useEffect(() => {
85
+ collectLayers();
86
+ }, [collectLayers, refreshKey]);
87
+
88
+ useEffect(() => {
89
+ const iframe = previewIframeRef.current;
90
+ if (!iframe) return;
91
+ const handleLoad = () => {
92
+ prevDocVersionRef.current += 1;
93
+ collectLayers();
94
+ };
95
+ iframe.addEventListener("load", handleLoad);
96
+ return () => iframe.removeEventListener("load", handleLoad);
97
+ }, [previewIframeRef, collectLayers]);
98
+
99
+ useEffect(() => {
100
+ if (!compositionLoading) {
101
+ const timer = setTimeout(collectLayers, 100);
102
+ return () => clearTimeout(timer);
103
+ }
104
+ }, [compositionLoading, collectLayers]);
105
+
106
+ useEffect(() => {
107
+ const ref = hoverSeekTimerRef;
108
+ return () => {
109
+ if (ref.current) clearTimeout(ref.current);
110
+ };
111
+ }, []);
112
+
113
+ const resolveSelection = useCallback(
114
+ (layer: DomEditLayerItem) =>
115
+ resolveDomEditSelection(layer.element, {
116
+ activeCompositionPath: activeCompPath,
117
+ isMasterView,
118
+ preferClipAncestor: false,
119
+ }),
120
+ [activeCompPath, isMasterView],
121
+ );
122
+
123
+ const hoverSeekTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
124
+
125
+ const seekToLayer = useCallback(
126
+ (layer: DomEditLayerItem) => {
127
+ const selection = resolveSelection(layer);
128
+ if (!selection) return;
129
+
130
+ let matchedId = findMatchingTimelineElementId(selection, timelineElements);
131
+
132
+ // No direct match — walk up DOM ancestors to find the nearest element
133
+ // that has a timeline entry (e.g. a child of scene1 seeks to scene1.start)
134
+ if (!matchedId) {
135
+ const sourceFile = selection.sourceFile ?? "index.html";
136
+ let ancestor = layer.element.parentElement;
137
+ while (ancestor && !matchedId) {
138
+ const elId = ancestor.id;
139
+ if (elId) {
140
+ const found = timelineElements.find(
141
+ (e) => e.domId === elId && (e.sourceFile ?? "index.html") === sourceFile,
142
+ );
143
+ if (found) matchedId = found.key ?? found.id;
144
+ }
145
+ ancestor = ancestor.parentElement;
146
+ }
147
+ }
148
+
149
+ if (matchedId) {
150
+ const el = timelineElements.find((e) => (e.key ?? e.id) === matchedId);
151
+ if (el) {
152
+ usePlayerStore.getState().requestSeek(el.start + el.duration / 2);
153
+ }
154
+ }
155
+ },
156
+ [resolveSelection, timelineElements],
157
+ );
158
+
159
+ const handleSelectLayer = useCallback(
160
+ (layer: DomEditLayerItem) => {
161
+ const selection = resolveSelection(layer);
162
+ if (!selection) return;
163
+ applyDomSelection(selection);
164
+ seekToLayer(layer);
165
+ },
166
+ [resolveSelection, applyDomSelection, seekToLayer],
167
+ );
168
+
169
+ const handleLayerHover = useCallback(
170
+ (layer: DomEditLayerItem | null) => {
171
+ if (!layer) {
172
+ if (hoverSeekTimerRef.current) clearTimeout(hoverSeekTimerRef.current);
173
+ updateDomEditHoverSelection(null);
174
+ return;
175
+ }
176
+ const selection = resolveSelection(layer);
177
+ updateDomEditHoverSelection(selection);
178
+ // Debounce hover seeks so brushing past items doesn't thrash the player
179
+ if (hoverSeekTimerRef.current) clearTimeout(hoverSeekTimerRef.current);
180
+ hoverSeekTimerRef.current = setTimeout(() => seekToLayer(layer), 300);
181
+ },
182
+ [resolveSelection, updateDomEditHoverSelection, seekToLayer],
183
+ );
184
+
185
+ const toggleCollapse = useCallback((key: string, e: React.MouseEvent) => {
186
+ e.stopPropagation();
187
+ setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
188
+ }, []);
189
+
190
+ const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null;
191
+
192
+ const visibleLayers = getVisibleLayers(layers, collapsed);
193
+
194
+ if (layers.length === 0) {
195
+ return (
196
+ <div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
197
+ <Layers size={18} className="mb-3 text-neutral-600" />
198
+ <p className="text-sm font-medium text-neutral-200">No layers</p>
199
+ <p className="mt-1 text-xs text-neutral-500">Load a composition to see its element tree</p>
200
+ </div>
201
+ );
202
+ }
203
+
204
+ return (
205
+ <div
206
+ className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900"
207
+ onPointerLeave={() => handleLayerHover(null)}
208
+ >
209
+ <div className="border-b border-white/10 px-3 py-2 text-[11px] text-neutral-500">
210
+ {layers.length} layer{layers.length === 1 ? "" : "s"}
211
+ </div>
212
+ <div className="min-h-0 flex-1 overflow-y-auto py-1">
213
+ {visibleLayers.map((layer) => {
214
+ const selected = layer.key === selectedKey;
215
+ const isCollapsed = collapsed[layer.key] ?? false;
216
+ const hasChildren = layer.childCount > 0;
217
+ const isCompHost = isCompositionHost(layer.element);
218
+
219
+ return (
220
+ <div
221
+ key={layer.key}
222
+ role="button"
223
+ tabIndex={0}
224
+ onClick={() => handleSelectLayer(layer)}
225
+ onPointerEnter={() => handleLayerHover(layer)}
226
+ onKeyDown={(e) => {
227
+ if (e.key === "Enter" || e.key === " ") {
228
+ e.preventDefault();
229
+ handleSelectLayer(layer);
230
+ }
231
+ }}
232
+ className={`group flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-left transition-colors ${
233
+ selected
234
+ ? "bg-studio-accent/14 text-studio-accent"
235
+ : "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
236
+ }`}
237
+ style={{ paddingLeft: 8 + layer.depth * 16 }}
238
+ >
239
+ {hasChildren ? (
240
+ <button
241
+ type="button"
242
+ onClick={(e) => toggleCollapse(layer.key, e)}
243
+ className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-neutral-500 hover:text-neutral-300"
244
+ >
245
+ <svg
246
+ width="8"
247
+ height="8"
248
+ viewBox="0 0 8 8"
249
+ fill="currentColor"
250
+ className={`transition-transform ${isCollapsed ? "" : "rotate-90"}`}
251
+ >
252
+ <path d="M2 1l4 3-4 3z" />
253
+ </svg>
254
+ </button>
255
+ ) : (
256
+ <span className="w-4 flex-shrink-0" />
257
+ )}
258
+ <span
259
+ className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[8px] font-bold uppercase ${
260
+ selected
261
+ ? "bg-studio-accent/18 text-studio-accent"
262
+ : isCompHost
263
+ ? "bg-blue-900/40 text-blue-400"
264
+ : "bg-neutral-800 text-neutral-500"
265
+ }`}
266
+ >
267
+ {getTagBadge(layer.tagName)}
268
+ </span>
269
+ <span className="min-w-0 flex-1 truncate text-[11px]">{layer.label}</span>
270
+ {hasChildren && (
271
+ <span className="text-[9px] tabular-nums text-neutral-600">{layer.childCount}</span>
272
+ )}
273
+ </div>
274
+ );
275
+ })}
276
+ </div>
277
+ </div>
278
+ );
279
+ });
280
+
281
+ function getVisibleLayers(
282
+ layers: DomEditLayerItem[],
283
+ collapsed: CollapsedState,
284
+ ): DomEditLayerItem[] {
285
+ if (Object.keys(collapsed).length === 0) return layers;
286
+
287
+ const result: DomEditLayerItem[] = [];
288
+ let skipDepth = -1;
289
+
290
+ for (const layer of layers) {
291
+ if (skipDepth >= 0 && layer.depth > skipDepth) continue;
292
+ skipDepth = -1;
293
+
294
+ result.push(layer);
295
+
296
+ if (collapsed[layer.key] && layer.childCount > 0) {
297
+ skipDepth = layer.depth;
298
+ }
299
+ }
300
+
301
+ return result;
302
+ }
@@ -1,4 +1,4 @@
1
- import { useRef, useCallback, memo } from "react";
1
+ import { useRef, useCallback, useEffect, memo } from "react";
2
2
  import {
3
3
  EditorView,
4
4
  keymap,
@@ -69,6 +69,9 @@ export const SourceEditor = memo(function SourceEditor({
69
69
  const onChangeRef = useRef(onChange);
70
70
  onChangeRef.current = onChange;
71
71
 
72
+ const contentRef = useRef(content);
73
+ contentRef.current = content;
74
+
72
75
  const mountEditor = useCallback(
73
76
  (node: HTMLDivElement | null) => {
74
77
  if (editorRef.current) {
@@ -87,7 +90,7 @@ export const SourceEditor = memo(function SourceEditor({
87
90
  });
88
91
 
89
92
  const state = EditorState.create({
90
- doc: content,
93
+ doc: contentRef.current,
91
94
  extensions: [
92
95
  lineNumbers(),
93
96
  highlightActiveLine(),
@@ -112,8 +115,22 @@ export const SourceEditor = memo(function SourceEditor({
112
115
 
113
116
  editorRef.current = new EditorView({ state, parent: node });
114
117
  },
115
- [content, filePath, language, readOnly],
118
+ [filePath, language, readOnly],
116
119
  );
117
120
 
121
+ // Sync external content changes into the editor without recreating it.
122
+ // Only applies when the new content differs from the current document
123
+ // (e.g. file switch or server refresh), not on every keystroke.
124
+ useEffect(() => {
125
+ const view = editorRef.current;
126
+ if (!view) return;
127
+ const current = view.state.doc.toString();
128
+ if (current !== content) {
129
+ view.dispatch({
130
+ changes: { from: 0, to: current.length, insert: content },
131
+ });
132
+ }
133
+ }, [content]);
134
+
118
135
  return <div ref={mountEditor} className="h-full w-full overflow-hidden" />;
119
136
  });
@@ -334,24 +334,43 @@ export function Section({
334
334
  icon,
335
335
  children,
336
336
  accessory,
337
+ defaultCollapsed = false,
337
338
  }: {
338
339
  title: string;
339
340
  icon: ReactNode;
340
341
  children: ReactNode;
341
342
  accessory?: ReactNode;
343
+ defaultCollapsed?: boolean;
342
344
  }) {
345
+ const [collapsed, setCollapsed] = useState(defaultCollapsed);
346
+
343
347
  return (
344
- <section className="min-w-0 border-t border-neutral-800/80 px-4 py-4">
345
- <div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-2">
348
+ <section className="min-w-0 border-t border-neutral-800/80">
349
+ <button
350
+ type="button"
351
+ onClick={() => setCollapsed((v) => !v)}
352
+ className="flex w-full items-center justify-between gap-2 px-4 py-3"
353
+ >
346
354
  <div className="flex min-w-0 items-center gap-2.5">
347
355
  <span className="flex-shrink-0 text-neutral-500">{icon}</span>
348
356
  <h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-300">
349
357
  {title}
350
358
  </h3>
351
359
  </div>
352
- {accessory}
353
- </div>
354
- {children}
360
+ <div className="flex items-center gap-2">
361
+ {accessory}
362
+ <svg
363
+ width="10"
364
+ height="10"
365
+ viewBox="0 0 10 10"
366
+ fill="currentColor"
367
+ className={`flex-shrink-0 text-neutral-500 transition-transform ${collapsed ? "-rotate-90" : ""}`}
368
+ >
369
+ <path d="M2 3l3 4 3-4z" />
370
+ </svg>
371
+ </div>
372
+ </button>
373
+ {!collapsed && <div className="px-4 pb-4">{children}</div>}
355
374
  </section>
356
375
  );
357
376
  }
@@ -109,7 +109,7 @@ export function StyleSections({
109
109
  return (
110
110
  <>
111
111
  {isFlex && (
112
- <Section title="Flex" icon={<Layers size={15} />}>
112
+ <Section title="Flex" icon={<Layers size={15} />} defaultCollapsed>
113
113
  <div className="space-y-4">
114
114
  <SegmentedControl
115
115
  disabled={styleEditingDisabled}
@@ -154,7 +154,7 @@ export function StyleSections({
154
154
  )}
155
155
 
156
156
  {hasVisualBackground && (
157
- <Section title="Radius" icon={<Settings size={15} />}>
157
+ <Section title="Radius" icon={<Settings size={15} />} defaultCollapsed>
158
158
  <SliderControl
159
159
  value={radiusValue}
160
160
  min={0}
@@ -168,7 +168,7 @@ export function StyleSections({
168
168
  </Section>
169
169
  )}
170
170
 
171
- <Section title="Stroke" icon={<Square size={15} />}>
171
+ <Section title="Stroke" icon={<Square size={15} />} defaultCollapsed>
172
172
  <div className="space-y-4">
173
173
  <div className={RESPONSIVE_GRID}>
174
174
  <MetricField
@@ -226,7 +226,7 @@ export function StyleSections({
226
226
  </div>
227
227
  </Section>
228
228
 
229
- <Section title="Effects" icon={<Zap size={15} />}>
229
+ <Section title="Effects" icon={<Zap size={15} />} defaultCollapsed>
230
230
  <div className="space-y-4">
231
231
  <SelectField
232
232
  label="Shadow"
@@ -279,7 +279,7 @@ export function StyleSections({
279
279
  </div>
280
280
  </Section>
281
281
 
282
- <Section title="Clip" icon={<Layers size={15} />}>
282
+ <Section title="Clip" icon={<Layers size={15} />} defaultCollapsed>
283
283
  <div className="space-y-4">
284
284
  <div className={RESPONSIVE_GRID}>
285
285
  <SelectField
@@ -325,7 +325,7 @@ export function StyleSections({
325
325
  </div>
326
326
  </Section>
327
327
 
328
- <Section title="Transparency" icon={<Eye size={15} />}>
328
+ <Section title="Transparency" icon={<Eye size={15} />} defaultCollapsed>
329
329
  <div className="space-y-4">
330
330
  <SliderControl
331
331
  value={opacityValue}
@@ -23,12 +23,7 @@ import {
23
23
  restoreStudioPathOffset,
24
24
  restoreStudioRotation,
25
25
  } from "./manualEdits";
26
- import {
27
- type GroupOverlayItem,
28
- type OverlayRect,
29
- groupOverlayItemsEqual,
30
- rectsEqual,
31
- } from "./domEditOverlayGeometry";
26
+ import { type GroupOverlayItem, type OverlayRect } from "./domEditOverlayGeometry";
32
27
  import {
33
28
  BLOCKED_MOVE_THRESHOLD_PX,
34
29
  type BlockedMoveState,
@@ -87,8 +82,6 @@ export type UseDomEditOverlayGesturesOptions = {
87
82
 
88
83
  export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
89
84
  const setDraftOverlayRect = (next: OverlayRect) => {
90
- if (rectsEqual(opts.overlayRectRef.current, next)) return;
91
- opts.overlayRectRef.current = next;
92
85
  opts.setOverlayRect(next);
93
86
  };
94
87
  const restoreGestureOverlayRect = (g: GestureState) => {
@@ -102,8 +95,6 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
102
95
  });
103
96
  };
104
97
  const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => {
105
- if (groupOverlayItemsEqual(opts.groupOverlayItemsRef.current, next)) return;
106
- opts.groupOverlayItemsRef.current = next;
107
98
  opts.setGroupOverlayItems(next);
108
99
  };
109
100
 
@@ -247,9 +247,11 @@ export const NLELayout = memo(function NLELayout({
247
247
  const currentLevel = compositionStack[compositionStack.length - 1];
248
248
  const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
249
249
 
250
+ const onIframeRefStable = useRef(onIframeRef);
251
+ onIframeRefStable.current = onIframeRef;
250
252
  useEffect(() => {
251
- onIframeRef?.(iframeRef.current);
252
- }, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
253
+ onIframeRefStable.current?.(iframeRef.current);
254
+ }, [compositionStack.length, refreshKey, iframeRef]);
253
255
 
254
256
  // Resize divider handlers
255
257
  const handleDividerPointerDown = useCallback(