@hyperframes/studio 0.7.6 → 0.7.7
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-B_NnmU6q.js → index-B2YXvFxf.js} +1 -1
- package/dist/assets/index-BSkUuN8g.css +1 -0
- package/dist/assets/{index-ysftPins.js → index-BeRh2hMe.js} +195 -194
- package/dist/assets/{index-DsBhGFPe.js → index-BoASKOeE.js} +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.html +2 -2
- package/dist/index.js +1765 -1193
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/editor/AnimationCard.tsx +6 -0
- package/src/components/editor/EaseCurveSection.tsx +175 -99
- package/src/components/editor/GsapAnimationSection.tsx +2 -0
- package/src/components/editor/KeyframeEaseList.tsx +67 -3
- package/src/components/editor/PropertyPanel.tsx +11 -2
- package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
- package/src/components/editor/gsapAnimationConstants.ts +6 -35
- package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
- package/src/components/editor/propertyPanelHelpers.ts +6 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
- package/src/components/editor/useMotionPathData.ts +2 -1
- package/src/components/sidebar/AssetContextMenu.tsx +97 -0
- package/src/components/sidebar/AssetsTab.tsx +542 -254
- package/src/components/sidebar/assetHelpers.ts +40 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +101 -0
- package/src/hooks/gsapRuntimeBridge.ts +22 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
- package/src/hooks/useDomEditSession.ts +20 -0
- package/src/hooks/useDomEditWiring.ts +3 -0
- package/src/hooks/useGestureCommit.ts +0 -4
- package/src/hooks/useGsapAwareEditing.ts +0 -4
- package/src/hooks/useGsapScriptCommits.ts +0 -12
- package/src/hooks/useGsapTweenCache.test.ts +45 -1
- package/src/hooks/useGsapTweenCache.ts +119 -38
- package/src/hooks/useMusicBeatAnalysis.ts +36 -21
- package/src/hooks/useRazorSplit.ts +0 -3
- package/dist/assets/index-wJZf6FK3.css +0 -1
- package/src/utils/editDebugLog.ts +0 -9
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes";
|
|
2
|
+
|
|
3
|
+
export type MediaCategory = "audio" | "images" | "video" | "fonts";
|
|
4
|
+
|
|
5
|
+
export function getCategory(path: string): MediaCategory | null {
|
|
6
|
+
if (AUDIO_EXT.test(path)) return "audio";
|
|
7
|
+
if (IMAGE_EXT.test(path)) return "images";
|
|
8
|
+
if (VIDEO_EXT.test(path)) return "video";
|
|
9
|
+
if (FONT_EXT.test(path)) return "fonts";
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getAudioSubtype(path: string): string {
|
|
14
|
+
const lower = path.toLowerCase();
|
|
15
|
+
if (lower.includes("/bgm/") || lower.includes("/music/")) return "BGM";
|
|
16
|
+
if (lower.includes("/sfx/") || lower.includes("/sound")) return "SFX";
|
|
17
|
+
if (lower.includes("/voice/") || lower.includes("/narrat")) return "Voice";
|
|
18
|
+
return "Audio";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function basename(path: string): string {
|
|
22
|
+
const name = path.split("/").pop() ?? path;
|
|
23
|
+
const dot = name.lastIndexOf(".");
|
|
24
|
+
return dot > 0 ? name.slice(0, dot) : name;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ext(path: string): string {
|
|
28
|
+
const name = path.split("/").pop() ?? path;
|
|
29
|
+
const dot = name.lastIndexOf(".");
|
|
30
|
+
return dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const CATEGORY_LABELS: Record<MediaCategory, string> = {
|
|
34
|
+
audio: "Audio",
|
|
35
|
+
images: "Images",
|
|
36
|
+
video: "Video",
|
|
37
|
+
fonts: "Fonts",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const FILTER_ORDER: MediaCategory[] = ["audio", "images", "video", "fonts"];
|
|
@@ -63,6 +63,7 @@ export interface DomEditActionsValue extends Pick<
|
|
|
63
63
|
| "commitMutation"
|
|
64
64
|
| "applyMarqueeSelection"
|
|
65
65
|
| "handleUpdateKeyframeEase"
|
|
66
|
+
| "handleSetAllKeyframeEases"
|
|
66
67
|
> {}
|
|
67
68
|
|
|
68
69
|
export interface DomEditSelectionValue extends Pick<
|
|
@@ -171,6 +172,7 @@ export function DomEditProvider({
|
|
|
171
172
|
commitMutation,
|
|
172
173
|
applyMarqueeSelection,
|
|
173
174
|
handleUpdateKeyframeEase,
|
|
175
|
+
handleSetAllKeyframeEases,
|
|
174
176
|
},
|
|
175
177
|
children,
|
|
176
178
|
}: {
|
|
@@ -244,6 +246,7 @@ export function DomEditProvider({
|
|
|
244
246
|
commitMutation: stableCommitMutation,
|
|
245
247
|
applyMarqueeSelection,
|
|
246
248
|
handleUpdateKeyframeEase,
|
|
249
|
+
handleSetAllKeyframeEases,
|
|
247
250
|
}),
|
|
248
251
|
[
|
|
249
252
|
handleTimelineElementSelect,
|
|
@@ -303,6 +306,7 @@ export function DomEditProvider({
|
|
|
303
306
|
stableCommitMutation,
|
|
304
307
|
applyMarqueeSelection,
|
|
305
308
|
handleUpdateKeyframeEase,
|
|
309
|
+
handleSetAllKeyframeEases,
|
|
306
310
|
],
|
|
307
311
|
);
|
|
308
312
|
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
|
+
import {
|
|
8
|
+
STUDIO_ORIGINAL_WIDTH_ATTR,
|
|
9
|
+
STUDIO_ORIGINAL_HEIGHT_ATTR,
|
|
10
|
+
} from "../components/editor/manualEditsTypes";
|
|
7
11
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
12
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
9
13
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
@@ -342,6 +346,103 @@ export async function commitStaticGsapSize(
|
|
|
342
346
|
);
|
|
343
347
|
}
|
|
344
348
|
|
|
349
|
+
/** Rounded `n` when it's a positive finite number, else `fallback`. */
|
|
350
|
+
function positiveOr(n: number, fallback: number): number {
|
|
351
|
+
return Number.isFinite(n) && n > 0 ? Math.round(n) : fallback;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Prior size for a keyframed resize: the existing global set's value, else the
|
|
356
|
+
* element's pre-resize size (the draft saved it on the element before mutating
|
|
357
|
+
* el.style.width/height). Falls back to the new size when neither is available.
|
|
358
|
+
*/
|
|
359
|
+
function resolvePriorSize(
|
|
360
|
+
sizeSet: GsapAnimation | null,
|
|
361
|
+
el: Element | null | undefined,
|
|
362
|
+
fallbackW: number,
|
|
363
|
+
fallbackH: number,
|
|
364
|
+
): { width: number; height: number } {
|
|
365
|
+
if (sizeSet) {
|
|
366
|
+
return {
|
|
367
|
+
width: positiveOr(Number(sizeSet.properties.width), fallbackW),
|
|
368
|
+
height: positiveOr(Number(sizeSet.properties.height), fallbackH),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
const ow = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR) ?? "");
|
|
372
|
+
const oh = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR) ?? "");
|
|
373
|
+
return { width: positiveOr(ow, fallbackW), height: positiveOr(oh, fallbackH) };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Resize an *animated* element by keyframing its size at the current playhead,
|
|
378
|
+
* instead of a global `gsap.set` hold. Builds a width/height keyframe tween
|
|
379
|
+
* aligned to the element's existing animation: every base keyframe keeps the
|
|
380
|
+
* prior size, only the keyframe nearest the playhead gets the new size — so
|
|
381
|
+
* resizing one keyframe leaves the others unchanged. Replaces any prior global
|
|
382
|
+
* size set. Returns false when there's no usable range (caller falls back to the
|
|
383
|
+
* static set).
|
|
384
|
+
*/
|
|
385
|
+
export async function commitKeyframedSizeFromResize(
|
|
386
|
+
selection: DomEditSelection,
|
|
387
|
+
size: { width: number; height: number },
|
|
388
|
+
selector: string,
|
|
389
|
+
sizeSet: GsapAnimation | null,
|
|
390
|
+
animatedTween: GsapAnimation,
|
|
391
|
+
callbacks: GsapDragCommitCallbacks,
|
|
392
|
+
): Promise<boolean> {
|
|
393
|
+
const ts = resolveTweenStart(animatedTween) ?? 0;
|
|
394
|
+
const td = resolveTweenDuration(animatedTween);
|
|
395
|
+
if (!(td > 0)) return false;
|
|
396
|
+
|
|
397
|
+
const newW = Math.round(size.width);
|
|
398
|
+
const newH = Math.round(size.height);
|
|
399
|
+
const prior = resolvePriorSize(sizeSet, selection.element, newW, newH);
|
|
400
|
+
|
|
401
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
402
|
+
const pct = Math.max(0, Math.min(100, Math.round(((ct - ts) / td) * 1000) / 10));
|
|
403
|
+
|
|
404
|
+
// Base keyframe percentages from the animated tween (flat tween → 0 & 100),
|
|
405
|
+
// plus the endpoints and the playhead. Each keeps the prior size except the
|
|
406
|
+
// keyframe at the playhead, which gets the new size.
|
|
407
|
+
const pcts = new Set<number>(
|
|
408
|
+
animatedTween.keyframes?.keyframes.map((k) => k.percentage) ?? [0, 100],
|
|
409
|
+
);
|
|
410
|
+
pcts.add(0);
|
|
411
|
+
pcts.add(100);
|
|
412
|
+
pcts.add(pct);
|
|
413
|
+
const keyframes = Array.from(pcts)
|
|
414
|
+
.sort((a, b) => a - b)
|
|
415
|
+
.map((p) => ({
|
|
416
|
+
percentage: p,
|
|
417
|
+
properties: Math.abs(p - pct) < 0.05 ? { width: newW, height: newH } : { ...prior },
|
|
418
|
+
}));
|
|
419
|
+
|
|
420
|
+
// Add the size keyframe tween FIRST, then delete the old global hold. The two
|
|
421
|
+
// commits aren't transactional, so ordering matters: if the delete fails the
|
|
422
|
+
// size is preserved (animated, recoverable) rather than lost. Only the last
|
|
423
|
+
// commit triggers the reload.
|
|
424
|
+
const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`;
|
|
425
|
+
await callbacks.commitMutation(
|
|
426
|
+
selection,
|
|
427
|
+
{
|
|
428
|
+
type: "add-with-keyframes",
|
|
429
|
+
targetSelector: selector,
|
|
430
|
+
position: roundTo3(ts),
|
|
431
|
+
duration: roundTo3(td),
|
|
432
|
+
keyframes,
|
|
433
|
+
},
|
|
434
|
+
sizeSet ? { label: addLabel, skipReload: true } : { label: addLabel, softReload: true },
|
|
435
|
+
);
|
|
436
|
+
if (sizeSet) {
|
|
437
|
+
await callbacks.commitMutation(
|
|
438
|
+
selection,
|
|
439
|
+
{ type: "delete", animationId: sizeSet.id },
|
|
440
|
+
{ label: "Resize layer", softReload: true },
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
|
|
345
446
|
export { findSizeSetAnimation };
|
|
346
447
|
|
|
347
448
|
// ── Whole-path offset (plain drag on animated element) ──────────────────
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
commitStaticGsapPosition,
|
|
19
19
|
commitStaticGsapRotation,
|
|
20
20
|
commitStaticGsapSize,
|
|
21
|
+
commitKeyframedSizeFromResize,
|
|
21
22
|
commitWholePathOffset,
|
|
22
23
|
computeCurrentPercentage,
|
|
23
24
|
findPositionSetAnimation,
|
|
@@ -329,6 +330,27 @@ export async function tryGsapResizeIntercept(
|
|
|
329
330
|
const sel = selectorFromSelection(selection);
|
|
330
331
|
if (!sel) return false;
|
|
331
332
|
const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
|
|
333
|
+
|
|
334
|
+
// If the element is animated (has a real tween, not just a static size
|
|
335
|
+
// hold), keyframe the size at the playhead so other keyframes keep theirs —
|
|
336
|
+
// instead of a global set that resizes every frame.
|
|
337
|
+
if (resizeGroup === "size") {
|
|
338
|
+
const animatedTween = pickClosestToPlayhead(
|
|
339
|
+
animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0),
|
|
340
|
+
);
|
|
341
|
+
if (animatedTween) {
|
|
342
|
+
const handled = await commitKeyframedSizeFromResize(
|
|
343
|
+
selection,
|
|
344
|
+
size,
|
|
345
|
+
sel,
|
|
346
|
+
sizeSet,
|
|
347
|
+
animatedTween,
|
|
348
|
+
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
349
|
+
);
|
|
350
|
+
if (handled) return true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
332
354
|
await commitStaticGsapSize(selection, size, sel, sizeSet, {
|
|
333
355
|
commitMutation,
|
|
334
356
|
fetchAnimations: fetchFallbackAnimations,
|
|
@@ -101,6 +101,53 @@ describe("readRuntimeKeyframes — multiple tweens pick the one under the playhe
|
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
describe("readRuntimeKeyframes — requireChannels keeps the motion path on the positional tween", () => {
|
|
105
|
+
// An animated element with a position tween AND a (longer) size tween, both at
|
|
106
|
+
// start 0 — the shape the per-keyframe size resize produces.
|
|
107
|
+
const el = { id: "dot-a" };
|
|
108
|
+
const positionTween = {
|
|
109
|
+
targets: () => [el],
|
|
110
|
+
vars: {
|
|
111
|
+
keyframes: [
|
|
112
|
+
{ x: -1252, y: -394 },
|
|
113
|
+
{ x: 244, y: -316 },
|
|
114
|
+
],
|
|
115
|
+
duration: 2.4,
|
|
116
|
+
},
|
|
117
|
+
duration: () => 2.4,
|
|
118
|
+
startTime: () => 0, // range [0, 2.4]
|
|
119
|
+
};
|
|
120
|
+
const sizeTween = {
|
|
121
|
+
targets: () => [el],
|
|
122
|
+
vars: {
|
|
123
|
+
keyframes: [
|
|
124
|
+
{ width: 120, height: 96 },
|
|
125
|
+
{ width: 325, height: 300 },
|
|
126
|
+
],
|
|
127
|
+
duration: 3.243,
|
|
128
|
+
},
|
|
129
|
+
duration: () => 3.243,
|
|
130
|
+
startTime: () => 0, // range [0, 3.243] — outlives the position tween
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
it("playhead past the position range (2.4–3.243s) still returns the position tween, not size", () => {
|
|
134
|
+
// Without the filter the size tween (the only one containing the playhead)
|
|
135
|
+
// would win and blank the path.
|
|
136
|
+
const read = readRuntimeKeyframes(
|
|
137
|
+
fakeIframe(el, [positionTween, sizeTween], 3.0),
|
|
138
|
+
"#dot-a",
|
|
139
|
+
undefined,
|
|
140
|
+
["x", "y"],
|
|
141
|
+
);
|
|
142
|
+
expect(read?.keyframes[0]?.properties).toHaveProperty("x");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("without requireChannels the size tween shadows the path past the position range (documents the bug)", () => {
|
|
146
|
+
const read = readRuntimeKeyframes(fakeIframe(el, [positionTween, sizeTween], 3.0), "#dot-a");
|
|
147
|
+
expect(read?.keyframes[0]?.properties).toHaveProperty("width");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
104
151
|
describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-parse guard)", () => {
|
|
105
152
|
const el = { id: "puck-b" };
|
|
106
153
|
const holdSet = {
|
|
@@ -252,14 +252,26 @@ export function resolveRuntimeTween(
|
|
|
252
252
|
return channelMatch ?? first;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/** Whether a read carries at least one of `channels` as a keyframe property. */
|
|
256
|
+
function readCarriesChannel(read: ReadTween, channels: string[]): boolean {
|
|
257
|
+
return read.keyframes.some((kf) => channels.some((c) => kf.properties[c] != null));
|
|
258
|
+
}
|
|
259
|
+
|
|
255
260
|
/**
|
|
256
261
|
* Read keyframes (incl. motionPath arcs) for one selector from the live timeline.
|
|
257
262
|
* Returns tween-relative percentages; callers convert to clip-relative.
|
|
263
|
+
*
|
|
264
|
+
* `requireChannels` restricts the scan to tweens whose read carries one of those
|
|
265
|
+
* properties — e.g. the motion-path overlay passes `["x","y"]` so it never picks
|
|
266
|
+
* up a co-located size/scale tween (which has no x/y and would blank the path
|
|
267
|
+
* whenever the playhead sits in that tween's range but outside the position
|
|
268
|
+
* tween's). Omitted → any keyframed tween qualifies (back-compat).
|
|
258
269
|
*/
|
|
259
270
|
export function readRuntimeKeyframes(
|
|
260
271
|
iframe: HTMLIFrameElement | null,
|
|
261
272
|
selector: string,
|
|
262
273
|
compositionId?: string,
|
|
274
|
+
requireChannels?: string[],
|
|
263
275
|
): ReadTween | null {
|
|
264
276
|
const timelines = timelinesOf(iframe);
|
|
265
277
|
if (!timelines) return null;
|
|
@@ -299,6 +311,7 @@ export function readRuntimeKeyframes(
|
|
|
299
311
|
if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
|
|
300
312
|
const read = readTween(tween.vars);
|
|
301
313
|
if (!read) continue;
|
|
314
|
+
if (requireChannels && !readCarriesChannel(read, requireChannels)) continue;
|
|
302
315
|
if (firstRead === null) firstRead = read;
|
|
303
316
|
// Prefer the tween whose [start, start+dur] contains the playhead.
|
|
304
317
|
if (now != null) {
|
|
@@ -410,6 +410,25 @@ export function useDomEditSession({
|
|
|
410
410
|
[gsapCommitMutation, domEditSelectionRef],
|
|
411
411
|
);
|
|
412
412
|
|
|
413
|
+
// Apply one ease to every segment at once (AE select-all + F9): set easeEach
|
|
414
|
+
// and strip per-keyframe overrides in a single mutation.
|
|
415
|
+
const handleSetAllKeyframeEases = useCallback(
|
|
416
|
+
(animationId: string, ease: string) => {
|
|
417
|
+
const sel = domEditSelectionRef.current;
|
|
418
|
+
if (!sel) return;
|
|
419
|
+
gsapCommitMutation(
|
|
420
|
+
sel,
|
|
421
|
+
{
|
|
422
|
+
type: "update-meta",
|
|
423
|
+
animationId,
|
|
424
|
+
updates: { easeEach: ease, resetKeyframeEases: true },
|
|
425
|
+
},
|
|
426
|
+
{ label: "Apply ease to all segments", softReload: true },
|
|
427
|
+
);
|
|
428
|
+
},
|
|
429
|
+
[gsapCommitMutation, domEditSelectionRef],
|
|
430
|
+
);
|
|
431
|
+
|
|
413
432
|
return {
|
|
414
433
|
// State
|
|
415
434
|
domEditSelection,
|
|
@@ -477,6 +496,7 @@ export function useDomEditSession({
|
|
|
477
496
|
handleGsapRemoveAllKeyframes,
|
|
478
497
|
handleResetSelectedElementKeyframes,
|
|
479
498
|
handleUpdateKeyframeEase,
|
|
499
|
+
handleSetAllKeyframeEases,
|
|
480
500
|
commitAnimatedProperty,
|
|
481
501
|
handleSetArcPath,
|
|
482
502
|
handleUpdateArcSegment,
|
|
@@ -197,6 +197,9 @@ export function useDomEditWiring({
|
|
|
197
197
|
? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
|
|
198
198
|
: null,
|
|
199
199
|
gsapCacheVersion,
|
|
200
|
+
// Pass the preview iframe so class/selector tweens (e.g. `.dot`) resolve to
|
|
201
|
+
// the live element and surface in the inspector — not just by #id match.
|
|
202
|
+
previewIframeRef,
|
|
200
203
|
);
|
|
201
204
|
|
|
202
205
|
// ── Telemetry & fallback ──
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Extracted from App.tsx to keep file sizes under the 600-line limit.
|
|
4
4
|
*/
|
|
5
5
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
6
|
-
import { editLog } from "../utils/editDebugLog";
|
|
7
6
|
import { useGestureRecording } from "./useGestureRecording";
|
|
8
7
|
import { simplifyGestureSamples } from "../utils/rdpSimplify";
|
|
9
8
|
import { fitEasesFromVelocity } from "../utils/velocityEaseFitter";
|
|
@@ -294,9 +293,6 @@ export function useGestureCommit({
|
|
|
294
293
|
|
|
295
294
|
// fallow-ignore-next-line complexity
|
|
296
295
|
const handleToggleRecording = useCallback(() => {
|
|
297
|
-
editLog("gesture", gestureStateRef.current === "recording" ? "stop" : "start", {
|
|
298
|
-
id: domEditSessionRef.current.domEditSelection?.id,
|
|
299
|
-
});
|
|
300
296
|
if (gestureStateRef.current === "recording") {
|
|
301
297
|
void stopAndCommitRecording();
|
|
302
298
|
return;
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* from the rest of the editing orchestration.
|
|
9
9
|
*/
|
|
10
10
|
import { useCallback } from "react";
|
|
11
|
-
import { editLog } from "../utils/editDebugLog";
|
|
12
11
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
13
12
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
14
13
|
import {
|
|
@@ -97,7 +96,6 @@ export function useGsapAwareEditing({
|
|
|
97
96
|
next: { x: number; y: number },
|
|
98
97
|
modifiers?: { altKey?: boolean },
|
|
99
98
|
) => {
|
|
100
|
-
editLog("manual-drag:move", { id: selection.id, next, altKey: modifiers?.altKey });
|
|
101
99
|
if (gsapCommitMutation) {
|
|
102
100
|
try {
|
|
103
101
|
await tryGsapDragIntercept(
|
|
@@ -126,7 +124,6 @@ export function useGsapAwareEditing({
|
|
|
126
124
|
|
|
127
125
|
const handleGsapAwareBoxSizeCommit = useCallback(
|
|
128
126
|
async (selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
129
|
-
editLog("manual-drag:resize", { id: selection.id, next });
|
|
130
127
|
if (gsapCommitMutation) {
|
|
131
128
|
try {
|
|
132
129
|
const handled = await tryGsapResizeIntercept(
|
|
@@ -157,7 +154,6 @@ export function useGsapAwareEditing({
|
|
|
157
154
|
|
|
158
155
|
const handleGsapAwareRotationCommit = useCallback(
|
|
159
156
|
async (selection: DomEditSelection, next: { angle: number }) => {
|
|
160
|
-
editLog("manual-drag:rotate", { id: selection.id, next });
|
|
161
157
|
if (gsapCommitMutation) {
|
|
162
158
|
try {
|
|
163
159
|
// Single source of truth for rotation too: tryGsapRotationIntercept handles
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useCallback, useMemo, useRef } from "react";
|
|
2
|
-
import { editLog } from "../utils/editDebugLog";
|
|
3
2
|
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
4
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
5
4
|
import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
|
|
@@ -130,12 +129,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
130
129
|
const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
131
130
|
const pid = projectIdRef.current;
|
|
132
131
|
if (!pid) return;
|
|
133
|
-
editLog("gsap-commit", {
|
|
134
|
-
type: mutation.type,
|
|
135
|
-
id: selection.id,
|
|
136
|
-
file: selection.sourceFile || activeCompPath,
|
|
137
|
-
label: options.label,
|
|
138
|
-
});
|
|
139
132
|
const unsafeFields = findUnsafeMutationValues(mutation);
|
|
140
133
|
if (unsafeFields.length > 0) {
|
|
141
134
|
showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
|
|
@@ -165,11 +158,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
165
158
|
options.beforeReload?.();
|
|
166
159
|
applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
|
|
167
160
|
onCacheInvalidate();
|
|
168
|
-
editLog("gsap-commit:done", {
|
|
169
|
-
type: mutation.type,
|
|
170
|
-
changed: result.changed,
|
|
171
|
-
instant: Boolean(options.instantPatch),
|
|
172
|
-
});
|
|
173
161
|
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
|
|
174
162
|
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
175
163
|
// commits to the SAME file (any op type, any animation) interleave server-side,
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
-
import { getAnimationsForElement } from "./useGsapTweenCache";
|
|
3
|
+
import { getAnimationsForElement, resolveSelectorElementIds } from "./useGsapTweenCache";
|
|
4
|
+
|
|
5
|
+
// Minimal Document stub: querySelectorAll returns the elements mapped per selector.
|
|
6
|
+
function fakeDoc(map: Record<string, { id: string }[]>): Document {
|
|
7
|
+
return {
|
|
8
|
+
querySelectorAll: (sel: string) => (map[sel] ?? []) as unknown as NodeListOf<Element>,
|
|
9
|
+
} as unknown as Document;
|
|
10
|
+
}
|
|
4
11
|
|
|
5
12
|
function anim(targetSelector: string): GsapAnimation {
|
|
6
13
|
return {
|
|
@@ -46,4 +53,41 @@ describe("getAnimationsForElement", () => {
|
|
|
46
53
|
expect(getAnimationsForElement(grouped, { selector: ".clock-hand" })).toHaveLength(1);
|
|
47
54
|
expect(getAnimationsForElement(grouped, { selector: ".unrelated" })).toHaveLength(0);
|
|
48
55
|
});
|
|
56
|
+
|
|
57
|
+
it("attributes a class tween to an id-selected element via element.matches", () => {
|
|
58
|
+
// gsap.from(".dot", {stagger}) — the element is selected by id (#dot-a), so
|
|
59
|
+
// its selector string never equals ".dot", but the live element matches it.
|
|
60
|
+
const dots = [anim(".dot")];
|
|
61
|
+
const el = { matches: (s: string) => s === ".dot" || s === "#dot-a" } as unknown as Element;
|
|
62
|
+
expect(getAnimationsForElement(dots, { id: "dot-a", selector: "#dot-a" }, el)).toHaveLength(1);
|
|
63
|
+
// Without the live element the class tween is still missed (legacy behavior).
|
|
64
|
+
expect(getAnimationsForElement(dots, { id: "dot-a", selector: "#dot-a" })).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("element.matches gates attribution — no over-matching", () => {
|
|
68
|
+
const dots = [anim(".dot")];
|
|
69
|
+
const el = { matches: () => false } as unknown as Element;
|
|
70
|
+
expect(getAnimationsForElement(dots, { id: "other", selector: "#other" }, el)).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("resolveSelectorElementIds", () => {
|
|
75
|
+
it("resolves a bare #id without touching the DOM", () => {
|
|
76
|
+
expect(resolveSelectorElementIds("#hero", null)).toEqual(["hero"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("resolves a class selector to every matching element id (the .dot+stagger case)", () => {
|
|
80
|
+
const doc = fakeDoc({ ".dot": [{ id: "dot-a" }, { id: "dot-b" }] });
|
|
81
|
+
expect(resolveSelectorElementIds(".dot", doc)).toEqual(["dot-a", "dot-b"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("resolves a group selector across its parts (deduped)", () => {
|
|
85
|
+
const doc = fakeDoc({ ".a": [{ id: "x" }], ".b": [{ id: "y" }, { id: "x" }] });
|
|
86
|
+
expect(resolveSelectorElementIds(".a, .b", doc).sort()).toEqual(["x", "y"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls back to a leading #id when there is no DOM", () => {
|
|
90
|
+
expect(resolveSelectorElementIds("#card .label", null)).toEqual(["card"]);
|
|
91
|
+
expect(resolveSelectorElementIds(".dot", null)).toEqual([]);
|
|
92
|
+
});
|
|
49
93
|
});
|