@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,163 @@
1
+ // fallow-ignore-file unused-file
2
+ import { memo, useCallback, useEffect, useRef, useState } from "react";
3
+ import { MagnetStraight, GridFour } from "@phosphor-icons/react";
4
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
5
+
6
+ const SNAP_DEFAULTS = {
7
+ snapEnabled: true,
8
+ gridVisible: false,
9
+ gridSpacing: 50,
10
+ snapToGrid: false,
11
+ };
12
+
13
+ // fallow-ignore-next-line complexity
14
+ function readSnapPrefs() {
15
+ const prefs = readStudioUiPreferences();
16
+ return {
17
+ snapEnabled: prefs.snapEnabled ?? SNAP_DEFAULTS.snapEnabled,
18
+ gridVisible: prefs.gridVisible ?? SNAP_DEFAULTS.gridVisible,
19
+ gridSpacing: prefs.gridSpacing ?? SNAP_DEFAULTS.gridSpacing,
20
+ snapToGrid: prefs.snapToGrid ?? SNAP_DEFAULTS.snapToGrid,
21
+ };
22
+ }
23
+
24
+ interface SnapToolbarProps {
25
+ onSnapChange?: (prefs: {
26
+ snapEnabled: boolean;
27
+ gridVisible: boolean;
28
+ gridSpacing: number;
29
+ snapToGrid: boolean;
30
+ }) => void;
31
+ }
32
+
33
+ // fallow-ignore-next-line complexity
34
+ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolbarProps) {
35
+ const [prefs, setPrefs] = useState(readSnapPrefs);
36
+ const [gridPopoverOpen, setGridPopoverOpen] = useState(false);
37
+ const popoverRef = useRef<HTMLDivElement>(null);
38
+ const gridButtonRef = useRef<HTMLButtonElement>(null);
39
+
40
+ const updatePrefs = useCallback(
41
+ (patch: Partial<typeof prefs>) => {
42
+ setPrefs((prev) => {
43
+ const next = { ...prev, ...patch };
44
+ writeStudioUiPreferences(patch);
45
+ onSnapChange?.(next);
46
+ return next;
47
+ });
48
+ },
49
+ [onSnapChange],
50
+ );
51
+
52
+ const toggleSnap = useCallback(() => {
53
+ updatePrefs({ snapEnabled: !prefs.snapEnabled });
54
+ }, [prefs.snapEnabled, updatePrefs]);
55
+
56
+ const toggleGrid = useCallback(() => {
57
+ updatePrefs({ gridVisible: !prefs.gridVisible });
58
+ }, [prefs.gridVisible, updatePrefs]);
59
+
60
+ useEffect(() => {
61
+ // fallow-ignore-next-line complexity
62
+ const handleKeyDown = (e: KeyboardEvent) => {
63
+ const t = e.target;
64
+ if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return;
65
+ if (t instanceof HTMLElement && t.isContentEditable) return;
66
+ if (t instanceof HTMLIFrameElement) return;
67
+ if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
68
+ e.preventDefault();
69
+ updatePrefs({ snapEnabled: !readSnapPrefs().snapEnabled });
70
+ }
71
+ if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
72
+ e.preventDefault();
73
+ updatePrefs({ gridVisible: !readSnapPrefs().gridVisible });
74
+ }
75
+ };
76
+ document.addEventListener("keydown", handleKeyDown);
77
+ return () => document.removeEventListener("keydown", handleKeyDown);
78
+ }, [updatePrefs]);
79
+
80
+ useEffect(() => {
81
+ if (!gridPopoverOpen) return;
82
+ const handleClickOutside = (e: MouseEvent) => {
83
+ const target = e.target as Node;
84
+ if (popoverRef.current?.contains(target) || gridButtonRef.current?.contains(target)) return;
85
+ setGridPopoverOpen(false);
86
+ };
87
+ document.addEventListener("mousedown", handleClickOutside);
88
+ return () => document.removeEventListener("mousedown", handleClickOutside);
89
+ }, [gridPopoverOpen]);
90
+
91
+ return (
92
+ <div className="absolute top-2 right-2 z-50 flex items-center gap-1">
93
+ <button
94
+ type="button"
95
+ className={`rounded-md p-1.5 transition-colors ${
96
+ prefs.snapEnabled
97
+ ? "bg-studio-accent/20 text-studio-accent"
98
+ : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
99
+ }`}
100
+ onClick={toggleSnap}
101
+ title={prefs.snapEnabled ? "Snap enabled (S)" : "Snap disabled (S)"}
102
+ aria-label="Toggle snap"
103
+ >
104
+ <MagnetStraight size={16} weight={prefs.snapEnabled ? "fill" : "regular"} />
105
+ </button>
106
+
107
+ <div className="relative">
108
+ <button
109
+ ref={gridButtonRef}
110
+ type="button"
111
+ className={`rounded-md p-1.5 transition-colors ${
112
+ prefs.gridVisible
113
+ ? "bg-studio-accent/20 text-studio-accent"
114
+ : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
115
+ }`}
116
+ onClick={toggleGrid}
117
+ onContextMenu={(e) => {
118
+ e.preventDefault();
119
+ setGridPopoverOpen((v) => !v);
120
+ }}
121
+ title={prefs.gridVisible ? "Grid visible (G)" : "Grid hidden (G)"}
122
+ aria-label="Toggle grid"
123
+ >
124
+ <GridFour size={16} weight={prefs.gridVisible ? "fill" : "regular"} />
125
+ </button>
126
+
127
+ {gridPopoverOpen && (
128
+ <div
129
+ ref={popoverRef}
130
+ className="absolute right-0 top-full mt-1 rounded-lg bg-neutral-800 border border-neutral-700 p-3 shadow-xl min-w-[180px]"
131
+ >
132
+ <label className="flex items-center justify-between text-xs text-white/80 mb-2">
133
+ <span>Grid spacing</span>
134
+ <input
135
+ type="number"
136
+ min={10}
137
+ max={500}
138
+ step={10}
139
+ value={prefs.gridSpacing}
140
+ onChange={(e) => {
141
+ const val = Number.parseInt(e.target.value, 10);
142
+ if (Number.isFinite(val) && val >= 10 && val <= 500) {
143
+ updatePrefs({ gridSpacing: val });
144
+ }
145
+ }}
146
+ className="w-16 rounded bg-neutral-900 border border-neutral-600 px-1.5 py-0.5 text-xs text-white text-right tabular-nums outline-none focus:border-studio-accent"
147
+ />
148
+ </label>
149
+ <label className="flex items-center gap-2 text-xs text-white/80 cursor-pointer">
150
+ <input
151
+ type="checkbox"
152
+ checked={prefs.snapToGrid}
153
+ onChange={() => updatePrefs({ snapToGrid: !prefs.snapToGrid })}
154
+ className="accent-studio-accent"
155
+ />
156
+ <span>Snap to grid</span>
157
+ </label>
158
+ </div>
159
+ )}
160
+ </div>
161
+ </div>
162
+ );
163
+ });
@@ -0,0 +1,256 @@
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { generateSpringEaseData, SPRING_PRESETS } from "@hyperframes/core/spring-ease";
3
+ import { LABEL } from "./MotionPanelFields";
4
+ import { RotateCcw } from "../../icons/SystemIcons";
5
+
6
+ interface SpringParams {
7
+ mass: number;
8
+ stiffness: number;
9
+ damping: number;
10
+ }
11
+
12
+ const DEFAULT_SPRING: SpringParams = { mass: 1, stiffness: 180, damping: 12 };
13
+
14
+ const SLIDERS: {
15
+ key: keyof SpringParams;
16
+ label: string;
17
+ min: number;
18
+ max: number;
19
+ step: number;
20
+ }[] = [
21
+ { key: "mass", label: "Mass", min: 0.1, max: 5, step: 0.1 },
22
+ { key: "stiffness", label: "Stiffness", min: 10, max: 500, step: 10 },
23
+ { key: "damping", label: "Damping", min: 1, max: 50, step: 1 },
24
+ ];
25
+
26
+ function springValue(mass: number, stiffness: number, damping: number, t: number): number {
27
+ const w0 = Math.sqrt(stiffness / mass);
28
+ const zeta = damping / (2 * Math.sqrt(stiffness * mass));
29
+ if (zeta < 1) {
30
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
31
+ return (
32
+ 1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + ((zeta * w0) / wd) * Math.sin(wd * t))
33
+ );
34
+ }
35
+ if (zeta === 1) {
36
+ return 1 - (1 + w0 * t) * Math.exp(-w0 * t);
37
+ }
38
+ const s1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1));
39
+ const s2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1));
40
+ return 1 + (s1 * Math.exp(s2 * t) - s2 * Math.exp(s1 * t)) / (s2 - s1);
41
+ }
42
+
43
+ function springSimDuration(mass: number, stiffness: number, damping: number): number {
44
+ const w0 = Math.sqrt(stiffness / mass);
45
+ const zeta = damping / (2 * Math.sqrt(stiffness * mass));
46
+ if (zeta < 1) return Math.min(5 / (zeta * w0), 10);
47
+ const decayRate = zeta * w0 - w0 * Math.sqrt(zeta * zeta - 1);
48
+ return Math.min(4 / Math.max(decayRate, 0.01), 10);
49
+ }
50
+
51
+ function buildSpringPath(
52
+ params: SpringParams,
53
+ mapFn: (point: { x: number; y: number }) => { x: number; y: number },
54
+ ): string {
55
+ const steps = 64;
56
+ const simDur = springSimDuration(params.mass, params.stiffness, params.damping);
57
+ const commands: string[] = [];
58
+ for (let i = 0; i <= steps; i++) {
59
+ const t = i / steps;
60
+ const simT = t * simDur;
61
+ const y = springValue(params.mass, params.stiffness, params.damping, simT);
62
+ const mapped = mapFn({ x: t, y });
63
+ commands.push(`${i === 0 ? "M" : "L"}${mapped.x.toFixed(2)},${mapped.y.toFixed(2)}`);
64
+ }
65
+ return commands.join(" ");
66
+ }
67
+
68
+ export function SpringEaseEditor({
69
+ onCommit,
70
+ }: {
71
+ onCommit: (easeId: string, easeData: string) => void;
72
+ }) {
73
+ const [params, setParams] = useState<SpringParams>(DEFAULT_SPRING);
74
+ const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
75
+
76
+ const scheduleCommit = useCallback(
77
+ (next: SpringParams) => {
78
+ if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current);
79
+ commitTimeoutRef.current = setTimeout(() => {
80
+ const data = generateSpringEaseData(next.mass, next.stiffness, next.damping);
81
+ const id = `spring-m${next.mass}-k${next.stiffness}-d${next.damping}`;
82
+ onCommit(id, data);
83
+ }, 120);
84
+ },
85
+ [onCommit],
86
+ );
87
+
88
+ useEffect(() => {
89
+ return () => {
90
+ if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current);
91
+ };
92
+ }, []);
93
+
94
+ const updateParam = (key: keyof SpringParams, value: number) => {
95
+ const next = { ...params, [key]: value };
96
+ setParams(next);
97
+ scheduleCommit(next);
98
+ };
99
+
100
+ const applyPreset = (preset: (typeof SPRING_PRESETS)[number]) => {
101
+ const next: SpringParams = {
102
+ mass: preset.mass,
103
+ stiffness: preset.stiffness,
104
+ damping: preset.damping,
105
+ };
106
+ setParams(next);
107
+ const data = generateSpringEaseData(next.mass, next.stiffness, next.damping);
108
+ onCommit(preset.name, data);
109
+ };
110
+
111
+ const reset = () => {
112
+ setParams(DEFAULT_SPRING);
113
+ const data = generateSpringEaseData(
114
+ DEFAULT_SPRING.mass,
115
+ DEFAULT_SPRING.stiffness,
116
+ DEFAULT_SPRING.damping,
117
+ );
118
+ onCommit("spring-bouncy", data);
119
+ };
120
+
121
+ // SVG layout matching EaseCurveEditor proportions
122
+ const width = 324;
123
+ const height = 214;
124
+ const plot = { left: 46, top: 24, width: 242, height: 146 };
125
+ const yMin = -0.2;
126
+ const yMax = 1.3;
127
+
128
+ const mapPoint = (point: { x: number; y: number }) => ({
129
+ x: plot.left + point.x * plot.width,
130
+ y: plot.top + ((yMax - point.y) / (yMax - yMin)) * plot.height,
131
+ });
132
+
133
+ const curvePath = buildSpringPath(params, mapPoint);
134
+ const start = mapPoint({ x: 0, y: 0 });
135
+ const end = mapPoint({ x: 1, y: 1 });
136
+
137
+ const activePreset = SPRING_PRESETS.find(
138
+ (p) =>
139
+ p.mass === params.mass && p.stiffness === params.stiffness && p.damping === params.damping,
140
+ );
141
+
142
+ return (
143
+ <div className="overflow-hidden rounded-2xl border border-neutral-800 bg-black/40">
144
+ <div className="flex items-center justify-between gap-3 border-b border-neutral-800 px-3 py-2">
145
+ <div>
146
+ <div className={LABEL}>Spring Ease</div>
147
+ <div className="mt-1 font-mono text-[10px] text-neutral-500">
148
+ {activePreset?.label ?? `m${params.mass} k${params.stiffness} d${params.damping}`}
149
+ </div>
150
+ </div>
151
+ <button
152
+ type="button"
153
+ onClick={reset}
154
+ className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-800 bg-neutral-950 px-3 text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-100"
155
+ >
156
+ <RotateCcw size={13} />
157
+ Reset
158
+ </button>
159
+ </div>
160
+
161
+ {/* Curve preview */}
162
+ <svg viewBox={`0 0 ${width} ${height}`} className="block w-full select-none touch-none">
163
+ <rect x="0" y="0" width={width} height={height} fill="transparent" />
164
+ {[0, 0.5, 1].map((value) => {
165
+ const mapped = mapPoint({ x: 0, y: value });
166
+ return (
167
+ <g key={value}>
168
+ <line
169
+ x1={plot.left}
170
+ x2={plot.left + plot.width}
171
+ y1={mapped.y}
172
+ y2={mapped.y}
173
+ stroke="rgba(255,255,255,0.12)"
174
+ strokeDasharray="5 8"
175
+ />
176
+ <text
177
+ x={plot.left - 12}
178
+ y={mapped.y + 4}
179
+ textAnchor="end"
180
+ className="fill-neutral-500 text-[10px] font-semibold"
181
+ >
182
+ {value}
183
+ </text>
184
+ </g>
185
+ );
186
+ })}
187
+ <line
188
+ x1={plot.left}
189
+ x2={plot.left + plot.width}
190
+ y1={plot.top + plot.height}
191
+ y2={plot.top + plot.height}
192
+ stroke="rgba(255,255,255,0.18)"
193
+ />
194
+ <line
195
+ x1={plot.left}
196
+ x2={plot.left}
197
+ y1={plot.top}
198
+ y2={plot.top + plot.height}
199
+ stroke="rgba(255,255,255,0.18)"
200
+ />
201
+ <path d={curvePath} fill="none" stroke="#ffdd57" strokeWidth="4" strokeLinecap="round" />
202
+ <circle cx={start.x} cy={start.y} r="5" fill="#ffdd57" />
203
+ <circle cx={end.x} cy={end.y} r="5" fill="#ffdd57" />
204
+ </svg>
205
+
206
+ {/* Presets */}
207
+ <div className="flex gap-1.5 border-t border-neutral-800 px-3 py-2">
208
+ {SPRING_PRESETS.map((preset) => {
209
+ const isActive =
210
+ preset.mass === params.mass &&
211
+ preset.stiffness === params.stiffness &&
212
+ preset.damping === params.damping;
213
+ return (
214
+ <button
215
+ key={preset.name}
216
+ type="button"
217
+ onClick={() => applyPreset(preset)}
218
+ className={`flex-1 rounded-lg px-1.5 py-1.5 text-[10px] font-semibold transition-colors ${
219
+ isActive
220
+ ? "border border-yellow-400/40 bg-yellow-400/10 text-yellow-300"
221
+ : "border border-neutral-800 bg-neutral-950 text-neutral-500 hover:border-neutral-700 hover:text-neutral-300"
222
+ }`}
223
+ >
224
+ {preset.label}
225
+ </button>
226
+ );
227
+ })}
228
+ </div>
229
+
230
+ {/* Sliders */}
231
+ <div className="space-y-3 border-t border-neutral-800 px-3 py-3">
232
+ {SLIDERS.map((slider) => (
233
+ <div key={slider.key}>
234
+ <div className="mb-1 flex items-center justify-between">
235
+ <span className="text-[10px] font-medium uppercase tracking-[0.14em] text-neutral-500">
236
+ {slider.label}
237
+ </span>
238
+ <span className="min-w-[36px] text-right font-mono text-[10px] text-neutral-400">
239
+ {params[slider.key]}
240
+ </span>
241
+ </div>
242
+ <input
243
+ type="range"
244
+ min={slider.min}
245
+ max={slider.max}
246
+ step={slider.step}
247
+ value={params[slider.key]}
248
+ onChange={(e) => updateParam(slider.key, Number(e.target.value))}
249
+ className="h-1 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-yellow-400 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-yellow-400"
250
+ />
251
+ </div>
252
+ ))}
253
+ </div>
254
+ </div>
255
+ );
256
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  } from "./manualEdits";
7
7
  import type { ManualOffsetDragMember } from "./manualOffsetDrag";
8
8
  import type { GroupOverlayItem } from "./domEditOverlayGeometry";
9
+ import type { SnapContext } from "./snapTargetCollection";
9
10
 
10
11
  export type GestureKind = "drag" | "resize" | "rotate";
11
12
 
@@ -36,6 +37,9 @@ export interface GestureState {
36
37
  editScaleX: number;
37
38
  editScaleY: number;
38
39
  manualEditDragToken?: string;
40
+ snapContext?: SnapContext;
41
+ lastSnappedDx?: number;
42
+ lastSnappedDy?: number;
39
43
  }
40
44
 
41
45
  export interface GroupGestureState {
@@ -43,6 +47,9 @@ export interface GroupGestureState {
43
47
  startY: number;
44
48
  originItems: GroupOverlayItem[];
45
49
  members: ManualOffsetDragMember[];
50
+ snapContext?: SnapContext;
51
+ lastSnappedDx?: number;
52
+ lastSnappedDy?: number;
46
53
  }
47
54
 
48
55
  export interface BlockedMoveState {
@@ -23,6 +23,7 @@ import {
23
23
  } from "./domEditOverlayGeometry";
24
24
  import { type GestureKind, type GestureState } from "./domEditOverlayGestures";
25
25
  import type { UseDomEditOverlayGesturesOptions } from "./useDomEditOverlayGestures";
26
+ import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
26
27
 
27
28
  export function startGroupDrag(
28
29
  e: React.PointerEvent<HTMLElement>,
@@ -61,6 +62,20 @@ export function startGroupDrag(
61
62
  members.push(result.member);
62
63
  }
63
64
 
65
+ const overlayEl = opts.overlayRef.current;
66
+ const iframe = opts.iframeRef.current;
67
+ const snapContext =
68
+ overlayEl && iframe
69
+ ? collectSnapContext({
70
+ overlayEl,
71
+ iframe,
72
+ excludeElements: buildExcludeElements({
73
+ iframe,
74
+ groupSelections: items.map((i) => i.selection),
75
+ }),
76
+ })
77
+ : undefined;
78
+
64
79
  e.preventDefault();
65
80
  e.stopPropagation();
66
81
  e.currentTarget.setPointerCapture(e.pointerId);
@@ -70,10 +85,12 @@ export function startGroupDrag(
70
85
  startY: e.clientY,
71
86
  originItems: items,
72
87
  members,
88
+ snapContext,
73
89
  };
74
90
  return true;
75
91
  }
76
92
 
93
+ // fallow-ignore-next-line complexity
77
94
  export function startGesture(
78
95
  kind: GestureKind,
79
96
  e: React.PointerEvent<HTMLElement>,
@@ -124,6 +141,16 @@ export function startGesture(
124
141
  const overlayBounds = overlayEl?.getBoundingClientRect();
125
142
  const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
126
143
  const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
144
+
145
+ const iframe = opts.iframeRef.current;
146
+ const snapContext =
147
+ (kind === "drag" || kind === "resize") && overlayEl && iframe
148
+ ? collectSnapContext({
149
+ overlayEl,
150
+ iframe,
151
+ excludeElements: buildExcludeElements({ iframe, selection: sel }),
152
+ })
153
+ : undefined;
127
154
  e.preventDefault();
128
155
  e.stopPropagation();
129
156
  e.currentTarget.setPointerCapture(e.pointerId);
@@ -150,6 +177,7 @@ export function startGesture(
150
177
  editScaleX: rect.editScaleX,
151
178
  editScaleY: rect.editScaleY,
152
179
  manualEditDragToken,
180
+ snapContext,
153
181
  };
154
182
  return true;
155
183
  }
@@ -27,6 +27,16 @@ export const PROP_LABELS: Record<string, string> = {
27
27
  autoAlpha: "Visibility",
28
28
  visibility: "Visible",
29
29
  scaleX_alias: "Stretch X",
30
+ filter: "Filter",
31
+ clipPath: "Clip Path",
32
+ color: "Color",
33
+ backgroundColor: "Background",
34
+ borderColor: "Border Color",
35
+ borderRadius: "Radius",
36
+ fontSize: "Font Size",
37
+ letterSpacing: "Tracking",
38
+ skewX: "Skew X",
39
+ skewY: "Skew Y",
30
40
  };
31
41
 
32
42
  export const PROP_UNITS: Record<string, string> = {
@@ -83,6 +93,11 @@ export const EASE_LABELS: Record<string, string> = {
83
93
  "expo.out": "Very snappy stop",
84
94
  "expo.in": "Very slow start",
85
95
  "expo.inOut": "Dramatic ease",
96
+ "spring-gentle": "Gentle spring",
97
+ "spring-bouncy": "Bouncy spring",
98
+ "spring-stiff": "Stiff spring",
99
+ "spring-wobbly": "Wobbly spring",
100
+ "spring-heavy": "Heavy spring",
86
101
  };
87
102
 
88
103
  export const EASE_CURVES: Record<string, [number, number, number, number]> = {
@@ -123,6 +138,33 @@ export function parseCustomEaseFromString(ease: string): {
123
138
 
124
139
  export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
125
140
 
141
+ export const PROP_CONSTRAINTS: Record<string, { min?: number; max?: number; step?: number }> = {
142
+ opacity: { min: 0, max: 1, step: 0.01 },
143
+ autoAlpha: { min: 0, max: 1, step: 0.01 },
144
+ scale: { min: -10, max: 10, step: 0.01 },
145
+ scaleX: { min: -10, max: 10, step: 0.01 },
146
+ scaleY: { min: -10, max: 10, step: 0.01 },
147
+ rotation: { step: 1 },
148
+ skewX: { min: -90, max: 90, step: 1 },
149
+ skewY: { min: -90, max: 90, step: 1 },
150
+ width: { min: 0, step: 1 },
151
+ height: { min: 0, step: 1 },
152
+ borderRadius: { min: 0, step: 1 },
153
+ x: { step: 1 },
154
+ y: { step: 1 },
155
+ fontSize: { min: 1, step: 1 },
156
+ letterSpacing: { step: 0.1 },
157
+ };
158
+
159
+ export function clampPropertyValue(prop: string, value: number): number {
160
+ const constraint = PROP_CONSTRAINTS[prop];
161
+ if (!constraint) return value;
162
+ let clamped = value;
163
+ if (constraint.min !== undefined) clamped = Math.max(constraint.min, clamped);
164
+ if (constraint.max !== undefined) clamped = Math.min(constraint.max, clamped);
165
+ return clamped;
166
+ }
167
+
126
168
  export const ADD_METHODS = ["to", "from", "fromTo", "set"] as const;
127
169
 
128
170
  export const ADD_METHOD_LABELS: Record<string, string> = {
@@ -14,7 +14,8 @@ export function buildTweenSummary(animation: GsapAnimation): string {
14
14
  const props = Object.entries(animation.properties);
15
15
  const target = animation.targetSelector;
16
16
  const dur = animation.duration ?? 0;
17
- const pos = animation.position;
17
+ const rawPos = animation.position;
18
+ const pos = typeof rawPos === "number" ? parseFloat(rawPos.toFixed(3)) : rawPos;
18
19
  const propDescs = props.map(([p, v]) => {
19
20
  const label = (PROP_LABELS[p] ?? p).toLowerCase();
20
21
  return `${label} to ${formatPropValue(p, v)}`;
@@ -68,6 +68,12 @@ export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
68
68
  export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
69
69
  env,
70
70
  ["VITE_STUDIO_ENABLE_GSAP_PANEL", "VITE_STUDIO_GSAP_PANEL_ENABLED"],
71
+ true,
72
+ );
73
+
74
+ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
75
+ env,
76
+ ["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"],
71
77
  false,
72
78
  );
73
79
 
@@ -223,6 +223,7 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
223
223
  }
224
224
 
225
225
  function stripGsapTranslateFromTransform(element: HTMLElement): void {
226
+ if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
226
227
  const transform = element.style.getPropertyValue("transform");
227
228
  if (!transform || transform === "none") return;
228
229
  const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null)
@@ -233,8 +234,11 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
233
234
  if (m.m41 === 0 && m.m42 === 0) return;
234
235
  const offsetX = readPxCustomProperty(element, STUDIO_OFFSET_X_PROP);
235
236
  const offsetY = readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP);
236
- m.m41 -= offsetX;
237
- m.m42 -= offsetY;
237
+ const angle = Math.atan2(m.b, m.a);
238
+ const cos = Math.cos(angle);
239
+ const sin = Math.sin(angle);
240
+ m.m41 -= offsetX * cos - offsetY * sin;
241
+ m.m42 -= offsetX * sin + offsetY * cos;
238
242
  if (Math.abs(m.m41) < 0.01 && Math.abs(m.m42) < 0.01 && isIdentityAfterTranslateStrip(m)) {
239
243
  element.style.removeProperty("transform");
240
244
  } else {
@@ -512,8 +516,58 @@ function reapplyPathOffsets(doc: Document): void {
512
516
  }
513
517
  }
514
518
 
519
+ function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
520
+ const win = el.ownerDocument.defaultView as
521
+ | (Window & {
522
+ __timelines?: Record<
523
+ string,
524
+ {
525
+ getChildren?: (
526
+ deep: boolean,
527
+ ) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
528
+ }
529
+ >;
530
+ })
531
+ | null;
532
+ if (!win?.__timelines) return false;
533
+ const propSet = new Set(props);
534
+ for (const tl of Object.values(win.__timelines)) {
535
+ if (!tl?.getChildren) continue;
536
+ try {
537
+ for (const child of tl.getChildren(true)) {
538
+ if (!child.targets || !child.vars) continue;
539
+ let targetsEl = false;
540
+ for (const t of child.targets()) {
541
+ if (t === el || (el.id && t.id === el.id)) {
542
+ targetsEl = true;
543
+ break;
544
+ }
545
+ }
546
+ if (!targetsEl) continue;
547
+ const vars = child.vars;
548
+ for (const p of propSet) {
549
+ if (p in vars) return true;
550
+ }
551
+ if (vars.keyframes && typeof vars.keyframes === "object") {
552
+ for (const kfVal of Object.values(vars.keyframes as Record<string, unknown>)) {
553
+ if (kfVal && typeof kfVal === "object") {
554
+ for (const p of propSet) {
555
+ if (p in (kfVal as Record<string, unknown>)) return true;
556
+ }
557
+ }
558
+ }
559
+ }
560
+ }
561
+ } catch {
562
+ /* */
563
+ }
564
+ }
565
+ return false;
566
+ }
567
+
515
568
  function reapplyBoxSizes(doc: Document): void {
516
569
  for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
570
+ if (gsapAnimatesProperty(el, "width", "height")) continue;
517
571
  const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
518
572
  const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
519
573
  if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {