@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.
@@ -1,5 +1,14 @@
1
- import { memo, useRef, useState, type Ref } from "react";
1
+ import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
2
2
  import { Player } from "../../player";
3
+ import {
4
+ DEFAULT_PREVIEW_ZOOM,
5
+ clampPreviewPan,
6
+ clampPreviewZoomPercent,
7
+ resolvePreviewWheelZoom,
8
+ toDomPrecision,
9
+ type PreviewZoomState,
10
+ } from "./previewZoom";
11
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
3
12
 
4
13
  interface NLEPreviewProps {
5
14
  projectId: string;
@@ -23,17 +32,20 @@ export function getPreviewPlayerKey({
23
32
  return directUrl ?? projectId;
24
33
  }
25
34
 
26
- /**
27
- * Manages the composition preview with crossfade on reload.
28
- *
29
- * When refreshKey changes, a new Player is mounted alongside the old one.
30
- * The old Player stays visible (opacity 1) until the new one fires onLoad,
31
- * at which point the old is removed. This avoids the flash that a simple
32
- * key-swap remount would cause.
33
- *
34
- * Uses the render-time state adjustment pattern (React-sanctioned) to detect
35
- * refreshKey changes — no useEffect needed.
36
- */
35
+ const ZOOM_HUD_TIMEOUT_MS = 1200;
36
+ const ZOOM_SETTLE_MS = 200;
37
+
38
+ function loadInitialZoom(): PreviewZoomState {
39
+ const stored = readStudioUiPreferences().previewZoom;
40
+ return stored
41
+ ? {
42
+ zoomPercent: clampPreviewZoomPercent(stored.zoomPercent),
43
+ panX: stored.panX,
44
+ panY: stored.panY,
45
+ }
46
+ : DEFAULT_PREVIEW_ZOOM;
47
+ }
48
+
37
49
  export const NLEPreview = memo(function NLEPreview({
38
50
  projectId,
39
51
  iframeRef,
@@ -46,12 +58,78 @@ export const NLEPreview = memo(function NLEPreview({
46
58
  }: NLEPreviewProps) {
47
59
  const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
48
60
  const prevRefreshKeyRef = useRef(refreshKey);
61
+ const viewportRef = useRef<HTMLDivElement>(null);
62
+ const stageRef = useRef<HTMLDivElement>(null);
49
63
  const [retiringKey, setRetiringKey] = useState<string | null>(null);
50
64
  const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
51
65
 
52
- // Detect refreshKey change during render (React-sanctioned derived state pattern).
53
- // When the key changes, the current active player becomes the retiring player
54
- // and a new active player is mounted alongside it.
66
+ const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
67
+ const hudRef = useRef<HTMLDivElement>(null);
68
+ const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
69
+ const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
70
+ const zoomingRef = useRef(false);
71
+ const dragRef = useRef<{
72
+ pointerId: number;
73
+ startX: number;
74
+ startY: number;
75
+ originX: number;
76
+ originY: number;
77
+ } | null>(null);
78
+
79
+ useEffect(() => {
80
+ return () => {
81
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
82
+ if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
83
+ if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
84
+ };
85
+ }, []);
86
+
87
+ const writeTransform = useCallback((state: PreviewZoomState) => {
88
+ const stage = stageRef.current;
89
+ if (!stage) return;
90
+ const s = toDomPrecision(state.zoomPercent / 100);
91
+ const px = toDomPrecision(state.panX);
92
+ const py = toDomPrecision(state.panY);
93
+ stage.style.zoom = String(s);
94
+ stage.style.transform = `translate(${px}px, ${py}px)`;
95
+ }, []);
96
+
97
+ const applyZoom = useCallback(
98
+ (next: PreviewZoomState) => {
99
+ const clamped: PreviewZoomState = {
100
+ zoomPercent: clampPreviewZoomPercent(next.zoomPercent),
101
+ panX: Number.isFinite(next.panX) ? next.panX : 0,
102
+ panY: Number.isFinite(next.panY) ? next.panY : 0,
103
+ };
104
+ zoomRef.current = clamped;
105
+
106
+ if (!zoomingRef.current) {
107
+ zoomingRef.current = true;
108
+ const hud = hudRef.current;
109
+ if (hud) hud.style.opacity = "1";
110
+ }
111
+
112
+ writeTransform(clamped);
113
+
114
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
115
+ settleTimerRef.current = setTimeout(() => {
116
+ zoomingRef.current = false;
117
+ const final = zoomRef.current;
118
+ writeStudioUiPreferences({ previewZoom: final });
119
+ const hud = hudRef.current;
120
+ if (hud) {
121
+ const zoomed = Math.abs(final.zoomPercent - 100) > 0.5;
122
+ hud.textContent = zoomed ? `${Math.round(final.zoomPercent)}%` : "Fit";
123
+ if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
124
+ hudTimerRef.current = setTimeout(() => {
125
+ if (hudRef.current) hudRef.current.style.opacity = "0";
126
+ }, ZOOM_HUD_TIMEOUT_MS);
127
+ }
128
+ }, ZOOM_SETTLE_MS);
129
+ },
130
+ [writeTransform],
131
+ );
132
+
55
133
  if (refreshKey !== prevRefreshKeyRef.current) {
56
134
  const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
57
135
  prevRefreshKeyRef.current = refreshKey;
@@ -60,8 +138,16 @@ export const NLEPreview = memo(function NLEPreview({
60
138
 
61
139
  const activeKey = `${baseKey}:${refreshKey ?? 0}`;
62
140
 
141
+ const applyInitialZoom = useCallback(() => {
142
+ const z = zoomRef.current;
143
+ if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
144
+ writeTransform(z);
145
+ }
146
+ }, [writeTransform]);
147
+
63
148
  const handleNewPlayerLoad = () => {
64
149
  onIframeLoad();
150
+ applyInitialZoom();
65
151
  if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
66
152
  retiringTimerRef.current = setTimeout(() => {
67
153
  setRetiringKey(null);
@@ -69,33 +155,167 @@ export const NLEPreview = memo(function NLEPreview({
69
155
  }, 160);
70
156
  };
71
157
 
158
+ useEffect(() => {
159
+ const viewport = viewportRef.current;
160
+ if (!viewport) return;
161
+
162
+ let lastZoomTime = 0;
163
+
164
+ const handleWheel = (event: WheelEvent) => {
165
+ const rect = viewport.getBoundingClientRect();
166
+ if (
167
+ event.clientX < rect.left ||
168
+ event.clientX > rect.right ||
169
+ event.clientY < rect.top ||
170
+ event.clientY > rect.bottom
171
+ ) {
172
+ return;
173
+ }
174
+
175
+ const isZoomGesture = event.ctrlKey || event.metaKey;
176
+
177
+ if (isZoomGesture) {
178
+ lastZoomTime = Date.now();
179
+ event.preventDefault();
180
+ event.stopPropagation();
181
+
182
+ const next = resolvePreviewWheelZoom({
183
+ state: zoomRef.current,
184
+ deltaY: event.deltaY,
185
+ viewportWidth: rect.width,
186
+ viewportHeight: rect.height,
187
+ });
188
+ applyZoom(next);
189
+ return;
190
+ }
191
+
192
+ if (Date.now() - lastZoomTime < 400) {
193
+ event.preventDefault();
194
+ event.stopPropagation();
195
+ }
196
+ };
197
+
198
+ document.addEventListener("wheel", handleWheel, { passive: false, capture: true });
199
+ return () => document.removeEventListener("wheel", handleWheel, { capture: true });
200
+ }, [applyZoom]);
201
+
202
+ useEffect(() => {
203
+ const viewport = viewportRef.current;
204
+ if (!viewport) return;
205
+
206
+ const handleDblClick = (event: MouseEvent) => {
207
+ if (Math.abs(zoomRef.current.zoomPercent - 100) < 0.5) return;
208
+ const rect = viewport.getBoundingClientRect();
209
+ if (
210
+ event.clientX < rect.left ||
211
+ event.clientX > rect.right ||
212
+ event.clientY < rect.top ||
213
+ event.clientY > rect.bottom
214
+ ) {
215
+ return;
216
+ }
217
+ applyZoom(DEFAULT_PREVIEW_ZOOM);
218
+ };
219
+
220
+ document.addEventListener("dblclick", handleDblClick, { capture: true });
221
+ return () => document.removeEventListener("dblclick", handleDblClick, { capture: true });
222
+ }, [applyZoom]);
223
+
224
+ const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
225
+ if (zoomRef.current.zoomPercent <= 100 || event.button !== 0) return;
226
+ event.currentTarget.setPointerCapture(event.pointerId);
227
+ dragRef.current = {
228
+ pointerId: event.pointerId,
229
+ startX: event.clientX,
230
+ startY: event.clientY,
231
+ originX: zoomRef.current.panX,
232
+ originY: zoomRef.current.panY,
233
+ };
234
+ }, []);
235
+
236
+ const handlePointerMove = useCallback(
237
+ (event: React.PointerEvent<HTMLDivElement>) => {
238
+ const drag = dragRef.current;
239
+ const viewport = viewportRef.current;
240
+ if (!drag || !viewport || drag.pointerId !== event.pointerId) return;
241
+ event.preventDefault();
242
+ const rect = viewport.getBoundingClientRect();
243
+ const pan = clampPreviewPan({
244
+ panX: drag.originX + event.clientX - drag.startX,
245
+ panY: drag.originY + event.clientY - drag.startY,
246
+ zoomPercent: zoomRef.current.zoomPercent,
247
+ viewportWidth: rect.width,
248
+ viewportHeight: rect.height,
249
+ });
250
+ applyZoom({ ...zoomRef.current, ...pan });
251
+ },
252
+ [applyZoom],
253
+ );
254
+
255
+ const finishDrag = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
256
+ if (dragRef.current?.pointerId === event.pointerId) {
257
+ dragRef.current = null;
258
+ }
259
+ }, []);
260
+
261
+ const initial = zoomRef.current;
262
+
72
263
  return (
73
264
  <div className="flex flex-col h-full min-h-0">
74
265
  <div
266
+ ref={viewportRef}
75
267
  className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
76
268
  tabIndex={0}
77
269
  aria-label="Composition preview"
270
+ onPointerDown={handlePointerDown}
271
+ onPointerMove={handlePointerMove}
272
+ onPointerUp={finishDrag}
273
+ onPointerCancel={finishDrag}
78
274
  >
79
- {retiringKey && (
275
+ <div
276
+ ref={stageRef}
277
+ className="absolute inset-2"
278
+ style={{
279
+ zoom: toDomPrecision(initial.zoomPercent / 100),
280
+ transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px)`,
281
+ transformOrigin: "0 0",
282
+ }}
283
+ data-testid="preview-zoom-stage"
284
+ >
285
+ {retiringKey && (
286
+ <Player
287
+ key={retiringKey}
288
+ projectId={directUrl ? undefined : projectId}
289
+ directUrl={directUrl}
290
+ onLoad={() => {}}
291
+ portrait={portrait}
292
+ style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
293
+ />
294
+ )}
80
295
  <Player
81
- key={retiringKey}
296
+ key={activeKey}
297
+ ref={iframeRef}
82
298
  projectId={directUrl ? undefined : projectId}
83
299
  directUrl={directUrl}
84
- onLoad={() => {}}
300
+ onLoad={
301
+ retiringKey
302
+ ? handleNewPlayerLoad
303
+ : () => {
304
+ onIframeLoad();
305
+ applyInitialZoom();
306
+ }
307
+ }
308
+ onCompositionLoadingChange={onCompositionLoadingChange}
85
309
  portrait={portrait}
86
- style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
310
+ style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
311
+ suppressLoadingOverlay={suppressLoadingOverlay}
87
312
  />
88
- )}
89
- <Player
90
- key={activeKey}
91
- ref={iframeRef}
92
- projectId={directUrl ? undefined : projectId}
93
- directUrl={directUrl}
94
- onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
95
- onCompositionLoadingChange={onCompositionLoadingChange}
96
- portrait={portrait}
97
- style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
98
- suppressLoadingOverlay={suppressLoadingOverlay}
313
+ </div>
314
+ <div
315
+ ref={hudRef}
316
+ className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-lg px-4 py-2 text-sm font-mono tabular-nums text-white/90 bg-black/60 backdrop-blur-sm shadow-lg"
317
+ style={{ opacity: 0, transition: "opacity 300ms ease-out" }}
318
+ aria-live="polite"
99
319
  />
100
320
  </div>
101
321
  </div>
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ DEFAULT_PREVIEW_ZOOM,
4
+ MAX_PREVIEW_ZOOM_PERCENT,
5
+ MIN_PREVIEW_ZOOM_PERCENT,
6
+ clampPreviewPan,
7
+ clampPreviewZoomPercent,
8
+ getNextPreviewZoomPercent,
9
+ getPreviewWheelZoomPercent,
10
+ resolvePreviewWheelZoom,
11
+ toDomPrecision,
12
+ } from "./previewZoom";
13
+
14
+ describe("toDomPrecision", () => {
15
+ it("rounds to 4 decimal places", () => {
16
+ expect(toDomPrecision(1.23456789)).toBe(1.2346);
17
+ });
18
+
19
+ it("preserves zero", () => {
20
+ expect(toDomPrecision(0)).toBe(0);
21
+ });
22
+
23
+ it("handles negative values", () => {
24
+ expect(toDomPrecision(-3.14159)).toBe(-3.1416);
25
+ });
26
+ });
27
+
28
+ describe("clampPreviewZoomPercent", () => {
29
+ it("falls back to fit zoom for invalid input", () => {
30
+ expect(clampPreviewZoomPercent(Number.NaN)).toBe(100);
31
+ });
32
+
33
+ it("clamps to supported preview zoom bounds", () => {
34
+ expect(clampPreviewZoomPercent(1)).toBe(MIN_PREVIEW_ZOOM_PERCENT);
35
+ expect(clampPreviewZoomPercent(5000)).toBe(MAX_PREVIEW_ZOOM_PERCENT);
36
+ });
37
+ });
38
+
39
+ describe("getPreviewWheelZoomPercent", () => {
40
+ it("zooms in on negative deltaY (scroll up / pinch out)", () => {
41
+ expect(getPreviewWheelZoomPercent(-5, 100)).toBeGreaterThan(100);
42
+ });
43
+
44
+ it("zooms out on positive deltaY (scroll down / pinch in)", () => {
45
+ expect(getPreviewWheelZoomPercent(5, 200)).toBeLessThan(200);
46
+ });
47
+
48
+ it("clamps large deltas to prevent overshoot", () => {
49
+ const small = getPreviewWheelZoomPercent(-5, 100);
50
+ const large = getPreviewWheelZoomPercent(-50, 100);
51
+ expect(large).toBeLessThan(small * 2);
52
+ });
53
+
54
+ it("preserves the current zoom for invalid input", () => {
55
+ expect(getPreviewWheelZoomPercent(Number.NaN, 180)).toBe(180);
56
+ });
57
+ });
58
+
59
+ describe("getNextPreviewZoomPercent", () => {
60
+ it("steps preview zoom in and out", () => {
61
+ expect(getNextPreviewZoomPercent("in", 100)).toBe(125);
62
+ expect(getNextPreviewZoomPercent("out", 125)).toBe(100);
63
+ });
64
+ });
65
+
66
+ describe("clampPreviewPan", () => {
67
+ it("centers the preview when fit or zoomed out", () => {
68
+ expect(
69
+ clampPreviewPan({
70
+ panX: 120,
71
+ panY: -90,
72
+ zoomPercent: 100,
73
+ viewportWidth: 800,
74
+ viewportHeight: 600,
75
+ }),
76
+ ).toEqual({ panX: 0, panY: 0 });
77
+ });
78
+
79
+ it("keeps pan within the zoomed preview bounds", () => {
80
+ expect(
81
+ clampPreviewPan({
82
+ panX: 900,
83
+ panY: -900,
84
+ zoomPercent: 200,
85
+ viewportWidth: 800,
86
+ viewportHeight: 600,
87
+ }),
88
+ ).toEqual({ panX: 400, panY: -300 });
89
+ });
90
+ });
91
+
92
+ describe("resolvePreviewWheelZoom", () => {
93
+ it("zooms in from center without shifting pan", () => {
94
+ const next = resolvePreviewWheelZoom({
95
+ state: DEFAULT_PREVIEW_ZOOM,
96
+ deltaY: -5,
97
+ viewportWidth: 800,
98
+ viewportHeight: 600,
99
+ });
100
+
101
+ expect(next.zoomPercent).toBeGreaterThan(100);
102
+ expect(next.panX).toBe(0);
103
+ expect(next.panY).toBe(0);
104
+ });
105
+
106
+ it("clamps pan when zooming out past minimum", () => {
107
+ const next = resolvePreviewWheelZoom({
108
+ state: { zoomPercent: 26, panX: 20, panY: 20 },
109
+ deltaY: 500,
110
+ viewportWidth: 800,
111
+ viewportHeight: 600,
112
+ });
113
+
114
+ expect(next.zoomPercent).toBeCloseTo(MIN_PREVIEW_ZOOM_PERCENT, 0);
115
+ expect(next.panX).toBe(0);
116
+ expect(next.panY).toBe(0);
117
+ });
118
+ });
@@ -0,0 +1,84 @@
1
+ export interface PreviewZoomState {
2
+ zoomPercent: number;
3
+ panX: number;
4
+ panY: number;
5
+ }
6
+
7
+ export const MIN_PREVIEW_ZOOM_PERCENT = 25;
8
+ export const MAX_PREVIEW_ZOOM_PERCENT = 400;
9
+ export const DEFAULT_PREVIEW_ZOOM: PreviewZoomState = {
10
+ zoomPercent: 100,
11
+ panX: 0,
12
+ panY: 0,
13
+ };
14
+
15
+ const ZOOM_SENSITIVITY = 0.007;
16
+ const MAX_DELTA = 10;
17
+
18
+ export function toDomPrecision(value: number): number {
19
+ return Math.round(value * 10000) / 10000;
20
+ }
21
+
22
+ export function clampPreviewZoomPercent(percent: number): number {
23
+ if (!Number.isFinite(percent)) return 100;
24
+ return Math.min(MAX_PREVIEW_ZOOM_PERCENT, Math.max(MIN_PREVIEW_ZOOM_PERCENT, percent));
25
+ }
26
+
27
+ export function getPreviewWheelZoomPercent(deltaY: number, currentZoomPercent: number): number {
28
+ if (!Number.isFinite(deltaY)) return clampPreviewZoomPercent(currentZoomPercent);
29
+ const clamped = Math.abs(deltaY) > MAX_DELTA ? MAX_DELTA * Math.sign(deltaY) : deltaY;
30
+ const step = -clamped * ZOOM_SENSITIVITY;
31
+ const current = clampPreviewZoomPercent(currentZoomPercent);
32
+ return clampPreviewZoomPercent(current * Math.exp(step));
33
+ }
34
+
35
+ export function getNextPreviewZoomPercent(
36
+ direction: "in" | "out",
37
+ currentZoomPercent: number,
38
+ ): number {
39
+ const current = clampPreviewZoomPercent(currentZoomPercent);
40
+ const multiplier = direction === "in" ? 1.25 : 0.8;
41
+ return clampPreviewZoomPercent(current * multiplier);
42
+ }
43
+
44
+ export function clampPreviewPan(input: {
45
+ panX: number;
46
+ panY: number;
47
+ zoomPercent: number;
48
+ viewportWidth: number;
49
+ viewportHeight: number;
50
+ }): Pick<PreviewZoomState, "panX" | "panY"> {
51
+ const scale = clampPreviewZoomPercent(input.zoomPercent) / 100;
52
+ if (scale <= 1) return { panX: 0, panY: 0 };
53
+
54
+ const maxPanX = ((scale - 1) * input.viewportWidth) / 2;
55
+ const maxPanY = ((scale - 1) * input.viewportHeight) / 2;
56
+ return {
57
+ panX: Math.min(maxPanX, Math.max(-maxPanX, input.panX)),
58
+ panY: Math.min(maxPanY, Math.max(-maxPanY, input.panY)),
59
+ };
60
+ }
61
+
62
+ export function resolvePreviewWheelZoom(input: {
63
+ state: PreviewZoomState;
64
+ deltaY: number;
65
+ viewportWidth: number;
66
+ viewportHeight: number;
67
+ }): PreviewZoomState {
68
+ const nextZoomPercent = getPreviewWheelZoomPercent(
69
+ input.deltaY,
70
+ clampPreviewZoomPercent(input.state.zoomPercent),
71
+ );
72
+ const pan = clampPreviewPan({
73
+ panX: input.state.panX,
74
+ panY: input.state.panY,
75
+ zoomPercent: nextZoomPercent,
76
+ viewportWidth: input.viewportWidth,
77
+ viewportHeight: input.viewportHeight,
78
+ });
79
+
80
+ return {
81
+ zoomPercent: nextZoomPercent,
82
+ ...pan,
83
+ };
84
+ }
@@ -207,7 +207,7 @@ function FormatExportButton({
207
207
  const showQuality = format !== "mov";
208
208
 
209
209
  return (
210
- <div className="flex items-center gap-1">
210
+ <div className="flex items-center gap-1 flex-wrap justify-end">
211
211
  <FormatInfoTooltip format={format} />
212
212
  {/* Resolution must remain the leftmost <select> in this row — it
213
213
  carries `rounded-l` for the joined-button look. If you ever hide it
@@ -298,7 +298,7 @@ export const RenderQueue = memo(function RenderQueue({
298
298
  return (
299
299
  <div className="flex flex-col h-full">
300
300
  {/* Header — no title, already shown in header button */}
301
- <div className="flex items-center justify-end px-3 py-2 border-b border-neutral-800/50 flex-shrink-0">
301
+ <div className="flex items-center justify-end flex-wrap gap-y-1.5 px-3 py-2 border-b border-neutral-800/50 flex-shrink-0">
302
302
  <div className="flex items-center gap-1.5">
303
303
  {completedCount > 0 && (
304
304
  <button
@@ -45,6 +45,7 @@ export function DomEditProvider({
45
45
  handleDomManualDragStart,
46
46
  handleDomEditElementDelete,
47
47
  buildDomSelectionForTimelineElement,
48
+ updateDomEditHoverSelection,
48
49
  resolveImportedFontAsset,
49
50
  setAgentModalOpen,
50
51
  setAgentPromptSelectionContext,
@@ -89,6 +90,7 @@ export function DomEditProvider({
89
90
  handleDomManualDragStart,
90
91
  handleDomEditElementDelete,
91
92
  buildDomSelectionForTimelineElement,
93
+ updateDomEditHoverSelection,
92
94
  resolveImportedFontAsset,
93
95
  setAgentModalOpen,
94
96
  setAgentPromptSelectionContext,
@@ -127,6 +129,7 @@ export function DomEditProvider({
127
129
  handleDomManualDragStart,
128
130
  handleDomEditElementDelete,
129
131
  buildDomSelectionForTimelineElement,
132
+ updateDomEditHoverSelection,
130
133
  resolveImportedFontAsset,
131
134
  setAgentModalOpen,
132
135
  setAgentPromptSelectionContext,
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import type { LintFinding } from "../components/LintModal";
3
3
 
4
4
  /**
@@ -9,10 +9,10 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
9
9
  const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
10
10
  const consoleErrorsRef = useRef<LintFinding[]>([]);
11
11
 
12
- const resetErrors = () => {
12
+ const resetErrors = useCallback(() => {
13
13
  consoleErrorsRef.current = [];
14
14
  setConsoleErrors(null);
15
- };
15
+ }, []);
16
16
 
17
17
  // eslint-disable-next-line no-restricted-syntax
18
18
  useEffect(() => {
@@ -334,6 +334,7 @@ export function useDomEditSession({
334
334
  handleDomManualDragStart,
335
335
  handleDomEditElementDelete,
336
336
  buildDomSelectionForTimelineElement,
337
+ updateDomEditHoverSelection,
337
338
  resolveImportedFontAsset,
338
339
  setAgentModalOpen,
339
340
  setAgentPromptSelectionContext,
@@ -50,7 +50,11 @@ export interface UseDomSelectionReturn {
50
50
  // Callbacks
51
51
  applyDomSelection: (
52
52
  selection: DomEditSelection | null,
53
- options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
53
+ options?: {
54
+ revealPanel?: boolean;
55
+ additive?: boolean;
56
+ preserveGroup?: boolean;
57
+ },
54
58
  ) => void;
55
59
  clearDomSelection: () => void;
56
60
  buildDomSelectionFromTarget: (
@@ -108,7 +112,11 @@ export function useDomSelection({
108
112
  const applyDomSelection = useCallback(
109
113
  (
110
114
  selection: DomEditSelection | null,
111
- options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
115
+ options?: {
116
+ revealPanel?: boolean;
117
+ additive?: boolean;
118
+ preserveGroup?: boolean;
119
+ },
112
120
  ) => {
113
121
  if (!selection) {
114
122
  domEditSelectionRef.current = null;
@@ -157,7 +165,9 @@ export function useDomSelection({
157
165
  if (nextSelection) {
158
166
  if (options?.revealPanel !== false) {
159
167
  setRightCollapsed(false);
160
- setRightPanelTab("design");
168
+ if (rightPanelTab !== "layers") {
169
+ setRightPanelTab("design");
170
+ }
161
171
  }
162
172
  const nextSelectedTimelineId = findMatchingTimelineElementId(
163
173
  nextSelection,
@@ -169,7 +179,13 @@ export function useDomSelection({
169
179
 
170
180
  setSelectedTimelineElementId(null);
171
181
  },
172
- [setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab],
182
+ [
183
+ setSelectedTimelineElementId,
184
+ timelineElements,
185
+ setRightCollapsed,
186
+ setRightPanelTab,
187
+ rightPanelTab,
188
+ ],
173
189
  );
174
190
 
175
191
  const clearDomSelection = useCallback(() => {
@@ -223,7 +239,9 @@ export function useDomSelection({
223
239
  isMasterView,
224
240
  });
225
241
  return targetElement
226
- ? buildDomSelectionFromTarget(targetElement, { preferClipAncestor: false })
242
+ ? buildDomSelectionFromTarget(targetElement, {
243
+ preferClipAncestor: false,
244
+ })
227
245
  : null;
228
246
  },
229
247
  [activeCompPath, buildDomSelectionFromTarget, compIdToSrc, isMasterView, previewIframeRef],
@@ -259,7 +277,10 @@ export function useDomSelection({
259
277
 
260
278
  const nextSelection = buildDomSelectionFromTarget(element);
261
279
  if (nextSelection) {
262
- applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
280
+ applyDomSelection(nextSelection, {
281
+ revealPanel: false,
282
+ preserveGroup: true,
283
+ });
263
284
  }
264
285
  },
265
286
  [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],