@hyperframes/studio 0.6.26 → 0.6.28

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
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-DZWPbGBw.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-EdfhuQ5T.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-DVpLGNHi.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.26",
3
+ "version": "0.6.28",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,8 +30,9 @@
30
30
  "@codemirror/theme-one-dark": "^6.1.2",
31
31
  "@codemirror/view": "6.40.0",
32
32
  "@phosphor-icons/react": "^2.1.10",
33
- "@hyperframes/core": "0.6.26",
34
- "@hyperframes/player": "0.6.26"
33
+ "mediabunny": "^1.45.3",
34
+ "@hyperframes/core": "0.6.28",
35
+ "@hyperframes/player": "0.6.28"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@types/react": "19",
@@ -45,7 +46,7 @@
45
46
  "vite": "^6.4.2",
46
47
  "vitest": "^3.2.4",
47
48
  "zustand": "^5.0.0",
48
- "@hyperframes/producer": "0.6.26"
49
+ "@hyperframes/producer": "0.6.28"
49
50
  },
50
51
  "peerDependencies": {
51
52
  "react": "19",
package/src/App.tsx CHANGED
@@ -8,7 +8,7 @@ import { useCaptionSync } from "./captions/hooks/useCaptionSync";
8
8
  import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
9
9
  import { usePanelLayout } from "./hooks/usePanelLayout";
10
10
  import { useFileManager } from "./hooks/useFileManager";
11
- import { useManifestPersistence } from "./hooks/useManifestPersistence";
11
+ import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
12
12
  import { useTimelineEditing } from "./hooks/useTimelineEditing";
13
13
  import { addBlockToProject } from "./utils/blockInstaller";
14
14
  import type { BlockParam } from "@hyperframes/core/registry";
@@ -117,12 +117,9 @@ export function StudioApp() {
117
117
  });
118
118
  const editHistory = usePersistentEditHistory({ projectId });
119
119
  const domEditSaveTimestampRef = useRef(0);
120
+ const pendingTimelineEditPathRef = useRef(new Set<string>());
120
121
  const reloadPreview = useCallback(() => {
121
- try {
122
- previewIframeRef.current?.contentWindow?.location.reload();
123
- } catch {
124
- setRefreshKey((k) => k + 1);
125
- }
122
+ setRefreshKey((k) => k + 1);
126
123
  }, []);
127
124
 
128
125
  const fileManager = useFileManager({
@@ -145,7 +142,7 @@ export function StudioApp() {
145
142
  setActiveCompPathHydrated(true);
146
143
  }, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
147
144
 
148
- const manifestPersistence = useManifestPersistence({
145
+ const previewPersistence = usePreviewPersistence({
149
146
  projectId,
150
147
  showToast,
151
148
  readOptionalProjectFile: fileManager.readOptionalProjectFile,
@@ -155,6 +152,7 @@ export function StudioApp() {
155
152
  activeCompPathRef,
156
153
  domEditSaveTimestampRef,
157
154
  reloadPreview: () => setRefreshKey((k) => k + 1),
155
+ pendingTimelineEditPathRef,
158
156
  });
159
157
 
160
158
  const timelineEditing = useTimelineEditing({
@@ -166,6 +164,8 @@ export function StudioApp() {
166
164
  recordEdit: editHistory.recordEdit,
167
165
  domEditSaveTimestampRef,
168
166
  reloadPreview,
167
+ previewIframeRef,
168
+ pendingTimelineEditPathRef,
169
169
  uploadProjectFiles: fileManager.uploadProjectFiles,
170
170
  });
171
171
 
@@ -274,8 +274,8 @@ export function StudioApp() {
274
274
  writeProjectFile: fileManager.writeProjectFile,
275
275
  domEditSaveTimestampRef,
276
276
  showToast,
277
- syncHistoryPreviewAfterApply: manifestPersistence.syncHistoryPreviewAfterApply,
278
- waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
277
+ syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
278
+ waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
279
279
  leftSidebarRef,
280
280
  handleCopy,
281
281
  handlePaste,
@@ -297,7 +297,7 @@ export function StudioApp() {
297
297
  setRightPanelTab: panelLayout.setRightPanelTab,
298
298
  showToast,
299
299
  refreshPreviewDocumentVersion,
300
- queueDomEditSave: manifestPersistence.queueDomEditSave,
300
+ queueDomEditSave: previewPersistence.queueDomEditSave,
301
301
  readProjectFile: fileManager.readProjectFile,
302
302
  writeProjectFile: fileManager.writeProjectFile,
303
303
  domEditSaveTimestampRef,
@@ -309,7 +309,7 @@ export function StudioApp() {
309
309
  previewIframe,
310
310
  refreshKey,
311
311
  rightPanelTab: panelLayout.rightPanelTab,
312
- applyStudioManualEditsToPreviewRef: manifestPersistence.applyStudioManualEditsToPreviewRef,
312
+ applyStudioManualEditsToPreviewRef: previewPersistence.applyStudioManualEditsToPreviewRef,
313
313
  syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
314
314
  reloadPreview,
315
315
  setRefreshKey,
@@ -345,7 +345,7 @@ export function StudioApp() {
345
345
  projectId,
346
346
  activeCompPath,
347
347
  showToast,
348
- waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
348
+ waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
349
349
  });
350
350
  const {
351
351
  consoleErrors,
@@ -453,7 +453,7 @@ export function StudioApp() {
453
453
  startRender: renderQueue.startRender as (options: unknown) => Promise<void>,
454
454
  },
455
455
  compositionDimensions,
456
- waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
456
+ waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
457
457
  handlePreviewIframeRef,
458
458
  refreshPreviewDocumentVersion,
459
459
  timelineVisible,
@@ -321,6 +321,29 @@ describe("resolveVisualDomEditSelectionTarget", () => {
321
321
  expect(visualTarget).toBe(headline);
322
322
  expect(explicitSelection?.id).toBe("container");
323
323
  });
324
+
325
+ it("prefers the visually-on-top sibling over a deeper element in a separate visual layer", () => {
326
+ const document = createDocument(`
327
+ <div id="comp-root">
328
+ <div id="sub-comp" class="sub-comp">
329
+ <img id="sf-chrome" class="sf-chrome" style="width:100%;height:100%" />
330
+ </div>
331
+ <video id="pip-studio" class="pip-studio" style="position:absolute;z-index:15" />
332
+ </div>
333
+ `);
334
+ const pipStudio = document.getElementById("pip-studio") as HTMLElement;
335
+ const sfChrome = document.getElementById("sf-chrome") as HTMLElement;
336
+ const subComp = document.getElementById("sub-comp") as HTMLElement;
337
+ setElementRect(pipStudio, { left: 50, top: 50, width: 320, height: 320 });
338
+ setElementRect(sfChrome, { left: 0, top: 0, width: 1920, height: 1080 });
339
+ setElementRect(subComp, { left: 0, top: 0, width: 1920, height: 1080 });
340
+
341
+ expect(
342
+ resolveVisualDomEditSelectionTarget([pipStudio, subComp, sfChrome], {
343
+ activeCompositionPath: "index.html",
344
+ }),
345
+ ).toBe(pipStudio);
346
+ });
324
347
  });
325
348
 
326
349
  describe("isLargeRasterDomEditSelection", () => {
@@ -12,11 +12,9 @@ import type {
12
12
  import {
13
13
  buildStableSelector,
14
14
  escapeCssString,
15
- getElementDepth,
16
15
  getSelectorIndex,
17
16
  getSourceFileForElement,
18
17
  isHtmlElement,
19
- isTextBearingTag,
20
18
  normalizeTimelineCompositionSource,
21
19
  querySelectorAllSafely,
22
20
  } from "./domEditingDom";
@@ -68,23 +66,6 @@ function hasRenderedBox(el: HTMLElement): boolean {
68
66
 
69
67
  // ─── Visual scoring ──────────────────────────────────────────────────────────
70
68
 
71
- function isEditableTextLeafForScoring(el: HTMLElement): boolean {
72
- return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
73
- }
74
-
75
- function getVisualElementScore(el: HTMLElement, pointerStackIndex: number): number {
76
- const tagName = el.tagName.toLowerCase();
77
- const rect = el.getBoundingClientRect();
78
- const area = Math.max(1, rect.width * rect.height);
79
- const smallerElementBonus = Math.max(0, 1_000_000 - Math.min(area, 1_000_000)) / 1_000;
80
- const visualLeafBonus =
81
- isEditableTextLeafForScoring(el) || ["img", "video", "canvas", "svg"].includes(tagName)
82
- ? 2_000
83
- : 0;
84
-
85
- return getElementDepth(el) * 10_000 + visualLeafBonus + smallerElementBonus - pointerStackIndex;
86
- }
87
-
88
69
  // ─── Layer patch target ──────────────────────────────────────────────────────
89
70
 
90
71
  const DOM_LAYER_IGNORED_TAGS = new Set([
@@ -172,25 +153,31 @@ export function resolveVisualDomEditSelectionTarget(
172
153
  elementsFromPoint: Iterable<Element | null | undefined>,
173
154
  options: Pick<DomEditContextOptions, "activeCompositionPath">,
174
155
  ): HTMLElement | null {
175
- let best: { element: HTMLElement; score: number } | null = null;
176
- let pointerStackIndex = 0;
156
+ const candidates: HTMLElement[] = [];
177
157
 
178
158
  for (const entry of elementsFromPoint) {
179
- if (!isHtmlElement(entry)) {
180
- pointerStackIndex += 1;
181
- continue;
159
+ if (!isHtmlElement(entry)) continue;
160
+ if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
161
+ candidates.push(entry);
182
162
  }
163
+ }
183
164
 
184
- if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
185
- const score = getVisualElementScore(entry, pointerStackIndex);
186
- if (!best || score > best.score) {
187
- best = { element: entry, score };
188
- }
165
+ if (candidates.length === 0) return null;
166
+
167
+ // candidates are in visual stacking order (topmost first, from elementsFromPoint).
168
+ // Start with the topmost and only replace with a descendant that is more
169
+ // specific within the same visual subtree. Never jump to an unrelated
170
+ // element that happens to be painted behind the current pick.
171
+ let best = candidates[0];
172
+
173
+ for (let i = 1; i < candidates.length; i++) {
174
+ const candidate = candidates[i];
175
+ if (best.contains(candidate)) {
176
+ best = candidate;
189
177
  }
190
- pointerStackIndex += 1;
191
178
  }
192
179
 
193
- return best?.element ?? null;
180
+ return best;
194
181
  }
195
182
 
196
183
  // ─── Raster detection ────────────────────────────────────────────────────────
@@ -151,7 +151,6 @@ export function useDomEditSession({
151
151
  setAgentModalOpen,
152
152
  setAgentPromptSelectionContext,
153
153
  setAgentModalAnchorPoint,
154
- preloadAgentPromptSnippet,
155
154
  handleAskAgent,
156
155
  handleAgentModalSubmit,
157
156
  } = useAskAgentModal({
@@ -181,10 +180,6 @@ export function useDomEditSession({
181
180
  applyDomSelection,
182
181
  resolveDomSelectionFromPreviewPoint,
183
182
  updateDomEditHoverSelection,
184
- preloadAgentPromptSnippet,
185
- setAgentPromptSelectionContext,
186
- setAgentModalAnchorPoint,
187
- setAgentModalOpen,
188
183
  onClickToSource,
189
184
  });
190
185
 
@@ -1,16 +1,8 @@
1
1
  import { useCallback } from "react";
2
2
  import { liveTime, usePlayerStore } from "../player";
3
- import {
4
- getPreviewLocalPointer,
5
- buildRasterClickSelectionContext,
6
- pauseStudioPreviewPlayback,
7
- } from "../utils/studioPreviewHelpers";
3
+ import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
8
4
  import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability";
9
- import {
10
- isLargeRasterDomEditSelection,
11
- type DomEditSelection,
12
- } from "../components/editor/domEditing";
13
- import type { AgentModalAnchorPoint } from "../utils/studioHelpers";
5
+ import { type DomEditSelection } from "../components/editor/domEditing";
14
6
 
15
7
  // ── Types ──
16
8
 
@@ -32,12 +24,6 @@ export interface UsePreviewInteractionParams {
32
24
  ) => DomEditSelection | null;
33
25
  updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
34
26
 
35
- // From useAskAgentModal
36
- preloadAgentPromptSnippet: (selection: DomEditSelection) => Promise<void>;
37
- setAgentPromptSelectionContext: (context: string | undefined) => void;
38
- setAgentModalAnchorPoint: (point: AgentModalAnchorPoint | null) => void;
39
- setAgentModalOpen: (open: boolean) => void;
40
-
41
27
  onClickToSource?: (selection: DomEditSelection) => void;
42
28
  }
43
29
 
@@ -51,10 +37,6 @@ export function usePreviewInteraction({
51
37
  applyDomSelection,
52
38
  resolveDomSelectionFromPreviewPoint,
53
39
  updateDomEditHoverSelection,
54
- preloadAgentPromptSnippet,
55
- setAgentPromptSelectionContext,
56
- setAgentModalAnchorPoint,
57
- setAgentModalOpen,
58
40
  onClickToSource,
59
41
  }: UsePreviewInteractionParams) {
60
42
  const handlePreviewCanvasMouseDown = useCallback(
@@ -69,37 +51,17 @@ export function usePreviewInteraction({
69
51
  }
70
52
  e.preventDefault();
71
53
  e.stopPropagation();
72
- const localPointer = previewIframeRef.current
73
- ? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
74
- : null;
75
54
  applyDomSelection(nextSelection, { additive: e.shiftKey });
76
55
  if (!e.shiftKey && e.altKey && onClickToSource) {
77
56
  onClickToSource(nextSelection);
78
57
  }
79
- if (
80
- !e.shiftKey &&
81
- localPointer &&
82
- isLargeRasterDomEditSelection(nextSelection, localPointer.viewport)
83
- ) {
84
- setAgentPromptSelectionContext(
85
- buildRasterClickSelectionContext(nextSelection, localPointer),
86
- );
87
- setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY });
88
- void preloadAgentPromptSnippet(nextSelection);
89
- setAgentModalOpen(true);
90
- }
91
58
  },
92
59
  [
93
60
  applyDomSelection,
94
61
  captionEditMode,
95
62
  compositionLoading,
96
63
  onClickToSource,
97
- preloadAgentPromptSnippet,
98
64
  resolveDomSelectionFromPreviewPoint,
99
- previewIframeRef,
100
- setAgentModalAnchorPoint,
101
- setAgentModalOpen,
102
- setAgentPromptSelectionContext,
103
65
  ],
104
66
  );
105
67
 
@@ -17,7 +17,7 @@ interface RecordEditInput {
17
17
  files: Record<string, { before: string; after: string }>;
18
18
  }
19
19
 
20
- interface UseManifestPersistenceParams {
20
+ interface UsePreviewPersistenceParams {
21
21
  projectId: string | null;
22
22
  showToast: (message: string, tone?: "error" | "info") => void;
23
23
  readOptionalProjectFile: (path: string) => Promise<string>;
@@ -26,15 +26,18 @@ interface UseManifestPersistenceParams {
26
26
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
27
27
  activeCompPathRef: React.MutableRefObject<string | null>;
28
28
  /** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
29
- * Used to suppress SSE echoes so we don't double-reload after our own saves. */
29
+ * Used to suppress file-change echoes so we don't reload after our own saves. */
30
30
  domEditSaveTimestampRef: React.MutableRefObject<number>;
31
+ /** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change
32
+ * events for these paths are always suppressed since the preview is already up-to-date. */
33
+ pendingTimelineEditPathRef?: React.MutableRefObject<Set<string>>;
31
34
  /** Called to reload the preview after undo/redo or external file changes. */
32
35
  reloadPreview: () => void;
33
36
  }
34
37
 
35
38
  // ── Hook ──
36
39
 
37
- export function useManifestPersistence({
40
+ export function usePreviewPersistence({
38
41
  projectId,
39
42
  showToast: _showToast,
40
43
  readOptionalProjectFile: _readOptionalProjectFile,
@@ -44,7 +47,8 @@ export function useManifestPersistence({
44
47
  activeCompPathRef: _activeCompPathRef,
45
48
  domEditSaveTimestampRef,
46
49
  reloadPreview,
47
- }: UseManifestPersistenceParams) {
50
+ pendingTimelineEditPathRef,
51
+ }: UsePreviewPersistenceParams) {
48
52
  void _showToast;
49
53
  void _recordEdit;
50
54
  void _activeCompPathRef;
@@ -162,8 +166,11 @@ export function useManifestPersistence({
162
166
  const handler = (payload?: unknown) => {
163
167
  const changedPath = readStudioFileChangePath(payload);
164
168
  if (!changedPath) return;
165
- const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
166
- // External file change — reload unless it's an echo of our own save.
169
+ const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 4000;
170
+ if (pendingTimelineEditPathRef?.current.has(changedPath)) {
171
+ pendingTimelineEditPathRef.current.delete(changedPath);
172
+ return;
173
+ }
167
174
  if (!recentDomEditSave) {
168
175
  reloadPreview();
169
176
  }