@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
|
@@ -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
|
+
});
|
|
@@ -40,6 +40,47 @@ function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
describe("composition card drag", () => {
|
|
43
|
+
it("uses a cached image instead of eagerly mounting a live preview iframe", () => {
|
|
44
|
+
const { host } = mount();
|
|
45
|
+
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
|
46
|
+
expect(thumbnail).not.toBeNull();
|
|
47
|
+
expect(new URL(thumbnail?.src ?? "").searchParams.get("t")).toBe("3.00");
|
|
48
|
+
expect(host.querySelector("iframe")).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("shows a fallback when the cached thumbnail fails", () => {
|
|
52
|
+
const { host } = mount();
|
|
53
|
+
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
|
54
|
+
if (!thumbnail) throw new Error("composition thumbnail did not render");
|
|
55
|
+
|
|
56
|
+
act(() => thumbnail.dispatchEvent(new Event("error")));
|
|
57
|
+
|
|
58
|
+
expect(host.textContent).toContain("Preview unavailable");
|
|
59
|
+
expect(host.querySelector('img[src*="/thumbnail/"]')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("mounts one live preview only after sustained hover and removes it on leave", () => {
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
65
|
+
try {
|
|
66
|
+
const { host, card } = mount();
|
|
67
|
+
act(() => {
|
|
68
|
+
card.dispatchEvent(new Event("pointerover", { bubbles: true }));
|
|
69
|
+
vi.advanceTimersByTime(300);
|
|
70
|
+
});
|
|
71
|
+
expect(host.querySelectorAll("iframe")).toHaveLength(1);
|
|
72
|
+
|
|
73
|
+
act(() => {
|
|
74
|
+
card.dispatchEvent(new Event("pointerout", { bubbles: true }));
|
|
75
|
+
});
|
|
76
|
+
expect(host.querySelector("iframe")).toBeNull();
|
|
77
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
78
|
+
} finally {
|
|
79
|
+
consoleError.mockRestore();
|
|
80
|
+
vi.useRealTimers();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
43
84
|
it("keeps ordinary click navigation", () => {
|
|
44
85
|
const { card, onSelect } = mount();
|
|
45
86
|
act(() => card.click());
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
|
3
|
+
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
|
3
4
|
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
|
4
5
|
|
|
5
6
|
interface CompositionsTabProps {
|
|
@@ -130,6 +131,8 @@ function CompCard({
|
|
|
130
131
|
}) {
|
|
131
132
|
const [hovered, setHovered] = useState(false);
|
|
132
133
|
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
|
134
|
+
const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
|
|
135
|
+
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
|
133
136
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
134
137
|
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
135
138
|
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -158,10 +161,21 @@ function CompCard({
|
|
|
158
161
|
clearTimeout(hoverTimer.current);
|
|
159
162
|
hoverTimer.current = null;
|
|
160
163
|
}
|
|
164
|
+
if (syncTimer.current) {
|
|
165
|
+
clearTimeout(syncTimer.current);
|
|
166
|
+
syncTimer.current = null;
|
|
167
|
+
}
|
|
161
168
|
setHovered(false);
|
|
169
|
+
setLivePreviewLoaded(false);
|
|
162
170
|
};
|
|
163
171
|
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
164
172
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
|
173
|
+
const thumbnailUrl = buildCompositionThumbnailUrl({
|
|
174
|
+
previewUrl,
|
|
175
|
+
seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
|
|
176
|
+
duration: 0,
|
|
177
|
+
origin: window.location.origin,
|
|
178
|
+
});
|
|
165
179
|
const previewScale = resolveCompositionPreviewScale({
|
|
166
180
|
cardWidth: CARD_W,
|
|
167
181
|
cardHeight: CARD_H,
|
|
@@ -172,7 +186,7 @@ function CompCard({
|
|
|
172
186
|
const thumbnailOffsetY = (CARD_H - stageSize.height * previewScale) / 2;
|
|
173
187
|
|
|
174
188
|
useEffect(() => {
|
|
175
|
-
|
|
189
|
+
if (hovered) requestIframePlaybackSync(true);
|
|
176
190
|
}, [hovered, requestIframePlaybackSync]);
|
|
177
191
|
|
|
178
192
|
useEffect(() => {
|
|
@@ -216,36 +230,56 @@ function CompCard({
|
|
|
216
230
|
}`}
|
|
217
231
|
>
|
|
218
232
|
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
233
|
+
{thumbnailFailed ? (
|
|
234
|
+
<div className="absolute inset-0 flex items-center justify-center px-1 text-center text-[8px] leading-tight text-neutral-600">
|
|
235
|
+
Preview unavailable
|
|
236
|
+
</div>
|
|
237
|
+
) : (
|
|
238
|
+
<img
|
|
239
|
+
src={thumbnailUrl}
|
|
240
|
+
alt=""
|
|
241
|
+
draggable={false}
|
|
242
|
+
loading="lazy"
|
|
243
|
+
decoding="async"
|
|
244
|
+
onError={() => setThumbnailFailed(true)}
|
|
245
|
+
className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
|
|
246
|
+
livePreviewLoaded ? "opacity-0" : "opacity-100"
|
|
247
|
+
}`}
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
250
|
+
{hovered && (
|
|
251
|
+
<iframe
|
|
252
|
+
ref={iframeRef}
|
|
253
|
+
src={previewUrl}
|
|
254
|
+
sandbox="allow-scripts allow-same-origin"
|
|
255
|
+
className="absolute border-none pointer-events-none"
|
|
256
|
+
style={{
|
|
257
|
+
transformOrigin: "0 0",
|
|
258
|
+
width: stageSize.width,
|
|
259
|
+
height: stageSize.height,
|
|
260
|
+
left: thumbnailOffsetX,
|
|
261
|
+
top: thumbnailOffsetY,
|
|
262
|
+
transform: `scale(${previewScale})`,
|
|
263
|
+
}}
|
|
264
|
+
onLoad={(e) => {
|
|
265
|
+
try {
|
|
266
|
+
const iframe = e.currentTarget;
|
|
267
|
+
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
|
|
268
|
+
const width =
|
|
269
|
+
Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
|
|
270
|
+
const height =
|
|
271
|
+
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
|
|
272
|
+
setStageSize({ width, height });
|
|
273
|
+
setLivePreviewLoaded(true);
|
|
274
|
+
requestIframePlaybackSync(true);
|
|
275
|
+
} catch {
|
|
276
|
+
setStageSize(DEFAULT_PREVIEW_STAGE);
|
|
277
|
+
}
|
|
278
|
+
}}
|
|
279
|
+
title={`${name} preview`}
|
|
280
|
+
tabIndex={-1}
|
|
281
|
+
/>
|
|
282
|
+
)}
|
|
249
283
|
</div>
|
|
250
284
|
<div
|
|
251
285
|
className="min-w-0 flex-1"
|
|
@@ -283,7 +317,10 @@ function CompCard({
|
|
|
283
317
|
e.stopPropagation();
|
|
284
318
|
onRender();
|
|
285
319
|
}}
|
|
286
|
-
|
|
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 ${
|
|
287
324
|
isRendering
|
|
288
325
|
? "text-neutral-600 cursor-not-allowed"
|
|
289
326
|
: "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
|
|
@@ -331,7 +368,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
331
368
|
<div className="flex-1 overflow-y-auto">
|
|
332
369
|
{compositions.map((comp) => (
|
|
333
370
|
<CompCard
|
|
334
|
-
key={comp}
|
|
371
|
+
key={`${projectId}:${comp}`}
|
|
335
372
|
projectId={projectId}
|
|
336
373
|
comp={comp}
|
|
337
374
|
isActive={activeComposition === comp}
|
|
@@ -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",
|