@hyperframes/studio 0.6.58 → 0.6.60

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
  });
@@ -343,6 +346,7 @@ export function useDomEditSession({
343
346
  useEffect(() => {
344
347
  if (!previewIframe) return;
345
348
 
349
+ // fallow-ignore-next-line complexity
346
350
  const syncSelectionFromDocument = async () => {
347
351
  if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
348
352
  const currentSelection = domEditSelectionRef.current;
@@ -402,16 +406,20 @@ export function useDomEditSession({
402
406
  // not when openSourceForSelection is recreated due to editingFile content updates.
403
407
  const openSourceRef = useRef(openSourceForSelection);
404
408
  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]);
409
+ useEffect(
410
+ // fallow-ignore-next-line complexity
411
+ () => {
412
+ if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
413
+ if (!domEditSelection.sourceFile) return;
414
+ if (getSidebarTab() !== "code") return;
415
+ openSourceRef.current(domEditSelection.sourceFile, {
416
+ id: domEditSelection.id,
417
+ selector: domEditSelection.selector,
418
+ selectorIndex: domEditSelection.selectorIndex,
419
+ });
420
+ },
421
+ [domEditSelection, getSidebarTab],
422
+ );
415
423
 
416
424
  return {
417
425
  // 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
  () =>
@@ -105,6 +105,7 @@ export function useGsapScriptCommits({
105
105
 
106
106
  /** Send a mutation and record the edit in undo history. */
107
107
  const commitMutation = useCallback(
108
+ // fallow-ignore-next-line complexity
108
109
  async (
109
110
  selection: DomEditSelection,
110
111
  mutation: Record<string, unknown>,
@@ -210,6 +211,7 @@ export function useGsapScriptCommits({
210
211
  );
211
212
 
212
213
  const addGsapAnimation = useCallback(
214
+ // fallow-ignore-next-line complexity
213
215
  async (
214
216
  selection: DomEditSelection,
215
217
  method: "to" | "from" | "set" | "fromTo",
@@ -268,6 +270,7 @@ export function useGsapScriptCommits({
268
270
  );
269
271
 
270
272
  const addGsapProperty = useCallback(
273
+ // fallow-ignore-next-line complexity
271
274
  (selection: DomEditSelection, animationId: string, property: string) => {
272
275
  let defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
273
276
  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);