@hyperframes/studio 0.7.14 → 0.7.16
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-C4cUbqql.js +375 -0
- package/dist/assets/{index-CkrVt5DX.js → index-D2SMcOdY.js} +1 -1
- package/dist/assets/index-DmkOvZns.css +1 -0
- package/dist/assets/{index-DYgkaIlV.js → index-sCOjSz40.js} +1 -1
- package/dist/{chunk-KZXYQYIU.js → chunk-SBGXX7WY.js} +52 -22
- package/dist/chunk-SBGXX7WY.js.map +1 -0
- package/dist/{domEditingLayers-SSXQZHHQ.js → domEditingLayers-VZMLL4AP.js} +2 -4
- package/dist/index.d.ts +23 -6
- package/dist/index.html +2 -2
- package/dist/index.js +3158 -2809
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
- package/src/App.tsx +2 -0
- package/src/components/StudioPreviewArea.tsx +0 -41
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/TimelineToolbar.tsx +4 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/GestureRecordControl.tsx +2 -2
- package/src/components/editor/GsapAnimationSection.tsx +3 -3
- package/src/components/editor/InspectorHeaderActions.tsx +62 -0
- package/src/components/editor/LayersPanel.tsx +35 -2
- package/src/components/editor/MotionPathOverlay.tsx +24 -8
- package/src/components/editor/PropertyPanel.tsx +48 -60
- package/src/components/editor/Transform3DCube.tsx +84 -85
- package/src/components/editor/domEditOverlayGeometry.ts +28 -0
- package/src/components/editor/domEditingDom.ts +36 -2
- package/src/components/editor/domEditingGroups.ts +41 -0
- package/src/components/editor/domEditingLayers.test.ts +97 -1
- package/src/components/editor/domEditingLayers.ts +13 -29
- package/src/components/editor/domEditingTypes.ts +4 -1
- package/src/components/editor/gsapAnimationCallbacks.ts +1 -1
- package/src/components/editor/gsapAnimationHelpers.test.ts +2 -2
- package/src/components/editor/gsapAnimationHelpers.ts +1 -1
- package/src/components/editor/manualEditsTypes.ts +1 -1
- package/src/components/editor/manualOffsetDrag.ts +23 -2
- package/src/components/editor/motionPathCommit.test.ts +1 -1
- package/src/components/editor/motionPathSelection.ts +1 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +70 -30
- package/src/components/editor/propertyPanelHelpers.ts +6 -4
- package/src/components/editor/useDomEditOverlayRects.ts +8 -3
- package/src/components/editor/useMotionPathData.ts +47 -1
- package/src/contexts/DomEditContext.tsx +16 -0
- package/src/contexts/TimelineEditContext.tsx +0 -1
- package/src/hooks/gsapRuntimeBridge.ts +9 -2
- package/src/hooks/gsapTweenSynth.ts +64 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +126 -26
- package/src/hooks/useAppHotkeys.ts +34 -3
- package/src/hooks/useDomEditSession.ts +48 -0
- package/src/hooks/useDomSelection.ts +73 -9
- package/src/hooks/useEnableKeyframes.test.ts +35 -0
- package/src/hooks/useEnableKeyframes.ts +29 -1
- package/src/hooks/useGroupCommits.ts +188 -0
- package/src/hooks/useGsapSelectionHandlers.ts +4 -0
- package/src/hooks/useGsapTweenCache.ts +56 -72
- package/src/hooks/usePreviewInteraction.ts +60 -2
- package/src/player/components/ShortcutsPanel.tsx +2 -0
- package/src/player/components/Timeline.tsx +0 -15
- package/src/player/components/TimelineCanvas.tsx +0 -13
- package/src/player/components/TimelineClipDiamonds.tsx +4 -125
- package/src/player/components/timelineCallbacks.ts +0 -1
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +47 -4
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -4
- package/src/player/store/playerStore.ts +20 -0
- package/src/utils/sdkCutoverParity.test.ts +1 -1
- package/src/utils/studioPreviewHelpers.ts +37 -0
- package/dist/assets/index-BXG-pbr_.js +0 -375
- package/dist/assets/index-svYFaNuq.css +0 -1
- package/dist/chunk-KZXYQYIU.js.map +0 -1
- package/src/components/editor/keyframeMove.test.ts +0 -101
- package/src/components/editor/keyframeMove.ts +0 -151
- /package/dist/{domEditingLayers-SSXQZHHQ.js.map → domEditingLayers-VZMLL4AP.js.map} +0 -0
|
@@ -15,6 +15,8 @@ import { usePlayerStore } from "../player/store/playerStore";
|
|
|
15
15
|
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
|
|
16
16
|
import type { SetPatchProps } from "./gsapRuntimePatch";
|
|
17
17
|
import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
|
|
18
|
+
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
19
|
+
import { roundTo3 } from "../utils/rounding";
|
|
18
20
|
|
|
19
21
|
interface CommitAnimatedPropertyDeps {
|
|
20
22
|
selectedGsapAnimations: GsapAnimation[];
|
|
@@ -45,14 +47,22 @@ function pickBestAnimation(
|
|
|
45
47
|
selector: string | null,
|
|
46
48
|
property?: string,
|
|
47
49
|
): GsapAnimation | undefined {
|
|
48
|
-
if (animations.length <= 1) return animations[0];
|
|
49
|
-
const currentTime = usePlayerStore.getState().currentTime;
|
|
50
50
|
const targetGroup = property ? classifyPropertyGroup(property) : undefined;
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
51
|
+
// Group-aware: never hand back a tween from a DIFFERENT property group. The old
|
|
52
|
+
// `animations.length <= 1` early return merged a rotation/3D edit into the element's
|
|
53
|
+
// only tween even when that was a `position` tween — contaminating it and leaving the
|
|
54
|
+
// new property with no clean keyframe baseline. When a target group is known, only
|
|
55
|
+
// same-group tweens are candidates; if none exist we return undefined and the caller
|
|
56
|
+
// creates a fresh same-group tween.
|
|
57
|
+
const candidates =
|
|
58
|
+
targetGroup !== undefined
|
|
59
|
+
? animations.filter((a) => a.propertyGroup === targetGroup)
|
|
60
|
+
: animations;
|
|
61
|
+
if (candidates.length === 0) return undefined;
|
|
62
|
+
if (candidates.length === 1) return candidates[0];
|
|
63
|
+
const currentTime = usePlayerStore.getState().currentTime;
|
|
64
|
+
const scored = candidates.map((a) => {
|
|
54
65
|
let score = 0;
|
|
55
|
-
if (targetGroup && a.propertyGroup === targetGroup) score += 20;
|
|
56
66
|
if (a.keyframes) score += 10;
|
|
57
67
|
if (selector && a.targetSelector === selector) score += 5;
|
|
58
68
|
else if (a.targetSelector.includes(",")) score -= 3;
|
|
@@ -196,14 +206,15 @@ async function commitKeyframeProps(
|
|
|
196
206
|
iframe: HTMLIFrameElement | null,
|
|
197
207
|
commit: Commit,
|
|
198
208
|
): Promise<void> {
|
|
199
|
-
|
|
209
|
+
const wasKeyframed = !!anim.keyframes;
|
|
210
|
+
if (!wasKeyframed) {
|
|
200
211
|
await commit(
|
|
201
212
|
selection,
|
|
202
213
|
{ type: "convert-to-keyframes", animationId: anim.id },
|
|
203
214
|
{ label: "Convert to keyframes", skipReload: true },
|
|
204
215
|
);
|
|
205
216
|
}
|
|
206
|
-
const
|
|
217
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
207
218
|
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
|
208
219
|
const properties: Record<string, number | string> = { ...runtimeProps, ...props };
|
|
209
220
|
|
|
@@ -216,6 +227,52 @@ async function commitKeyframeProps(
|
|
|
216
227
|
backfillDefaults[property] = value;
|
|
217
228
|
}
|
|
218
229
|
|
|
230
|
+
// Playhead OUTSIDE the keyframe tween's time range → EXTEND the tween to reach it
|
|
231
|
+
// and add a keyframe there, exactly like manual drag's extendTweenAndAddKeyframe.
|
|
232
|
+
// The add-keyframe below only writes WITHIN the existing range, so without this a
|
|
233
|
+
// depth edit past the tween end just overwrites the last keyframe (the bug: no new
|
|
234
|
+
// diamond appears at a playhead beyond the tween). Only for an already-keyframed
|
|
235
|
+
// tween — a freshly-converted set has no prior range worth remapping.
|
|
236
|
+
const kfs = anim.keyframes?.keyframes;
|
|
237
|
+
const ts = resolveTweenStart(anim);
|
|
238
|
+
const td = resolveTweenDuration(anim);
|
|
239
|
+
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
|
240
|
+
const playheadOutside = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
241
|
+
const willExtend = wasKeyframed && !!kfs && playheadOutside && !hasSelectedKeyframe;
|
|
242
|
+
if (willExtend && kfs && ts !== null) {
|
|
243
|
+
const newStart = Math.min(ct, ts);
|
|
244
|
+
const newEnd = Math.max(ct, ts + td);
|
|
245
|
+
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
246
|
+
const remapped = kfs.map((kf) => {
|
|
247
|
+
const absTime = ts + (kf.percentage / 100) * td;
|
|
248
|
+
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
|
|
249
|
+
const p: Record<string, number | string> = { ...kf.properties };
|
|
250
|
+
for (const k of Object.keys(properties)) {
|
|
251
|
+
if (!(k in p) && backfillDefaults[k] != null) p[k] = backfillDefaults[k];
|
|
252
|
+
}
|
|
253
|
+
return { percentage: newPct, properties: p };
|
|
254
|
+
});
|
|
255
|
+
remapped.push({
|
|
256
|
+
percentage: Math.round(((ct - newStart) / newDuration) * 1000) / 10,
|
|
257
|
+
properties,
|
|
258
|
+
});
|
|
259
|
+
remapped.sort((a, b) => a.percentage - b.percentage);
|
|
260
|
+
await commit(
|
|
261
|
+
selection,
|
|
262
|
+
{
|
|
263
|
+
type: "replace-with-keyframes",
|
|
264
|
+
animationId: anim.id,
|
|
265
|
+
targetSelector: anim.targetSelector,
|
|
266
|
+
position: roundTo3(newStart),
|
|
267
|
+
duration: roundTo3(newDuration),
|
|
268
|
+
keyframes: remapped,
|
|
269
|
+
},
|
|
270
|
+
{ label: `Edit ${primaryProp} (extended keyframe)`, softReload: true },
|
|
271
|
+
);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const pct = computeElementPercentage(ct, selection, anim);
|
|
219
276
|
const existingKf = anim.keyframes?.keyframes.some((kf) => Math.abs(kf.percentage - pct) < 0.05);
|
|
220
277
|
// Rebuild the live keyframe tween in place so the edit shows instantly (no flash);
|
|
221
278
|
// rebuildKeyframeTween declines → soft reload if the tween can't be safely rebuilt.
|
|
@@ -275,8 +332,30 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
275
332
|
// so the rejection doesn't escape as an uncaught promise, and bump the cache
|
|
276
333
|
// so selectedGsapAnimations re-syncs and the user's next edit self-heals.
|
|
277
334
|
try {
|
|
278
|
-
//
|
|
279
|
-
//
|
|
335
|
+
// Animated element → keyframe at the playhead, EXACTLY like manual drag /
|
|
336
|
+
// resize / rotate: if the picked anim is still a static `set`,
|
|
337
|
+
// commitKeyframeProps converts it to keyframes first, then writes the new
|
|
338
|
+
// value as a keyframe at the current time — so the 3D animates instead of
|
|
339
|
+
// holding a flat constant. This MUST come before the `set`-update path below,
|
|
340
|
+
// or a 3D `set` would short-circuit to an in-place update and the playhead
|
|
341
|
+
// keyframe would never land (the bug: scrolling depth on a keyframed element
|
|
342
|
+
// just changed the constant instead of dropping a keyframe).
|
|
343
|
+
if (elementHasKeyframes && anim) {
|
|
344
|
+
await commitKeyframeProps(
|
|
345
|
+
selection,
|
|
346
|
+
anim,
|
|
347
|
+
props,
|
|
348
|
+
propEntries,
|
|
349
|
+
primaryProp,
|
|
350
|
+
selector,
|
|
351
|
+
iframe,
|
|
352
|
+
gsapCommitMutation,
|
|
353
|
+
);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Existing static hold on a NON-animated element — merge the props into the
|
|
358
|
+
// `set` in place (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
|
|
280
359
|
if (anim?.method === "set") {
|
|
281
360
|
await commitSetProps(
|
|
282
361
|
selection,
|
|
@@ -289,8 +368,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
289
368
|
return;
|
|
290
369
|
}
|
|
291
370
|
|
|
292
|
-
// Static element — persist as a `tl.set`, never
|
|
293
|
-
// no-animation case, which
|
|
371
|
+
// Static element (no keyframes anywhere) — persist as a `tl.set`, never
|
|
372
|
+
// keyframes (incl. the no-animation case, which creates a fresh set).
|
|
294
373
|
if (!elementHasKeyframes) {
|
|
295
374
|
await commitStaticSet(
|
|
296
375
|
selection,
|
|
@@ -302,22 +381,43 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
302
381
|
return;
|
|
303
382
|
}
|
|
304
383
|
|
|
305
|
-
// Animated element
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
384
|
+
// Animated element but NO same-group tween exists (e.g. the FIRST rotation/3D
|
|
385
|
+
// keyframe on an element that only has a position tween). Create a fresh
|
|
386
|
+
// same-group keyframed tween WITH a 0% baseline at the playhead, instead of
|
|
387
|
+
// contaminating a foreign-group tween. Mirror an existing keyframed tween's
|
|
388
|
+
// time range so the new group animates over the same span. The 0% baseline is
|
|
389
|
+
// an `_auto` endpoint so it tracks the nearest keyframe as you add more.
|
|
390
|
+
if (selector) {
|
|
391
|
+
const template = selectedGsapAnimations.find((a) => !!a.keyframes);
|
|
392
|
+
const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
|
|
393
|
+
const tDur = template ? resolveTweenDuration(template) || 1 : 1;
|
|
394
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
395
|
+
const pct =
|
|
396
|
+
tDur > 0
|
|
397
|
+
? Math.max(0, Math.min(100, Math.round(((ct - tStart) / tDur) * 1000) / 10))
|
|
398
|
+
: 0;
|
|
399
|
+
const newProps = Object.fromEntries(propEntries);
|
|
400
|
+
const keyframes =
|
|
401
|
+
pct <= 0.05
|
|
402
|
+
? [{ percentage: 0, properties: newProps }]
|
|
403
|
+
: [
|
|
404
|
+
{ percentage: 0, properties: { ...newProps, _auto: 1 } },
|
|
405
|
+
{ percentage: pct, properties: newProps },
|
|
406
|
+
];
|
|
407
|
+
await gsapCommitMutation(
|
|
408
|
+
selection,
|
|
409
|
+
{
|
|
410
|
+
type: "add-with-keyframes",
|
|
411
|
+
targetSelector: selector,
|
|
412
|
+
position: roundTo3(tStart),
|
|
413
|
+
duration: roundTo3(tDur),
|
|
414
|
+
keyframes,
|
|
415
|
+
},
|
|
416
|
+
{ label: `Add ${primaryProp} keyframe`, softReload: true },
|
|
417
|
+
);
|
|
309
418
|
return;
|
|
310
419
|
}
|
|
311
|
-
|
|
312
|
-
selection,
|
|
313
|
-
anim,
|
|
314
|
-
props,
|
|
315
|
-
propEntries,
|
|
316
|
-
primaryProp,
|
|
317
|
-
selector,
|
|
318
|
-
iframe,
|
|
319
|
-
gsapCommitMutation,
|
|
320
|
-
);
|
|
420
|
+
bumpGsapCache();
|
|
321
421
|
} catch {
|
|
322
422
|
bumpGsapCache();
|
|
323
423
|
}
|
|
@@ -8,6 +8,7 @@ import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/tim
|
|
|
8
8
|
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
|
9
9
|
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
10
10
|
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
11
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
11
12
|
|
|
12
13
|
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
|
|
13
14
|
try {
|
|
@@ -117,6 +118,10 @@ interface UseAppHotkeysParams {
|
|
|
117
118
|
onDeleteSelectedKeyframes: () => void;
|
|
118
119
|
onAfterUndoRedo?: () => void;
|
|
119
120
|
onToggleRecording?: () => void;
|
|
121
|
+
/** Group the current multi-selection into a data-hf-group wrapper (⌘G). */
|
|
122
|
+
onGroupSelection?: () => void;
|
|
123
|
+
/** Ungroup the selected group wrapper (⌘⇧G). */
|
|
124
|
+
onUngroupSelection?: () => void;
|
|
120
125
|
/** Active composition path — used to decide whether undo/redo must resync the SDK session. */
|
|
121
126
|
activeCompPath?: string | null;
|
|
122
127
|
/**
|
|
@@ -142,6 +147,8 @@ interface HotkeyCallbacks {
|
|
|
142
147
|
onResetKeyframes: () => boolean;
|
|
143
148
|
onDeleteSelectedKeyframes: () => void;
|
|
144
149
|
onToggleRecording?: () => void;
|
|
150
|
+
onGroupSelection?: () => void;
|
|
151
|
+
onUngroupSelection?: () => void;
|
|
145
152
|
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
|
|
146
153
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
147
154
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
@@ -152,36 +159,56 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba
|
|
|
152
159
|
!shouldIgnoreHistoryShortcut(event.target) &&
|
|
153
160
|
handleUndoRedoKey(
|
|
154
161
|
event,
|
|
155
|
-
() =>
|
|
156
|
-
|
|
162
|
+
() => {
|
|
163
|
+
trackStudioEvent("keyboard_shortcut", { action: "undo" });
|
|
164
|
+
void cb.handleUndo();
|
|
165
|
+
},
|
|
166
|
+
() => {
|
|
167
|
+
trackStudioEvent("keyboard_shortcut", { action: "redo" });
|
|
168
|
+
void cb.handleRedo();
|
|
169
|
+
},
|
|
157
170
|
)
|
|
158
171
|
)
|
|
159
172
|
return true;
|
|
160
173
|
|
|
161
174
|
if (event.key === "1") {
|
|
162
175
|
event.preventDefault();
|
|
176
|
+
trackStudioEvent("keyboard_shortcut", { action: "tab_compositions" });
|
|
163
177
|
cb.leftSidebarRef.current?.selectTab("compositions");
|
|
164
178
|
return true;
|
|
165
179
|
}
|
|
166
180
|
if (event.key === "2") {
|
|
167
181
|
event.preventDefault();
|
|
182
|
+
trackStudioEvent("keyboard_shortcut", { action: "tab_assets" });
|
|
168
183
|
cb.leftSidebarRef.current?.selectTab("assets");
|
|
169
184
|
return true;
|
|
170
185
|
}
|
|
171
186
|
|
|
187
|
+
if (key === "g" && !event.altKey && !isEditableTarget(event.target)) {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
if (event.shiftKey) cb.onUngroupSelection?.();
|
|
190
|
+
else cb.onGroupSelection?.();
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
172
194
|
if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
|
|
173
195
|
if (key === "c") {
|
|
174
|
-
if (cb.handleCopy())
|
|
196
|
+
if (cb.handleCopy()) {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
trackStudioEvent("keyboard_shortcut", { action: "copy" });
|
|
199
|
+
}
|
|
175
200
|
return true;
|
|
176
201
|
}
|
|
177
202
|
if (key === "v") {
|
|
178
203
|
event.preventDefault();
|
|
204
|
+
trackStudioEvent("keyboard_shortcut", { action: "paste" });
|
|
179
205
|
void cb.handlePaste();
|
|
180
206
|
return true;
|
|
181
207
|
}
|
|
182
208
|
if (key === "x") {
|
|
183
209
|
if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) {
|
|
184
210
|
event.preventDefault();
|
|
211
|
+
trackStudioEvent("keyboard_shortcut", { action: "cut" });
|
|
185
212
|
void cb.handleCut();
|
|
186
213
|
}
|
|
187
214
|
return true;
|
|
@@ -310,6 +337,8 @@ export function useAppHotkeys({
|
|
|
310
337
|
onDeleteSelectedKeyframes,
|
|
311
338
|
onAfterUndoRedo,
|
|
312
339
|
onToggleRecording,
|
|
340
|
+
onGroupSelection,
|
|
341
|
+
onUngroupSelection,
|
|
313
342
|
activeCompPath,
|
|
314
343
|
forceReloadSdkSession,
|
|
315
344
|
}: UseAppHotkeysParams) {
|
|
@@ -403,6 +432,8 @@ export function useAppHotkeys({
|
|
|
403
432
|
onResetKeyframes,
|
|
404
433
|
onDeleteSelectedKeyframes,
|
|
405
434
|
onToggleRecording,
|
|
435
|
+
onGroupSelection,
|
|
436
|
+
onUngroupSelection,
|
|
406
437
|
leftSidebarRef,
|
|
407
438
|
domEditSelectionRef,
|
|
408
439
|
showToast,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
2
3
|
import type { TimelineElement } from "../player";
|
|
3
4
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
4
5
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
@@ -12,6 +13,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
|
|
|
12
13
|
import { useDomSelection } from "./useDomSelection";
|
|
13
14
|
import { usePreviewInteraction } from "./usePreviewInteraction";
|
|
14
15
|
import { useDomEditCommits } from "./useDomEditCommits";
|
|
16
|
+
import { useGroupCommits } from "./useGroupCommits";
|
|
15
17
|
import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
16
18
|
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
|
17
19
|
import { useDomEditWiring } from "./useDomEditWiring";
|
|
@@ -114,7 +116,10 @@ export function useDomEditSession({
|
|
|
114
116
|
domEditSelection,
|
|
115
117
|
domEditGroupSelections,
|
|
116
118
|
domEditHoverSelection,
|
|
119
|
+
activeGroupElement,
|
|
117
120
|
domEditSelectionRef,
|
|
121
|
+
domEditGroupSelectionsRef,
|
|
122
|
+
setActiveGroupElement,
|
|
118
123
|
applyDomSelection,
|
|
119
124
|
clearDomSelection,
|
|
120
125
|
buildDomSelectionFromTarget,
|
|
@@ -279,6 +284,44 @@ export function useDomEditSession({
|
|
|
279
284
|
: undefined,
|
|
280
285
|
});
|
|
281
286
|
|
|
287
|
+
// ── Element groups (wrap selected elements in a data-hf-group div) ──
|
|
288
|
+
|
|
289
|
+
const { groupSelection, ungroupSelection } = useGroupCommits({
|
|
290
|
+
activeCompPath,
|
|
291
|
+
showToast,
|
|
292
|
+
writeProjectFile,
|
|
293
|
+
domEditSaveTimestampRef,
|
|
294
|
+
editHistory,
|
|
295
|
+
projectIdRef,
|
|
296
|
+
reloadPreview,
|
|
297
|
+
clearDomSelection,
|
|
298
|
+
forceReloadSdkSession,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const handleGroupSelection = useCallback(() => {
|
|
302
|
+
const group = domEditGroupSelectionsRef.current;
|
|
303
|
+
const single = domEditSelectionRef.current;
|
|
304
|
+
const members = group.length > 0 ? group : single ? [single] : [];
|
|
305
|
+
if (members.length < 2) {
|
|
306
|
+
showToast("Select at least 2 elements to group", "info");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
trackStudioEvent("group", { action: "create", count: members.length });
|
|
310
|
+
void groupSelection(members);
|
|
311
|
+
}, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]);
|
|
312
|
+
|
|
313
|
+
const handleUngroupSelection = useCallback(() => {
|
|
314
|
+
const sel = domEditSelectionRef.current;
|
|
315
|
+
if (!sel?.element.hasAttribute("data-hf-group")) {
|
|
316
|
+
showToast("Select a group to ungroup", "info");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// Dissolving the group exits any drill-in (the wrapper is about to vanish).
|
|
320
|
+
trackStudioEvent("group", { action: "ungroup" });
|
|
321
|
+
setActiveGroupElement(null);
|
|
322
|
+
void ungroupSelection(sel);
|
|
323
|
+
}, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]);
|
|
324
|
+
|
|
282
325
|
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
|
283
326
|
|
|
284
327
|
const {
|
|
@@ -360,6 +403,7 @@ export function useDomEditSession({
|
|
|
360
403
|
resolveDomSelectionFromPreviewPoint,
|
|
361
404
|
resolveAllDomSelectionsFromPreviewPoint,
|
|
362
405
|
updateDomEditHoverSelection,
|
|
406
|
+
setActiveGroupElement,
|
|
363
407
|
onClickToSource,
|
|
364
408
|
});
|
|
365
409
|
|
|
@@ -435,6 +479,7 @@ export function useDomEditSession({
|
|
|
435
479
|
domEditSelection,
|
|
436
480
|
domEditGroupSelections,
|
|
437
481
|
domEditHoverSelection,
|
|
482
|
+
activeGroupElement,
|
|
438
483
|
agentModalOpen,
|
|
439
484
|
agentModalAnchorPoint,
|
|
440
485
|
copiedAgentPrompt,
|
|
@@ -467,6 +512,9 @@ export function useDomEditSession({
|
|
|
467
512
|
handleBlockedDomMove,
|
|
468
513
|
handleDomManualDragStart,
|
|
469
514
|
handleDomEditElementDelete,
|
|
515
|
+
handleGroupSelection,
|
|
516
|
+
handleUngroupSelection,
|
|
517
|
+
setActiveGroupElement,
|
|
470
518
|
buildDomSelectionFromTarget,
|
|
471
519
|
buildDomSelectionForTimelineElement,
|
|
472
520
|
updateDomEditHoverSelection,
|
|
@@ -48,13 +48,16 @@ export interface UseDomSelectionReturn {
|
|
|
48
48
|
domEditSelection: DomEditSelection | null;
|
|
49
49
|
domEditGroupSelections: DomEditSelection[];
|
|
50
50
|
domEditHoverSelection: DomEditSelection | null;
|
|
51
|
+
activeGroupElement: HTMLElement | null;
|
|
51
52
|
// Refs
|
|
52
53
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
53
54
|
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
|
|
54
55
|
domEditHoverSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
56
|
+
activeGroupElementRef: React.MutableRefObject<HTMLElement | null>;
|
|
55
57
|
// State setters (needed by useDomEditSession for agent-prompt reset flows)
|
|
56
58
|
setDomEditSelection: React.Dispatch<React.SetStateAction<DomEditSelection | null>>;
|
|
57
59
|
setDomEditGroupSelections: React.Dispatch<React.SetStateAction<DomEditSelection[]>>;
|
|
60
|
+
setActiveGroupElement: (el: HTMLElement | null) => void;
|
|
58
61
|
// Callbacks
|
|
59
62
|
applyDomSelection: (
|
|
60
63
|
selection: DomEditSelection | null,
|
|
@@ -67,12 +70,20 @@ export interface UseDomSelectionReturn {
|
|
|
67
70
|
clearDomSelection: () => void;
|
|
68
71
|
buildDomSelectionFromTarget: (
|
|
69
72
|
target: HTMLElement,
|
|
70
|
-
options?: {
|
|
73
|
+
options?: {
|
|
74
|
+
preferClipAncestor?: boolean;
|
|
75
|
+
skipSourceProbe?: boolean;
|
|
76
|
+
activeGroupElement?: HTMLElement | null;
|
|
77
|
+
},
|
|
71
78
|
) => Promise<DomEditSelection | null>;
|
|
72
79
|
resolveDomSelectionFromPreviewPoint: (
|
|
73
80
|
clientX: number,
|
|
74
81
|
clientY: number,
|
|
75
|
-
options?: {
|
|
82
|
+
options?: {
|
|
83
|
+
preferClipAncestor?: boolean;
|
|
84
|
+
skipSourceProbe?: boolean;
|
|
85
|
+
activeGroupElement?: HTMLElement | null;
|
|
86
|
+
},
|
|
76
87
|
) => Promise<DomEditSelection | null>;
|
|
77
88
|
resolveAllDomSelectionsFromPreviewPoint: (
|
|
78
89
|
clientX: number,
|
|
@@ -110,17 +121,21 @@ export function useDomSelection({
|
|
|
110
121
|
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
|
111
122
|
const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
|
|
112
123
|
const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
|
|
124
|
+
// The data-hf-group wrapper the user has drilled into (null = top level).
|
|
125
|
+
const [activeGroupElement, setActiveGroupElementState] = useState<HTMLElement | null>(null);
|
|
113
126
|
|
|
114
127
|
// ── Refs ──
|
|
115
128
|
|
|
116
129
|
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
|
|
117
130
|
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
|
|
118
131
|
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
|
|
132
|
+
const activeGroupElementRef = useRef<HTMLElement | null>(activeGroupElement);
|
|
119
133
|
|
|
120
134
|
// Keep refs in sync with state
|
|
121
135
|
domEditSelectionRef.current = domEditSelection;
|
|
122
136
|
domEditGroupSelectionsRef.current = domEditGroupSelections;
|
|
123
137
|
domEditHoverSelectionRef.current = domEditHoverSelection;
|
|
138
|
+
activeGroupElementRef.current = activeGroupElement;
|
|
124
139
|
|
|
125
140
|
// ── Callbacks ──
|
|
126
141
|
|
|
@@ -178,6 +193,14 @@ export function useDomSelection({
|
|
|
178
193
|
setDomEditSelection(nextSelection);
|
|
179
194
|
setDomEditGroupSelections(nextGroup);
|
|
180
195
|
|
|
196
|
+
// Selecting something outside the drilled-into group exits the drill-in, so
|
|
197
|
+
// a later click on the group selects it as a unit again (non-sticky drill-in).
|
|
198
|
+
const activeGroup = activeGroupElementRef.current;
|
|
199
|
+
if (activeGroup && nextSelection && !activeGroup.contains(nextSelection.element)) {
|
|
200
|
+
activeGroupElementRef.current = null;
|
|
201
|
+
setActiveGroupElementState(null);
|
|
202
|
+
}
|
|
203
|
+
|
|
181
204
|
if (nextSelection) {
|
|
182
205
|
if (options?.revealPanel !== false) {
|
|
183
206
|
setRightCollapsed(false);
|
|
@@ -203,16 +226,36 @@ export function useDomSelection({
|
|
|
203
226
|
applyDomSelection(null, { revealPanel: false });
|
|
204
227
|
}, [applyDomSelection]);
|
|
205
228
|
|
|
229
|
+
// Drill into / out of a group. Changing scope clears the current selection so
|
|
230
|
+
// the user isn't left with an out-of-scope element selected.
|
|
231
|
+
const setActiveGroupElement = useCallback(
|
|
232
|
+
(el: HTMLElement | null) => {
|
|
233
|
+
setActiveGroupElementState(el);
|
|
234
|
+
applyDomSelection(null, { revealPanel: false });
|
|
235
|
+
},
|
|
236
|
+
[applyDomSelection],
|
|
237
|
+
);
|
|
238
|
+
|
|
206
239
|
const buildDomSelectionFromTarget = useCallback(
|
|
207
240
|
(
|
|
208
241
|
target: HTMLElement,
|
|
209
|
-
options?: {
|
|
242
|
+
options?: {
|
|
243
|
+
preferClipAncestor?: boolean;
|
|
244
|
+
skipSourceProbe?: boolean;
|
|
245
|
+
// Override the drill-in scope (used by canvas double-click to resolve the
|
|
246
|
+
// child inside a group before the activeGroupElement state has re-rendered).
|
|
247
|
+
activeGroupElement?: HTMLElement | null;
|
|
248
|
+
},
|
|
210
249
|
) => {
|
|
211
250
|
return resolveDomEditSelection(target, {
|
|
212
251
|
activeCompositionPath: activeCompPath,
|
|
213
252
|
isMasterView,
|
|
214
253
|
preferClipAncestor: options?.preferClipAncestor,
|
|
215
254
|
skipSourceProbe: options?.skipSourceProbe,
|
|
255
|
+
activeGroupElement:
|
|
256
|
+
options && "activeGroupElement" in options
|
|
257
|
+
? options.activeGroupElement
|
|
258
|
+
: activeGroupElementRef.current,
|
|
216
259
|
projectId,
|
|
217
260
|
});
|
|
218
261
|
},
|
|
@@ -224,7 +267,11 @@ export function useDomSelection({
|
|
|
224
267
|
async (
|
|
225
268
|
clientX: number,
|
|
226
269
|
clientY: number,
|
|
227
|
-
options?: {
|
|
270
|
+
options?: {
|
|
271
|
+
preferClipAncestor?: boolean;
|
|
272
|
+
skipSourceProbe?: boolean;
|
|
273
|
+
activeGroupElement?: HTMLElement | null;
|
|
274
|
+
},
|
|
228
275
|
) => {
|
|
229
276
|
const iframe = previewIframeRef.current;
|
|
230
277
|
if (!iframe || captionEditMode) return null;
|
|
@@ -235,10 +282,19 @@ export function useDomSelection({
|
|
|
235
282
|
}
|
|
236
283
|
const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
|
|
237
284
|
if (!target) return null;
|
|
238
|
-
return buildDomSelectionFromTarget(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
285
|
+
return buildDomSelectionFromTarget(
|
|
286
|
+
target,
|
|
287
|
+
options && "activeGroupElement" in options
|
|
288
|
+
? {
|
|
289
|
+
preferClipAncestor: options.preferClipAncestor,
|
|
290
|
+
skipSourceProbe: options.skipSourceProbe,
|
|
291
|
+
activeGroupElement: options.activeGroupElement,
|
|
292
|
+
}
|
|
293
|
+
: {
|
|
294
|
+
preferClipAncestor: options?.preferClipAncestor,
|
|
295
|
+
skipSourceProbe: options?.skipSourceProbe,
|
|
296
|
+
},
|
|
297
|
+
);
|
|
242
298
|
},
|
|
243
299
|
[activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
|
|
244
300
|
);
|
|
@@ -445,7 +501,12 @@ export function useDomSelection({
|
|
|
445
501
|
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
|
|
446
502
|
}
|
|
447
503
|
} else {
|
|
448
|
-
|
|
504
|
+
// Dedupe by target: under select-as-unit several marquee'd members collapse
|
|
505
|
+
// to the same group, which must count as one selection, not many duplicates.
|
|
506
|
+
nextGroup = [];
|
|
507
|
+
for (const s of selections) {
|
|
508
|
+
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup.push(s);
|
|
509
|
+
}
|
|
449
510
|
}
|
|
450
511
|
const nextSelection = additive && current ? current : selections[0];
|
|
451
512
|
domEditSelectionRef.current = nextSelection;
|
|
@@ -478,13 +539,16 @@ export function useDomSelection({
|
|
|
478
539
|
domEditSelection,
|
|
479
540
|
domEditGroupSelections,
|
|
480
541
|
domEditHoverSelection,
|
|
542
|
+
activeGroupElement,
|
|
481
543
|
// Refs
|
|
482
544
|
domEditSelectionRef,
|
|
483
545
|
domEditGroupSelectionsRef,
|
|
484
546
|
domEditHoverSelectionRef,
|
|
547
|
+
activeGroupElementRef,
|
|
485
548
|
// State setters
|
|
486
549
|
setDomEditSelection,
|
|
487
550
|
setDomEditGroupSelections,
|
|
551
|
+
setActiveGroupElement,
|
|
488
552
|
// Callbacks
|
|
489
553
|
applyDomSelection,
|
|
490
554
|
clearDomSelection,
|
|
@@ -167,4 +167,39 @@ describe("promoteSetToKeyframes — auto endpoint", () => {
|
|
|
167
167
|
expect(kfs[1].percentage).toBe(100);
|
|
168
168
|
expect(kfs[1].auto).toBeUndefined();
|
|
169
169
|
});
|
|
170
|
+
|
|
171
|
+
it("playhead AT the set (t <= setStart) drops a single 0% keyframe, not a no-op", async () => {
|
|
172
|
+
// Regression: enabling keyframes on a `gsap.set` element at t=0 (set start 0)
|
|
173
|
+
// returned early (`t <= setStart`) → nothing created. Must give a 0% keyframe.
|
|
174
|
+
let committed: Record<string, unknown> | undefined;
|
|
175
|
+
const session = {
|
|
176
|
+
commitMutation: async (mutation: Record<string, unknown>) => {
|
|
177
|
+
committed = mutation;
|
|
178
|
+
},
|
|
179
|
+
} as unknown as EnableKeyframesSession;
|
|
180
|
+
const sel = {
|
|
181
|
+
id: "box",
|
|
182
|
+
selector: "#box",
|
|
183
|
+
sourceFile: "index.html",
|
|
184
|
+
element: { isConnected: true } as unknown as HTMLElement,
|
|
185
|
+
} as unknown as DomEditSelection;
|
|
186
|
+
const iframe = {
|
|
187
|
+
contentWindow: { gsap: { getProperty: () => -1091 } },
|
|
188
|
+
} as unknown as HTMLIFrameElement;
|
|
189
|
+
const setAnim = anim({
|
|
190
|
+
id: "#box-set-0-position",
|
|
191
|
+
targetSelector: "#box",
|
|
192
|
+
method: "set",
|
|
193
|
+
global: true,
|
|
194
|
+
resolvedStart: 0,
|
|
195
|
+
properties: { x: -1091, y: 280 },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await promoteSetToKeyframes(session, sel, setAnim, 0, iframe);
|
|
199
|
+
|
|
200
|
+
const kfs = committed?.keyframes as Array<{ percentage: number }>;
|
|
201
|
+
expect(committed?.type).toBe("replace-with-keyframes");
|
|
202
|
+
expect(kfs).toHaveLength(1);
|
|
203
|
+
expect(kfs[0].percentage).toBe(0);
|
|
204
|
+
});
|
|
170
205
|
});
|
|
@@ -253,7 +253,35 @@ export async function promoteSetToKeyframes(
|
|
|
253
253
|
): Promise<void> {
|
|
254
254
|
const selector = selectorFromSelection(sel);
|
|
255
255
|
const setStart = resolveTweenStart(setAnim) ?? 0;
|
|
256
|
-
if (!selector || !session.commitMutation
|
|
256
|
+
if (!selector || !session.commitMutation) return;
|
|
257
|
+
// Playhead at or before the set → there's no forward range to promote into.
|
|
258
|
+
// Instead of doing nothing (which read as "can't add a keyframe at 0"), replace
|
|
259
|
+
// the set with a single keyframe at the playhead holding its value, matching the
|
|
260
|
+
// no-animation branch: one diamond the user can build motion from.
|
|
261
|
+
if (t <= setStart) {
|
|
262
|
+
const position = readElementPosition(iframe, sel, setAnim);
|
|
263
|
+
if (Object.keys(position).length === 0) {
|
|
264
|
+
for (const key of Object.keys(setAnim.properties ?? {})) {
|
|
265
|
+
const held = setAnim.properties?.[key];
|
|
266
|
+
if (typeof held === "number") position[key] = held;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (Object.keys(position).length === 0) return;
|
|
270
|
+
const range = resolveNewTweenRange(sel.dataAttributes?.start, sel.dataAttributes?.duration, t);
|
|
271
|
+
await session.commitMutation(
|
|
272
|
+
{
|
|
273
|
+
type: "replace-with-keyframes",
|
|
274
|
+
animationId: setAnim.id,
|
|
275
|
+
targetSelector: selector,
|
|
276
|
+
position: roundTo3(range.start),
|
|
277
|
+
duration: roundTo3(range.duration),
|
|
278
|
+
keyframes: [{ percentage: 0, properties: position }],
|
|
279
|
+
ease: setAnim.ease,
|
|
280
|
+
},
|
|
281
|
+
{ label: "Enable keyframes", softReload: true },
|
|
282
|
+
);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
257
285
|
const endPosition = readElementPosition(iframe, sel, setAnim);
|
|
258
286
|
if (Object.keys(endPosition).length === 0) return;
|
|
259
287
|
const startPosition: Record<string, number> = {};
|