@hyperframes/studio 0.7.77 → 0.7.79
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/{hyperframes-player-CEggaaxR.js → hyperframes-player-mFah2TZE.js} +1 -1
- package/dist/assets/{index-B1Tjjdse.js → index-Bqj3h_1a.js} +1 -1
- package/dist/assets/{index-Bp4jAYZG.js → index-DlZMDyYs.js} +194 -194
- package/dist/assets/index-gGVKuFg5.css +1 -0
- package/dist/assets/{index-B1INQNmj.js → index-hmnoiSEV.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1432 -1181
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +5 -4
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +4 -1
- package/src/components/editor/PropertyPanel.tsx +3 -7
- package/src/components/editor/keyframeRetime.test.ts +42 -0
- package/src/components/editor/keyframeRetime.ts +4 -1
- package/src/components/editor/motionPathSelection.test.ts +65 -0
- package/src/components/editor/motionPathSelection.ts +13 -3
- package/src/components/editor/propertyPanelColor.test.tsx +181 -12
- package/src/components/editor/propertyPanelColor.tsx +32 -26
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
- package/src/components/nle/TimelineResizeDivider.tsx +9 -4
- package/src/components/pointerTargetSize.test.tsx +65 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
- package/src/components/sidebar/CompositionsTab.tsx +70 -33
- package/src/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
- package/src/hooks/gsapKeyframeCommit.ts +11 -2
- package/src/hooks/gsapResizeIntercept.ts +2 -2
- package/src/hooks/gsapRuntimeBridge.ts +3 -2
- package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
- package/src/hooks/gsapShared.test.ts +144 -0
- package/src/hooks/gsapShared.ts +264 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
- package/src/hooks/keyframeCacheAstLoad.ts +5 -71
- package/src/hooks/newTweenTarget.test.ts +364 -0
- package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
- package/src/hooks/timelineTrackVisibility.test.ts +61 -1
- package/src/hooks/timelineTrackVisibility.ts +11 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
- package/src/hooks/useEnableKeyframes.test.ts +44 -0
- package/src/hooks/useEnableKeyframes.ts +18 -5
- package/src/hooks/useGestureCommit.ts +21 -4
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapTweenCache.test.ts +11 -3
- package/src/hooks/useGsapTweenCache.ts +4 -11
- package/src/player/components/LayerDisclosureRow.tsx +4 -1
- package/src/player/components/Player.test.ts +143 -12
- package/src/player/components/Player.tsx +84 -43
- package/src/player/components/ShortcutsPanel.test.tsx +158 -0
- package/src/player/components/ShortcutsPanel.tsx +11 -15
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +2 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
- package/src/player/components/TimelineLanes.test.tsx +281 -0
- package/src/player/components/TimelineLanes.tsx +43 -97
- package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
- package/src/player/components/TimelinePropertyLanes.tsx +80 -11
- package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
- package/src/player/components/TimelineTrackHeader.tsx +52 -22
- package/src/player/components/timelineLaneProps.ts +86 -0
- package/src/player/components/timelineTrackDisplay.test.ts +35 -0
- package/src/player/components/timelineTrackDisplay.ts +38 -0
- package/src/player/components/useTimelineRangeSelection.ts +5 -0
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
- package/src/player/components/useTimelineTrackLayout.ts +4 -4
- package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- package/dist/assets/index-CBAfprvx.css +0 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
|
7
|
-
import {
|
|
7
|
+
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
|
|
8
8
|
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
|
9
9
|
|
|
10
10
|
export function updateKeyframeCacheFromParsed(
|
|
@@ -12,58 +12,65 @@ export function updateKeyframeCacheFromParsed(
|
|
|
12
12
|
targetPath: string,
|
|
13
13
|
selectionId: string | undefined,
|
|
14
14
|
mutation: Record<string, unknown>,
|
|
15
|
+
doc?: Document | null,
|
|
15
16
|
): void {
|
|
16
|
-
const { setKeyframeCache, elements } = usePlayerStore.getState();
|
|
17
|
+
const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState();
|
|
17
18
|
const idsWithKeyframes = new Set<string>();
|
|
18
19
|
const merged = new Map<string, KeyframeCacheEntry>();
|
|
19
20
|
const sourceAnimations = new Map<string, GsapAnimation[]>();
|
|
20
21
|
for (const anim of animations) {
|
|
21
|
-
const id = idFromSelector(anim.targetSelector);
|
|
22
22
|
const kfSource =
|
|
23
23
|
anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? [];
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
if (kfSource.length === 0) continue;
|
|
25
|
+
// Attribute the tween to every element it actually animates. A leading-id
|
|
26
|
+
// match filed `#stat3 .block` under `#stat3`: the child's diamonds landed on
|
|
27
|
+
// its ancestor AND collided with the ancestor's own tween at the shared
|
|
28
|
+
// percentage, which the same-% merge then resolved by dropping the ease.
|
|
29
|
+
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
|
|
30
|
+
idsWithKeyframes.add(id);
|
|
31
|
+
// Every tween that fed keyframeCache also lands in gsapAnimations, group or
|
|
32
|
+
// not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
|
|
33
|
+
// cache diamonds with no source animation behind them, so the collapsed row
|
|
34
|
+
// drew keyframes the expanded lanes couldn't render. Lane consumers do the
|
|
35
|
+
// group filtering themselves (animationContributesLane).
|
|
36
|
+
sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
// Convert tween-relative percentages to clip-relative so diamonds
|
|
39
|
+
// render at the correct position within the timeline clip. The basis comes
|
|
40
|
+
// from the shared resolver, so this writer agrees with the AST load on both
|
|
41
|
+
// the sub-comp host fallback and the tween's own time frame.
|
|
42
|
+
const { elStart, elDuration } = resolveClipTimingBasis(
|
|
43
|
+
id,
|
|
44
|
+
targetPath,
|
|
45
|
+
elements,
|
|
46
|
+
domClipChildren,
|
|
47
|
+
);
|
|
48
|
+
const clipKeyframes = toClipKeyframes(kfSource, anim, elStart, elDuration);
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
const existing = merged.get(id);
|
|
51
|
+
if (existing) {
|
|
52
|
+
// deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
|
|
53
|
+
// flag downstream lanes read); a second copy of that rule here is how the
|
|
54
|
+
// two writers drift.
|
|
55
|
+
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
|
56
|
+
} else {
|
|
57
|
+
merged.set(id, {
|
|
58
|
+
...anim.keyframes,
|
|
59
|
+
format: anim.keyframes?.format ?? "percentage",
|
|
60
|
+
keyframes: clipKeyframes,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
57
63
|
}
|
|
58
64
|
}
|
|
59
65
|
for (const [id, entry] of merged) {
|
|
60
66
|
for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
|
|
61
67
|
writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
|
|
62
68
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
const mutationSelector = (mutation as { targetSelector?: string }).targetSelector;
|
|
70
|
+
const mutated = mutationSelector ? resolveSelectorElementIds(mutationSelector, doc) : [];
|
|
71
|
+
const targetIds = mutated.length > 0 ? mutated : selectionId ? [selectionId] : [];
|
|
72
|
+
for (const targetId of targetIds) {
|
|
73
|
+
if (!idsWithKeyframes.has(targetId)) clearKeyframeCacheForElement(targetPath, targetId);
|
|
67
74
|
}
|
|
68
75
|
}
|
|
69
76
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
3
|
import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/globalTimeCompiler";
|
|
4
|
-
import { PROPERTY_DEFAULTS, selectorFromSelection } from "./gsapShared";
|
|
4
|
+
import { PROPERTY_DEFAULTS, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
5
5
|
import { roundToCenti } from "../utils/rounding";
|
|
6
6
|
|
|
7
7
|
type CommitFn = (
|
|
@@ -22,6 +22,9 @@ export async function commitKeyframeAtTimeImpl(
|
|
|
22
22
|
properties: Record<string, number | string>,
|
|
23
23
|
commitMutation: CommitFn,
|
|
24
24
|
): Promise<void> {
|
|
25
|
+
// Matching an authored tween is a string compare against what the author
|
|
26
|
+
// wrote, so it keeps using the selection's own selector; the NEW tween below
|
|
27
|
+
// is authored with the one-element form instead.
|
|
25
28
|
const selector = selectorFromSelection(selection);
|
|
26
29
|
if (!selector) return;
|
|
27
30
|
|
|
@@ -60,12 +63,18 @@ export async function commitKeyframeAtTimeImpl(
|
|
|
60
63
|
},
|
|
61
64
|
);
|
|
62
65
|
} else {
|
|
66
|
+
// Null means the live DOM could not prove any one-element form. Falling
|
|
67
|
+
// back to the author's own selector would write the group-collapsing
|
|
68
|
+
// target this narrowing exists to prevent, so the keyframe is dropped
|
|
69
|
+
// instead (see writeTargetSelector).
|
|
70
|
+
const target = writeTargetSelector(selection);
|
|
71
|
+
if (!target) return;
|
|
63
72
|
const defaultDuration = 0.5;
|
|
64
73
|
await commitMutation(
|
|
65
74
|
selection,
|
|
66
75
|
{
|
|
67
76
|
type: "add-with-keyframes" as const,
|
|
68
|
-
targetSelector:
|
|
77
|
+
targetSelector: target,
|
|
69
78
|
position: absoluteTime,
|
|
70
79
|
duration: defaultDuration,
|
|
71
80
|
keyframes: [
|
|
@@ -79,7 +79,7 @@ export async function tryGsapResizeIntercept(
|
|
|
79
79
|
if (!anim || isInstantHold(anim)) {
|
|
80
80
|
const sel = selectorFromSelection(selection);
|
|
81
81
|
if (!sel) return false;
|
|
82
|
-
const sizeSet = anim ?? findSizeSetAnimation(animations, sel);
|
|
82
|
+
const sizeSet = anim ?? findSizeSetAnimation(animations, sel, selection.element);
|
|
83
83
|
|
|
84
84
|
// If the element is animated (has a real tween, not just a static size
|
|
85
85
|
// hold), keyframe the size at the playhead so other keyframes keep theirs —
|
|
@@ -242,7 +242,7 @@ export async function tryGsapResizeIntercept(
|
|
|
242
242
|
const currentAnimations = fetchFallbackAnimations
|
|
243
243
|
? await fetchFallbackAnimations()
|
|
244
244
|
: (resolved?.animations ?? animations);
|
|
245
|
-
const existingSet = findExistingPositionWrite(currentAnimations, selector);
|
|
245
|
+
const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
|
|
246
246
|
// Delta chosen so the drag-path math composes back to exactly `corrected`
|
|
247
247
|
// (no drag scratch attrs exist during a resize, so base = gsapPos).
|
|
248
248
|
await commitStaticGsapPosition(
|
|
@@ -213,7 +213,7 @@ export async function tryGsapDragIntercept(
|
|
|
213
213
|
const existingSet =
|
|
214
214
|
posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector
|
|
215
215
|
? posAnim
|
|
216
|
-
: findExistingPositionWrite(resolvedAnimations, selector);
|
|
216
|
+
: findExistingPositionWrite(resolvedAnimations, selector, selection.element);
|
|
217
217
|
await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
|
|
218
218
|
commitMutation,
|
|
219
219
|
fetchAnimations: fetchFallbackAnimations,
|
|
@@ -301,7 +301,8 @@ export async function tryGsapRotationIntercept(
|
|
|
301
301
|
// rotation set in place, else add a new one. This replaces the old
|
|
302
302
|
// `--hf-studio-rotation` CSS-var fallback (the same dual-channel bug class).
|
|
303
303
|
if (!anim || isInstantHold(anim)) {
|
|
304
|
-
const existingSet =
|
|
304
|
+
const existingSet =
|
|
305
|
+
anim ?? findRotationSetAnimation(resolvedAnimations, selector, selection.element);
|
|
305
306
|
await commitStaticGsapRotation(selection, newRotation, selector, existingSet, {
|
|
306
307
|
commitMutation,
|
|
307
308
|
fetchAnimations: fetchFallbackAnimations,
|
|
@@ -2,17 +2,32 @@ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mu
|
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
3
|
|
|
4
4
|
export { PROPERTY_DEFAULTS } from "./gsapShared";
|
|
5
|
-
import { idSelector } from "./gsapShared";
|
|
5
|
+
import { idSelector, matchesExactlyOne } from "./gsapShared";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The selector to author a NEW tween against, minting an id on the element when
|
|
9
|
+
* it has no address of its own.
|
|
10
|
+
*
|
|
11
|
+
* `selection.selector` is only usable when it addresses ONE element:
|
|
12
|
+
* `buildStableSelector` hands back a bare class for an id-less element, so
|
|
13
|
+
* returning it unconditionally aimed "add animation" at every sibling sharing
|
|
14
|
+
* the class (the attribution blow-up that collapsed the timeline to one row,
|
|
15
|
+
* see writeTargetSelector). A non-unique selector falls through to the id mint
|
|
16
|
+
* below, which is the stronger fix here than a structural path: the id it writes
|
|
17
|
+
* back to the source also makes every later lookup for this element exact.
|
|
18
|
+
*/
|
|
7
19
|
export function ensureElementAddressable(selection: DomEditSelection): {
|
|
8
20
|
selector: string;
|
|
9
21
|
autoId?: string;
|
|
10
22
|
} {
|
|
11
23
|
if (selection.id) return { selector: idSelector(selection.id) };
|
|
12
|
-
if (selection.selector) return { selector: selection.selector };
|
|
13
24
|
|
|
14
25
|
const el = selection.element;
|
|
15
26
|
const doc = el.ownerDocument;
|
|
27
|
+
if (selection.selector && matchesExactlyOne(doc, selection.selector, el)) {
|
|
28
|
+
return { selector: selection.selector };
|
|
29
|
+
}
|
|
30
|
+
|
|
16
31
|
const tag = el.tagName.toLowerCase();
|
|
17
32
|
let id = tag;
|
|
18
33
|
let n = 1;
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
idSelector,
|
|
7
7
|
isInstantHold,
|
|
8
8
|
parsePercentageKeyframes,
|
|
9
|
+
resolveClipTimingBasis,
|
|
9
10
|
resolveEditableTweenDuration,
|
|
10
11
|
toClipKeyframes,
|
|
11
12
|
toClipPercentage,
|
|
@@ -169,6 +170,149 @@ describe("toClipKeyframes", () => {
|
|
|
169
170
|
});
|
|
170
171
|
});
|
|
171
172
|
|
|
173
|
+
describe("resolveClipTimingBasis", () => {
|
|
174
|
+
// Measured on v-product-promo: `captions-comp` mounts at 1.5s for 12.5s, and the
|
|
175
|
+
// six tweens inside it resolve to 0..12.5 — composition-local, not main-timeline
|
|
176
|
+
// absolute. Subtracting the host's 1.5 mount from a 0s tween cached pct -12.
|
|
177
|
+
const host = { id: "captions-comp", domId: "captions-comp", start: 1.5, duration: 12.5 };
|
|
178
|
+
const children = [{ id: "line", hostId: "captions-comp" }];
|
|
179
|
+
|
|
180
|
+
it("gives a sub-composition inner element the host window in the tween's own frame", () => {
|
|
181
|
+
expect(resolveClipTimingBasis("line", "captions.html", [host], children)).toEqual({
|
|
182
|
+
elStart: 0,
|
|
183
|
+
elDuration: 12.5,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("leaves a root-composition element on the main timeline", () => {
|
|
188
|
+
const box = { id: "box", domId: "box", start: 3, duration: 2 };
|
|
189
|
+
expect(resolveClipTimingBasis("box", "index.html", [box], [])).toEqual({
|
|
190
|
+
elStart: 3,
|
|
191
|
+
elDuration: 2,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("rebases an expanded sub-comp child by its host mount", () => {
|
|
196
|
+
// Expanded children carry host-ABSOLUTE display starts; the tweens they own are
|
|
197
|
+
// still composition-local, so the basis is the child's local start.
|
|
198
|
+
const pill = { id: "pill", domId: "pill", start: 8, duration: 4, expandedParentStart: 6 };
|
|
199
|
+
expect(resolveClipTimingBasis("pill", "scene.html", [pill], [])).toEqual({
|
|
200
|
+
elStart: 2,
|
|
201
|
+
elDuration: 4,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("rebases by the parent composition clip when the child is not expanded", () => {
|
|
206
|
+
const parent = { id: "scene-comp", domId: "scene-comp", start: 5, duration: 10 };
|
|
207
|
+
const pill = {
|
|
208
|
+
id: "pill",
|
|
209
|
+
domId: "pill",
|
|
210
|
+
start: 7,
|
|
211
|
+
duration: 3,
|
|
212
|
+
parentCompositionId: "scene-comp",
|
|
213
|
+
};
|
|
214
|
+
expect(resolveClipTimingBasis("pill", "scene.html", [parent, pill], [])).toEqual({
|
|
215
|
+
elStart: 2,
|
|
216
|
+
elDuration: 3,
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("treats a child whose parent composition is missing as starting at 0", () => {
|
|
221
|
+
// The mount is unknowable, so the only safe frame is the child's own. The
|
|
222
|
+
// old `?? 0` handed back `start` unchanged, which is a main-timeline value
|
|
223
|
+
// masquerading as a composition-local one and caches negative percentages.
|
|
224
|
+
const pill = {
|
|
225
|
+
id: "pill",
|
|
226
|
+
domId: "pill",
|
|
227
|
+
start: 7,
|
|
228
|
+
duration: 3,
|
|
229
|
+
parentCompositionId: "not-in-elements",
|
|
230
|
+
};
|
|
231
|
+
expect(resolveClipTimingBasis("pill", "scene.html", [pill], [])).toEqual({
|
|
232
|
+
elStart: 0,
|
|
233
|
+
elDuration: 3,
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("keeps a main-timeline clip's own start when it names no parent", () => {
|
|
238
|
+
const box = { id: "box", domId: "box", start: 4, duration: 2 };
|
|
239
|
+
expect(resolveClipTimingBasis("box", "index.html", [box], [])).toEqual({
|
|
240
|
+
elStart: 4,
|
|
241
|
+
elDuration: 2,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("falls back to a unit window when neither the element nor a host resolves", () => {
|
|
246
|
+
expect(resolveClipTimingBasis("ghost", "index.html", [], [])).toEqual({
|
|
247
|
+
elStart: 0,
|
|
248
|
+
elDuration: 1,
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("sub-composition keyframe percentages", () => {
|
|
254
|
+
const host = { id: "captions-comp", domId: "captions-comp", start: 1.5, duration: 12.5 };
|
|
255
|
+
const children = [{ id: "line", hostId: "captions-comp" }];
|
|
256
|
+
const basis = () => resolveClipTimingBasis("line", "captions.html", [host], children);
|
|
257
|
+
const inner = (resolvedStart: number, duration?: number) =>
|
|
258
|
+
({
|
|
259
|
+
id: `t-${resolvedStart}`,
|
|
260
|
+
method: "to",
|
|
261
|
+
targetSelector: "#line",
|
|
262
|
+
vars: {},
|
|
263
|
+
resolvedStart,
|
|
264
|
+
duration,
|
|
265
|
+
}) as unknown as GsapAnimation;
|
|
266
|
+
const percentages = (animation: GsapAnimation) => {
|
|
267
|
+
const { elStart, elDuration } = basis();
|
|
268
|
+
return toClipKeyframes(
|
|
269
|
+
[{ percentage: 0 }, { percentage: 100 }],
|
|
270
|
+
animation,
|
|
271
|
+
elStart,
|
|
272
|
+
elDuration,
|
|
273
|
+
).map((row) => row.percentage);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
it("puts a tween on the host's first frame at 0%, never below zero", () => {
|
|
277
|
+
// A clip-relative percentage can never be negative; this one cached -12.
|
|
278
|
+
expect(percentages(inner(0))).toEqual([0, 100]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("puts the last tween's end keyframe at 100%", () => {
|
|
282
|
+
expect(percentages(inner(12.1, 0.4))).toEqual([96.8, 100]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("keeps every measured tween of the fixture inside 0..100", () => {
|
|
286
|
+
for (const start of [0, 3.2, 3.5, 7.7, 8, 12.1]) {
|
|
287
|
+
for (const percentage of percentages(inner(start, 0.4))) {
|
|
288
|
+
expect(percentage).toBeGreaterThanOrEqual(0);
|
|
289
|
+
expect(percentage).toBeLessThanOrEqual(100);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("keeps a root-composition tween at the head of its own clip", () => {
|
|
295
|
+
const box = { id: "box", domId: "box", start: 3, duration: 2 };
|
|
296
|
+
const { elStart, elDuration } = resolveClipTimingBasis("box", "index.html", [box], []);
|
|
297
|
+
const rows = toClipKeyframes([{ percentage: 0 }], inner(3, 2), elStart, elDuration);
|
|
298
|
+
expect(rows[0]!.percentage).toBe(0);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("passes tween percentages through for a zero-length clip", () => {
|
|
302
|
+
expect(toClipKeyframes([{ percentage: 40 }], inner(0, 0.4), 0, 0)[0]!.percentage).toBe(40);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("round-trips a clip percentage through the basis it was written with", () => {
|
|
306
|
+
// The drag commit converts a dropped clip-% back to a time with this basis
|
|
307
|
+
// (useTimelineEditCallbacks) and compares it against the tween's own
|
|
308
|
+
// resolvedStart, so the basis has to be in the tween's frame on both sides.
|
|
309
|
+
const { elStart, elDuration } = basis();
|
|
310
|
+
const absTime = elStart + (40 / 100) * elDuration;
|
|
311
|
+
expect(absTime).toBe(5);
|
|
312
|
+
expect(toClipPercentage(absTime, elStart, elDuration, 0)).toBe(40);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
172
316
|
describe("idFromSelector", () => {
|
|
173
317
|
it("round-trips every shape idSelector emits", () => {
|
|
174
318
|
for (const id of ["hero-word", "el_1", "01-hook-hero-word", "my.class", "1box", '1"x']) {
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -110,12 +110,215 @@ export function idFromSelector(selector: string | undefined | null): string | nu
|
|
|
110
110
|
return (attribute[1] ?? "").replace(/\\(["\\])/g, "$1");
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/** Either shape {@link idSelector} emits, anchored to the WHOLE selector. */
|
|
114
|
+
const WHOLE_SELECTOR_ID = /^(#[\w-]+|\[id="(?:\\.|[^"\\])*"\])$/;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The id a selector addresses **as a whole**, or null. `"#stat3 .block"` animates
|
|
118
|
+
* the `.block` INSIDE `#stat3`, not `#stat3`, so the unanchored leading-id match
|
|
119
|
+
* of {@link idFromSelector} is wrong for attribution: it files the child's
|
|
120
|
+
* keyframes under its ancestor. `idFromSelector` stays unanchored on purpose
|
|
121
|
+
* (two non-attribution callers want the leading id); attribution goes through
|
|
122
|
+
* here, or through the DOM (see resolveSelectorElementIds).
|
|
123
|
+
*/
|
|
124
|
+
function wholeSelectorElementId(selector: string): string | null {
|
|
125
|
+
const trimmed = selector.trim();
|
|
126
|
+
return WHOLE_SELECTOR_ID.test(trimmed) ? idFromSelector(trimmed) : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve a tween's target selector to the ids of the element(s) it animates.
|
|
131
|
+
* A whole-selector `#id` resolves directly; anything else (a class like `.dot`,
|
|
132
|
+
* a group `.a, .b`, or a descendant selector) is matched against the live
|
|
133
|
+
* preview DOM so class/selector tweens (e.g. `gsap.from(".dot", {stagger})`)
|
|
134
|
+
* attribute to every element they animate — not just one parsed from the string.
|
|
135
|
+
* With no DOM, only whole-selector ids resolve: a descendant selector has no
|
|
136
|
+
* answer that isn't a guess at its ancestor.
|
|
137
|
+
*/
|
|
138
|
+
export function resolveSelectorElementIds(
|
|
139
|
+
selector: string,
|
|
140
|
+
doc: Document | null | undefined,
|
|
141
|
+
): string[] {
|
|
142
|
+
const bareId = wholeSelectorElementId(selector);
|
|
143
|
+
if (bareId) return [bareId];
|
|
144
|
+
const ids = new Set<string>();
|
|
145
|
+
for (const part of selector.split(",")) {
|
|
146
|
+
const sel = part.trim();
|
|
147
|
+
if (!sel) continue;
|
|
148
|
+
if (!doc) {
|
|
149
|
+
const whole = wholeSelectorElementId(sel);
|
|
150
|
+
if (whole) ids.add(whole);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
for (const el of Array.from(doc.querySelectorAll(sel))) {
|
|
155
|
+
if (el.id) ids.add(el.id);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// An unsupported/invalid selector never reached the DOM, so the leading id
|
|
159
|
+
// is the best available answer (`[id="01-hook"]:has(>*)` still names it).
|
|
160
|
+
const lead = idFromSelector(sel);
|
|
161
|
+
if (lead) ids.add(lead);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return Array.from(ids);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The clip start in the frame the element's OWN tweens are measured in. An
|
|
169
|
+
* expanded sub-composition child sits on the master timeline at a host-absolute
|
|
170
|
+
* `start`, but its tweens are parsed from its own source file and are local to
|
|
171
|
+
* it, so the two must be brought into one frame before any clip-% math — or
|
|
172
|
+
* every keyframe rebases to a percentage far outside the clip.
|
|
173
|
+
*/
|
|
174
|
+
export function clipTimingStart(element: { start: number; expandedParentStart?: number }): number {
|
|
175
|
+
return element.start - (element.expandedParentStart ?? 0);
|
|
176
|
+
}
|
|
177
|
+
|
|
113
178
|
export function selectorFromSelection(selection: DomEditSelection): string | null {
|
|
114
179
|
if (selection.id) return idSelector(selection.id);
|
|
115
180
|
if (selection.selector) return selection.selector;
|
|
116
181
|
return null;
|
|
117
182
|
}
|
|
118
183
|
|
|
184
|
+
/** `[name="value"]`, with the quote/backslash escaping a CSS string needs. */
|
|
185
|
+
function attributeSelector(name: string, value: string): string {
|
|
186
|
+
return `[${name}="${value.replace(/(["\\])/g, "\\$1")}"]`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Whether `selector` addresses `element` AND NOTHING ELSE. The single test for
|
|
191
|
+
* "this string is safe to author a new tween against": a selector that also
|
|
192
|
+
* hits siblings writes a tween that animates all of them.
|
|
193
|
+
*/
|
|
194
|
+
export function matchesExactlyOne(doc: Document, selector: string, element: Element): boolean {
|
|
195
|
+
try {
|
|
196
|
+
const matches = doc.querySelectorAll(selector);
|
|
197
|
+
return matches.length === 1 && matches[0] === element;
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A structural address for an element that carries no identity of its own:
|
|
205
|
+
* `:nth-child` steps up to the nearest ancestor that IS uniquely addressable
|
|
206
|
+
* (an id or a data-hf-id). This is the `selector` + `selectorIndex` pair the
|
|
207
|
+
* selection already carries, resolved through the live DOM the index was
|
|
208
|
+
* counted in — an index can't be spelled in CSS, but the element's position can.
|
|
209
|
+
*/
|
|
210
|
+
function structuralSelector(element: Element): string | null {
|
|
211
|
+
const doc = element.ownerDocument;
|
|
212
|
+
if (!doc) return null;
|
|
213
|
+
const parts: string[] = [];
|
|
214
|
+
for (let node: Element | null = element; node; node = node.parentElement) {
|
|
215
|
+
if (node !== element) {
|
|
216
|
+
const id = node instanceof HTMLElement ? node.id : "";
|
|
217
|
+
const hfId = node.getAttribute("data-hf-id");
|
|
218
|
+
if (id) {
|
|
219
|
+
parts.unshift(idSelector(id));
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
if (hfId) {
|
|
223
|
+
parts.unshift(attributeSelector("data-hf-id", hfId));
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const parent = node.parentElement;
|
|
228
|
+
if (!parent) break;
|
|
229
|
+
const index = [...parent.children].indexOf(node) + 1;
|
|
230
|
+
if (index < 1) return null;
|
|
231
|
+
parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
|
|
232
|
+
}
|
|
233
|
+
if (parts.length === 0) return null;
|
|
234
|
+
const selector = parts.join(" > ");
|
|
235
|
+
return matchesExactlyOne(doc, selector, element) ? selector : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The selector to author a NEW tween with. Distinct from
|
|
240
|
+
* {@link selectorFromSelection}, which must keep returning the exact string an
|
|
241
|
+
* already-authored tween is string-matched against (findTweenAtTime): this one
|
|
242
|
+
* has to ADDRESS ONE ELEMENT.
|
|
243
|
+
*
|
|
244
|
+
* `buildStableSelector` hands back a bare class for any element without an id,
|
|
245
|
+
* so "add keyframe at playhead" on one of five `.group` siblings wrote
|
|
246
|
+
* `tl.set(".group", …)` — a tween that animates all five and that
|
|
247
|
+
* {@link resolveSelectorElementIds} reads back as all five, collapsing their
|
|
248
|
+
* timeline rows into one. Every rung below resolves to exactly one element.
|
|
249
|
+
*
|
|
250
|
+
* Null means "no string here addresses one element". With a live DOM to check
|
|
251
|
+
* against, a failed structural walk (detached between select and commit, a
|
|
252
|
+
* shadow-root boundary, a chain that no longer re-resolves) IS that evidence,
|
|
253
|
+
* so returning the bare selector anyway would hand back the exact input this
|
|
254
|
+
* function exists to replace. Every caller treats the null as "do not author
|
|
255
|
+
* this tween": falling back to the selection's own selector would write the
|
|
256
|
+
* group-collapsing target this function exists to prevent, and a gesture that
|
|
257
|
+
* does not persist reverts visibly on the next reload, where a tween silently
|
|
258
|
+
* aimed at five elements does not. The bare selector comes back only with no DOM
|
|
259
|
+
* to disambiguate against, where refusing would be guessing rather than knowing.
|
|
260
|
+
*/
|
|
261
|
+
export function writeTargetSelector(selection: DomEditSelection): string | null {
|
|
262
|
+
if (selection.id) return idSelector(selection.id);
|
|
263
|
+
if (selection.hfId) return attributeSelector("data-hf-id", selection.hfId);
|
|
264
|
+
const element = selection.element;
|
|
265
|
+
const doc = element?.ownerDocument;
|
|
266
|
+
if (element && doc) {
|
|
267
|
+
if (selection.selector && matchesExactlyOne(doc, selection.selector, element)) {
|
|
268
|
+
return selection.selector;
|
|
269
|
+
}
|
|
270
|
+
return structuralSelector(element);
|
|
271
|
+
}
|
|
272
|
+
return selection.selector ?? null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The selector a `replace-with-keyframes` mutation must re-author an EXISTING
|
|
277
|
+
* tween against. The server deletes the tween and adds it back, so this string
|
|
278
|
+
* REWRITES its target: deriving it from the selection instead discards whatever
|
|
279
|
+
* the author aimed at, and silently widens a tween {@link writeTargetSelector}
|
|
280
|
+
* had already narrowed to one element back onto every class sibling.
|
|
281
|
+
*
|
|
282
|
+
* The selection is the fallback only for a target the parser could not resolve
|
|
283
|
+
* statically, where there is no authored string to preserve.
|
|
284
|
+
*/
|
|
285
|
+
export function existingTweenTargetSelector(
|
|
286
|
+
animation: Pick<GsapAnimation, "targetSelector" | "hasUnresolvedSelector">,
|
|
287
|
+
selection: DomEditSelection,
|
|
288
|
+
): string | null {
|
|
289
|
+
if (animation.targetSelector && !animation.hasUnresolvedSelector) {
|
|
290
|
+
return animation.targetSelector;
|
|
291
|
+
}
|
|
292
|
+
return selectorFromSelection(selection);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The read half of {@link writeTargetSelector}: does an already-authored tween
|
|
297
|
+
* write THIS element?
|
|
298
|
+
*
|
|
299
|
+
* String equality against `selectorFromSelection` alone is not enough once new
|
|
300
|
+
* tweens are authored with a narrowed one-element selector: the next edit would
|
|
301
|
+
* miss the write it just made and append a second, conflicting one. Falling back
|
|
302
|
+
* to the live DOM keeps the pair consistent.
|
|
303
|
+
*
|
|
304
|
+
* That fallback is `matchesExactlyOne`, not a bare `element.matches`. A target
|
|
305
|
+
* the element merely shares with its siblings is a GROUP tween, and the callers
|
|
306
|
+
* here MUTATE what they find: an individual nudge on one of five `.group`
|
|
307
|
+
* siblings would rewrite the group's own tween and move all five. Only the
|
|
308
|
+
* selection that IS the group (string equality above, where the author selected
|
|
309
|
+
* `.group` itself) may edit it; every other element authors its own write.
|
|
310
|
+
*/
|
|
311
|
+
export function tweenTargetsElement(
|
|
312
|
+
targetSelector: string,
|
|
313
|
+
selector: string,
|
|
314
|
+
element: Element | null | undefined,
|
|
315
|
+
): boolean {
|
|
316
|
+
if (targetSelector === selector) return true;
|
|
317
|
+
const doc = element?.ownerDocument;
|
|
318
|
+
if (!element || !doc) return false;
|
|
319
|
+
return matchesExactlyOne(doc, targetSelector, element);
|
|
320
|
+
}
|
|
321
|
+
|
|
119
322
|
// ── Percentage computation ────────────────────────────────────────────────────
|
|
120
323
|
|
|
121
324
|
/**
|
|
@@ -259,6 +462,67 @@ export function toAbsoluteTime(tweenPos: number, tweenDur: number, percentage: n
|
|
|
259
462
|
return tweenPos + (percentage / 100) * tweenDur;
|
|
260
463
|
}
|
|
261
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Timing basis for an element's keyframes, expressed in the TWEEN's own time
|
|
467
|
+
* frame. Sub-composition internals (e.g. pills inside a scene) aren't timeline
|
|
468
|
+
* clips themselves — they're derived at expand time — so they're absent from
|
|
469
|
+
* `elements`. Without a basis, elDuration defaulted to 1 and clip-relative
|
|
470
|
+
* keyframe percentages blew past 100% (rendering off the clip). Fall back to the
|
|
471
|
+
* sub-comp HOST's bounds, resolved via domClipChildren (the host's
|
|
472
|
+
* data-composition-src is stripped in the rendered DOM, so we can't query it).
|
|
473
|
+
*
|
|
474
|
+
* `elStart` is the clip's start in the frame the tween's own times are measured
|
|
475
|
+
* in. A sub-composition tween's resolvedStart is composition-local while a
|
|
476
|
+
* timeline element's start is main-timeline absolute, so passing the raw element
|
|
477
|
+
* start subtracted two different frames from each other: a host mounted at 1.5s
|
|
478
|
+
* cached its 0s tween at -12%, and a clip-relative percentage can never be
|
|
479
|
+
* negative. The composition's mount is `expandedParentStart` for an expanded
|
|
480
|
+
* child, the parent composition clip's start otherwise, and 0 for a
|
|
481
|
+
* root-composition element, whose start already IS the tween frame.
|
|
482
|
+
*/
|
|
483
|
+
export function resolveClipTimingBasis(
|
|
484
|
+
elementId: string,
|
|
485
|
+
sourceFile: string,
|
|
486
|
+
elements: ReadonlyArray<{
|
|
487
|
+
domId?: string;
|
|
488
|
+
key?: string;
|
|
489
|
+
id: string;
|
|
490
|
+
start: number;
|
|
491
|
+
duration: number;
|
|
492
|
+
expandedParentStart?: number;
|
|
493
|
+
parentCompositionId?: string | null;
|
|
494
|
+
}>,
|
|
495
|
+
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
|
|
496
|
+
): { elStart: number; elDuration: number } {
|
|
497
|
+
const direct = elements.find(
|
|
498
|
+
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
|
499
|
+
);
|
|
500
|
+
if (direct) {
|
|
501
|
+
const parentId = direct.parentCompositionId;
|
|
502
|
+
const parent = parentId
|
|
503
|
+
? elements.find((el) => el.domId === parentId || el.id === parentId)
|
|
504
|
+
: undefined;
|
|
505
|
+
const mount = direct.expandedParentStart ?? parent?.start;
|
|
506
|
+
if (mount !== undefined) return { elStart: direct.start - mount, elDuration: direct.duration };
|
|
507
|
+
// No parent composition named, so this IS a main-timeline clip and its own
|
|
508
|
+
// start is already the basis.
|
|
509
|
+
if (!parentId) return { elStart: direct.start, elDuration: direct.duration };
|
|
510
|
+
// It named a parent we cannot find, so the mount is unknowable. Its tweens
|
|
511
|
+
// are still composition-local, so treat its own window as the frame rather
|
|
512
|
+
// than subtracting nothing and handing back a main-timeline start, which is
|
|
513
|
+
// exactly the mixed-frame subtraction this function exists to prevent.
|
|
514
|
+
return { elStart: 0, elDuration: direct.duration };
|
|
515
|
+
}
|
|
516
|
+
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
|
|
517
|
+
const host = hostId
|
|
518
|
+
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
|
|
519
|
+
: undefined;
|
|
520
|
+
// The inner element is not a clip of its own: the host's window IS the frame
|
|
521
|
+
// its tweens are timed in, so the start in that frame is 0, not the host's
|
|
522
|
+
// main-timeline mount.
|
|
523
|
+
return { elStart: 0, elDuration: host?.duration ?? 1 };
|
|
524
|
+
}
|
|
525
|
+
|
|
262
526
|
/**
|
|
263
527
|
* An absolute time as a percentage of a timeline clip, at the one precision every
|
|
264
528
|
* keyframe-cache writer must share. 0.001% keeps a beat-snapped keyframe centered
|