@hyperframes/studio 0.6.121 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-CbNg8Ghm.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
- package/dist/assets/{index-CLsc2FW3.js → index-DsxCdV8b.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3757 -2232
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -39
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +7 -1
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-20PAVS4k.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- package/src/player/components/TimelinePropertyRows.tsx +0 -126
|
@@ -128,18 +128,32 @@ export function findClosestByAttribute(
|
|
|
128
128
|
}
|
|
129
129
|
return null;
|
|
130
130
|
}
|
|
131
|
+
// ─── Composition source resolution ───────────────────────────────────────────
|
|
131
132
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
133
|
+
// The runtime INLINES subcompositions and strips the source-file linkage from the
|
|
134
|
+
// mounted root (it keeps `data-composition-id` but drops `data-composition-src`/
|
|
135
|
+
// `-file`), so a subcomp element's DOM ancestors no longer say which file it came
|
|
136
|
+
// from. This project-global map (composition-id → source file, built once from
|
|
137
|
+
// index.html's clips — see NLELayout) recovers it. The studio loads one project at a
|
|
138
|
+
// time, so module scope is the right lifetime; it's empty until set, in which case
|
|
139
|
+
// resolution falls back to the historical attribute-only behavior.
|
|
140
|
+
let compositionSourceMap: Map<string, string> = new Map();
|
|
141
|
+
|
|
142
|
+
export function setCompositionSourceMap(map: Map<string, string>): void {
|
|
143
|
+
compositionSourceMap = map;
|
|
140
144
|
}
|
|
141
145
|
|
|
142
|
-
|
|
146
|
+
function sourceFromCompositionId(ownerRoot: HTMLElement | null): string | undefined {
|
|
147
|
+
if (!ownerRoot || compositionSourceMap.size === 0) return undefined;
|
|
148
|
+
// The runtime may rename the mounted id to a runtime-unique one, preserving the
|
|
149
|
+
// authored id on `data-hf-original-composition-id` — prefer that, then the current id.
|
|
150
|
+
const authored = ownerRoot.getAttribute("data-hf-original-composition-id");
|
|
151
|
+
const current = ownerRoot.getAttribute("data-composition-id");
|
|
152
|
+
return (
|
|
153
|
+
(authored ? compositionSourceMap.get(authored) : undefined) ??
|
|
154
|
+
(current ? compositionSourceMap.get(current) : undefined)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
143
157
|
|
|
144
158
|
export function getSourceFileForElement(
|
|
145
159
|
el: HTMLElement,
|
|
@@ -152,6 +166,7 @@ export function getSourceFileForElement(
|
|
|
152
166
|
sourceHost?.getAttribute("data-composition-src") ??
|
|
153
167
|
ownerRoot?.getAttribute("data-composition-file") ??
|
|
154
168
|
ownerRoot?.getAttribute("data-composition-src") ??
|
|
169
|
+
sourceFromCompositionId(ownerRoot) ??
|
|
155
170
|
activeCompositionPath ??
|
|
156
171
|
"index.html";
|
|
157
172
|
|
|
@@ -24,11 +24,6 @@ describe("manual editing availability", () => {
|
|
|
24
24
|
expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
it("enables GSAP drag intercept by default", async () => {
|
|
28
|
-
const availability = await loadAvailabilityWithEnv({});
|
|
29
|
-
expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(true);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
27
|
it("keeps color grading off by default", async () => {
|
|
33
28
|
const availability = await loadAvailabilityWithEnv({});
|
|
34
29
|
expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(false);
|
|
@@ -41,13 +36,6 @@ describe("manual editing availability", () => {
|
|
|
41
36
|
expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(true);
|
|
42
37
|
});
|
|
43
38
|
|
|
44
|
-
it("disables GSAP drag intercept when env var is false", async () => {
|
|
45
|
-
const availability = await loadAvailabilityWithEnv({
|
|
46
|
-
VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT: "false",
|
|
47
|
-
});
|
|
48
|
-
expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(false);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
39
|
it("disables preview selection when the inspector panel flag is explicitly off", async () => {
|
|
52
40
|
const availability = await loadAvailabilityWithEnv({
|
|
53
41
|
VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "0",
|
|
@@ -73,7 +73,7 @@ export const STUDIO_COLOR_GRADING_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
73
73
|
export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
|
|
74
74
|
env,
|
|
75
75
|
["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"],
|
|
76
|
-
|
|
76
|
+
true,
|
|
77
77
|
);
|
|
78
78
|
|
|
79
79
|
export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
|
|
@@ -92,16 +92,6 @@ export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
92
92
|
false,
|
|
93
93
|
);
|
|
94
94
|
|
|
95
|
-
// When disabled (the default), drag/resize/rotate commits always take the CSS
|
|
96
|
-
// persist path instead of being intercepted into GSAP script keyframe
|
|
97
|
-
// mutations. The keyframe intercept rewrites timeline tweens from drag
|
|
98
|
-
// gestures and is opt-in until its recording path is hardened.
|
|
99
|
-
export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
|
|
100
|
-
env,
|
|
101
|
-
["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"],
|
|
102
|
-
true,
|
|
103
|
-
);
|
|
104
|
-
|
|
105
95
|
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
|
106
96
|
|
|
107
97
|
// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
|
|
@@ -70,6 +70,19 @@ export function readStudioPathOffset(element: HTMLElement): { x: number; y: numb
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* The path offset ACTUALLY applied right now. The `--hf-studio-offset` vars can
|
|
75
|
+
* linger after GSAP re-bakes the element's transform (`translate:"none"`), so the
|
|
76
|
+
* raw var isn't a safe drag base — using it re-commits a phantom offset and flings
|
|
77
|
+
* the element off-screen. The offset only counts when the inline `translate` is the
|
|
78
|
+
* studio var-translate; otherwise it's dormant and the applied offset is zero.
|
|
79
|
+
*/
|
|
80
|
+
export function readAppliedStudioPathOffset(element: HTMLElement): { x: number; y: number } {
|
|
81
|
+
return (element.style.translate || "").includes(STUDIO_OFFSET_X_PROP)
|
|
82
|
+
? readStudioPathOffset(element)
|
|
83
|
+
: { x: 0, y: 0 };
|
|
84
|
+
}
|
|
85
|
+
|
|
73
86
|
export function readStudioBoxSize(element: HTMLElement): { width: number; height: number } {
|
|
74
87
|
return {
|
|
75
88
|
width: readPxCustomProperty(element, STUDIO_WIDTH_PROP),
|
|
@@ -250,6 +263,50 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
|
|
|
250
263
|
}
|
|
251
264
|
}
|
|
252
265
|
|
|
266
|
+
// GSAP owns the element's `transform` (it bakes x/y into a matrix and writes
|
|
267
|
+
// `translate: none` every tick). Folding the drag offset into a CSS `translate`
|
|
268
|
+
// — as the non-GSAP path does — composes ON TOP of GSAP's transform, and the
|
|
269
|
+
// subsequent strip/reapply math compounds into a runaway matrix that flings the
|
|
270
|
+
// element off-canvas. So for GSAP-animated elements we keep `translate: none`
|
|
271
|
+
// and push the offset straight into GSAP's x/y via gsap.set; the var() offset is
|
|
272
|
+
// still persisted (buildPathOffsetPatches), and GSAP re-reads it at init on
|
|
273
|
+
// reload. Returns true when handled as GSAP (caller must skip the CSS path).
|
|
274
|
+
function applyStudioPathOffsetViaGsap(
|
|
275
|
+
element: HTMLElement,
|
|
276
|
+
offset: { x: number; y: number },
|
|
277
|
+
): boolean {
|
|
278
|
+
if (!gsapAnimatesProperty(element, "x", "y")) return false;
|
|
279
|
+
element.style.setProperty("translate", "none");
|
|
280
|
+
const win = element.ownerDocument.defaultView as
|
|
281
|
+
| (Window & {
|
|
282
|
+
gsap?: {
|
|
283
|
+
set: (el: Element, vars: Record<string, unknown>) => void;
|
|
284
|
+
getProperty: (el: Element, prop: string) => number;
|
|
285
|
+
};
|
|
286
|
+
})
|
|
287
|
+
| null;
|
|
288
|
+
if (win?.gsap) {
|
|
289
|
+
const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
|
|
290
|
+
const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
|
|
291
|
+
const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
|
|
292
|
+
const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
|
|
293
|
+
const gsapBaseX = Number.isFinite(baseX)
|
|
294
|
+
? baseX
|
|
295
|
+
: (win.gsap.getProperty(element, "x") as number);
|
|
296
|
+
const gsapBaseY = Number.isFinite(baseY)
|
|
297
|
+
? baseY
|
|
298
|
+
: (win.gsap.getProperty(element, "y") as number);
|
|
299
|
+
if (!Number.isFinite(baseX))
|
|
300
|
+
element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
|
|
301
|
+
if (!Number.isFinite(baseY))
|
|
302
|
+
element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
|
|
303
|
+
const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
|
|
304
|
+
const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
|
|
305
|
+
win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
|
|
306
|
+
}
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
253
310
|
export function applyStudioPathOffset(
|
|
254
311
|
element: HTMLElement,
|
|
255
312
|
offset: { x: number; y: number },
|
|
@@ -257,6 +314,10 @@ export function applyStudioPathOffset(
|
|
|
257
314
|
): void {
|
|
258
315
|
promoteInlineForTransform(element);
|
|
259
316
|
writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true });
|
|
317
|
+
// GSAP elements: route through gsap.set, NOT a CSS translate (would corrupt the
|
|
318
|
+
// matrix). Symmetrical with applyStudioPathOffsetDraft — the commit path used to
|
|
319
|
+
// skip this branch, which is what flung dragged GSAP elements off-canvas.
|
|
320
|
+
if (applyStudioPathOffsetViaGsap(element, offset)) return;
|
|
260
321
|
element.style.setProperty(
|
|
261
322
|
"translate",
|
|
262
323
|
composeTranslateValue(
|
|
@@ -274,45 +335,13 @@ export function applyStudioPathOffsetDraft(
|
|
|
274
335
|
): void {
|
|
275
336
|
promoteInlineForTransform(element);
|
|
276
337
|
writeStudioPathOffsetVars(element, offset, { updateBase: false });
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
set: (el: Element, vars: Record<string, unknown>) => void;
|
|
285
|
-
getProperty: (el: Element, prop: string) => number;
|
|
286
|
-
};
|
|
287
|
-
})
|
|
288
|
-
| null;
|
|
289
|
-
if (win?.gsap) {
|
|
290
|
-
const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
|
|
291
|
-
const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
|
|
292
|
-
const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
|
|
293
|
-
const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
|
|
294
|
-
const gsapBaseX = Number.isFinite(baseX)
|
|
295
|
-
? baseX
|
|
296
|
-
: (win.gsap.getProperty(element, "x") as number);
|
|
297
|
-
const gsapBaseY = Number.isFinite(baseY)
|
|
298
|
-
? baseY
|
|
299
|
-
: (win.gsap.getProperty(element, "y") as number);
|
|
300
|
-
if (!Number.isFinite(baseX))
|
|
301
|
-
element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
|
|
302
|
-
if (!Number.isFinite(baseY))
|
|
303
|
-
element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
|
|
304
|
-
const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
|
|
305
|
-
const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
|
|
306
|
-
win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
|
|
307
|
-
}
|
|
308
|
-
} else {
|
|
309
|
-
// Non-GSAP elements: use CSS translate as before.
|
|
310
|
-
element.style.setProperty(
|
|
311
|
-
"translate",
|
|
312
|
-
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
|
|
313
|
-
);
|
|
314
|
-
stripGsapTranslateFromTransform(element);
|
|
315
|
-
}
|
|
338
|
+
if (applyStudioPathOffsetViaGsap(element, offset)) return;
|
|
339
|
+
// Non-GSAP elements: use CSS translate as before.
|
|
340
|
+
element.style.setProperty(
|
|
341
|
+
"translate",
|
|
342
|
+
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
|
|
343
|
+
);
|
|
344
|
+
stripGsapTranslateFromTransform(element);
|
|
316
345
|
}
|
|
317
346
|
|
|
318
347
|
/* ── Box size apply ───────────────────────────────────────────────── */
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { applyStudioPathOffset, applyStudioPathOffsetDraft } from "./manualEditsDom";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: dragging a GSAP-animated element (e.g. a flat `to(#el, {x})` tween)
|
|
7
|
+
* must NOT fold the offset into a CSS `translate`. GSAP owns `style.transform`, so
|
|
8
|
+
* a CSS translate composes on top of it and the strip/reapply math compounds into
|
|
9
|
+
* a runaway matrix that flings the element off-canvas. Both the live draft and the
|
|
10
|
+
* commit must instead push the offset into GSAP's x/y via gsap.set and keep
|
|
11
|
+
* `translate: none`. Before the fix, the commit (applyStudioPathOffset) skipped the
|
|
12
|
+
* GSAP branch the draft already had — that asymmetry caused the off-canvas jump.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function makeGsapWindow(
|
|
16
|
+
el: HTMLElement,
|
|
17
|
+
gsapSet: (e: Element, v: Record<string, unknown>) => void,
|
|
18
|
+
) {
|
|
19
|
+
const win = el.ownerDocument.defaultView as unknown as {
|
|
20
|
+
__timelines?: Record<string, unknown>;
|
|
21
|
+
gsap?: unknown;
|
|
22
|
+
};
|
|
23
|
+
win.__timelines = {
|
|
24
|
+
playground: {
|
|
25
|
+
getChildren: () => [{ targets: () => [el], vars: { x: -260 } }],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
win.gsap = {
|
|
29
|
+
set: gsapSet,
|
|
30
|
+
getProperty: () => 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
const win = window as unknown as { __timelines?: unknown; gsap?: unknown };
|
|
36
|
+
delete win.__timelines;
|
|
37
|
+
delete win.gsap;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("applyStudioPathOffset — GSAP-owned transform", () => {
|
|
41
|
+
it("non-GSAP element folds the offset into a CSS translate var()", () => {
|
|
42
|
+
const el = document.createElement("div");
|
|
43
|
+
document.body.appendChild(el);
|
|
44
|
+
|
|
45
|
+
applyStudioPathOffset(el, { x: -120, y: 40 });
|
|
46
|
+
|
|
47
|
+
expect(el.style.translate).toContain("var(--hf-studio-offset-x");
|
|
48
|
+
expect(el.style.getPropertyValue("--hf-studio-offset-x")).toBe("-120px");
|
|
49
|
+
expect(el.style.getPropertyValue("--hf-studio-offset-y")).toBe("40px");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("GSAP element keeps translate:none and routes the offset through gsap.set", () => {
|
|
53
|
+
const el = document.createElement("div");
|
|
54
|
+
el.id = "puck-a";
|
|
55
|
+
document.body.appendChild(el);
|
|
56
|
+
const gsapSet = vi.fn();
|
|
57
|
+
makeGsapWindow(el, gsapSet);
|
|
58
|
+
|
|
59
|
+
applyStudioPathOffset(el, { x: -409, y: 398 });
|
|
60
|
+
|
|
61
|
+
// No CSS translate to collide with GSAP's transform.
|
|
62
|
+
expect(el.style.translate).toBe("none");
|
|
63
|
+
expect(el.style.translate).not.toContain("var(");
|
|
64
|
+
// Offset pushed into GSAP's x/y (gsapBase 0 + delta = the offset itself here).
|
|
65
|
+
expect(gsapSet).toHaveBeenCalledWith(el, { x: -409, y: 398 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("draft and commit treat a GSAP element identically (translate:none)", () => {
|
|
69
|
+
const el = document.createElement("div");
|
|
70
|
+
el.id = "puck-a";
|
|
71
|
+
document.body.appendChild(el);
|
|
72
|
+
makeGsapWindow(el, vi.fn());
|
|
73
|
+
|
|
74
|
+
applyStudioPathOffsetDraft(el, { x: -50, y: 10 });
|
|
75
|
+
const draftTranslate = el.style.translate;
|
|
76
|
+
applyStudioPathOffset(el, { x: -50, y: 10 });
|
|
77
|
+
const commitTranslate = el.style.translate;
|
|
78
|
+
|
|
79
|
+
expect(draftTranslate).toBe("none");
|
|
80
|
+
expect(commitTranslate).toBe("none");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -193,6 +193,12 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
|
|
|
193
193
|
|
|
194
194
|
element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px");
|
|
195
195
|
element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px");
|
|
196
|
+
// Old projects bake the offset by referencing the vars in the inline
|
|
197
|
+
// `translate` longhand — that's what makes the offset "applied" and thus the
|
|
198
|
+
// valid drag base (readAppliedStudioPathOffset). A raw var with no applied
|
|
199
|
+
// translate is dormant and reads as zero. Assign the typed `.translate`
|
|
200
|
+
// accessor (happy-dom doesn't surface it via setProperty).
|
|
201
|
+
element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
|
|
196
202
|
element.style.setProperty("transform", "translate(50px, -15px)");
|
|
197
203
|
|
|
198
204
|
element.getBoundingClientRect = () => {
|
|
@@ -228,6 +234,12 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
|
|
|
228
234
|
// Simulate GSAP baking a translate into transform each cycle
|
|
229
235
|
for (let cycle = 0; cycle < 3; cycle++) {
|
|
230
236
|
element.style.setProperty("transform", `translate(${50 * (cycle + 1)}px, 0px)`);
|
|
237
|
+
// Mark the offset as APPLIED (the inline translate references the studio
|
|
238
|
+
// vars, the form an old project bakes) so readAppliedStudioPathOffset reads
|
|
239
|
+
// the var, not zero. Without this the var is dormant and reads as zero.
|
|
240
|
+
// Assign the typed `.translate` accessor (happy-dom doesn't surface it via
|
|
241
|
+
// setProperty).
|
|
242
|
+
element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
|
|
231
243
|
|
|
232
244
|
const result = createManualOffsetDragMember({
|
|
233
245
|
key: "test",
|
|
@@ -5,10 +5,71 @@ import {
|
|
|
5
5
|
beginStudioManualEditGesture,
|
|
6
6
|
captureStudioPathOffset,
|
|
7
7
|
endStudioManualEditGesture,
|
|
8
|
-
|
|
8
|
+
readAppliedStudioPathOffset,
|
|
9
9
|
restoreStudioPathOffset,
|
|
10
10
|
type StudioPathOffsetSnapshot,
|
|
11
11
|
} from "./manualEdits";
|
|
12
|
+
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
13
|
+
|
|
14
|
+
interface OffsetDragGsap {
|
|
15
|
+
set: (el: Element, vars: Record<string, number | string>) => void;
|
|
16
|
+
getProperty: (el: Element, prop: string) => number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
|
|
20
|
+
const win = element.ownerDocument.defaultView as
|
|
21
|
+
| (Window & { gsap?: Partial<OffsetDragGsap> })
|
|
22
|
+
| null;
|
|
23
|
+
const gsap = win?.gsap;
|
|
24
|
+
return gsap?.set && gsap.getProperty ? (gsap as OffsetDragGsap) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Live drag preview through the GSAP channel — the SAME channel the commit
|
|
29
|
+
* lands in (a `tl.set`/keyframe on the timeline), so what the user sees while
|
|
30
|
+
* dragging equals what gets written (plan R3/R4). Reuses the commit's
|
|
31
|
+
* base+delta+rotation math so preview and commit agree by construction. Returns
|
|
32
|
+
* true when handled via gsap; false when gsap is unavailable (caller falls back
|
|
33
|
+
* to the CSS draft).
|
|
34
|
+
*/
|
|
35
|
+
function applyOffsetDragDraftViaGsap(
|
|
36
|
+
element: HTMLElement,
|
|
37
|
+
offset: { x: number; y: number },
|
|
38
|
+
): boolean {
|
|
39
|
+
const gsap = getOffsetDragGsap(element);
|
|
40
|
+
if (!gsap) return false;
|
|
41
|
+
// GSAP owns the transform; neutralize the CSS translate longhand so the two
|
|
42
|
+
// channels can't compose into a doubled position.
|
|
43
|
+
element.style.setProperty("translate", "none");
|
|
44
|
+
const fallbackBase = {
|
|
45
|
+
x: Number(gsap.getProperty(element, "x")) || 0,
|
|
46
|
+
y: Number(gsap.getProperty(element, "y")) || 0,
|
|
47
|
+
};
|
|
48
|
+
const { newX, newY } = computeDraggedGsapPosition(element, offset, fallbackBase);
|
|
49
|
+
gsap.set(element, { x: newX, y: newY });
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Live rotation preview through the GSAP channel — the SAME channel the commit
|
|
55
|
+
* lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`.
|
|
56
|
+
* GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep
|
|
57
|
+
* the two channels from composing. `angle` is the absolute target rotation. Returns
|
|
58
|
+
* false when gsap is unavailable (caller falls back to the CSS draft).
|
|
59
|
+
*/
|
|
60
|
+
export function applyRotationDraftViaGsap(element: HTMLElement, angle: number): boolean {
|
|
61
|
+
const gsap = getOffsetDragGsap(element);
|
|
62
|
+
if (!gsap) return false;
|
|
63
|
+
element.style.setProperty("rotate", "none");
|
|
64
|
+
gsap.set(element, { rotation: angle });
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Current GSAP transform rotation — the single-source rotation base. 0 if gsap is unavailable. */
|
|
69
|
+
export function readGsapRotation(element: HTMLElement): number {
|
|
70
|
+
const gsap = getOffsetDragGsap(element);
|
|
71
|
+
return gsap ? Number(gsap.getProperty(element, "rotation")) || 0 : 0;
|
|
72
|
+
}
|
|
12
73
|
|
|
13
74
|
const DEFAULT_OFFSET_PROBE_PX = 100;
|
|
14
75
|
const MIN_PROBE_VECTOR_LENGTH_PX = 0.01;
|
|
@@ -241,7 +302,10 @@ export function createManualOffsetDragMember(input: {
|
|
|
241
302
|
element: HTMLElement;
|
|
242
303
|
rect: ManualOffsetDragRect;
|
|
243
304
|
}): ManualOffsetDragMemberResult {
|
|
244
|
-
|
|
305
|
+
// Base the drag on the offset ACTUALLY applied, never the raw (possibly dormant)
|
|
306
|
+
// var — see readAppliedStudioPathOffset. This keeps the commit purely relative
|
|
307
|
+
// (applied + delta) so a stale offset can't fling the element off-screen.
|
|
308
|
+
const initialOffset = readAppliedStudioPathOffset(input.element);
|
|
245
309
|
input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
|
|
246
310
|
input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
|
|
247
311
|
|
|
@@ -335,7 +399,12 @@ export function applyManualOffsetDragDraft(
|
|
|
335
399
|
dy: number,
|
|
336
400
|
): { x: number; y: number } {
|
|
337
401
|
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
|
|
338
|
-
|
|
402
|
+
// Position is single-sourced on the GSAP timeline; preview through gsap.set so
|
|
403
|
+
// the live draft matches the committed `tl.set`/keyframe. CSS draft only when
|
|
404
|
+
// gsap is unavailable (no preview iframe runtime).
|
|
405
|
+
if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
|
|
406
|
+
applyStudioPathOffsetDraft(member.element, offset);
|
|
407
|
+
}
|
|
339
408
|
return offset;
|
|
340
409
|
}
|
|
341
410
|
|
|
@@ -345,7 +414,13 @@ export function applyManualOffsetDragCommit(
|
|
|
345
414
|
dy: number,
|
|
346
415
|
): { x: number; y: number } {
|
|
347
416
|
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
|
|
348
|
-
|
|
417
|
+
// Optimistic visual through the GSAP channel (same as the live draft and the
|
|
418
|
+
// committed `tl.set`), so the element holds its dropped position until the
|
|
419
|
+
// source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
|
|
420
|
+
// CSS apply only when gsap is unavailable.
|
|
421
|
+
if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
|
|
422
|
+
applyStudioPathOffset(member.element, offset);
|
|
423
|
+
}
|
|
349
424
|
return offset;
|
|
350
425
|
}
|
|
351
426
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import { editableAnimationId } from "./motionPathSelection";
|
|
4
|
+
import {
|
|
5
|
+
commitNode,
|
|
6
|
+
commitAddWaypoint,
|
|
7
|
+
commitAddKeyframe,
|
|
8
|
+
commitRemoveWaypoint,
|
|
9
|
+
commitCreatePath,
|
|
10
|
+
} from "./motionPathCommit";
|
|
11
|
+
|
|
12
|
+
const anim = (over: Partial<GsapAnimation>): GsapAnimation =>
|
|
13
|
+
({
|
|
14
|
+
id: "a1",
|
|
15
|
+
targetSelector: "#el",
|
|
16
|
+
method: "to",
|
|
17
|
+
position: 0,
|
|
18
|
+
properties: {},
|
|
19
|
+
...over,
|
|
20
|
+
}) as GsapAnimation;
|
|
21
|
+
|
|
22
|
+
describe("editableAnimationId", () => {
|
|
23
|
+
it("picks the arc animation for an arc path", () => {
|
|
24
|
+
const arc = anim({ id: "arc1", arcPath: { enabled: true, autoRotate: false, segments: [] } });
|
|
25
|
+
expect(editableAnimationId([anim({ id: "other" }), arc], "arc")).toBe("arc1");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("picks a position-keyframe animation for a linear path", () => {
|
|
29
|
+
const kf = anim({
|
|
30
|
+
id: "kf1",
|
|
31
|
+
propertyGroup: "position",
|
|
32
|
+
keyframes: {
|
|
33
|
+
format: "percentage",
|
|
34
|
+
keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }],
|
|
35
|
+
} as never,
|
|
36
|
+
});
|
|
37
|
+
expect(editableAnimationId([kf], "linear")).toBe("kf1");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns null for dynamic (unresolved) tweens — read-only", () => {
|
|
41
|
+
const dyn = anim({
|
|
42
|
+
id: "dyn",
|
|
43
|
+
arcPath: { enabled: true, autoRotate: false, segments: [] },
|
|
44
|
+
hasUnresolvedKeyframes: true,
|
|
45
|
+
});
|
|
46
|
+
expect(editableAnimationId([dyn], "arc")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns null for non-literal (helper) provenance — read-only", () => {
|
|
50
|
+
const helper = anim({
|
|
51
|
+
id: "h",
|
|
52
|
+
arcPath: { enabled: true, autoRotate: false, segments: [] },
|
|
53
|
+
provenance: { kind: "helper" } as never,
|
|
54
|
+
});
|
|
55
|
+
expect(editableAnimationId([helper], "arc")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns null when nothing matches", () => {
|
|
59
|
+
expect(editableAnimationId([anim({ id: "x" })], "linear")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("commitNode", () => {
|
|
64
|
+
it("routes a keyframe node to update-keyframe by percentage", async () => {
|
|
65
|
+
const commit = vi.fn().mockResolvedValue(undefined);
|
|
66
|
+
await commitNode({ type: "keyframe", pct: 50 }, 120, 30, "a1", commit);
|
|
67
|
+
expect(commit).toHaveBeenCalledWith(
|
|
68
|
+
{ type: "update-keyframe", animationId: "a1", percentage: 50, properties: { x: 120, y: 30 } },
|
|
69
|
+
expect.objectContaining({ softReload: true }),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("routes a waypoint node to update-motion-path-point by index", async () => {
|
|
74
|
+
const commit = vi.fn().mockResolvedValue(undefined);
|
|
75
|
+
await commitNode({ type: "waypoint", index: 2 }, 80, 40, "a1", commit);
|
|
76
|
+
expect(commit).toHaveBeenCalledWith(
|
|
77
|
+
{ type: "update-motion-path-point", animationId: "a1", pointIndex: 2, x: 80, y: 40 },
|
|
78
|
+
expect.objectContaining({ softReload: true }),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("commitAddWaypoint / commitRemoveWaypoint", () => {
|
|
84
|
+
it("adds a waypoint at an index with coordinates", async () => {
|
|
85
|
+
const commit = vi.fn().mockResolvedValue(undefined);
|
|
86
|
+
await commitAddWaypoint("a1", 1, 120, -40, commit);
|
|
87
|
+
expect(commit).toHaveBeenCalledWith(
|
|
88
|
+
{ type: "add-motion-path-point", animationId: "a1", index: 1, x: 120, y: -40 },
|
|
89
|
+
expect.objectContaining({ softReload: true }),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("removes a waypoint by index", async () => {
|
|
94
|
+
const commit = vi.fn().mockResolvedValue(undefined);
|
|
95
|
+
await commitRemoveWaypoint("a1", 2, commit);
|
|
96
|
+
expect(commit).toHaveBeenCalledWith(
|
|
97
|
+
{ type: "remove-motion-path-point", animationId: "a1", index: 2 },
|
|
98
|
+
expect.objectContaining({ softReload: true }),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("commitAddKeyframe", () => {
|
|
104
|
+
it("inserts an x/y keyframe at a tween-relative percentage", async () => {
|
|
105
|
+
const commit = vi.fn().mockResolvedValue(undefined);
|
|
106
|
+
await commitAddKeyframe("a1", 42.5, 80, -20, commit);
|
|
107
|
+
expect(commit).toHaveBeenCalledWith(
|
|
108
|
+
{ type: "add-keyframe", animationId: "a1", percentage: 42.5, properties: { x: 80, y: -20 } },
|
|
109
|
+
expect.objectContaining({ softReload: true }),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("commitCreatePath", () => {
|
|
115
|
+
it("authors a new motionPath to a destination at a given time", async () => {
|
|
116
|
+
const commit = vi.fn().mockResolvedValue(undefined);
|
|
117
|
+
await commitCreatePath("#title", 2.0, 300, -120, commit);
|
|
118
|
+
expect(commit).toHaveBeenCalledWith(
|
|
119
|
+
{
|
|
120
|
+
type: "add-motion-path",
|
|
121
|
+
targetSelector: "#title",
|
|
122
|
+
position: 2.0,
|
|
123
|
+
duration: 1.5,
|
|
124
|
+
x: 300,
|
|
125
|
+
y: -120,
|
|
126
|
+
},
|
|
127
|
+
expect.objectContaining({ softReload: true }),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|