Skip to content

Commit 35bf415

Browse files
committed
feat(studio): marquee selection, AE presets, per-keyframe ease + velocity fitting
Marquee selection: click+drag on empty canvas space draws a dashed selection rectangle. On release, all elements whose oriented bounding box intersects are group-selected via SAT. Shift+marquee adds to existing selection. Click on empty space deselects. Per-keyframe easing: each keyframe segment now has its own ease, editable via an expandable bezier curve editor in the Animation panel. The parser preserves per-keyframe ease strings through round-trips. AE Easy Ease presets: correct After Effects bezier values (0.333, 0, 0.667, 1) as first-class presets in the preset grid. Velocity-based curve fitting: gesture recordings analyze the velocity profile of raw samples and assign per-keyframe custom eases — segments where the user decelerated get Easy Ease In, accelerated segments get Easy Ease Out, constant speed stays linear.
1 parent 20d7200 commit 35bf415

21 files changed

Lines changed: 926 additions & 36 deletions

packages/core/src/parsers/gsapParser.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2216,6 +2216,23 @@ export function updateKeyframeInScript(
22162216
const match = findKeyframePropByPct(kfNode, percentage);
22172217
if (!match) return script;
22182218

2219+
if (Object.keys(properties).length === 0 && ease) {
2220+
// Ease-only update: preserve existing properties, just add/replace ease
2221+
const existing = match.prop.value;
2222+
if (existing?.type === "ObjectExpression") {
2223+
const props = (existing.properties ?? []) as AstNode[];
2224+
const easeIdx = props.findIndex(
2225+
(p: AstNode) => p.type === "Property" && (p.key?.name ?? p.key?.value) === "ease",
2226+
);
2227+
const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0];
2228+
if (easeIdx >= 0) {
2229+
props[easeIdx] = easeNode;
2230+
} else {
2231+
props.push(easeNode);
2232+
}
2233+
return recast.print(loc.parsed.ast).code;
2234+
}
2235+
}
22192236
match.prop.value = buildKeyframeValueNode(properties, ease);
22202237
return recast.print(loc.parsed.ast).code;
22212238
}

packages/studio/src/components/StudioPreviewArea.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
STUDIO_PREVIEW_SELECTION_ENABLED,
1818
} from "./editor/manualEditingAvailability";
1919
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
20-
import { useDomEditContext } from "../contexts/DomEditContext";
20+
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
2121
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
2222
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
2323
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
@@ -117,6 +117,9 @@ export function StudioPreviewArea({
117117
domEditHoverSelection,
118118
domEditSelection,
119119
domEditGroupSelections,
120+
selectedGsapAnimations,
121+
} = useDomEditSelectionContext();
122+
const {
120123
handleTimelineElementSelect,
121124
handlePreviewCanvasMouseDown,
122125
handlePreviewCanvasPointerMove,
@@ -128,15 +131,16 @@ export function StudioPreviewArea({
128131
handleDomGroupPathOffsetCommit,
129132
handleDomBoxSizeCommit,
130133
handleDomRotationCommit,
131-
selectedGsapAnimations,
132134
handleGsapRemoveKeyframe,
133135
handleGsapUpdateMeta,
134136
handleGsapAddKeyframe,
135137
handleGsapConvertToKeyframes,
136138
handleGsapDeleteAllForElement,
137139
buildDomSelectionForTimelineElement,
138-
} = useDomEditContext();
140+
applyMarqueeSelection,
141+
} = useDomEditActionsContext();
139142

143+
// fallow-ignore-next-line complexity
140144
const [snapPrefs, setSnapPrefs] = useState(() => {
141145
const p = readStudioUiPreferences();
142146
return {
@@ -160,6 +164,7 @@ export function StudioPreviewArea({
160164
const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId;
161165
handleGsapDeleteAllForElement(`#${rawId}`);
162166
},
167+
// fallow-ignore-next-line complexity
163168
onDeleteKeyframe: (_elId: string, pct: number) => {
164169
const cacheKey = domEditSelection?.id ?? "";
165170
const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
@@ -215,6 +220,7 @@ export function StudioPreviewArea({
215220
}
216221
}
217222
},
223+
// fallow-ignore-next-line complexity
218224
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
219225
const currentTime = usePlayerStore.getState().currentTime;
220226
const pct =
@@ -339,6 +345,7 @@ export function StudioPreviewArea({
339345
gridSpacing={snapPrefs.gridSpacing}
340346
recordingState={recordingState}
341347
onToggleRecording={onToggleRecording}
348+
onMarqueeSelect={applyMarqueeSelection}
342349
/>
343350
<SnapToolbar onSnapChange={setSnapPrefs} />
344351
{STUDIO_KEYFRAMES_ENABLED && (

packages/studio/src/components/StudioRightPanel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export function StudioRightPanel({
121121
handleSetArcPath,
122122
handleUpdateArcSegment,
123123
handleUnroll,
124+
handleUpdateKeyframeEase,
124125
handleGsapAddKeyframe,
125126
handleGsapRemoveKeyframe,
126127
handleGsapConvertToKeyframes,
@@ -274,6 +275,7 @@ export function StudioRightPanel({
274275
onSetArcPath={handleSetArcPath}
275276
onUpdateArcSegment={handleUpdateArcSegment}
276277
onUnroll={handleUnroll}
278+
onUpdateKeyframeEase={handleUpdateKeyframeEase}
277279
recordingState={recordingState}
278280
recordingDuration={recordingDuration}
279281
onToggleRecording={onToggleRecording}

packages/studio/src/components/editor/AnimationCard.tsx

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { EaseCurveSection } from "./EaseCurveSection";
2020
import { ArcPathControls } from "./ArcPathControls";
2121
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
2222
import { ComputedTweenNotice } from "./ComputedTweenNotice";
23+
import { KeyframeEaseList } from "./KeyframeEaseList";
2324
import { P } from "./panelTokens";
2425
const BOOLEAN_PROPS = new Set(["visibility"]);
2526
const STRING_PROPS = new Set(["filter", "clipPath"]);
@@ -257,11 +258,13 @@ export const AnimationCard = memo(function AnimationCard({
257258
onLivePreviewEnd,
258259
onSetArcPath,
259260
onUpdateArcSegment,
261+
onUpdateKeyframeEase,
260262
onUnroll,
261263
}: AnimationCardProps) {
262264
const [expanded, setExpanded] = useState(defaultExpanded);
263265
const [addingProp, setAddingProp] = useState(false);
264266
const [addingFromProp, setAddingFromProp] = useState(false);
267+
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
265268

266269
const usedProps = useMemo(
267270
() => new Set(Object.keys(animation.properties)),
@@ -393,7 +396,7 @@ export const AnimationCard = memo(function AnimationCard({
393396
clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
394397
}}
395398
/>
396-
Keyframed — edit values in the Layout panel above
399+
Keyframed — click a segment below to edit its curve
397400
</p>
398401
)}
399402
</div>
@@ -435,29 +438,41 @@ export const AnimationCard = memo(function AnimationCard({
435438

436439
{animation.method !== "set" && (
437440
<>
438-
<SelectField
439-
label="Speed"
440-
value={easeName.startsWith("custom(") ? "custom" : easeName}
441-
options={[...SUPPORTED_EASES, "custom"]}
442-
onChange={(next) => {
443-
if (next === "custom") {
444-
const points = controlPointsForGsapEase(
445-
easeName !== "none" ? easeName : "power2.out",
446-
);
447-
const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
448-
onUpdateMeta(animation.id, { ease: `custom(${path})` });
449-
} else {
450-
onUpdateMeta(animation.id, { ease: next });
451-
}
452-
}}
453-
/>
454-
<EaseCurveSection
455-
ease={easeName}
456-
duration={animation.duration}
457-
onCustomEaseCommit={(customEase) =>
458-
onUpdateMeta(animation.id, { ease: customEase })
459-
}
460-
/>
441+
{animation.keyframes && onUpdateKeyframeEase ? (
442+
<KeyframeEaseList
443+
keyframes={animation.keyframes.keyframes}
444+
globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
445+
expandedPct={expandedKfPct}
446+
onToggle={setExpandedKfPct}
447+
onEaseCommit={(pct, ease) => onUpdateKeyframeEase(animation.id, pct, ease)}
448+
/>
449+
) : (
450+
<>
451+
<SelectField
452+
label="Speed"
453+
value={easeName.startsWith("custom(") ? "custom" : easeName}
454+
options={[...SUPPORTED_EASES, "custom"]}
455+
onChange={(next) => {
456+
if (next === "custom") {
457+
const points = controlPointsForGsapEase(
458+
easeName !== "none" ? easeName : "power2.out",
459+
);
460+
const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
461+
onUpdateMeta(animation.id, { ease: `custom(${path})` });
462+
} else {
463+
onUpdateMeta(animation.id, { ease: next });
464+
}
465+
}}
466+
/>
467+
<EaseCurveSection
468+
ease={easeName}
469+
duration={animation.duration}
470+
onCustomEaseCommit={(customEase) =>
471+
onUpdateMeta(animation.id, { ease: customEase })
472+
}
473+
/>
474+
</>
475+
)}
461476
</>
462477
)}
463478

packages/studio/src/components/editor/DomEditOverlay.tsx

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { memo, useMemo, useRef, useState, type RefObject } from "react";
22
import { useMountEffect } from "../../hooks/useMountEffect";
33
import { type DomEditSelection } from "./domEditing";
4+
import { useMarqueeGestures } from "./marqueeCommit";
45
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
56
import {
67
type BlockedMoveState,
@@ -67,8 +68,10 @@ interface DomEditOverlayProps {
6768
gridSpacing?: number;
6869
recordingState?: GestureRecordingState;
6970
onToggleRecording?: () => void;
71+
onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
7072
}
7173

74+
// fallow-ignore-next-line complexity
7275
export const DomEditOverlay = memo(function DomEditOverlay({
7376
iframeRef,
7477
activeCompositionPath,
@@ -88,9 +91,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
8891
onGroupPathOffsetCommit,
8992
onBoxSizeCommit,
9093
onRotationCommit,
94+
onMarqueeSelect,
9195
}: DomEditOverlayProps) {
9296
const overlayRef = useRef<HTMLDivElement | null>(null);
9397
const boxRef = useRef<HTMLDivElement | null>(null);
98+
const onMarqueeSelectRef = useRef(onMarqueeSelect);
99+
onMarqueeSelectRef.current = onMarqueeSelect;
94100

95101
const selectionShapeStyles = (() => {
96102
const fallback = {
@@ -238,6 +244,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
238244
snapGuidesRef,
239245
});
240246

247+
const marquee = useMarqueeGestures({
248+
iframeRef,
249+
overlayRef,
250+
activeCompositionPathRef,
251+
onMarqueeSelectRef,
252+
selectionRef,
253+
gestures,
254+
});
255+
241256
const selectionKey = useMemo(() => {
242257
if (!selection) return "none";
243258
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
@@ -306,6 +321,36 @@ export const DomEditOverlay = memo(function DomEditOverlay({
306321

307322
const target = event.target as HTMLElement | null;
308323
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
324+
325+
// Start marquee if clicking on empty canvas (no element under pointer)
326+
if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
327+
const overlayEl = overlayRef.current;
328+
if (overlayEl) {
329+
const oRect = overlayEl.getBoundingClientRect();
330+
const cx = event.clientX - oRect.left;
331+
const cy = event.clientY - oRect.top;
332+
const inComp =
333+
cx >= compRect.left &&
334+
cx <= compRect.left + compRect.width &&
335+
cy >= compRect.top &&
336+
cy <= compRect.top + compRect.height;
337+
if (inComp) {
338+
event.preventDefault();
339+
event.stopPropagation();
340+
suppressNextOverlayMouseDownRef.current = true;
341+
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
342+
marquee.marqueeRef.current = {
343+
startX: cx,
344+
startY: cy,
345+
currentX: cx,
346+
currentY: cy,
347+
pointerId: event.pointerId,
348+
pastThreshold: false,
349+
};
350+
return;
351+
}
352+
}
353+
}
309354
};
310355

311356
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -332,15 +377,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
332377
className="absolute inset-0 z-10 pointer-events-auto outline-none"
333378
tabIndex={-1}
334379
aria-label="Composition canvas"
380+
style={marquee.marqueeRef.current?.pastThreshold ? { cursor: "crosshair" } : undefined}
335381
onPointerDownCapture={(event) =>
336382
focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay)
337383
}
338384
onPointerDown={handleOverlayPointerDown}
339385
onMouseDown={handleOverlayMouseDown}
340-
onPointerMove={gestures.onPointerMove}
386+
onPointerMove={marquee.onPointerMove}
341387
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
342-
onPointerUp={gestures.onPointerUp}
343-
onPointerCancel={() => gestures.clearPointerState(selectionRef)}
388+
onPointerUp={marquee.onPointerUp}
389+
onPointerCancel={marquee.onPointerCancel}
344390
>
345391
{hoverSelection && hoverRect && compRect.width > 0 && (
346392
<div
@@ -496,6 +542,18 @@ export const DomEditOverlay = memo(function DomEditOverlay({
496542
}}
497543
/>
498544
))}
545+
{marquee.marqueeRect && (
546+
<div
547+
aria-hidden="true"
548+
className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
549+
style={{
550+
left: marquee.marqueeRect.left,
551+
top: marquee.marqueeRect.top,
552+
width: marquee.marqueeRect.width,
553+
height: marquee.marqueeRect.height,
554+
}}
555+
/>
556+
)}
499557
<GridOverlay
500558
visible={gridVisible}
501559
spacing={gridSpacing}

packages/studio/src/components/editor/EaseCurveSection.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnima
33
import { roundToCenti } from "../../utils/rounding";
44

55
const PRESET_GRID_EASES = [
6+
"ae-ease",
7+
"ae-ease-in",
8+
"ae-ease-out",
69
"none",
710
"power2.out",
811
"power2.in",
9-
"power2.inOut",
10-
"power3.out",
1112
"back.out",
1213
"expo.out",
13-
"elastic.out",
1414
] as const;
1515

1616
function MiniCurveSvg({

packages/studio/src/components/editor/GsapAnimationSection.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
3030
onLivePreviewEnd,
3131
onSetArcPath,
3232
onUpdateArcSegment,
33+
onUpdateKeyframeEase,
3334
onUnroll,
3435
}: GsapAnimationSectionProps) {
3536
const [addMenuOpen, setAddMenuOpen] = useState(false);
@@ -68,6 +69,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
6869
onLivePreviewEnd={onLivePreviewEnd}
6970
onSetArcPath={onSetArcPath}
7071
onUpdateArcSegment={onUpdateArcSegment}
72+
onUpdateKeyframeEase={onUpdateKeyframeEase}
7173
onUnroll={onUnroll}
7274
/>
7375
))}

0 commit comments

Comments
 (0)