@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.
Files changed (83) hide show
  1. package/dist/assets/hyperframes-player-CtNVImMi.js +473 -0
  2. package/dist/assets/{index-Cz3E_ZSj.js → index-B_MF86RE.js} +1 -1
  3. package/dist/assets/{index-8Fgryj7q.js → index-DJ-Ol7YS.js} +1 -1
  4. package/dist/assets/{index-d5DKK15Q.js → index-DZKcDAtf.js} +203 -203
  5. package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
  6. package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
  7. package/dist/chunk-PQFEGZWM.js.map +1 -0
  8. package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
  9. package/dist/index.d.ts +203 -160
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +1958 -1611
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +8 -8
  15. package/src/components/editor/DomEditOverlay.tsx +1 -2
  16. package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
  17. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
  18. package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
  19. package/src/components/editor/LayersPanel.test.ts +14 -0
  20. package/src/components/editor/LayersPanel.tsx +2 -2
  21. package/src/components/editor/TopologyLens.tsx +2 -2
  22. package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +0 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
  25. package/src/components/editor/domEditing.test.ts +29 -0
  26. package/src/components/editor/domEditingDom.ts +1 -0
  27. package/src/components/editor/domEditingTypes.ts +1 -0
  28. package/src/components/editor/marqueeCommit.test.tsx +84 -0
  29. package/src/components/editor/marqueeCommit.ts +13 -1
  30. package/src/components/editor/useColorGradingController.test.ts +24 -0
  31. package/src/components/editor/useColorGradingController.ts +3 -2
  32. package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
  33. package/src/components/nle/useCompositionStack.test.tsx +53 -0
  34. package/src/components/nle/useCompositionStack.ts +4 -8
  35. package/src/hooks/domStyleCommit.ts +129 -0
  36. package/src/hooks/gsapEditOutcome.ts +2 -2
  37. package/src/hooks/gsapElementMatch.ts +38 -0
  38. package/src/hooks/gsapResizeIntercept.ts +10 -31
  39. package/src/hooks/gsapResizePreflight.ts +56 -0
  40. package/src/hooks/gsapRuntimeBridge.ts +66 -26
  41. package/src/hooks/keyframeCacheAstLoad.ts +4 -1
  42. package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
  43. package/src/hooks/useDomEditCommits.ts +17 -182
  44. package/src/hooks/useDomEditPersist.ts +225 -0
  45. package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
  46. package/src/hooks/useDomEditPreviewSync.ts +2 -4
  47. package/src/hooks/useDomEditSession.test.tsx +51 -1
  48. package/src/hooks/useDomEditSession.ts +11 -2
  49. package/src/hooks/useDomEditTextCommits.ts +15 -99
  50. package/src/hooks/useDomStyleCommit.test.tsx +155 -0
  51. package/src/hooks/useDomStyleCommit.ts +74 -0
  52. package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
  53. package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
  54. package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
  55. package/src/hooks/useGsapAwareEditing.ts +1 -1
  56. package/src/hooks/useGsapTweenCache.ts +2 -48
  57. package/src/index.ts +10 -1
  58. package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
  59. package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
  60. package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
  61. package/src/player/components/TimelineGroupHeader.tsx +21 -10
  62. package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
  63. package/src/player/components/TimelineGroupRow.test.tsx +24 -33
  64. package/src/player/components/TimelineGroupRow.tsx +4 -1
  65. package/src/player/components/TimelineLanes.tsx +5 -5
  66. package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
  67. package/src/player/components/TimelineTrackHeader.tsx +6 -3
  68. package/src/player/components/TimelineTypes.ts +1 -0
  69. package/src/player/components/timelineLaneProps.ts +1 -0
  70. package/src/player/components/timelineMountTestEnv.ts +29 -0
  71. package/src/player/components/useTimelineLabelColumn.ts +18 -0
  72. package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
  73. package/src/player/components/useTimelineProviderState.tsx +6 -12
  74. package/src/player/components/useTimelineRangeSelection.ts +13 -12
  75. package/src/player/hooks/useShadowPreviewReload.ts +2 -0
  76. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
  77. package/src/player/hooks/useTimelinePlayer.ts +29 -7
  78. package/src/player/lib/runtimeProtocol.test.ts +1 -0
  79. package/src/player/sceneSwap.test.ts +95 -0
  80. package/src/player/sceneSwap.ts +53 -0
  81. package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
  82. package/dist/chunk-3IOCVWB6.js.map +0 -1
  83. /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.86",
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.86",
64
- "@hyperframes/sdk": "0.8.86",
65
- "@hyperframes/player": "0.8.86",
66
- "@hyperframes/studio-server": "0.8.86",
67
- "@hyperframes/shader-transitions": "0.8.86",
68
- "@hyperframes/parsers": "0.8.86"
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.86"
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 { BlockedMoveState, ResizeHandle } from "./domEditOverlayGestures";
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 || !groupCanMove || e.shiftKey) return;
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
- e.preventDefault();
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.addEventListener("load", handleLoad);
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.addEventListener("load", dismiss);
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
+ });
@@ -102,7 +102,6 @@ export interface BlockedMoveState {
102
102
  pointerId: number;
103
103
  startX: number;
104
104
  startY: number;
105
- notified: boolean;
106
105
  }
107
106
 
108
107
  export type FocusableDomEditOverlay = {