@hyperframes/studio 0.6.0-alpha.9 → 0.6.1
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/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +421 -4303
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +167 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +88 -993
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1117
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +84 -1049
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +68 -155
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +196 -1518
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +105 -1371
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -1,18 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
useEffect,
|
|
4
|
-
useMemo,
|
|
5
|
-
useRef,
|
|
6
|
-
useState,
|
|
7
|
-
type PointerEvent,
|
|
8
|
-
type ReactNode,
|
|
9
|
-
} from "react";
|
|
10
|
-
import { RotateCcw, X, Zap } from "../../icons/SystemIcons";
|
|
1
|
+
import { memo, useMemo } from "react";
|
|
2
|
+
import { X, Zap } from "../../icons/SystemIcons";
|
|
11
3
|
import type { DomEditSelection } from "./domEditing";
|
|
12
4
|
import {
|
|
13
5
|
STUDIO_GSAP_EASE_OPTIONS,
|
|
14
6
|
buildStudioGsapPresetMotion,
|
|
15
|
-
clampStudioCustomEasePoints,
|
|
16
7
|
controlPointsForGsapEase,
|
|
17
8
|
parseStudioCustomEaseData,
|
|
18
9
|
serializeStudioCustomEaseData,
|
|
@@ -21,6 +12,17 @@ import {
|
|
|
21
12
|
type StudioGsapMotionDirection,
|
|
22
13
|
type StudioGsapMotionPreset,
|
|
23
14
|
} from "./studioMotion";
|
|
15
|
+
import {
|
|
16
|
+
formatNumericValue,
|
|
17
|
+
clampMotionNumber,
|
|
18
|
+
parsePlainNumber,
|
|
19
|
+
DetailField,
|
|
20
|
+
SegmentedControl,
|
|
21
|
+
SelectField,
|
|
22
|
+
MotionSection,
|
|
23
|
+
RESPONSIVE_GRID,
|
|
24
|
+
} from "./MotionPanelFields";
|
|
25
|
+
import { EaseCurveEditor } from "./EaseCurveEditor";
|
|
24
26
|
|
|
25
27
|
interface MotionPanelProps {
|
|
26
28
|
element: DomEditSelection | null;
|
|
@@ -33,11 +35,6 @@ interface MotionPanelProps {
|
|
|
33
35
|
onClearMotion: (element: DomEditSelection) => void;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
const FIELD =
|
|
37
|
-
"min-w-0 rounded-xl border border-neutral-800 bg-neutral-900/95 px-3 py-2 text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] transition-colors focus-within:border-neutral-600";
|
|
38
|
-
const LABEL = "text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500";
|
|
39
|
-
const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
|
|
40
|
-
|
|
41
38
|
const MOTION_PRESET_OPTIONS: Array<{ label: string; value: StudioGsapMotionPreset }> = [
|
|
42
39
|
{ label: "Fade Up", value: "fade-up" },
|
|
43
40
|
{ label: "Slide", value: "slide" },
|
|
@@ -46,28 +43,6 @@ const MOTION_PRESET_OPTIONS: Array<{ label: string; value: StudioGsapMotionPrese
|
|
|
46
43
|
|
|
47
44
|
const MOTION_DIRECTION_OPTIONS: StudioGsapMotionDirection[] = ["up", "down", "left", "right"];
|
|
48
45
|
|
|
49
|
-
function formatNumericValue(value: number): string {
|
|
50
|
-
const rounded = Math.round(value * 100) / 100;
|
|
51
|
-
return Number.isInteger(rounded)
|
|
52
|
-
? `${rounded}`
|
|
53
|
-
: rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function clampMotionNumber(
|
|
57
|
-
value: number | null,
|
|
58
|
-
min: number,
|
|
59
|
-
max: number,
|
|
60
|
-
fallback: number,
|
|
61
|
-
): number {
|
|
62
|
-
if (value == null || !Number.isFinite(value)) return fallback;
|
|
63
|
-
return Math.min(max, Math.max(min, value));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function parsePlainNumber(value: string): number | null {
|
|
67
|
-
const parsed = Number.parseFloat(value.trim());
|
|
68
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
46
|
function motionValueDistance(motion: StudioGsapMotion | null): number {
|
|
72
47
|
if (!motion) return 32;
|
|
73
48
|
return Math.max(Math.abs(motion.from.x ?? 0), Math.abs(motion.from.y ?? 0), 1);
|
|
@@ -97,357 +72,6 @@ function buildStudioCustomEaseId(element: DomEditSelection): string {
|
|
|
97
72
|
return `studio-${normalized || "layer"}-ease`;
|
|
98
73
|
}
|
|
99
74
|
|
|
100
|
-
function CommitField({
|
|
101
|
-
value,
|
|
102
|
-
disabled,
|
|
103
|
-
onCommit,
|
|
104
|
-
}: {
|
|
105
|
-
value: string;
|
|
106
|
-
disabled?: boolean;
|
|
107
|
-
onCommit: (nextValue: string) => void;
|
|
108
|
-
}) {
|
|
109
|
-
const [draft, setDraft] = useState(value);
|
|
110
|
-
const focusedRef = useRef(false);
|
|
111
|
-
|
|
112
|
-
useEffect(() => {
|
|
113
|
-
if (!focusedRef.current) setDraft(value);
|
|
114
|
-
}, [value]);
|
|
115
|
-
|
|
116
|
-
const commitDraft = () => {
|
|
117
|
-
focusedRef.current = false;
|
|
118
|
-
const next = draft.trim();
|
|
119
|
-
if (next !== value) onCommit(next);
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
return (
|
|
123
|
-
<input
|
|
124
|
-
type="text"
|
|
125
|
-
value={draft}
|
|
126
|
-
disabled={disabled}
|
|
127
|
-
onFocus={() => {
|
|
128
|
-
focusedRef.current = true;
|
|
129
|
-
}}
|
|
130
|
-
onChange={(event) => setDraft(event.target.value)}
|
|
131
|
-
onBlur={commitDraft}
|
|
132
|
-
onKeyDown={(event) => {
|
|
133
|
-
if (event.key === "Enter") (event.target as HTMLInputElement).blur();
|
|
134
|
-
}}
|
|
135
|
-
className="w-full min-w-0 bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
136
|
-
/>
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function DetailField({
|
|
141
|
-
label,
|
|
142
|
-
value,
|
|
143
|
-
disabled,
|
|
144
|
-
onCommit,
|
|
145
|
-
}: {
|
|
146
|
-
label: string;
|
|
147
|
-
value: string;
|
|
148
|
-
disabled?: boolean;
|
|
149
|
-
onCommit: (nextValue: string) => void;
|
|
150
|
-
}) {
|
|
151
|
-
return (
|
|
152
|
-
<label className="grid min-w-0 gap-1.5">
|
|
153
|
-
<span className={LABEL}>{label}</span>
|
|
154
|
-
<div className={FIELD}>
|
|
155
|
-
<CommitField value={value} disabled={disabled} onCommit={onCommit} />
|
|
156
|
-
</div>
|
|
157
|
-
</label>
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function SegmentedControl({
|
|
162
|
-
value,
|
|
163
|
-
options,
|
|
164
|
-
onChange,
|
|
165
|
-
}: {
|
|
166
|
-
value: string;
|
|
167
|
-
options: Array<{ label: string; value: string }>;
|
|
168
|
-
onChange: (value: string) => void;
|
|
169
|
-
}) {
|
|
170
|
-
return (
|
|
171
|
-
<div className="grid grid-cols-3 gap-1 rounded-2xl border border-neutral-800 bg-neutral-950 p-1">
|
|
172
|
-
{options.map((option) => (
|
|
173
|
-
<button
|
|
174
|
-
key={option.value}
|
|
175
|
-
type="button"
|
|
176
|
-
onClick={() => onChange(option.value)}
|
|
177
|
-
className={`h-9 rounded-xl text-[11px] font-semibold transition-colors ${
|
|
178
|
-
option.value === value
|
|
179
|
-
? "bg-neutral-800 text-white shadow-sm"
|
|
180
|
-
: "text-neutral-500 hover:bg-neutral-900 hover:text-neutral-200"
|
|
181
|
-
}`}
|
|
182
|
-
>
|
|
183
|
-
{option.label}
|
|
184
|
-
</button>
|
|
185
|
-
))}
|
|
186
|
-
</div>
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function SelectField({
|
|
191
|
-
label,
|
|
192
|
-
value,
|
|
193
|
-
options,
|
|
194
|
-
onChange,
|
|
195
|
-
}: {
|
|
196
|
-
label: string;
|
|
197
|
-
value: string;
|
|
198
|
-
options: readonly string[];
|
|
199
|
-
onChange: (value: string) => void;
|
|
200
|
-
}) {
|
|
201
|
-
return (
|
|
202
|
-
<label className="grid min-w-0 gap-1.5">
|
|
203
|
-
<span className={LABEL}>{label}</span>
|
|
204
|
-
<div className={FIELD}>
|
|
205
|
-
<select
|
|
206
|
-
value={value}
|
|
207
|
-
onChange={(event) => onChange(event.target.value)}
|
|
208
|
-
className="w-full min-w-0 appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none"
|
|
209
|
-
>
|
|
210
|
-
{options.map((option) => (
|
|
211
|
-
<option key={option} value={option}>
|
|
212
|
-
{option}
|
|
213
|
-
</option>
|
|
214
|
-
))}
|
|
215
|
-
</select>
|
|
216
|
-
</div>
|
|
217
|
-
</label>
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function MotionSection({
|
|
222
|
-
title,
|
|
223
|
-
children,
|
|
224
|
-
accessory,
|
|
225
|
-
}: {
|
|
226
|
-
title: string;
|
|
227
|
-
children: ReactNode;
|
|
228
|
-
accessory?: ReactNode;
|
|
229
|
-
}) {
|
|
230
|
-
return (
|
|
231
|
-
<section className="border-b border-neutral-800 px-4 py-5">
|
|
232
|
-
<div className="mb-4 flex items-center justify-between gap-3">
|
|
233
|
-
<div className="flex items-center gap-3">
|
|
234
|
-
<Zap size={15} className="text-neutral-500" />
|
|
235
|
-
<h3 className="text-[11px] font-semibold uppercase tracking-[0.22em] text-neutral-300">
|
|
236
|
-
{title}
|
|
237
|
-
</h3>
|
|
238
|
-
</div>
|
|
239
|
-
{accessory}
|
|
240
|
-
</div>
|
|
241
|
-
{children}
|
|
242
|
-
</section>
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function cubicBezierPoint(t: number, p1: StudioCustomEaseControlPoints): { x: number; y: number } {
|
|
247
|
-
const inv = 1 - t;
|
|
248
|
-
const inv2 = inv * inv;
|
|
249
|
-
const t2 = t * t;
|
|
250
|
-
return {
|
|
251
|
-
x: 3 * inv2 * t * p1.x1 + 3 * inv * t2 * p1.x2 + t2 * t,
|
|
252
|
-
y: 3 * inv2 * t * p1.y1 + 3 * inv * t2 * p1.y2 + t2 * t,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function buildCurvePath(
|
|
257
|
-
points: StudioCustomEaseControlPoints,
|
|
258
|
-
map: (point: { x: number; y: number }) => { x: number; y: number },
|
|
259
|
-
): string {
|
|
260
|
-
const commands: string[] = [];
|
|
261
|
-
for (let index = 0; index <= 48; index += 1) {
|
|
262
|
-
const point = map(cubicBezierPoint(index / 48, points));
|
|
263
|
-
commands.push(`${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`);
|
|
264
|
-
}
|
|
265
|
-
return commands.join(" ");
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function EaseCurveEditor({
|
|
269
|
-
points,
|
|
270
|
-
onCommit,
|
|
271
|
-
}: {
|
|
272
|
-
points: StudioCustomEaseControlPoints;
|
|
273
|
-
onCommit: (points: StudioCustomEaseControlPoints) => void;
|
|
274
|
-
}) {
|
|
275
|
-
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
276
|
-
const [draft, setDraft] = useState(points);
|
|
277
|
-
const draggingRef = useRef<"p1" | "p2" | null>(null);
|
|
278
|
-
|
|
279
|
-
useEffect(() => {
|
|
280
|
-
setDraft(points);
|
|
281
|
-
}, [points]);
|
|
282
|
-
|
|
283
|
-
const width = 324;
|
|
284
|
-
const height = 214;
|
|
285
|
-
const plot = { left: 46, top: 24, width: 242, height: 146 };
|
|
286
|
-
const yMin = -0.4;
|
|
287
|
-
const yMax = 1.4;
|
|
288
|
-
|
|
289
|
-
const mapPoint = (point: { x: number; y: number }) => ({
|
|
290
|
-
x: plot.left + point.x * plot.width,
|
|
291
|
-
y: plot.top + ((yMax - point.y) / (yMax - yMin)) * plot.height,
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
const unmapPointer = (event: PointerEvent<SVGSVGElement>) => {
|
|
295
|
-
const rect = svgRef.current?.getBoundingClientRect();
|
|
296
|
-
if (!rect) return null;
|
|
297
|
-
const x = ((event.clientX - rect.left) / rect.width) * width;
|
|
298
|
-
const y = ((event.clientY - rect.top) / rect.height) * height;
|
|
299
|
-
return clampStudioCustomEasePoints({
|
|
300
|
-
x1: draggingRef.current === "p1" ? (x - plot.left) / plot.width : draft.x1,
|
|
301
|
-
y1:
|
|
302
|
-
draggingRef.current === "p1"
|
|
303
|
-
? yMax - ((y - plot.top) / plot.height) * (yMax - yMin)
|
|
304
|
-
: draft.y1,
|
|
305
|
-
x2: draggingRef.current === "p2" ? (x - plot.left) / plot.width : draft.x2,
|
|
306
|
-
y2:
|
|
307
|
-
draggingRef.current === "p2"
|
|
308
|
-
? yMax - ((y - plot.top) / plot.height) * (yMax - yMin)
|
|
309
|
-
: draft.y2,
|
|
310
|
-
});
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
const start = mapPoint({ x: 0, y: 0 });
|
|
314
|
-
const end = mapPoint({ x: 1, y: 1 });
|
|
315
|
-
const p1 = mapPoint({ x: draft.x1, y: draft.y1 });
|
|
316
|
-
const p2 = mapPoint({ x: draft.x2, y: draft.y2 });
|
|
317
|
-
const curvePath = buildCurvePath(draft, mapPoint);
|
|
318
|
-
|
|
319
|
-
const handlePointerMove = (event: PointerEvent<SVGSVGElement>) => {
|
|
320
|
-
if (!draggingRef.current) return;
|
|
321
|
-
event.preventDefault();
|
|
322
|
-
const next = unmapPointer(event);
|
|
323
|
-
if (next) setDraft(next);
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
const endDrag = () => {
|
|
327
|
-
if (!draggingRef.current) return;
|
|
328
|
-
draggingRef.current = null;
|
|
329
|
-
onCommit(draft);
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
const startDrag = (handle: "p1" | "p2", event: PointerEvent<SVGCircleElement>) => {
|
|
333
|
-
event.preventDefault();
|
|
334
|
-
event.stopPropagation();
|
|
335
|
-
draggingRef.current = handle;
|
|
336
|
-
event.currentTarget.setPointerCapture(event.pointerId);
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
return (
|
|
340
|
-
<div className="overflow-hidden rounded-2xl border border-neutral-800 bg-black/40">
|
|
341
|
-
<div className="flex items-center justify-between gap-3 border-b border-neutral-800 px-3 py-2">
|
|
342
|
-
<div>
|
|
343
|
-
<div className={LABEL}>CustomEase</div>
|
|
344
|
-
<div className="mt-1 font-mono text-[10px] text-neutral-500">
|
|
345
|
-
{serializeStudioCustomEaseData(draft)}
|
|
346
|
-
</div>
|
|
347
|
-
</div>
|
|
348
|
-
<button
|
|
349
|
-
type="button"
|
|
350
|
-
onClick={() => {
|
|
351
|
-
const reset = controlPointsForGsapEase("power3.out");
|
|
352
|
-
setDraft(reset);
|
|
353
|
-
onCommit(reset);
|
|
354
|
-
}}
|
|
355
|
-
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"
|
|
356
|
-
>
|
|
357
|
-
<RotateCcw size={13} />
|
|
358
|
-
Reset
|
|
359
|
-
</button>
|
|
360
|
-
</div>
|
|
361
|
-
<svg
|
|
362
|
-
ref={svgRef}
|
|
363
|
-
viewBox={`0 0 ${width} ${height}`}
|
|
364
|
-
className="block w-full select-none touch-none"
|
|
365
|
-
onPointerMove={handlePointerMove}
|
|
366
|
-
onPointerUp={endDrag}
|
|
367
|
-
onPointerCancel={endDrag}
|
|
368
|
-
>
|
|
369
|
-
<rect x="0" y="0" width={width} height={height} fill="transparent" />
|
|
370
|
-
{[0, 0.5, 1].map((value) => {
|
|
371
|
-
const mapped = mapPoint({ x: 0, y: value });
|
|
372
|
-
return (
|
|
373
|
-
<g key={value}>
|
|
374
|
-
<line
|
|
375
|
-
x1={plot.left}
|
|
376
|
-
x2={plot.left + plot.width}
|
|
377
|
-
y1={mapped.y}
|
|
378
|
-
y2={mapped.y}
|
|
379
|
-
stroke="rgba(255,255,255,0.12)"
|
|
380
|
-
strokeDasharray="5 8"
|
|
381
|
-
/>
|
|
382
|
-
<text
|
|
383
|
-
x={plot.left - 12}
|
|
384
|
-
y={mapped.y + 4}
|
|
385
|
-
textAnchor="end"
|
|
386
|
-
className="fill-neutral-500 text-[10px] font-semibold"
|
|
387
|
-
>
|
|
388
|
-
{value}
|
|
389
|
-
</text>
|
|
390
|
-
</g>
|
|
391
|
-
);
|
|
392
|
-
})}
|
|
393
|
-
<line
|
|
394
|
-
x1={plot.left}
|
|
395
|
-
x2={plot.left + plot.width}
|
|
396
|
-
y1={plot.top + plot.height}
|
|
397
|
-
y2={plot.top + plot.height}
|
|
398
|
-
stroke="rgba(255,255,255,0.18)"
|
|
399
|
-
/>
|
|
400
|
-
<line
|
|
401
|
-
x1={plot.left}
|
|
402
|
-
x2={plot.left}
|
|
403
|
-
y1={plot.top}
|
|
404
|
-
y2={plot.top + plot.height}
|
|
405
|
-
stroke="rgba(255,255,255,0.18)"
|
|
406
|
-
/>
|
|
407
|
-
<line x1={start.x} y1={start.y} x2={p1.x} y2={p1.y} stroke="rgba(255,221,87,0.34)" />
|
|
408
|
-
<line x1={end.x} y1={end.y} x2={p2.x} y2={p2.y} stroke="rgba(255,221,87,0.34)" />
|
|
409
|
-
<path d={curvePath} fill="none" stroke="#ffdd57" strokeWidth="4" strokeLinecap="round" />
|
|
410
|
-
<circle cx={start.x} cy={start.y} r="5" fill="#ffdd57" />
|
|
411
|
-
<circle cx={end.x} cy={end.y} r="5" fill="#ffdd57" />
|
|
412
|
-
<circle
|
|
413
|
-
cx={p1.x}
|
|
414
|
-
cy={p1.y}
|
|
415
|
-
r="9"
|
|
416
|
-
fill="#141414"
|
|
417
|
-
stroke="#ffdd57"
|
|
418
|
-
strokeWidth="4"
|
|
419
|
-
className="cursor-grab active:cursor-grabbing"
|
|
420
|
-
onPointerDown={(event) => startDrag("p1", event)}
|
|
421
|
-
/>
|
|
422
|
-
<circle
|
|
423
|
-
cx={p2.x}
|
|
424
|
-
cy={p2.y}
|
|
425
|
-
r="9"
|
|
426
|
-
fill="#141414"
|
|
427
|
-
stroke="#ffdd57"
|
|
428
|
-
strokeWidth="4"
|
|
429
|
-
className="cursor-grab active:cursor-grabbing"
|
|
430
|
-
onPointerDown={(event) => startDrag("p2", event)}
|
|
431
|
-
/>
|
|
432
|
-
<text x={p1.x + 12} y={p1.y - 10} className="fill-neutral-400 text-[10px] font-semibold">
|
|
433
|
-
P1
|
|
434
|
-
</text>
|
|
435
|
-
<text x={p2.x + 12} y={p2.y - 10} className="fill-neutral-400 text-[10px] font-semibold">
|
|
436
|
-
P2
|
|
437
|
-
</text>
|
|
438
|
-
</svg>
|
|
439
|
-
<div className="grid grid-cols-2 gap-2 border-t border-neutral-800 p-3">
|
|
440
|
-
<div className="rounded-xl border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-[10px] text-neutral-400">
|
|
441
|
-
P1 {formatNumericValue(draft.x1)}, {formatNumericValue(draft.y1)}
|
|
442
|
-
</div>
|
|
443
|
-
<div className="rounded-xl border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-[10px] text-neutral-400">
|
|
444
|
-
P2 {formatNumericValue(draft.x2)}, {formatNumericValue(draft.y2)}
|
|
445
|
-
</div>
|
|
446
|
-
</div>
|
|
447
|
-
</div>
|
|
448
|
-
);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
75
|
export const MotionPanel = memo(function MotionPanel({
|
|
452
76
|
element,
|
|
453
77
|
motion,
|
|
@@ -546,7 +170,9 @@ export const MotionPanel = memo(function MotionPanel({
|
|
|
546
170
|
<div className="border-b border-neutral-800 px-4 py-5">
|
|
547
171
|
<div className="flex items-start justify-between gap-4">
|
|
548
172
|
<div className="min-w-0">
|
|
549
|
-
<div className=
|
|
173
|
+
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500">
|
|
174
|
+
Motion Target
|
|
175
|
+
</div>
|
|
550
176
|
<div className="mt-3 truncate text-[12px] font-semibold text-neutral-100">
|
|
551
177
|
{element.label}
|
|
552
178
|
</div>
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, type ReactNode } from "react";
|
|
2
|
+
import { Zap } from "../../icons/SystemIcons";
|
|
3
|
+
|
|
4
|
+
export const FIELD =
|
|
5
|
+
"min-w-0 rounded-xl border border-neutral-800 bg-neutral-900/95 px-3 py-2 text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] transition-colors focus-within:border-neutral-600";
|
|
6
|
+
export const LABEL = "text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500";
|
|
7
|
+
export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
|
|
8
|
+
|
|
9
|
+
export function formatNumericValue(value: number): string {
|
|
10
|
+
const rounded = Math.round(value * 100) / 100;
|
|
11
|
+
return Number.isInteger(rounded)
|
|
12
|
+
? `${rounded}`
|
|
13
|
+
: rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function clampMotionNumber(
|
|
17
|
+
value: number | null,
|
|
18
|
+
min: number,
|
|
19
|
+
max: number,
|
|
20
|
+
fallback: number,
|
|
21
|
+
): number {
|
|
22
|
+
if (value == null || !Number.isFinite(value)) return fallback;
|
|
23
|
+
return Math.min(max, Math.max(min, value));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parsePlainNumber(value: string): number | null {
|
|
27
|
+
const parsed = Number.parseFloat(value.trim());
|
|
28
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── CommitField ──
|
|
32
|
+
|
|
33
|
+
export function CommitField({
|
|
34
|
+
value,
|
|
35
|
+
disabled,
|
|
36
|
+
onCommit,
|
|
37
|
+
}: {
|
|
38
|
+
value: string;
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
onCommit: (nextValue: string) => void;
|
|
41
|
+
}) {
|
|
42
|
+
const [draft, setDraft] = useState(value);
|
|
43
|
+
const focusedRef = useRef(false);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!focusedRef.current) setDraft(value);
|
|
47
|
+
}, [value]);
|
|
48
|
+
|
|
49
|
+
const commitDraft = () => {
|
|
50
|
+
focusedRef.current = false;
|
|
51
|
+
const next = draft.trim();
|
|
52
|
+
if (next !== value) onCommit(next);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<input
|
|
57
|
+
type="text"
|
|
58
|
+
value={draft}
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
onFocus={() => {
|
|
61
|
+
focusedRef.current = true;
|
|
62
|
+
}}
|
|
63
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
64
|
+
onBlur={commitDraft}
|
|
65
|
+
onKeyDown={(event) => {
|
|
66
|
+
if (event.key === "Enter") (event.target as HTMLInputElement).blur();
|
|
67
|
+
}}
|
|
68
|
+
className="w-full min-w-0 bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── DetailField ──
|
|
74
|
+
|
|
75
|
+
export function DetailField({
|
|
76
|
+
label,
|
|
77
|
+
value,
|
|
78
|
+
disabled,
|
|
79
|
+
onCommit,
|
|
80
|
+
}: {
|
|
81
|
+
label: string;
|
|
82
|
+
value: string;
|
|
83
|
+
disabled?: boolean;
|
|
84
|
+
onCommit: (nextValue: string) => void;
|
|
85
|
+
}) {
|
|
86
|
+
return (
|
|
87
|
+
<label className="grid min-w-0 gap-1.5">
|
|
88
|
+
<span className={LABEL}>{label}</span>
|
|
89
|
+
<div className={FIELD}>
|
|
90
|
+
<CommitField value={value} disabled={disabled} onCommit={onCommit} />
|
|
91
|
+
</div>
|
|
92
|
+
</label>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── SegmentedControl ──
|
|
97
|
+
|
|
98
|
+
export function SegmentedControl({
|
|
99
|
+
value,
|
|
100
|
+
options,
|
|
101
|
+
onChange,
|
|
102
|
+
}: {
|
|
103
|
+
value: string;
|
|
104
|
+
options: Array<{ label: string; value: string }>;
|
|
105
|
+
onChange: (value: string) => void;
|
|
106
|
+
}) {
|
|
107
|
+
return (
|
|
108
|
+
<div className="grid grid-cols-3 gap-1 rounded-2xl border border-neutral-800 bg-neutral-950 p-1">
|
|
109
|
+
{options.map((option) => (
|
|
110
|
+
<button
|
|
111
|
+
key={option.value}
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={() => onChange(option.value)}
|
|
114
|
+
className={`h-9 rounded-xl text-[11px] font-semibold transition-colors ${
|
|
115
|
+
option.value === value
|
|
116
|
+
? "bg-neutral-800 text-white shadow-sm"
|
|
117
|
+
: "text-neutral-500 hover:bg-neutral-900 hover:text-neutral-200"
|
|
118
|
+
}`}
|
|
119
|
+
>
|
|
120
|
+
{option.label}
|
|
121
|
+
</button>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── SelectField ──
|
|
128
|
+
|
|
129
|
+
export function SelectField({
|
|
130
|
+
label,
|
|
131
|
+
value,
|
|
132
|
+
options,
|
|
133
|
+
onChange,
|
|
134
|
+
}: {
|
|
135
|
+
label: string;
|
|
136
|
+
value: string;
|
|
137
|
+
options: readonly string[];
|
|
138
|
+
onChange: (value: string) => void;
|
|
139
|
+
}) {
|
|
140
|
+
return (
|
|
141
|
+
<label className="grid min-w-0 gap-1.5">
|
|
142
|
+
<span className={LABEL}>{label}</span>
|
|
143
|
+
<div className={FIELD}>
|
|
144
|
+
<select
|
|
145
|
+
value={value}
|
|
146
|
+
onChange={(event) => onChange(event.target.value)}
|
|
147
|
+
className="w-full min-w-0 appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none"
|
|
148
|
+
>
|
|
149
|
+
{options.map((option) => (
|
|
150
|
+
<option key={option} value={option}>
|
|
151
|
+
{option}
|
|
152
|
+
</option>
|
|
153
|
+
))}
|
|
154
|
+
</select>
|
|
155
|
+
</div>
|
|
156
|
+
</label>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── MotionSection ──
|
|
161
|
+
|
|
162
|
+
export function MotionSection({
|
|
163
|
+
title,
|
|
164
|
+
children,
|
|
165
|
+
accessory,
|
|
166
|
+
}: {
|
|
167
|
+
title: string;
|
|
168
|
+
children: ReactNode;
|
|
169
|
+
accessory?: ReactNode;
|
|
170
|
+
}) {
|
|
171
|
+
return (
|
|
172
|
+
<section className="border-b border-neutral-800 px-4 py-5">
|
|
173
|
+
<div className="mb-4 flex items-center justify-between gap-3">
|
|
174
|
+
<div className="flex items-center gap-3">
|
|
175
|
+
<Zap size={15} className="text-neutral-500" />
|
|
176
|
+
<h3 className="text-[11px] font-semibold uppercase tracking-[0.22em] text-neutral-300">
|
|
177
|
+
{title}
|
|
178
|
+
</h3>
|
|
179
|
+
</div>
|
|
180
|
+
{accessory}
|
|
181
|
+
</div>
|
|
182
|
+
{children}
|
|
183
|
+
</section>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -4,10 +4,8 @@ import {
|
|
|
4
4
|
buildStrokeWidthStyleUpdates,
|
|
5
5
|
getClipPathInsetPx,
|
|
6
6
|
getCssFilterFunctionPx,
|
|
7
|
-
getPropertyPanelVisibleSections,
|
|
8
7
|
inferBoxShadowPreset,
|
|
9
8
|
inferClipPathPreset,
|
|
10
|
-
isPropertyPanelMediaLikeSelection,
|
|
11
9
|
normalizePanelPxValue,
|
|
12
10
|
setCssFilterFunctionPx,
|
|
13
11
|
} from "./PropertyPanel";
|
|
@@ -66,51 +64,4 @@ describe("PropertyPanel style helpers", () => {
|
|
|
66
64
|
expect(buildStrokeStyleUpdates("none", "4px")).toEqual([["border-style", "none"]]);
|
|
67
65
|
expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
|
|
68
66
|
});
|
|
69
|
-
|
|
70
|
-
it("orders the simplified default inspector sections around high-confidence edits", () => {
|
|
71
|
-
expect(
|
|
72
|
-
getPropertyPanelVisibleSections({
|
|
73
|
-
hasSelection: true,
|
|
74
|
-
canEditStyles: true,
|
|
75
|
-
hasTextControls: true,
|
|
76
|
-
hasColorControls: true,
|
|
77
|
-
}),
|
|
78
|
-
).toEqual(["Text", "Layout", "Colors", "Radius", "Shadow"]);
|
|
79
|
-
|
|
80
|
-
expect(
|
|
81
|
-
getPropertyPanelVisibleSections({
|
|
82
|
-
hasSelection: true,
|
|
83
|
-
canEditStyles: true,
|
|
84
|
-
hasTextControls: false,
|
|
85
|
-
hasColorControls: false,
|
|
86
|
-
}),
|
|
87
|
-
).toEqual(["Layout", "Radius", "Shadow"]);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("treats media tags and background-image layers as image-like controls", () => {
|
|
91
|
-
expect(
|
|
92
|
-
isPropertyPanelMediaLikeSelection({
|
|
93
|
-
tagName: "img",
|
|
94
|
-
styles: {},
|
|
95
|
-
}),
|
|
96
|
-
).toBe(true);
|
|
97
|
-
|
|
98
|
-
expect(
|
|
99
|
-
isPropertyPanelMediaLikeSelection({
|
|
100
|
-
tagName: "div",
|
|
101
|
-
styles: {
|
|
102
|
-
"background-image": "url(/assets/studio.png)",
|
|
103
|
-
},
|
|
104
|
-
}),
|
|
105
|
-
).toBe(true);
|
|
106
|
-
|
|
107
|
-
expect(
|
|
108
|
-
isPropertyPanelMediaLikeSelection({
|
|
109
|
-
tagName: "div",
|
|
110
|
-
styles: {
|
|
111
|
-
"background-image": "none",
|
|
112
|
-
},
|
|
113
|
-
}),
|
|
114
|
-
).toBe(false);
|
|
115
|
-
});
|
|
116
67
|
});
|