@hyperframes/studio 0.7.15 → 0.7.17
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-CTXExGxO.js → index-BCAdHPz_.js} +1 -1
- package/dist/assets/{index-Db9KG66z.js → index-BDx28x9R.js} +1 -1
- package/dist/assets/index-DmkOvZns.css +1 -0
- package/dist/assets/index-hiqLDAtz.js +375 -0
- 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
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
2
2
|
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
3
|
-
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
|
4
3
|
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
|
|
5
4
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
5
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
|
@@ -8,63 +7,8 @@ import {
|
|
|
8
7
|
clearKeyframeCacheForElement,
|
|
9
8
|
clearKeyframeCacheForFile,
|
|
10
9
|
} from "./gsapKeyframeCacheHelpers";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
|
|
14
|
-
const byPct = new Map<number, GsapPercentageKeyframe>();
|
|
15
|
-
for (const kf of keyframes) {
|
|
16
|
-
const existing = byPct.get(kf.percentage);
|
|
17
|
-
if (existing) {
|
|
18
|
-
existing.properties = { ...existing.properties, ...kf.properties };
|
|
19
|
-
if (kf.ease) existing.ease = kf.ease;
|
|
20
|
-
} else {
|
|
21
|
-
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// fallow-ignore-next-line complexity
|
|
28
|
-
function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
|
|
29
|
-
if (anim.method === "set") {
|
|
30
|
-
// A `set` is a STATIC HOLD — a value applied at one point, not an animated
|
|
31
|
-
// keyframe. It must NOT synthesize a keyframe, or the timeline + panel show a
|
|
32
|
-
// phantom diamond for a value that doesn't animate. This holds for a base
|
|
33
|
-
// `gsap.set` (off-timeline) AND an on-timeline `tl.set`, and aligns the AST
|
|
34
|
-
// path with the runtime scan, which already skips every zero-duration set.
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
const toProps = anim.properties;
|
|
38
|
-
const fromProps = anim.fromProperties;
|
|
39
|
-
if (!toProps || Object.keys(toProps).length === 0) return null;
|
|
40
|
-
|
|
41
|
-
const startProps: Record<string, number | string> = {};
|
|
42
|
-
const endProps: Record<string, number | string> = {};
|
|
43
|
-
|
|
44
|
-
if (anim.method === "from") {
|
|
45
|
-
for (const [k, v] of Object.entries(toProps)) {
|
|
46
|
-
startProps[k] = v;
|
|
47
|
-
endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
|
48
|
-
}
|
|
49
|
-
} else if (anim.method === "fromTo" && fromProps) {
|
|
50
|
-
Object.assign(startProps, fromProps);
|
|
51
|
-
Object.assign(endProps, toProps);
|
|
52
|
-
} else {
|
|
53
|
-
for (const [k, v] of Object.entries(toProps)) {
|
|
54
|
-
startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
|
55
|
-
endProps[k] = v;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
format: "percentage",
|
|
61
|
-
keyframes: [
|
|
62
|
-
{ percentage: 0, properties: startProps },
|
|
63
|
-
{ percentage: 100, properties: endProps },
|
|
64
|
-
],
|
|
65
|
-
...(anim.ease ? { ease: anim.ease } : {}),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
10
|
+
import { toAbsoluteTime } from "./gsapShared";
|
|
11
|
+
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
|
68
12
|
|
|
69
13
|
function extractIdFromSelector(selector: string): string | null {
|
|
70
14
|
const match = selector.match(/^#([\w-]+)/);
|
|
@@ -176,6 +120,37 @@ export async function fetchParsedAnimations(
|
|
|
176
120
|
}
|
|
177
121
|
}
|
|
178
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
|
|
125
|
+
* inside a scene) aren't timeline clips themselves — they're derived at expand time
|
|
126
|
+
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
|
|
127
|
+
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
|
|
128
|
+
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
|
|
129
|
+
* data-composition-src is stripped in the rendered DOM, so we can't query it).
|
|
130
|
+
*/
|
|
131
|
+
function resolveClipTimingBasis(
|
|
132
|
+
elementId: string,
|
|
133
|
+
sourceFile: string,
|
|
134
|
+
elements: ReadonlyArray<{
|
|
135
|
+
domId?: string;
|
|
136
|
+
key?: string;
|
|
137
|
+
id: string;
|
|
138
|
+
start: number;
|
|
139
|
+
duration: number;
|
|
140
|
+
}>,
|
|
141
|
+
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
|
|
142
|
+
): { elStart: number; elDuration: number } {
|
|
143
|
+
const direct = elements.find(
|
|
144
|
+
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
|
145
|
+
);
|
|
146
|
+
if (direct) return { elStart: direct.start, elDuration: direct.duration };
|
|
147
|
+
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
|
|
148
|
+
const host = hostId
|
|
149
|
+
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
|
|
150
|
+
: undefined;
|
|
151
|
+
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
|
|
152
|
+
}
|
|
153
|
+
|
|
179
154
|
export function useGsapAnimationsForElement(
|
|
180
155
|
projectId: string | null,
|
|
181
156
|
sourceFile: string,
|
|
@@ -192,6 +167,11 @@ export function useGsapAnimationsForElement(
|
|
|
192
167
|
const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
|
|
193
168
|
const lastFetchKeyRef = useRef("");
|
|
194
169
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
170
|
+
// Re-run the per-element cache populate when sub-comp DOM children appear, so a
|
|
171
|
+
// sub-comp element gets its host-relative keyframe percentages (not elDuration=1).
|
|
172
|
+
const domClipChildrenKey = usePlayerStore((s) =>
|
|
173
|
+
s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
|
|
174
|
+
);
|
|
195
175
|
|
|
196
176
|
useEffect(() => {
|
|
197
177
|
const targetKey = target?.id ?? target?.selector ?? "";
|
|
@@ -351,12 +331,13 @@ export function useGsapAnimationsForElement(
|
|
|
351
331
|
|
|
352
332
|
// Resolve the element's time range from the player store so we can
|
|
353
333
|
// convert tween-relative keyframe percentages to clip-relative ones.
|
|
354
|
-
const { elements } = usePlayerStore.getState();
|
|
355
|
-
const
|
|
356
|
-
|
|
334
|
+
const { elements, domClipChildren } = usePlayerStore.getState();
|
|
335
|
+
const { elStart, elDuration } = resolveClipTimingBasis(
|
|
336
|
+
elementId,
|
|
337
|
+
sourceFile,
|
|
338
|
+
elements,
|
|
339
|
+
domClipChildren,
|
|
357
340
|
);
|
|
358
|
-
const elStart = timelineEl?.start ?? 0;
|
|
359
|
-
const elDuration = timelineEl?.duration ?? 1;
|
|
360
341
|
|
|
361
342
|
const allKeyframes: Array<
|
|
362
343
|
GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string }
|
|
@@ -419,7 +400,8 @@ export function useGsapAnimationsForElement(
|
|
|
419
400
|
// PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
|
|
420
401
|
// so write a duplicate entry under the bare key for cross-component lookups.
|
|
421
402
|
setKeyframeCache(elementId, merged);
|
|
422
|
-
|
|
403
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
404
|
+
}, [elementId, sourceFile, animations, domClipChildrenKey]);
|
|
423
405
|
|
|
424
406
|
return { animations, multipleTimelines, unsupportedTimelinePattern };
|
|
425
407
|
}
|
|
@@ -442,13 +424,19 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
442
424
|
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
|
443
425
|
): void {
|
|
444
426
|
const elementCount = usePlayerStore((s) => s.elements.length);
|
|
427
|
+
// Re-run when sub-comp DOM children appear (they supply the host bounds the
|
|
428
|
+
// clip-relative keyframe percentages are computed against; without this the
|
|
429
|
+
// cache is computed once before they exist and the percentages stay wrong).
|
|
430
|
+
const domClipChildrenKey = usePlayerStore((s) =>
|
|
431
|
+
s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
|
|
432
|
+
);
|
|
445
433
|
const lastFetchKeyRef = useRef("");
|
|
446
434
|
|
|
447
435
|
const runtimeScanDoneRef = useRef("");
|
|
448
436
|
const astFetchDoneRef = useRef("");
|
|
449
437
|
|
|
450
438
|
useEffect(() => {
|
|
451
|
-
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`;
|
|
439
|
+
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`;
|
|
452
440
|
if (fetchKey === lastFetchKeyRef.current) return;
|
|
453
441
|
lastFetchKeyRef.current = fetchKey;
|
|
454
442
|
runtimeScanDoneRef.current = "";
|
|
@@ -461,7 +449,7 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
461
449
|
if (!parsed) return;
|
|
462
450
|
const { setKeyframeCache } = usePlayerStore.getState();
|
|
463
451
|
clearKeyframeCacheForFile(sf);
|
|
464
|
-
const { elements } = usePlayerStore.getState();
|
|
452
|
+
const { elements, domClipChildren } = usePlayerStore.getState();
|
|
465
453
|
const doc = iframeRef?.current?.contentDocument;
|
|
466
454
|
const mergedByElement = new Map<string, GsapKeyframesData>();
|
|
467
455
|
for (const anim of parsed.animations) {
|
|
@@ -482,11 +470,7 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
482
470
|
// Attribute the tween to every element it animates (handles class /
|
|
483
471
|
// group / descendant selectors, not just `#id`).
|
|
484
472
|
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
|
|
485
|
-
const
|
|
486
|
-
(el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
|
|
487
|
-
);
|
|
488
|
-
const elStart = timelineEl?.start ?? 0;
|
|
489
|
-
const elDuration = timelineEl?.duration ?? 1;
|
|
473
|
+
const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
|
|
490
474
|
const clipKeyframes = kfData.keyframes.map((kf) => {
|
|
491
475
|
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
|
|
492
476
|
// 0.001% precision (matching useGsapAnimationsForElement above) so a
|
|
@@ -524,7 +508,7 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
524
508
|
// iframeRef is read for DOM selector resolution but intentionally not a dep
|
|
525
509
|
// (it's a stable ref; the separate runtime-scan effect owns iframe timing).
|
|
526
510
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
527
|
-
}, [projectId, sourceFile, version, elementCount]);
|
|
511
|
+
}, [projectId, sourceFile, version, elementCount, domClipChildrenKey]);
|
|
528
512
|
|
|
529
513
|
// Separate effect for runtime keyframe discovery — polls until the iframe
|
|
530
514
|
// has loaded GSAP timelines, independent of the AST fetch lifecycle.
|
|
@@ -3,6 +3,7 @@ import { liveTime, usePlayerStore } from "../player";
|
|
|
3
3
|
import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
|
|
4
4
|
import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
5
5
|
import { type DomEditSelection } from "../components/editor/domEditing";
|
|
6
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
6
7
|
|
|
7
8
|
// ── Types ──
|
|
8
9
|
|
|
@@ -20,13 +21,19 @@ export interface UsePreviewInteractionParams {
|
|
|
20
21
|
resolveDomSelectionFromPreviewPoint: (
|
|
21
22
|
clientX: number,
|
|
22
23
|
clientY: number,
|
|
23
|
-
options?: {
|
|
24
|
+
options?: {
|
|
25
|
+
preferClipAncestor?: boolean;
|
|
26
|
+
skipSourceProbe?: boolean;
|
|
27
|
+
activeGroupElement?: HTMLElement | null;
|
|
28
|
+
},
|
|
24
29
|
) => Promise<DomEditSelection | null>;
|
|
25
30
|
resolveAllDomSelectionsFromPreviewPoint: (
|
|
26
31
|
clientX: number,
|
|
27
32
|
clientY: number,
|
|
28
33
|
) => Promise<DomEditSelection[]>;
|
|
29
34
|
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
|
|
35
|
+
/** Drill into a group (double-click on the canvas) so its children become selectable. */
|
|
36
|
+
setActiveGroupElement: (el: HTMLElement | null) => void;
|
|
30
37
|
|
|
31
38
|
onClickToSource?: (selection: DomEditSelection) => void;
|
|
32
39
|
}
|
|
@@ -41,6 +48,12 @@ interface ClickCycleState {
|
|
|
41
48
|
|
|
42
49
|
const CYCLE_RADIUS_PX = 6;
|
|
43
50
|
const CYCLE_WINDOW_MS = 600;
|
|
51
|
+
// Manual double-click window. `e.detail` can't be trusted here: the first click
|
|
52
|
+
// selects the group and re-renders the overlay, so the second click lands on a
|
|
53
|
+
// fresh element and the browser's native click-counter resets to 1 — drill-in
|
|
54
|
+
// (which keyed off `e.detail >= 2`) never fired. We track time+position instead.
|
|
55
|
+
const DOUBLE_CLICK_MS = 400;
|
|
56
|
+
const DOUBLE_CLICK_RADIUS_PX = 6;
|
|
44
57
|
|
|
45
58
|
// ── Hook ──
|
|
46
59
|
|
|
@@ -53,15 +66,47 @@ export function usePreviewInteraction({
|
|
|
53
66
|
resolveDomSelectionFromPreviewPoint,
|
|
54
67
|
resolveAllDomSelectionsFromPreviewPoint,
|
|
55
68
|
updateDomEditHoverSelection,
|
|
69
|
+
setActiveGroupElement,
|
|
56
70
|
onClickToSource,
|
|
57
71
|
}: UsePreviewInteractionParams) {
|
|
58
72
|
const cycleRef = useRef<ClickCycleState | null>(null);
|
|
73
|
+
const lastDownRef = useRef<{ t: number; x: number; y: number } | null>(null);
|
|
59
74
|
|
|
60
75
|
const handlePreviewCanvasMouseDown = useCallback(
|
|
61
76
|
// fallow-ignore-next-line complexity
|
|
62
77
|
async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
63
78
|
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
|
|
64
79
|
|
|
80
|
+
// Manual double-click detection (see DOUBLE_CLICK_MS): the first click
|
|
81
|
+
// re-renders the overlay so `e.detail` never reaches 2 on the canvas.
|
|
82
|
+
const downTs = Date.now();
|
|
83
|
+
const lastDown = lastDownRef.current;
|
|
84
|
+
const isDoubleClick =
|
|
85
|
+
e.detail >= 2 ||
|
|
86
|
+
(lastDown != null &&
|
|
87
|
+
downTs - lastDown.t < DOUBLE_CLICK_MS &&
|
|
88
|
+
Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < DOUBLE_CLICK_RADIUS_PX);
|
|
89
|
+
lastDownRef.current = { t: downTs, x: e.clientX, y: e.clientY };
|
|
90
|
+
|
|
91
|
+
// Double-click a group → drill into it and select the child under the
|
|
92
|
+
// pointer (resolve with the group as the explicit drill-in scope, since the
|
|
93
|
+
// activeGroupElement state hasn't re-rendered yet within this handler).
|
|
94
|
+
if (isDoubleClick && !e.shiftKey) {
|
|
95
|
+
const hit = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY);
|
|
96
|
+
if (hit?.element.hasAttribute("data-hf-group")) {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
e.stopPropagation();
|
|
99
|
+
cycleRef.current = null;
|
|
100
|
+
trackStudioEvent("group", { action: "drill_in" });
|
|
101
|
+
setActiveGroupElement(hit.element);
|
|
102
|
+
const child = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
103
|
+
activeGroupElement: hit.element,
|
|
104
|
+
});
|
|
105
|
+
applyDomSelection(child ?? hit);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
65
110
|
const now = Date.now();
|
|
66
111
|
const prev = cycleRef.current;
|
|
67
112
|
const dx = prev ? e.clientX - prev.x : Infinity;
|
|
@@ -96,9 +141,21 @@ export function usePreviewInteraction({
|
|
|
96
141
|
}
|
|
97
142
|
|
|
98
143
|
// Fresh click — resolve topmost element
|
|
99
|
-
|
|
144
|
+
let nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
100
145
|
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
101
146
|
});
|
|
147
|
+
// A null result while drilled into a group means the click landed OUTSIDE that
|
|
148
|
+
// group (resolveGroupCapture → out-of-scope). Drill-in isn't sticky: exit it and
|
|
149
|
+
// re-resolve at the top level so this click selects whatever's there (or the
|
|
150
|
+
// group as a unit). Without this, a stale drill-in keeps selecting children and
|
|
151
|
+
// the "first click selects the group" expectation breaks.
|
|
152
|
+
if (!nextSelection) {
|
|
153
|
+
setActiveGroupElement(null);
|
|
154
|
+
nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
155
|
+
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
156
|
+
activeGroupElement: null,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
102
159
|
if (!nextSelection) {
|
|
103
160
|
cycleRef.current = null;
|
|
104
161
|
applyDomSelection(null, { revealPanel: false });
|
|
@@ -125,6 +182,7 @@ export function usePreviewInteraction({
|
|
|
125
182
|
onClickToSource,
|
|
126
183
|
resolveAllDomSelectionsFromPreviewPoint,
|
|
127
184
|
resolveDomSelectionFromPreviewPoint,
|
|
185
|
+
setActiveGroupElement,
|
|
128
186
|
],
|
|
129
187
|
);
|
|
130
188
|
|
|
@@ -36,6 +36,8 @@ const SHORTCUT_SECTIONS = [
|
|
|
36
36
|
{ key: "⌘V", label: "Paste element" },
|
|
37
37
|
{ key: "⌘X", label: "Cut element" },
|
|
38
38
|
{ key: "S", label: "Split clip at playhead" },
|
|
39
|
+
{ key: "⌘G", label: "Group elements" },
|
|
40
|
+
{ key: "⌘⇧G", label: "Ungroup" },
|
|
39
41
|
{ key: "Del", label: "Delete selected element" },
|
|
40
42
|
],
|
|
41
43
|
},
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
type KeyframeDiamondContextMenuState,
|
|
21
21
|
} from "./KeyframeDiamondContextMenu";
|
|
22
22
|
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
23
|
-
import { snapKeyframePctToBeat } from "./timelineEditing";
|
|
24
23
|
import { ClipContextMenu } from "./ClipContextMenu";
|
|
25
24
|
import {
|
|
26
25
|
GUTTER,
|
|
@@ -87,7 +86,6 @@ export const Timeline = memo(function Timeline({
|
|
|
87
86
|
onDeleteKeyframe,
|
|
88
87
|
onDeleteAllKeyframes,
|
|
89
88
|
onChangeKeyframeEase,
|
|
90
|
-
onMoveKeyframe,
|
|
91
89
|
} = useResolvedTimelineEditCallbacks({
|
|
92
90
|
onMoveElement: onMoveElementOverride,
|
|
93
91
|
onResizeElement: onResizeElementOverride,
|
|
@@ -481,19 +479,6 @@ export const Timeline = memo(function Timeline({
|
|
|
481
479
|
onShiftClickKeyframe={(elId, pct) => {
|
|
482
480
|
toggleSelectedKeyframe(`${elId}:${pct}`);
|
|
483
481
|
}}
|
|
484
|
-
onDragKeyframe={(el, oldPct, newPct) => {
|
|
485
|
-
onMoveKeyframe?.(el, oldPct, newPct);
|
|
486
|
-
}}
|
|
487
|
-
onSnapKeyframePct={(el, pct) =>
|
|
488
|
-
snapKeyframePctToBeat(el, pct, adjustedBeatAnalysis?.beatTimes, pps)
|
|
489
|
-
}
|
|
490
|
-
onPickKeyframeElement={(el) => {
|
|
491
|
-
const elKey = el.key ?? el.id;
|
|
492
|
-
if (selectedElementId !== elKey) {
|
|
493
|
-
setSelectedElementId(elKey);
|
|
494
|
-
onSelectElement?.(el);
|
|
495
|
-
}
|
|
496
|
-
}}
|
|
497
482
|
onContextMenuKeyframe={(e, elId, pct) => {
|
|
498
483
|
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
|
|
499
484
|
if (el) {
|
|
@@ -91,11 +91,6 @@ interface TimelineCanvasProps {
|
|
|
91
91
|
currentTime: number;
|
|
92
92
|
onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
|
|
93
93
|
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
|
94
|
-
onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
|
95
|
-
/** Snap a keyframe's clip-relative % to the nearest beat (returns unchanged when none in range). */
|
|
96
|
-
onSnapKeyframePct?: (element: TimelineElement, pct: number) => number;
|
|
97
|
-
/** Select the element when a keyframe drag starts (loads its GSAP session). */
|
|
98
|
-
onPickKeyframeElement?: (element: TimelineElement) => void;
|
|
99
94
|
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
|
100
95
|
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
101
96
|
beatAnalysis?: MusicBeatAnalysis | null;
|
|
@@ -143,9 +138,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
143
138
|
currentTime,
|
|
144
139
|
onClickKeyframe,
|
|
145
140
|
onShiftClickKeyframe,
|
|
146
|
-
onDragKeyframe,
|
|
147
|
-
onSnapKeyframePct,
|
|
148
|
-
onPickKeyframeElement,
|
|
149
141
|
onContextMenuKeyframe,
|
|
150
142
|
onContextMenuClip,
|
|
151
143
|
beatAnalysis,
|
|
@@ -446,11 +438,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
446
438
|
selectedKeyframes={selectedKeyframes}
|
|
447
439
|
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
|
|
448
440
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
449
|
-
onDragKeyframe={(oldPct, newPct) =>
|
|
450
|
-
onDragKeyframe?.(previewElement, oldPct, newPct)
|
|
451
|
-
}
|
|
452
|
-
snapPct={(pct) => onSnapKeyframePct?.(previewElement, pct) ?? pct}
|
|
453
|
-
onPickForDrag={() => onPickKeyframeElement?.(previewElement)}
|
|
454
441
|
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
455
442
|
/>
|
|
456
443
|
)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo
|
|
1
|
+
import { memo } from "react";
|
|
2
2
|
import { BEAT_BAND_H } from "./BeatStrip";
|
|
3
3
|
|
|
4
4
|
interface KeyframeEntry {
|
|
@@ -28,15 +28,7 @@ interface TimelineClipDiamondsProps {
|
|
|
28
28
|
selectedKeyframes: Set<string>;
|
|
29
29
|
onClickKeyframe?: (percentage: number) => void;
|
|
30
30
|
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
|
31
|
-
onDragKeyframe?: (percentage: number, newPercentage: number) => void;
|
|
32
31
|
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
|
33
|
-
/** Snap a clip-relative percentage to the nearest beat (returns it unchanged
|
|
34
|
-
* when no beat is within range). Drives live beat-snapping while dragging. */
|
|
35
|
-
snapPct?: (percentage: number) => number;
|
|
36
|
-
/** Select this element when a keyframe drag begins, so its GSAP session is
|
|
37
|
-
* loaded by the time the move commits (diamonds render on unselected clips
|
|
38
|
-
* too, and a drag suppresses the selecting click). */
|
|
39
|
-
onPickForDrag?: () => void;
|
|
40
32
|
}
|
|
41
33
|
|
|
42
34
|
const DIAMOND_RATIO = 0.8;
|
|
@@ -59,54 +51,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
59
51
|
selectedKeyframes,
|
|
60
52
|
onClickKeyframe,
|
|
61
53
|
onShiftClickKeyframe,
|
|
62
|
-
onDragKeyframe,
|
|
63
54
|
onContextMenuKeyframe,
|
|
64
|
-
snapPct,
|
|
65
|
-
onPickForDrag,
|
|
66
55
|
}: TimelineClipDiamondsProps) {
|
|
67
|
-
// Live drag: which keyframe (by original %) is being dragged and its current
|
|
68
|
-
// (beat-snapped) %, so the diamond + its connecting lines follow the cursor.
|
|
69
|
-
const dragRef = useRef<{ origPct: number; pct: number; moved: boolean } | null>(null);
|
|
70
|
-
const [drag, setDrag] = useState<{ origPct: number; pct: number } | null>(null);
|
|
71
|
-
// Commit through the latest callback, not the one captured at pointer-down:
|
|
72
|
-
// selecting the element on drag-start loads its GSAP session asynchronously,
|
|
73
|
-
// and the commit must use the closure that sees the loaded session.
|
|
74
|
-
const onDragKeyframeRef = useRef(onDragKeyframe);
|
|
75
|
-
onDragKeyframeRef.current = onDragKeyframe;
|
|
76
|
-
// Optimistic hold: after a commit, keep the diamond at the dropped position
|
|
77
|
-
// until the cache reflects the change (the file round-trip rewrites
|
|
78
|
-
// keyframesData), so it doesn't flash back to the old spot in between.
|
|
79
|
-
const pendingRef = useRef(false);
|
|
80
|
-
const pendingHeldPctRef = useRef<number | null>(null);
|
|
81
|
-
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
82
|
-
// Cleanup for an in-flight drag's document listeners, so an unmount mid-drag
|
|
83
|
-
// (clip deleted, comp switch, zoom-out → early return) doesn't leak them.
|
|
84
|
-
const dragCleanupRef = useRef<(() => void) | null>(null);
|
|
85
|
-
|
|
86
|
-
useEffect(() => {
|
|
87
|
-
if (!pendingRef.current) return;
|
|
88
|
-
// Only release the optimistic hold once the cache actually reflects the
|
|
89
|
-
// committed position (a keyframe near the held %). An unrelated cache
|
|
90
|
-
// rebuild (e.g. elementCount change) rebuilds keyframesData with the SAME
|
|
91
|
-
// percentages — releasing then would flash the diamond back to the old spot.
|
|
92
|
-
const held = pendingHeldPctRef.current;
|
|
93
|
-
if (held != null && !keyframesData.keyframes.some((k) => Math.abs(k.percentage - held) < 0.3)) {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
pendingRef.current = false;
|
|
97
|
-
pendingHeldPctRef.current = null;
|
|
98
|
-
if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
|
|
99
|
-
setDrag(null);
|
|
100
|
-
}, [keyframesData]);
|
|
101
|
-
|
|
102
|
-
useEffect(
|
|
103
|
-
() => () => {
|
|
104
|
-
clearTimeout(pendingTimerRef.current ?? undefined);
|
|
105
|
-
dragCleanupRef.current?.();
|
|
106
|
-
},
|
|
107
|
-
[],
|
|
108
|
-
);
|
|
109
|
-
|
|
110
56
|
if (clipWidthPx < 20) return null;
|
|
111
57
|
|
|
112
58
|
// When the beat strip occupies the top band, shrink the diamonds and center
|
|
@@ -129,79 +75,13 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
129
75
|
}
|
|
130
76
|
};
|
|
131
77
|
|
|
132
|
-
const handlePointerDown = (e: React.PointerEvent, pct: number) => {
|
|
133
|
-
if (e.button !== 0) return;
|
|
134
|
-
e.stopPropagation();
|
|
135
|
-
// Ignore a new drag while a prior drop is still settling: `pct` comes from
|
|
136
|
-
// props (the pre-drop position) but the diamond is held at its dropped spot
|
|
137
|
-
// via effPct(), so a re-grab would track from a stale origin and commit
|
|
138
|
-
// against the wrong tween. The hold clears on the cache round-trip (≤2s).
|
|
139
|
-
if (pendingRef.current) return;
|
|
140
|
-
// Select the element up front so its GSAP session loads during the drag and
|
|
141
|
-
// the commit (which resolves the animation from the selection) isn't a no-op.
|
|
142
|
-
onPickForDrag?.();
|
|
143
|
-
const startX = e.clientX;
|
|
144
|
-
dragRef.current = { origPct: pct, pct, moved: false };
|
|
145
|
-
|
|
146
|
-
const handleMove = (me: PointerEvent) => {
|
|
147
|
-
const d = dragRef.current;
|
|
148
|
-
if (!d) return;
|
|
149
|
-
const dx = me.clientX - startX;
|
|
150
|
-
// 4px dead zone so a click doesn't register as a drag.
|
|
151
|
-
if (!d.moved && Math.abs(dx) <= 4) return;
|
|
152
|
-
d.moved = true;
|
|
153
|
-
const rawPct = Math.max(0, Math.min(100, pct + (dx / clipWidthPx) * 100));
|
|
154
|
-
const snapped = snapPct ? snapPct(rawPct) : rawPct;
|
|
155
|
-
d.pct = snapped;
|
|
156
|
-
setDrag({ origPct: pct, pct: snapped });
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
const handleUp = () => {
|
|
160
|
-
document.removeEventListener("pointermove", handleMove);
|
|
161
|
-
document.removeEventListener("pointerup", handleUp);
|
|
162
|
-
dragCleanupRef.current = null;
|
|
163
|
-
const d = dragRef.current;
|
|
164
|
-
dragRef.current = null;
|
|
165
|
-
const willCommit = !!(d && d.moved && Math.abs(d.pct - d.origPct) > 0.5);
|
|
166
|
-
if (willCommit && d) {
|
|
167
|
-
// Hold the dropped position optimistically; the effect clears it once the
|
|
168
|
-
// cache round-trip lands (fallback timeout in case it never does).
|
|
169
|
-
pendingRef.current = true;
|
|
170
|
-
pendingHeldPctRef.current = d.pct;
|
|
171
|
-
setDrag({ origPct: d.origPct, pct: d.pct });
|
|
172
|
-
if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
|
|
173
|
-
pendingTimerRef.current = setTimeout(() => {
|
|
174
|
-
pendingRef.current = false;
|
|
175
|
-
pendingHeldPctRef.current = null;
|
|
176
|
-
setDrag(null);
|
|
177
|
-
}, 2000);
|
|
178
|
-
onDragKeyframeRef.current?.(d.origPct, d.pct);
|
|
179
|
-
} else {
|
|
180
|
-
setDrag(null);
|
|
181
|
-
}
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
dragCleanupRef.current = () => {
|
|
185
|
-
document.removeEventListener("pointermove", handleMove);
|
|
186
|
-
document.removeEventListener("pointerup", handleUp);
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
document.addEventListener("pointermove", handleMove);
|
|
190
|
-
document.addEventListener("pointerup", handleUp);
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p);
|
|
194
|
-
|
|
195
78
|
return (
|
|
196
79
|
<div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
|
|
197
80
|
{sorted.map((kf, i) => {
|
|
198
81
|
if (i === 0) return null;
|
|
199
82
|
const prev = sorted[i - 1]!;
|
|
200
|
-
const x1 = Math.max(
|
|
201
|
-
|
|
202
|
-
Math.min(clipWidthPx, (effPct(prev.percentage) / 100) * clipWidthPx),
|
|
203
|
-
);
|
|
204
|
-
const x2 = Math.max(0, Math.min(clipWidthPx, (effPct(kf.percentage) / 100) * clipWidthPx));
|
|
83
|
+
const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx));
|
|
84
|
+
const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx));
|
|
205
85
|
if (x2 - x1 < 1) return null;
|
|
206
86
|
return (
|
|
207
87
|
<div
|
|
@@ -227,7 +107,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
227
107
|
// is the clip's left edge (the diamond's left half overflows, which the
|
|
228
108
|
// overflow-visible clip shows) — NOT shifted fully inside. No clamp, or
|
|
229
109
|
// boundary keyframes (0% / 100%) would render off-center.
|
|
230
|
-
const leftPx = (
|
|
110
|
+
const leftPx = (kf.percentage / 100) * clipWidthPx - half;
|
|
231
111
|
const kfKey = `${elementId}:${kf.percentage}`;
|
|
232
112
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
|
233
113
|
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
|
@@ -252,7 +132,6 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
252
132
|
padding: 0,
|
|
253
133
|
}}
|
|
254
134
|
onClick={(e) => handleClick(e, kf.percentage)}
|
|
255
|
-
onPointerDown={(e) => handlePointerDown(e, kf.percentage)}
|
|
256
135
|
onContextMenu={(e) => {
|
|
257
136
|
e.preventDefault();
|
|
258
137
|
e.stopPropagation();
|
|
@@ -39,6 +39,5 @@ export interface TimelineEditCallbacks {
|
|
|
39
39
|
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
|
40
40
|
onDeleteAllKeyframes?: (elementId: string) => void;
|
|
41
41
|
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
42
|
-
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
|
43
42
|
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
44
43
|
}
|
|
@@ -122,4 +122,47 @@ describe("buildExpandedElements", () => {
|
|
|
122
122
|
expect(child.key).toBe("index.html#eyebrow");
|
|
123
123
|
expect(child.key).toBe(expectedStoreKey);
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
// Sub-comp internals (group + pills) have no data-start, so they're not in the
|
|
127
|
+
// manifest. They arrive as DOM children and must still expand under their host.
|
|
128
|
+
it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
|
|
129
|
+
const elements = [
|
|
130
|
+
el({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
|
|
131
|
+
];
|
|
132
|
+
const manifest = [
|
|
133
|
+
clip({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
|
|
134
|
+
];
|
|
135
|
+
// pill-3 selected → parent group-1 → host scene-host. None of group-1/pills
|
|
136
|
+
// are in the manifest; they're DOM children with parent links.
|
|
137
|
+
const parentMap = new Map([
|
|
138
|
+
["group-1", "scene-host"],
|
|
139
|
+
["pill-1", "group-1"],
|
|
140
|
+
["pill-2", "group-1"],
|
|
141
|
+
["pill-3", "group-1"],
|
|
142
|
+
]);
|
|
143
|
+
const domClipChildren = [
|
|
144
|
+
{ id: "group-1", parentId: "scene-host", hostId: "scene-host", label: "Group 1" },
|
|
145
|
+
{ id: "pill-1", parentId: "group-1", hostId: "scene-host", label: "pill-1" },
|
|
146
|
+
{ id: "pill-2", parentId: "group-1", hostId: "scene-host", label: "pill-2" },
|
|
147
|
+
{ id: "pill-3", parentId: "group-1", hostId: "scene-host", label: "pill-3" },
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
// Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1.
|
|
151
|
+
const out = buildExpandedElements(
|
|
152
|
+
elements,
|
|
153
|
+
manifest,
|
|
154
|
+
parentMap,
|
|
155
|
+
"scene-host",
|
|
156
|
+
"group-1",
|
|
157
|
+
domClipChildren,
|
|
158
|
+
);
|
|
159
|
+
const pills = out.filter((e) => e.domId?.startsWith("pill-"));
|
|
160
|
+
expect(pills).toHaveLength(3);
|
|
161
|
+
// Children span the host's bounds and rebase onto the host's file.
|
|
162
|
+
expect(pills[0]!.start).toBe(5);
|
|
163
|
+
expect(pills[0]!.duration).toBe(6);
|
|
164
|
+
expect(pills[0]!.sourceFile).toBe("scene.html");
|
|
165
|
+
// The host row is replaced by its children.
|
|
166
|
+
expect(out.some((e) => e.domId === "scene-host")).toBe(false);
|
|
167
|
+
});
|
|
125
168
|
});
|