@hyperframes/studio 0.7.78 → 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-uiAAPKvw.js → hyperframes-player-mFah2TZE.js} +1 -1
- package/dist/assets/{index-CDSTMtUs.js → index-Bqj3h_1a.js} +1 -1
- package/dist/assets/{index-BSZrK0bx.js → index-DlZMDyYs.js} +194 -194
- package/dist/assets/index-gGVKuFg5.css +1 -0
- package/dist/assets/{index-r1tKKlU7.js → index-hmnoiSEV.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1303 -1121
- 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.tsx +4 -1
- 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/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-DpkO2Hvw.css +0 -1
|
@@ -73,9 +73,9 @@ export function TimelineResizeDivider({
|
|
|
73
73
|
);
|
|
74
74
|
|
|
75
75
|
return (
|
|
76
|
-
// Horizontal resize divider: 3px visible seam (h-[3px]),
|
|
76
|
+
// Horizontal resize divider: 3px visible seam (h-[3px]), 10px pointer-capture
|
|
77
77
|
// zone via the absolutely-positioned inner hit area so the layout gap stays
|
|
78
|
-
// at 3px while draggability is preserved over the full
|
|
78
|
+
// at 3px while draggability is preserved over the full band.
|
|
79
79
|
<div
|
|
80
80
|
role="separator"
|
|
81
81
|
aria-orientation="horizontal"
|
|
@@ -94,8 +94,13 @@ export function TimelineResizeDivider({
|
|
|
94
94
|
onPointerCancel={handlePointerUp}
|
|
95
95
|
onKeyDown={handleKeyDown}
|
|
96
96
|
>
|
|
97
|
-
{/* Expanded hit zone:
|
|
98
|
-
|
|
97
|
+
{/* Expanded hit zone, deliberately asymmetric: 4px up into the transport
|
|
98
|
+
row's vertical centring slack, the 3px seam, then 3px down into the
|
|
99
|
+
timeline card's border + toolbar padding. It stops at 10px rather than
|
|
100
|
+
the 24px WCAG 2.2 (2.5.8) target because that is all the dead space
|
|
101
|
+
there is: 28px transport buttons sit above and 28px toolbar buttons
|
|
102
|
+
below, and silently stealing their clicks is the worse bug. */}
|
|
103
|
+
<div className="absolute inset-x-0 -top-[4px] h-[10px]" />
|
|
99
104
|
{/* Visible hairline — invisible at rest, subtle wash on hover/drag/focus */}
|
|
100
105
|
<div className="h-[3px] w-full bg-transparent transition-colors group-hover:bg-white/12 group-active:bg-white/18 group-focus-visible:bg-studio-accent/60" />
|
|
101
106
|
</div>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { CompositionsTab } from "./sidebar/CompositionsTab";
|
|
7
|
+
import { TimelineToolbar } from "./TimelineToolbar";
|
|
8
|
+
|
|
9
|
+
// WCAG 2.2 (2.5.8) requires a 24x24 CSS pixel pointer target. happy-dom has no
|
|
10
|
+
// CSS engine, so getBoundingClientRect() is 0x0 for everything here and the size
|
|
11
|
+
// itself cannot be measured — these assert the utility classes that PRODUCE the
|
|
12
|
+
// size instead, which is enough to fail if someone drops them. Real geometry
|
|
13
|
+
// still needs a browser check.
|
|
14
|
+
|
|
15
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
16
|
+
(
|
|
17
|
+
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
|
|
18
|
+
).happyDOM.settings.disableIframePageLoading = true;
|
|
19
|
+
|
|
20
|
+
let root: Root | null = null;
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
if (root) act(() => root?.unmount());
|
|
24
|
+
root = null;
|
|
25
|
+
document.body.innerHTML = "";
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function mount(element: React.ReactNode) {
|
|
29
|
+
const host = document.createElement("div");
|
|
30
|
+
document.body.append(host);
|
|
31
|
+
root = createRoot(host);
|
|
32
|
+
act(() => root?.render(element));
|
|
33
|
+
return host;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("pointer target sizing classes (proxy for WCAG 2.5.8, not a geometry check)", () => {
|
|
37
|
+
it("gives the timeline zoom slider a 24px box without changing its 2px track or 10px thumb", () => {
|
|
38
|
+
const host = mount(<TimelineToolbar />);
|
|
39
|
+
const slider = host.querySelector<HTMLInputElement>('input[aria-label="Timeline zoom"]');
|
|
40
|
+
if (!slider) throw new Error("zoom slider did not render");
|
|
41
|
+
|
|
42
|
+
expect(slider.className).toContain("h-6");
|
|
43
|
+
expect(slider.className).toContain("[&::-webkit-slider-runnable-track]:h-[2px]");
|
|
44
|
+
expect(slider.className).toContain("[&::-webkit-slider-thumb]:h-[10px]");
|
|
45
|
+
expect(slider.className).toContain("[&::-webkit-slider-thumb]:w-[10px]");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("gives the composition card's render button a 24x24 box around its 14px glyph", () => {
|
|
49
|
+
const host = mount(
|
|
50
|
+
<CompositionsTab
|
|
51
|
+
projectId="demo"
|
|
52
|
+
compositions={["compositions/headline.html"]}
|
|
53
|
+
activeComposition={null}
|
|
54
|
+
onSelect={vi.fn()}
|
|
55
|
+
onRenderComposition={vi.fn()}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Render headline"]');
|
|
59
|
+
if (!button) throw new Error("render button did not render");
|
|
60
|
+
|
|
61
|
+
expect(button.className).toContain("h-6");
|
|
62
|
+
expect(button.className).toContain("w-6");
|
|
63
|
+
expect(button.querySelector("svg")?.getAttribute("width")).toBe("14");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -317,7 +317,10 @@ function CompCard({
|
|
|
317
317
|
e.stopPropagation();
|
|
318
318
|
onRender();
|
|
319
319
|
}}
|
|
320
|
-
|
|
320
|
+
// h-6 w-6 = the 24x24 WCAG 2.2 (2.5.8) minimum target; the 14px glyph
|
|
321
|
+
// is unchanged, only the box grows. The sibling "+" button is h-8 w-8,
|
|
322
|
+
// so the card row already has the room.
|
|
323
|
+
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded transition-colors ${
|
|
321
324
|
isRendering
|
|
322
325
|
? "text-neutral-600 cursor-not-allowed"
|
|
323
326
|
: "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
|
|
@@ -12,7 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore";
|
|
|
12
12
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
13
13
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
14
14
|
import { roundTo3 } from "../utils/rounding";
|
|
15
|
-
import { computeElementPercentage, idSelector } from "./gsapShared";
|
|
15
|
+
import { computeElementPercentage, idSelector, writeTargetSelector } from "./gsapShared";
|
|
16
16
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
17
17
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
18
18
|
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
|
@@ -44,6 +44,23 @@ export interface GsapDragCommitCallbacks {
|
|
|
44
44
|
fetchAnimations?: () => Promise<GsapAnimation[]>;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The target for a tween these helpers are about to CREATE. Callers derive
|
|
49
|
+
* `selector` with `selectorFromSelection`, which hands back a bare class for an
|
|
50
|
+
* id-less element: authoring that widens a one-element drag/resize/rotate into a
|
|
51
|
+
* write over every sibling sharing the class. Retargets of an EXISTING tween
|
|
52
|
+
* must NOT come through here (they keep `anim.targetSelector`, so a tween the
|
|
53
|
+
* author aimed at a group stays aimed at it).
|
|
54
|
+
*
|
|
55
|
+
* Null means no one-element form exists, and every caller drops the commit
|
|
56
|
+
* rather than falling back to `selector` (see writeTargetSelector): the drag
|
|
57
|
+
* reverts on the next reload, which is recoverable, where a `.group` write is
|
|
58
|
+
* not.
|
|
59
|
+
*/
|
|
60
|
+
function newTweenTarget(selection: DomEditSelection): string | null {
|
|
61
|
+
return writeTargetSelector(selection);
|
|
62
|
+
}
|
|
63
|
+
|
|
47
64
|
// Re-export for backward compatibility with existing imports.
|
|
48
65
|
export function computeCurrentPercentage(
|
|
49
66
|
selection: DomEditSelection,
|
|
@@ -65,17 +82,18 @@ export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void {
|
|
|
65
82
|
|
|
66
83
|
async function replaceKeyframedPositionHold(
|
|
67
84
|
selection: DomEditSelection,
|
|
68
|
-
selector: string,
|
|
69
85
|
existingSet: GsapAnimation,
|
|
70
86
|
properties: { x: number; y: number },
|
|
71
87
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
72
88
|
): Promise<void> {
|
|
89
|
+
const target = newTweenTarget(selection);
|
|
90
|
+
if (!target) return;
|
|
73
91
|
const persist = async (commit: GsapDragCommitCallbacks["commitMutation"]) => {
|
|
74
92
|
await commit(
|
|
75
93
|
selection,
|
|
76
94
|
{
|
|
77
95
|
type: "add",
|
|
78
|
-
targetSelector:
|
|
96
|
+
targetSelector: target,
|
|
79
97
|
method: "set",
|
|
80
98
|
position: 0,
|
|
81
99
|
properties,
|
|
@@ -174,7 +192,6 @@ export async function commitStaticGsapPosition(
|
|
|
174
192
|
// least one hold on disk, then delete the corrupt tween in one transaction.
|
|
175
193
|
await replaceKeyframedPositionHold(
|
|
176
194
|
selection,
|
|
177
|
-
selector,
|
|
178
195
|
existingSet,
|
|
179
196
|
{ x: newX, y: newY },
|
|
180
197
|
callbacks.commitMutation,
|
|
@@ -199,11 +216,15 @@ export async function commitStaticGsapPosition(
|
|
|
199
216
|
}
|
|
200
217
|
// New static hold → a base `gsap.set` (off-timeline, no 0% keyframe marker), with
|
|
201
218
|
// an instant patch so the first nudge shows immediately (no soft-reload flash).
|
|
219
|
+
// The patch reuses the WRITTEN target so the runtime moves exactly the element
|
|
220
|
+
// the source write names.
|
|
221
|
+
const target = newTweenTarget(selection);
|
|
222
|
+
if (!target) return;
|
|
202
223
|
await callbacks.commitMutation(
|
|
203
224
|
selection,
|
|
204
225
|
{
|
|
205
226
|
type: "add",
|
|
206
|
-
targetSelector:
|
|
227
|
+
targetSelector: target,
|
|
207
228
|
method: "set",
|
|
208
229
|
position: 0,
|
|
209
230
|
properties: { x: newX, y: newY },
|
|
@@ -212,7 +233,10 @@ export async function commitStaticGsapPosition(
|
|
|
212
233
|
{
|
|
213
234
|
label: "Move layer",
|
|
214
235
|
softReload: true,
|
|
215
|
-
instantPatch: {
|
|
236
|
+
instantPatch: {
|
|
237
|
+
selector: target,
|
|
238
|
+
change: { kind: "global-set", props: { x: newX, y: newY } },
|
|
239
|
+
},
|
|
216
240
|
},
|
|
217
241
|
);
|
|
218
242
|
}
|
|
@@ -252,11 +276,13 @@ export async function commitStaticGsapRotation(
|
|
|
252
276
|
return;
|
|
253
277
|
}
|
|
254
278
|
// New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
|
|
279
|
+
const target = newTweenTarget(selection);
|
|
280
|
+
if (!target) return;
|
|
255
281
|
await callbacks.commitMutation(
|
|
256
282
|
selection,
|
|
257
283
|
{
|
|
258
284
|
type: "add",
|
|
259
|
-
targetSelector:
|
|
285
|
+
targetSelector: target,
|
|
260
286
|
method: "set",
|
|
261
287
|
position: 0,
|
|
262
288
|
properties: { rotation: newRotation },
|
|
@@ -265,7 +291,10 @@ export async function commitStaticGsapRotation(
|
|
|
265
291
|
{
|
|
266
292
|
label: "Rotate layer",
|
|
267
293
|
softReload: true,
|
|
268
|
-
instantPatch: {
|
|
294
|
+
instantPatch: {
|
|
295
|
+
selector: target,
|
|
296
|
+
change: { kind: "global-set", props: { rotation: newRotation } },
|
|
297
|
+
},
|
|
269
298
|
},
|
|
270
299
|
);
|
|
271
300
|
}
|
|
@@ -301,11 +330,13 @@ export async function commitStaticGsapSize(
|
|
|
301
330
|
);
|
|
302
331
|
return;
|
|
303
332
|
}
|
|
333
|
+
const target = newTweenTarget(selection);
|
|
334
|
+
if (!target) return;
|
|
304
335
|
await callbacks.commitMutation(
|
|
305
336
|
selection,
|
|
306
337
|
{
|
|
307
338
|
type: "add",
|
|
308
|
-
targetSelector:
|
|
339
|
+
targetSelector: target,
|
|
309
340
|
method: "set",
|
|
310
341
|
position: 0,
|
|
311
342
|
properties: { width, height },
|
|
@@ -389,11 +420,13 @@ export async function commitKeyframedSizeFromResize(
|
|
|
389
420
|
// transport applies both in one ordered batch; a plain commit fallback keeps the
|
|
390
421
|
// same recoverable ordering. Only the transaction's result triggers the reload.
|
|
391
422
|
const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`;
|
|
423
|
+
const target = newTweenTarget(selection);
|
|
424
|
+
if (!target) return false;
|
|
392
425
|
await callbacks.commitMutation(
|
|
393
426
|
selection,
|
|
394
427
|
{
|
|
395
428
|
type: "add-with-keyframes",
|
|
396
|
-
targetSelector:
|
|
429
|
+
targetSelector: target,
|
|
397
430
|
position: roundTo3(ts),
|
|
398
431
|
duration: roundTo3(td),
|
|
399
432
|
keyframes,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
|
|
3
|
-
import { isInstantHold } from "./gsapShared";
|
|
3
|
+
import { isInstantHold, tweenTargetsElement } from "./gsapShared";
|
|
4
4
|
|
|
5
5
|
/** The shape of an `update-property` mutation a static-set nudge POSTs. */
|
|
6
6
|
interface UpdatePropertyMutation {
|
|
@@ -34,12 +34,13 @@ export function setPatchFromUpdateProperty(
|
|
|
34
34
|
function findPositionSetAnimation(
|
|
35
35
|
animations: GsapAnimation[],
|
|
36
36
|
selector: string,
|
|
37
|
+
element?: Element | null,
|
|
37
38
|
): GsapAnimation | null {
|
|
38
39
|
return (
|
|
39
40
|
animations.find(
|
|
40
41
|
(a) =>
|
|
41
42
|
a.method === "set" &&
|
|
42
|
-
a.targetSelector
|
|
43
|
+
tweenTargetsElement(a.targetSelector, selector, element) &&
|
|
43
44
|
("x" in a.properties || "y" in a.properties),
|
|
44
45
|
) ?? null
|
|
45
46
|
);
|
|
@@ -61,12 +62,16 @@ function findPositionSetAnimation(
|
|
|
61
62
|
export function findExistingPositionWrite(
|
|
62
63
|
animations: GsapAnimation[],
|
|
63
64
|
selector: string,
|
|
65
|
+
element?: Element | null,
|
|
64
66
|
): GsapAnimation | null {
|
|
65
|
-
const set = findPositionSetAnimation(animations, selector);
|
|
67
|
+
const set = findPositionSetAnimation(animations, selector, element);
|
|
66
68
|
if (set) return set;
|
|
67
69
|
return (
|
|
68
70
|
animations.find(
|
|
69
|
-
(a) =>
|
|
71
|
+
(a) =>
|
|
72
|
+
tweenTargetsElement(a.targetSelector, selector, element) &&
|
|
73
|
+
a.propertyGroup === "position" &&
|
|
74
|
+
isInstantHold(a),
|
|
70
75
|
) ?? null
|
|
71
76
|
);
|
|
72
77
|
}
|
|
@@ -74,10 +79,14 @@ export function findExistingPositionWrite(
|
|
|
74
79
|
export function findRotationSetAnimation(
|
|
75
80
|
animations: GsapAnimation[],
|
|
76
81
|
selector: string,
|
|
82
|
+
element?: Element | null,
|
|
77
83
|
): GsapAnimation | null {
|
|
78
84
|
return (
|
|
79
85
|
animations.find(
|
|
80
|
-
(a) =>
|
|
86
|
+
(a) =>
|
|
87
|
+
isInstantHold(a) &&
|
|
88
|
+
tweenTargetsElement(a.targetSelector, selector, element) &&
|
|
89
|
+
"rotation" in a.properties,
|
|
81
90
|
) ?? null
|
|
82
91
|
);
|
|
83
92
|
}
|
|
@@ -85,12 +94,13 @@ export function findRotationSetAnimation(
|
|
|
85
94
|
export function findSizeSetAnimation(
|
|
86
95
|
animations: GsapAnimation[],
|
|
87
96
|
selector: string,
|
|
97
|
+
element?: Element | null,
|
|
88
98
|
): GsapAnimation | null {
|
|
89
99
|
return (
|
|
90
100
|
animations.find(
|
|
91
101
|
(a) =>
|
|
92
102
|
isInstantHold(a) &&
|
|
93
|
-
a.targetSelector
|
|
103
|
+
tweenTargetsElement(a.targetSelector, selector, element) &&
|
|
94
104
|
("width" in a.properties || "height" in a.properties),
|
|
95
105
|
) ?? null
|
|
96
106
|
);
|
|
@@ -276,6 +276,69 @@ describe("updateKeyframeCacheFromParsed", () => {
|
|
|
276
276
|
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
+
// `#stat3 .block` animates the BLOCK inside #stat3, not #stat3. An unanchored
|
|
280
|
+
// `^#([\w-]+)/` prefix match filed it under "stat3", which both stole the
|
|
281
|
+
// child's diamonds and collided with #stat3's own tween at the shared
|
|
282
|
+
// percentage (dropping #stat3's ease as ambiguous).
|
|
283
|
+
it("attributes a descendant selector to the child, not to its ancestor", () => {
|
|
284
|
+
const parent: GsapAnimation = {
|
|
285
|
+
id: "stat3-fromTo",
|
|
286
|
+
targetSelector: "#stat3",
|
|
287
|
+
method: "fromTo",
|
|
288
|
+
position: 8.88,
|
|
289
|
+
resolvedStart: 8.88,
|
|
290
|
+
duration: 0.25,
|
|
291
|
+
fromProperties: { y: 20 },
|
|
292
|
+
properties: { y: 0 },
|
|
293
|
+
ease: "power2.out",
|
|
294
|
+
propertyGroup: "position",
|
|
295
|
+
};
|
|
296
|
+
const child: GsapAnimation = {
|
|
297
|
+
id: "block-from",
|
|
298
|
+
targetSelector: "#stat3 .block",
|
|
299
|
+
method: "from",
|
|
300
|
+
position: 9.13,
|
|
301
|
+
resolvedStart: 9.13,
|
|
302
|
+
duration: 0.3,
|
|
303
|
+
properties: { opacity: 0 },
|
|
304
|
+
ease: "power2.in",
|
|
305
|
+
propertyGroup: "visual",
|
|
306
|
+
};
|
|
307
|
+
usePlayerStore.setState({
|
|
308
|
+
elements: [
|
|
309
|
+
{ id: "stat3-clip", domId: "stat3", tag: "div", start: 8.88, duration: 1, track: 0 },
|
|
310
|
+
],
|
|
311
|
+
});
|
|
312
|
+
const doc = {
|
|
313
|
+
querySelectorAll: (selector: string) =>
|
|
314
|
+
(selector === "#stat3 .block"
|
|
315
|
+
? [{ id: "stat3-block" }]
|
|
316
|
+
: []) as unknown as NodeListOf<Element>,
|
|
317
|
+
} as unknown as Document;
|
|
318
|
+
|
|
319
|
+
updateKeyframeCacheFromParsed([parent, child], "scene.html", "stat3", {}, doc);
|
|
320
|
+
|
|
321
|
+
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#stat3")).toEqual([parent]);
|
|
322
|
+
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#stat3-block")).toEqual([child]);
|
|
323
|
+
// With the collision gone, #stat3's own curve survives instead of being
|
|
324
|
+
// deleted as an ambiguous same-percentage merge.
|
|
325
|
+
const parentKeyframes = cache().get("scene.html#stat3")?.keyframes ?? [];
|
|
326
|
+
expect(parentKeyframes.at(-1)?.ease).toBe("power2.out");
|
|
327
|
+
expect(parentKeyframes.some((keyframe) => "easeAmbiguous" in keyframe)).toBe(false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("attributes a descendant selector to nothing when there is no document", () => {
|
|
331
|
+
const child: GsapAnimation = {
|
|
332
|
+
...animWithKeyframes("block-from"),
|
|
333
|
+
targetSelector: "#stat3 .block",
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
updateKeyframeCacheFromParsed([child], "scene.html", "stat3", {});
|
|
337
|
+
|
|
338
|
+
expect(cache().has("scene.html#stat3")).toBe(false);
|
|
339
|
+
expect(usePlayerStore.getState().gsapAnimations.has("scene.html#stat3")).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
|
|
279
342
|
it("does not cache a flat tween without animatable numeric properties", () => {
|
|
280
343
|
const animation: GsapAnimation = {
|
|
281
344
|
id: "flat-box",
|
|
@@ -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;
|