@hyperframes/studio 0.8.125 → 0.8.127
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-CmIh-wq9.js → hyperframes-player-uqzDaxVL.js} +1 -1
- package/dist/assets/{index-4LRR6o2F.js → index-CLBNYlDY.js} +1 -1
- package/dist/assets/{index-CrYoEZ5p.js → index-COhbL1xq.js} +177 -177
- package/dist/assets/{index-yCK8osBg.js → index-DXSYlqmV.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +5368 -4986
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
- package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
- package/src/components/editor/MotionPathNode.tsx +56 -0
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +167 -33
- package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
- package/src/components/editor/MotionPathOverlay.tsx +49 -86
- package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
- package/src/components/editor/manualEditsSeekReapply.ts +3 -6
- package/src/components/editor/manualOffsetDrag.ts +2 -3
- package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
- package/src/components/editor/motionPathCommit.ts +58 -0
- package/src/components/editor/motionPathGeometry.test.ts +61 -1
- package/src/components/editor/motionPathGeometry.ts +50 -10
- package/src/components/editor/motionPathHome.ts +75 -0
- package/src/components/editor/motionPathLayerNode.test.ts +43 -35
- package/src/components/editor/motionPathLayerNode.ts +20 -16
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
- package/src/components/editor/studioMotion.ts +7 -19
- package/src/components/editor/useMotionPathData.test.tsx +236 -0
- package/src/components/editor/useMotionPathData.ts +23 -61
- package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
- package/src/hooks/gsapDragCommit.ts +33 -1
- package/src/hooks/gsapEditOutcome.ts +27 -2
- package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
- package/src/hooks/gsapParsedTween.ts +39 -2
- package/src/hooks/gsapRuntimeBridge.ts +7 -1
- package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +11 -4
- package/src/hooks/gsapShared.test.ts +1 -0
- package/src/hooks/gsapShared.ts +8 -3
- package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +107 -5
- package/src/hooks/gsapValueAtPlayhead.test.ts +129 -27
- package/src/hooks/gsapValueAtPlayhead.ts +46 -6
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
- package/src/hooks/intersectionObserverTestUtils.ts +16 -0
- package/src/hooks/resizeObserverTestUtils.ts +9 -2
- package/src/hooks/timelineEditingHelpers.ts +2 -9
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
- package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
- package/src/hooks/useThumbnailStripSize.ts +206 -17
- package/src/noWindowBoundDomChecks.test.ts +31 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -6
- package/src/player/components/ImageThumbnail.tsx +11 -5
- package/src/player/components/ThumbnailTiles.test.tsx +30 -0
- package/src/player/components/ThumbnailTiles.tsx +41 -0
- package/src/player/components/VideoThumbnail.test.tsx +116 -9
- package/src/player/components/VideoThumbnail.tsx +11 -5
- package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
- package/src/player/lib/timelineGroupInfo.ts +2 -1
- package/src/webmcp/webmcpTestUtils.ts +2 -2
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
+
import { gsap } from "gsap";
|
|
2
3
|
import { act, useRef } from "react";
|
|
3
4
|
import { createRoot } from "react-dom/client";
|
|
4
5
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
6
|
+
import { elementHome } from "./motionPathHome";
|
|
5
7
|
import { useMotionPathData } from "./useMotionPathData";
|
|
6
8
|
import { resetOverlayFrameLoopForTests } from "./overlayFrameLoop";
|
|
7
9
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
@@ -92,3 +94,237 @@ it("reads no layout per frame until there is a path or a create ring to draw", (
|
|
|
92
94
|
act(() => root.unmount());
|
|
93
95
|
}
|
|
94
96
|
});
|
|
97
|
+
|
|
98
|
+
it("finds the layer whichever window built its node, so the path draws on every load", () => {
|
|
99
|
+
runtime.read = {
|
|
100
|
+
keyframes: [
|
|
101
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
102
|
+
{ percentage: 100, properties: { x: 100, y: 0 } },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
const iframe = document.createElement("iframe");
|
|
106
|
+
document.body.append(iframe);
|
|
107
|
+
const box = iframe.contentDocument!.createElement("div");
|
|
108
|
+
box.id = "box";
|
|
109
|
+
iframe.contentDocument!.body.append(box);
|
|
110
|
+
// happy-dom shares one realm, so the frame's own constructor stands in for another window's.
|
|
111
|
+
Object.defineProperty(iframe.contentWindow!, "HTMLElement", { value: class {} });
|
|
112
|
+
let home: unknown = null;
|
|
113
|
+
function Probe() {
|
|
114
|
+
const ref = useRef(iframe);
|
|
115
|
+
home = useMotionPathData(ref, "#box").home;
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
119
|
+
try {
|
|
120
|
+
act(() => root.render(<Probe />));
|
|
121
|
+
runFrames(3);
|
|
122
|
+
expect(home).not.toBeNull();
|
|
123
|
+
} finally {
|
|
124
|
+
act(() => root.unmount());
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("redraws a node whose keyframe changes only its size", () => {
|
|
129
|
+
const read = (width: number) => ({
|
|
130
|
+
keyframes: [
|
|
131
|
+
{ percentage: 66.667, properties: { x: 60, width } },
|
|
132
|
+
{ percentage: 100, properties: { x: 120, width: 320 } },
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
runtime.read = read(280);
|
|
136
|
+
runtime.gsapPosition = { x: 50, y: 30 };
|
|
137
|
+
let w: number | undefined;
|
|
138
|
+
function Probe() {
|
|
139
|
+
const ref = useRef(document.createElement("iframe"));
|
|
140
|
+
w = useMotionPathData(ref, "#box").geometry?.nodes[0]?.w;
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
144
|
+
try {
|
|
145
|
+
act(() => root.render(<Probe />));
|
|
146
|
+
expect(w).toBe(280);
|
|
147
|
+
runtime.read = read(300);
|
|
148
|
+
act(() => vi.advanceTimersByTime(250));
|
|
149
|
+
expect(w).toBe(300);
|
|
150
|
+
} finally {
|
|
151
|
+
act(() => root.unmount());
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** A layer laid out at (960, 540), 240 x 160, with `css` inline and `cache` as GSAP's `_gsap`. */
|
|
156
|
+
function layer(css: Partial<CSSStyleDeclaration>, cache?: Record<string, number>) {
|
|
157
|
+
const el = document.body.appendChild(document.createElement("div"));
|
|
158
|
+
Object.assign(el.style, css);
|
|
159
|
+
const box = {
|
|
160
|
+
offsetLeft: 960,
|
|
161
|
+
offsetTop: 540,
|
|
162
|
+
offsetWidth: 240,
|
|
163
|
+
offsetHeight: 160,
|
|
164
|
+
offsetParent: null,
|
|
165
|
+
};
|
|
166
|
+
for (const [key, value] of Object.entries(box)) Object.defineProperty(el, key, { value });
|
|
167
|
+
return cache ? Object.assign(el, { _gsap: cache }) : el;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
it("anchors a layer GSAP centres with xPercent/yPercent -50 on its centre", () => {
|
|
171
|
+
const el = layer(
|
|
172
|
+
{ position: "absolute", left: "50%", top: "50%" },
|
|
173
|
+
{ x: 0, y: 0, xPercent: -50, yPercent: -50 },
|
|
174
|
+
);
|
|
175
|
+
expect(elementHome(el)).toEqual({ x: 960, y: 540, w: 240, h: 160, ax: 0, ay: 0 });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("moves a resized layer's centre by half the change from a set left, against it from a set right, and not in flow", () => {
|
|
179
|
+
const fromLeft = layer({ position: "absolute", left: "10px", top: "10px" });
|
|
180
|
+
expect(elementHome(fromLeft)).toMatchObject({ x: 1080, y: 620, ax: 0.5, ay: 0.5 });
|
|
181
|
+
const fromRight = layer({ position: "absolute", right: "10px", bottom: "10px" });
|
|
182
|
+
const auto = new Set(["left", "top"]);
|
|
183
|
+
fromRight.computedStyleMap = () =>
|
|
184
|
+
({
|
|
185
|
+
get: (side: string) => (auto.has(side) ? "auto" : "10px"),
|
|
186
|
+
}) as unknown as StylePropertyMapReadOnly;
|
|
187
|
+
expect(elementHome(fromRight)).toMatchObject({ ax: -0.5, ay: -0.5 });
|
|
188
|
+
expect(elementHome(layer({}))).toMatchObject({ x: 1080, ax: 0, ay: 0 });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const resolved = (style: CSSStyleDeclaration) =>
|
|
192
|
+
(style.translate ?? "").replace(/var\((--[\w-]+)\)/g, (_, name) =>
|
|
193
|
+
style.getPropertyValue(name),
|
|
194
|
+
) || "none";
|
|
195
|
+
|
|
196
|
+
/** Computes `translate` and `transform` as Chromium does (happy-dom does not), so GSAP's parse would fold them. */
|
|
197
|
+
function computeTranslate() {
|
|
198
|
+
const real = window.getComputedStyle.bind(window);
|
|
199
|
+
const computed = (node: Element, pseudo?: string | null) =>
|
|
200
|
+
new Proxy(real(node, pseudo), {
|
|
201
|
+
get(style, key) {
|
|
202
|
+
if (key === "translate") return resolved((node as HTMLElement).style);
|
|
203
|
+
if (key === "transform") return (node as HTMLElement).style.transform || "none";
|
|
204
|
+
if (key === "scale" || key === "rotate") return "none";
|
|
205
|
+
const value = Reflect.get(style, key);
|
|
206
|
+
return typeof value === "function" ? value.bind(style) : value;
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
vi.stubGlobal("getComputedStyle", computed);
|
|
210
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(
|
|
211
|
+
computed as typeof window.getComputedStyle,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
it("leaves a layer's CSS translate alone and anchors where GSAP's parse will put it (real GSAP)", () => {
|
|
216
|
+
vi.stubGlobal("gsap", gsap);
|
|
217
|
+
computeTranslate();
|
|
218
|
+
try {
|
|
219
|
+
const offset = "var(--hf-studio-offset-x) var(--hf-studio-offset-y)";
|
|
220
|
+
const cases: [string, number, number][] = [
|
|
221
|
+
// GSAP folds these into x, which a created path sets.
|
|
222
|
+
["40px 30px", 1080, 620],
|
|
223
|
+
[offset, 1080, 620],
|
|
224
|
+
// Minus half the size becomes xPercent/yPercent -50.
|
|
225
|
+
["-50% -50%", 960, 540],
|
|
226
|
+
["-120px -80px", 960, 540],
|
|
227
|
+
];
|
|
228
|
+
// GSAP sums the layer's own transform with its translate: Chromium's matrix for translate(-50%, -50%).
|
|
229
|
+
const centred = layer({ position: "absolute", transform: "matrix(1, 0, 0, 1, -120, -80)" });
|
|
230
|
+
expect(elementHome(centred)).toMatchObject({ x: 960, y: 540 });
|
|
231
|
+
for (const [translate, x, y] of cases) {
|
|
232
|
+
const el = layer({ position: "absolute", left: "10px", top: "10px", translate });
|
|
233
|
+
el.style.setProperty("--hf-studio-offset-x", "40px");
|
|
234
|
+
el.style.setProperty("--hf-studio-offset-y", "30px");
|
|
235
|
+
expect(elementHome(el), translate).toMatchObject({ x, y });
|
|
236
|
+
expect([el.style.translate, el.style.transform]).toEqual([translate, ""]);
|
|
237
|
+
}
|
|
238
|
+
// A drag's offset on a layer GSAP already parsed composes with its transform.
|
|
239
|
+
const dragged = layer({ position: "absolute", left: "10px", top: "10px" });
|
|
240
|
+
gsap.set(dragged, { x: 20 });
|
|
241
|
+
dragged.style.setProperty("--hf-studio-offset-x", "40px");
|
|
242
|
+
dragged.style.setProperty("--hf-studio-offset-y", "30px");
|
|
243
|
+
dragged.style.translate = offset;
|
|
244
|
+
expect(elementHome(dragged)).toMatchObject({ x: 1120, y: 650 });
|
|
245
|
+
// After clearProps GSAP marks its cache uncache and its next parse folds the translate again.
|
|
246
|
+
const css = { position: "absolute", left: "10px", top: "10px", translate: "40px 30px" };
|
|
247
|
+
expect(elementHome(layer(css, { x: 0, y: 0, uncache: 1 }))).toMatchObject({ x: 1080, y: 620 });
|
|
248
|
+
// A fade gives GSAP a cache with no transform in it yet, so its next parse still folds.
|
|
249
|
+
expect(elementHome(layer(css, { opacity: 1 }))).toMatchObject({ x: 1080, y: 620 });
|
|
250
|
+
const owned = layer({ position: "absolute", left: "50%", top: "50%" });
|
|
251
|
+
gsap.set(owned, { xPercent: -50, yPercent: -50 });
|
|
252
|
+
expect(elementHome(owned)).toMatchObject({ x: 960, y: 540, ax: 0, ay: 0 });
|
|
253
|
+
} finally {
|
|
254
|
+
vi.unstubAllGlobals();
|
|
255
|
+
vi.restoreAllMocks();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("redraws the start ring when only GSAP's start changes", () => {
|
|
260
|
+
const read = (start?: Record<string, number>) => ({
|
|
261
|
+
keyframes: [
|
|
262
|
+
{ percentage: 50, properties: { x: 60 } },
|
|
263
|
+
{ percentage: 100, properties: { x: 120 } },
|
|
264
|
+
],
|
|
265
|
+
...(start && { start }),
|
|
266
|
+
});
|
|
267
|
+
runtime.read = read();
|
|
268
|
+
runtime.gsapPosition = { x: 50, y: 30 };
|
|
269
|
+
let start: unknown;
|
|
270
|
+
function Probe() {
|
|
271
|
+
const ref = useRef(document.createElement("iframe"));
|
|
272
|
+
start = useMotionPathData(ref, "#box").geometry?.start;
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
276
|
+
try {
|
|
277
|
+
act(() => root.render(<Probe />));
|
|
278
|
+
expect(start).toBeUndefined();
|
|
279
|
+
runtime.read = read({ x: 40 });
|
|
280
|
+
act(() => vi.advanceTimersByTime(250));
|
|
281
|
+
expect(start).toEqual({ x: 40, y: 30 });
|
|
282
|
+
} finally {
|
|
283
|
+
act(() => root.unmount());
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("updates the home when the layer's size or anchoring changes but its centre does not", () => {
|
|
288
|
+
runtime.read = {
|
|
289
|
+
keyframes: [
|
|
290
|
+
{ percentage: 0, properties: { x: 0, width: 200 } },
|
|
291
|
+
{ percentage: 100, properties: { x: 100, width: 240 } },
|
|
292
|
+
],
|
|
293
|
+
};
|
|
294
|
+
const iframe = document.body.appendChild(document.createElement("iframe"));
|
|
295
|
+
const layout = { offsetLeft: 100, offsetWidth: 200 };
|
|
296
|
+
const box = iframe.contentDocument!.body.appendChild(
|
|
297
|
+
iframe.contentDocument!.createElement("div"),
|
|
298
|
+
);
|
|
299
|
+
box.id = "box";
|
|
300
|
+
for (const key of ["offsetLeft", "offsetWidth"] as const)
|
|
301
|
+
Object.defineProperty(box, key, { get: () => layout[key] });
|
|
302
|
+
let w: number | undefined;
|
|
303
|
+
let ax: number | undefined;
|
|
304
|
+
function Probe() {
|
|
305
|
+
const ref = useRef(iframe);
|
|
306
|
+
({ w, ax } = useMotionPathData(ref, "#box").home ?? {});
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
310
|
+
try {
|
|
311
|
+
act(() => root.render(<Probe />));
|
|
312
|
+
runFrames(2);
|
|
313
|
+
expect(w).toBe(200);
|
|
314
|
+
Object.assign(layout, { offsetLeft: 80, offsetWidth: 240 });
|
|
315
|
+
runFrames(2);
|
|
316
|
+
expect(w).toBe(240);
|
|
317
|
+
// Re-anchored from the left to the right edge without moving: the share a size change moves changes.
|
|
318
|
+
Object.assign(box.style, { position: "absolute", left: "10px" });
|
|
319
|
+
runFrames(2);
|
|
320
|
+
expect(ax).toBe(0.5);
|
|
321
|
+
box.computedStyleMap = () =>
|
|
322
|
+
({
|
|
323
|
+
get: (side: string) => (side === "left" ? "auto" : "0px"),
|
|
324
|
+
}) as unknown as StylePropertyMapReadOnly;
|
|
325
|
+
runFrames(2);
|
|
326
|
+
expect(ax).toBe(-0.5);
|
|
327
|
+
} finally {
|
|
328
|
+
act(() => root.unmount());
|
|
329
|
+
}
|
|
330
|
+
});
|
|
@@ -1,31 +1,19 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
|
2
|
+
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
2
3
|
import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
4
|
import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
|
|
4
5
|
import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildMotionPathGeometry,
|
|
8
|
+
type MotionPathGeometry,
|
|
9
|
+
type MotionPathHome,
|
|
10
|
+
} from "./motionPathGeometry";
|
|
11
|
+
import { elementHome } from "./motionPathHome";
|
|
6
12
|
import { subscribeOverlayFrame } from "./overlayFrameLoop";
|
|
7
13
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
14
|
|
|
9
15
|
type Rect = { left: number; top: number; width: number; height: number };
|
|
10
16
|
|
|
11
|
-
// The translate (e/f) components of an element's computed transform, in comp px.
|
|
12
|
-
// A group wrapper dragged via GSAP carries its offset here, not in offsetLeft/Top.
|
|
13
|
-
function transformTranslate(el: HTMLElement): { x: number; y: number } {
|
|
14
|
-
const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform;
|
|
15
|
-
if (!t || t === "none") return { x: 0, y: 0 };
|
|
16
|
-
const m3 = t.match(/matrix3d\(([^)]+)\)/);
|
|
17
|
-
if (m3) {
|
|
18
|
-
const v = m3[1].split(",").map(Number);
|
|
19
|
-
return { x: v[12] || 0, y: v[13] || 0 };
|
|
20
|
-
}
|
|
21
|
-
const m = t.match(/matrix\(([^)]+)\)/);
|
|
22
|
-
if (m) {
|
|
23
|
-
const v = m[1].split(",").map(Number);
|
|
24
|
-
return { x: v[4] || 0, y: v[5] || 0 };
|
|
25
|
-
}
|
|
26
|
-
return { x: 0, y: 0 };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
17
|
// Perspective foreshortening of the element's OWN transform (matrix3d m44). A
|
|
30
18
|
// depth element (translateZ toward the viewer) renders 1/m44× larger, so its
|
|
31
19
|
// animated x/y offsets travel 1/m44× further on screen than the flat preview
|
|
@@ -40,43 +28,6 @@ export function transformWDivisor(el: HTMLElement): number {
|
|
|
40
28
|
return Number.isFinite(w) && w > 0 ? w : 1;
|
|
41
29
|
}
|
|
42
30
|
|
|
43
|
-
export function elementHome(el: HTMLElement): { x: number; y: number } {
|
|
44
|
-
let left = 0;
|
|
45
|
-
let top = 0;
|
|
46
|
-
let node: HTMLElement | null = el;
|
|
47
|
-
while (node) {
|
|
48
|
-
left += node.offsetLeft;
|
|
49
|
-
top += node.offsetTop;
|
|
50
|
-
// Ancestor transforms (e.g. a group wrapper moved via GSAP) shift where the
|
|
51
|
-
// element actually renders, so the path must anchor on top of them. The element's
|
|
52
|
-
// OWN transform is excluded — that's the animated offset the path itself draws.
|
|
53
|
-
if (node !== el) {
|
|
54
|
-
const t = transformTranslate(node);
|
|
55
|
-
left += t.x;
|
|
56
|
-
top += t.y;
|
|
57
|
-
}
|
|
58
|
-
const parent = node.offsetParent as HTMLElement | null;
|
|
59
|
-
if (!parent || parent.hasAttribute("data-composition-id")) break;
|
|
60
|
-
node = parent;
|
|
61
|
-
}
|
|
62
|
-
let x = left + el.offsetWidth / 2;
|
|
63
|
-
let y = top + el.offsetHeight / 2;
|
|
64
|
-
if ((el.style.translate ?? "").includes("var(")) {
|
|
65
|
-
x += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
|
|
66
|
-
y += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
|
|
67
|
-
}
|
|
68
|
-
return { x, y };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function isPreviewHtmlElement(
|
|
72
|
-
node: Element | null | undefined,
|
|
73
|
-
iframe: HTMLIFrameElement | null,
|
|
74
|
-
): node is HTMLElement {
|
|
75
|
-
const Ctor = (iframe?.contentWindow as unknown as { HTMLElement?: typeof HTMLElement } | null)
|
|
76
|
-
?.HTMLElement;
|
|
77
|
-
return Boolean(node && Ctor && node instanceof Ctor);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
31
|
function rectsClose(a: Rect, b: Rect): boolean {
|
|
81
32
|
return (
|
|
82
33
|
Math.abs(a.left - b.left) < 0.5 &&
|
|
@@ -104,7 +55,7 @@ export function useMotionPathData(
|
|
|
104
55
|
geometry: MotionPathGeometry | null;
|
|
105
56
|
geometryResolved: boolean;
|
|
106
57
|
visibleInPreview: boolean;
|
|
107
|
-
home:
|
|
58
|
+
home: MotionPathHome | null;
|
|
108
59
|
pScale: number;
|
|
109
60
|
} {
|
|
110
61
|
const [rect, setRect] = useState<Rect | null>(null);
|
|
@@ -112,7 +63,7 @@ export function useMotionPathData(
|
|
|
112
63
|
const resolvedForRef = useRef<string | null>(null);
|
|
113
64
|
const geometryResolved = resolvedForRef.current === selector;
|
|
114
65
|
const [visibleInPreview, setVisibleInPreview] = useState(true);
|
|
115
|
-
const [home, setHome] = useState<
|
|
66
|
+
const [home, setHome] = useState<MotionPathHome | null>(null);
|
|
116
67
|
// Perspective magnification (1/m44) of the selected element — applied to the
|
|
117
68
|
// path's offset points so depth (translateZ) elements' paths track on screen.
|
|
118
69
|
const [pScale, setPScale] = useState(1);
|
|
@@ -146,13 +97,21 @@ export function useMotionPathData(
|
|
|
146
97
|
} catch {
|
|
147
98
|
/* cross-origin guard */
|
|
148
99
|
}
|
|
149
|
-
const live =
|
|
100
|
+
const live = isHtmlElement(target) ? target : null;
|
|
150
101
|
const vis = live ? isElementVisibleForOverlay(live) : true;
|
|
151
102
|
setVisibleInPreview((prev) => (prev === vis ? prev : vis));
|
|
152
103
|
if (live) {
|
|
153
104
|
const h = elementHome(live);
|
|
154
105
|
setHome((prev) =>
|
|
155
|
-
prev &&
|
|
106
|
+
prev &&
|
|
107
|
+
Math.abs(prev.x - h.x) < 0.5 &&
|
|
108
|
+
Math.abs(prev.y - h.y) < 0.5 &&
|
|
109
|
+
prev.w === h.w &&
|
|
110
|
+
prev.h === h.h &&
|
|
111
|
+
prev.ax === h.ax &&
|
|
112
|
+
prev.ay === h.ay
|
|
113
|
+
? prev
|
|
114
|
+
: h,
|
|
156
115
|
);
|
|
157
116
|
const ps = 1 / transformWDivisor(live);
|
|
158
117
|
setPScale((p) => (Math.abs(p - ps) < 0.001 ? p : ps));
|
|
@@ -173,7 +132,10 @@ export function useMotionPathData(
|
|
|
173
132
|
const base = read ? readGsapPositionFromIframe(iframeRef.current, selector) : null;
|
|
174
133
|
const next = buildMotionPathGeometry(read, base ?? undefined);
|
|
175
134
|
setGeometry((prev) =>
|
|
176
|
-
prev?.
|
|
135
|
+
prev?.kind === next?.kind &&
|
|
136
|
+
JSON.stringify([prev?.nodes, prev?.start]) === JSON.stringify([next?.nodes, next?.start])
|
|
137
|
+
? prev
|
|
138
|
+
: next,
|
|
177
139
|
);
|
|
178
140
|
resolvedForRef.current = selector;
|
|
179
141
|
};
|
|
@@ -12,7 +12,8 @@ import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
|
12
12
|
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
13
13
|
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
|
14
14
|
import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
|
|
15
|
-
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
15
|
+
import { GsapEditBlockedError, type PlayheadEditRefusal } from "./gsapEditOutcome";
|
|
16
|
+
import { isTweenConfigKey } from "@hyperframes/parsers/gsap-constants";
|
|
16
17
|
export {
|
|
17
18
|
findExistingPositionWrite,
|
|
18
19
|
findRotationSetAnimation,
|
|
@@ -117,6 +118,36 @@ export async function materializeIfDynamic(
|
|
|
117
118
|
throw new GsapEditBlockedError("source-uneditable", "geometry-unresolved-source");
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
/** Why a percentage keyframe can't stand in for this array step entry, or null when it can. */
|
|
122
|
+
function stepBlock([key, value]: [string, number | string]): PlayheadEditRefusal | null {
|
|
123
|
+
if (key === "delay") return "array-step-delay";
|
|
124
|
+
if (/^on[A-Z]/.test(key)) return "array-step-callback";
|
|
125
|
+
if (isTweenConfigKey(key)) return "array-step-config";
|
|
126
|
+
if (typeof value === "number") return null;
|
|
127
|
+
return STEP_VALUE_BLOCKS.find(([pattern]) => pattern.test(value))?.[1] ?? null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** First match wins: code is computed, even when it calls random(). */
|
|
131
|
+
const STEP_VALUE_BLOCKS: Array<[RegExp, PlayheadEditRefusal]> = [
|
|
132
|
+
[/^__raw:/, "array-step-computed"],
|
|
133
|
+
[/random\(/, "array-step-random"],
|
|
134
|
+
[/[-+*/]=/, "array-step-relative"],
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
/** Why a step list can't be rewritten as percentage keyframes, or null. */
|
|
138
|
+
export function stepListBlock(anim: GsapAnimation): PlayheadEditRefusal | null {
|
|
139
|
+
const data = anim.keyframes;
|
|
140
|
+
if (data?.format !== "object-array") return null;
|
|
141
|
+
const entries = data.keyframes.flatMap((kf) => Object.entries(kf.properties));
|
|
142
|
+
return entries.map(stepBlock).find(Boolean) ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Whole-offset writers rewrite every step as a keyframe; refuse a list that holds more than values. */
|
|
146
|
+
export function refuseStepListRewrite(anim: GsapAnimation): void {
|
|
147
|
+
const step = stepListBlock(anim);
|
|
148
|
+
if (step) throw new GsapEditBlockedError("keyframes-uneditable", step);
|
|
149
|
+
}
|
|
150
|
+
|
|
120
151
|
// ── Drag → GSAP position math ──────────────────────────────────────────────
|
|
121
152
|
|
|
122
153
|
/**
|
|
@@ -330,6 +361,7 @@ export async function commitWholePathOffset(
|
|
|
330
361
|
// fallow-ignore-next-line code-duplication
|
|
331
362
|
let effectiveAnim = anim;
|
|
332
363
|
if (anim.keyframes) {
|
|
364
|
+
refuseStepListRewrite(anim);
|
|
333
365
|
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
334
366
|
if (newId) effectiveAnim = { ...anim, id: newId };
|
|
335
367
|
}
|
|
@@ -15,7 +15,7 @@ export type GsapEditBlockReason =
|
|
|
15
15
|
* true for `provenance-runtime-dynamic`; the others are parser or source-match
|
|
16
16
|
* limits, where the animation may well be plain authored source.
|
|
17
17
|
*
|
|
18
|
-
* Telemetry
|
|
18
|
+
* Telemetry, plus the sharper message a few details get (GSAP_EDIT_DETAIL_COPY).
|
|
19
19
|
*/
|
|
20
20
|
export type GsapEditBlockDetail =
|
|
21
21
|
| "provenance-runtime-dynamic"
|
|
@@ -33,6 +33,12 @@ export type GsapEditBlockDetail =
|
|
|
33
33
|
export type PlayheadEditRefusal =
|
|
34
34
|
| "eased-keyframes"
|
|
35
35
|
| "simple-array-keyframes"
|
|
36
|
+
| "array-step-delay"
|
|
37
|
+
| "array-step-callback"
|
|
38
|
+
| "array-step-config"
|
|
39
|
+
| "array-step-computed"
|
|
40
|
+
| "array-step-relative"
|
|
41
|
+
| "array-step-random"
|
|
36
42
|
| "unknown-ease"
|
|
37
43
|
| "implicit-end-unknown"
|
|
38
44
|
| "not-a-tween"
|
|
@@ -74,12 +80,31 @@ export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
|
|
|
74
80
|
"These layers are animated in different files. Move each file's layers separately.",
|
|
75
81
|
};
|
|
76
82
|
|
|
83
|
+
const STEP_LIST = "This animation's keyframes have a step";
|
|
84
|
+
const IN_CODE = "Edit them in the Code tab.";
|
|
85
|
+
/** A refusal a person can act on more precisely than its reason's message: what blocks it, then what to do. */
|
|
86
|
+
const GSAP_EDIT_DETAIL_COPY: Partial<Record<GsapEditBlockDetail, string>> = {
|
|
87
|
+
"array-step-delay": `${STEP_LIST} with its own delay, which Studio can't keep while it edits them. ${IN_CODE}`,
|
|
88
|
+
"array-step-callback": `${STEP_LIST} that runs code as it plays (like onComplete), which an edit would run again. ${IN_CODE}`,
|
|
89
|
+
"array-step-config": `${STEP_LIST} with its own tween setting (like repeat or stagger), which Studio can't keep while it edits them. ${IN_CODE}`,
|
|
90
|
+
"array-step-computed": `${STEP_LIST} whose value comes from code, which Studio can't keep while it edits them. ${IN_CODE}`,
|
|
91
|
+
"array-step-relative": `${STEP_LIST} with a relative value (like "+=40"), which an edit would apply again. ${IN_CODE}`,
|
|
92
|
+
"array-step-random": `${STEP_LIST} with a random() value, which an edit would roll again. ${IN_CODE}`,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export function gsapEditBlockMessage(
|
|
96
|
+
reason: GsapEditBlockReason,
|
|
97
|
+
detail?: GsapEditBlockDetail,
|
|
98
|
+
): string {
|
|
99
|
+
return (detail && GSAP_EDIT_DETAIL_COPY[detail]) ?? GSAP_EDIT_BLOCK_COPY[reason];
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
export class GsapEditBlockedError extends Error {
|
|
78
103
|
constructor(
|
|
79
104
|
readonly reason: GsapEditBlockReason,
|
|
80
105
|
readonly detail?: GsapEditBlockDetail,
|
|
81
106
|
) {
|
|
82
|
-
super(
|
|
107
|
+
super(gsapEditBlockMessage(reason, detail));
|
|
83
108
|
this.name = "GsapEditBlockedError";
|
|
84
109
|
}
|
|
85
110
|
}
|
|
@@ -22,7 +22,7 @@ export function liveTween(
|
|
|
22
22
|
vars: tween.vars,
|
|
23
23
|
_from: from,
|
|
24
24
|
...(head && { _pt: { d: { _pt: head } } }),
|
|
25
|
-
...(parts && { timeline: { getChildren: () => parts } }),
|
|
25
|
+
...(parts && { timeline: { getChildren: () => parts, duration: () => tween.duration } }),
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -47,6 +47,33 @@ export function previewWith(
|
|
|
47
47
|
export const tween = (fields: Partial<GsapAnimation>): GsapAnimation =>
|
|
48
48
|
({ targetSelector: "#box", propertyGroup: "position", ...fields }) as GsapAnimation;
|
|
49
49
|
|
|
50
|
+
/** `#box`'s position as a keyframe step list, parsed and run by GSAP (step `[duration, props]`). */
|
|
51
|
+
export function boxSteps(steps: Array<[number, Record<string, number | string>]>) {
|
|
52
|
+
const duration = steps.reduce((sum, [d]) => sum + d, 0);
|
|
53
|
+
let end = 0;
|
|
54
|
+
const keyframes = steps.map(([d, properties]) => {
|
|
55
|
+
end += d;
|
|
56
|
+
return { percentage: Math.round((end / duration) * 1000) / 10, properties };
|
|
57
|
+
});
|
|
58
|
+
const keys = tween({
|
|
59
|
+
id: "#box-to-0-position",
|
|
60
|
+
method: "to",
|
|
61
|
+
properties: {},
|
|
62
|
+
resolvedStart: 0,
|
|
63
|
+
duration,
|
|
64
|
+
keyframes: { format: "object-array", keyframes },
|
|
65
|
+
});
|
|
66
|
+
let start = 0;
|
|
67
|
+
const parts = steps.map(([d]) => {
|
|
68
|
+
const at = start;
|
|
69
|
+
start += d;
|
|
70
|
+
return { startTime: () => at, duration: () => d };
|
|
71
|
+
});
|
|
72
|
+
const live = (el: Element) =>
|
|
73
|
+
liveTween(el, { start: 0, duration, vars: { keyframes: [] } }, { parts });
|
|
74
|
+
return { keys, live };
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
/** A `to` tween on `#el` from 0, grouped as the parser would; duration 0 is an immediate-render hold. */
|
|
51
78
|
export function elTween(
|
|
52
79
|
id: string,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import { elementTargets } from "../utils/elementGsap";
|
|
3
3
|
import { resolveTweenStart } from "../utils/globalTimeCompiler";
|
|
4
|
+
import { KEYFRAME_PCT_MATCH } from "./gsapShared";
|
|
4
5
|
import type { ImplicitEndValue } from "./gsapValueAtPlayhead";
|
|
6
|
+
import type { ReadTween } from "./gsapRuntimeKeyframes";
|
|
5
7
|
|
|
6
8
|
// GSAP 3 internals: a property tween in a tween's `_pt` chain; CSSPlugin keeps its own under `d._pt`.
|
|
7
9
|
interface PropTween {
|
|
@@ -22,7 +24,7 @@ interface ParsedTween {
|
|
|
22
24
|
seek?: (time: number, suppressEvents?: boolean) => unknown;
|
|
23
25
|
getChildren?: (nested?: boolean, tweens?: boolean, timelines?: boolean) => ParsedTween[];
|
|
24
26
|
};
|
|
25
|
-
timeline?: { getChildren?: () => ParsedTween[] };
|
|
27
|
+
timeline?: { getChildren?: () => ParsedTween[]; duration?: () => number };
|
|
26
28
|
targets?: () => unknown[];
|
|
27
29
|
startTime?: () => number;
|
|
28
30
|
duration?: () => number;
|
|
@@ -57,6 +59,21 @@ function endsIn(tween: ParsedTween, prop: string): [number, number] | null {
|
|
|
57
59
|
return tween._from ? [pair[1], pair[0]] : pair;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/** `read` plus GSAP's start for a step list or a first key past 0%, read per channel. */
|
|
63
|
+
export function withParsedStart(read: ReadTween, live: unknown): ReadTween {
|
|
64
|
+
const tween = live as ParsedTween;
|
|
65
|
+
const first = read.keyframes[0];
|
|
66
|
+
if (!Array.isArray(tween.vars?.keyframes) && !((first?.percentage ?? 0) > 0)) return read;
|
|
67
|
+
const children = tween.timeline?.getChildren?.() ?? [tween];
|
|
68
|
+
const start: Record<string, number> = {};
|
|
69
|
+
for (const prop of ["x", "y", "width", "height"]) {
|
|
70
|
+
const ends = children.map((child) => endsIn(child, prop)).find(Boolean);
|
|
71
|
+
if (ends) start[prop] = ends[0];
|
|
72
|
+
}
|
|
73
|
+
const moved = Object.entries(start).some(([prop, value]) => value !== first?.properties[prop]);
|
|
74
|
+
return moved ? { ...read, start } : read;
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
/** The live tween GSAP built from `anim`: same element, start and channels, parsed. */
|
|
61
78
|
// fallow-ignore-next-line complexity
|
|
62
79
|
export function findParsedTween(
|
|
@@ -230,7 +247,7 @@ const BUILT_IN_EASES = [
|
|
|
230
247
|
export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
|
|
231
248
|
const data = anim.keyframes;
|
|
232
249
|
const parts = tween?.timeline?.getChildren?.() ?? [];
|
|
233
|
-
const total = tween?.duration?.() ?? 0;
|
|
250
|
+
const total = tween?.timeline?.duration?.() ?? 0;
|
|
234
251
|
if (data?.format !== "object-array" || parts.length !== data.keyframes.length || !(total > 0))
|
|
235
252
|
return anim;
|
|
236
253
|
const ends = parts.map((part) => ((part.startTime?.() ?? 0) + (part.duration?.() ?? 0)) / total);
|
|
@@ -241,3 +258,23 @@ export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | nul
|
|
|
241
258
|
}));
|
|
242
259
|
return { ...anim, keyframes: { ...data, keyframes } };
|
|
243
260
|
}
|
|
261
|
+
|
|
262
|
+
/** The keyframe nearest `pct` within {@link KEYFRAME_PCT_MATCH}, or -1: two steps can sit under 1% apart. */
|
|
263
|
+
export function nearestKeyframeIndex(keyframes: { percentage: number }[], pct: number): number {
|
|
264
|
+
let best = -1;
|
|
265
|
+
keyframes.forEach((kf, i) => {
|
|
266
|
+
const off = Math.abs(kf.percentage - pct);
|
|
267
|
+
if (
|
|
268
|
+
off <= KEYFRAME_PCT_MATCH &&
|
|
269
|
+
(best < 0 || off < Math.abs(keyframes[best]!.percentage - pct))
|
|
270
|
+
)
|
|
271
|
+
best = i;
|
|
272
|
+
});
|
|
273
|
+
return best;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function exactKeyframePct(anim: GsapAnimation, tween: ParsedTween | null, pct: number) {
|
|
277
|
+
const authored = anim.keyframes?.keyframes ?? [];
|
|
278
|
+
const i = nearestKeyframeIndex(authored, pct);
|
|
279
|
+
return withExactStepTimes(anim, tween).keyframes?.keyframes[i]?.percentage ?? pct;
|
|
280
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
computeCurrentPercentage,
|
|
24
24
|
findExistingPositionWrite,
|
|
25
25
|
findRotationSetAnimation,
|
|
26
|
+
stepListBlock,
|
|
26
27
|
} from "./gsapDragCommit";
|
|
27
28
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
28
29
|
import { isGestureTransactionCommit } from "./gestureTransaction";
|
|
@@ -218,7 +219,12 @@ async function planDrag(
|
|
|
218
219
|
if (!posAnim) {
|
|
219
220
|
return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
|
|
220
221
|
}
|
|
221
|
-
if (route === "whole-path")
|
|
222
|
+
if (route === "whole-path") {
|
|
223
|
+
const step = stepListBlock(posAnim);
|
|
224
|
+
return step
|
|
225
|
+
? { status: "blocked", reason: "keyframes-uneditable", detail: step }
|
|
226
|
+
: { status: "persisted" };
|
|
227
|
+
}
|
|
222
228
|
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
223
229
|
return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
|
|
224
230
|
}
|