@hyperframes/studio 0.7.5 → 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-BwFzbjZQ.js → index-B2YXvFxf.js} +1 -1
- package/dist/assets/index-BSkUuN8g.css +1 -0
- package/dist/assets/index-BeRh2hMe.js +375 -0
- package/dist/assets/{index-C5NAfiPa.js → index-BoASKOeE.js} +1 -1
- package/dist/chunk-KZXYQYIU.js +876 -0
- package/dist/chunk-KZXYQYIU.js.map +1 -0
- package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
- package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.html +2 -2
- package/dist/index.js +3326 -2939
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/components/StudioPreviewArea.tsx +10 -3
- package/src/components/StudioRightPanel.tsx +4 -0
- package/src/components/editor/AnimationCard.tsx +77 -250
- package/src/components/editor/AnimationCardParts.tsx +220 -0
- package/src/components/editor/BlockParamsPanel.tsx +4 -8
- package/src/components/editor/DomEditOverlay.tsx +132 -48
- package/src/components/editor/EaseCurveSection.tsx +177 -101
- package/src/components/editor/GsapAnimationSection.tsx +4 -0
- package/src/components/editor/KeyframeEaseList.tsx +127 -0
- package/src/components/editor/OffCanvasIndicators.tsx +111 -0
- package/src/components/editor/PropertyPanel.tsx +63 -56
- package/src/components/editor/gsapAnimationCallbacks.ts +3 -0
- package/src/components/editor/gsapAnimationConstants.ts +11 -32
- package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
- package/src/components/editor/manualOffsetDrag.ts +8 -0
- package/src/components/editor/marqueeCommit.ts +168 -0
- package/src/components/editor/propertyPanelHelpers.ts +7 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
- package/src/components/editor/snapTargetCollection.ts +0 -5
- package/src/components/editor/useMotionPathData.ts +2 -1
- package/src/components/panels/SlideshowPanel.tsx +0 -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 +12 -0
- package/src/hooks/gsapDragCommit.ts +101 -0
- package/src/hooks/gsapDragPositionCommit.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.ts +28 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
- package/src/hooks/gsapRuntimeReaders.ts +1 -6
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useDomEditPreviewSync.ts +5 -0
- package/src/hooks/useDomEditSession.ts +43 -0
- package/src/hooks/useDomEditTextCommits.ts +3 -12
- package/src/hooks/useDomEditWiring.ts +3 -0
- package/src/hooks/useDomSelection.ts +46 -0
- package/src/hooks/useGestureCommit.ts +26 -7
- package/src/hooks/useGestureRecording.ts +2 -16
- package/src/hooks/useGsapAnimationOps.ts +2 -2
- 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 +151 -42
- package/src/hooks/useMusicBeatAnalysis.ts +36 -21
- package/src/hooks/useRazorSplit.ts +0 -3
- package/src/player/components/Player.tsx +0 -5
- package/src/player/components/timelineIcons.tsx +2 -1
- package/src/player/hooks/useTimelinePlayer.ts +4 -14
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
- package/src/player/lib/timelineIframeHelpers.ts +2 -6
- package/src/telemetry/client.ts +2 -0
- package/src/utils/gestureSmoother.test.ts +48 -0
- package/src/utils/gestureSmoother.ts +46 -0
- package/src/utils/marqueeGeometry.test.ts +123 -0
- package/src/utils/marqueeGeometry.ts +172 -0
- package/src/utils/optimisticUpdate.ts +1 -2
- package/src/utils/sourcePatcher.ts +0 -10
- package/src/utils/velocityEaseFitter.test.ts +58 -0
- package/src/utils/velocityEaseFitter.ts +121 -0
- package/dist/assets/index-D_JGXmfx.js +0 -374
- package/dist/assets/index-DzWIinxk.css +0 -1
- package/src/utils/editDebugLog.ts +0 -16
|
@@ -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"];
|
|
@@ -61,6 +61,9 @@ export interface DomEditActionsValue extends Pick<
|
|
|
61
61
|
| "invalidateGsapCache"
|
|
62
62
|
| "previewIframeRef"
|
|
63
63
|
| "commitMutation"
|
|
64
|
+
| "applyMarqueeSelection"
|
|
65
|
+
| "handleUpdateKeyframeEase"
|
|
66
|
+
| "handleSetAllKeyframeEases"
|
|
64
67
|
> {}
|
|
65
68
|
|
|
66
69
|
export interface DomEditSelectionValue extends Pick<
|
|
@@ -167,6 +170,9 @@ export function DomEditProvider({
|
|
|
167
170
|
invalidateGsapCache,
|
|
168
171
|
previewIframeRef,
|
|
169
172
|
commitMutation,
|
|
173
|
+
applyMarqueeSelection,
|
|
174
|
+
handleUpdateKeyframeEase,
|
|
175
|
+
handleSetAllKeyframeEases,
|
|
170
176
|
},
|
|
171
177
|
children,
|
|
172
178
|
}: {
|
|
@@ -238,6 +244,9 @@ export function DomEditProvider({
|
|
|
238
244
|
invalidateGsapCache,
|
|
239
245
|
previewIframeRef,
|
|
240
246
|
commitMutation: stableCommitMutation,
|
|
247
|
+
applyMarqueeSelection,
|
|
248
|
+
handleUpdateKeyframeEase,
|
|
249
|
+
handleSetAllKeyframeEases,
|
|
241
250
|
}),
|
|
242
251
|
[
|
|
243
252
|
handleTimelineElementSelect,
|
|
@@ -295,6 +304,9 @@ export function DomEditProvider({
|
|
|
295
304
|
invalidateGsapCache,
|
|
296
305
|
previewIframeRef,
|
|
297
306
|
stableCommitMutation,
|
|
307
|
+
applyMarqueeSelection,
|
|
308
|
+
handleUpdateKeyframeEase,
|
|
309
|
+
handleSetAllKeyframeEases,
|
|
298
310
|
],
|
|
299
311
|
);
|
|
300
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) ──────────────────
|
|
@@ -165,8 +165,7 @@ async function commitFlatViaKeyframes(
|
|
|
165
165
|
if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
|
|
166
166
|
}
|
|
167
167
|
mainTl.seek(ct);
|
|
168
|
-
} catch
|
|
169
|
-
console.warn("[gsap-drag] start-value read failed; using identity from values", err);
|
|
168
|
+
} catch {
|
|
170
169
|
for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key];
|
|
171
170
|
} finally {
|
|
172
171
|
if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues);
|
|
@@ -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,
|
|
@@ -239,7 +240,9 @@ export async function tryGsapDragIntercept(
|
|
|
239
240
|
// `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
|
|
240
241
|
// place (idempotent), else add a new one. This also covers the stale-cache
|
|
241
242
|
// phantom — committing a set is correct because the element genuinely has no live motion.
|
|
242
|
-
|
|
243
|
+
const hasNonHold = hasNonHoldTweenForElement(iframe, selector);
|
|
244
|
+
|
|
245
|
+
if (!hasNonHold) {
|
|
243
246
|
const existingSet =
|
|
244
247
|
posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
|
|
245
248
|
? posAnim
|
|
@@ -251,7 +254,9 @@ export async function tryGsapDragIntercept(
|
|
|
251
254
|
return true;
|
|
252
255
|
}
|
|
253
256
|
|
|
254
|
-
if (!posAnim)
|
|
257
|
+
if (!posAnim) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
255
260
|
|
|
256
261
|
// Verify the anim ID is still valid in the current file. The React-state
|
|
257
262
|
// `animations` list can lag behind the file after a prior mutation changed
|
|
@@ -325,6 +330,27 @@ export async function tryGsapResizeIntercept(
|
|
|
325
330
|
const sel = selectorFromSelection(selection);
|
|
326
331
|
if (!sel) return false;
|
|
327
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
|
+
|
|
328
354
|
await commitStaticGsapSize(selection, size, sel, sizeSet, {
|
|
329
355
|
commitMutation,
|
|
330
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) {
|
|
@@ -115,12 +115,7 @@ export function readAllAnimatedProperties(
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
-
} catch
|
|
119
|
-
console.warn(
|
|
120
|
-
"Cross-tween guard failed — baseline capture may include values from other tweens",
|
|
121
|
-
e,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
118
|
+
} catch {}
|
|
124
119
|
for (const p of propKeys) otherTweenProps.delete(p);
|
|
125
120
|
|
|
126
121
|
// Tier 1: Transform + visual properties with universal CSS defaults.
|
|
@@ -224,10 +224,6 @@ export function useDomEditCommits({
|
|
|
224
224
|
target_source_file: selection.sourceFile ?? undefined,
|
|
225
225
|
composition: activeCompPath ?? undefined,
|
|
226
226
|
});
|
|
227
|
-
console.warn(
|
|
228
|
-
`[studio] Element not found in source: ${targetKey}. ` +
|
|
229
|
-
"This element may be generated at runtime and cannot be persisted.",
|
|
230
|
-
);
|
|
231
227
|
}
|
|
232
228
|
}
|
|
233
229
|
return;
|
|
@@ -68,6 +68,11 @@ export function useDomEditPreviewSync({
|
|
|
68
68
|
|
|
69
69
|
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
|
|
70
70
|
if (!nextElement) {
|
|
71
|
+
// The selected element no longer resolves in the (re-synced) document
|
|
72
|
+
// — comp/hot reload, activeCompPath swap, or post-save replacement.
|
|
73
|
+
// Clear so overlay geometry isn't computed on a stale, detached node.
|
|
74
|
+
// (Drag-release-in-gray-zone is handled separately by
|
|
75
|
+
// suppressNextBoxClickRef; the dragged element still resolves here.)
|
|
71
76
|
applyDomSelection(null, { revealPanel: false });
|
|
72
77
|
return;
|
|
73
78
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
1
2
|
import type { TimelineElement } from "../player";
|
|
2
3
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
3
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
@@ -123,6 +124,7 @@ export function useDomEditSession({
|
|
|
123
124
|
buildDomSelectionForTimelineElement,
|
|
124
125
|
handleTimelineElementSelect,
|
|
125
126
|
refreshDomEditSelectionFromPreview,
|
|
127
|
+
applyMarqueeSelection,
|
|
126
128
|
} = useDomSelection({
|
|
127
129
|
projectId,
|
|
128
130
|
activeCompPath,
|
|
@@ -389,6 +391,44 @@ export function useDomEditSession({
|
|
|
389
391
|
updateArcSegment,
|
|
390
392
|
});
|
|
391
393
|
|
|
394
|
+
const handleUpdateKeyframeEase = useCallback(
|
|
395
|
+
(animationId: string, percentage: number, ease: string) => {
|
|
396
|
+
const sel = domEditSelectionRef.current;
|
|
397
|
+
if (!sel) return;
|
|
398
|
+
gsapCommitMutation(
|
|
399
|
+
sel,
|
|
400
|
+
{
|
|
401
|
+
type: "update-keyframe",
|
|
402
|
+
animationId,
|
|
403
|
+
percentage,
|
|
404
|
+
properties: {},
|
|
405
|
+
ease,
|
|
406
|
+
},
|
|
407
|
+
{ label: "Update keyframe ease", softReload: true },
|
|
408
|
+
);
|
|
409
|
+
},
|
|
410
|
+
[gsapCommitMutation, domEditSelectionRef],
|
|
411
|
+
);
|
|
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
|
+
|
|
392
432
|
return {
|
|
393
433
|
// State
|
|
394
434
|
domEditSelection,
|
|
@@ -429,6 +469,7 @@ export function useDomEditSession({
|
|
|
429
469
|
buildDomSelectionFromTarget,
|
|
430
470
|
buildDomSelectionForTimelineElement,
|
|
431
471
|
updateDomEditHoverSelection,
|
|
472
|
+
applyMarqueeSelection,
|
|
432
473
|
resolveImportedFontAsset,
|
|
433
474
|
setAgentModalOpen,
|
|
434
475
|
setAgentPromptSelectionContext,
|
|
@@ -454,6 +495,8 @@ export function useDomEditSession({
|
|
|
454
495
|
handleGsapConvertToKeyframes,
|
|
455
496
|
handleGsapRemoveAllKeyframes,
|
|
456
497
|
handleResetSelectedElementKeyframes,
|
|
498
|
+
handleUpdateKeyframeEase,
|
|
499
|
+
handleSetAllKeyframeEases,
|
|
457
500
|
commitAnimatedProperty,
|
|
458
501
|
handleSetArcPath,
|
|
459
502
|
handleUpdateArcSegment,
|
|
@@ -126,9 +126,7 @@ export function useDomEditTextCommits({
|
|
|
126
126
|
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
127
127
|
: undefined,
|
|
128
128
|
});
|
|
129
|
-
} catch
|
|
130
|
-
console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
|
|
131
|
-
}
|
|
129
|
+
} catch {}
|
|
132
130
|
refreshDomEditSelectionFromPreview(domEditSelection);
|
|
133
131
|
},
|
|
134
132
|
[
|
|
@@ -162,9 +160,7 @@ export function useDomEditTextCommits({
|
|
|
162
160
|
coalesceKey: `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`,
|
|
163
161
|
skipRefresh: options.skipRefresh,
|
|
164
162
|
});
|
|
165
|
-
} catch
|
|
166
|
-
console.warn(options.warningMessage, err instanceof Error ? err.message : err);
|
|
167
|
-
}
|
|
163
|
+
} catch {}
|
|
168
164
|
if (options.refreshAfter) {
|
|
169
165
|
refreshDomEditSelectionFromPreview(domEditSelection);
|
|
170
166
|
}
|
|
@@ -224,12 +220,7 @@ export function useDomEditTextCommits({
|
|
|
224
220
|
coalesceKey: `html-attr:${attr}:${getDomEditTargetKey(domEditSelection)}`,
|
|
225
221
|
skipRefresh: false,
|
|
226
222
|
});
|
|
227
|
-
} catch
|
|
228
|
-
console.warn(
|
|
229
|
-
"[Studio] HTML attribute persist failed:",
|
|
230
|
-
err instanceof Error ? err.message : err,
|
|
231
|
-
);
|
|
232
|
-
}
|
|
223
|
+
} catch {}
|
|
233
224
|
refreshDomEditSelectionFromPreview(domEditSelection);
|
|
234
225
|
},
|
|
235
226
|
[
|
|
@@ -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 ──
|
|
@@ -85,6 +85,7 @@ export interface UseDomSelectionReturn {
|
|
|
85
85
|
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
|
|
86
86
|
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
|
|
87
87
|
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
|
|
88
|
+
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
// ── Hook ──
|
|
@@ -419,6 +420,50 @@ export function useDomSelection({
|
|
|
419
420
|
applyDomSelection(null, { revealPanel: false });
|
|
420
421
|
}, [applyDomSelection, captionEditMode]);
|
|
421
422
|
|
|
423
|
+
const applyMarqueeSelection = useCallback(
|
|
424
|
+
// fallow-ignore-next-line complexity
|
|
425
|
+
(selections: DomEditSelection[], additive: boolean) => {
|
|
426
|
+
// Honor the inspector-panels kill switch like applyDomSelection does, so
|
|
427
|
+
// marquee can't land selections while the inspector UI is suppressed.
|
|
428
|
+
if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
|
|
429
|
+
domEditSelectionRef.current = null;
|
|
430
|
+
domEditGroupSelectionsRef.current = [];
|
|
431
|
+
setDomEditSelection(null);
|
|
432
|
+
setDomEditGroupSelections([]);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (selections.length === 0) {
|
|
436
|
+
if (!additive) applyDomSelection(null, { revealPanel: false });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const current = domEditSelectionRef.current;
|
|
440
|
+
const currentGroup = domEditGroupSelectionsRef.current;
|
|
441
|
+
let nextGroup: DomEditSelection[];
|
|
442
|
+
if (additive) {
|
|
443
|
+
nextGroup = seedDomEditGroupWithSelection(currentGroup, current);
|
|
444
|
+
for (const s of selections) {
|
|
445
|
+
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
nextGroup = selections;
|
|
449
|
+
}
|
|
450
|
+
const nextSelection = additive && current ? current : selections[0];
|
|
451
|
+
domEditSelectionRef.current = nextSelection;
|
|
452
|
+
domEditGroupSelectionsRef.current = nextGroup;
|
|
453
|
+
setDomEditSelection(nextSelection);
|
|
454
|
+
setDomEditGroupSelections(nextGroup);
|
|
455
|
+
const nextTimelineId =
|
|
456
|
+
findMatchingTimelineElementId(nextSelection, timelineElements) ??
|
|
457
|
+
findTimelineIdByAncestor(
|
|
458
|
+
nextSelection.element,
|
|
459
|
+
timelineElements,
|
|
460
|
+
nextSelection.sourceFile || "index.html",
|
|
461
|
+
);
|
|
462
|
+
setSelectedTimelineElementId(nextTimelineId);
|
|
463
|
+
},
|
|
464
|
+
[applyDomSelection, timelineElements, setSelectedTimelineElementId],
|
|
465
|
+
);
|
|
466
|
+
|
|
422
467
|
// Disabled inspector effect
|
|
423
468
|
// eslint-disable-next-line no-restricted-syntax
|
|
424
469
|
useEffect(() => {
|
|
@@ -451,5 +496,6 @@ export function useDomSelection({
|
|
|
451
496
|
handleTimelineElementSelect,
|
|
452
497
|
refreshDomEditSelectionFromPreview,
|
|
453
498
|
refreshDomEditGroupSelectionsFromPreview,
|
|
499
|
+
applyMarqueeSelection,
|
|
454
500
|
};
|
|
455
501
|
}
|