@hyperframes/studio 0.6.73 → 0.6.74
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-BcJO6Ej5.js +140 -0
- package/dist/assets/index-C2gBZ2km.css +1 -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/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/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +24 -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 +39 -4
- package/src/hooks/useDomEditSession.ts +177 -63
- 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,166 @@
|
|
|
1
|
+
// fallow-ignore-file unused-file
|
|
2
|
+
import { memo, useRef, type RefObject } from "react";
|
|
3
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
+
import type { SnapGuide, SpacingGuide } from "./snapEngine";
|
|
5
|
+
|
|
6
|
+
export interface SnapGuidesState {
|
|
7
|
+
guides: SnapGuide[];
|
|
8
|
+
spacingGuides: SpacingGuide[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX_GUIDES = 6;
|
|
12
|
+
const MAX_SPACING_GUIDES = 4;
|
|
13
|
+
|
|
14
|
+
const GUIDE_COLOR = "rgba(255, 68, 204, 0.85)";
|
|
15
|
+
const SPACING_COLOR = "rgba(255, 68, 204, 0.6)";
|
|
16
|
+
const SPACING_BG = "rgba(255, 68, 204, 0.15)";
|
|
17
|
+
|
|
18
|
+
interface SnapGuideOverlayProps {
|
|
19
|
+
snapGuidesRef: RefObject<SnapGuidesState | null>;
|
|
20
|
+
overlayWidth: number;
|
|
21
|
+
overlayHeight: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const SnapGuideOverlay = memo(function SnapGuideOverlay({
|
|
25
|
+
snapGuidesRef,
|
|
26
|
+
overlayWidth,
|
|
27
|
+
overlayHeight,
|
|
28
|
+
}: SnapGuideOverlayProps) {
|
|
29
|
+
const guideElsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
30
|
+
const spacingElsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
31
|
+
const spacingLabelElsRef = useRef<(HTMLSpanElement | null)[]>([]);
|
|
32
|
+
const overlayWidthRef = useRef(overlayWidth);
|
|
33
|
+
overlayWidthRef.current = overlayWidth;
|
|
34
|
+
const overlayHeightRef = useRef(overlayHeight);
|
|
35
|
+
overlayHeightRef.current = overlayHeight;
|
|
36
|
+
|
|
37
|
+
useMountEffect(() => {
|
|
38
|
+
let frame = 0;
|
|
39
|
+
|
|
40
|
+
// fallow-ignore-next-line complexity
|
|
41
|
+
const update = () => {
|
|
42
|
+
frame = requestAnimationFrame(update);
|
|
43
|
+
|
|
44
|
+
const state = snapGuidesRef.current;
|
|
45
|
+
const guides = state?.guides ?? [];
|
|
46
|
+
const spacingGuides = state?.spacingGuides ?? [];
|
|
47
|
+
const w = overlayWidthRef.current;
|
|
48
|
+
const h = overlayHeightRef.current;
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < MAX_GUIDES; i++) {
|
|
51
|
+
const el = guideElsRef.current[i];
|
|
52
|
+
if (!el) continue;
|
|
53
|
+
|
|
54
|
+
const guide = guides[i];
|
|
55
|
+
if (!guide) {
|
|
56
|
+
el.style.display = "none";
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
el.style.display = "";
|
|
61
|
+
if (guide.axis === "x") {
|
|
62
|
+
el.style.left = `${guide.position}px`;
|
|
63
|
+
el.style.top = "0";
|
|
64
|
+
el.style.width = "1px";
|
|
65
|
+
el.style.height = `${h}px`;
|
|
66
|
+
} else {
|
|
67
|
+
el.style.left = "0";
|
|
68
|
+
el.style.top = `${guide.position}px`;
|
|
69
|
+
el.style.width = `${w}px`;
|
|
70
|
+
el.style.height = "1px";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < MAX_SPACING_GUIDES; i++) {
|
|
75
|
+
const el = spacingElsRef.current[i];
|
|
76
|
+
const label = spacingLabelElsRef.current[i];
|
|
77
|
+
if (!el) continue;
|
|
78
|
+
|
|
79
|
+
const sg = spacingGuides[i];
|
|
80
|
+
if (!sg) {
|
|
81
|
+
el.style.display = "none";
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
el.style.display = "flex";
|
|
86
|
+
el.style.alignItems = "center";
|
|
87
|
+
el.style.justifyContent = "center";
|
|
88
|
+
if (sg.axis === "x") {
|
|
89
|
+
el.style.left = `${sg.position}px`;
|
|
90
|
+
el.style.top = `${sg.from}px`;
|
|
91
|
+
el.style.width = `${sg.size}px`;
|
|
92
|
+
el.style.height = `${sg.to - sg.from}px`;
|
|
93
|
+
el.style.borderLeft = `1px dashed ${SPACING_COLOR}`;
|
|
94
|
+
el.style.borderRight = `1px dashed ${SPACING_COLOR}`;
|
|
95
|
+
el.style.borderTop = "none";
|
|
96
|
+
el.style.borderBottom = "none";
|
|
97
|
+
} else {
|
|
98
|
+
el.style.left = `${sg.from}px`;
|
|
99
|
+
el.style.top = `${sg.position}px`;
|
|
100
|
+
el.style.width = `${sg.to - sg.from}px`;
|
|
101
|
+
el.style.height = `${sg.size}px`;
|
|
102
|
+
el.style.borderTop = `1px dashed ${SPACING_COLOR}`;
|
|
103
|
+
el.style.borderBottom = `1px dashed ${SPACING_COLOR}`;
|
|
104
|
+
el.style.borderLeft = "none";
|
|
105
|
+
el.style.borderRight = "none";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (label) {
|
|
109
|
+
label.textContent = `${Math.round(sg.size)}`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
frame = requestAnimationFrame(update);
|
|
115
|
+
return () => cancelAnimationFrame(frame);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
|
120
|
+
{Array.from({ length: MAX_GUIDES }, (_, i) => (
|
|
121
|
+
<div
|
|
122
|
+
key={`guide-${i}`}
|
|
123
|
+
ref={(el) => {
|
|
124
|
+
guideElsRef.current[i] = el;
|
|
125
|
+
}}
|
|
126
|
+
style={{
|
|
127
|
+
display: "none",
|
|
128
|
+
position: "absolute",
|
|
129
|
+
backgroundColor: GUIDE_COLOR,
|
|
130
|
+
zIndex: 50,
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
))}
|
|
134
|
+
|
|
135
|
+
{Array.from({ length: MAX_SPACING_GUIDES }, (_, i) => (
|
|
136
|
+
<div
|
|
137
|
+
key={`spacing-${i}`}
|
|
138
|
+
ref={(el) => {
|
|
139
|
+
spacingElsRef.current[i] = el;
|
|
140
|
+
}}
|
|
141
|
+
style={{
|
|
142
|
+
display: "none",
|
|
143
|
+
position: "absolute",
|
|
144
|
+
zIndex: 50,
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<span
|
|
148
|
+
ref={(el) => {
|
|
149
|
+
spacingLabelElsRef.current[i] = el;
|
|
150
|
+
}}
|
|
151
|
+
style={{
|
|
152
|
+
fontSize: "10px",
|
|
153
|
+
fontFamily: "monospace",
|
|
154
|
+
color: GUIDE_COLOR,
|
|
155
|
+
backgroundColor: SPACING_BG,
|
|
156
|
+
padding: "0 3px",
|
|
157
|
+
borderRadius: "2px",
|
|
158
|
+
lineHeight: "14px",
|
|
159
|
+
whiteSpace: "nowrap",
|
|
160
|
+
}}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
});
|
|
@@ -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
|
}
|