@hyperframes/studio 0.6.59 → 0.6.61

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.
@@ -153,31 +153,49 @@ export function resolveVisualDomEditSelectionTarget(
153
153
  elementsFromPoint: Iterable<Element | null | undefined>,
154
154
  options: Pick<DomEditContextOptions, "activeCompositionPath">,
155
155
  ): HTMLElement | null {
156
- const candidates: HTMLElement[] = [];
156
+ const candidates = resolveAllVisualDomEditTargets(elementsFromPoint, options);
157
+ return candidates[0] ?? null;
158
+ }
159
+
160
+ /**
161
+ * Returns all independently-selectable elements at the given point, in paint
162
+ * order (topmost first). Used for click-cycling through stacked layers.
163
+ *
164
+ * Each entry in the returned array is an independent "layer" — an element
165
+ * that is not an ancestor of an earlier entry. This gives one result per
166
+ * z-stacked element rather than one per DOM node.
167
+ */
168
+ export function resolveAllVisualDomEditTargets(
169
+ elementsFromPoint: Iterable<Element | null | undefined>,
170
+ options: Pick<DomEditContextOptions, "activeCompositionPath">,
171
+ ): HTMLElement[] {
172
+ const raw: HTMLElement[] = [];
157
173
 
158
174
  for (const entry of elementsFromPoint) {
159
175
  if (!isHtmlElement(entry)) continue;
160
176
  if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
161
- candidates.push(entry);
177
+ raw.push(entry);
162
178
  }
163
179
  }
164
180
 
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;
181
+ if (raw.length === 0) return [];
182
+
183
+ // First pass: for each contiguous ancestor-descendant run, keep only the
184
+ // deepest (most specific) element, matching the original single-pick logic.
185
+ const layers: HTMLElement[] = [];
186
+ let best = raw[0];
187
+ for (let i = 1; i < raw.length; i++) {
188
+ const el = raw[i];
189
+ if (best.contains(el)) {
190
+ best = el; // go deeper in this subtree
191
+ } else {
192
+ layers.push(best);
193
+ best = el;
177
194
  }
178
195
  }
196
+ layers.push(best);
179
197
 
180
- return best;
198
+ return layers;
181
199
  }
182
200
 
183
201
  // ─── Raster detection ────────────────────────────────────────────────────────
@@ -248,6 +266,7 @@ export function findElementForSelection(
248
266
  return matches[0] ?? null;
249
267
  }
250
268
 
269
+ // fallow-ignore-next-line complexity
251
270
  export function findElementForTimelineElement(
252
271
  doc: Document,
253
272
  element: TimelineElementDomTarget,
@@ -4,7 +4,7 @@ export const METHOD_LABELS: Record<string, string> = {
4
4
  set: "Set",
5
5
  to: "Animate",
6
6
  from: "Animate In",
7
- fromTo: "Animate",
7
+ fromTo: "From → To",
8
8
  };
9
9
 
10
10
  export const METHOD_TOOLTIPS: Record<string, string> = {
@@ -121,6 +121,8 @@ export function parseCustomEaseFromString(ease: string): {
121
121
  return { x1: nums[2], y1: nums[3], x2: nums[4], y2: nums[5] };
122
122
  }
123
123
 
124
+ export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
125
+
124
126
  export const ADD_METHODS = ["to", "from", "fromTo", "set"] as const;
125
127
 
126
128
  export const ADD_METHOD_LABELS: Record<string, string> = {
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildTweenSummary } from "./gsapAnimationHelpers";
3
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
+
5
+ function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
6
+ return {
7
+ id: "a1",
8
+ method: "to",
9
+ targetSelector: "#box",
10
+ properties: {},
11
+ position: 0,
12
+ duration: 1,
13
+ ease: "power2.out",
14
+ ...overrides,
15
+ } as GsapAnimation;
16
+ }
17
+
18
+ describe("buildTweenSummary", () => {
19
+ it("describes a to tween", () => {
20
+ const s = buildTweenSummary(anim({ properties: { opacity: 1, x: 100 } }));
21
+ expect(s).toContain("#box");
22
+ expect(s).toContain("opacity");
23
+ expect(s).toContain("move x");
24
+ });
25
+
26
+ it("describes a from tween", () => {
27
+ const s = buildTweenSummary(anim({ method: "from", properties: { opacity: 0 } }));
28
+ expect(s).toContain("enters from");
29
+ expect(s).toContain("opacity");
30
+ });
31
+
32
+ it("describes a set tween", () => {
33
+ const s = buildTweenSummary(anim({ method: "set", properties: { opacity: 0 } }));
34
+ expect(s).toMatch(/^At 0s, instantly set/);
35
+ expect(s).toContain("opacity");
36
+ });
37
+
38
+ it("describes a fromTo tween with both from and to sections", () => {
39
+ const s = buildTweenSummary(
40
+ anim({
41
+ method: "fromTo",
42
+ fromProperties: { opacity: 0, x: -50 },
43
+ properties: { opacity: 1, x: 0 },
44
+ position: 0.5,
45
+ duration: 1.5,
46
+ ease: "expo.out",
47
+ }),
48
+ );
49
+ expect(s).toContain("animates from");
50
+ expect(s).toContain("[opacity 0%");
51
+ expect(s).toContain("move x -50px");
52
+ expect(s).toContain("opacity to 100%");
53
+ expect(s).toContain("very snappy stop");
54
+ });
55
+
56
+ it("handles fromTo with empty fromProperties", () => {
57
+ const s = buildTweenSummary(
58
+ anim({ method: "fromTo", fromProperties: {}, properties: { scale: 2 } }),
59
+ );
60
+ expect(s).toContain("from [—]");
61
+ });
62
+
63
+ it("handles no properties", () => {
64
+ const s = buildTweenSummary(anim({ properties: {} }));
65
+ expect(s).toContain("no properties yet");
66
+ });
67
+ });
@@ -0,0 +1,36 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import { EASE_LABELS, PERCENT_PROPS, PROP_LABELS, PROP_UNITS } from "./gsapAnimationConstants";
3
+
4
+ function formatPropValue(prop: string, v: number | string): string {
5
+ const unit = PROP_UNITS[prop] ?? "";
6
+ if (PERCENT_PROPS.has(prop)) return `${Math.round(Number(v) * 100)}${unit}`;
7
+ return `${v}${unit}`;
8
+ }
9
+
10
+ // fallow-ignore-next-line complexity
11
+ export function buildTweenSummary(animation: GsapAnimation): string {
12
+ const easeName = animation.ease ?? "none";
13
+ const ease = EASE_LABELS[easeName] ?? easeName;
14
+ const props = Object.entries(animation.properties);
15
+ const target = animation.targetSelector;
16
+ const dur = animation.duration ?? 0;
17
+ const pos = animation.position;
18
+ const propDescs = props.map(([p, v]) => {
19
+ const label = (PROP_LABELS[p] ?? p).toLowerCase();
20
+ return `${label} to ${formatPropValue(p, v)}`;
21
+ });
22
+ const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet";
23
+ if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`;
24
+ if (animation.method === "from")
25
+ return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`;
26
+ if (animation.method === "fromTo") {
27
+ const fromProps = Object.entries(animation.fromProperties ?? {});
28
+ const fromDescs = fromProps.map(([p, v]) => {
29
+ const label = (PROP_LABELS[p] ?? p).toLowerCase();
30
+ return `${label} ${formatPropValue(p, v)}`;
31
+ });
32
+ const fromText = fromDescs.length > 0 ? fromDescs.join(", ") : "—";
33
+ return `Starting at ${pos}s, over ${dur}s, ${target} animates from [${fromText}] to [${propText}] using a ${ease.toLowerCase()} curve.`;
34
+ }
35
+ return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
36
+ }
@@ -29,7 +29,7 @@ function CommitField({
29
29
  const el = inputRef.current;
30
30
  if (!el) return;
31
31
  const handler = (e: WheelEvent) => {
32
- if (disabled) return;
32
+ if (disabled || document.activeElement !== el) return;
33
33
  const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
34
34
  if (delta === 0) return;
35
35
  const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e);
@@ -97,6 +97,7 @@ export function shouldDisableTimelineWhileCompositionLoading(compositionLoading:
97
97
  return compositionLoading;
98
98
  }
99
99
 
100
+ // fallow-ignore-next-line complexity
100
101
  export const NLELayout = memo(function NLELayout({
101
102
  projectId,
102
103
  portrait,
@@ -367,7 +368,7 @@ export const NLELayout = memo(function NLELayout({
367
368
  {/* Preview + player controls */}
368
369
  <div className="flex-1 min-h-0 flex flex-col">
369
370
  <div
370
- className="flex-1 min-h-0 relative"
371
+ className="flex-1 min-h-0 relative overflow-hidden"
371
372
  data-preview-pan-surface="true"
372
373
  onDragOver={handlePreviewDragOver}
373
374
  onDragLeave={handlePreviewDragLeave}
@@ -67,6 +67,7 @@ export interface UseDomEditSessionParams {
67
67
 
68
68
  // ── Hook ──
69
69
 
70
+ // fallow-ignore-next-line complexity
70
71
  export function useDomEditSession({
71
72
  projectId,
72
73
  activeCompPath,
@@ -129,6 +130,7 @@ export function useDomEditSession({
129
130
  clearDomSelection,
130
131
  buildDomSelectionFromTarget,
131
132
  resolveDomSelectionFromPreviewPoint,
133
+ resolveAllDomSelectionsFromPreviewPoint,
132
134
  updateDomEditHoverSelection,
133
135
  buildDomSelectionForTimelineElement,
134
136
  handleTimelineElementSelect,
@@ -187,6 +189,7 @@ export function useDomEditSession({
187
189
  showToast,
188
190
  applyDomSelection,
189
191
  resolveDomSelectionFromPreviewPoint,
192
+ resolveAllDomSelectionsFromPreviewPoint,
190
193
  updateDomEditHoverSelection,
191
194
  onClickToSource,
192
195
  });
@@ -221,6 +224,7 @@ export function useDomEditSession({
221
224
  } = useGsapScriptCommits({
222
225
  projectIdRef,
223
226
  activeCompPath,
227
+ previewIframeRef,
224
228
  editHistory,
225
229
  domEditSaveTimestampRef,
226
230
  reloadPreview,
@@ -343,6 +347,7 @@ export function useDomEditSession({
343
347
  useEffect(() => {
344
348
  if (!previewIframe) return;
345
349
 
350
+ // fallow-ignore-next-line complexity
346
351
  const syncSelectionFromDocument = async () => {
347
352
  if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
348
353
  const currentSelection = domEditSelectionRef.current;
@@ -402,16 +407,20 @@ export function useDomEditSession({
402
407
  // not when openSourceForSelection is recreated due to editingFile content updates.
403
408
  const openSourceRef = useRef(openSourceForSelection);
404
409
  openSourceRef.current = openSourceForSelection;
405
- useEffect(() => {
406
- if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
407
- if (!domEditSelection.sourceFile) return;
408
- if (getSidebarTab() !== "code") return;
409
- openSourceRef.current(domEditSelection.sourceFile, {
410
- id: domEditSelection.id,
411
- selector: domEditSelection.selector,
412
- selectorIndex: domEditSelection.selectorIndex,
413
- });
414
- }, [domEditSelection, getSidebarTab]);
410
+ useEffect(
411
+ // fallow-ignore-next-line complexity
412
+ () => {
413
+ if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
414
+ if (!domEditSelection.sourceFile) return;
415
+ if (getSidebarTab() !== "code") return;
416
+ openSourceRef.current(domEditSelection.sourceFile, {
417
+ id: domEditSelection.id,
418
+ selector: domEditSelection.selector,
419
+ selectorIndex: domEditSelection.selectorIndex,
420
+ });
421
+ },
422
+ [domEditSelection, getSidebarTab],
423
+ );
415
424
 
416
425
  return {
417
426
  // State
@@ -1,6 +1,9 @@
1
1
  import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import type { TimelineElement } from "../player";
3
- import { getPreviewTargetFromPointer } from "../utils/studioPreviewHelpers";
3
+ import {
4
+ getAllPreviewTargetsFromPointer,
5
+ getPreviewTargetFromPointer,
6
+ } from "../utils/studioPreviewHelpers";
4
7
  import { findMatchingTimelineElementId, type RightPanelTab } from "../utils/studioHelpers";
5
8
  import {
6
9
  domEditSelectionsTargetSame,
@@ -67,6 +70,10 @@ export interface UseDomSelectionReturn {
67
70
  clientY: number,
68
71
  options?: { preferClipAncestor?: boolean },
69
72
  ) => Promise<DomEditSelection | null>;
73
+ resolveAllDomSelectionsFromPreviewPoint: (
74
+ clientX: number,
75
+ clientY: number,
76
+ ) => Promise<DomEditSelection[]>;
70
77
  updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
71
78
  buildDomSelectionForTimelineElement: (
72
79
  element: TimelineElement,
@@ -113,6 +120,7 @@ export function useDomSelection({
113
120
  // ── Callbacks ──
114
121
 
115
122
  const applyDomSelection = useCallback(
123
+ // fallow-ignore-next-line complexity
116
124
  (
117
125
  selection: DomEditSelection | null,
118
126
  options?: {
@@ -212,6 +220,7 @@ export function useDomSelection({
212
220
  );
213
221
 
214
222
  const resolveDomSelectionFromPreviewPoint = useCallback(
223
+ // fallow-ignore-next-line complexity
215
224
  async (
216
225
  clientX: number,
217
226
  clientY: number,
@@ -234,6 +243,27 @@ export function useDomSelection({
234
243
  [activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
235
244
  );
236
245
 
246
+ const resolveAllDomSelectionsFromPreviewPoint = useCallback(
247
+ // fallow-ignore-next-line complexity
248
+ async (clientX: number, clientY: number): Promise<DomEditSelection[]> => {
249
+ const iframe = previewIframeRef.current;
250
+ if (!iframe || captionEditMode) return [];
251
+ try {
252
+ if (iframe.contentDocument) reapplyPositionEditsAfterSeek(iframe.contentDocument);
253
+ } catch {
254
+ /* cross-origin guard */
255
+ }
256
+ const targets = getAllPreviewTargetsFromPointer(iframe, clientX, clientY, activeCompPath);
257
+ const results: DomEditSelection[] = [];
258
+ for (const target of targets) {
259
+ const sel = await buildDomSelectionFromTarget(target, { skipSourceProbe: true });
260
+ if (sel) results.push(sel);
261
+ }
262
+ return results;
263
+ },
264
+ [activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
265
+ );
266
+
237
267
  const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
238
268
  if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
239
269
  domEditHoverSelectionRef.current = selection;
@@ -241,6 +271,7 @@ export function useDomSelection({
241
271
  }, []);
242
272
 
243
273
  const buildDomSelectionForTimelineElement = useCallback(
274
+ // fallow-ignore-next-line complexity
244
275
  async (element: TimelineElement): Promise<DomEditSelection | null> => {
245
276
  const iframe = previewIframeRef.current;
246
277
  let doc: Document | null = null;
@@ -282,6 +313,7 @@ export function useDomSelection({
282
313
  );
283
314
 
284
315
  const refreshDomEditSelectionFromPreview = useCallback(
316
+ // fallow-ignore-next-line complexity
285
317
  async (selection: DomEditSelection) => {
286
318
  const iframe = previewIframeRef.current;
287
319
  let doc: Document | null = null;
@@ -307,6 +339,7 @@ export function useDomSelection({
307
339
  );
308
340
 
309
341
  const refreshDomEditGroupSelectionsFromPreview = useCallback(
342
+ // fallow-ignore-next-line complexity
310
343
  async (selections: DomEditSelection[]) => {
311
344
  const iframe = previewIframeRef.current;
312
345
  let doc: Document | null = null;
@@ -430,6 +463,7 @@ export function useDomSelection({
430
463
  clearDomSelection,
431
464
  buildDomSelectionFromTarget,
432
465
  resolveDomSelectionFromPreviewPoint,
466
+ resolveAllDomSelectionsFromPreviewPoint,
433
467
  updateDomEditHoverSelection,
434
468
  buildDomSelectionForTimelineElement,
435
469
  handleTimelineElementSelect,
@@ -38,6 +38,7 @@ export function useFileManager({
38
38
  const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
39
39
  const [projectDir, setProjectDir] = useState<string | null>(null);
40
40
  const [fileTree, setFileTree] = useState<string[]>([]);
41
+ const [compositionPaths, setCompositionPaths] = useState<string[]>([]);
41
42
  const [fileTreeLoaded, setFileTreeLoaded] = useState(false);
42
43
  const [revealSourceOffset, setRevealSourceOffset] = useState<number | null>(null);
43
44
 
@@ -65,8 +66,9 @@ export function useFileManager({
65
66
  setFileTreeLoaded(false);
66
67
  fetch(`/api/projects/${projectId}`)
67
68
  .then((r) => r.json())
68
- .then((data: { files?: string[]; dir?: string }) => {
69
+ .then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
69
70
  if (!cancelled && data.files) setFileTree(data.files);
71
+ if (!cancelled && data.compositions) setCompositionPaths(data.compositions);
70
72
  if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
71
73
  })
72
74
  .catch(() => {
@@ -417,10 +419,7 @@ export function useFileManager({
417
419
 
418
420
  // ── Derived state ──
419
421
 
420
- const compositions = useMemo(
421
- () => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")),
422
- [fileTree],
423
- );
422
+ const compositions = compositionPaths;
424
423
 
425
424
  const assets = useMemo(
426
425
  () =>
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
2
2
  import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
+ import { applySoftReload } from "../utils/gsapSoftReload";
5
6
 
6
7
  const PROPERTY_DEFAULTS: Record<string, number> = {
7
8
  opacity: 1,
@@ -45,6 +46,7 @@ interface MutationResult {
45
46
  parsed?: ParsedGsap;
46
47
  before?: string;
47
48
  after?: string;
49
+ scriptText?: string;
48
50
  }
49
51
 
50
52
  async function mutateGsapScript(
@@ -71,6 +73,7 @@ async function mutateGsapScript(
71
73
  interface GsapScriptCommitsParams {
72
74
  projectIdRef: React.MutableRefObject<string | null>;
73
75
  activeCompPath: string | null;
76
+ previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
74
77
  editHistory: {
75
78
  recordEdit: (entry: {
76
79
  label: string;
@@ -90,6 +93,7 @@ const DEBOUNCE_MS = 150;
90
93
  export function useGsapScriptCommits({
91
94
  projectIdRef,
92
95
  activeCompPath,
96
+ previewIframeRef,
93
97
  editHistory,
94
98
  domEditSaveTimestampRef,
95
99
  reloadPreview,
@@ -105,6 +109,7 @@ export function useGsapScriptCommits({
105
109
 
106
110
  /** Send a mutation and record the edit in undo history. */
107
111
  const commitMutation = useCallback(
112
+ // fallow-ignore-next-line complexity
108
113
  async (
109
114
  selection: DomEditSelection,
110
115
  mutation: Record<string, unknown>,
@@ -130,13 +135,18 @@ export function useGsapScriptCommits({
130
135
 
131
136
  onCacheInvalidate();
132
137
 
133
- if (!options.softReload) {
138
+ if (options.softReload && result.scriptText) {
139
+ if (!applySoftReload(previewIframeRef.current, result.scriptText)) {
140
+ reloadPreview();
141
+ }
142
+ } else {
134
143
  reloadPreview();
135
144
  }
136
145
  },
137
146
  [
138
147
  projectIdRef,
139
148
  activeCompPath,
149
+ previewIframeRef,
140
150
  editHistory,
141
151
  domEditSaveTimestampRef,
142
152
  reloadPreview,
@@ -155,6 +165,7 @@ export function useGsapScriptCommits({
155
165
  {
156
166
  label: `Edit GSAP ${property}`,
157
167
  coalesceKey: `gsap:${animationId}:${property}`,
168
+ softReload: true,
158
169
  },
159
170
  );
160
171
  }, [commitMutation]);
@@ -210,6 +221,7 @@ export function useGsapScriptCommits({
210
221
  );
211
222
 
212
223
  const addGsapAnimation = useCallback(
224
+ // fallow-ignore-next-line complexity
213
225
  async (
214
226
  selection: DomEditSelection,
215
227
  method: "to" | "from" | "set" | "fromTo",
@@ -268,6 +280,7 @@ export function useGsapScriptCommits({
268
280
  );
269
281
 
270
282
  const addGsapProperty = useCallback(
283
+ // fallow-ignore-next-line complexity
271
284
  (selection: DomEditSelection, animationId: string, property: string) => {
272
285
  let defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
273
286
  const el = selection.element;
@@ -1,4 +1,4 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, useRef } from "react";
2
2
  import { liveTime, usePlayerStore } from "../player";
3
3
  import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
4
4
  import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability";
@@ -22,11 +22,26 @@ export interface UsePreviewInteractionParams {
22
22
  clientY: number,
23
23
  options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
24
24
  ) => Promise<DomEditSelection | null>;
25
+ resolveAllDomSelectionsFromPreviewPoint: (
26
+ clientX: number,
27
+ clientY: number,
28
+ ) => Promise<DomEditSelection[]>;
25
29
  updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
26
30
 
27
31
  onClickToSource?: (selection: DomEditSelection) => void;
28
32
  }
29
33
 
34
+ interface ClickCycleState {
35
+ x: number;
36
+ y: number;
37
+ candidates: DomEditSelection[];
38
+ index: number;
39
+ at: number;
40
+ }
41
+
42
+ const CYCLE_RADIUS_PX = 6;
43
+ const CYCLE_WINDOW_MS = 600;
44
+
30
45
  // ── Hook ──
31
46
 
32
47
  export function usePreviewInteraction({
@@ -36,36 +51,85 @@ export function usePreviewInteraction({
36
51
  showToast,
37
52
  applyDomSelection,
38
53
  resolveDomSelectionFromPreviewPoint,
54
+ resolveAllDomSelectionsFromPreviewPoint,
39
55
  updateDomEditHoverSelection,
40
56
  onClickToSource,
41
57
  }: UsePreviewInteractionParams) {
58
+ const cycleRef = useRef<ClickCycleState | null>(null);
59
+
42
60
  const handlePreviewCanvasMouseDown = useCallback(
61
+ // fallow-ignore-next-line complexity
43
62
  async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
44
63
  if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
64
+
65
+ const now = Date.now();
66
+ const prev = cycleRef.current;
67
+ const dx = prev ? e.clientX - prev.x : Infinity;
68
+ const dy = prev ? e.clientY - prev.y : Infinity;
69
+ const sameSpot =
70
+ prev !== null &&
71
+ Math.sqrt(dx * dx + dy * dy) < CYCLE_RADIUS_PX &&
72
+ now - prev.at < CYCLE_WINDOW_MS;
73
+
74
+ if (e.shiftKey) {
75
+ // Additive selection — no cycling
76
+ cycleRef.current = null;
77
+ const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
78
+ preferClipAncestor: options?.preferClipAncestor ?? false,
79
+ });
80
+ if (!nextSelection) return;
81
+ e.preventDefault();
82
+ e.stopPropagation();
83
+ applyDomSelection(nextSelection, { additive: true });
84
+ return;
85
+ }
86
+
87
+ if (sameSpot && prev) {
88
+ // Cycle to next candidate in z-stack
89
+ const nextIndex = (prev.index + 1) % prev.candidates.length;
90
+ const nextSel = prev.candidates[nextIndex];
91
+ cycleRef.current = { ...prev, index: nextIndex, at: now };
92
+ e.preventDefault();
93
+ e.stopPropagation();
94
+ applyDomSelection(nextSel);
95
+ return;
96
+ }
97
+
98
+ // Fresh click — resolve topmost element
45
99
  const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
46
100
  preferClipAncestor: options?.preferClipAncestor ?? false,
47
101
  });
48
102
  if (!nextSelection) {
49
- if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
103
+ cycleRef.current = null;
104
+ applyDomSelection(null, { revealPanel: false });
50
105
  return;
51
106
  }
52
107
  e.preventDefault();
53
108
  e.stopPropagation();
54
- applyDomSelection(nextSelection, { additive: e.shiftKey });
109
+ applyDomSelection(nextSelection);
110
+
55
111
  if (!e.shiftKey && e.altKey && onClickToSource) {
56
112
  onClickToSource(nextSelection);
57
113
  }
114
+
115
+ // Resolve all stacked candidates so a subsequent click at the same
116
+ // position can cycle to the next layer (issues #1124, #1125).
117
+ const all = await resolveAllDomSelectionsFromPreviewPoint(e.clientX, e.clientY);
118
+ cycleRef.current =
119
+ all.length > 1 ? { x: e.clientX, y: e.clientY, candidates: all, index: 0, at: now } : null;
58
120
  },
59
121
  [
60
122
  applyDomSelection,
61
123
  captionEditMode,
62
124
  compositionLoading,
63
125
  onClickToSource,
126
+ resolveAllDomSelectionsFromPreviewPoint,
64
127
  resolveDomSelectionFromPreviewPoint,
65
128
  ],
66
129
  );
67
130
 
68
131
  const handlePreviewCanvasPointerMove = useCallback(
132
+ // fallow-ignore-next-line complexity
69
133
  async (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
70
134
  if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
71
135
  updateDomEditHoverSelection(null);