@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.
- package/dist/assets/index-DcyZuBcU.css +1 -0
- package/dist/assets/index-uB_W2GDl.js +140 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +30 -24
- package/src/components/StudioPreviewArea.tsx +101 -26
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/StudioToast.tsx +18 -0
- package/src/components/TimelineToolbar.tsx +230 -4
- package/src/components/editor/AnimationCard.tsx +68 -4
- package/src/components/editor/DomEditOverlay.tsx +70 -1
- package/src/components/editor/GridOverlay.tsx +50 -0
- package/src/components/editor/KeyframeDiamond.tsx +49 -0
- package/src/components/editor/KeyframeNavigation.tsx +139 -0
- package/src/components/editor/LayersPanel.test.ts +135 -0
- package/src/components/editor/LayersPanel.tsx +151 -15
- package/src/components/editor/PropertyPanel.tsx +293 -140
- package/src/components/editor/SnapGuideOverlay.tsx +166 -0
- package/src/components/editor/SnapToolbar.tsx +163 -0
- package/src/components/editor/SpringEaseEditor.tsx +256 -0
- package/src/components/editor/domEditOverlayGestures.ts +7 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
- package/src/components/editor/gsapAnimationConstants.ts +42 -0
- package/src/components/editor/gsapAnimationHelpers.ts +2 -1
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/manualEditsDom.ts +56 -2
- package/src/components/editor/manualOffsetDrag.ts +19 -3
- package/src/components/editor/propertyPanelHelpers.ts +90 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
- package/src/components/editor/snapEngine.test.ts +657 -0
- package/src/components/editor/snapEngine.ts +575 -0
- package/src/components/editor/snapTargetCollection.ts +147 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
- package/src/components/editor/useLayerDrag.ts +213 -0
- package/src/components/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +27 -0
- package/src/hooks/gsapRuntimeBridge.ts +585 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
- package/src/hooks/useAppHotkeys.ts +63 -1
- package/src/hooks/useDomEditCommits.ts +88 -4
- package/src/hooks/useDomEditSession.ts +179 -65
- package/src/hooks/useGsapScriptCommits.ts +144 -7
- package/src/hooks/useGsapSelectionHandlers.ts +202 -0
- package/src/hooks/useGsapTweenCache.ts +174 -3
- package/src/hooks/useTimelineEditing.ts +93 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/ClipContextMenu.tsx +99 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
- package/src/player/components/Timeline.test.ts +2 -1
- package/src/player/components/Timeline.tsx +108 -68
- package/src/player/components/TimelineCanvas.tsx +47 -1
- package/src/player/components/TimelineClip.tsx +8 -3
- package/src/player/components/TimelineClipDiamonds.tsx +174 -0
- package/src/player/components/timelineDragDrop.ts +103 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/store/playerStore.ts +42 -0
- package/src/utils/editHistory.ts +1 -1
- package/src/utils/optimisticUpdate.test.ts +53 -0
- package/src/utils/optimisticUpdate.ts +18 -0
- package/src/utils/studioUiPreferences.ts +17 -0
- package/dist/assets/index-CrxThtSJ.css +0 -1
- 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
|
|
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.
|
|
237
|
-
|
|
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) {
|