@hyperframes/studio 0.6.73 → 0.6.75

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.
Files changed (63) hide show
  1. package/dist/assets/index-DcyZuBcU.css +1 -0
  2. package/dist/assets/index-uB_W2GDl.js +140 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +30 -24
  6. package/src/components/StudioPreviewArea.tsx +101 -26
  7. package/src/components/StudioRightPanel.tsx +3 -0
  8. package/src/components/StudioToast.tsx +18 -0
  9. package/src/components/TimelineToolbar.tsx +230 -4
  10. package/src/components/editor/AnimationCard.tsx +68 -4
  11. package/src/components/editor/DomEditOverlay.tsx +70 -1
  12. package/src/components/editor/GridOverlay.tsx +50 -0
  13. package/src/components/editor/KeyframeDiamond.tsx +49 -0
  14. package/src/components/editor/KeyframeNavigation.tsx +139 -0
  15. package/src/components/editor/LayersPanel.test.ts +135 -0
  16. package/src/components/editor/LayersPanel.tsx +151 -15
  17. package/src/components/editor/PropertyPanel.tsx +293 -140
  18. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  19. package/src/components/editor/SnapToolbar.tsx +163 -0
  20. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  21. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  22. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  23. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  24. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  25. package/src/components/editor/manualEditingAvailability.ts +6 -0
  26. package/src/components/editor/manualEditsDom.ts +56 -2
  27. package/src/components/editor/manualOffsetDrag.ts +19 -3
  28. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  29. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  30. package/src/components/editor/snapEngine.test.ts +657 -0
  31. package/src/components/editor/snapEngine.ts +575 -0
  32. package/src/components/editor/snapTargetCollection.ts +147 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  34. package/src/components/editor/useLayerDrag.ts +213 -0
  35. package/src/components/nle/NLELayout.tsx +18 -0
  36. package/src/contexts/DomEditContext.tsx +27 -0
  37. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  38. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  39. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  40. package/src/hooks/useAppHotkeys.ts +63 -1
  41. package/src/hooks/useDomEditCommits.ts +88 -4
  42. package/src/hooks/useDomEditSession.ts +179 -65
  43. package/src/hooks/useGsapScriptCommits.ts +144 -7
  44. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  45. package/src/hooks/useGsapTweenCache.ts +174 -3
  46. package/src/hooks/useTimelineEditing.ts +93 -0
  47. package/src/icons/SystemIcons.tsx +2 -0
  48. package/src/player/components/ClipContextMenu.tsx +99 -0
  49. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  50. package/src/player/components/Timeline.test.ts +2 -1
  51. package/src/player/components/Timeline.tsx +108 -68
  52. package/src/player/components/TimelineCanvas.tsx +47 -1
  53. package/src/player/components/TimelineClip.tsx +8 -3
  54. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  55. package/src/player/components/timelineDragDrop.ts +103 -0
  56. package/src/player/components/timelineLayout.ts +1 -1
  57. package/src/player/store/playerStore.ts +42 -0
  58. package/src/utils/editHistory.ts +1 -1
  59. package/src/utils/optimisticUpdate.test.ts +53 -0
  60. package/src/utils/optimisticUpdate.ts +18 -0
  61. package/src/utils/studioUiPreferences.ts +17 -0
  62. package/dist/assets/index-CrxThtSJ.css +0 -1
  63. package/dist/assets/index-Dc2HfqON.js +0 -140
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Read GSAP keyframe data from the live runtime in the preview iframe.
3
+ * Used to discover dynamic keyframes that the AST parser can't resolve
4
+ * (loops, variables, computed selectors).
5
+ */
6
+
7
+ interface RuntimeTween {
8
+ targets?: () => Element[];
9
+ vars?: Record<string, unknown>;
10
+ duration?: () => number;
11
+ startTime?: () => number;
12
+ }
13
+
14
+ interface RuntimeTimeline {
15
+ getChildren?: (deep: boolean) => RuntimeTween[];
16
+ duration?: () => number;
17
+ }
18
+
19
+ export function readRuntimeKeyframes(
20
+ iframe: HTMLIFrameElement | null,
21
+ selector: string,
22
+ compositionId?: string,
23
+ ): {
24
+ keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
25
+ easeEach?: string;
26
+ } | null {
27
+ if (!iframe?.contentWindow) return null;
28
+
29
+ let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
30
+ try {
31
+ timelines = (
32
+ iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
33
+ ).__timelines;
34
+ } catch {
35
+ return null;
36
+ }
37
+ if (!timelines) return null;
38
+
39
+ const tlId = compositionId || Object.keys(timelines)[0];
40
+ if (!tlId) return null;
41
+ const timeline = timelines[tlId];
42
+ if (!timeline?.getChildren) return null;
43
+
44
+ let doc: Document | null = null;
45
+ try {
46
+ doc = iframe.contentDocument;
47
+ } catch {
48
+ return null;
49
+ }
50
+ if (!doc) return null;
51
+
52
+ const targetEl = doc.querySelector(selector);
53
+ if (!targetEl) return null;
54
+
55
+ for (const tween of timeline.getChildren(true)) {
56
+ if (!tween.targets || !tween.vars) continue;
57
+ let matches = false;
58
+ for (const t of tween.targets()) {
59
+ if (t === targetEl || (targetEl.id && t.id === targetEl.id)) {
60
+ matches = true;
61
+ break;
62
+ }
63
+ }
64
+ if (!matches) continue;
65
+
66
+ const vars = tween.vars;
67
+ if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
68
+
69
+ const kfObj = vars.keyframes as Record<string, unknown>;
70
+ const result: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
71
+ let easeEach: string | undefined;
72
+
73
+ for (const [key, val] of Object.entries(kfObj)) {
74
+ if (key === "easeEach") {
75
+ if (typeof val === "string") easeEach = val;
76
+ continue;
77
+ }
78
+ const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
79
+ if (!pctMatch || !val || typeof val !== "object") continue;
80
+ const percentage = parseFloat(pctMatch[1]);
81
+ const properties: Record<string, number | string> = {};
82
+ for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
83
+ if (pk === "ease") continue;
84
+ if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
85
+ else if (typeof pv === "string") properties[pk] = pv;
86
+ }
87
+ if (Object.keys(properties).length > 0) {
88
+ result.push({ percentage, properties });
89
+ }
90
+ }
91
+
92
+ if (result.length > 0) {
93
+ result.sort((a, b) => a.percentage - b.percentage);
94
+ return { keyframes: result, easeEach };
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ // fallow-ignore-next-line complexity
101
+ export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
102
+ string,
103
+ {
104
+ keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
105
+ easeEach?: string;
106
+ }
107
+ > {
108
+ const result = new Map<
109
+ string,
110
+ {
111
+ keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
112
+ easeEach?: string;
113
+ }
114
+ >();
115
+ if (!iframe?.contentWindow) return result;
116
+
117
+ let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
118
+ try {
119
+ timelines = (
120
+ iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
121
+ ).__timelines;
122
+ } catch {
123
+ return result;
124
+ }
125
+ if (!timelines) return result;
126
+
127
+ for (const timeline of Object.values(timelines)) {
128
+ if (!timeline?.getChildren) continue;
129
+ for (const tween of timeline.getChildren(true)) {
130
+ if (!tween.targets || !tween.vars) continue;
131
+ const vars = tween.vars;
132
+ if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
133
+
134
+ const kfObj = vars.keyframes as Record<string, unknown>;
135
+ const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
136
+ [];
137
+ let easeEach: string | undefined;
138
+
139
+ for (const [key, val] of Object.entries(kfObj)) {
140
+ if (key === "easeEach") {
141
+ if (typeof val === "string") easeEach = val;
142
+ continue;
143
+ }
144
+ const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
145
+ if (!pctMatch || !val || typeof val !== "object") continue;
146
+ const percentage = parseFloat(pctMatch[1]);
147
+ const properties: Record<string, number | string> = {};
148
+ for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
149
+ if (pk === "ease") continue;
150
+ if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
151
+ else if (typeof pv === "string") properties[pk] = pv;
152
+ }
153
+ if (Object.keys(properties).length > 0) {
154
+ keyframes.push({ percentage, properties });
155
+ }
156
+ }
157
+
158
+ if (keyframes.length === 0) continue;
159
+ keyframes.sort((a, b) => a.percentage - b.percentage);
160
+
161
+ for (const target of tween.targets()) {
162
+ const id = (target as HTMLElement).id;
163
+ if (id && !result.has(id)) {
164
+ result.set(id, { keyframes, easeEach });
165
+ }
166
+ }
167
+ }
168
+ }
169
+ return result;
170
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Unified helper for committing any GSAP property value from the design panel.
3
+ *
4
+ * Handles three cases:
5
+ * 1. Animation with keyframes → add-keyframe at current percentage
6
+ * 2. Flat animation (no keyframes) → convert to keyframes, then add-keyframe
7
+ * 3. No animation → create tl.to(), convert to keyframes, then add-keyframe
8
+ */
9
+ import { useCallback } from "react";
10
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
11
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
12
+ import { usePlayerStore } from "../player/store/playerStore";
13
+ import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
14
+
15
+ interface CommitAnimatedPropertyDeps {
16
+ selectedGsapAnimations: GsapAnimation[];
17
+ gsapCommitMutation:
18
+ | ((
19
+ selection: DomEditSelection,
20
+ mutation: Record<string, unknown>,
21
+ options: {
22
+ label: string;
23
+ coalesceKey?: string;
24
+ softReload?: boolean;
25
+ skipReload?: boolean;
26
+ },
27
+ ) => Promise<void>)
28
+ | null;
29
+ addGsapAnimation: (
30
+ selection: DomEditSelection,
31
+ method: "to" | "from" | "set" | "fromTo",
32
+ currentTime?: number,
33
+ ) => void;
34
+ convertToKeyframes: (selection: DomEditSelection, animId: string) => void;
35
+ previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
36
+ bumpGsapCache: () => void;
37
+ }
38
+
39
+ function computePercentage(selection: DomEditSelection): number {
40
+ const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
41
+ const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
42
+ const currentTime = usePlayerStore.getState().currentTime;
43
+ return elDuration > 0
44
+ ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
45
+ : 0;
46
+ }
47
+
48
+ function selectorFor(selection: DomEditSelection): string | null {
49
+ if (selection.id) return `#${selection.id}`;
50
+ if (selection.selector) return selection.selector;
51
+ return null;
52
+ }
53
+
54
+ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
55
+ const {
56
+ selectedGsapAnimations,
57
+ gsapCommitMutation,
58
+ addGsapAnimation,
59
+ previewIframeRef,
60
+ bumpGsapCache,
61
+ } = deps;
62
+
63
+ const commitAnimatedProperty = useCallback(
64
+ async (
65
+ selection: DomEditSelection,
66
+ property: string,
67
+ value: number | string,
68
+ ): Promise<void> => {
69
+ if (!gsapCommitMutation) return;
70
+
71
+ const iframe = previewIframeRef.current;
72
+ const selector = selectorFor(selection);
73
+ const pct = computePercentage(selection);
74
+
75
+ let anim: GsapAnimation | undefined =
76
+ selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
77
+
78
+ // Case 3: No animation — create one first
79
+ if (!anim) {
80
+ addGsapAnimation(selection, "to");
81
+ // The addGsapAnimation triggers a reload. We need to wait for the cache
82
+ // to update. Use a small delay then bump cache to re-fetch.
83
+ await new Promise((r) => setTimeout(r, 500));
84
+ bumpGsapCache();
85
+ // After creation, we can't proceed in this call — the animation isn't
86
+ // in our local state yet. The user's next edit will find it.
87
+ // For immediate feedback, trigger a convert-to-keyframes on the new animation.
88
+ return;
89
+ }
90
+
91
+ // Case 2: Flat animation — convert to keyframes first
92
+ if (!anim.keyframes) {
93
+ await gsapCommitMutation(
94
+ selection,
95
+ { type: "convert-to-keyframes", animationId: anim.id },
96
+ { label: "Convert to keyframes", skipReload: true },
97
+ );
98
+ }
99
+
100
+ // Read all currently animated properties from runtime for backfill
101
+ const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
102
+
103
+ // Build the properties object: all runtime props + the new value
104
+ const properties: Record<string, number | string> = { ...runtimeProps };
105
+ properties[property] = value;
106
+
107
+ // Compute backfill defaults for properties not in existing keyframes
108
+ const backfillDefaults: Record<string, number | string> = { ...runtimeProps };
109
+ if (!(property in runtimeProps) && selector) {
110
+ const cssVal = readGsapProperty(iframe, selector, property);
111
+ if (cssVal != null) backfillDefaults[property] = cssVal;
112
+ }
113
+ backfillDefaults[property] = typeof value === "number" ? value : value;
114
+
115
+ await gsapCommitMutation(
116
+ selection,
117
+ {
118
+ type: "add-keyframe",
119
+ animationId: anim.id,
120
+ percentage: pct,
121
+ properties,
122
+ backfillDefaults,
123
+ },
124
+ { label: `Edit ${property} (keyframe ${pct}%)`, softReload: true },
125
+ );
126
+ },
127
+ [selectedGsapAnimations, gsapCommitMutation, addGsapAnimation, previewIframeRef, bumpGsapCache],
128
+ );
129
+
130
+ return commitAnimatedProperty;
131
+ }
@@ -62,6 +62,7 @@ interface EditHistoryHandle {
62
62
  interface UseAppHotkeysParams {
63
63
  toggleTimelineVisibility: () => void;
64
64
  handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
65
+ handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
65
66
  handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
66
67
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
67
68
  clearDomSelectionRef: React.MutableRefObject<() => void>;
@@ -77,6 +78,9 @@ interface UseAppHotkeysParams {
77
78
  handleCopy: () => boolean;
78
79
  handlePaste: () => Promise<void>;
79
80
  handleCut: () => Promise<boolean>;
81
+ onResetKeyframes: () => boolean;
82
+ onDeleteSelectedKeyframes: () => void;
83
+ onAfterUndoRedo?: () => void;
80
84
  }
81
85
 
82
86
  // ── Hook ──
@@ -84,6 +88,7 @@ interface UseAppHotkeysParams {
84
88
  export function useAppHotkeys({
85
89
  toggleTimelineVisibility,
86
90
  handleTimelineElementDelete,
91
+ handleTimelineElementSplit,
87
92
  handleDomEditElementDelete,
88
93
  domEditSelectionRef,
89
94
  editHistory,
@@ -98,6 +103,9 @@ export function useAppHotkeys({
98
103
  handleCopy,
99
104
  handlePaste,
100
105
  handleCut,
106
+ onResetKeyframes,
107
+ onDeleteSelectedKeyframes,
108
+ onAfterUndoRedo,
101
109
  }: UseAppHotkeysParams) {
102
110
  const previewHotkeyWindowRef = useRef<Window | null>(null);
103
111
  const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
@@ -144,6 +152,7 @@ export function useAppHotkeys({
144
152
  return;
145
153
  }
146
154
  if (result.ok && result.label) {
155
+ onAfterUndoRedo?.();
147
156
  await syncHistoryPreviewAfterApply(result.paths);
148
157
  showToast(`Undid ${result.label}`, "info");
149
158
  }
@@ -154,6 +163,7 @@ export function useAppHotkeys({
154
163
  syncHistoryPreviewAfterApply,
155
164
  waitForPendingDomEditSaves,
156
165
  writeHistoryProjectFile,
166
+ onAfterUndoRedo,
157
167
  ]);
158
168
 
159
169
  const handleRedo = useCallback(async () => {
@@ -167,6 +177,7 @@ export function useAppHotkeys({
167
177
  return;
168
178
  }
169
179
  if (result.ok && result.label) {
180
+ onAfterUndoRedo?.();
170
181
  await syncHistoryPreviewAfterApply(result.paths);
171
182
  showToast(`Redid ${result.label}`, "info");
172
183
  }
@@ -177,6 +188,7 @@ export function useAppHotkeys({
177
188
  syncHistoryPreviewAfterApply,
178
189
  waitForPendingDomEditSaves,
179
190
  writeHistoryProjectFile,
191
+ onAfterUndoRedo,
180
192
  ]);
181
193
 
182
194
  // ── Stable refs for the consolidated keydown handler ──
@@ -185,6 +197,8 @@ export function useAppHotkeys({
185
197
  handleToggleRef.current = handleTimelineToggleHotkey;
186
198
  const handleDeleteRef = useRef(handleTimelineElementDelete);
187
199
  handleDeleteRef.current = handleTimelineElementDelete;
200
+ const handleSplitRef = useRef(handleTimelineElementSplit);
201
+ handleSplitRef.current = handleTimelineElementSplit;
188
202
  const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
189
203
  handleDomEditDeleteRef.current = handleDomEditElementDelete;
190
204
  const handleUndoRef = useRef(handleUndo);
@@ -197,6 +211,10 @@ export function useAppHotkeys({
197
211
  handlePasteRef.current = handlePaste;
198
212
  const handleCutRef = useRef(handleCut);
199
213
  handleCutRef.current = handleCut;
214
+ const onResetKeyframesRef = useRef(onResetKeyframes);
215
+ onResetKeyframesRef.current = onResetKeyframes;
216
+ const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes);
217
+ onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes;
200
218
 
201
219
  // ── Consolidated keydown handler ──
202
220
 
@@ -292,7 +310,31 @@ export function useAppHotkeys({
292
310
  return;
293
311
  }
294
312
 
295
- // Delete / Backspace remove selected element (timeline clip or preview selection)
313
+ // Ssplit selected clip at playhead
314
+ if (
315
+ event.key === "s" &&
316
+ !event.metaKey &&
317
+ !event.ctrlKey &&
318
+ !event.altKey &&
319
+ !isEditableTarget(event.target)
320
+ ) {
321
+ const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
322
+ if (selectedElementId) {
323
+ const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
324
+ if (
325
+ element &&
326
+ ["video", "audio", "img"].includes(element.tag) &&
327
+ currentTime > element.start &&
328
+ currentTime < element.start + element.duration
329
+ ) {
330
+ event.preventDefault();
331
+ void handleSplitRef.current(element, currentTime);
332
+ return;
333
+ }
334
+ }
335
+ }
336
+
337
+ // Delete / Backspace — remove selected keyframes > reset keyframes > remove element
296
338
  if (
297
339
  (event.key === "Delete" || event.key === "Backspace") &&
298
340
  !event.metaKey &&
@@ -300,6 +342,26 @@ export function useAppHotkeys({
300
342
  !event.altKey &&
301
343
  !isEditableTarget(event.target)
302
344
  ) {
345
+ // Priority: selected keyframes take precedence over clip deletion
346
+ const { selectedKeyframes } = usePlayerStore.getState();
347
+ if (selectedKeyframes.size > 0) {
348
+ onDeleteSelectedKeyframesRef.current();
349
+ usePlayerStore.getState().clearSelectedKeyframes();
350
+ event.preventDefault();
351
+ return;
352
+ }
353
+
354
+ // Backspace: try resetting keyframes first; fall through to delete if none found
355
+ if (event.key === "Backspace") {
356
+ const { selectedElementId, keyframeCache } = usePlayerStore.getState();
357
+ if (selectedElementId && keyframeCache.has(selectedElementId)) {
358
+ if (onResetKeyframesRef.current()) {
359
+ event.preventDefault();
360
+ return;
361
+ }
362
+ }
363
+ }
364
+
303
365
  const { selectedElementId, elements } = usePlayerStore.getState();
304
366
  if (selectedElementId) {
305
367
  const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
@@ -35,6 +35,37 @@ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditO
35
35
  import type { EditHistoryKind } from "../utils/editHistory";
36
36
  import { useDomEditTextCommits } from "./useDomEditTextCommits";
37
37
 
38
+ // ── Helpers ──
39
+
40
+ type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
41
+
42
+ function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean {
43
+ if (!iframe?.contentWindow) return false;
44
+ let timelines: Record<string, TimelineLike> | undefined;
45
+ try {
46
+ timelines = (iframe.contentWindow as Window & { __timelines?: Record<string, TimelineLike> })
47
+ .__timelines;
48
+ } catch {
49
+ return false;
50
+ }
51
+ if (!timelines) return false;
52
+ const id = element.id;
53
+ for (const tl of Object.values(timelines)) {
54
+ if (!tl?.getChildren) continue;
55
+ try {
56
+ for (const child of tl.getChildren(true)) {
57
+ if (!child.targets) continue;
58
+ for (const t of child.targets()) {
59
+ if (t === element || (id && t.id === id)) return true;
60
+ }
61
+ }
62
+ } catch {
63
+ continue;
64
+ }
65
+ }
66
+ return false;
67
+ }
68
+
38
69
  // ── Types ──
39
70
 
40
71
  interface RecordEditInput {
@@ -290,12 +321,13 @@ export function useDomEditCommits({
290
321
  const handleDomPathOffsetCommit = useCallback(
291
322
  (selection: DomEditSelection, next: { x: number; y: number }) => {
292
323
  applyStudioPathOffset(selection.element, next);
324
+ if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
293
325
  commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
294
326
  label: "Move layer",
295
327
  coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
296
328
  });
297
329
  },
298
- [commitPositionPatchToHtml],
330
+ [commitPositionPatchToHtml, previewIframeRef],
299
331
  );
300
332
 
301
333
  const handleDomGroupPathOffsetCommit = useCallback(
@@ -307,35 +339,38 @@ export function useDomEditCommits({
307
339
  .join(":");
308
340
  for (const { selection, next } of updates) {
309
341
  applyStudioPathOffset(selection.element, next);
342
+ if (isElementGsapTargeted(previewIframeRef.current, selection.element)) continue;
310
343
  commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
311
344
  label: `Move ${updates.length} layers`,
312
345
  coalesceKey: `group-path-offset:${coalesceKey}`,
313
346
  });
314
347
  }
315
348
  },
316
- [commitPositionPatchToHtml],
349
+ [commitPositionPatchToHtml, previewIframeRef],
317
350
  );
318
351
 
319
352
  const handleDomBoxSizeCommit = useCallback(
320
353
  (selection: DomEditSelection, next: { width: number; height: number }) => {
321
354
  applyStudioBoxSize(selection.element, next);
355
+ if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
322
356
  commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
323
357
  label: "Resize layer box",
324
358
  coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
325
359
  });
326
360
  },
327
- [commitPositionPatchToHtml],
361
+ [commitPositionPatchToHtml, previewIframeRef],
328
362
  );
329
363
 
330
364
  const handleDomRotationCommit = useCallback(
331
365
  (selection: DomEditSelection, next: { angle: number }) => {
332
366
  applyStudioRotation(selection.element, next);
367
+ if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
333
368
  commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
334
369
  label: "Rotate layer",
335
370
  coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
336
371
  });
337
372
  },
338
- [commitPositionPatchToHtml],
373
+ [commitPositionPatchToHtml, previewIframeRef],
339
374
  );
340
375
 
341
376
  const handleDomManualEditsReset = useCallback(
@@ -494,6 +529,54 @@ export function useDomEditCommits({
494
529
  ],
495
530
  );
496
531
 
532
+ const handleDomZIndexReorderCommit = useCallback(
533
+ (
534
+ entries: Array<{
535
+ element: HTMLElement;
536
+ zIndex: number;
537
+ id?: string;
538
+ selector?: string;
539
+ selectorIndex?: number;
540
+ sourceFile: string;
541
+ }>,
542
+ ) => {
543
+ if (entries.length === 0) return;
544
+ const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? "el").join(":")}`;
545
+ for (let i = 0; i < entries.length; i++) {
546
+ const entry = entries[i];
547
+ entry.element.style.zIndex = String(entry.zIndex);
548
+ const patches: Array<{ type: "inline-style"; property: string; value: string }> = [
549
+ { type: "inline-style", property: "z-index", value: String(entry.zIndex) },
550
+ ];
551
+ try {
552
+ const win = entry.element.ownerDocument?.defaultView;
553
+ if (win && win.getComputedStyle(entry.element).position === "static") {
554
+ entry.element.style.position = "relative";
555
+ patches.push({ type: "inline-style", property: "position", value: "relative" });
556
+ }
557
+ } catch {
558
+ /* cross-origin or detached — skip */
559
+ }
560
+ commitPositionPatchToHtml(
561
+ {
562
+ element: entry.element,
563
+ id: entry.id ?? null,
564
+ selector: entry.selector,
565
+ selectorIndex: entry.selectorIndex,
566
+ sourceFile: entry.sourceFile,
567
+ } as unknown as DomEditSelection,
568
+ patches,
569
+ {
570
+ label: "Reorder layers",
571
+ coalesceKey,
572
+ skipRefresh: i < entries.length - 1,
573
+ },
574
+ );
575
+ }
576
+ },
577
+ [commitPositionPatchToHtml],
578
+ );
579
+
497
580
  return {
498
581
  resolveImportedFontAsset,
499
582
  handleDomStyleCommit,
@@ -512,5 +595,6 @@ export function useDomEditCommits({
512
595
  handleDomMotionCommit,
513
596
  handleDomMotionClear,
514
597
  handleDomEditElementDelete,
598
+ handleDomZIndexReorderCommit,
515
599
  };
516
600
  }