@hyperframes/studio 0.6.30 → 0.6.32

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.
@@ -12,7 +12,6 @@ import {
12
12
  type PreviewZoomState,
13
13
  } from "./previewZoom";
14
14
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
15
-
16
15
  interface NLEPreviewProps {
17
16
  projectId: string;
18
17
  iframeRef: Ref<HTMLIFrameElement>;
@@ -20,8 +19,8 @@ interface NLEPreviewProps {
20
19
  onCompositionLoadingChange?: (loading: boolean) => void;
21
20
  portrait?: boolean;
22
21
  directUrl?: string;
23
- refreshKey?: number;
24
22
  suppressLoadingOverlay?: boolean;
23
+ onStageRef?: (ref: React.RefObject<HTMLDivElement | null>) => void;
25
24
  }
26
25
 
27
26
  export function getPreviewPlayerKey({
@@ -30,7 +29,6 @@ export function getPreviewPlayerKey({
30
29
  }: {
31
30
  projectId: string;
32
31
  directUrl?: string;
33
- refreshKey?: number;
34
32
  }): string {
35
33
  return directUrl ?? projectId;
36
34
  }
@@ -91,16 +89,16 @@ export const NLEPreview = memo(function NLEPreview({
91
89
  onCompositionLoadingChange,
92
90
  portrait,
93
91
  directUrl,
94
- refreshKey,
95
92
  suppressLoadingOverlay,
93
+ onStageRef,
96
94
  }: NLEPreviewProps) {
97
- const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
98
- const prevRefreshKeyRef = useRef(refreshKey);
95
+ const activeKey = getPreviewPlayerKey({ projectId, directUrl });
99
96
  const viewportRef = useRef<HTMLDivElement>(null);
100
97
  const stageRef = useRef<HTMLDivElement>(null);
101
- const [retiringKey, setRetiringKey] = useState<string | null>(null);
98
+ useEffect(() => {
99
+ onStageRef?.(stageRef);
100
+ }, [onStageRef]);
102
101
  const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait));
103
- const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
104
102
 
105
103
  const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
106
104
  const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
@@ -120,7 +118,6 @@ export const NLEPreview = memo(function NLEPreview({
120
118
  return () => {
121
119
  if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
122
120
  if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
123
- if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
124
121
  };
125
122
  }, []);
126
123
 
@@ -205,14 +202,6 @@ export const NLEPreview = memo(function NLEPreview({
205
202
  [applyTransform],
206
203
  );
207
204
 
208
- if (refreshKey !== prevRefreshKeyRef.current) {
209
- const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
210
- prevRefreshKeyRef.current = refreshKey;
211
- setRetiringKey(oldKey);
212
- }
213
-
214
- const activeKey = `${baseKey}:${refreshKey ?? 0}`;
215
-
216
205
  const applyInitialZoom = useCallback(() => {
217
206
  const z = zoomRef.current;
218
207
  if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
@@ -220,16 +209,6 @@ export const NLEPreview = memo(function NLEPreview({
220
209
  }
221
210
  }, [writeTransform]);
222
211
 
223
- const handleNewPlayerLoad = () => {
224
- onIframeLoad();
225
- applyInitialZoom();
226
- if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
227
- retiringTimerRef.current = setTimeout(() => {
228
- setRetiringKey(null);
229
- retiringTimerRef.current = null;
230
- }, 160);
231
- };
232
-
233
212
  useEffect(() => {
234
213
  const viewport = viewportRef.current;
235
214
  if (!viewport) return;
@@ -412,32 +391,17 @@ export const NLEPreview = memo(function NLEPreview({
412
391
  }}
413
392
  data-testid="preview-zoom-stage"
414
393
  >
415
- {retiringKey && (
416
- <Player
417
- key={retiringKey}
418
- projectId={directUrl ? undefined : projectId}
419
- directUrl={directUrl}
420
- onLoad={() => {}}
421
- portrait={portrait}
422
- style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
423
- />
424
- )}
425
394
  <Player
426
395
  key={activeKey}
427
396
  ref={iframeRef}
428
397
  projectId={directUrl ? undefined : projectId}
429
398
  directUrl={directUrl}
430
- onLoad={
431
- retiringKey
432
- ? handleNewPlayerLoad
433
- : () => {
434
- onIframeLoad();
435
- applyInitialZoom();
436
- }
437
- }
399
+ onLoad={() => {
400
+ onIframeLoad();
401
+ applyInitialZoom();
402
+ }}
438
403
  onCompositionLoadingChange={onCompositionLoadingChange}
439
404
  portrait={portrait}
440
- style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
441
405
  suppressLoadingOverlay={suppressLoadingOverlay}
442
406
  />
443
407
  </div>
@@ -0,0 +1,109 @@
1
+ import { useCallback, useState, type RefObject } from "react";
2
+ import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
3
+
4
+ interface UsePreviewBlockDropOptions {
5
+ portrait?: boolean;
6
+ stageRef: RefObject<HTMLDivElement | null>;
7
+ onBlockDrop?: (blockName: string, position: { left: number; top: number }) => void;
8
+ }
9
+
10
+ interface BlockDropPayload {
11
+ name: string;
12
+ dimensions?: { width: number; height: number };
13
+ }
14
+
15
+ function parseBlockPayload(raw: string): BlockDropPayload | null {
16
+ try {
17
+ const parsed = JSON.parse(raw) as {
18
+ name?: string;
19
+ dimensions?: { width: number; height: number };
20
+ };
21
+ return parsed.name ? (parsed as BlockDropPayload) : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function resolveCompositionPosition(
28
+ clientX: number,
29
+ clientY: number,
30
+ stageRect: DOMRect,
31
+ portrait: boolean | undefined,
32
+ ): { left: number; top: number } | null {
33
+ if (stageRect.width === 0 || stageRect.height === 0) return null;
34
+
35
+ const normalizedX = (clientX - stageRect.left) / stageRect.width;
36
+ const normalizedY = (clientY - stageRect.top) / stageRect.height;
37
+
38
+ const compWidth = portrait ? 1080 : 1920;
39
+ const compHeight = portrait ? 1920 : 1080;
40
+
41
+ return {
42
+ left: Math.max(0, Math.min(normalizedX * compWidth, compWidth)),
43
+ top: Math.max(0, Math.min(normalizedY * compHeight, compHeight)),
44
+ };
45
+ }
46
+
47
+ function centerBlockAtPosition(
48
+ pos: { left: number; top: number },
49
+ block: BlockDropPayload,
50
+ ): { left: number; top: number } {
51
+ const blockW = block.dimensions?.width ?? 0;
52
+ const blockH = block.dimensions?.height ?? 0;
53
+ return {
54
+ left: Math.max(0, pos.left - blockW / 2),
55
+ top: Math.max(0, pos.top - blockH / 2),
56
+ };
57
+ }
58
+
59
+ export function usePreviewBlockDrop({
60
+ portrait,
61
+ stageRef,
62
+ onBlockDrop,
63
+ }: UsePreviewBlockDropOptions) {
64
+ const [isDragOver, setIsDragOver] = useState(false);
65
+
66
+ const handleDragOver = useCallback(
67
+ (e: React.DragEvent) => {
68
+ if (!onBlockDrop) return;
69
+ if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
70
+ e.preventDefault();
71
+ e.dataTransfer.dropEffect = "copy";
72
+ setIsDragOver(true);
73
+ },
74
+ [onBlockDrop],
75
+ );
76
+
77
+ const handleDragLeave = useCallback(() => {
78
+ setIsDragOver(false);
79
+ }, []);
80
+
81
+ // fallow-ignore-next-line complexity
82
+ const handleDrop = useCallback(
83
+ (e: React.DragEvent) => {
84
+ setIsDragOver(false);
85
+ if (!onBlockDrop) return;
86
+
87
+ const payload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
88
+ if (!payload) return;
89
+ e.preventDefault();
90
+
91
+ const block = parseBlockPayload(payload);
92
+ const stage = stageRef.current;
93
+ if (!block || !stage) return;
94
+
95
+ const pos = resolveCompositionPosition(
96
+ e.clientX,
97
+ e.clientY,
98
+ stage.getBoundingClientRect(),
99
+ portrait,
100
+ );
101
+ if (!pos) return;
102
+
103
+ onBlockDrop(block.name, centerBlockAtPosition(pos, block));
104
+ },
105
+ [onBlockDrop, stageRef, portrait],
106
+ );
107
+
108
+ return { isDragOver, handleDragOver, handleDragLeave, handleDrop };
109
+ }
@@ -1,5 +1,4 @@
1
1
  import { memo, useState, useCallback, useRef, useEffect } from "react";
2
- import { createPortal } from "react-dom";
3
2
  import { useBlockCatalog } from "../../hooks/useBlockCatalog";
4
3
  import {
5
4
  BLOCK_CATEGORIES,
@@ -8,12 +7,19 @@ import {
8
7
  } from "../../utils/blockCategories";
9
8
  import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
10
9
 
10
+ export interface BlockPreviewInfo {
11
+ videoUrl?: string;
12
+ posterUrl?: string;
13
+ title: string;
14
+ }
15
+
11
16
  interface BlocksTabProps {
12
17
  onAddBlock: (blockName: string) => void;
18
+ onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
13
19
  }
14
20
 
15
21
  // fallow-ignore-next-line complexity
16
- export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps) {
22
+ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }: BlocksTabProps) {
17
23
  const { loading, error, search, setSearch, category, setCategory, filteredBlocks } =
18
24
  useBlockCatalog();
19
25
 
@@ -114,6 +120,7 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps)
114
120
  videoUrl={block.preview?.video}
115
121
  dimensions={dims}
116
122
  onAdd={() => onAddBlock(block.name)}
123
+ onPreview={onPreviewBlock}
117
124
  />
118
125
  );
119
126
  })}
@@ -163,6 +170,7 @@ function BlockCard({
163
170
  videoUrl,
164
171
  dimensions,
165
172
  onAdd,
173
+ onPreview,
166
174
  }: {
167
175
  name: string;
168
176
  title: string;
@@ -173,52 +181,35 @@ function BlockCard({
173
181
  videoUrl?: string;
174
182
  dimensions?: { width: number; height: number };
175
183
  onAdd: () => void;
184
+ onPreview?: (preview: BlockPreviewInfo | null) => void;
176
185
  }) {
177
186
  const [hovered, setHovered] = useState(false);
178
187
  const [adding, setAdding] = useState(false);
179
188
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
180
- const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
181
- const videoRef = useRef<HTMLVideoElement | null>(null);
182
189
  const colors = getCategoryColors(category);
183
190
  const needsWebGL = tags?.includes("html-in-canvas") || tags?.includes("webgl");
184
191
 
185
- const cancelLeave = useCallback(() => {
186
- if (leaveTimer.current) {
187
- clearTimeout(leaveTimer.current);
188
- leaveTimer.current = null;
189
- }
190
- }, []);
191
-
192
192
  const handleEnter = useCallback(() => {
193
- cancelLeave();
194
- hoverTimer.current = setTimeout(() => setHovered(true), 500);
195
- }, [cancelLeave]);
196
-
197
- const dismiss = useCallback(() => {
198
- if (hoverTimer.current) {
199
- clearTimeout(hoverTimer.current);
200
- hoverTimer.current = null;
201
- }
202
- cancelLeave();
203
- setHovered(false);
204
- }, [cancelLeave]);
193
+ hoverTimer.current = setTimeout(() => {
194
+ setHovered(true);
195
+ onPreview?.({ videoUrl, posterUrl, title });
196
+ }, 300);
197
+ }, [onPreview, videoUrl, posterUrl, title]);
205
198
 
206
199
  const handleLeave = useCallback(() => {
207
200
  if (hoverTimer.current) {
208
201
  clearTimeout(hoverTimer.current);
209
202
  hoverTimer.current = null;
210
203
  }
211
- leaveTimer.current = setTimeout(() => setHovered(false), 150);
212
- }, []);
204
+ setHovered(false);
205
+ onPreview?.(null);
206
+ }, [onPreview]);
213
207
 
214
208
  useEffect(() => {
215
- if (!hovered) return;
216
- const onKey = (e: KeyboardEvent) => {
217
- if (e.key === "Escape") dismiss();
209
+ return () => {
210
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
218
211
  };
219
- window.addEventListener("keydown", onKey);
220
- return () => window.removeEventListener("keydown", onKey);
221
- }, [hovered, dismiss]);
212
+ }, []);
222
213
 
223
214
  const handleAdd = useCallback(
224
215
  (e: React.MouseEvent) => {
@@ -251,7 +242,6 @@ function BlockCard({
251
242
  <div className="aspect-video w-full overflow-hidden relative">
252
243
  {hovered && videoUrl ? (
253
244
  <video
254
- ref={videoRef}
255
245
  src={videoUrl}
256
246
  autoPlay
257
247
  muted
@@ -313,72 +303,6 @@ function BlockCard({
313
303
  </span>
314
304
  </div>
315
305
  </div>
316
-
317
- {/* Fullscreen hover preview */}
318
- {hovered &&
319
- (videoUrl || posterUrl) &&
320
- createPortal(
321
- <div
322
- className="fixed inset-0 z-50 flex items-center justify-center cursor-pointer"
323
- onClick={dismiss}
324
- onPointerEnter={cancelLeave}
325
- onPointerLeave={handleLeave}
326
- >
327
- <div className="bg-black/80 absolute inset-0" />
328
- <button
329
- type="button"
330
- onClick={dismiss}
331
- className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800/80 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
332
- >
333
- <svg
334
- width="16"
335
- height="16"
336
- viewBox="0 0 24 24"
337
- fill="none"
338
- stroke="currentColor"
339
- strokeWidth="2"
340
- strokeLinecap="round"
341
- strokeLinejoin="round"
342
- >
343
- <path d="M18 6 6 18" />
344
- <path d="m6 6 12 12" />
345
- </svg>
346
- </button>
347
- <div
348
- className="relative rounded-xl overflow-hidden shadow-2xl border border-neutral-600/30 cursor-default"
349
- style={{ width: "80vw", maxWidth: 1200, maxHeight: "80vh" }}
350
- onClick={(e) => e.stopPropagation()}
351
- >
352
- <div className="aspect-video bg-neutral-950">
353
- {videoUrl ? (
354
- <video
355
- src={videoUrl}
356
- autoPlay
357
- muted
358
- loop
359
- playsInline
360
- className="w-full h-full object-contain"
361
- />
362
- ) : (
363
- <img src={posterUrl} alt={title} className="w-full h-full object-contain" />
364
- )}
365
- </div>
366
- <div className="bg-neutral-900/95 px-4 py-3">
367
- <div className="text-[14px] font-semibold text-neutral-100">{title}</div>
368
- <div className="flex items-center gap-2 mt-1">
369
- <span className={`w-2 h-2 rounded-full ${colors.dot}`} />
370
- <span className={`text-[11px] ${colors.text}`}>
371
- {BLOCK_CATEGORIES.find((c) => c.id === category)?.label}
372
- </span>
373
- {duration != null && (
374
- <span className="text-[11px] text-neutral-500">{duration}s</span>
375
- )}
376
- </div>
377
- </div>
378
- </div>
379
- </div>,
380
- document.body,
381
- )}
382
306
  </div>
383
307
  );
384
308
  }
@@ -3,13 +3,14 @@ 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";
15
16
 
@@ -17,6 +18,7 @@ export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
17
18
 
18
19
  export interface LeftSidebarHandle {
19
20
  selectTab: (tab: SidebarTab) => void;
21
+ getTab: () => SidebarTab;
20
22
  }
21
23
 
22
24
  const STORAGE_KEY = "hf-studio-sidebar-tab";
@@ -53,6 +55,7 @@ interface LeftSidebarProps {
53
55
  linting?: boolean;
54
56
  onToggleCollapse?: () => void;
55
57
  onAddBlock?: (blockName: string) => void;
58
+ onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
56
59
  takeoverContent?: ReactNode;
57
60
  }
58
61
 
@@ -82,11 +85,14 @@ export const LeftSidebar = memo(
82
85
  linting,
83
86
  onToggleCollapse,
84
87
  onAddBlock,
88
+ onPreviewBlock,
85
89
  takeoverContent,
86
90
  },
87
91
  ref,
88
92
  ) {
89
93
  const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
94
+ const tabRef = useRef(tab);
95
+ tabRef.current = tab;
90
96
 
91
97
  const selectTab = useCallback((t: SidebarTab) => {
92
98
  setTab(t);
@@ -94,7 +100,9 @@ export const LeftSidebar = memo(
94
100
  trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
95
101
  }, []);
96
102
 
97
- useImperativeHandle(ref, () => ({ selectTab }), [selectTab]);
103
+ const getTab = useCallback(() => tabRef.current, []);
104
+
105
+ useImperativeHandle(ref, () => ({ selectTab, getTab }), [selectTab, getTab]);
98
106
 
99
107
  return (
100
108
  <div
@@ -159,7 +167,7 @@ export const LeftSidebar = memo(
159
167
  : "text-neutral-500 hover:text-neutral-200"
160
168
  }`}
161
169
  >
162
- Blocks
170
+ Catalog
163
171
  </button>
164
172
  )}
165
173
  </div>
@@ -239,7 +247,7 @@ export const LeftSidebar = memo(
239
247
  )}
240
248
 
241
249
  {STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && onAddBlock && (
242
- <BlocksTab onAddBlock={onAddBlock} />
250
+ <BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
243
251
  )}
244
252
 
245
253
  {/* Lint button pinned at the bottom */}
@@ -248,6 +248,24 @@ export function useAppHotkeys({
248
248
  }
249
249
  }
250
250
 
251
+ // F — toggle fullscreen preview
252
+ if (
253
+ event.key.toLowerCase() === "f" &&
254
+ !event.metaKey &&
255
+ !event.ctrlKey &&
256
+ !event.altKey &&
257
+ !event.shiftKey &&
258
+ !isEditableTarget(event.target)
259
+ ) {
260
+ event.preventDefault();
261
+ if (document.fullscreenElement) {
262
+ void document.exitFullscreen();
263
+ } else {
264
+ document.querySelector<HTMLElement>("[data-studio-fullscreen-target]")?.requestFullscreen();
265
+ }
266
+ return;
267
+ }
268
+
251
269
  // Delete / Backspace — remove selected element (timeline clip or preview selection)
252
270
  if (
253
271
  (event.key === "Delete" || event.key === "Backspace") &&
@@ -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
@@ -7,19 +7,39 @@ import "./styles/studio.css";
7
7
 
8
8
  trackStudioEvent("session_start");
9
9
 
10
+ function errorProps(value: unknown): {
11
+ error_message: string;
12
+ error_name: string | null;
13
+ stack_trace: string | null;
14
+ } {
15
+ if (value instanceof Error) {
16
+ return {
17
+ error_message: value.message,
18
+ error_name: value.name,
19
+ stack_trace: value.stack?.slice(0, 4000) ?? null,
20
+ };
21
+ }
22
+ return { error_message: String(value), error_name: null, stack_trace: null };
23
+ }
24
+
10
25
  window.addEventListener("error", (event) => {
26
+ if (event.message?.includes("ResizeObserver loop")) {
27
+ event.stopImmediatePropagation();
28
+ event.preventDefault();
29
+ return;
30
+ }
31
+
11
32
  trackStudioEvent("unhandled_error", {
33
+ ...errorProps(event.error),
12
34
  error_message: event.message,
13
- filename: event.filename ?? null,
14
- lineno: event.lineno ?? null,
15
- colno: event.colno ?? null,
35
+ filename: event.filename,
36
+ lineno: event.lineno,
37
+ colno: event.colno,
16
38
  });
17
39
  });
18
40
 
19
41
  window.addEventListener("unhandledrejection", (event) => {
20
- trackStudioEvent("unhandled_promise_rejection", {
21
- error_message: event.reason instanceof Error ? event.reason.message : String(event.reason),
22
- });
42
+ trackStudioEvent("unhandled_promise_rejection", errorProps(event.reason));
23
43
  });
24
44
 
25
45
  createRoot(document.getElementById("root")!).render(