@hyperframes/studio 0.8.85 → 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 (95) hide show
  1. package/dist/assets/hyperframes-player-CtNVImMi.js +473 -0
  2. package/dist/assets/{index-Bus1BKtk.js → index-B_MF86RE.js} +1 -1
  3. package/dist/assets/{index-CZxhjQpw.js → index-DJ-Ol7YS.js} +1 -1
  4. package/dist/assets/{index-ClLdmgua.js → index-DZKcDAtf.js} +203 -203
  5. package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
  6. package/dist/{chunk-7VDQIGOU.js → chunk-7PLTC6SV.js} +8 -2
  7. package/dist/chunk-7PLTC6SV.js.map +1 -0
  8. package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
  9. package/dist/chunk-PQFEGZWM.js.map +1 -0
  10. package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
  11. package/dist/index.d.ts +207 -161
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +2084 -1705
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +1 -1
  16. package/dist/styles.css +1 -1
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.test.tsx +53 -2
  19. package/src/components/TimelineToolbar.tsx +119 -101
  20. package/src/components/editor/DomEditOverlay.tsx +1 -2
  21. package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
  22. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
  23. package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
  24. package/src/components/editor/LayersPanel.test.ts +14 -0
  25. package/src/components/editor/LayersPanel.tsx +2 -2
  26. package/src/components/editor/TopologyLens.tsx +2 -2
  27. package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
  28. package/src/components/editor/domEditOverlayGestures.ts +0 -1
  29. package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
  30. package/src/components/editor/domEditing.test.ts +29 -0
  31. package/src/components/editor/domEditingDom.ts +1 -0
  32. package/src/components/editor/domEditingTypes.ts +1 -0
  33. package/src/components/editor/marqueeCommit.test.tsx +84 -0
  34. package/src/components/editor/marqueeCommit.ts +13 -1
  35. package/src/components/editor/useColorGradingController.test.ts +24 -0
  36. package/src/components/editor/useColorGradingController.ts +3 -2
  37. package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
  38. package/src/components/nle/useCompositionStack.test.tsx +179 -0
  39. package/src/components/nle/useCompositionStack.ts +26 -20
  40. package/src/hooks/domStyleCommit.ts +129 -0
  41. package/src/hooks/gsapEditOutcome.ts +2 -2
  42. package/src/hooks/gsapElementMatch.ts +38 -0
  43. package/src/hooks/gsapResizeIntercept.ts +10 -31
  44. package/src/hooks/gsapResizePreflight.ts +56 -0
  45. package/src/hooks/gsapRuntimeBridge.ts +66 -26
  46. package/src/hooks/keyframeCacheAstLoad.ts +4 -1
  47. package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
  48. package/src/hooks/useDomEditCommits.ts +17 -182
  49. package/src/hooks/useDomEditPersist.ts +225 -0
  50. package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
  51. package/src/hooks/useDomEditPreviewSync.ts +2 -4
  52. package/src/hooks/useDomEditSession.test.tsx +51 -1
  53. package/src/hooks/useDomEditSession.ts +11 -2
  54. package/src/hooks/useDomEditTextCommits.ts +15 -99
  55. package/src/hooks/useDomStyleCommit.test.tsx +155 -0
  56. package/src/hooks/useDomStyleCommit.ts +74 -0
  57. package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
  59. package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
  60. package/src/hooks/useGsapAwareEditing.ts +1 -1
  61. package/src/hooks/useGsapTweenCache.ts +2 -48
  62. package/src/index.ts +10 -1
  63. package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
  64. package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
  65. package/src/player/components/Timeline.playheadRemount.test.tsx +41 -0
  66. package/src/player/components/Timeline.test.ts +32 -19
  67. package/src/player/components/TimelineGroupHeader.tsx +21 -10
  68. package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
  69. package/src/player/components/TimelineGroupRow.test.tsx +24 -33
  70. package/src/player/components/TimelineGroupRow.tsx +4 -1
  71. package/src/player/components/TimelineLanes.tsx +5 -5
  72. package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
  73. package/src/player/components/TimelineTrackHeader.tsx +6 -3
  74. package/src/player/components/TimelineTypes.ts +1 -0
  75. package/src/player/components/timelineLaneProps.ts +1 -0
  76. package/src/player/components/timelineLayout.ts +16 -23
  77. package/src/player/components/timelineMountTestEnv.ts +29 -0
  78. package/src/player/components/useTimelineGeometry.test.tsx +105 -0
  79. package/src/player/components/useTimelineGeometry.ts +18 -10
  80. package/src/player/components/useTimelineLabelColumn.ts +18 -0
  81. package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
  82. package/src/player/components/useTimelinePlayhead.ts +1 -1
  83. package/src/player/components/useTimelineProviderState.tsx +6 -12
  84. package/src/player/components/useTimelineRangeSelection.ts +13 -12
  85. package/src/player/hooks/useShadowPreviewReload.ts +2 -0
  86. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
  87. package/src/player/hooks/useTimelinePlayer.ts +29 -7
  88. package/src/player/lib/runtimeProtocol.test.ts +1 -0
  89. package/src/player/sceneSwap.test.ts +95 -0
  90. package/src/player/sceneSwap.ts +53 -0
  91. package/src/player/store/liveTime.ts +6 -1
  92. package/dist/assets/hyperframes-player-BK18SggX.js +0 -464
  93. package/dist/chunk-3IOCVWB6.js.map +0 -1
  94. package/dist/chunk-7VDQIGOU.js.map +0 -1
  95. /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
@@ -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 = {
@@ -35,6 +35,15 @@ import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection
35
35
  import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
36
36
  import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
37
37
 
38
+ export function notifyBlockedPress(
39
+ e: React.PointerEvent<HTMLElement>,
40
+ opts: UseDomEditOverlayGesturesOptions,
41
+ selection: DomEditSelection,
42
+ ): void {
43
+ if (e.button !== 0 || selection.capabilities.commitCheckPending) return;
44
+ opts.onBlockedMoveRef.current(selection);
45
+ }
46
+
38
47
  export function startGroupDrag(
39
48
  e: React.PointerEvent<HTMLElement>,
40
49
  opts: UseDomEditOverlayGesturesOptions,
@@ -48,7 +57,7 @@ export function startGroupDrag(
48
57
  if (blockedSelection) {
49
58
  e.preventDefault();
50
59
  e.stopPropagation();
51
- opts.onBlockedMoveRef.current(blockedSelection);
60
+ notifyBlockedPress(e, opts, blockedSelection);
52
61
  return false;
53
62
  }
54
63
 
@@ -17,6 +17,7 @@ import {
17
17
  resolveDomEditCapabilities,
18
18
  resolveDomEditSelection,
19
19
  } from "./domEditing";
20
+ import { normalizeTimelineCompositionSource } from "./domEditingDom";
20
21
 
21
22
  function createDocument(markup: string): Document {
22
23
  const window = new Window();
@@ -910,6 +911,34 @@ describe("resolveDomEditSelection", () => {
910
911
  expect(first).not.toBe(second);
911
912
  });
912
913
 
914
+ it("reads a preview prefix only from a URL, not from a project folder named preview", () => {
915
+ expect(normalizeTimelineCompositionSource("sections/preview/x.html")).toBe(
916
+ "sections/preview/x.html",
917
+ );
918
+ expect(
919
+ normalizeTimelineCompositionSource(
920
+ "http://localhost:5190/api/projects/p/preview/sections/x.html",
921
+ ),
922
+ ).toBe("sections/x.html");
923
+ expect(normalizeTimelineCompositionSource("/api/projects/p/preview/comp/sections/x.html")).toBe(
924
+ "sections/x.html",
925
+ );
926
+ });
927
+
928
+ it("finds a composition host authored with a ./ path", () => {
929
+ const document = createDocument(`
930
+ <div data-composition-id="scene" data-composition-file="./compositions/scene.html"></div>
931
+ `);
932
+
933
+ expect(
934
+ findElementForTimelineElement(
935
+ document,
936
+ { id: "scene", compositionSrc: "./compositions/scene.html" },
937
+ { activeCompositionPath: null, isMasterView: true },
938
+ )?.getAttribute("data-composition-id"),
939
+ ).toBe("scene");
940
+ });
941
+
913
942
  it("resolves generated timeline ids without throwing", () => {
914
943
  const document = createDocument(`
915
944
  <div data-composition-id="hook">
@@ -199,6 +199,7 @@ export function getSourceFileForElement(
199
199
  export function normalizeTimelineCompositionSource(value: string | undefined): string | undefined {
200
200
  const trimmed = value?.trim();
201
201
  if (!trimmed) return undefined;
202
+ if (!/^([a-z][a-z0-9+.-]*:|\/)/i.test(trimmed)) return trimmed;
202
203
 
203
204
  let pathname = trimmed;
204
205
  try {
@@ -61,6 +61,7 @@ export interface DomEditCapabilities {
61
61
  canApplyManualSize: boolean;
62
62
  canApplyManualRotation: boolean;
63
63
  reasonIfDisabled?: string;
64
+ commitCheckPending?: boolean;
64
65
  }
65
66
 
66
67
  export interface DomEditTextField {
@@ -0,0 +1,84 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, useRef } from "react";
3
+ import type { Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, expect, it } from "vitest";
5
+ import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
6
+ import { useMarqueeGestures } from "./marqueeCommit";
7
+
8
+ installReactActEnvironment();
9
+ HTMLElement.prototype.setPointerCapture ??= () => {};
10
+ HTMLElement.prototype.releasePointerCapture ??= () => {};
11
+
12
+ function Overlay() {
13
+ const overlayRef = useRef<HTMLDivElement>(null);
14
+ const marquee = useMarqueeGestures({
15
+ iframeRef: useRef<HTMLIFrameElement>(null),
16
+ overlayRef,
17
+ activeCompositionPathRef: useRef<string | null>("index.html"),
18
+ onMarqueeSelectRef: useRef(undefined),
19
+ });
20
+ return (
21
+ <div
22
+ ref={overlayRef}
23
+ data-overlay
24
+ onPointerDown={marquee.begin}
25
+ onPointerMove={marquee.onPointerMove}
26
+ onPointerUp={marquee.onPointerUp}
27
+ >
28
+ {marquee.marqueeRect && <div data-band />}
29
+ </div>
30
+ );
31
+ }
32
+
33
+ const pointer = (type: string, clientX: number, clientY: number) =>
34
+ act(() => {
35
+ document
36
+ .querySelector("[data-overlay]")!
37
+ .dispatchEvent(
38
+ new PointerEvent(type, { bubbles: true, button: 0, pointerId: 1, clientX, clientY }),
39
+ );
40
+ });
41
+ const escape = () => {
42
+ const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
43
+ act(() => {
44
+ document.body.dispatchEvent(event);
45
+ });
46
+ return event;
47
+ };
48
+
49
+ let root: Root;
50
+ let hostEscapes: KeyboardEvent[];
51
+ const hostListener = (e: KeyboardEvent) => {
52
+ if (e.key === "Escape") hostEscapes.push(e);
53
+ };
54
+
55
+ beforeEach(() => {
56
+ hostEscapes = [];
57
+ window.addEventListener("keydown", hostListener);
58
+ root = mountReactHarness(<Overlay />);
59
+ });
60
+
61
+ afterEach(() => {
62
+ window.removeEventListener("keydown", hostListener);
63
+ act(() => root.unmount());
64
+ document.body.innerHTML = "";
65
+ });
66
+
67
+ it("an escape cancels a preview band while the pointer is still down, and stops there", () => {
68
+ pointer("pointerdown", 10, 10);
69
+ pointer("pointermove", 120, 90);
70
+ expect(document.querySelector("[data-band]")).not.toBeNull();
71
+
72
+ const event = escape();
73
+
74
+ expect(document.querySelector("[data-band]")).toBeNull();
75
+ expect(event.defaultPrevented).toBe(true);
76
+ expect(hostEscapes).toHaveLength(0);
77
+ });
78
+
79
+ it("an escape with no band in flight still reaches the host", () => {
80
+ const event = escape();
81
+
82
+ expect(event.defaultPrevented).toBe(false);
83
+ expect(hostEscapes).toHaveLength(1);
84
+ });
@@ -1,5 +1,5 @@
1
1
  // fallow-ignore-file code-duplication
2
- import { useCallback, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
5
5
  import { isElementComputedVisible } from "./domEditingElement";
@@ -209,6 +209,18 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
209
209
  reset();
210
210
  }, [reset]);
211
211
 
212
+ useEffect(() => {
213
+ const cancelBandBeforeHostEscape = (e: KeyboardEvent) => {
214
+ if (e.key !== "Escape" || !marqueeRef.current) return;
215
+ e.preventDefault();
216
+ e.stopPropagation();
217
+ cancel();
218
+ };
219
+ window.addEventListener("keydown", cancelBandBeforeHostEscape, { capture: true });
220
+ return () =>
221
+ window.removeEventListener("keydown", cancelBandBeforeHostEscape, { capture: true });
222
+ }, [cancel]);
223
+
212
224
  const onPointerMove = useCallback(
213
225
  (event: React.PointerEvent<HTMLDivElement>) => {
214
226
  const m = marqueeRef.current;