@hyperframes/studio 0.8.124 → 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.
- package/dist/assets/{hyperframes-player-ColOZ_78.js → hyperframes-player-BbbN-wWN.js} +1 -1
- package/dist/assets/{index-BuSTrK-Z.js → index-B8kU2xXl.js} +177 -177
- package/dist/assets/{index-CVuUfURE.js → index-Cll7uTNy.js} +1 -1
- package/dist/assets/{index-jQEqFKMk.js → index-nvLF29ZH.js} +1 -1
- package/dist/index.d.ts +43 -35
- package/dist/index.html +1 -1
- package/dist/index.js +4458 -4072
- 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 +140 -48
- package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
- package/src/components/editor/MotionPathOverlay.tsx +30 -61
- package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
- package/src/components/editor/domEditOverlayGestures.ts +2 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +6 -0
- package/src/components/editor/dragUndoPaint.test.ts +170 -2
- package/src/components/editor/gestureUndoRevert.ts +104 -0
- package/src/components/editor/manualEditsSeekReapply.ts +3 -6
- package/src/components/editor/manualOffsetDrag.ts +3 -15
- package/src/components/editor/motionPathGeometry.test.ts +48 -1
- package/src/components/editor/motionPathGeometry.ts +46 -8
- 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/useDomEditNudge.ts +1 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +9 -5
- 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/gestureTransaction.test.ts +60 -0
- package/src/hooks/gestureTransaction.ts +5 -2
- package/src/hooks/gsapGestureOutcome.ts +9 -2
- package/src/hooks/gsapParsedTween.ts +16 -0
- package/src/hooks/gsapResizeDropPoint.test.ts +39 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +27 -0
- package/src/hooks/gsapResizeIntercept.ts +55 -68
- package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +10 -3
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.ts +2 -1
- package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
- package/src/hooks/intersectionObserverTestUtils.ts +16 -0
- package/src/hooks/resizeObserverTestUtils.ts +9 -2
- package/src/hooks/timelineEditingHelpers.ts +2 -9
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +29 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +21 -25
- package/src/hooks/useGsapAwareGroupMove.ts +4 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +42 -1
- package/src/hooks/useGsapScriptCommits.ts +12 -3
- 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/utils/studioPendingEdits.test.ts +52 -0
- package/src/utils/studioPendingEdits.ts +55 -10
- package/src/webmcp/webmcpTestUtils.ts +2 -2
|
@@ -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 });
|
|
@@ -32,10 +32,10 @@ import {
|
|
|
32
32
|
applyManualOffsetNudgeDraft,
|
|
33
33
|
createManualOffsetDragMember,
|
|
34
34
|
endManualOffsetDragMembers,
|
|
35
|
-
manualOffsetMoveRevert,
|
|
36
35
|
restoreManualOffsetDragMembers,
|
|
37
36
|
type ManualOffsetDragMember,
|
|
38
37
|
} from "./manualOffsetDrag";
|
|
38
|
+
import { manualOffsetMoveRevert } from "./gestureUndoRevert";
|
|
39
39
|
import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
|
|
40
40
|
import {
|
|
41
41
|
CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
applyManualOffsetDragDraft,
|
|
13
13
|
endManualOffsetDragMembers,
|
|
14
14
|
restoreManualOffsetDragMembers,
|
|
15
|
-
manualOffsetMoveRevert,
|
|
16
15
|
} from "./manualOffsetDrag";
|
|
16
|
+
import { manualOffsetMoveRevert, elementLookRevert } from "./gestureUndoRevert";
|
|
17
17
|
import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
|
|
18
18
|
import {
|
|
19
19
|
applyStudioBoxSize,
|
|
@@ -443,7 +443,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
443
443
|
// Hold the final angle while the commit lands.
|
|
444
444
|
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
|
|
445
445
|
const commit = { ...finalRotation, plain: g.plainRotation };
|
|
446
|
-
|
|
446
|
+
const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
|
|
447
|
+
const saved = Promise.resolve(edit.adopt(() => opts.onRotationCommitRef.current(sel, commit)))
|
|
447
448
|
.then((result) => trackPreviewEditResult("rotate", "drag", result))
|
|
448
449
|
.catch((error) => {
|
|
449
450
|
logGestureCommitFailure("rotate commit failed", error);
|
|
@@ -454,6 +455,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
454
455
|
restoreRotation();
|
|
455
456
|
})
|
|
456
457
|
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
|
|
458
|
+
edit.settle(saved);
|
|
457
459
|
} else if (g.kind === "drag") {
|
|
458
460
|
// A moved drag (taps returned earlier) must not let the release click
|
|
459
461
|
// re-select whatever now sits under the pointer — dropping over a
|
|
@@ -535,9 +537,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
535
537
|
restoreStudioBoxSize(sel.element, g.initialBoxSize);
|
|
536
538
|
if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
|
|
537
539
|
};
|
|
538
|
-
|
|
539
|
-
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member)
|
|
540
|
-
)
|
|
540
|
+
const commitSize = () =>
|
|
541
|
+
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member);
|
|
542
|
+
const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
|
|
543
|
+
const saved = Promise.resolve(edit.adopt(commitSize))
|
|
541
544
|
.then((result) => trackPreviewEditResult("resize", "drag", result))
|
|
542
545
|
.catch((error) => {
|
|
543
546
|
logGestureCommitFailure("resize commit failed", error);
|
|
@@ -546,6 +549,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
546
549
|
if (member) endManualOffsetDragMembers([member]);
|
|
547
550
|
else endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
|
548
551
|
});
|
|
552
|
+
edit.settle(saved);
|
|
549
553
|
logResizeSettle(sel.element, "post-release");
|
|
550
554
|
}
|
|
551
555
|
};
|
|
@@ -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
|
+
});
|