@hyperframes/studio 0.7.99 → 0.7.100
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-BY5RLMBA.js → hyperframes-player-D0x4bQn-.js} +1 -1
- package/dist/assets/{index-DMSqmZM6.js → index-CVAZ9AJE.js} +200 -200
- package/dist/assets/{index-CQ07_DLG.js → index-Cy1eXOyE.js} +1 -1
- package/dist/assets/{index-DeXLAktv.js → index-F8iVj3EF.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +440 -344
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -2
- package/src/components/StudioHeader.test.ts +28 -0
- package/src/components/StudioHeader.tsx +21 -2
- package/src/components/StudioLeftSidebar.tsx +2 -2
- package/src/components/nle/NLEContext.tsx +16 -7
- package/src/components/nle/TimelineResizeDivider.tsx +4 -11
- package/src/contexts/PanelLayoutContext.tsx +6 -3
- package/src/hooks/gsapEditOutcome.ts +19 -1
- package/src/hooks/gsapResizeGeometrySweep.test.ts +289 -0
- package/src/hooks/gsapResizeIntercept.test.ts +2 -2
- package/src/hooks/gsapResizeIntercept.ts +37 -14
- package/src/hooks/gsapResizeMixedTween.test.ts +168 -0
- package/src/hooks/gsapResizeSweep.test.ts +274 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +19 -2
- package/src/hooks/useGsapAwareEditing.ts +13 -5
- package/src/hooks/usePanelLayout.test.ts +117 -0
- package/src/hooks/usePanelLayout.ts +121 -39
- package/src/utils/fitPanels.test.ts +164 -0
- package/src/utils/fitPanels.ts +151 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.100",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"gsap": "^3.13.0",
|
|
48
48
|
"marked": "^14.1.4",
|
|
49
49
|
"mediabunny": "^1.45.3",
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/sdk": "0.7.
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
50
|
+
"@hyperframes/core": "0.7.100",
|
|
51
|
+
"@hyperframes/player": "0.7.100",
|
|
52
|
+
"@hyperframes/sdk": "0.7.100",
|
|
53
|
+
"@hyperframes/studio-server": "0.7.100",
|
|
54
|
+
"@hyperframes/parsers": "0.7.100"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "19",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"vite": "^6.4.2",
|
|
68
68
|
"vitest": "^3.2.4",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
|
-
"@hyperframes/producer": "0.7.
|
|
70
|
+
"@hyperframes/producer": "0.7.100"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -399,7 +399,7 @@ export function StudioApp() {
|
|
|
399
399
|
} = useInspectorState(
|
|
400
400
|
panelLayout.rightPanelTab,
|
|
401
401
|
panelLayout.rightInspectorPanes,
|
|
402
|
-
panelLayout.
|
|
402
|
+
panelLayout.effectiveRightCollapsed,
|
|
403
403
|
isPlaying,
|
|
404
404
|
domEditSession.domEditSelection,
|
|
405
405
|
gestureState === "recording",
|
|
@@ -512,7 +512,7 @@ export function StudioApp() {
|
|
|
512
512
|
/>
|
|
513
513
|
}
|
|
514
514
|
right={
|
|
515
|
-
panelLayout.
|
|
515
|
+
panelLayout.effectiveRightCollapsed ? null : (
|
|
516
516
|
<StudioRightPanel
|
|
517
517
|
designPanelActive={designPanelActive}
|
|
518
518
|
activeBlockParams={activeBlockParams}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shouldOpenInspector } from "./StudioHeader";
|
|
3
|
+
|
|
4
|
+
describe("shouldOpenInspector", () => {
|
|
5
|
+
it("opens when the panel is hidden", () => {
|
|
6
|
+
expect(shouldOpenInspector(true, false)).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("opens when a non-inspector tab is showing", () => {
|
|
10
|
+
expect(shouldOpenInspector(false, false)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("closes when the inspector is genuinely on screen", () => {
|
|
14
|
+
expect(shouldOpenInspector(false, true)).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("opens when the window railed the panel away", () => {
|
|
18
|
+
// The regression this guards: the button used to branch on the raw
|
|
19
|
+
// rightCollapsed intent, which is still `false` while the window has the
|
|
20
|
+
// panel railed. That took the close branch, wrote rightCollapsed=true, and
|
|
21
|
+
// since that value is synced into the shareable Studio URL, a click that
|
|
22
|
+
// did nothing visible rewrote the link.
|
|
23
|
+
const userIntentIsOpen = false;
|
|
24
|
+
const windowRailedItAway = true;
|
|
25
|
+
expect(shouldOpenInspector(windowRailedItAway, true)).toBe(true);
|
|
26
|
+
expect(shouldOpenInspector(userIntentIsOpen, true)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -196,6 +196,21 @@ export function ViewModeToggle() {
|
|
|
196
196
|
);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Does the header's Inspector button open the panel, or close it?
|
|
201
|
+
*
|
|
202
|
+
* Takes the EFFECTIVE collapse state, so a panel the window has railed away
|
|
203
|
+
* counts as closed even though the user's stored intent still says open. The
|
|
204
|
+
* argument name is the guard: passing raw intent here is the bug this exists
|
|
205
|
+
* to keep out.
|
|
206
|
+
*/
|
|
207
|
+
export function shouldOpenInspector(
|
|
208
|
+
effectiveRightCollapsed: boolean,
|
|
209
|
+
inspectorPanelActive: boolean,
|
|
210
|
+
): boolean {
|
|
211
|
+
return effectiveRightCollapsed || !inspectorPanelActive;
|
|
212
|
+
}
|
|
213
|
+
|
|
199
214
|
// fallow-ignore-next-line complexity
|
|
200
215
|
export function StudioHeader({
|
|
201
216
|
captureFrameHref,
|
|
@@ -208,7 +223,11 @@ export function StudioHeader({
|
|
|
208
223
|
onExport,
|
|
209
224
|
}: StudioHeaderProps) {
|
|
210
225
|
const { projectId, editHistory, handleUndo, handleRedo, renderQueue } = useStudioShellContext();
|
|
211
|
-
|
|
226
|
+
// effectiveRightCollapsed, not the raw intent: in the auto-railed state the
|
|
227
|
+
// intent is still "open" while the panel is hidden, so branching on intent
|
|
228
|
+
// made this button write rightCollapsed=true — and that value is synced into
|
|
229
|
+
// the shareable Studio URL, so a dead click would rewrite a link.
|
|
230
|
+
const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
|
|
212
231
|
const isRendering = renderQueue.isRendering;
|
|
213
232
|
|
|
214
233
|
return (
|
|
@@ -328,7 +347,7 @@ export function StudioHeader({
|
|
|
328
347
|
<button
|
|
329
348
|
type="button"
|
|
330
349
|
onClick={() => {
|
|
331
|
-
if (
|
|
350
|
+
if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
|
|
332
351
|
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
|
|
333
352
|
setRightPanelTab("design");
|
|
334
353
|
setRightCollapsed(false);
|
|
@@ -36,7 +36,7 @@ export function StudioLeftSidebar({
|
|
|
36
36
|
onAddCompositionToTimeline,
|
|
37
37
|
}: StudioLeftSidebarProps) {
|
|
38
38
|
const {
|
|
39
|
-
|
|
39
|
+
effectiveLeftCollapsed,
|
|
40
40
|
leftWidth,
|
|
41
41
|
adjustPanelWidth,
|
|
42
42
|
toggleLeftSidebar,
|
|
@@ -71,7 +71,7 @@ export function StudioLeftSidebar({
|
|
|
71
71
|
[renderQueue, waitForPendingDomEditSaves],
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
-
if (
|
|
74
|
+
if (effectiveLeftCollapsed) {
|
|
75
75
|
return (
|
|
76
76
|
<div className="mr-0.5 flex w-10 flex-shrink-0 flex-col items-center rounded-lg border border-neutral-800/50 bg-neutral-950 pt-1">
|
|
77
77
|
<button
|
|
@@ -11,7 +11,7 @@ import { useTimelinePlayer, usePlayerStore } from "../../player";
|
|
|
11
11
|
import type { TimelineElement } from "../../player";
|
|
12
12
|
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
|
13
13
|
import { useCompositionStack } from "./useCompositionStack";
|
|
14
|
-
import { MIN_TIMELINE_H,
|
|
14
|
+
import { MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
|
|
15
15
|
import { setCompositionSourceMap } from "../editor/domEditingDom";
|
|
16
16
|
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
|
|
17
17
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
@@ -273,13 +273,22 @@ export function NLEProvider({
|
|
|
273
273
|
}, []);
|
|
274
274
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
275
275
|
// A height persisted on a tall window can exceed this window's container and
|
|
276
|
-
// collapse the flex-1 preview to 0px
|
|
277
|
-
//
|
|
276
|
+
// collapse the flex-1 preview to 0px. Observing the container rather than
|
|
277
|
+
// clamping once at mount is what makes a window RESIZED after load behave the
|
|
278
|
+
// same as one loaded at that size: dragging 760 -> 520 tall used to leave the
|
|
279
|
+
// timeline at its stored 429px and the preview at 47px.
|
|
278
280
|
useEffect(() => {
|
|
279
|
-
const
|
|
280
|
-
if (!
|
|
281
|
-
const
|
|
282
|
-
|
|
281
|
+
const element = containerRef.current;
|
|
282
|
+
if (!element || typeof ResizeObserver === "undefined") return;
|
|
283
|
+
const reconcile = () => {
|
|
284
|
+
const containerH = element.getBoundingClientRect().height;
|
|
285
|
+
if (!containerH) return;
|
|
286
|
+
setTimelineH((prev) => fitTimelineHeight(containerH, prev));
|
|
287
|
+
};
|
|
288
|
+
reconcile();
|
|
289
|
+
const observer = new ResizeObserver(reconcile);
|
|
290
|
+
observer.observe(element);
|
|
291
|
+
return () => observer.disconnect();
|
|
283
292
|
}, []);
|
|
284
293
|
|
|
285
294
|
const hasLoadedOnceRef = useRef(false);
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
-
|
|
3
|
-
export const MIN_TIMELINE_H = 100;
|
|
4
|
-
export const MIN_PREVIEW_H = 120;
|
|
2
|
+
import { MIN_PREVIEW_H, MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* Horizontal drag/keyboard-resizable divider between the preview and the
|
|
@@ -41,12 +39,7 @@ export function TimelineResizeDivider({
|
|
|
41
39
|
if (!isDragging.current || !containerRef.current) return;
|
|
42
40
|
const rect = containerRef.current.getBoundingClientRect();
|
|
43
41
|
const mouseY = e.clientY - rect.top;
|
|
44
|
-
|
|
45
|
-
const newTimelineH = Math.max(
|
|
46
|
-
MIN_TIMELINE_H,
|
|
47
|
-
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
|
48
|
-
);
|
|
49
|
-
setTimelineH(newTimelineH);
|
|
42
|
+
setTimelineH(fitTimelineHeight(rect.height, rect.height - mouseY));
|
|
50
43
|
},
|
|
51
44
|
[disabled, containerRef, setTimelineH],
|
|
52
45
|
);
|
|
@@ -61,10 +54,10 @@ export function TimelineResizeDivider({
|
|
|
61
54
|
if (disabled) return;
|
|
62
55
|
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
63
56
|
e.preventDefault();
|
|
64
|
-
const containerH = containerRef.current?.getBoundingClientRect().height ??
|
|
57
|
+
const containerH = containerRef.current?.getBoundingClientRect().height ?? 0;
|
|
65
58
|
const delta = e.key === "ArrowUp" ? 16 : -16;
|
|
66
59
|
setTimelineH((prev) => {
|
|
67
|
-
const next =
|
|
60
|
+
const next = fitTimelineHeight(containerH, prev + delta);
|
|
68
61
|
persistTimelineH(next);
|
|
69
62
|
return next;
|
|
70
63
|
});
|
|
@@ -17,9 +17,10 @@ export function PanelLayoutProvider({
|
|
|
17
17
|
rightWidth,
|
|
18
18
|
adjustPanelWidth,
|
|
19
19
|
leftCollapsed,
|
|
20
|
-
setLeftCollapsed,
|
|
21
20
|
rightCollapsed,
|
|
22
21
|
setRightCollapsed,
|
|
22
|
+
effectiveLeftCollapsed,
|
|
23
|
+
effectiveRightCollapsed,
|
|
23
24
|
rightPanelTab,
|
|
24
25
|
setRightPanelTab,
|
|
25
26
|
rightInspectorPanes,
|
|
@@ -41,9 +42,10 @@ export function PanelLayoutProvider({
|
|
|
41
42
|
rightWidth,
|
|
42
43
|
adjustPanelWidth,
|
|
43
44
|
leftCollapsed,
|
|
44
|
-
setLeftCollapsed,
|
|
45
45
|
rightCollapsed,
|
|
46
46
|
setRightCollapsed,
|
|
47
|
+
effectiveLeftCollapsed,
|
|
48
|
+
effectiveRightCollapsed,
|
|
47
49
|
rightPanelTab,
|
|
48
50
|
setRightPanelTab,
|
|
49
51
|
rightInspectorPanes,
|
|
@@ -59,9 +61,10 @@ export function PanelLayoutProvider({
|
|
|
59
61
|
rightWidth,
|
|
60
62
|
adjustPanelWidth,
|
|
61
63
|
leftCollapsed,
|
|
62
|
-
setLeftCollapsed,
|
|
63
64
|
rightCollapsed,
|
|
64
65
|
setRightCollapsed,
|
|
66
|
+
effectiveLeftCollapsed,
|
|
67
|
+
effectiveRightCollapsed,
|
|
65
68
|
rightPanelTab,
|
|
66
69
|
setRightPanelTab,
|
|
67
70
|
rightInspectorPanes,
|
|
@@ -3,7 +3,25 @@ import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/
|
|
|
3
3
|
export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
|
|
4
4
|
|
|
5
5
|
export type GsapEditOutcome =
|
|
6
|
-
| {
|
|
6
|
+
| {
|
|
7
|
+
status: "persisted";
|
|
8
|
+
/**
|
|
9
|
+
* Whether this edit already accounted for where the gesture left the
|
|
10
|
+
* element, so the caller must not persist the drag offset on top.
|
|
11
|
+
*
|
|
12
|
+
* The scale route needs it: a committed scale renders around the element
|
|
13
|
+
* centre rather than the dragged corner, so it measures the difference
|
|
14
|
+
* and writes the position itself. Every other route moves nothing the
|
|
15
|
+
* caller has not already been told about, and the caller owns the offset.
|
|
16
|
+
*
|
|
17
|
+
* It has to be reported rather than inferred. The caller used to guess
|
|
18
|
+
* from "does this element have a scale-group tween", which is true for an
|
|
19
|
+
* element whose scale is an instant hold — but that resize commits
|
|
20
|
+
* width/height, not scale, so the guess withheld an offset nobody wrote
|
|
21
|
+
* and the element snapped back to its authored position on every drag.
|
|
22
|
+
*/
|
|
23
|
+
ownsDragOffset?: boolean;
|
|
24
|
+
}
|
|
7
25
|
| { status: "blocked"; reason: GsapEditBlockReason };
|
|
8
26
|
|
|
9
27
|
const COPY: Record<GsapEditBlockReason, string> = {
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/**
|
|
3
|
+
* What the resize COMMITS, checked as geometry rather than as structure.
|
|
4
|
+
*
|
|
5
|
+
* The structural sweep beside this one proves a resize never addresses a
|
|
6
|
+
* missing animation and never leaves a tween spanning two property groups.
|
|
7
|
+
* Neither says the box ends up the size the user dragged it to, which is the
|
|
8
|
+
* thing they are actually looking at.
|
|
9
|
+
*
|
|
10
|
+
* The invariant is split by who owns the drop point, because the two halves are
|
|
11
|
+
* genuinely different jobs:
|
|
12
|
+
*
|
|
13
|
+
* - The committed size or scale must reproduce the RENDERED box the user
|
|
14
|
+
* dropped, whatever rotation is on the element. This is the resize's job in
|
|
15
|
+
* every route.
|
|
16
|
+
* - When the resize reports `ownsDragOffset`, the box must also land on the
|
|
17
|
+
* drop POINT, because it has taken responsibility for the position. When it
|
|
18
|
+
* does not, position is the drag's job and is not asserted here.
|
|
19
|
+
*
|
|
20
|
+
* Rotation is the reason this exists. The committed scale is worked out from
|
|
21
|
+
* the element's CSS box, and a rotated element's rendered box is not its CSS
|
|
22
|
+
* box — so the two are only equal if the drafted size is in CSS-box terms all
|
|
23
|
+
* the way through. A sweep across rotations is what tells us it is.
|
|
24
|
+
*/
|
|
25
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
26
|
+
import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
27
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
28
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
29
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
30
|
+
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
35
|
+
document.body.innerHTML = "";
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const LAYOUT = { left: 120, top: 520 };
|
|
39
|
+
|
|
40
|
+
interface Pose {
|
|
41
|
+
box: { w: number; h: number };
|
|
42
|
+
pos: { x: number; y: number };
|
|
43
|
+
scale: { x: number; y: number };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The AABB a browser reports for `translate() rotate() scale()` about the centre. */
|
|
47
|
+
function renderRect(pose: Pose, rotationDeg: number) {
|
|
48
|
+
const rad = (rotationDeg * Math.PI) / 180;
|
|
49
|
+
const [cos, sin] = [Math.abs(Math.cos(rad)), Math.abs(Math.sin(rad))];
|
|
50
|
+
const [sw, sh] = [pose.box.w * pose.scale.x, pose.box.h * pose.scale.y];
|
|
51
|
+
const w = sw * cos + sh * sin;
|
|
52
|
+
const h = sw * sin + sh * cos;
|
|
53
|
+
const cx = LAYOUT.left + pose.box.w / 2 + pose.pos.x;
|
|
54
|
+
const cy = LAYOUT.top + pose.box.h / 2 + pose.pos.y;
|
|
55
|
+
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Props = Record<string, number>;
|
|
59
|
+
|
|
60
|
+
function tween(id: string, properties: Props, duration: number): GsapAnimation {
|
|
61
|
+
return {
|
|
62
|
+
id,
|
|
63
|
+
targetSelector: "#el",
|
|
64
|
+
propertyGroup: classifyTweenPropertyGroup(properties),
|
|
65
|
+
method: "to",
|
|
66
|
+
properties,
|
|
67
|
+
position: 0,
|
|
68
|
+
resolvedStart: 0,
|
|
69
|
+
duration,
|
|
70
|
+
...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
|
|
71
|
+
} as unknown as GsapAnimation;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Case {
|
|
75
|
+
name: string;
|
|
76
|
+
/** The element's untransformed CSS box. */
|
|
77
|
+
box: { w: number; h: number };
|
|
78
|
+
/** Where it sat, and at what scale, before the gesture. */
|
|
79
|
+
base: { x: number; y: number };
|
|
80
|
+
liveScale: { x: number; y: number };
|
|
81
|
+
rotation: number;
|
|
82
|
+
/** The box the user dragged to, and where the draft put it. */
|
|
83
|
+
drop: { w: number; h: number; x: number; y: number };
|
|
84
|
+
animations: () => GsapAnimation[];
|
|
85
|
+
/** Whether this route takes responsibility for where the box lands. */
|
|
86
|
+
settles: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ROTATIONS = [0, -8, 45, -47, 90, 180];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The routes a resize can take, and whether each SETTLES the drop point.
|
|
93
|
+
*
|
|
94
|
+
* Only a committed scale moves the box: it renders about the element centre
|
|
95
|
+
* rather than the dragged corner, so the route measures the difference and
|
|
96
|
+
* writes the position. Every other route commits width and height and moves
|
|
97
|
+
* nothing, which leaves the anchor to the drag. An element whose scale is an
|
|
98
|
+
* instant hold has a scale tween and still commits size, so it belongs with
|
|
99
|
+
* the size routes here however it looks from the animation list.
|
|
100
|
+
*/
|
|
101
|
+
const ROUTES = {
|
|
102
|
+
"scale tween": { animations: () => [tween("#el-scale", { scale: 1 }, 2)], settles: true },
|
|
103
|
+
"scale longhands": {
|
|
104
|
+
animations: () => [tween("#el-scale", { scaleX: 1, scaleY: 1 }, 2)],
|
|
105
|
+
settles: true,
|
|
106
|
+
},
|
|
107
|
+
"scale instant hold": { animations: () => [tween("#el-scale", { scale: 1 }, 0)], settles: false },
|
|
108
|
+
"size tween": {
|
|
109
|
+
animations: () => [tween("#el-size", { width: 630, height: 408 }, 2)],
|
|
110
|
+
settles: false,
|
|
111
|
+
},
|
|
112
|
+
"size instant hold": {
|
|
113
|
+
animations: () => [tween("#el-size", { width: 630, height: 408 }, 0)],
|
|
114
|
+
settles: false,
|
|
115
|
+
},
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
118
|
+
function buildCases(): Case[] {
|
|
119
|
+
const cases: Case[] = [];
|
|
120
|
+
for (const [routeName, route] of Object.entries(ROUTES)) {
|
|
121
|
+
for (const rotation of ROTATIONS) {
|
|
122
|
+
for (const [dropName, drop] of Object.entries({
|
|
123
|
+
shrink: { w: 326, h: 213, x: 60, y: 40 },
|
|
124
|
+
grow: { w: 980, h: 640, x: -120, y: -90 },
|
|
125
|
+
"near zero": { w: 12, h: 8, x: 200, y: 160 },
|
|
126
|
+
"aspect flip": { w: 900, h: 90, x: 10, y: 10 },
|
|
127
|
+
})) {
|
|
128
|
+
cases.push({
|
|
129
|
+
name: `${routeName} / rotation ${rotation} / ${dropName}`,
|
|
130
|
+
box: { w: 630, h: 408 },
|
|
131
|
+
base: { x: 40, y: 25 },
|
|
132
|
+
liveScale: { x: 1, y: 1 },
|
|
133
|
+
rotation,
|
|
134
|
+
drop,
|
|
135
|
+
animations: route.animations,
|
|
136
|
+
settles: route.settles,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return cases;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const CASES = buildCases();
|
|
145
|
+
|
|
146
|
+
/** The scale and size the run committed, read at the playhead. */
|
|
147
|
+
function committed(calls: unknown[][]) {
|
|
148
|
+
let scale: { x: number; y: number } | null = null;
|
|
149
|
+
let size: { w: number; h: number } | null = null;
|
|
150
|
+
const take = (source: Props | undefined) => {
|
|
151
|
+
if (!source) return;
|
|
152
|
+
const sx = source.scaleX ?? source.scale;
|
|
153
|
+
const sy = source.scaleY ?? source.scale;
|
|
154
|
+
if (sx != null && sy != null) scale = { x: sx, y: sy };
|
|
155
|
+
if (source.width != null && source.height != null) {
|
|
156
|
+
size = { w: source.width, h: source.height };
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
for (const call of calls) {
|
|
160
|
+
const mutation = call[1] as {
|
|
161
|
+
properties?: Props;
|
|
162
|
+
percentage?: number;
|
|
163
|
+
keyframes?: Array<{ percentage: number; properties: Props }>;
|
|
164
|
+
};
|
|
165
|
+
if (mutation.keyframes) {
|
|
166
|
+
for (const frame of mutation.keyframes) if (frame.percentage === 0) take(frame.properties);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (mutation.percentage != null && mutation.percentage !== 0) continue;
|
|
170
|
+
take(mutation.properties);
|
|
171
|
+
}
|
|
172
|
+
return { scale, size };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The element as the gesture leaves it: drafted box, base pose, live pose. */
|
|
176
|
+
function mountCase(testCase: Case, live: Pose) {
|
|
177
|
+
const el = document.createElement("div");
|
|
178
|
+
el.id = "el";
|
|
179
|
+
el.setAttribute("data-hf-studio-original-box-width", String(testCase.box.w));
|
|
180
|
+
el.setAttribute("data-hf-studio-original-box-height", String(testCase.box.h));
|
|
181
|
+
el.setAttribute("data-hf-drag-gsap-base-x", String(testCase.base.x));
|
|
182
|
+
el.setAttribute("data-hf-drag-gsap-base-y", String(testCase.base.y));
|
|
183
|
+
el.setAttribute("data-hf-studio-box-size", "true");
|
|
184
|
+
el.style.width = `${testCase.drop.w}px`;
|
|
185
|
+
el.style.height = `${testCase.drop.h}px`;
|
|
186
|
+
document.body.append(el);
|
|
187
|
+
|
|
188
|
+
el.getBoundingClientRect = () => {
|
|
189
|
+
const w = Number.parseFloat(el.style.width) || live.box.w;
|
|
190
|
+
const h = Number.parseFloat(el.style.height) || live.box.h;
|
|
191
|
+
const rect = renderRect({ ...live, box: { w, h } }, testCase.rotation);
|
|
192
|
+
return { ...rect, width: rect.w, height: rect.h } as unknown as DOMRect;
|
|
193
|
+
};
|
|
194
|
+
const gsap = {
|
|
195
|
+
set: (_target: Element, vars: Props) => {
|
|
196
|
+
if (vars.x != null) live.pos.x = vars.x;
|
|
197
|
+
if (vars.y != null) live.pos.y = vars.y;
|
|
198
|
+
if (vars.scaleX != null) live.scale.x = vars.scaleX;
|
|
199
|
+
if (vars.scaleY != null) live.scale.y = vars.scaleY;
|
|
200
|
+
},
|
|
201
|
+
getProperty: (_target: Element, prop: string) =>
|
|
202
|
+
({
|
|
203
|
+
scaleX: live.scale.x,
|
|
204
|
+
scaleY: live.scale.y,
|
|
205
|
+
x: live.pos.x,
|
|
206
|
+
y: live.pos.y,
|
|
207
|
+
rotation: testCase.rotation,
|
|
208
|
+
})[prop] ?? 0,
|
|
209
|
+
};
|
|
210
|
+
Object.assign(window, { gsap });
|
|
211
|
+
const iframe = {
|
|
212
|
+
contentWindow: { gsap, __timelines: { main: { getChildren: () => [] } } },
|
|
213
|
+
contentDocument: document,
|
|
214
|
+
} as unknown as HTMLIFrameElement;
|
|
215
|
+
return { el, iframe };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One run: mount the case, drive the intercept, hand the result to the judge. */
|
|
219
|
+
async function runCase(testCase: Case): Promise<string[]> {
|
|
220
|
+
document.body.innerHTML = "";
|
|
221
|
+
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
222
|
+
const live: Pose = {
|
|
223
|
+
box: { ...testCase.box },
|
|
224
|
+
pos: { x: testCase.drop.x, y: testCase.drop.y },
|
|
225
|
+
scale: { ...testCase.liveScale },
|
|
226
|
+
};
|
|
227
|
+
const { el, iframe } = mountCase(testCase, live);
|
|
228
|
+
const dropped = el.getBoundingClientRect();
|
|
229
|
+
const animations = testCase.animations();
|
|
230
|
+
const commitMutation = vi.fn();
|
|
231
|
+
|
|
232
|
+
const outcome = await tryGsapResizeIntercept(
|
|
233
|
+
{ id: "el", selector: "#el", element: el } as DomEditSelection,
|
|
234
|
+
{ width: testCase.drop.w, height: testCase.drop.h },
|
|
235
|
+
animations,
|
|
236
|
+
iframe,
|
|
237
|
+
commitMutation as never,
|
|
238
|
+
async () => animations,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const { scale, size } = committed(commitMutation.mock.calls);
|
|
242
|
+
const settled = renderRect(
|
|
243
|
+
{ box: size ?? testCase.box, pos: { ...live.pos }, scale: scale ?? testCase.liveScale },
|
|
244
|
+
testCase.rotation,
|
|
245
|
+
);
|
|
246
|
+
const owns = outcome.status === "persisted" && outcome.ownsDragOffset === true;
|
|
247
|
+
return judge(testCase, dropped, settled, owns);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* What the run got wrong, if anything. 1px: position rounds to whole pixels and
|
|
252
|
+
* scale keeps three decimals.
|
|
253
|
+
*/
|
|
254
|
+
function judge(
|
|
255
|
+
testCase: Case,
|
|
256
|
+
dropped: DOMRect,
|
|
257
|
+
settled: { x: number; y: number; w: number; h: number },
|
|
258
|
+
owns: boolean,
|
|
259
|
+
): string[] {
|
|
260
|
+
const off = (a: number, b: number) => Math.abs(a - b) > 1;
|
|
261
|
+
if (off(settled.w, dropped.width) || off(settled.h, dropped.height)) {
|
|
262
|
+
return [
|
|
263
|
+
`${testCase.name} — box ${settled.w.toFixed(1)}x${settled.h.toFixed(1)}` +
|
|
264
|
+
`, dropped ${dropped.width.toFixed(1)}x${dropped.height.toFixed(1)}`,
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
// Claiming the drop point is only honest for the routes that settle it. The
|
|
268
|
+
// size routes commit width and height and move nothing, so the drag still owns
|
|
269
|
+
// the anchor — and a run that claims otherwise makes the caller withhold an
|
|
270
|
+
// offset nobody writes. The position check below cannot see that on its own:
|
|
271
|
+
// the fixture's live pose starts at the drop, which is where the gesture
|
|
272
|
+
// leaves it, so a size route trivially "lands" there.
|
|
273
|
+
if (owns !== testCase.settles) {
|
|
274
|
+
return [`${testCase.name} — ownsDragOffset ${owns}, expected ${testCase.settles}`];
|
|
275
|
+
}
|
|
276
|
+
if (owns && (off(settled.x, dropped.x) || off(settled.y, dropped.y))) {
|
|
277
|
+
return [
|
|
278
|
+
`${testCase.name} — landed ${settled.x.toFixed(1)},${settled.y.toFixed(1)}` +
|
|
279
|
+
`, dropped ${dropped.x.toFixed(1)},${dropped.y.toFixed(1)}`,
|
|
280
|
+
];
|
|
281
|
+
}
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
it(`sweeps ${CASES.length} rotations and drops for the box the user dropped`, async () => {
|
|
286
|
+
const failures: string[] = [];
|
|
287
|
+
for (const testCase of CASES) failures.push(...(await runCase(testCase)));
|
|
288
|
+
expect(failures).toEqual([]);
|
|
289
|
+
});
|
|
@@ -98,7 +98,7 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
|
|
|
98
98
|
commitMutation,
|
|
99
99
|
);
|
|
100
100
|
|
|
101
|
-
expect(handled).
|
|
101
|
+
expect(handled).toMatchObject({ status: "persisted" });
|
|
102
102
|
expect(commitMutation).toHaveBeenCalledTimes(1);
|
|
103
103
|
expect(commitMutation.mock.calls[0]![1]).toEqual({
|
|
104
104
|
type: "update-properties",
|
|
@@ -246,7 +246,7 @@ async function runResize(
|
|
|
246
246
|
commitMutation as never,
|
|
247
247
|
async () => [keyframedScaleFixture()],
|
|
248
248
|
);
|
|
249
|
-
expect(handled).
|
|
249
|
+
expect(handled).toMatchObject({ status: "persisted" });
|
|
250
250
|
return committed;
|
|
251
251
|
}
|
|
252
252
|
|