@hyperframes/studio 0.7.15 → 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-CTXExGxO.js → index-D2SMcOdY.js} +1 -1
- package/dist/assets/index-DmkOvZns.css +1 -0
- package/dist/assets/{index-Db9KG66z.js → index-sCOjSz40.js} +1 -1
- package/dist/{chunk-UUEDZ4UJ.js → chunk-SBGXX7WY.js} +52 -22
- package/dist/chunk-SBGXX7WY.js.map +1 -0
- package/dist/{domEditingLayers-NWCBED4R.js → domEditingLayers-VZMLL4AP.js} +2 -4
- package/dist/index.d.ts +18 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3157 -2808
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- 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 +3 -0
- package/src/components/editor/manualOffsetDrag.ts +23 -2
- package/src/components/editor/propertyPanel3dTransform.tsx +70 -30
- package/src/components/editor/propertyPanelHelpers.ts +2 -0
- 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/studioPreviewHelpers.ts +37 -0
- package/dist/assets/index-B7VWFKy1.js +0 -375
- package/dist/assets/index-svYFaNuq.css +0 -1
- package/dist/chunk-UUEDZ4UJ.js.map +0 -1
- package/src/components/editor/keyframeMove.test.ts +0 -101
- package/src/components/editor/keyframeMove.ts +0 -151
- /package/dist/{domEditingLayers-NWCBED4R.js.map → domEditingLayers-VZMLL4AP.js.map} +0 -0
|
@@ -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> = {};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
3
|
+
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
|
|
4
|
+
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
6
|
+
|
|
7
|
+
interface RecordEditInput {
|
|
8
|
+
label: string;
|
|
9
|
+
kind: EditHistoryKind;
|
|
10
|
+
coalesceKey?: string;
|
|
11
|
+
files: Record<string, { before: string; after: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UseGroupCommitsParams {
|
|
15
|
+
activeCompPath: string | null;
|
|
16
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
17
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
18
|
+
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
19
|
+
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
|
20
|
+
projectIdRef: React.MutableRefObject<string | null>;
|
|
21
|
+
reloadPreview: () => void;
|
|
22
|
+
clearDomSelection: () => void;
|
|
23
|
+
/** Resync the SDK session after a server-side write (the wrapper/unwrap changes
|
|
24
|
+
* structure the in-memory doc doesn't know about). */
|
|
25
|
+
forceReloadSdkSession?: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface PatchTarget {
|
|
29
|
+
id?: string | null;
|
|
30
|
+
hfId?: string;
|
|
31
|
+
selector?: string;
|
|
32
|
+
selectorIndex?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface GroupGeometry {
|
|
36
|
+
bbox: { left: number; top: number; width: number; height: number };
|
|
37
|
+
targets: PatchTarget[];
|
|
38
|
+
rebases: Array<{ target: PatchTarget; left: number; top: number }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Wrapper sits at the members' bounding box top-left; each member is rebased so
|
|
42
|
+
// its absolute position is unchanged. offsetLeft/Top are layout coordinates in
|
|
43
|
+
// composition space (transforms excluded), exactly the space the rebase formula
|
|
44
|
+
// `left_new = left_old - W.left` operates in — GSAP x/y and offset vars are
|
|
45
|
+
// transform deltas and stay correct without adjustment.
|
|
46
|
+
function computeGroupGeometry(members: DomEditSelection[]): GroupGeometry {
|
|
47
|
+
const boxes = members.map((m) => ({
|
|
48
|
+
target: buildDomEditPatchTarget(m),
|
|
49
|
+
left: m.element.offsetLeft,
|
|
50
|
+
top: m.element.offsetTop,
|
|
51
|
+
right: m.element.offsetLeft + m.element.offsetWidth,
|
|
52
|
+
bottom: m.element.offsetTop + m.element.offsetHeight,
|
|
53
|
+
}));
|
|
54
|
+
const left = Math.min(...boxes.map((b) => b.left));
|
|
55
|
+
const top = Math.min(...boxes.map((b) => b.top));
|
|
56
|
+
const width = Math.max(...boxes.map((b) => b.right)) - left;
|
|
57
|
+
const height = Math.max(...boxes.map((b) => b.bottom)) - top;
|
|
58
|
+
return {
|
|
59
|
+
bbox: { left, top, width, height },
|
|
60
|
+
targets: boxes.map((b) => b.target),
|
|
61
|
+
rebases: boxes.map((b) => ({ target: b.target, left: b.left - left, top: b.top - top })),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Shared read → mutate-route → save-with-history → reload pipeline for both
|
|
66
|
+
// wrap (group) and unwrap (ungroup). Mirrors the structural-mutation pattern in
|
|
67
|
+
// useElementLifecycleOps (delete). Returns the route's JSON, or throws.
|
|
68
|
+
async function commitStructuralMutation(
|
|
69
|
+
pid: string,
|
|
70
|
+
targetPath: string,
|
|
71
|
+
route: "wrap-elements" | "unwrap-elements",
|
|
72
|
+
body: unknown,
|
|
73
|
+
label: string,
|
|
74
|
+
deps: Pick<
|
|
75
|
+
UseGroupCommitsParams,
|
|
76
|
+
| "writeProjectFile"
|
|
77
|
+
| "editHistory"
|
|
78
|
+
| "domEditSaveTimestampRef"
|
|
79
|
+
| "clearDomSelection"
|
|
80
|
+
| "forceReloadSdkSession"
|
|
81
|
+
| "reloadPreview"
|
|
82
|
+
>,
|
|
83
|
+
): Promise<{ content?: string; groupId?: string }> {
|
|
84
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
|
|
87
|
+
}
|
|
88
|
+
const data = (await response.json()) as { content?: string };
|
|
89
|
+
const originalContent = data.content;
|
|
90
|
+
if (typeof originalContent !== "string") {
|
|
91
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
deps.domEditSaveTimestampRef.current = Date.now();
|
|
95
|
+
const mutateResponse = await fetch(
|
|
96
|
+
`/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`,
|
|
97
|
+
{
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "Content-Type": "application/json" },
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
if (!mutateResponse.ok) {
|
|
104
|
+
const errBody = (await mutateResponse.json().catch(() => null)) as { error?: string } | null;
|
|
105
|
+
throw new Error(errBody?.error ?? `Failed to ${label.toLowerCase()} in ${targetPath}`);
|
|
106
|
+
}
|
|
107
|
+
const mutateData = (await mutateResponse.json()) as { content?: string; groupId?: string };
|
|
108
|
+
const patchedContent =
|
|
109
|
+
typeof mutateData.content === "string" ? mutateData.content : originalContent;
|
|
110
|
+
|
|
111
|
+
await saveProjectFilesWithHistory({
|
|
112
|
+
projectId: pid,
|
|
113
|
+
label,
|
|
114
|
+
kind: "manual",
|
|
115
|
+
files: { [targetPath]: patchedContent },
|
|
116
|
+
readFile: async () => originalContent,
|
|
117
|
+
writeFile: deps.writeProjectFile,
|
|
118
|
+
recordEdit: deps.editHistory.recordEdit,
|
|
119
|
+
});
|
|
120
|
+
deps.clearDomSelection();
|
|
121
|
+
deps.forceReloadSdkSession?.();
|
|
122
|
+
deps.reloadPreview();
|
|
123
|
+
return mutateData;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
127
|
+
const { activeCompPath, showToast, projectIdRef } = params;
|
|
128
|
+
|
|
129
|
+
const groupSelection = useCallback(
|
|
130
|
+
async (members: DomEditSelection[]): Promise<string | null> => {
|
|
131
|
+
const pid = projectIdRef.current;
|
|
132
|
+
if (!pid || members.length === 0) return null;
|
|
133
|
+
|
|
134
|
+
// All members must live in the same source file — the wrapper is one node
|
|
135
|
+
// in one document. (Cross-file grouping is out of scope.)
|
|
136
|
+
const targetPath = members[0].sourceFile || activeCompPath || "index.html";
|
|
137
|
+
if (members.some((m) => (m.sourceFile || activeCompPath || "index.html") !== targetPath)) {
|
|
138
|
+
showToast("Can't group elements from different files", "error");
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Auto-name "Group N" by the count of existing groups in the document.
|
|
143
|
+
const doc = members[0].element.ownerDocument;
|
|
144
|
+
const groupId = `Group ${doc.querySelectorAll("[data-hf-group]").length + 1}`;
|
|
145
|
+
const { bbox, targets, rebases } = computeGroupGeometry(members);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const data = await commitStructuralMutation(
|
|
149
|
+
pid,
|
|
150
|
+
targetPath,
|
|
151
|
+
"wrap-elements",
|
|
152
|
+
{ targets, groupId, bbox, rebases },
|
|
153
|
+
"Group elements",
|
|
154
|
+
params,
|
|
155
|
+
);
|
|
156
|
+
return data.groupId ?? groupId;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
showToast(error instanceof Error ? error.message : "Failed to group elements", "error");
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
[activeCompPath, projectIdRef, showToast, params],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const ungroupSelection = useCallback(
|
|
166
|
+
async (group: DomEditSelection): Promise<void> => {
|
|
167
|
+
const pid = projectIdRef.current;
|
|
168
|
+
if (!pid) return;
|
|
169
|
+
const targetPath = group.sourceFile || activeCompPath || "index.html";
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
await commitStructuralMutation(
|
|
173
|
+
pid,
|
|
174
|
+
targetPath,
|
|
175
|
+
"unwrap-elements",
|
|
176
|
+
{ target: buildDomEditPatchTarget(group) },
|
|
177
|
+
"Ungroup elements",
|
|
178
|
+
params,
|
|
179
|
+
);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
showToast(error instanceof Error ? error.message : "Failed to ungroup elements", "error");
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
[activeCompPath, projectIdRef, showToast, params],
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return { groupSelection, ungroupSelection };
|
|
188
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useCallback, useRef } from "react";
|
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
3
|
import { usePlayerStore } from "../player";
|
|
4
4
|
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
5
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Thin useCallback wrappers that guard on `domEditSelection` before
|
|
@@ -136,6 +137,7 @@ export function useGsapSelectionHandlers({
|
|
|
136
137
|
(targetSelector: string) => {
|
|
137
138
|
const sel = domEditSelection ?? lastSelectionRef.current;
|
|
138
139
|
if (!sel) return;
|
|
140
|
+
trackStudioEvent("keyframe", { action: "delete_all" });
|
|
139
141
|
deleteAllForSelector(sel, targetSelector);
|
|
140
142
|
},
|
|
141
143
|
[domEditSelection, deleteAllForSelector],
|
|
@@ -206,6 +208,7 @@ export function useGsapSelectionHandlers({
|
|
|
206
208
|
) => {
|
|
207
209
|
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
|
208
210
|
if (!sel) return;
|
|
211
|
+
trackStudioEvent("keyframe", { action: "add", property });
|
|
209
212
|
addKeyframe(sel, animId, percentage, property, value);
|
|
210
213
|
},
|
|
211
214
|
[domEditSelection, addKeyframe],
|
|
@@ -224,6 +227,7 @@ export function useGsapSelectionHandlers({
|
|
|
224
227
|
(animId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
|
|
225
228
|
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
|
226
229
|
if (!sel) return;
|
|
230
|
+
trackStudioEvent("keyframe", { action: "remove" });
|
|
227
231
|
removeKeyframe(sel, animId, percentage);
|
|
228
232
|
},
|
|
229
233
|
[domEditSelection, removeKeyframe],
|