@hyperframes/studio 0.8.86 → 0.8.87
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-CtNVImMi.js +473 -0
- package/dist/assets/{index-Cz3E_ZSj.js → index-B_MF86RE.js} +1 -1
- package/dist/assets/{index-8Fgryj7q.js → index-DJ-Ol7YS.js} +1 -1
- package/dist/assets/{index-d5DKK15Q.js → index-DZKcDAtf.js} +203 -203
- package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
- package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
- package/dist/chunk-PQFEGZWM.js.map +1 -0
- package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
- package/dist/index.d.ts +203 -160
- package/dist/index.html +2 -2
- package/dist/index.js +1958 -1611
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditOverlay.tsx +1 -2
- package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
- package/src/components/editor/LayersPanel.test.ts +14 -0
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/TopologyLens.tsx +2 -2
- package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
- package/src/components/editor/domEditOverlayGestures.ts +0 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
- package/src/components/editor/domEditing.test.ts +29 -0
- package/src/components/editor/domEditingDom.ts +1 -0
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/marqueeCommit.test.tsx +84 -0
- package/src/components/editor/marqueeCommit.ts +13 -1
- package/src/components/editor/useColorGradingController.test.ts +24 -0
- package/src/components/editor/useColorGradingController.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
- package/src/components/nle/useCompositionStack.test.tsx +53 -0
- package/src/components/nle/useCompositionStack.ts +4 -8
- package/src/hooks/domStyleCommit.ts +129 -0
- package/src/hooks/gsapEditOutcome.ts +2 -2
- package/src/hooks/gsapElementMatch.ts +38 -0
- package/src/hooks/gsapResizeIntercept.ts +10 -31
- package/src/hooks/gsapResizePreflight.ts +56 -0
- package/src/hooks/gsapRuntimeBridge.ts +66 -26
- package/src/hooks/keyframeCacheAstLoad.ts +4 -1
- package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
- package/src/hooks/useDomEditCommits.ts +17 -182
- package/src/hooks/useDomEditPersist.ts +225 -0
- package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
- package/src/hooks/useDomEditPreviewSync.ts +2 -4
- package/src/hooks/useDomEditSession.test.tsx +51 -1
- package/src/hooks/useDomEditSession.ts +11 -2
- package/src/hooks/useDomEditTextCommits.ts +15 -99
- package/src/hooks/useDomStyleCommit.test.tsx +155 -0
- package/src/hooks/useDomStyleCommit.ts +74 -0
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
- package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
- package/src/hooks/useGsapAwareEditing.ts +1 -1
- package/src/hooks/useGsapTweenCache.ts +2 -48
- package/src/index.ts +10 -1
- package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
- package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
- package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
- package/src/player/components/TimelineGroupHeader.tsx +21 -10
- package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
- package/src/player/components/TimelineGroupRow.test.tsx +24 -33
- package/src/player/components/TimelineGroupRow.tsx +4 -1
- package/src/player/components/TimelineLanes.tsx +5 -5
- package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
- package/src/player/components/TimelineTrackHeader.tsx +6 -3
- package/src/player/components/TimelineTypes.ts +1 -0
- package/src/player/components/timelineLaneProps.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +29 -0
- package/src/player/components/useTimelineLabelColumn.ts +18 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
- package/src/player/components/useTimelineProviderState.tsx +6 -12
- package/src/player/components/useTimelineRangeSelection.ts +13 -12
- package/src/player/hooks/useShadowPreviewReload.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
- package/src/player/hooks/useTimelinePlayer.ts +29 -7
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- package/src/player/sceneSwap.test.ts +95 -0
- package/src/player/sceneSwap.ts +53 -0
- package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
- package/dist/chunk-3IOCVWB6.js.map +0 -1
- /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.87",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/player": "0.8.
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.87",
|
|
64
|
+
"@hyperframes/parsers": "0.8.87",
|
|
65
|
+
"@hyperframes/player": "0.8.87",
|
|
66
|
+
"@hyperframes/shader-transitions": "0.8.87",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.87",
|
|
68
|
+
"@hyperframes/sdk": "0.8.87"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.87"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -47,7 +47,7 @@ export {
|
|
|
47
47
|
} from "./domEditOverlayGestures";
|
|
48
48
|
export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
|
|
49
49
|
|
|
50
|
-
interface DomEditOverlayProps {
|
|
50
|
+
export interface DomEditOverlayProps {
|
|
51
51
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
52
52
|
activeCompositionPath: string | null;
|
|
53
53
|
selection: DomEditSelection | null;
|
|
@@ -518,7 +518,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
518
518
|
boxClipPath={boxClipPath}
|
|
519
519
|
selectionKey={selectionKey}
|
|
520
520
|
groupSelectionCount={groupSelections.length}
|
|
521
|
-
blockedMoveRef={blockedMoveRef}
|
|
522
521
|
gestures={gestures}
|
|
523
522
|
onStyleCommit={onStyleCommitRef.current}
|
|
524
523
|
onBoxMouseDown={suppressBoxMouseDown}
|
|
@@ -6,6 +6,7 @@ import { expect, it, vi } from "vitest";
|
|
|
6
6
|
import type { DomEditSelection } from "./domEditing";
|
|
7
7
|
import "./domEditOverlayTestMocks";
|
|
8
8
|
import { DomEditOverlay } from "./DomEditOverlay";
|
|
9
|
+
import { DomEditOverlay as PackageDomEditOverlay } from "../../index";
|
|
9
10
|
|
|
10
11
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
12
|
|
|
@@ -74,3 +75,7 @@ it("rotates the hover box with the element", () => {
|
|
|
74
75
|
it("leaves the hover box untransformed at angle zero", () => {
|
|
75
76
|
expect(renderHover(0)).toBe("");
|
|
76
77
|
});
|
|
78
|
+
|
|
79
|
+
it("is exported from the package entry, so a host can mount it with its own commits", () => {
|
|
80
|
+
expect(PackageDomEditOverlay).toBe(DomEditOverlay);
|
|
81
|
+
});
|
|
@@ -53,7 +53,6 @@ describe("DomEditSelectionChrome crop composition", () => {
|
|
|
53
53
|
boxClipPath={undefined}
|
|
54
54
|
selectionKey="headline"
|
|
55
55
|
groupSelectionCount={0}
|
|
56
|
-
blockedMoveRef={createRef()}
|
|
57
56
|
gestures={{ startGesture: vi.fn() } as never}
|
|
58
57
|
onStyleCommit={vi.fn()}
|
|
59
58
|
onBoxMouseDown={vi.fn()}
|
|
@@ -109,7 +108,6 @@ describe("DomEditSelectionChrome crop composition", () => {
|
|
|
109
108
|
boxClipPath={undefined}
|
|
110
109
|
selectionKey="clip"
|
|
111
110
|
groupSelectionCount={0}
|
|
112
|
-
blockedMoveRef={createRef()}
|
|
113
111
|
gestures={{ startGesture: vi.fn() } as never}
|
|
114
112
|
onStyleCommit={vi.fn()}
|
|
115
113
|
onBoxMouseDown={vi.fn()}
|
|
@@ -170,7 +168,6 @@ describe("DomEditSelectionChrome while editing text", () => {
|
|
|
170
168
|
boxClipPath={undefined}
|
|
171
169
|
selectionKey="copy"
|
|
172
170
|
groupSelectionCount={0}
|
|
173
|
-
blockedMoveRef={createRef()}
|
|
174
171
|
gestures={{ startGesture: vi.fn() } as never}
|
|
175
172
|
onStyleCommit={vi.fn()}
|
|
176
173
|
onBoxMouseDown={vi.fn()}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RefObject } from "react";
|
|
2
2
|
import { type DomEditSelection } from "./domEditing";
|
|
3
3
|
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
|
|
4
|
-
import type {
|
|
4
|
+
import type { ResizeHandle } from "./domEditOverlayGestures";
|
|
5
5
|
import type { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
6
6
|
import { DomEditCropHandles } from "./DomEditCropHandles";
|
|
7
7
|
import { DomEditRotateHandle } from "./DomEditRotateHandle";
|
|
@@ -103,7 +103,7 @@ export function DomEditGroupChrome({
|
|
|
103
103
|
cursor: canManipulate && groupCanMove ? "move" : "default",
|
|
104
104
|
}}
|
|
105
105
|
onPointerDown={(e) => {
|
|
106
|
-
if (!canManipulate ||
|
|
106
|
+
if (!canManipulate || e.shiftKey) return;
|
|
107
107
|
gestures.startGroupDrag(e);
|
|
108
108
|
}}
|
|
109
109
|
onMouseDown={onBoxMouseDown}
|
|
@@ -123,7 +123,6 @@ interface DomEditSelectionChromeProps {
|
|
|
123
123
|
boxClipPath: string | undefined;
|
|
124
124
|
selectionKey: string;
|
|
125
125
|
groupSelectionCount: number;
|
|
126
|
-
blockedMoveRef: RefObject<BlockedMoveState | null>;
|
|
127
126
|
gestures: GestureHandlers;
|
|
128
127
|
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
|
129
128
|
onBoxMouseDown: (e: React.MouseEvent) => void;
|
|
@@ -152,7 +151,6 @@ export function DomEditSelectionChrome({
|
|
|
152
151
|
boxClipPath,
|
|
153
152
|
selectionKey,
|
|
154
153
|
groupSelectionCount,
|
|
155
|
-
blockedMoveRef,
|
|
156
154
|
gestures,
|
|
157
155
|
onStyleCommit,
|
|
158
156
|
onBoxMouseDown,
|
|
@@ -218,15 +216,7 @@ export function DomEditSelectionChrome({
|
|
|
218
216
|
gestures.startGesture("drag", e);
|
|
219
217
|
return;
|
|
220
218
|
}
|
|
221
|
-
|
|
222
|
-
e.stopPropagation();
|
|
223
|
-
e.currentTarget.setPointerCapture(e.pointerId);
|
|
224
|
-
blockedMoveRef.current = {
|
|
225
|
-
pointerId: e.pointerId,
|
|
226
|
-
startX: e.clientX,
|
|
227
|
-
startY: e.clientY,
|
|
228
|
-
notified: false,
|
|
229
|
-
};
|
|
219
|
+
gestures.startBlockedMove(e, selection);
|
|
230
220
|
}}
|
|
231
221
|
onMouseDown={onBoxMouseDown}
|
|
232
222
|
onClick={onBoxClick}
|
|
@@ -9,6 +9,7 @@ import type { DomEditLayerItem } from "./domEditingTypes";
|
|
|
9
9
|
import { createRafThrottle, LayersPanel, sortLayersByZIndex } from "./LayersPanel";
|
|
10
10
|
import { isLayerDraggable } from "./useLayerDrag";
|
|
11
11
|
import { liveTime } from "../../player";
|
|
12
|
+
import { sceneSwapFor } from "../../player/sceneSwap";
|
|
12
13
|
|
|
13
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
15
|
|
|
@@ -292,4 +293,17 @@ describe("LayersPanel preview promotion", () => {
|
|
|
292
293
|
});
|
|
293
294
|
expect(collectedRootIds()).toEqual(["b"]);
|
|
294
295
|
});
|
|
296
|
+
it("recollects layers when edited scenes are swapped into the live document", async () => {
|
|
297
|
+
const a = makeIframe("a");
|
|
298
|
+
Object.defineProperty(a, "contentWindow", { value: { __hfSwapScenes: async () => {} } });
|
|
299
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html></html>"));
|
|
300
|
+
mocks.previewIframeRef.current = a;
|
|
301
|
+
usePreviewIframeStore.setState({ iframe: a });
|
|
302
|
+
act(() => root.render(createElement(LayersPanel)));
|
|
303
|
+
mocks.collect.mockClear();
|
|
304
|
+
|
|
305
|
+
await act(() => sceneSwapFor(a)!("/preview", () => true));
|
|
306
|
+
expect(collectedRootIds()).toEqual(["a"]);
|
|
307
|
+
fetchSpy.mockRestore();
|
|
308
|
+
});
|
|
295
309
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
2
|
+
import { onPreviewContentReplaced } from "../../player/sceneSwap";
|
|
2
3
|
import { memo, useState, useCallback, useEffect, useRef } from "react";
|
|
3
4
|
import {
|
|
4
5
|
collectDomEditLayerItems,
|
|
@@ -164,8 +165,7 @@ export const LayersPanel = memo(function LayersPanel() {
|
|
|
164
165
|
prevDocVersionRef.current += 1;
|
|
165
166
|
collectLayers();
|
|
166
167
|
};
|
|
167
|
-
iframe
|
|
168
|
-
return () => iframe.removeEventListener("load", handleLoad);
|
|
168
|
+
return onPreviewContentReplaced(iframe, handleLoad);
|
|
169
169
|
}, [previewIframeRef, livePreviewIframe, collectLayers]);
|
|
170
170
|
|
|
171
171
|
useEffect(() => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
2
|
+
import { onPreviewContentReplaced } from "../../player/sceneSwap";
|
|
2
3
|
import {
|
|
3
4
|
useEffect,
|
|
4
5
|
useLayoutEffect,
|
|
@@ -119,8 +120,7 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
|
|
|
119
120
|
const iframe = iframeRef.current;
|
|
120
121
|
if (!iframe || !callId) return;
|
|
121
122
|
const dismiss = () => studioEditLifecycle.dismiss(callId);
|
|
122
|
-
iframe
|
|
123
|
-
return () => iframe.removeEventListener("load", dismiss);
|
|
123
|
+
return onPreviewContentReplaced(iframe, dismiss);
|
|
124
124
|
}, [callId, iframeRef, livePreviewIframe]);
|
|
125
125
|
|
|
126
126
|
useEffect(() => {
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
7
|
+
import { makeSelection } from "../../hooks/domSelectionTestHarness";
|
|
8
|
+
import { GSAP_EDIT_BLOCK_COPY } from "../../hooks/gsapEditOutcome";
|
|
9
|
+
import { tryGsapDragIntercept } from "../../hooks/gsapRuntimeBridge";
|
|
10
|
+
import { getAnimationsForElement } from "../../hooks/useGsapTweenCache";
|
|
11
|
+
import { useCommitPreflightCapabilities } from "../../hooks/useCommitPreflightCapabilities";
|
|
12
|
+
import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS } from "./domEditNudge";
|
|
13
|
+
import { __resetForTests } from "../../utils/canvasNudgeGate";
|
|
14
|
+
import type { DomEditSelection } from "./domEditing";
|
|
15
|
+
import "./domEditOverlayTestMocks";
|
|
16
|
+
import { DomEditOverlay } from "./DomEditOverlay";
|
|
17
|
+
|
|
18
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
19
|
+
|
|
20
|
+
type Parse = { animations: GsapAnimation[] } | null;
|
|
21
|
+
const parses = vi.hoisted(() => ({ fetch: vi.fn<(...args: unknown[]) => Promise<unknown>>() }));
|
|
22
|
+
const layout = vi.hoisted(() => ({ group: [] as unknown[] }));
|
|
23
|
+
|
|
24
|
+
vi.mock("../../hooks/keyframeCacheAstLoad", async (importOriginal) => ({
|
|
25
|
+
...(await importOriginal<typeof import("../../hooks/keyframeCacheAstLoad")>()),
|
|
26
|
+
fetchParsedAnimations: (...args: unknown[]) => parses.fetch(...args),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("./useDomEditOverlayRects", () => {
|
|
30
|
+
const rect = { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
|
|
31
|
+
return {
|
|
32
|
+
useDomEditOverlayRects: () => ({
|
|
33
|
+
overlayRect: rect,
|
|
34
|
+
overlayRectRef: { current: rect },
|
|
35
|
+
setOverlayRect: () => undefined,
|
|
36
|
+
hoverRect: null,
|
|
37
|
+
groupOverlayItems: layout.group,
|
|
38
|
+
groupOverlayItemsRef: { current: layout.group },
|
|
39
|
+
setGroupOverlayItems: () => undefined,
|
|
40
|
+
childRects: [],
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const BOX = '[data-dom-edit-selection-box="true"]';
|
|
46
|
+
const DOTS = "div.h-4.w-4";
|
|
47
|
+
const ROTATE = '[aria-label="Rotate selection"]';
|
|
48
|
+
const RECT = { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
|
|
49
|
+
let root: Root;
|
|
50
|
+
let host: HTMLElement;
|
|
51
|
+
|
|
52
|
+
function tween(
|
|
53
|
+
properties: Record<string, number>,
|
|
54
|
+
extra: Partial<GsapAnimation> = {},
|
|
55
|
+
): GsapAnimation {
|
|
56
|
+
return {
|
|
57
|
+
id: `#title-to-${Object.keys(properties).join("-")}`,
|
|
58
|
+
targetSelector: "#title",
|
|
59
|
+
method: "to",
|
|
60
|
+
properties,
|
|
61
|
+
position: 0,
|
|
62
|
+
resolvedStart: 0,
|
|
63
|
+
duration: 2,
|
|
64
|
+
...extra,
|
|
65
|
+
} as unknown as GsapAnimation;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const loop = (properties: Record<string, number>, targetSelector = "#title") =>
|
|
69
|
+
tween(properties, { targetSelector, provenance: { kind: "loop" } } as never);
|
|
70
|
+
|
|
71
|
+
function element(id: string, className = "") {
|
|
72
|
+
const el = document.createElement("h1");
|
|
73
|
+
el.id = id;
|
|
74
|
+
el.className = className;
|
|
75
|
+
document.body.append(el);
|
|
76
|
+
return el;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolved(el: HTMLElement): DomEditSelection {
|
|
80
|
+
const selection = makeSelection(el.id, el);
|
|
81
|
+
selection.capabilities.canApplyManualRotation = true;
|
|
82
|
+
return selection;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A preview whose runtime timeline is visibly moving `els` with one shared tween. */
|
|
86
|
+
function livePreview(els: HTMLElement[], vars: Record<string, number>) {
|
|
87
|
+
const liveTween = {
|
|
88
|
+
targets: () => els,
|
|
89
|
+
vars: { ...vars, duration: 1 },
|
|
90
|
+
duration: () => 1,
|
|
91
|
+
startTime: () => 0,
|
|
92
|
+
};
|
|
93
|
+
const timeline = { getChildren: () => [liveTween], duration: () => 6 };
|
|
94
|
+
const byId = (sel: string) => els.find((el) => sel === `#${el.id}`) ?? null;
|
|
95
|
+
return {
|
|
96
|
+
contentWindow: { __timelines: { root: timeline }, gsap: { getProperty: () => 0 } },
|
|
97
|
+
contentDocument: { querySelector: byId },
|
|
98
|
+
} as unknown as HTMLIFrameElement;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface EditorProps {
|
|
102
|
+
selection: DomEditSelection | null;
|
|
103
|
+
groups?: DomEditSelection[];
|
|
104
|
+
version?: number;
|
|
105
|
+
preview?: HTMLIFrameElement | null;
|
|
106
|
+
projectId?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Studio's session narrowing feeding the real overlay, as the editor mounts it. */
|
|
110
|
+
function mount(first: EditorProps) {
|
|
111
|
+
const spies = {
|
|
112
|
+
onBlockedMove: vi.fn(),
|
|
113
|
+
onPathOffsetCommit: vi.fn(),
|
|
114
|
+
onManualDragStart: vi.fn(),
|
|
115
|
+
};
|
|
116
|
+
const seen: { selection: DomEditSelection | null; groups: DomEditSelection[] } = {
|
|
117
|
+
selection: null,
|
|
118
|
+
groups: [],
|
|
119
|
+
};
|
|
120
|
+
const iframeRef = { current: document.createElement("iframe") };
|
|
121
|
+
function Editor({
|
|
122
|
+
selection,
|
|
123
|
+
groups,
|
|
124
|
+
version = 0,
|
|
125
|
+
preview = null,
|
|
126
|
+
projectId = "p",
|
|
127
|
+
}: EditorProps) {
|
|
128
|
+
const groupSelections = groups ?? (selection ? [selection] : []);
|
|
129
|
+
const narrowed = useCommitPreflightCapabilities({
|
|
130
|
+
projectId,
|
|
131
|
+
enabled: true,
|
|
132
|
+
selection,
|
|
133
|
+
groupSelections,
|
|
134
|
+
previewIframeRef: { current: preview },
|
|
135
|
+
version,
|
|
136
|
+
});
|
|
137
|
+
seen.selection = narrowed.selection;
|
|
138
|
+
seen.groups = narrowed.groupSelections;
|
|
139
|
+
layout.group =
|
|
140
|
+
groupSelections.length > 1
|
|
141
|
+
? narrowed.groupSelections.map((s) => ({
|
|
142
|
+
key: s.id,
|
|
143
|
+
selection: s,
|
|
144
|
+
element: s.element,
|
|
145
|
+
rect: RECT,
|
|
146
|
+
}))
|
|
147
|
+
: [];
|
|
148
|
+
return (
|
|
149
|
+
<DomEditOverlay
|
|
150
|
+
iframeRef={iframeRef}
|
|
151
|
+
activeCompositionPath={null}
|
|
152
|
+
selection={groupSelections.length > 1 ? null : narrowed.selection}
|
|
153
|
+
groupSelections={groupSelections.length > 1 ? narrowed.groupSelections : []}
|
|
154
|
+
hoverSelection={null}
|
|
155
|
+
onCanvasMouseDown={() => undefined}
|
|
156
|
+
onCanvasPointerMove={() => Promise.resolve(narrowed.selection)}
|
|
157
|
+
onCanvasPointerLeave={() => undefined}
|
|
158
|
+
onSelectionChange={() => undefined}
|
|
159
|
+
onGroupPathOffsetCommit={() => undefined}
|
|
160
|
+
onBoxSizeCommit={() => undefined}
|
|
161
|
+
onRotationCommit={() => undefined}
|
|
162
|
+
{...spies}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
host = document.createElement("div");
|
|
167
|
+
document.body.append(host);
|
|
168
|
+
root = createRoot(host);
|
|
169
|
+
const render = (props: EditorProps) => act(() => root.render(<Editor {...props} />));
|
|
170
|
+
render(first);
|
|
171
|
+
return { spies, seen, render, overlay: () => host.firstElementChild as HTMLElement };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const answer = (animations: GsapAnimation[]) =>
|
|
175
|
+
parses.fetch.mockResolvedValueOnce({ animations } satisfies Parse);
|
|
176
|
+
|
|
177
|
+
async function settle() {
|
|
178
|
+
await act(async () => {
|
|
179
|
+
for (let tick = 0; tick < 10; tick++) await Promise.resolve();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
|
|
184
|
+
act(() => {
|
|
185
|
+
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, ...init }));
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const flags = (selection: DomEditSelection | null) => {
|
|
190
|
+
const c = selection?.capabilities;
|
|
191
|
+
return [c?.canApplyManualOffset, c?.canApplyManualSize, c?.canApplyManualRotation];
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const handles = (overlay: HTMLElement) => ({
|
|
195
|
+
dots: overlay.querySelectorAll(DOTS).length,
|
|
196
|
+
rotate: Boolean(overlay.querySelector(ROTATE)),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/** Select `#title` in a file whose parse is `animations` (none queued when null). */
|
|
200
|
+
async function select(animations: GsapAnimation[] | null, props: Partial<EditorProps> = {}) {
|
|
201
|
+
if (animations) answer(animations);
|
|
202
|
+
const view = mount({ selection: resolved(element("title")), ...props });
|
|
203
|
+
await settle();
|
|
204
|
+
return view;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
describe("handles follow what Studio would commit", () => {
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
HTMLElement.prototype.setPointerCapture = () => undefined;
|
|
210
|
+
__resetForTests();
|
|
211
|
+
parses.fetch.mockReset();
|
|
212
|
+
vi.useFakeTimers();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
afterEach(() => {
|
|
216
|
+
vi.useRealTimers();
|
|
217
|
+
act(() => root.unmount());
|
|
218
|
+
document.body.innerHTML = "";
|
|
219
|
+
layout.group = [];
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("hides every handle on a helper-loop animation and says why", async () => {
|
|
223
|
+
const { seen, overlay, spies } = await select([loop({ x: 120, rotation: 30, width: 320 })]);
|
|
224
|
+
|
|
225
|
+
expect(flags(seen.selection)).toEqual([false, false, false]);
|
|
226
|
+
expect(seen.selection?.capabilities.reasonIfDisabled).toBe(
|
|
227
|
+
GSAP_EDIT_BLOCK_COPY["unroll-required"],
|
|
228
|
+
);
|
|
229
|
+
expect(handles(overlay())).toEqual({ dots: 0, rotate: false });
|
|
230
|
+
expect((overlay().querySelector(BOX) as HTMLElement).style.cursor).toBe("default");
|
|
231
|
+
act(() => {
|
|
232
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
|
233
|
+
vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
|
|
234
|
+
});
|
|
235
|
+
expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("hides every handle on a runtime-computed animation and says why", async () => {
|
|
239
|
+
const computed = { provenance: { kind: "runtime-dynamic" } } as never;
|
|
240
|
+
const { seen, overlay } = await select([tween({ x: 120, rotation: 30, width: 320 }, computed)]);
|
|
241
|
+
|
|
242
|
+
expect(flags(seen.selection)).toEqual([false, false, false]);
|
|
243
|
+
expect(seen.selection?.capabilities.reasonIfDisabled).toBe(
|
|
244
|
+
GSAP_EDIT_BLOCK_COPY["source-uneditable"],
|
|
245
|
+
);
|
|
246
|
+
expect(handles(overlay())).toEqual({ dots: 0, rotate: false });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("keeps every handle on a plain keyframed element, and a drag still commits", async () => {
|
|
250
|
+
const frames = [
|
|
251
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
252
|
+
{ percentage: 100, properties: { x: 200, y: 40 } },
|
|
253
|
+
];
|
|
254
|
+
const keyframed = { propertyGroup: "position", keyframes: { keyframes: frames } } as never;
|
|
255
|
+
const { seen, overlay, spies } = await select([tween({}, keyframed)]);
|
|
256
|
+
|
|
257
|
+
expect(flags(seen.selection)).toEqual([true, true, true]);
|
|
258
|
+
expect(handles(overlay())).toEqual({ dots: 4, rotate: true });
|
|
259
|
+
fire(overlay().querySelector(BOX)!, "pointerdown", { clientX: 150, clientY: 150 });
|
|
260
|
+
fire(overlay(), "pointermove", { clientX: 190, clientY: 150 });
|
|
261
|
+
fire(overlay(), "pointerup", { clientX: 190, clientY: 150 });
|
|
262
|
+
await settle();
|
|
263
|
+
expect(spies.onBlockedMove).not.toHaveBeenCalled();
|
|
264
|
+
expect(spies.onPathOffsetCommit).toHaveBeenCalledTimes(1);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("keeps the handles of an element a class tween animates, as the commit does", async () => {
|
|
268
|
+
const card = element("card-1", "card");
|
|
269
|
+
const preview = livePreview([card], { y: 40 });
|
|
270
|
+
const stagger = tween({ y: 40 }, { targetSelector: ".card", method: "from" } as never);
|
|
271
|
+
const selection = resolved(card);
|
|
272
|
+
const { seen, overlay } = await select([stagger], { selection, preview });
|
|
273
|
+
|
|
274
|
+
expect(flags(seen.selection)).toEqual([true, true, true]);
|
|
275
|
+
expect(handles(overlay()).dots).toBe(4);
|
|
276
|
+
const commitList = getAnimationsForElement(
|
|
277
|
+
[stagger],
|
|
278
|
+
{ id: "card-1", selector: "#card-1" },
|
|
279
|
+
card,
|
|
280
|
+
);
|
|
281
|
+
const preflightOnly = { preflightOnly: true };
|
|
282
|
+
const commit = tryGsapDragIntercept(
|
|
283
|
+
selection,
|
|
284
|
+
{ x: 0, y: 0 },
|
|
285
|
+
commitList,
|
|
286
|
+
preview,
|
|
287
|
+
vi.fn(),
|
|
288
|
+
undefined,
|
|
289
|
+
preflightOnly,
|
|
290
|
+
);
|
|
291
|
+
expect(await commit).toEqual({ status: "persisted" });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("shows no handles and stays silent on a press while the check is still running", async () => {
|
|
295
|
+
parses.fetch.mockReturnValue(new Promise<Parse>(() => undefined));
|
|
296
|
+
const { seen, overlay, spies } = await select(null);
|
|
297
|
+
|
|
298
|
+
expect(flags(seen.selection)).toEqual([false, false, false]);
|
|
299
|
+
expect(seen.selection?.capabilities.commitCheckPending).toBe(true);
|
|
300
|
+
expect(handles(overlay())).toEqual({ dots: 0, rotate: false });
|
|
301
|
+
fire(overlay().querySelector(BOX)!, "pointerdown", { clientX: 150, clientY: 150 });
|
|
302
|
+
expect(spies.onBlockedMove).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("gives a plain element in a file with no animations its handles after one read", async () => {
|
|
306
|
+
const { seen, overlay, render } = await select([]);
|
|
307
|
+
|
|
308
|
+
expect(flags(seen.selection)).toEqual([true, true, true]);
|
|
309
|
+
expect(handles(overlay()).dots).toBe(4);
|
|
310
|
+
render({ selection: resolved(element("subtitle")) });
|
|
311
|
+
expect(flags(seen.selection)).toEqual([true, true, true]);
|
|
312
|
+
expect(parses.fetch).toHaveBeenCalledTimes(1);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("asks again after a failed read instead of keeping the failure", async () => {
|
|
316
|
+
parses.fetch.mockResolvedValueOnce(null);
|
|
317
|
+
const { seen, render } = await select(null);
|
|
318
|
+
const title = seen.selection!.element;
|
|
319
|
+
expect(flags(seen.selection)[0]).toBe(false);
|
|
320
|
+
|
|
321
|
+
answer([]);
|
|
322
|
+
render({ selection: null });
|
|
323
|
+
render({ selection: resolved(title) });
|
|
324
|
+
await settle();
|
|
325
|
+
expect(parses.fetch).toHaveBeenCalledTimes(2);
|
|
326
|
+
expect(flags(seen.selection)[0]).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("re-checks when the animations change, keeping the last answer until the new one lands", async () => {
|
|
330
|
+
const selection = resolved(element("title"));
|
|
331
|
+
const { seen, render } = await select([tween({ x: 120 })], { selection });
|
|
332
|
+
expect(flags(seen.selection)[0]).toBe(true);
|
|
333
|
+
|
|
334
|
+
let land: (parse: Parse) => void = () => undefined;
|
|
335
|
+
parses.fetch.mockReturnValueOnce(new Promise<Parse>((resolve) => (land = resolve)));
|
|
336
|
+
render({ selection, version: 1 });
|
|
337
|
+
await settle();
|
|
338
|
+
// The save re-resolves the selection while the new read is still in flight.
|
|
339
|
+
render({ selection: resolved(selection.element), version: 1 });
|
|
340
|
+
expect(flags(seen.selection)[0]).toBe(true);
|
|
341
|
+
await act(async () => land({ animations: [loop({ x: 120 })] }));
|
|
342
|
+
await settle();
|
|
343
|
+
expect(flags(seen.selection)[0]).toBe(false);
|
|
344
|
+
expect(parses.fetch).toHaveBeenCalledTimes(2);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("keeps the last answer when a re-check read fails, and takes the next good one", async () => {
|
|
348
|
+
const selection = resolved(element("title"));
|
|
349
|
+
const { seen, render } = await select([], { selection });
|
|
350
|
+
expect(flags(seen.selection)[0]).toBe(true);
|
|
351
|
+
|
|
352
|
+
parses.fetch.mockResolvedValueOnce(null);
|
|
353
|
+
render({ selection, version: 1 });
|
|
354
|
+
await settle();
|
|
355
|
+
let land: (parse: Parse) => void = () => undefined;
|
|
356
|
+
parses.fetch.mockReturnValueOnce(new Promise<Parse>((resolve) => (land = resolve)));
|
|
357
|
+
render({ selection: resolved(selection.element), version: 1 });
|
|
358
|
+
await settle();
|
|
359
|
+
expect(parses.fetch).toHaveBeenCalledTimes(3);
|
|
360
|
+
expect(flags(seen.selection)[0]).toBe(true);
|
|
361
|
+
expect(seen.selection?.capabilities.commitCheckPending).toBeUndefined();
|
|
362
|
+
|
|
363
|
+
await act(async () => land({ animations: [loop({ x: 120 })] }));
|
|
364
|
+
await settle();
|
|
365
|
+
expect(flags(seen.selection)[0]).toBe(false);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("checks another project's file against that project's own parse", async () => {
|
|
369
|
+
const { seen, render } = await select([loop({ x: 120 })], { projectId: "a" });
|
|
370
|
+
const title = seen.selection!.element;
|
|
371
|
+
expect(flags(seen.selection)[0]).toBe(false);
|
|
372
|
+
|
|
373
|
+
answer([]);
|
|
374
|
+
render({ selection: resolved(title), projectId: "b" });
|
|
375
|
+
await settle();
|
|
376
|
+
expect(parses.fetch).toHaveBeenLastCalledWith("b", "index.html");
|
|
377
|
+
expect(flags(seen.selection)[0]).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("refuses a group move carried by one shared tween, while a single member stays movable", async () => {
|
|
381
|
+
const dots = [element("dot-1", "dot"), element("dot-2", "dot")];
|
|
382
|
+
const preview = livePreview(dots, { y: 40 });
|
|
383
|
+
const stagger = tween({ y: 40 }, { targetSelector: ".dot", method: "from" } as never);
|
|
384
|
+
const [a, b] = dots.map(resolved);
|
|
385
|
+
const { seen, overlay, spies, render } = await select([stagger], {
|
|
386
|
+
selection: a!,
|
|
387
|
+
groups: [a!, b!],
|
|
388
|
+
preview,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(seen.groups.map((s) => flags(s)[0])).toEqual([false, false]);
|
|
392
|
+
expect(seen.groups[0]?.capabilities.reasonIfDisabled).toBe(
|
|
393
|
+
GSAP_EDIT_BLOCK_COPY["source-uneditable"],
|
|
394
|
+
);
|
|
395
|
+
fire(overlay().querySelector(BOX)!, "pointerdown", { clientX: 150, clientY: 150 });
|
|
396
|
+
expect(spies.onBlockedMove).toHaveBeenCalledTimes(1);
|
|
397
|
+
const commitList = getAnimationsForElement(
|
|
398
|
+
[stagger],
|
|
399
|
+
{ id: "dot-1", selector: "#dot-1" },
|
|
400
|
+
dots[0],
|
|
401
|
+
);
|
|
402
|
+
const groupPreflight = { preflightOnly: true, group: true };
|
|
403
|
+
const commit = tryGsapDragIntercept(
|
|
404
|
+
a!,
|
|
405
|
+
{ x: 0, y: 0 },
|
|
406
|
+
commitList,
|
|
407
|
+
preview,
|
|
408
|
+
vi.fn(),
|
|
409
|
+
undefined,
|
|
410
|
+
groupPreflight,
|
|
411
|
+
);
|
|
412
|
+
expect(await commit).toMatchObject({
|
|
413
|
+
status: "blocked",
|
|
414
|
+
detail: "live-position-no-source-tween",
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
render({ selection: a!, preview });
|
|
418
|
+
expect(flags(seen.selection)[0]).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("toasts once on the primary press of a blocked element, before any travel", async () => {
|
|
422
|
+
const { overlay, spies } = await select([loop({ x: 120 })]);
|
|
423
|
+
const box = overlay().querySelector(BOX)!;
|
|
424
|
+
|
|
425
|
+
fire(box, "pointerdown", { button: 2, clientX: 150, clientY: 150 });
|
|
426
|
+
fire(overlay(), "pointerup", { button: 2 });
|
|
427
|
+
expect(spies.onBlockedMove).not.toHaveBeenCalled();
|
|
428
|
+
fire(box, "pointerdown", { clientX: 150, clientY: 150 });
|
|
429
|
+
expect(spies.onBlockedMove).toHaveBeenCalledTimes(1);
|
|
430
|
+
expect(spies.onBlockedMove.mock.calls[0]![0].capabilities.reasonIfDisabled).toBe(
|
|
431
|
+
GSAP_EDIT_BLOCK_COPY["unroll-required"],
|
|
432
|
+
);
|
|
433
|
+
fire(overlay(), "pointermove", { clientX: 200, clientY: 150 });
|
|
434
|
+
fire(overlay(), "pointerup", { clientX: 200, clientY: 150 });
|
|
435
|
+
expect(spies.onBlockedMove).toHaveBeenCalledTimes(1);
|
|
436
|
+
expect(spies.onManualDragStart).not.toHaveBeenCalled();
|
|
437
|
+
expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("narrows each group member on its own and toasts the blocked one on a group press", async () => {
|
|
441
|
+
const a = resolved(element("a"));
|
|
442
|
+
const b = resolved(element("b"));
|
|
443
|
+
const { seen, overlay, spies } = await select([loop({ x: 120 }, "#a")], {
|
|
444
|
+
selection: a,
|
|
445
|
+
groups: [a, b],
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
expect(seen.groups.map((s) => flags(s)[0])).toEqual([false, true]);
|
|
449
|
+
fire(overlay().querySelector(BOX)!, "pointerdown", { clientX: 150, clientY: 150 });
|
|
450
|
+
expect(spies.onBlockedMove).toHaveBeenCalledTimes(1);
|
|
451
|
+
expect(spies.onBlockedMove.mock.calls[0]![0].element).toBe(a.element);
|
|
452
|
+
expect(spies.onManualDragStart).not.toHaveBeenCalled();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it.each([
|
|
456
|
+
["rotation", loop({ rotation: 90 }), [true, true, false], { dots: 4, rotate: false }],
|
|
457
|
+
["resize", loop({ width: 320, height: 90 }), [true, false, true], { dots: 0, rotate: true }],
|
|
458
|
+
])(
|
|
459
|
+
"narrows only %s when only that channel is a helper's",
|
|
460
|
+
async (_, animation, expected, shown) => {
|
|
461
|
+
const { seen, overlay } = await select([animation]);
|
|
462
|
+
expect(flags(seen.selection)).toEqual(expected);
|
|
463
|
+
expect(handles(overlay())).toEqual(shown);
|
|
464
|
+
},
|
|
465
|
+
);
|
|
466
|
+
});
|