Skip to content

Commit b5d05c4

Browse files
committed
feat(studio): marquee multi-selection + AE Easy Ease presets
Marquee selection: click+drag on empty canvas space draws a dashed selection rectangle. On release, all elements whose oriented bounding box intersects the marquee are group-selected via SAT intersection, handling rotated/scaled/skewed elements accurately. Shift+marquee adds to existing selection. Click on empty space deselects. AE presets: adds After Effects' correct Easy Ease bezier values (0.333, 0, 0.667, 1) as first-class presets in the Animation panel, replacing power2.inOut in the top row of the preset grid.
1 parent 20d7200 commit b5d05c4

10 files changed

Lines changed: 586 additions & 9 deletions

File tree

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/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/gsapAnimationConstants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ export const EASE_LABELS: Record<string, string> = {
119119
"spring-stiff": "Stiff spring",
120120
"spring-wobbly": "Wobbly spring",
121121
"spring-heavy": "Heavy spring",
122+
"ae-ease": "Easy Ease (AE)",
123+
"ae-ease-in": "Easy Ease In (AE)",
124+
"ae-ease-out": "Easy Ease Out (AE)",
122125
};
123126

124127
export const EASE_CURVES: Record<string, [number, number, number, number]> = {
@@ -141,6 +144,9 @@ export const EASE_CURVES: Record<string, [number, number, number, number]> = {
141144
"expo.out": [0.16, 1, 0.3, 1],
142145
"expo.in": [0.7, 0, 0.84, 0],
143146
"expo.inOut": [0.87, 0, 0.13, 1],
147+
"ae-ease": [0.333, 0, 0.667, 1],
148+
"ae-ease-in": [0.333, 0, 0.667, 0.667],
149+
"ae-ease-out": [0.333, 0.333, 0.667, 1],
144150
};
145151

146152
export function parseCustomEaseFromString(ease: string): {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { useCallback, useRef, useState } from "react";
2+
import type { DomEditSelection } from "./domEditing";
3+
import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
4+
import { isElementComputedVisible } from "./domEditingElement";
5+
import { coversComposition } from "../../utils/studioPreviewHelpers";
6+
import { elementObbCorners, marqueeIntersectsObb } from "../../utils/marqueeGeometry";
7+
8+
interface MarqueeState {
9+
startX: number;
10+
startY: number;
11+
currentX: number;
12+
currentY: number;
13+
pointerId: number;
14+
pastThreshold: boolean;
15+
}
16+
17+
const MARQUEE_THRESHOLD_PX = 4;
18+
19+
// fallow-ignore-next-line complexity
20+
async function runMarqueeIntersection(
21+
rect: { left: number; top: number; width: number; height: number },
22+
iframe: HTMLIFrameElement,
23+
overlayEl: HTMLDivElement,
24+
activeCompositionPath: string,
25+
): Promise<DomEditSelection[]> {
26+
const doc = iframe.contentDocument;
27+
if (!doc) return [];
28+
29+
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
30+
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
31+
const items = collectDomEditLayerItems(root, { activeCompositionPath, isMasterView });
32+
33+
const rootEl = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement;
34+
const declW = Number.parseFloat(rootEl?.getAttribute("data-width") ?? "");
35+
const declH = Number.parseFloat(rootEl?.getAttribute("data-height") ?? "");
36+
const viewport = {
37+
width: declW > 0 ? declW : rootEl.getBoundingClientRect().width || 1,
38+
height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1,
39+
};
40+
41+
const hits: DomEditSelection[] = [];
42+
for (const item of items) {
43+
const el = item.element;
44+
if (!isElementComputedVisible(el)) continue;
45+
if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
46+
const corners = elementObbCorners(el, overlayEl, iframe);
47+
if (!corners) continue;
48+
if (!marqueeIntersectsObb(rect, corners)) continue;
49+
const sel = await resolveDomEditSelection(el, {
50+
activeCompositionPath,
51+
isMasterView,
52+
skipSourceProbe: true,
53+
});
54+
if (sel) hits.push(sel);
55+
}
56+
57+
return hits;
58+
}
59+
60+
interface MarqueeGesturesDeps {
61+
iframeRef: React.RefObject<HTMLIFrameElement | null>;
62+
overlayRef: React.RefObject<HTMLDivElement | null>;
63+
activeCompositionPathRef: React.RefObject<string | null>;
64+
onMarqueeSelectRef: React.RefObject<
65+
((selections: DomEditSelection[], additive: boolean) => void) | undefined
66+
>;
67+
selectionRef: React.RefObject<DomEditSelection | null>;
68+
gestures: {
69+
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
70+
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
71+
clearPointerState: (ref: React.RefObject<DomEditSelection | null>) => void;
72+
};
73+
}
74+
75+
// fallow-ignore-next-line complexity
76+
export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
77+
const marqueeRef = useRef<MarqueeState | null>(null);
78+
const [marqueeRect, setMarqueeRect] = useState<{
79+
left: number;
80+
top: number;
81+
width: number;
82+
height: number;
83+
} | null>(null);
84+
85+
const commitMarquee = useCallback(
86+
async (
87+
rect: { left: number; top: number; width: number; height: number },
88+
additive: boolean,
89+
) => {
90+
const iframe = deps.iframeRef.current;
91+
const overlay = deps.overlayRef.current;
92+
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
93+
const acp = deps.activeCompositionPathRef.current ?? "index.html";
94+
const hits = await runMarqueeIntersection(rect, iframe, overlay, acp);
95+
deps.onMarqueeSelectRef.current(hits, additive);
96+
},
97+
[deps.iframeRef, deps.overlayRef, deps.onMarqueeSelectRef, deps.activeCompositionPathRef],
98+
);
99+
100+
const onPointerMove = useCallback(
101+
(event: React.PointerEvent<HTMLDivElement>) => {
102+
const m = marqueeRef.current;
103+
if (m) {
104+
const oRect = deps.overlayRef.current?.getBoundingClientRect();
105+
if (!oRect) return;
106+
m.currentX = event.clientX - oRect.left;
107+
m.currentY = event.clientY - oRect.top;
108+
if (!m.pastThreshold) {
109+
const dx = m.currentX - m.startX;
110+
const dy = m.currentY - m.startY;
111+
if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return;
112+
m.pastThreshold = true;
113+
}
114+
setMarqueeRect({
115+
left: Math.min(m.startX, m.currentX),
116+
top: Math.min(m.startY, m.currentY),
117+
width: Math.abs(m.currentX - m.startX),
118+
height: Math.abs(m.currentY - m.startY),
119+
});
120+
return;
121+
}
122+
deps.gestures.onPointerMove(event);
123+
},
124+
[deps.gestures, deps.overlayRef],
125+
);
126+
127+
const onPointerUp = useCallback(
128+
(event: React.PointerEvent<HTMLDivElement>) => {
129+
const m = marqueeRef.current;
130+
if (m) {
131+
marqueeRef.current = null;
132+
try {
133+
(event.currentTarget as HTMLElement).releasePointerCapture(m.pointerId);
134+
} catch {
135+
/* already released */
136+
}
137+
if (m.pastThreshold) {
138+
commitMarquee(
139+
{
140+
left: Math.min(m.startX, m.currentX),
141+
top: Math.min(m.startY, m.currentY),
142+
width: Math.abs(m.currentX - m.startX),
143+
height: Math.abs(m.currentY - m.startY),
144+
},
145+
event.shiftKey,
146+
);
147+
} else {
148+
deps.onMarqueeSelectRef.current?.([], false);
149+
}
150+
setMarqueeRect(null);
151+
return;
152+
}
153+
deps.gestures.onPointerUp(event);
154+
},
155+
[deps.gestures, commitMarquee, deps.onMarqueeSelectRef],
156+
);
157+
158+
const onPointerCancel = useCallback(() => {
159+
if (marqueeRef.current) {
160+
marqueeRef.current = null;
161+
setMarqueeRect(null);
162+
return;
163+
}
164+
deps.gestures.clearPointerState(deps.selectionRef);
165+
}, [deps.gestures, deps.selectionRef]);
166+
167+
return { marqueeRef, marqueeRect, onPointerMove, onPointerUp, onPointerCancel };
168+
}

0 commit comments

Comments
 (0)