@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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
98
|
-
const
|
|
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
|
-
|
|
104
|
-
if (
|
|
140
|
+
const at = pointAt(kf.properties);
|
|
141
|
+
if (!at) return;
|
|
105
142
|
nodes.push({
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 {
|
|
3
|
+
import { controlForNode, controlUnder, pressControl } from "./motionPathLayerNode";
|
|
4
4
|
|
|
5
|
-
// Keyframe nodes at x 60 and 120 (y 30)
|
|
6
|
-
const atLayer = { x: 60, y: 30
|
|
7
|
-
const other = { x: 120, y: 30
|
|
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.
|
|
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
|
|
16
|
-
function press(
|
|
17
|
-
document.elementsFromPoint = () => [circle, ...under];
|
|
18
|
-
return { currentTarget: circle, clientX:
|
|
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(
|
|
23
|
-
|
|
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
|
|
29
|
-
expect(
|
|
30
|
-
|
|
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("
|
|
38
|
-
expect(
|
|
39
|
-
expect(
|
|
40
|
-
|
|
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("
|
|
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
|
-
|
|
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(
|
|
71
|
+
expect(pressControl(down, handle)).toBe(true);
|
|
62
72
|
expect(got).toEqual([[7, 12, 34, 0]]);
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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(
|
|
83
|
+
expect(pressControl(down, box)).toBe(false);
|
|
76
84
|
expect(got).toBe(1);
|
|
77
85
|
box.style.pointerEvents = "none";
|
|
78
|
-
expect(
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
pressed: DrawnNode,
|
|
14
|
-
dotR: number,
|
|
19
|
+
pressed: Point,
|
|
15
20
|
live: Point | null,
|
|
16
|
-
):
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
24
|
-
export function
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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(
|
|
83
|
+
return Array.from(document.getElementsByTagName(selector)).filter(isHtmlElement);
|
|
87
84
|
}
|
|
88
|
-
return Array.from(document.querySelectorAll(selector)).filter(
|
|
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
|
-
|
|
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
|
|
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
|
|
248
|
+
if (!isHtmlElement(el)) continue;
|
|
261
249
|
const motionData = readMotionAttr(el);
|
|
262
250
|
if (motionData) {
|
|
263
251
|
motionElements.push({ element: el, motion: motionData });
|