@hyperframes/studio 0.8.86 → 0.8.88
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-w3Bbv0kd.js +473 -0
- package/dist/assets/{index-Cz3E_ZSj.js → index-B6PGJTzp.js} +1 -1
- package/dist/assets/{index-d5DKK15Q.js → index-DE6cZYR5.js} +199 -199
- package/dist/assets/{index-8Fgryj7q.js → index-DldknZPp.js} +1 -1
- package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
- package/dist/{chunk-7PLTC6SV.js → chunk-KU4F7QZ3.js} +9 -1
- package/dist/chunk-KU4F7QZ3.js.map +1 -0
- 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 +206 -160
- package/dist/index.html +2 -2
- package/dist/index.js +2121 -1702
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/TimelineHistoryButtons.dom.test.tsx +14 -3
- package/src/components/TimelineToolPicker.tsx +90 -0
- package/src/components/TimelineToolbar.tsx +2 -27
- 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/appHotkeysDispatch.ts +8 -0
- 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/useAppHotkeys.test.ts +58 -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/Timeline.showKeyframes.test.tsx +91 -0
- 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 +2 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineLaneProps.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +29 -0
- package/src/player/components/useTimelineKeyframeData.ts +14 -0
- package/src/player/components/useTimelineLabelColumn.ts +18 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
- package/src/player/components/useTimelineProviderState.tsx +10 -14
- 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/src/player/store/playerStore.ts +16 -0
- package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
- package/dist/chunk-3IOCVWB6.js.map +0 -1
- package/dist/chunk-7PLTC6SV.js.map +0 -1
- /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
|
+
});
|
|
@@ -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
|
|
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 {
|
|
@@ -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;
|