@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.
Files changed (61) hide show
  1. package/dist/assets/{hyperframes-player-CmIh-wq9.js → hyperframes-player-uqzDaxVL.js} +1 -1
  2. package/dist/assets/{index-4LRR6o2F.js → index-CLBNYlDY.js} +1 -1
  3. package/dist/assets/{index-CrYoEZ5p.js → index-COhbL1xq.js} +177 -177
  4. package/dist/assets/{index-yCK8osBg.js → index-DXSYlqmV.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +5368 -4986
  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 +167 -33
  13. package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +49 -86
  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/motionPathCommit.nodeDrop.test.ts +212 -0
  19. package/src/components/editor/motionPathCommit.ts +58 -0
  20. package/src/components/editor/motionPathGeometry.test.ts +61 -1
  21. package/src/components/editor/motionPathGeometry.ts +50 -10
  22. package/src/components/editor/motionPathHome.ts +75 -0
  23. package/src/components/editor/motionPathLayerNode.test.ts +43 -35
  24. package/src/components/editor/motionPathLayerNode.ts +20 -16
  25. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
  26. package/src/components/editor/studioMotion.ts +7 -19
  27. package/src/components/editor/useMotionPathData.test.tsx +236 -0
  28. package/src/components/editor/useMotionPathData.ts +23 -61
  29. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
  30. package/src/hooks/gsapDragCommit.ts +33 -1
  31. package/src/hooks/gsapEditOutcome.ts +27 -2
  32. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  33. package/src/hooks/gsapParsedTween.ts +39 -2
  34. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  35. package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
  36. package/src/hooks/gsapRuntimeKeyframes.ts +11 -4
  37. package/src/hooks/gsapShared.test.ts +1 -0
  38. package/src/hooks/gsapShared.ts +8 -3
  39. package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
  40. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +107 -5
  41. package/src/hooks/gsapValueAtPlayhead.test.ts +129 -27
  42. package/src/hooks/gsapValueAtPlayhead.ts +46 -6
  43. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  44. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  45. package/src/hooks/intersectionObserverTestUtils.ts +16 -0
  46. package/src/hooks/resizeObserverTestUtils.ts +9 -2
  47. package/src/hooks/timelineEditingHelpers.ts +2 -9
  48. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  49. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
  50. package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
  51. package/src/hooks/useThumbnailStripSize.ts +206 -17
  52. package/src/noWindowBoundDomChecks.test.ts +31 -0
  53. package/src/player/components/CompositionThumbnail.tsx +10 -6
  54. package/src/player/components/ImageThumbnail.tsx +11 -5
  55. package/src/player/components/ThumbnailTiles.test.tsx +30 -0
  56. package/src/player/components/ThumbnailTiles.tsx +41 -0
  57. package/src/player/components/VideoThumbnail.test.tsx +116 -9
  58. package/src/player/components/VideoThumbnail.tsx +11 -5
  59. package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
  60. package/src/player/lib/timelineGroupInfo.ts +2 -1
  61. package/src/webmcp/webmcpTestUtils.ts +2 -2
@@ -8,21 +8,43 @@ import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
8
8
 
9
9
  /** Which source edit a dragged node maps to. */
10
10
  export type MotionNodeRef =
11
- | { type: "keyframe"; pct: number } // x/y position keyframe at this tween-relative %
11
+ | { type: "keyframe"; pct: number; step?: number } // x/y keyframe at this tween-relative %, array slot
12
12
  | { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index
13
13
 
14
- export interface MotionPathNode {
14
+ /** An offset on the path and the layout width/height set there, when one is. */
15
+ export interface MotionPathPoint {
15
16
  x: number;
16
17
  y: number;
18
+ w?: number;
19
+ h?: number;
20
+ }
21
+
22
+ export interface MotionPathNode extends MotionPathPoint {
17
23
  ref: MotionNodeRef;
18
24
  }
19
25
 
26
+ /** The live layer's centre without its x/y offset, its layout size now, and the share of a size
27
+ * change that moves the centre (0.5 from the left edge; 0 when xPercent -50 centres it). */
28
+ export type MotionPathHome = { x: number; y: number; w: number; h: number; ax: number; ay: number };
29
+
30
+ /** Where the layer's centre is at a node, with the size that keyframe sets. */
31
+ export function nodeCentre(n: MotionPathPoint, home: MotionPathHome, pScale: number) {
32
+ const grow = (to: number | undefined, now: number, share: number) =>
33
+ to === undefined ? 0 : (to - now) * share;
34
+ return {
35
+ x: home.x + (n.x + grow(n.w, home.w, home.ax)) * pScale,
36
+ y: home.y + (n.y + grow(n.h, home.h, home.ay)) * pScale,
37
+ };
38
+ }
39
+
20
40
  export interface MotionPathGeometry {
21
41
  /** "linear" = x/y keyframes; "arc" = motionPath tween. */
22
42
  kind: "linear" | "arc";
23
43
  /** SVG polyline points: "x,y x,y ...". */
24
44
  points: string;
25
45
  nodes: MotionPathNode[];
46
+ /** Where GSAP started the tween, when its first keyframe comes later: drawn, not draggable. */
47
+ start?: MotionPathPoint;
26
48
  }
27
49
 
28
50
  /**
@@ -78,6 +100,16 @@ export function nearestPointOnPath(
78
100
  return best;
79
101
  }
80
102
 
103
+ const finiteNumber = (v: unknown): v is number => typeof v === "number" && isFinite(v);
104
+
105
+ /** The layout width/height a keyframe sets, when it sets one. */
106
+ function sizeAt(p: Record<string, unknown>): { w?: number; h?: number } {
107
+ return {
108
+ ...(finiteNumber(p.width) && { w: p.width }),
109
+ ...(finiteNumber(p.height) && { h: p.height }),
110
+ };
111
+ }
112
+
81
113
  export function buildMotionPathGeometry(
82
114
  read: ReadTween | null,
83
115
  base: { x: number; y: number } = { x: 0, y: 0 },
@@ -94,26 +126,34 @@ export function buildMotionPathGeometry(
94
126
  // translate it folded in, else 0), so we default it and still draw a path. But if the tween
95
127
  // DOES animate an axis and a given keyframe omits it, that value is interpolated
96
128
  // (not 0) and can't be placed here → skip that node (the prior behavior).
97
- const finite = (v: unknown): v is number => typeof v === "number" && isFinite(v);
98
- const tweenHasX = read.keyframes.some((kf) => finite(kf.properties.x));
99
- const tweenHasY = read.keyframes.some((kf) => finite(kf.properties.y));
129
+ const tweenHasX = read.keyframes.some((kf) => finiteNumber(kf.properties.x));
130
+ const tweenHasY = read.keyframes.some((kf) => finiteNumber(kf.properties.y));
100
131
  if (!tweenHasX && !tweenHasY) return null; // no positional motion (opacity/scale only)
101
132
 
133
+ const pointAt = (p: Record<string, unknown>): MotionPathPoint | null => {
134
+ if ((tweenHasX && !finiteNumber(p.x)) || (tweenHasY && !finiteNumber(p.y))) return null;
135
+ const x = tweenHasX ? (p.x as number) : base.x;
136
+ const y = tweenHasY ? (p.y as number) : base.y;
137
+ return { x, y, ...sizeAt(p) };
138
+ };
102
139
  read.keyframes.forEach((kf, i) => {
103
- if (tweenHasX && !finite(kf.properties.x)) return;
104
- if (tweenHasY && !finite(kf.properties.y)) return;
140
+ const at = pointAt(kf.properties);
141
+ if (!at) return;
105
142
  nodes.push({
106
- x: tweenHasX ? (kf.properties.x as number) : base.x,
107
- y: tweenHasY ? (kf.properties.y as number) : base.y,
108
- ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage },
143
+ ...at,
144
+ ref: isArc
145
+ ? { type: "waypoint", index: i }
146
+ : { type: "keyframe", pct: kf.percentage, ...(kf.step == null ? {} : { step: kf.step }) },
109
147
  });
110
148
  });
111
149
 
112
150
  if (nodes.length < 2) return null;
151
+ const start = !isArc && read.start ? pointAt(read.start) : null;
113
152
 
114
153
  return {
115
154
  kind: isArc ? "arc" : "linear",
116
155
  points: nodes.map((n) => `${n.x},${n.y}`).join(" "),
117
156
  nodes,
157
+ ...(start && { start }),
118
158
  };
119
159
  }
@@ -0,0 +1,75 @@
1
+ import type { MotionPathHome } from "./motionPathGeometry";
2
+
3
+ // An element's computed transform translate: a group wrapper GSAP moved carries its offset here.
4
+ function transformTranslate(el: HTMLElement): { x: number; y: number } {
5
+ const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform;
6
+ if (!t || t === "none") return { x: 0, y: 0 };
7
+ const m3 = t.match(/matrix3d\(([^)]+)\)/);
8
+ if (m3) {
9
+ const v = m3[1].split(",").map(Number);
10
+ return { x: v[12] || 0, y: v[13] || 0 };
11
+ }
12
+ const m = t.match(/matrix\(([^)]+)\)/);
13
+ if (m) {
14
+ const v = m[1].split(",").map(Number);
15
+ return { x: v[4] || 0, y: v[5] || 0 };
16
+ }
17
+ return { x: 0, y: 0 };
18
+ }
19
+
20
+ function cssTranslate(el: HTMLElement): [number, number] {
21
+ const raw = el.ownerDocument?.defaultView?.getComputedStyle(el).translate ?? "none";
22
+ const [x = "0", y = "0"] = raw === "none" ? [] : raw.split(" ");
23
+ const px = (v: string, size: number) =>
24
+ v.endsWith("%") ? (Number.parseFloat(v) / 100) * size : Number.parseFloat(v) || 0;
25
+ return [px(x, el.offsetWidth), px(y, el.offsetHeight)];
26
+ }
27
+
28
+ /** Centre shift per px grown: 0.5 from a set left/top, -0.5 from a set right/bottom, plus the percent.
29
+ * ponytail: in-flow layers get 0 (layout may centre them); read their anchor if one draws off. */
30
+ function growShares(el: HTMLElement, px: number, py: number): { ax: number; ay: number } {
31
+ const style = el.ownerDocument?.defaultView?.getComputedStyle(el);
32
+ if (!style || !/^(absolute|fixed)$/.test(style.position)) return { ax: 0, ay: 0 };
33
+ const map = el.computedStyleMap?.();
34
+ const auto = (side: string) => String(map?.get(side) ?? "") === "auto";
35
+ const share = (start: string, end: string) => (auto(start) && !auto(end) ? -0.5 : 0.5);
36
+ return { ax: share("left", "right") + px, ay: share("top", "bottom") + py };
37
+ }
38
+
39
+ export function elementHome(el: HTMLElement): MotionPathHome {
40
+ let left = 0;
41
+ let top = 0;
42
+ let node: HTMLElement | null = el;
43
+ while (node) {
44
+ left += node.offsetLeft;
45
+ top += node.offsetTop;
46
+ // Ancestor transforms (a group wrapper GSAP moved) shift where the layer renders; its own
47
+ // transform is the animated offset the path itself draws, so it is left out.
48
+ if (node !== el) {
49
+ const t = transformTranslate(node);
50
+ left += t.x;
51
+ top += t.y;
52
+ }
53
+ const parent = node.offsetParent as HTMLElement | null;
54
+ if (!parent || parent.hasAttribute("data-composition-id")) break;
55
+ node = parent;
56
+ }
57
+ // A translate composes once GSAP has parsed the layer; before, its parse sums translate and transform,
58
+ // makes minus half the size xPercent -50 and folds the rest into x, which a path sets (_parseTransform).
59
+ const cache = (
60
+ el as { _gsap?: { x?: unknown; uncache?: unknown; xPercent?: unknown; yPercent?: unknown } }
61
+ )._gsap;
62
+ const parsed = cache !== undefined && "x" in cache && !cache.uncache;
63
+ const [tx, ty] = cssTranslate(el);
64
+ const share = (cached: unknown, t: number, size: number) =>
65
+ parsed ? (Number(cached) || 0) / 100 : t && Math.round(size / 2) === Math.round(-t) ? -0.5 : 0;
66
+ const own = parsed ? { x: 0, y: 0 } : transformTranslate(el);
67
+ const [px, py] = [
68
+ share(cache?.xPercent, tx + own.x, el.offsetWidth),
69
+ share(cache?.yPercent, ty + own.y, el.offsetHeight),
70
+ ];
71
+ const x = left + el.offsetWidth * (0.5 + px) + (parsed ? tx : 0);
72
+ const y = top + el.offsetHeight * (0.5 + py) + (parsed ? ty : 0);
73
+ const { ax, ay } = growShares(el, px, py);
74
+ return { x, y, w: el.offsetWidth, h: el.offsetHeight, ax, ay };
75
+ }
@@ -1,54 +1,64 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { expect, it } from "vitest";
3
- import { pressBelongsToLayer, pressSelectedLayer } from "./motionPathLayerNode";
3
+ import { controlForNode, controlUnder, pressControl } from "./motionPathLayerNode";
4
4
 
5
- // Keyframe nodes at x 60 and 120 (y 30), drawn from home (500, 400); dots of radius 6.
6
- const atLayer = { x: 60, y: 30, ax: 560, ay: 430 };
7
- const other = { x: 120, y: 30, ax: 620, ay: 430 };
5
+ // Keyframe nodes at x 60 and 120 (y 30); GSAP renders the layer at the first.
6
+ const atLayer = { x: 60, y: 30 };
7
+ const other = { x: 120, y: 30 };
8
8
  const live = { x: 60, y: 30 };
9
9
 
10
+ // The editor overlay: the selection's chrome (box and a handle) and another layer's off-canvas marker.
11
+ const overlay = document.createElement("div");
12
+ const chrome = document.createElement("div");
13
+ chrome.setAttribute("data-dom-edit-chrome", "true");
10
14
  const box = document.createElement("div");
11
15
  box.setAttribute("data-dom-edit-selection-box", "true");
16
+ const handle = document.createElement("button");
17
+ const marker = document.createElement("div");
18
+ chrome.append(box, handle);
19
+ overlay.append(chrome, marker);
20
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
12
21
  const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
13
- document.body.append(box, circle);
22
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
23
+ svg.append(line, circle);
24
+ document.body.append(overlay, svg);
14
25
 
15
- /** A press on `circle` at (x, y), with `under` what the document hit-tests there. */
16
- function press(x: number, y: number, under: Element[]) {
17
- document.elementsFromPoint = () => [circle, ...under];
18
- return { currentTarget: circle, clientX: x, clientY: y } as unknown as React.PointerEvent;
26
+ /** A press on `circle`, with `under` what the document hit-tests below it. */
27
+ function press(under: Element[]) {
28
+ document.elementsFromPoint = () => [circle, line, ...under];
29
+ return { currentTarget: circle, clientX: 0, clientY: 0 } as unknown as React.PointerEvent;
19
30
  }
20
31
 
21
32
  it("the node GSAP renders the layer at is the layer, inside the box or out", () => {
22
- expect(pressBelongsToLayer(press(562, 431, [box]), { x: 562, y: 431 }, atLayer, 6, live)).toBe(
23
- true,
24
- );
25
- expect(pressBelongsToLayer(press(570, 440, []), { x: 570, y: 440 }, atLayer, 6, live)).toBe(true);
33
+ expect(controlForNode(press([box, overlay]), atLayer, live)).toBe(box);
34
+ expect(controlForNode(press([overlay]), atLayer, live)).toBe(box);
26
35
  });
27
36
 
28
- it("inside the layer's box another node's grab ring is the layer's; its dot stays the node's", () => {
29
- expect(pressBelongsToLayer(press(610, 430, [box]), { x: 610, y: 430 }, other, 6, live)).toBe(
30
- true,
31
- );
32
- expect(pressBelongsToLayer(press(617, 430, [box]), { x: 617, y: 430 }, other, 6, live)).toBe(
33
- false,
34
- );
37
+ it("inside the layer's box any other node is the layer's, so a drag from its middle moves it", () => {
38
+ expect(controlForNode(press([box, overlay]), other, live)).toBe(box);
39
+ expect(controlForNode(press([box, overlay]), other, null)).toBe(box);
35
40
  });
36
41
 
37
- it("outside the layer's box a node's whole grab ring is the node's", () => {
38
- expect(pressBelongsToLayer(press(610, 430, []), { x: 610, y: 430 }, other, 6, live)).toBe(false);
39
- expect(pressBelongsToLayer(press(610, 430, [box]), { x: 610, y: 430 }, other, 6, null)).toBe(
40
- true,
41
- );
42
+ it("a handle above the box under a node takes the press, not the box or the node", () => {
43
+ expect(controlForNode(press([handle, box, overlay]), other, live)).toBe(handle);
44
+ expect(controlForNode(press([handle, box, overlay]), atLayer, live)).toBe(handle);
45
+ expect(controlUnder(press([handle, overlay]))).toBe(handle);
42
46
  });
43
47
 
44
- it("hands a press to the selected layer's box with its pointer and position", () => {
48
+ it("outside the layer's box and its handles a node is the node's", () => {
49
+ expect(controlForNode(press([overlay]), other, live)).toBeNull();
50
+ expect(controlForNode(press([marker, overlay]), other, live)).toBeNull();
51
+ expect(controlForNode(press([document.body]), other, live)).toBeNull();
52
+ });
53
+
54
+ it("hands a press to the control with its pointer and position", () => {
45
55
  const got: number[][] = [];
46
56
  const listen = (e: Event) => {
47
57
  const p = e as PointerEvent;
48
58
  got.push([p.pointerId, p.clientX, p.clientY, p.button]);
49
59
  e.preventDefault();
50
60
  };
51
- box.addEventListener("pointerdown", listen);
61
+ handle.addEventListener("pointerdown", listen);
52
62
  const nativeEvent = new PointerEvent("pointerdown", {
53
63
  pointerId: 7,
54
64
  clientX: 12,
@@ -58,24 +68,22 @@ it("hands a press to the selected layer's box with its pointer and position", ()
58
68
  cancelable: true,
59
69
  });
60
70
  const down = { currentTarget: circle, nativeEvent } as unknown as React.PointerEvent;
61
- expect(pressSelectedLayer(down)).toBe(true);
71
+ expect(pressControl(down, handle)).toBe(true);
62
72
  expect(got).toEqual([[7, 12, 34, 0]]);
63
- box.removeEventListener("pointerdown", listen);
64
- box.remove();
65
- expect(pressSelectedLayer(down)).toBe(false);
66
- document.body.append(box);
73
+ expect(pressControl(down, null)).toBe(false);
74
+ handle.removeEventListener("pointerdown", listen);
67
75
  });
68
76
 
69
- it("keeps the press when the box does not start a gesture with it, or takes no pointer", () => {
77
+ it("keeps the press when the control does not start a gesture with it, or takes no pointer", () => {
70
78
  let got = 0;
71
79
  const listen = () => void got++;
72
80
  box.addEventListener("pointerdown", listen);
73
81
  const nativeEvent = new PointerEvent("pointerdown", { bubbles: true, cancelable: true });
74
82
  const down = { currentTarget: circle, nativeEvent } as unknown as React.PointerEvent;
75
- expect(pressSelectedLayer(down)).toBe(false);
83
+ expect(pressControl(down, box)).toBe(false);
76
84
  expect(got).toBe(1);
77
85
  box.style.pointerEvents = "none";
78
- expect(pressSelectedLayer(down)).toBe(false);
86
+ expect(pressControl(down, box)).toBe(false);
79
87
  expect(got).toBe(1);
80
88
  box.style.pointerEvents = "";
81
89
  box.removeEventListener("pointerdown", listen);
@@ -1,28 +1,32 @@
1
1
  type Point = { x: number; y: number };
2
- type DrawnNode = Point & { ax: number; ay: number };
3
2
 
4
3
  const LAYER_BOX = '[data-dom-edit-selection-box="true"]';
4
+ export const SELECTION_CHROME = `${LAYER_BOX}, [data-dom-edit-chrome="true"], [data-dom-edit-crop-frame="true"]`;
5
5
 
6
6
  /** How far a node's dot reaches; a selected node draws it larger. */
7
7
  export const dotRadius = (r: number, selected: boolean) => (selected ? r * 1.5 : r);
8
8
 
9
- /** A press on the layer's own node, or in its box but off a node's dot, belongs to the layer. */
10
- export function pressBelongsToLayer(
9
+ export function controlUnder(e: React.PointerEvent): Element | null {
10
+ const path = e.currentTarget.closest("svg");
11
+ const hits = e.currentTarget.ownerDocument.elementsFromPoint(e.clientX, e.clientY);
12
+ const hit = hits.find((el) => !path?.contains(el));
13
+ return hit?.closest(SELECTION_CHROME) ? hit : null;
14
+ }
15
+
16
+ /** The handle or box above a node, else the box if the layer sits at the node; null: the node. */
17
+ export function controlForNode(
11
18
  e: React.PointerEvent,
12
- point: Point,
13
- pressed: DrawnNode,
14
- dotR: number,
19
+ pressed: Point,
15
20
  live: Point | null,
16
- ): boolean {
17
- if (live && Math.abs(pressed.x - live.x) < 0.5 && Math.abs(pressed.y - live.y) < 0.5) return true;
18
- const hits = e.currentTarget.ownerDocument.elementsFromPoint(e.clientX, e.clientY);
19
- const inBox = hits.some((el) => el.closest(LAYER_BOX));
20
- return inBox && Math.hypot(point.x - pressed.ax, point.y - pressed.ay) > dotR;
21
+ ): Element | null {
22
+ const atLayer = live && Math.abs(pressed.x - live.x) < 0.5 && Math.abs(pressed.y - live.y) < 0.5;
23
+ return (
24
+ controlUnder(e) ?? (atLayer ? e.currentTarget.ownerDocument.querySelector(LAYER_BOX) : null)
25
+ );
21
26
  }
22
27
 
23
- /** Hands a press to the selected layer's box; true when the box started a gesture with it. */
24
- export function pressSelectedLayer(e: React.PointerEvent): boolean {
25
- const box = e.currentTarget.ownerDocument.querySelector(LAYER_BOX);
26
- if (!box || getComputedStyle(box).pointerEvents === "none") return false;
27
- return !box.dispatchEvent(new PointerEvent("pointerdown", e.nativeEvent));
28
+ /** Hands a press to `control`; true when it started a gesture with it. */
29
+ export function pressControl(e: React.PointerEvent, control: Element | null): boolean {
30
+ if (!control || getComputedStyle(control).pointerEvents === "none") return false;
31
+ return !control.dispatchEvent(new PointerEvent("pointerdown", e.nativeEvent));
28
32
  }
@@ -130,6 +130,26 @@ describe("reapplyPositionEditsAfterSeek", () => {
130
130
  delete win.__timelines;
131
131
  });
132
132
 
133
+ it("reapplies and migrates marks in the preview, whichever window built its node", () => {
134
+ const frame = document.body.appendChild(document.createElement("iframe"));
135
+ const doc = frame.contentDocument!;
136
+ // The frame builds one node; the editor builds the other, as on loads where they differ.
137
+ const nodes = [doc.createElement("div"), document.createElement("div")];
138
+ for (const el of nodes) {
139
+ el.setAttribute(`data-${STUDIO_PATH_OFFSET_ATTR}`, "true");
140
+ el.setAttribute(STUDIO_ROTATION_ATTR, "true");
141
+ el.style.setProperty(STUDIO_ROTATION_PROP, "8deg");
142
+ doc.body.append(el);
143
+ }
144
+
145
+ reapplyPositionEditsAfterSeek(doc);
146
+
147
+ for (const el of nodes) {
148
+ expect(el.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true");
149
+ expect(el.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
150
+ }
151
+ });
152
+
133
153
  it("still migrates a legacy double-prefixed edit mark", () => {
134
154
  document.body.innerHTML = `<div id="a" data-${STUDIO_PATH_OFFSET_ATTR}="true"></div>`;
135
155
 
@@ -35,6 +35,7 @@ export {
35
35
  clearStudioMotionFromElement,
36
36
  } from "./studioMotionOps";
37
37
 
38
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
38
39
  import { readStudioMotionFromElement as readMotionAttr } from "./studioMotionOps";
39
40
  import {
40
41
  STUDIO_MOTION_ATTR,
@@ -71,21 +72,17 @@ function elementMatchesSourceFile(
71
72
  }
72
73
 
73
74
  function querySelectorCandidates(document: Document, selector: string): HTMLElement[] {
74
- const isCandidate = (element: Element): element is HTMLElement => {
75
- const HTMLElementCtor = element.ownerDocument.defaultView?.HTMLElement;
76
- return Boolean(HTMLElementCtor && element instanceof HTMLElementCtor);
77
- };
78
75
  const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
79
76
  if (className) {
80
77
  return Array.from(document.getElementsByTagName("*")).filter(
81
78
  (element): element is HTMLElement =>
82
- isCandidate(element) && element.classList.contains(className),
79
+ isHtmlElement(element) && element.classList.contains(className),
83
80
  );
84
81
  }
85
82
  if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
86
- return Array.from(document.getElementsByTagName(selector)).filter(isCandidate);
83
+ return Array.from(document.getElementsByTagName(selector)).filter(isHtmlElement);
87
84
  }
88
- return Array.from(document.querySelectorAll(selector)).filter(isCandidate);
85
+ return Array.from(document.querySelectorAll(selector)).filter(isHtmlElement);
89
86
  }
90
87
 
91
88
  function resolveTarget(
@@ -93,12 +90,10 @@ function resolveTarget(
93
90
  target: StudioMotionTarget,
94
91
  activeCompositionPath: string | null,
95
92
  ): HTMLElement | null {
96
- const HTMLElementCtor = document.defaultView?.HTMLElement;
97
93
  if (target.id) {
98
94
  const byId = document.getElementById(target.id);
99
95
  if (
100
- HTMLElementCtor &&
101
- byId instanceof HTMLElementCtor &&
96
+ isHtmlElement(byId) &&
102
97
  elementMatchesSourceFile(byId, target.sourceFile, activeCompositionPath)
103
98
  ) {
104
99
  return byId;
@@ -135,10 +130,8 @@ function restoreStudioMotionElement(element: HTMLElement, gsap: StudioMotionWind
135
130
  }
136
131
 
137
132
  function restoreStudioMotionElements(document: Document, gsap: StudioMotionWindow["gsap"]): void {
138
- const HTMLElementCtor = document.defaultView?.HTMLElement;
139
- if (!HTMLElementCtor) return;
140
133
  for (const element of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) {
141
- if (element instanceof HTMLElementCtor) restoreStudioMotionElement(element, gsap);
134
+ if (isHtmlElement(element)) restoreStudioMotionElement(element, gsap);
142
135
  }
143
136
  }
144
137
 
@@ -238,11 +231,6 @@ export function applyStudioMotionFromDom(document: Document, currentTime?: numbe
238
231
  win.__timelines[STUDIO_MOTION_TIMELINE_ID]?.kill?.();
239
232
  delete win.__timelines[STUDIO_MOTION_TIMELINE_ID];
240
233
 
241
- // Restore elements that had GSAP motion applied previously but whose attribute
242
- // is now just the legacy marker "true" (i.e. they were restored/cleared).
243
- const HTMLElementCtor = document.defaultView?.HTMLElement;
244
- if (!HTMLElementCtor) return 0;
245
-
246
234
  // Collect elements that have JSON motion data in their attribute
247
235
  const motionElements: Array<{
248
236
  element: HTMLElement;
@@ -257,7 +245,7 @@ export function applyStudioMotionFromDom(document: Document, currentTime?: numbe
257
245
  }> = [];
258
246
 
259
247
  for (const el of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) {
260
- if (!(el instanceof HTMLElementCtor)) continue;
248
+ if (!isHtmlElement(el)) continue;
261
249
  const motionData = readMotionAttr(el);
262
250
  if (motionData) {
263
251
  motionElements.push({ element: el, motion: motionData });