@hyperframes/studio 0.6.72 → 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.
Files changed (60) hide show
  1. package/dist/assets/index-BcJO6Ej5.js +140 -0
  2. package/dist/assets/index-C2gBZ2km.css +1 -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/PropertyPanel.tsx +293 -140
  16. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  17. package/src/components/editor/SnapToolbar.tsx +163 -0
  18. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  19. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  20. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  22. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  23. package/src/components/editor/manualEditingAvailability.ts +6 -0
  24. package/src/components/editor/manualEditsDom.ts +56 -2
  25. package/src/components/editor/manualOffsetDrag.ts +19 -3
  26. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  27. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  28. package/src/components/editor/snapEngine.test.ts +657 -0
  29. package/src/components/editor/snapEngine.ts +575 -0
  30. package/src/components/editor/snapTargetCollection.ts +147 -0
  31. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  32. package/src/components/nle/NLELayout.tsx +18 -0
  33. package/src/contexts/DomEditContext.tsx +24 -0
  34. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  35. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  36. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  37. package/src/hooks/useAppHotkeys.ts +63 -1
  38. package/src/hooks/useDomEditCommits.ts +39 -4
  39. package/src/hooks/useDomEditSession.ts +177 -63
  40. package/src/hooks/useGsapScriptCommits.ts +144 -7
  41. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  42. package/src/hooks/useGsapTweenCache.ts +174 -3
  43. package/src/hooks/useTimelineEditing.ts +93 -0
  44. package/src/icons/SystemIcons.tsx +2 -0
  45. package/src/player/components/ClipContextMenu.tsx +99 -0
  46. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  47. package/src/player/components/Timeline.test.ts +2 -1
  48. package/src/player/components/Timeline.tsx +108 -68
  49. package/src/player/components/TimelineCanvas.tsx +47 -1
  50. package/src/player/components/TimelineClip.tsx +8 -3
  51. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  52. package/src/player/components/timelineDragDrop.ts +103 -0
  53. package/src/player/components/timelineLayout.ts +1 -1
  54. package/src/player/store/playerStore.ts +42 -0
  55. package/src/utils/editHistory.ts +1 -1
  56. package/src/utils/optimisticUpdate.test.ts +53 -0
  57. package/src/utils/optimisticUpdate.ts +18 -0
  58. package/src/utils/studioUiPreferences.ts +17 -0
  59. package/dist/assets/index-CrxThtSJ.css +0 -1
  60. package/dist/assets/index-CveQve6o.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
  }