@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
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
3
3
|
import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
|
|
4
|
+
import {
|
|
5
|
+
timelineTrackOrder,
|
|
6
|
+
trackDisplayNumber,
|
|
7
|
+
trackDisplaySuffix,
|
|
8
|
+
} from "../player/components/timelineTrackDisplay";
|
|
4
9
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
5
10
|
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
|
6
11
|
import {
|
|
@@ -208,12 +213,17 @@ export async function toggleTimelineTrackHidden({
|
|
|
208
213
|
domEditSaveTimestampRef,
|
|
209
214
|
pendingTimelineEditPathRef,
|
|
210
215
|
}: ToggleTimelineTrackHiddenInput): Promise<string[]> {
|
|
216
|
+
// `track` is the fractional sort key the callback needs; the history entry is
|
|
217
|
+
// read by a human, so it gets the display row instead.
|
|
218
|
+
const suffix = trackDisplaySuffix(
|
|
219
|
+
trackDisplayNumber(timelineTrackOrder(timelineElements), track),
|
|
220
|
+
);
|
|
211
221
|
return setElementsHidden({
|
|
212
222
|
projectId,
|
|
213
223
|
activeCompPath,
|
|
214
224
|
elements: timelineElements.filter((element) => element.track === track),
|
|
215
225
|
hidden,
|
|
216
|
-
label: hidden ? `Hide track
|
|
226
|
+
label: hidden ? `Hide track${suffix}` : `Show track${suffix}`,
|
|
217
227
|
previewIframe,
|
|
218
228
|
writeProjectFile,
|
|
219
229
|
recordEdit,
|
|
@@ -14,7 +14,13 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
14
14
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
15
15
|
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
|
|
16
16
|
import type { SetPatchProps } from "./gsapRuntimePatch";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
selectorFromSelection,
|
|
19
|
+
computeElementPercentage,
|
|
20
|
+
isInstantHold,
|
|
21
|
+
writeTargetSelector,
|
|
22
|
+
tweenTargetsElement,
|
|
23
|
+
} from "./gsapShared";
|
|
18
24
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
19
25
|
import { roundTo3 } from "../utils/rounding";
|
|
20
26
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
@@ -185,7 +191,9 @@ async function commitStaticSet(
|
|
|
185
191
|
batch.push(entry);
|
|
186
192
|
byGroup.set(group, batch);
|
|
187
193
|
}
|
|
188
|
-
const staticWrites = animations.filter(
|
|
194
|
+
const staticWrites = animations.filter(
|
|
195
|
+
(a) => isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
|
|
196
|
+
);
|
|
189
197
|
// Resolve every group's target BEFORE committing anything, and coalesce
|
|
190
198
|
// groups that land on the SAME write into one commit: the snapshot is captured
|
|
191
199
|
// once, so if two groups resolved to one legacy mixed write, a first
|
|
@@ -206,7 +214,7 @@ async function commitStaticSet(
|
|
|
206
214
|
}
|
|
207
215
|
// Fresh adds don't reshape existing sets, so their ids can't go stale.
|
|
208
216
|
for (const batch of newSetBatches) {
|
|
209
|
-
await addGlobalStaticSet(selection, batch,
|
|
217
|
+
await addGlobalStaticSet(selection, batch, commit);
|
|
210
218
|
}
|
|
211
219
|
}
|
|
212
220
|
|
|
@@ -236,18 +244,22 @@ function findGroupOwningStaticWrite(
|
|
|
236
244
|
async function addGlobalStaticSet(
|
|
237
245
|
selection: DomEditSelection,
|
|
238
246
|
batch: [string, number | string][],
|
|
239
|
-
selector: string,
|
|
240
247
|
commit: Commit,
|
|
241
248
|
): Promise<void> {
|
|
242
249
|
const numericProps: SetPatchProps = {};
|
|
243
250
|
for (const [k, v] of batch) {
|
|
244
251
|
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
|
|
245
252
|
}
|
|
253
|
+
// A brand-new write, so it must address ONE element: the selection's own
|
|
254
|
+
// selector is the bare class an id-less element yields, which would hold every
|
|
255
|
+
// sibling. No one-element form means no write at all (see writeTargetSelector).
|
|
256
|
+
const target = writeTargetSelector(selection);
|
|
257
|
+
if (!target) return;
|
|
246
258
|
await commit(
|
|
247
259
|
selection,
|
|
248
260
|
{
|
|
249
261
|
type: "add",
|
|
250
|
-
targetSelector:
|
|
262
|
+
targetSelector: target,
|
|
251
263
|
method: "set",
|
|
252
264
|
position: 0,
|
|
253
265
|
properties: Object.fromEntries(batch),
|
|
@@ -259,7 +271,7 @@ async function addGlobalStaticSet(
|
|
|
259
271
|
...(Object.keys(numericProps).length > 0
|
|
260
272
|
? {
|
|
261
273
|
instantPatch: {
|
|
262
|
-
selector,
|
|
274
|
+
selector: target,
|
|
263
275
|
change: { kind: "global-set" as const, props: numericProps },
|
|
264
276
|
},
|
|
265
277
|
}
|
|
@@ -482,7 +494,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
482
494
|
// contaminating a foreign-group tween. Mirror an existing keyframed tween's
|
|
483
495
|
// time range so the new group animates over the same span. The 0% baseline is
|
|
484
496
|
// an `_auto` endpoint so it tracks the nearest keyframe as you add more.
|
|
485
|
-
|
|
497
|
+
// A fresh tween, so its target must address ONE element; with no
|
|
498
|
+
// one-element form the edit is dropped rather than written onto every
|
|
499
|
+
// class sibling (see writeTargetSelector).
|
|
500
|
+
const newTweenTarget = writeTargetSelector(selection);
|
|
501
|
+
if (selector && newTweenTarget) {
|
|
486
502
|
const template = selectedGsapAnimations.find((a) => !!a.keyframes);
|
|
487
503
|
const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
|
|
488
504
|
const tDur = template ? resolveTweenDuration(template) || 1 : 1;
|
|
@@ -503,7 +519,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
|
503
519
|
selection,
|
|
504
520
|
{
|
|
505
521
|
type: "add-with-keyframes",
|
|
506
|
-
targetSelector:
|
|
522
|
+
targetSelector: newTweenTarget,
|
|
507
523
|
position: roundTo3(tStart),
|
|
508
524
|
duration: roundTo3(tDur),
|
|
509
525
|
keyframes,
|
|
@@ -466,3 +466,47 @@ describe("useEnableKeyframes — flat tween transaction", () => {
|
|
|
466
466
|
});
|
|
467
467
|
});
|
|
468
468
|
});
|
|
469
|
+
|
|
470
|
+
describe("useEnableKeyframes — new tween on a class-only element", () => {
|
|
471
|
+
it("targets the selected sibling alone, not every element sharing its class", async () => {
|
|
472
|
+
window.location.hash = "#/project/test-project";
|
|
473
|
+
usePlayerStore.setState({ currentTime: 1 });
|
|
474
|
+
vi.stubGlobal(
|
|
475
|
+
"fetch",
|
|
476
|
+
vi.fn(async () => ({ ok: true, json: async () => ({ animations: [] }) })),
|
|
477
|
+
);
|
|
478
|
+
const scene = document.body.appendChild(document.createElement("div"));
|
|
479
|
+
scene.id = "scene";
|
|
480
|
+
scene.innerHTML = '<i class="group"></i>'.repeat(5);
|
|
481
|
+
const groups = Array.from(scene.querySelectorAll<HTMLElement>(".group"));
|
|
482
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
483
|
+
const enable = renderEnableKeyframes({
|
|
484
|
+
// A bare `.group` here writes `tl.to(".group", …)`, which animates all five
|
|
485
|
+
// siblings and reads back as all five — one add wiped the timeline.
|
|
486
|
+
domEditSelection: {
|
|
487
|
+
selector: ".group",
|
|
488
|
+
selectorIndex: 3,
|
|
489
|
+
sourceFile: "index.html",
|
|
490
|
+
element: groups[3],
|
|
491
|
+
dataAttributes: { start: "0", duration: "2" },
|
|
492
|
+
} as unknown as DomEditSelection,
|
|
493
|
+
selectedGsapAnimations: [],
|
|
494
|
+
previewIframeRef: {
|
|
495
|
+
current: {
|
|
496
|
+
contentWindow: { gsap: { getProperty: () => 7 } },
|
|
497
|
+
} as unknown as HTMLIFrameElement,
|
|
498
|
+
},
|
|
499
|
+
handleGsapAddAnimation: vi.fn(),
|
|
500
|
+
handleGsapConvertToKeyframes: vi.fn(),
|
|
501
|
+
handleGsapRemoveKeyframe: vi.fn(),
|
|
502
|
+
commitMutation,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
await act(async () => enable());
|
|
506
|
+
|
|
507
|
+
const mutation = commitMutation.mock.calls[0]?.[0] as { targetSelector: string };
|
|
508
|
+
expect(mutation?.targetSelector).toBeTruthy();
|
|
509
|
+
expect(document.querySelectorAll(mutation.targetSelector)).toHaveLength(1);
|
|
510
|
+
expect(document.querySelector(mutation.targetSelector)).toBe(groups[3]);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
@@ -13,11 +13,12 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
13
13
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
14
14
|
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
|
15
15
|
import {
|
|
16
|
-
|
|
16
|
+
existingTweenTargetSelector,
|
|
17
17
|
computeElementPercentage,
|
|
18
18
|
KEYFRAME_PCT_MATCH,
|
|
19
19
|
isInstantHold,
|
|
20
20
|
resolveEditableTweenDuration,
|
|
21
|
+
writeTargetSelector,
|
|
21
22
|
} from "./gsapShared";
|
|
22
23
|
import {
|
|
23
24
|
absoluteToPercentage,
|
|
@@ -250,7 +251,10 @@ async function extendKeyframedTweenToPlayhead(
|
|
|
250
251
|
iframe: HTMLIFrameElement | null,
|
|
251
252
|
commitOverrides?: Partial<CommitMutationOptions>,
|
|
252
253
|
): Promise<void> {
|
|
253
|
-
|
|
254
|
+
// Re-authoring an EXISTING tween: keep the target it already writes rather
|
|
255
|
+
// than re-deriving one from the selection, which would widen a tween already
|
|
256
|
+
// narrowed to one element back onto its class siblings (existingTweenTargetSelector).
|
|
257
|
+
const selector = existingTweenTargetSelector(anim, sel);
|
|
254
258
|
const position = readElementPosition(iframe, sel, anim);
|
|
255
259
|
if (!selector || Object.keys(position).length === 0 || !session.commitMutation) return;
|
|
256
260
|
const extended = buildExtendedKeyframes(anim, currentTime, position, duration);
|
|
@@ -335,7 +339,10 @@ export async function promoteSetToKeyframes(
|
|
|
335
339
|
t: number,
|
|
336
340
|
iframe: HTMLIFrameElement | null,
|
|
337
341
|
): Promise<void> {
|
|
338
|
-
|
|
342
|
+
// Re-authoring an EXISTING tween: keep the target it already writes rather
|
|
343
|
+
// than re-deriving one from the selection, which would widen a tween already
|
|
344
|
+
// narrowed to one element back onto its class siblings (existingTweenTargetSelector).
|
|
345
|
+
const selector = existingTweenTargetSelector(setAnim, sel);
|
|
339
346
|
const setStart = resolveTweenStart(setAnim) ?? 0;
|
|
340
347
|
if (!selector || !session.commitMutation) return;
|
|
341
348
|
// Playhead at or before the set → there's no forward range to promote into.
|
|
@@ -389,7 +396,10 @@ export async function applyArcKeyframeAtPlayhead(
|
|
|
389
396
|
iframe: HTMLIFrameElement | null,
|
|
390
397
|
): Promise<void> {
|
|
391
398
|
if (!session.commitMutation) return;
|
|
392
|
-
|
|
399
|
+
// Re-authoring an EXISTING tween: keep the target it already writes rather
|
|
400
|
+
// than re-deriving one from the selection, which would widen a tween already
|
|
401
|
+
// narrowed to one element back onto its class siblings (existingTweenTargetSelector).
|
|
402
|
+
const targetSelector = existingTweenTargetSelector(arcAnim, sel);
|
|
393
403
|
if (!targetSelector) return;
|
|
394
404
|
const start = resolveTweenStart(arcAnim) ?? 0;
|
|
395
405
|
const duration = resolveEditableTweenDuration(arcAnim, sel);
|
|
@@ -523,7 +533,10 @@ export function useEnableKeyframes(
|
|
|
523
533
|
sel.dataAttributes?.duration,
|
|
524
534
|
t,
|
|
525
535
|
);
|
|
526
|
-
|
|
536
|
+
// A brand-new tween: author it against the one element the user selected.
|
|
537
|
+
// The bare class selectorFromSelection hands back for an id-less element
|
|
538
|
+
// animates every sibling sharing the class (see writeTargetSelector).
|
|
539
|
+
const selector = writeTargetSelector(sel);
|
|
527
540
|
|
|
528
541
|
if (!selector) {
|
|
529
542
|
session.handleGsapAddAnimation("to");
|
|
@@ -13,7 +13,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
13
13
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
14
14
|
import { roundTo3 } from "../utils/rounding";
|
|
15
15
|
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
16
|
-
import { isInstantHold, idSelector } from "./gsapShared";
|
|
16
|
+
import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
|
|
17
17
|
|
|
18
18
|
type RecordedKeyframe = {
|
|
19
19
|
percentage: number;
|
|
@@ -168,11 +168,24 @@ export function useGestureCommit({
|
|
|
168
168
|
if (!sortedPcts.includes(0)) sortedPcts.unshift(0);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// Two different jobs, two different selectors. `selector` is the string an
|
|
172
|
+
// ALREADY-AUTHORED tween is matched against (and retargeted with, so a
|
|
173
|
+
// tween aimed at a whole group stays aimed at it). `writeSelector` is what
|
|
174
|
+
// a NEW tween is authored with: the bare class the id-less case yields here
|
|
175
|
+
// would record the gesture onto every sibling sharing it.
|
|
171
176
|
const selector = sel.id ? idSelector(sel.id) : sel.selector;
|
|
172
177
|
if (!selector) {
|
|
173
178
|
showToast("Cannot save — element has no selector", "error");
|
|
174
179
|
return;
|
|
175
180
|
}
|
|
181
|
+
// A recorded gesture becomes a NEW tween, so its target must address one
|
|
182
|
+
// element; the selection's own selector would record the motion onto
|
|
183
|
+
// every sibling sharing its class (see writeTargetSelector).
|
|
184
|
+
const writeSelector = writeTargetSelector(sel);
|
|
185
|
+
if (!writeSelector) {
|
|
186
|
+
showToast("Cannot save: element has no unique selector", "error");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
176
189
|
if (liveSession.commitMutation) {
|
|
177
190
|
const recStart = recordingStartTimeRef.current;
|
|
178
191
|
const rawKeyframes = sortedPcts.map((pct) => ({
|
|
@@ -186,7 +199,11 @@ export function useGestureCommit({
|
|
|
186
199
|
);
|
|
187
200
|
const allAnims = liveSession.selectedGsapAnimations ?? [];
|
|
188
201
|
const existingPositionTween = hasPositionProps
|
|
189
|
-
? allAnims.find(
|
|
202
|
+
? allAnims.find(
|
|
203
|
+
(a) =>
|
|
204
|
+
a.propertyGroup === "position" &&
|
|
205
|
+
tweenTargetsElement(a.targetSelector, selector, sel.element),
|
|
206
|
+
)
|
|
190
207
|
: undefined;
|
|
191
208
|
if (existingPositionTween) {
|
|
192
209
|
if (isInstantHold(existingPositionTween)) {
|
|
@@ -261,7 +278,7 @@ export function useGestureCommit({
|
|
|
261
278
|
await liveSession.commitMutation(
|
|
262
279
|
{
|
|
263
280
|
type: "add-with-keyframes",
|
|
264
|
-
targetSelector:
|
|
281
|
+
targetSelector: writeSelector,
|
|
265
282
|
position: roundTo3(recStart),
|
|
266
283
|
duration: roundTo3(duration),
|
|
267
284
|
keyframes: groupKfs,
|
|
@@ -287,7 +304,7 @@ export function useGestureCommit({
|
|
|
287
304
|
await liveSession.commitMutation(
|
|
288
305
|
{
|
|
289
306
|
type: "add-with-keyframes",
|
|
290
|
-
targetSelector:
|
|
307
|
+
targetSelector: writeSelector,
|
|
291
308
|
position: roundTo3(recStart),
|
|
292
309
|
duration: roundTo3(duration),
|
|
293
310
|
keyframes: groupKfs,
|
|
@@ -186,6 +186,9 @@ function syncCommittedGsapMutation({
|
|
|
186
186
|
targetPath,
|
|
187
187
|
selection.id ?? undefined,
|
|
188
188
|
mutation,
|
|
189
|
+
// The live preview document is what resolves a class / descendant tween to
|
|
190
|
+
// the elements it really animates; without it only whole-id selectors do.
|
|
191
|
+
iframe?.contentDocument,
|
|
189
192
|
);
|
|
190
193
|
}
|
|
191
194
|
refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
|
|
@@ -86,16 +86,24 @@ describe("resolveSelectorElementIds", () => {
|
|
|
86
86
|
expect(resolveSelectorElementIds(".a, .b", doc).sort()).toEqual(["x", "y"]);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
// A DOM-less LEADING-id fallback attributed `#card .label` to `#card`, the
|
|
90
|
+
// ancestor it merely scopes to. Without a DOM there is nothing to resolve the
|
|
91
|
+
// descendant against, so the honest answer is no element at all.
|
|
92
|
+
it("resolves nothing for a compound selector when there is no DOM", () => {
|
|
93
|
+
expect(resolveSelectorElementIds("#card .label", null)).toEqual([]);
|
|
91
94
|
expect(resolveSelectorElementIds(".dot", null)).toEqual([]);
|
|
92
95
|
});
|
|
93
96
|
|
|
97
|
+
it("still resolves every whole-id part of a group selector without a DOM", () => {
|
|
98
|
+
expect(resolveSelectorElementIds("#a, #b", null)).toEqual(["a", "b"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
94
101
|
// The `[id="…"]` form is what writers emit for a CSS-unsafe id (digit-leading,
|
|
95
102
|
// dotted). The old local `#id`-only regex read no id at all for those, so they
|
|
96
103
|
// silently dropped out of both DOM-less paths.
|
|
97
104
|
it("falls back to a bracketed id when there is no DOM", () => {
|
|
98
|
-
expect(resolveSelectorElementIds('[id="01-hook"]
|
|
105
|
+
expect(resolveSelectorElementIds('[id="01-hook"]', null)).toEqual(["01-hook"]);
|
|
106
|
+
expect(resolveSelectorElementIds('[id="01-hook"] .label', null)).toEqual([]);
|
|
99
107
|
});
|
|
100
108
|
|
|
101
109
|
it("falls back to a bracketed id when querySelectorAll rejects the selector", () => {
|
|
@@ -7,24 +7,17 @@ import {
|
|
|
7
7
|
pruneKeyframeCacheToFiles,
|
|
8
8
|
writeGsapAnimationsForElement,
|
|
9
9
|
} from "./gsapKeyframeCacheHelpers";
|
|
10
|
-
import { toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
|
10
|
+
import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
|
11
11
|
import {
|
|
12
12
|
deduplicateKeyframes,
|
|
13
13
|
isStaticPositionHold,
|
|
14
14
|
synthesizeFlatTweenKeyframes,
|
|
15
15
|
} from "./gsapTweenSynth";
|
|
16
|
-
import {
|
|
17
|
-
fetchParsedAnimations,
|
|
18
|
-
populateKeyframeCacheFromAst,
|
|
19
|
-
resolveClipTimingBasis,
|
|
20
|
-
} from "./keyframeCacheAstLoad";
|
|
16
|
+
import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
|
|
21
17
|
|
|
22
18
|
// Re-exported so callers keep importing the GSAP cache surface from one module.
|
|
23
|
-
export {
|
|
24
|
-
|
|
25
|
-
resolveClipTimingBasis,
|
|
26
|
-
resolveSelectorElementIds,
|
|
27
|
-
} from "./keyframeCacheAstLoad";
|
|
19
|
+
export { resolveClipTimingBasis } from "./gsapShared";
|
|
20
|
+
export { fetchParsedAnimations, resolveSelectorElementIds } from "./keyframeCacheAstLoad";
|
|
28
21
|
|
|
29
22
|
/** The selected element's identity for matching tweens to it. */
|
|
30
23
|
export interface GsapElementTarget {
|
|
@@ -22,7 +22,10 @@ export function LayerDisclosureRow({
|
|
|
22
22
|
/** Same adaptive width the lane rows use: a narrowed header column must not
|
|
23
23
|
* leave this row hanging over the clips it labels. */
|
|
24
24
|
columnWidth: number;
|
|
25
|
-
/** Id of the element holding the lanes this row's caret
|
|
25
|
+
/** Id of the CANVAS-side element holding the diamond lanes this row's caret
|
|
26
|
+
* expands (see TimelinePropertyLanes). The caret also reveals the per-lane
|
|
27
|
+
* control rows in this column, but the diamonds are what following the
|
|
28
|
+
* reference should land on. */
|
|
26
29
|
lanesId: string;
|
|
27
30
|
onToggleClipExpanded: () => void;
|
|
28
31
|
/** Trailing controls that act on the LAYER (the visibility eye), not on a lane. */
|
|
@@ -1,7 +1,146 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
hasUnloadedAssets,
|
|
8
|
+
Player,
|
|
9
|
+
readPreviewErrorMessage,
|
|
10
|
+
shouldShowCompositionLoadingOverlay,
|
|
11
|
+
} from "./Player";
|
|
12
|
+
|
|
13
|
+
vi.mock("@hyperframes/player", () => ({}));
|
|
14
|
+
|
|
15
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
16
|
+
|
|
17
|
+
let root: Root | null = null;
|
|
18
|
+
let lifecycleLog: string[] = [];
|
|
19
|
+
|
|
20
|
+
class TestHyperframesPlayer extends HTMLElement {
|
|
21
|
+
readonly iframeElement = document.createElement("iframe");
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
super();
|
|
25
|
+
|
|
26
|
+
const addIframeListener = this.iframeElement.addEventListener.bind(this.iframeElement);
|
|
27
|
+
this.iframeElement.addEventListener = ((type, listener, options) => {
|
|
28
|
+
lifecycleLog.push(`iframe:${type}`);
|
|
29
|
+
addIframeListener(type, listener, options);
|
|
30
|
+
}) as typeof this.iframeElement.addEventListener;
|
|
31
|
+
|
|
32
|
+
const addPlayerListener = this.addEventListener.bind(this);
|
|
33
|
+
this.addEventListener = ((type, listener, options) => {
|
|
34
|
+
lifecycleLog.push(`player:${type}`);
|
|
35
|
+
addPlayerListener(type, listener, options);
|
|
36
|
+
}) as typeof this.addEventListener;
|
|
37
|
+
|
|
38
|
+
const setPlayerAttribute = this.setAttribute.bind(this);
|
|
39
|
+
this.setAttribute = (name, value) => {
|
|
40
|
+
if (name === "src") lifecycleLog.push("src");
|
|
41
|
+
setPlayerAttribute(name, value);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!customElements.get("hyperframes-player")) {
|
|
47
|
+
customElements.define("hyperframes-player", TestHyperframesPlayer);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
if (root) act(() => root?.unmount());
|
|
52
|
+
root = null;
|
|
53
|
+
lifecycleLog = [];
|
|
54
|
+
document.body.innerHTML = "";
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function mountPlayer() {
|
|
58
|
+
const host = document.createElement("div");
|
|
59
|
+
document.body.append(host);
|
|
60
|
+
root = createRoot(host);
|
|
61
|
+
await act(async () => {
|
|
62
|
+
root?.render(
|
|
63
|
+
createElement(Player, {
|
|
64
|
+
directUrl: "/api/projects/demo/preview",
|
|
65
|
+
onLoad: vi.fn(),
|
|
66
|
+
suppressLoadingOverlay: true,
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const player = host.querySelector<TestHyperframesPlayer>("hyperframes-player");
|
|
73
|
+
if (!player) throw new Error("player did not mount");
|
|
74
|
+
return { host, player };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createAudioIframe() {
|
|
78
|
+
const iframe = document.createElement("iframe");
|
|
79
|
+
document.body.appendChild(iframe);
|
|
80
|
+
const audio = iframe.contentDocument?.createElement("audio");
|
|
81
|
+
expect(audio).toBeDefined();
|
|
82
|
+
iframe.contentDocument?.body.appendChild(audio!);
|
|
83
|
+
return { audio: audio!, iframe };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("preview errors", () => {
|
|
87
|
+
it("reads the player probe error for the visible retry state", () => {
|
|
88
|
+
expect(
|
|
89
|
+
readPreviewErrorMessage(
|
|
90
|
+
new CustomEvent("error", {
|
|
91
|
+
detail: { message: "Composition timeline not found after 8s" },
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
).toBe("Composition timeline not found after 8s");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("falls back when the player emits an unstructured error", () => {
|
|
98
|
+
expect(readPreviewErrorMessage(new Event("error"))).toBe(
|
|
99
|
+
"The composition preview did not become ready.",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("attaches lifecycle listeners before navigating the player", async () => {
|
|
104
|
+
await mountPlayer();
|
|
105
|
+
const srcIndex = lifecycleLog.indexOf("src");
|
|
106
|
+
|
|
107
|
+
expect(srcIndex).toBeGreaterThan(-1);
|
|
108
|
+
for (const listener of [
|
|
109
|
+
"iframe:load",
|
|
110
|
+
"player:click",
|
|
111
|
+
"player:shadertransitionstate",
|
|
112
|
+
"player:ready",
|
|
113
|
+
"player:error",
|
|
114
|
+
]) {
|
|
115
|
+
expect(lifecycleLog.indexOf(listener)).toBeGreaterThan(-1);
|
|
116
|
+
expect(lifecycleLog.indexOf(listener)).toBeLessThan(srcIndex);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("retries a failed preview with a fresh player URL", async () => {
|
|
121
|
+
const { host, player } = await mountPlayer();
|
|
122
|
+
|
|
123
|
+
act(() => {
|
|
124
|
+
player.dispatchEvent(
|
|
125
|
+
new CustomEvent("error", {
|
|
126
|
+
detail: { message: "Composition timeline not found after 8s" },
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(host.querySelector('[data-testid="composition-preview-error"]')).not.toBeNull();
|
|
132
|
+
const retry = Array.from(host.querySelectorAll("button")).find(
|
|
133
|
+
(button) => button.textContent === "Retry preview",
|
|
134
|
+
);
|
|
135
|
+
if (!retry) throw new Error("retry action did not render");
|
|
136
|
+
|
|
137
|
+
act(() => retry.click());
|
|
138
|
+
|
|
139
|
+
const retryUrl = new URL(player.getAttribute("src") ?? "", window.location.origin);
|
|
140
|
+
expect(retryUrl.searchParams.get("_hfStudioRetry")).toBe("1");
|
|
141
|
+
expect(host.querySelector('[data-testid="composition-preview-error"]')).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
5
144
|
|
|
6
145
|
describe("composition loading overlay", () => {
|
|
7
146
|
it("shows while the composition is loading", () => {
|
|
@@ -13,10 +152,7 @@ describe("composition loading overlay", () => {
|
|
|
13
152
|
});
|
|
14
153
|
|
|
15
154
|
it("keeps the asset overlay up while media is still buffering", () => {
|
|
16
|
-
const iframe =
|
|
17
|
-
document.body.appendChild(iframe);
|
|
18
|
-
const audio = iframe.contentDocument?.createElement("audio");
|
|
19
|
-
expect(audio).toBeDefined();
|
|
155
|
+
const { audio, iframe } = createAudioIframe();
|
|
20
156
|
Object.defineProperty(audio, "readyState", {
|
|
21
157
|
value: 0,
|
|
22
158
|
configurable: true,
|
|
@@ -25,7 +161,6 @@ describe("composition loading overlay", () => {
|
|
|
25
161
|
value: 2,
|
|
26
162
|
configurable: true,
|
|
27
163
|
});
|
|
28
|
-
iframe.contentDocument?.body.appendChild(audio!);
|
|
29
164
|
|
|
30
165
|
expect(hasUnloadedAssets(iframe, false)).toBe(true);
|
|
31
166
|
|
|
@@ -33,10 +168,7 @@ describe("composition loading overlay", () => {
|
|
|
33
168
|
});
|
|
34
169
|
|
|
35
170
|
it("does not keep the asset overlay stuck on failed media sources", () => {
|
|
36
|
-
const iframe =
|
|
37
|
-
document.body.appendChild(iframe);
|
|
38
|
-
const audio = iframe.contentDocument?.createElement("audio");
|
|
39
|
-
expect(audio).toBeDefined();
|
|
171
|
+
const { audio, iframe } = createAudioIframe();
|
|
40
172
|
Object.defineProperty(audio, "error", {
|
|
41
173
|
value: { code: 4, message: "format error" },
|
|
42
174
|
configurable: true,
|
|
@@ -49,7 +181,6 @@ describe("composition loading overlay", () => {
|
|
|
49
181
|
value: 3,
|
|
50
182
|
configurable: true,
|
|
51
183
|
});
|
|
52
|
-
iframe.contentDocument?.body.appendChild(audio!);
|
|
53
184
|
|
|
54
185
|
expect(hasUnloadedAssets(iframe, false)).toBe(false);
|
|
55
186
|
|