@hyperframes/studio 0.8.125 → 0.8.126

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.
Files changed (47) hide show
  1. package/dist/assets/{hyperframes-player-CmIh-wq9.js → hyperframes-player-BbbN-wWN.js} +1 -1
  2. package/dist/assets/{index-CrYoEZ5p.js → index-B8kU2xXl.js} +170 -170
  3. package/dist/assets/{index-yCK8osBg.js → index-Cll7uTNy.js} +1 -1
  4. package/dist/assets/{index-4LRR6o2F.js → index-nvLF29ZH.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +4251 -3984
  7. package/dist/index.js.map +1 -1
  8. package/package.json +9 -8
  9. package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
  10. package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
  11. package/src/components/editor/MotionPathNode.tsx +56 -0
  12. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +140 -48
  13. package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +30 -61
  15. package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
  16. package/src/components/editor/manualEditsSeekReapply.ts +3 -6
  17. package/src/components/editor/manualOffsetDrag.ts +2 -3
  18. package/src/components/editor/motionPathGeometry.test.ts +48 -1
  19. package/src/components/editor/motionPathGeometry.ts +46 -8
  20. package/src/components/editor/motionPathHome.ts +75 -0
  21. package/src/components/editor/motionPathLayerNode.test.ts +43 -35
  22. package/src/components/editor/motionPathLayerNode.ts +20 -16
  23. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
  24. package/src/components/editor/studioMotion.ts +7 -19
  25. package/src/components/editor/useMotionPathData.test.tsx +236 -0
  26. package/src/components/editor/useMotionPathData.ts +23 -61
  27. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
  28. package/src/hooks/gsapParsedTween.ts +16 -0
  29. package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
  30. package/src/hooks/gsapRuntimeKeyframes.ts +10 -3
  31. package/src/hooks/gsapShared.ts +2 -1
  32. package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
  33. package/src/hooks/intersectionObserverTestUtils.ts +16 -0
  34. package/src/hooks/resizeObserverTestUtils.ts +9 -2
  35. package/src/hooks/timelineEditingHelpers.ts +2 -9
  36. package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
  37. package/src/hooks/useThumbnailStripSize.ts +206 -17
  38. package/src/noWindowBoundDomChecks.test.ts +31 -0
  39. package/src/player/components/CompositionThumbnail.tsx +10 -6
  40. package/src/player/components/ImageThumbnail.tsx +11 -5
  41. package/src/player/components/ThumbnailTiles.test.tsx +30 -0
  42. package/src/player/components/ThumbnailTiles.tsx +41 -0
  43. package/src/player/components/VideoThumbnail.test.tsx +116 -9
  44. package/src/player/components/VideoThumbnail.tsx +11 -5
  45. package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
  46. package/src/player/lib/timelineGroupInfo.ts +2 -1
  47. 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 { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGeometry";
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: { x: number; y: number } | null;
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<{ x: number; y: number } | null>(null);
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 = isPreviewHtmlElement(target, el) ? target : null;
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 && Math.abs(prev.x - h.x) < 0.5 && Math.abs(prev.y - h.y) < 0.5 ? prev : h,
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?.points === next?.points && prev?.kind === next?.kind ? prev : next,
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
  };
@@ -39,6 +39,7 @@ class ViewportObserver {
39
39
  const entry = { isIntersecting: true } as IntersectionObserverEntry;
40
40
  this.callback([entry], this as unknown as IntersectionObserver);
41
41
  }
42
+ unobserve() {}
42
43
  disconnect() {}
43
44
  }
44
45
 
@@ -2,6 +2,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import { elementTargets } from "../utils/elementGsap";
3
3
  import { resolveTweenStart } from "../utils/globalTimeCompiler";
4
4
  import type { ImplicitEndValue } from "./gsapValueAtPlayhead";
5
+ import type { ReadTween } from "./gsapRuntimeKeyframes";
5
6
 
6
7
  // GSAP 3 internals: a property tween in a tween's `_pt` chain; CSSPlugin keeps its own under `d._pt`.
7
8
  interface PropTween {
@@ -57,6 +58,21 @@ function endsIn(tween: ParsedTween, prop: string): [number, number] | null {
57
58
  return tween._from ? [pair[1], pair[0]] : pair;
58
59
  }
59
60
 
61
+ /** `read` plus GSAP's start for a step list or a first key past 0%, read per channel. */
62
+ export function withParsedStart(read: ReadTween, live: unknown): ReadTween {
63
+ const tween = live as ParsedTween;
64
+ const first = read.keyframes[0];
65
+ if (!Array.isArray(tween.vars?.keyframes) && !((first?.percentage ?? 0) > 0)) return read;
66
+ const children = tween.timeline?.getChildren?.() ?? [tween];
67
+ const start: Record<string, number> = {};
68
+ for (const prop of ["x", "y", "width", "height"]) {
69
+ const ends = children.map((child) => endsIn(child, prop)).find(Boolean);
70
+ if (ends) start[prop] = ends[0];
71
+ }
72
+ const moved = Object.entries(start).some(([prop, value]) => value !== first?.properties[prop]);
73
+ return moved ? { ...read, start } : read;
74
+ }
75
+
60
76
  /** The live tween GSAP built from `anim`: same element, start and channels, parsed. */
61
77
  // fallow-ignore-next-line complexity
62
78
  export function findParsedTween(
@@ -0,0 +1,48 @@
1
+ // @vitest-environment happy-dom
2
+ import { gsap } from "gsap";
3
+ import { expect, it } from "vitest";
4
+ import { readRuntimeKeyframes } from "./gsapRuntimeKeyframes";
5
+
6
+ /** The edit bench's `keys` layer: GSAP starts it at x 40, 260 wide, and plays it to 1 s. */
7
+ function read(keyframes: string) {
8
+ const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
9
+ box.style.width = "260px";
10
+ gsap.set(box, { x: 40 });
11
+ const win = { __timelines: {} as Record<string, gsap.core.Timeline> };
12
+ new Function(
13
+ "gsap",
14
+ "window",
15
+ `var tl = gsap.timeline({ paused: true });
16
+ tl.to("#x", { keyframes: ${keyframes} }, 0);
17
+ window.__timelines["t"] = tl;`,
18
+ )(gsap, win);
19
+ const timeline = win.__timelines.t!;
20
+ timeline.progress(0.0001, true).seek(1);
21
+ const iframe = { contentWindow: { ...win, gsap }, contentDocument: document };
22
+ const got = readRuntimeKeyframes(iframe as unknown as HTMLIFrameElement, "#x");
23
+ timeline.kill();
24
+ box.remove();
25
+ return got;
26
+ }
27
+
28
+ it("reads where GSAP started a step list whose first keyframe comes later", () => {
29
+ const got = read(
30
+ `[{ x: 60, width: 280, duration: 2, ease: "none" }, { x: 120, width: 320, duration: 1, ease: "none" }]`,
31
+ );
32
+ expect(got?.start).toEqual({ x: 40, width: 260 });
33
+ });
34
+
35
+ it.each([
36
+ [`{ "50%": { x: 60, y: 20 }, "100%": { x: 120, y: 40 } }, duration: 2`, { x: 40, y: 0 }],
37
+ [
38
+ `{ "50%": { x: 60, width: 280 }, "100%": { x: 120, width: 320 } }, duration: 2`,
39
+ { x: 40, width: 260 },
40
+ ],
41
+ ])("reads every channel's start for a percentage list past 0%: %s", (keyframes, start) => {
42
+ expect(read(keyframes)?.start).toEqual(start);
43
+ });
44
+
45
+ it("reads no start for keyframes that begin at 0%, or a step list that begins where GSAP started", () => {
46
+ expect(read(`{ "0%": { x: 10 }, "100%": { x: 120 } }, duration: 3`)?.start).toBeUndefined();
47
+ expect(read(`[{ x: 40, duration: 0 }, { x: 120, duration: 1 }]`)?.start).toBeUndefined();
48
+ });
@@ -11,6 +11,7 @@
11
11
  import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
12
12
  import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
13
13
  import { roundTo3 } from "../utils/rounding";
14
+ import { withParsedStart } from "./gsapParsedTween";
14
15
  import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
15
16
  import { gsapRendersTransform } from "../components/editor/gsapAnimatesProperty";
16
17
 
@@ -43,7 +44,13 @@ export interface RuntimeTimeline {
43
44
  }
44
45
 
45
46
  type Pct = { percentage: number; properties: Record<string, number | string> };
46
- export type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
47
+ export type ReadTween = {
48
+ keyframes: Pct[];
49
+ easeEach?: string;
50
+ arcPath?: ArcPathConfig;
51
+ /** GSAP's start values when the first keyframe is after 0%. */
52
+ start?: Record<string, number>;
53
+ };
47
54
 
48
55
  export interface RuntimeKeyframeEntry {
49
56
  keyframes: Pct[];
@@ -365,11 +372,11 @@ export function readRuntimeKeyframes(
365
372
  const read = readTween(tween.vars);
366
373
  if (!read) continue;
367
374
  if (requireChannels && !readCarriesChannel(read, requireChannels)) continue;
368
- if (firstRead === null) firstRead = read;
375
+ if (firstRead === null) firstRead = withParsedStart(read, tween);
369
376
  // Prefer the tween whose [start, start+dur] contains the playhead.
370
377
  if (now != null) {
371
378
  const start = typeof tween.startTime === "function" ? tween.startTime() : 0;
372
- if (now >= start - 1e-3 && now <= start + dur + 1e-3) return read;
379
+ if (now >= start - 1e-3 && now <= start + dur + 1e-3) return withParsedStart(read, tween);
373
380
  }
374
381
  }
375
382
  }
@@ -3,6 +3,7 @@
3
3
  * Centralises duplicated interfaces, constants, and small utilities
4
4
  * to reduce drift risk.
5
5
  */
6
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
6
7
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
7
8
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
8
9
  import {
@@ -223,7 +224,7 @@ function structuralSelector(element: Element): string | null {
223
224
  const parts: string[] = [];
224
225
  for (let node: Element | null = element; node; node = node.parentElement) {
225
226
  if (node !== element) {
226
- const id = node instanceof HTMLElement ? node.id : "";
227
+ const id = isHtmlElement(node) ? node.id : "";
227
228
  const hfId = node.getAttribute("data-hf-id");
228
229
  if (id) {
229
230
  parts.unshift(idSelector(id));
@@ -110,6 +110,16 @@ describe("writeTargetSelector", () => {
110
110
  expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
111
111
  });
112
112
 
113
+ it("anchors on an ancestor's id in the preview, whichever window built its nodes", () => {
114
+ const frame = document.body.appendChild(document.createElement("iframe"));
115
+ const doc = frame.contentDocument!;
116
+ doc.body.innerHTML = `<div id="scene"><div></div><div></div></div>`;
117
+ const el = doc.querySelectorAll<HTMLElement>("#scene > div")[1]!;
118
+ const selection = { ...stableSelectionFor(el), selector: "div[unclosed" } as DomEditSelection;
119
+
120
+ expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
121
+ });
122
+
113
123
  it("returns null when a live DOM is present and no rung addresses one element", () => {
114
124
  document.body.innerHTML = `<div id="scene"><div class="group"></div><div class="group"></div></div>`;
115
125
  const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
@@ -0,0 +1,16 @@
1
+ // Test-only IntersectionObserver stand-in: everything observed is reported near the screen, asynchronously.
2
+ export function NearScreenIntersectionObserver(callback: IntersectionObserverCallback) {
3
+ const observer = {
4
+ observe(target: Element) {
5
+ queueMicrotask(() =>
6
+ callback(
7
+ [{ isIntersecting: true, target } as IntersectionObserverEntry],
8
+ observer as unknown as IntersectionObserver,
9
+ ),
10
+ );
11
+ },
12
+ unobserve() {},
13
+ disconnect() {},
14
+ };
15
+ return observer;
16
+ }
@@ -1,14 +1,21 @@
1
1
  // Test-only ResizeObserver stand-in: a test delivers a size by hand with `reportResize`.
2
2
  let resizeCallback: ResizeObserverCallback | null = null;
3
+ let observed: Element | null = null;
3
4
 
4
5
  export function MockResizeObserver(callback: ResizeObserverCallback) {
5
6
  resizeCallback = callback;
6
- return { observe() {}, disconnect() {}, unobserve() {} };
7
+ return {
8
+ observe(target: Element) {
9
+ observed = target;
10
+ },
11
+ disconnect() {},
12
+ unobserve() {},
13
+ };
7
14
  }
8
15
 
9
16
  export function reportResize(width: number, height: number): void {
10
17
  resizeCallback!(
11
- [{ contentRect: { width, height } } as ResizeObserverEntry],
18
+ [{ contentRect: { width, height }, target: observed } as ResizeObserverEntry],
12
19
  {} as ResizeObserver,
13
20
  );
14
21
  }
@@ -1,3 +1,4 @@
1
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
1
2
  import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
2
3
  import { toAuthoredStart } from "../player/store/timelineElement";
3
4
  import {
@@ -35,14 +36,6 @@ import {
35
36
  } from "../components/editor/domEditingElement";
36
37
  export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
37
38
  export { readFileContent };
38
- function isHTMLElement(element: Element | null): element is HTMLElement {
39
- if (!element) return false;
40
- // Use the element's OWN realm's HTMLElement: timeline clips live in the preview
41
- // iframe, and cross-realm `element instanceof HTMLElement` (main window) is
42
- // always false — which silently dropped every timeline z-index commit.
43
- const Ctor = element.ownerDocument?.defaultView?.HTMLElement ?? globalThis.HTMLElement;
44
- return element instanceof Ctor;
45
- }
46
39
  /**
47
40
  * Resolve a timeline vertical move to a z-index stacking reorder and commit it
48
41
  * through the shared layers-panel reorder path. Reads live sibling z-index from
@@ -96,7 +89,7 @@ export function applyTimelineStackingReorder(input: {
96
89
  input.activeCompPath,
97
90
  )
98
91
  : null;
99
- if (!isHTMLElement(element)) return Promise.resolve();
92
+ if (!isHtmlElement(element)) return Promise.resolve();
100
93
  if (getElementZIndex(element) === change.zIndex) continue;
101
94
  commitEntries.push({
102
95
  element,