@hyperframes/studio 0.6.47 → 0.6.49

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.
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
1
+ import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
2
2
  import { Player } from "../../player";
3
3
  import {
4
4
  DEFAULT_PREVIEW_ZOOM,
@@ -14,7 +14,7 @@ import {
14
14
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
15
15
  interface NLEPreviewProps {
16
16
  projectId: string;
17
- iframeRef: Ref<HTMLIFrameElement>;
17
+ iframeRef: RefObject<HTMLIFrameElement | null>;
18
18
  onIframeLoad: () => void;
19
19
  onCompositionLoadingChange?: (loading: boolean) => void;
20
20
  portrait?: boolean;
@@ -37,6 +37,11 @@ const ZOOM_HUD_TIMEOUT_MS = 1200;
37
37
  const ZOOM_SETTLE_MS = 200;
38
38
  const PREVIEW_STAGE_INSET_PX = 16;
39
39
 
40
+ interface PreviewCompositionSize {
41
+ width: number;
42
+ height: number;
43
+ }
44
+
40
45
  function isPreviewAtFit(state: PreviewZoomState): boolean {
41
46
  return (
42
47
  Math.abs(state.zoomPercent - 100) < 0.5 &&
@@ -56,14 +61,41 @@ function loadInitialZoom(): PreviewZoomState {
56
61
  : DEFAULT_PREVIEW_ZOOM;
57
62
  }
58
63
 
59
- function resolvePreviewStageSize(
64
+ // fallow-ignore-next-line complexity
65
+ function readPreviewCompositionSize(
66
+ iframe: HTMLIFrameElement | null,
67
+ ): PreviewCompositionSize | null {
68
+ try {
69
+ const doc = iframe?.contentDocument;
70
+ const root =
71
+ doc?.querySelector("[data-composition-id][data-width][data-height]") ??
72
+ doc?.querySelector("[data-width][data-height]");
73
+ if (!root) return null;
74
+ const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10);
75
+ const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10);
76
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
77
+ return null;
78
+ }
79
+ return { width, height };
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ export function resolvePreviewStageSize(
60
86
  viewportWidth: number,
61
87
  viewportHeight: number,
88
+ compositionSize: PreviewCompositionSize | null,
62
89
  portrait: boolean | undefined,
63
90
  ): { width: number; height: number } {
64
91
  const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX);
65
92
  const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX);
66
- const aspectRatio = portrait ? 9 / 16 : 16 / 9;
93
+ const aspectRatio =
94
+ compositionSize && compositionSize.width > 0 && compositionSize.height > 0
95
+ ? compositionSize.width / compositionSize.height
96
+ : portrait
97
+ ? 9 / 16
98
+ : 16 / 9;
67
99
 
68
100
  if (availableWidth === 0 || availableHeight === 0) {
69
101
  return { width: 0, height: 0 };
@@ -95,10 +127,12 @@ export const NLEPreview = memo(function NLEPreview({
95
127
  const activeKey = getPreviewPlayerKey({ projectId, directUrl });
96
128
  const viewportRef = useRef<HTMLDivElement>(null);
97
129
  const stageRef = useRef<HTMLDivElement>(null);
130
+ const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
98
131
  useEffect(() => {
99
132
  onStageRef?.(stageRef);
100
133
  }, [onStageRef]);
101
- const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait));
134
+ const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
135
+ const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
102
136
 
103
137
  const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
104
138
  const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
@@ -127,14 +161,29 @@ export const NLEPreview = memo(function NLEPreview({
127
161
 
128
162
  const updateStageSize = () => {
129
163
  const rect = viewport.getBoundingClientRect();
130
- setStageSize(resolvePreviewStageSize(rect.width, rect.height, portrait));
164
+ setStageSize(resolvePreviewStageSize(rect.width, rect.height, compositionSize, portrait));
131
165
  };
132
166
 
133
167
  updateStageSize();
134
168
  const observer = new ResizeObserver(updateStageSize);
135
169
  observer.observe(viewport);
136
170
  return () => observer.disconnect();
137
- }, [portrait]);
171
+ }, [compositionSize, portrait]);
172
+
173
+ const updateCompositionSizeFromPreview = useCallback(() => {
174
+ const next = readPreviewCompositionSize(previewIframeRef.current);
175
+ setCompositionSize((prev) =>
176
+ prev?.width === next?.width && prev?.height === next?.height ? prev : next,
177
+ );
178
+ }, []);
179
+
180
+ const setPreviewIframeRef = useCallback(
181
+ (node: HTMLIFrameElement | null) => {
182
+ previewIframeRef.current = node;
183
+ iframeRef.current = node;
184
+ },
185
+ [iframeRef],
186
+ );
138
187
 
139
188
  const stageSizeRef = useRef(stageSize);
140
189
  stageSizeRef.current = stageSize;
@@ -403,10 +452,11 @@ export const NLEPreview = memo(function NLEPreview({
403
452
  )}
404
453
  <Player
405
454
  key={activeKey}
406
- ref={iframeRef}
455
+ ref={setPreviewIframeRef}
407
456
  projectId={directUrl ? undefined : projectId}
408
457
  directUrl={directUrl}
409
458
  onLoad={() => {
459
+ updateCompositionSizeFromPreview();
410
460
  onIframeLoad();
411
461
  applyInitialZoom();
412
462
  }}
@@ -81,7 +81,7 @@ export interface UseDomEditCommitsParams {
81
81
  buildDomSelectionFromTarget: (
82
82
  target: HTMLElement,
83
83
  options?: { preferClipAncestor?: boolean },
84
- ) => DomEditSelection | null;
84
+ ) => Promise<DomEditSelection | null>;
85
85
  }
86
86
 
87
87
  // ── Hook ──
@@ -128,6 +128,7 @@ export function useDomEditCommits({
128
128
  [fileTree, projectId, importedFontAssetsRef],
129
129
  );
130
130
 
131
+ // fallow-ignore-next-line complexity
131
132
  const persistDomEditOperations: PersistDomEditOperations = useCallback(
132
133
  async (selection, operations, options) => {
133
134
  const pid = projectIdRef.current;
@@ -232,6 +233,7 @@ export function useDomEditCommits({
232
233
 
233
234
  // ── Position patch helper ──
234
235
 
236
+ // fallow-ignore-next-line complexity
235
237
  const commitPositionPatchToHtml = useCallback(
236
238
  (
237
239
  selection: DomEditSelection,
@@ -244,6 +246,7 @@ export function useDomEditCommits({
244
246
  coalesceKey: options.coalesceKey,
245
247
  skipRefresh: options.skipRefresh ?? true,
246
248
  });
249
+ // fallow-ignore-next-line complexity
247
250
  }).catch((error) => {
248
251
  const message = error instanceof Error ? error.message : "Failed to save position";
249
252
  showToast(message);
@@ -251,6 +254,9 @@ export function useDomEditCommits({
251
254
  source: "dom_edit",
252
255
  label: options.label,
253
256
  error_message: message,
257
+ target_id: selection.id ?? undefined,
258
+ target_selector: selection.selector ?? undefined,
259
+ target_source_file: selection.sourceFile ?? undefined,
254
260
  });
255
261
  });
256
262
  },
@@ -333,6 +339,7 @@ export function useDomEditCommits({
333
339
 
334
340
  // ── Motion commits (HTML-attribute–backed) ──
335
341
 
342
+ // fallow-ignore-next-line complexity
336
343
  const handleDomMotionCommit = useCallback(
337
344
  (
338
345
  selection: DomEditSelection,
@@ -359,6 +366,7 @@ export function useDomEditCommits({
359
366
  [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
360
367
  );
361
368
 
369
+ // fallow-ignore-next-line complexity
362
370
  const handleDomMotionClear = useCallback(
363
371
  (selection: DomEditSelection) => {
364
372
  const clearPatches = buildClearMotionPatches(selection.element);
@@ -387,6 +395,7 @@ export function useDomEditCommits({
387
395
  [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
388
396
  );
389
397
 
398
+ // fallow-ignore-next-line complexity
390
399
  const handleDomEditElementDelete = useCallback(
391
400
  async (selection: DomEditSelection) => {
392
401
  const pid = projectIdRef.current;
@@ -231,7 +231,7 @@ export function useDomEditSession({
231
231
  useEffect(() => {
232
232
  if (!previewIframe) return;
233
233
 
234
- const syncSelectionFromDocument = () => {
234
+ const syncSelectionFromDocument = async () => {
235
235
  if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
236
236
  const currentSelection = domEditSelectionRef.current;
237
237
  if (!currentSelection) return;
@@ -249,7 +249,7 @@ export function useDomEditSession({
249
249
  return;
250
250
  }
251
251
 
252
- const nextSelection = buildDomSelectionFromTarget(nextElement);
252
+ const nextSelection = await buildDomSelectionFromTarget(nextElement);
253
253
  if (nextSelection) {
254
254
  applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
255
255
  }
@@ -257,13 +257,13 @@ export function useDomEditSession({
257
257
 
258
258
  syncPreviewHistoryHotkey(previewIframe);
259
259
  void applyStudioManualEditsToPreviewRef.current(previewIframe);
260
- syncSelectionFromDocument();
260
+ void syncSelectionFromDocument();
261
261
  refreshPreviewDocumentVersion();
262
262
 
263
263
  const handleLoad = () => {
264
264
  syncPreviewHistoryHotkey(previewIframe);
265
265
  void applyStudioManualEditsToPreviewRef.current(previewIframe);
266
- syncSelectionFromDocument();
266
+ void syncSelectionFromDocument();
267
267
  refreshPreviewDocumentVersion();
268
268
  };
269
269
 
@@ -38,7 +38,7 @@ export interface UseDomEditTextCommitsParams {
38
38
  buildDomSelectionFromTarget: (
39
39
  target: HTMLElement,
40
40
  options?: { preferClipAncestor?: boolean },
41
- ) => DomEditSelection | null;
41
+ ) => Promise<DomEditSelection | null>;
42
42
  persistDomEditOperations: PersistDomEditOperations;
43
43
  resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
44
44
  }
@@ -231,7 +231,7 @@ export function useDomEditTextCommits({
231
231
  if (doc) {
232
232
  const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
233
233
  if (refreshed) {
234
- const nextSelection = buildDomSelectionFromTarget(refreshed);
234
+ const nextSelection = await buildDomSelectionFromTarget(refreshed);
235
235
  if (nextSelection) {
236
236
  applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
237
237
  }
@@ -287,7 +287,7 @@ export function useDomEditTextCommits({
287
287
  if (doc) {
288
288
  const refreshed = findElementForSelection(doc, selection, activeCompPath);
289
289
  if (refreshed) {
290
- const nextSelection = buildDomSelectionFromTarget(refreshed);
290
+ const nextSelection = await buildDomSelectionFromTarget(refreshed);
291
291
  if (nextSelection) {
292
292
  applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
293
293
  }
@@ -60,17 +60,19 @@ export interface UseDomSelectionReturn {
60
60
  buildDomSelectionFromTarget: (
61
61
  target: HTMLElement,
62
62
  options?: { preferClipAncestor?: boolean },
63
- ) => DomEditSelection | null;
63
+ ) => Promise<DomEditSelection | null>;
64
64
  resolveDomSelectionFromPreviewPoint: (
65
65
  clientX: number,
66
66
  clientY: number,
67
67
  options?: { preferClipAncestor?: boolean },
68
- ) => DomEditSelection | null;
68
+ ) => Promise<DomEditSelection | null>;
69
69
  updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
70
- buildDomSelectionForTimelineElement: (element: TimelineElement) => DomEditSelection | null;
71
- handleTimelineElementSelect: (element: TimelineElement | null) => void;
72
- refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
73
- refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => void;
70
+ buildDomSelectionForTimelineElement: (
71
+ element: TimelineElement,
72
+ ) => Promise<DomEditSelection | null>;
73
+ handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
74
+ refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
75
+ refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
74
76
  }
75
77
 
76
78
  // ── Hook ──
@@ -193,24 +195,34 @@ export function useDomSelection({
193
195
  }, [applyDomSelection]);
194
196
 
195
197
  const buildDomSelectionFromTarget = useCallback(
196
- (target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
198
+ (
199
+ target: HTMLElement,
200
+ options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
201
+ ) => {
197
202
  return resolveDomEditSelection(target, {
198
203
  activeCompositionPath: activeCompPath,
199
204
  isMasterView,
200
205
  preferClipAncestor: options?.preferClipAncestor,
206
+ skipSourceProbe: options?.skipSourceProbe,
207
+ projectId,
201
208
  });
202
209
  },
203
- [activeCompPath, isMasterView],
210
+ [activeCompPath, isMasterView, projectId],
204
211
  );
205
212
 
206
213
  const resolveDomSelectionFromPreviewPoint = useCallback(
207
- (clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
214
+ async (
215
+ clientX: number,
216
+ clientY: number,
217
+ options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
218
+ ) => {
208
219
  const iframe = previewIframeRef.current;
209
220
  if (!iframe || captionEditMode) return null;
210
221
  const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
211
222
  if (!target) return null;
212
223
  return buildDomSelectionFromTarget(target, {
213
224
  preferClipAncestor: options?.preferClipAncestor,
225
+ skipSourceProbe: options?.skipSourceProbe,
214
226
  });
215
227
  },
216
228
  [activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
@@ -223,7 +235,7 @@ export function useDomSelection({
223
235
  }, []);
224
236
 
225
237
  const buildDomSelectionForTimelineElement = useCallback(
226
- (element: TimelineElement): DomEditSelection | null => {
238
+ async (element: TimelineElement): Promise<DomEditSelection | null> => {
227
239
  const iframe = previewIframeRef.current;
228
240
  let doc: Document | null = null;
229
241
  try {
@@ -248,21 +260,21 @@ export function useDomSelection({
248
260
  );
249
261
 
250
262
  const handleTimelineElementSelect = useCallback(
251
- (element: TimelineElement | null) => {
263
+ async (element: TimelineElement | null) => {
252
264
  if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
253
265
  if (!element) {
254
266
  applyDomSelection(null, { revealPanel: false });
255
267
  return;
256
268
  }
257
269
 
258
- const selection = buildDomSelectionForTimelineElement(element);
270
+ const selection = await buildDomSelectionForTimelineElement(element);
259
271
  if (selection) applyDomSelection(selection);
260
272
  },
261
273
  [applyDomSelection, buildDomSelectionForTimelineElement],
262
274
  );
263
275
 
264
276
  const refreshDomEditSelectionFromPreview = useCallback(
265
- (selection: DomEditSelection) => {
277
+ async (selection: DomEditSelection) => {
266
278
  const iframe = previewIframeRef.current;
267
279
  let doc: Document | null = null;
268
280
  try {
@@ -275,7 +287,7 @@ export function useDomSelection({
275
287
  const element = findElementForSelection(doc, selection, activeCompPath);
276
288
  if (!element) return;
277
289
 
278
- const nextSelection = buildDomSelectionFromTarget(element);
290
+ const nextSelection = await buildDomSelectionFromTarget(element);
279
291
  if (nextSelection) {
280
292
  applyDomSelection(nextSelection, {
281
293
  revealPanel: false,
@@ -287,7 +299,7 @@ export function useDomSelection({
287
299
  );
288
300
 
289
301
  const refreshDomEditGroupSelectionsFromPreview = useCallback(
290
- (selections: DomEditSelection[]) => {
302
+ async (selections: DomEditSelection[]) => {
291
303
  const iframe = previewIframeRef.current;
292
304
  let doc: Document | null = null;
293
305
  try {
@@ -301,7 +313,7 @@ export function useDomSelection({
301
313
  for (const selection of selections) {
302
314
  const element = findElementForSelection(doc, selection, activeCompPath);
303
315
  if (!element) continue;
304
- const nextSelection = buildDomSelectionFromTarget(element);
316
+ const nextSelection = await buildDomSelectionFromTarget(element);
305
317
  if (nextSelection) nextGroup.push(nextSelection);
306
318
  }
307
319
  if (nextGroup.length === 0) return;
@@ -20,8 +20,8 @@ export interface UsePreviewInteractionParams {
20
20
  resolveDomSelectionFromPreviewPoint: (
21
21
  clientX: number,
22
22
  clientY: number,
23
- options?: { preferClipAncestor?: boolean },
24
- ) => DomEditSelection | null;
23
+ options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
24
+ ) => Promise<DomEditSelection | null>;
25
25
  updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
26
26
 
27
27
  onClickToSource?: (selection: DomEditSelection) => void;
@@ -40,9 +40,9 @@ export function usePreviewInteraction({
40
40
  onClickToSource,
41
41
  }: UsePreviewInteractionParams) {
42
42
  const handlePreviewCanvasMouseDown = useCallback(
43
- (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
43
+ async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
44
44
  if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
45
- const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
45
+ const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
46
46
  preferClipAncestor: options?.preferClipAncestor ?? false,
47
47
  });
48
48
  if (!nextSelection) {
@@ -66,14 +66,15 @@ export function usePreviewInteraction({
66
66
  );
67
67
 
68
68
  const handlePreviewCanvasPointerMove = useCallback(
69
- (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
69
+ async (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
70
70
  if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
71
71
  updateDomEditHoverSelection(null);
72
72
  return null;
73
73
  }
74
74
 
75
- const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
75
+ const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
76
76
  preferClipAncestor: options?.preferClipAncestor ?? false,
77
+ skipSourceProbe: true,
77
78
  });
78
79
  updateDomEditHoverSelection(nextSelection);
79
80
  return nextSelection;
@@ -25,7 +25,7 @@ interface UseStudioUrlStateParams {
25
25
  buildDomSelectionFromTarget: (
26
26
  target: HTMLElement,
27
27
  options?: { preferClipAncestor?: boolean },
28
- ) => DomEditSelection | null;
28
+ ) => Promise<DomEditSelection | null>;
29
29
  applyDomSelection: (
30
30
  selection: DomEditSelection | null,
31
31
  options?: {
@@ -140,10 +140,11 @@ export function useStudioUrlState({
140
140
  return;
141
141
  }
142
142
 
143
- const selection = buildDomSelectionFromTarget(element, { preferClipAncestor: false });
144
- applyDomSelection(selection, { revealPanel: false });
145
143
  hydratedSelectionRef.current = true;
146
144
  pendingSelectionRef.current = null;
145
+ void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((selection) => {
146
+ applyDomSelection(selection, { revealPanel: false });
147
+ });
147
148
  }, [
148
149
  activeCompPath,
149
150
  applyDomSelection,