@hyperframes/studio 0.8.102 → 0.8.104
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-BuRiFtrF.js → hyperframes-player-DAoaajuM.js} +1 -1
- package/dist/assets/{index-BT3sauee.js → index-BDK4rTMb.js} +1 -1
- package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
- package/dist/assets/{index-C2utDZt-.js → index-De2aO4XM.js} +179 -179
- package/dist/assets/{index-BM35fjFf.js → index-WV9LroAD.js} +1 -1
- package/dist/{chunk-MTAVNAGW.js → chunk-46XP7J3B.js} +2 -2
- package/dist/{chunk-S65AQH6M.js → chunk-DI4LCJHL.js} +3 -2
- package/dist/chunk-DI4LCJHL.js.map +1 -0
- package/dist/{chunk-7X4XRP3L.js → chunk-HIGHVWYD.js} +2 -2
- package/dist/{domEditingLayers-NU6Z5QWA.js → domEditingLayers-AQBDFNUC.js} +3 -3
- package/dist/index.d.ts +183 -170
- package/dist/index.html +2 -2
- package/dist/index.js +550 -389
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditOverlay.tsx +7 -7
- package/src/components/editor/PropertyPanel.tsx +5 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
- package/src/components/editor/domEditOverlayGestures.ts +14 -20
- package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
- package/src/components/editor/domEditOverlayStartGesture.ts +17 -27
- package/src/components/editor/manualEdits.test.ts +3 -9
- package/src/components/editor/manualEdits.ts +0 -1
- package/src/components/editor/manualEditsDom.ts +0 -10
- package/src/components/editor/manualEditsDomPatches.test.ts +1 -45
- package/src/components/editor/manualEditsDomPatches.ts +1 -23
- package/src/components/editor/manualEditsSnapshot.ts +2 -0
- package/src/components/editor/manualEditsTypes.ts +2 -0
- package/src/components/editor/manualOffsetDrag.test.ts +57 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -32
- package/src/components/editor/plainTranslate.test.ts +38 -22
- package/src/components/editor/plainTranslate.ts +75 -40
- package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
- package/src/components/editor/previewReadOnlyContext.tsx +3 -1
- package/src/components/editor/propertyPanelTransformCommit.test.ts +80 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +11 -6
- package/src/components/editor/propertyPanelTypes.ts +1 -0
- package/src/components/editor/rotateInlineSave.test.ts +111 -0
- package/src/components/editor/rotationDraft.test.ts +111 -0
- package/src/components/editor/rotationDraft.ts +145 -0
- package/src/components/editor/useColorGradingController.test.ts +36 -1
- package/src/components/editor/useColorGradingController.ts +2 -2
- package/src/components/editor/useDomEditNudge.test.tsx +34 -0
- package/src/components/editor/useDomEditNudge.ts +12 -5
- package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
- package/src/hooks/draggedGsapPosition.ts +28 -0
- package/src/hooks/elementOffsetStager.ts +26 -6
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
- package/src/hooks/plainRotation.test.ts +122 -0
- package/src/hooks/plainRotation.ts +42 -0
- package/src/hooks/useDomEditSession.ts +5 -5
- package/src/hooks/useDomGeometryCommit.test.tsx +75 -0
- package/src/hooks/useDomGeometryCommit.ts +17 -17
- package/src/hooks/useDomGeometryCommits.test.tsx +56 -1
- package/src/hooks/useDomGeometryCommits.ts +28 -36
- package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
- package/src/hooks/useGsapAwareEditing.ts +43 -42
- package/src/hooks/useInlineTextEdit.test.tsx +33 -3
- package/src/hooks/useInlineTextEdit.ts +2 -2
- package/src/hooks/usePreviewInteraction.test.ts +32 -0
- package/src/hooks/usePreviewInteraction.ts +3 -2
- package/src/player/components/TimelineClipFades.test.tsx +103 -3
- package/src/player/components/TimelineClipFades.tsx +53 -50
- package/src/styles/theme.css +1 -1
- package/src/utils/projectRouting.test.ts +9 -0
- package/src/utils/projectRouting.ts +3 -1
- package/dist/chunk-S65AQH6M.js.map +0 -1
- /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
- /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
- /package/dist/{domEditingLayers-NU6Z5QWA.js.map → domEditingLayers-AQBDFNUC.js.map} +0 -0
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Extracted from useDomEditSession to isolate the GSAP intercept routing
|
|
8
8
|
* from the rest of the editing orchestration.
|
|
9
9
|
*/
|
|
10
|
+
import type { RotationCommit } from "../components/editor/rotationDraft";
|
|
10
11
|
import { useCallback } from "react";
|
|
11
12
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
12
13
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
@@ -28,15 +29,18 @@ import type {
|
|
|
28
29
|
import { setElementGsapPosition } from "../utils/elementGsap";
|
|
29
30
|
import { logResize, logResizeSettle } from "../utils/resizeDebug";
|
|
30
31
|
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
32
|
+
import type { MoveCommitOptions } from "../components/editor/domEditOverlayGestures";
|
|
31
33
|
import { runGestureTransaction } from "./gestureTransaction";
|
|
32
34
|
import {
|
|
35
|
+
gsapWritesBox,
|
|
33
36
|
gsapWritesPosition,
|
|
37
|
+
gsapWritesRotation,
|
|
34
38
|
hasNonHoldTweenForElement,
|
|
35
39
|
POSITION_CHANNELS,
|
|
36
40
|
} from "./gsapRuntimeKeyframes";
|
|
37
41
|
import { assertGsapEditPersisted, saveMove } from "./gsapEditOutcome";
|
|
38
42
|
import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
|
|
39
|
-
import type
|
|
43
|
+
import { refuseGsapTakeover, type ElementOffsetStagerDeps } from "./elementOffsetStager";
|
|
40
44
|
import {
|
|
41
45
|
prepareCropResize,
|
|
42
46
|
saveCropResize,
|
|
@@ -80,12 +84,15 @@ export interface UseGsapAwareEditingParams {
|
|
|
80
84
|
stageElementPositionOffset: (
|
|
81
85
|
selection: DomEditSelection,
|
|
82
86
|
next: { x: number; y: number },
|
|
87
|
+
plainTranslate: boolean,
|
|
83
88
|
coalesceKey?: string,
|
|
84
89
|
) => { save: () => Promise<void>; rollback: () => void };
|
|
90
|
+
handleDomRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<void>;
|
|
85
91
|
handleDomBoxSizeCommit: (
|
|
86
92
|
selection: DomEditSelection,
|
|
87
93
|
next: { width: number; height: number },
|
|
88
94
|
offset?: { x: number; y: number },
|
|
95
|
+
restore?: () => void,
|
|
89
96
|
) => Promise<void>;
|
|
90
97
|
commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
|
|
91
98
|
// GSAP script commit ops (from useGsapScriptCommits)
|
|
@@ -131,6 +138,7 @@ export function useGsapAwareEditing({
|
|
|
131
138
|
trackGsapInteractionFailure,
|
|
132
139
|
stageElementPositionOffset,
|
|
133
140
|
handleDomBoxSizeCommit,
|
|
141
|
+
handleDomRotationCommit,
|
|
134
142
|
commitPositionPatchToHtml,
|
|
135
143
|
addGsapAnimation,
|
|
136
144
|
convertToKeyframes,
|
|
@@ -151,10 +159,11 @@ export function useGsapAwareEditing({
|
|
|
151
159
|
async (
|
|
152
160
|
selection: DomEditSelection,
|
|
153
161
|
next: { x: number; y: number },
|
|
154
|
-
modifiers?:
|
|
162
|
+
modifiers?: MoveCommitOptions,
|
|
155
163
|
) => {
|
|
156
|
-
|
|
157
|
-
|
|
164
|
+
// A gesture and the panel carry their route; webmcp's moveTo decides here.
|
|
165
|
+
if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element))
|
|
166
|
+
return stageElementPositionOffset(selection, next, true).save();
|
|
158
167
|
if (gsapCommitMutation) {
|
|
159
168
|
try {
|
|
160
169
|
const ownedAnimations = getGsapAnimationsForSelection(selection);
|
|
@@ -170,7 +179,7 @@ export function useGsapAwareEditing({
|
|
|
170
179
|
makeFetchFallback(selection),
|
|
171
180
|
modifiers,
|
|
172
181
|
);
|
|
173
|
-
await saveMove(outcome, () => stageElementPositionOffset(selection, next).save());
|
|
182
|
+
await saveMove(outcome, () => stageElementPositionOffset(selection, next, false).save());
|
|
174
183
|
} catch (error) {
|
|
175
184
|
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
|
|
176
185
|
throw error;
|
|
@@ -197,13 +206,9 @@ export function useGsapAwareEditing({
|
|
|
197
206
|
// it survives the N sequential server round-trips) onto each commit —
|
|
198
207
|
// otherwise each member records its own entry and it takes N presses to undo.
|
|
199
208
|
const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
|
|
200
|
-
// Members are written one at a time, and a
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// stay there until their own write lands, which is the single element seen
|
|
204
|
-
// jumping mid-commit while the rest of the group sat still. The drafted
|
|
205
|
-
// positions are already on screen, so holding the render until the last
|
|
206
|
-
// member has been written costs nothing and never shows a half-moved group.
|
|
209
|
+
// Members are written one at a time, and a re-render re-runs the script with the OLD
|
|
210
|
+
// position of every member not yet written, so they snap back until their own write
|
|
211
|
+
// lands. The drafts are already on screen: hold the render until the last member.
|
|
207
212
|
let renderOnCommit = false;
|
|
208
213
|
const previewFallbackLatch = { pending: false };
|
|
209
214
|
const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
|
|
@@ -236,17 +241,17 @@ export function useGsapAwareEditing({
|
|
|
236
241
|
return Promise.resolve();
|
|
237
242
|
};
|
|
238
243
|
const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// Every member reads the same file, and a preflight writes nothing — so run
|
|
245
|
-
// them together. The parse layer shares one in-flight request per file, which
|
|
246
|
-
// turns N sequential round trips into one.
|
|
244
|
+
// Members saved on themselves, each with its route: true for its CSS translate.
|
|
245
|
+
const offsetMembers = new Map<DomEditSelection, boolean>();
|
|
246
|
+
// Editability is user-atomic: prove every member can be written before the first source
|
|
247
|
+
// mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
|
|
248
|
+
// write nothing and share one in-flight parse per file, so they run together.
|
|
247
249
|
const preflightResults = await Promise.allSettled(
|
|
248
|
-
updates.map(async ({ selection }) => {
|
|
249
|
-
if (!gsapWritesPosition(selection.element))
|
|
250
|
+
updates.map(async ({ selection, plainTranslate }) => {
|
|
251
|
+
if (plainTranslate ?? !gsapWritesPosition(selection.element)) {
|
|
252
|
+
refuseGsapTakeover(selection.element, showToast);
|
|
253
|
+
return void offsetMembers.set(selection, true);
|
|
254
|
+
}
|
|
250
255
|
const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
|
|
251
256
|
preflightAnimations.set(selection, animations);
|
|
252
257
|
const outcome = await tryGsapDragIntercept(
|
|
@@ -258,7 +263,7 @@ export function useGsapAwareEditing({
|
|
|
258
263
|
undefined,
|
|
259
264
|
{ preflightOnly: true, group: true },
|
|
260
265
|
);
|
|
261
|
-
if (outcome.status === "element-offset") offsetMembers.
|
|
266
|
+
if (outcome.status === "element-offset") offsetMembers.set(selection, false);
|
|
262
267
|
assertGsapEditPersisted(outcome);
|
|
263
268
|
}),
|
|
264
269
|
);
|
|
@@ -277,8 +282,9 @@ export function useGsapAwareEditing({
|
|
|
277
282
|
);
|
|
278
283
|
for (const [index, { selection, next }] of updates.entries()) {
|
|
279
284
|
renderOnCommit = index === lastScriptWrite;
|
|
280
|
-
|
|
281
|
-
|
|
285
|
+
const plain = offsetMembers.get(selection);
|
|
286
|
+
if (plain !== undefined) {
|
|
287
|
+
await stageElementPositionOffset(selection, next, plain, coalesceKey).save();
|
|
282
288
|
continue;
|
|
283
289
|
}
|
|
284
290
|
try {
|
|
@@ -318,6 +324,7 @@ export function useGsapAwareEditing({
|
|
|
318
324
|
makeFetchFallback,
|
|
319
325
|
trackGsapInteractionFailure,
|
|
320
326
|
stageElementPositionOffset,
|
|
327
|
+
showToast,
|
|
321
328
|
],
|
|
322
329
|
);
|
|
323
330
|
|
|
@@ -328,6 +335,8 @@ export function useGsapAwareEditing({
|
|
|
328
335
|
offset?: { x: number; y: number },
|
|
329
336
|
restore: () => void = () => undefined,
|
|
330
337
|
) => {
|
|
338
|
+
if (!gsapWritesBox(selection.element))
|
|
339
|
+
return handleDomBoxSizeCommit(selection, next, offset, restore);
|
|
331
340
|
let targetAnimations: GsapAnimation[];
|
|
332
341
|
try {
|
|
333
342
|
const ownedAnimations = getGsapAnimationsForSelection(selection);
|
|
@@ -411,7 +420,8 @@ export function useGsapAwareEditing({
|
|
|
411
420
|
);
|
|
412
421
|
// Saved after the size, under its undo key, so the two are one step.
|
|
413
422
|
await saveMove(dragOutcome, async () => {
|
|
414
|
-
|
|
423
|
+
const plain = !gsapWritesPosition(selection.element);
|
|
424
|
+
anchorMove = stageElementPositionOffset(selection, offset, plain, coalesceKey);
|
|
415
425
|
});
|
|
416
426
|
}
|
|
417
427
|
logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size");
|
|
@@ -421,13 +431,7 @@ export function useGsapAwareEditing({
|
|
|
421
431
|
throw error;
|
|
422
432
|
}
|
|
423
433
|
}
|
|
424
|
-
|
|
425
|
-
next,
|
|
426
|
-
offset: offset ?? null,
|
|
427
|
-
hadGsapMutation: !!gsapCommitMutation,
|
|
428
|
-
});
|
|
429
|
-
logResizeSettle(selection.element, "dom-route");
|
|
430
|
-
await handleDomBoxSizeCommit(selection, next, offset);
|
|
434
|
+
throw new Error("Resize of a GSAP-owned box has no GSAP writer");
|
|
431
435
|
},
|
|
432
436
|
afterBufferedCommitsSaved: async () => {
|
|
433
437
|
await anchorMove?.save();
|
|
@@ -456,17 +460,13 @@ export function useGsapAwareEditing({
|
|
|
456
460
|
);
|
|
457
461
|
|
|
458
462
|
const handleGsapAwareRotationCommit = useCallback(
|
|
459
|
-
async (selection: DomEditSelection, next:
|
|
463
|
+
async (selection: DomEditSelection, next: RotationCommit) => {
|
|
464
|
+
if (next.plain || !gsapWritesRotation(selection.element))
|
|
465
|
+
return handleDomRotationCommit(selection, next);
|
|
460
466
|
if (gsapCommitMutation) {
|
|
461
467
|
try {
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
? ownedAnimations
|
|
465
|
-
: await ownedAnimations;
|
|
466
|
-
// Single source of truth for rotation too: tryGsapRotationIntercept handles
|
|
467
|
-
// tweened elements (keyframes) and static ones (a tl.set), so there's no
|
|
468
|
-
// CSS-var fallback. Selectorless/computed source rejects so the gesture
|
|
469
|
-
// transaction can restore its draft instead of reporting a false success.
|
|
468
|
+
const targetAnimations = await getGsapAnimationsForSelection(selection);
|
|
469
|
+
// A keyframe or a tl.set; a computed source rejects, so the gesture restores its draft.
|
|
470
470
|
const outcome = await tryGsapRotationIntercept(
|
|
471
471
|
selection,
|
|
472
472
|
next.angle,
|
|
@@ -488,6 +488,7 @@ export function useGsapAwareEditing({
|
|
|
488
488
|
makeFetchFallback,
|
|
489
489
|
trackGsapInteractionFailure,
|
|
490
490
|
getGsapAnimationsForSelection,
|
|
491
|
+
handleDomRotationCommit,
|
|
491
492
|
],
|
|
492
493
|
);
|
|
493
494
|
|
|
@@ -345,7 +345,7 @@ describe("useInlineTextEdit", () => {
|
|
|
345
345
|
act(() => root.unmount());
|
|
346
346
|
});
|
|
347
347
|
|
|
348
|
-
it("
|
|
348
|
+
it("commits on Escape", () => {
|
|
349
349
|
const element = heading("Original");
|
|
350
350
|
const { controls, root, onCommit } = mount();
|
|
351
351
|
act(() => {
|
|
@@ -355,8 +355,38 @@ describe("useInlineTextEdit", () => {
|
|
|
355
355
|
element.textContent = "half-typed";
|
|
356
356
|
press(element, "Escape");
|
|
357
357
|
|
|
358
|
-
expect(element.textContent).toBe("
|
|
359
|
-
expect(onCommit).
|
|
358
|
+
expect(element.textContent).toBe("half-typed");
|
|
359
|
+
expect(onCommit).toHaveBeenCalledTimes(1);
|
|
360
|
+
expect(controls().session).toBeNull();
|
|
361
|
+
act(() => root.unmount());
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// The save carries the styled markup it replaced, which is what undo puts back.
|
|
365
|
+
it("saves typing ended by Escape once, with the markup it replaced", async () => {
|
|
366
|
+
const element = heading();
|
|
367
|
+
element.innerHTML = 'Hello <span style="color: red">world</span>';
|
|
368
|
+
const { controls, root, onCommit } = mount();
|
|
369
|
+
act(() => {
|
|
370
|
+
controls().start(element);
|
|
371
|
+
});
|
|
372
|
+
await act(async () => {
|
|
373
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
|
374
|
+
});
|
|
375
|
+
expect(document.activeElement).toBe(element);
|
|
376
|
+
|
|
377
|
+
element.innerHTML = 'Hello <span style="color: red">world</span>!';
|
|
378
|
+
press(element, "Escape");
|
|
379
|
+
|
|
380
|
+
expect(onCommit.mock.calls).toEqual([
|
|
381
|
+
[
|
|
382
|
+
{
|
|
383
|
+
element,
|
|
384
|
+
html: 'Hello <span style="color: red">world</span>!',
|
|
385
|
+
previousHtml: 'Hello <span style="color: red">world</span>',
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
]);
|
|
389
|
+
expect(element.innerHTML).toBe('Hello <span style="color: red">world</span>!');
|
|
360
390
|
act(() => root.unmount());
|
|
361
391
|
});
|
|
362
392
|
|
|
@@ -203,7 +203,7 @@ export function useInlineTextEdit({
|
|
|
203
203
|
}
|
|
204
204
|
if (event.key === "Escape") {
|
|
205
205
|
event.preventDefault();
|
|
206
|
-
|
|
206
|
+
commit();
|
|
207
207
|
}
|
|
208
208
|
};
|
|
209
209
|
// Clicking away keeps the work, which is what every other field in Studio
|
|
@@ -243,7 +243,7 @@ export function useInlineTextEdit({
|
|
|
243
243
|
element.removeEventListener("dragover", onDragOver);
|
|
244
244
|
element.removeEventListener("drop", onDrop);
|
|
245
245
|
};
|
|
246
|
-
}, [session, commit
|
|
246
|
+
}, [session, commit]);
|
|
247
247
|
|
|
248
248
|
// Unmount keeps the typed words, as blur does. Read through a ref so a new
|
|
249
249
|
// onCommit never closes the session mid-edit.
|
|
@@ -267,3 +267,35 @@ describe("usePreviewInteraction", () => {
|
|
|
267
267
|
cleanup();
|
|
268
268
|
});
|
|
269
269
|
});
|
|
270
|
+
|
|
271
|
+
describe("a refused move's toast", () => {
|
|
272
|
+
it("gives the refusal's own reason first, else the selection's", () => {
|
|
273
|
+
const showToast = vi.fn();
|
|
274
|
+
let blocked!: (selection: DomEditSelection, reason?: string) => void;
|
|
275
|
+
function Harness() {
|
|
276
|
+
blocked = usePreviewInteraction({
|
|
277
|
+
captionEditMode: false,
|
|
278
|
+
compositionLoading: false,
|
|
279
|
+
previewIframeRef: { current: null },
|
|
280
|
+
showToast,
|
|
281
|
+
applyDomSelection: vi.fn(),
|
|
282
|
+
resolveDomSelectionFromPreviewPoint: vi.fn(async () => null),
|
|
283
|
+
resolveAllDomSelectionsFromPreviewPoint: vi.fn(async () => []),
|
|
284
|
+
updateDomEditHoverSelection: vi.fn(),
|
|
285
|
+
setActiveGroupElement: vi.fn(),
|
|
286
|
+
}).handleBlockedDomMove;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const root = createRoot(document.createElement("div"));
|
|
290
|
+
act(() => root.render(React.createElement(Harness)));
|
|
291
|
+
const selection = makeSelection("Box", document.createElement("div"));
|
|
292
|
+
selection.capabilities.reasonIfDisabled = "Locked layer.";
|
|
293
|
+
blocked(selection, "Studio can't read it.");
|
|
294
|
+
blocked(selection);
|
|
295
|
+
act(() => root.unmount());
|
|
296
|
+
expect(showToast.mock.calls).toEqual([
|
|
297
|
+
["Studio can't read it.", "info"],
|
|
298
|
+
["Locked layer.", "info"],
|
|
299
|
+
]);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -260,9 +260,10 @@ export function usePreviewInteraction({
|
|
|
260
260
|
}, [updateDomEditHoverSelection]);
|
|
261
261
|
|
|
262
262
|
const handleBlockedDomMove = useCallback(
|
|
263
|
-
(selection: DomEditSelection) => {
|
|
263
|
+
(selection: DomEditSelection, reason?: string) => {
|
|
264
264
|
showToast(
|
|
265
|
-
|
|
265
|
+
reason ??
|
|
266
|
+
selection.capabilities.reasonIfDisabled ??
|
|
266
267
|
"This element can't be adjusted directly from the preview.",
|
|
267
268
|
"info",
|
|
268
269
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
|
-
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { TimelineElement } from "../store/playerStore";
|
|
7
7
|
import { TimelineClipFades } from "./TimelineClipFades";
|
|
@@ -9,7 +9,10 @@ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
|
9
9
|
|
|
10
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
11
|
|
|
12
|
+
const mounted: Root[] = [];
|
|
13
|
+
|
|
12
14
|
afterEach(() => {
|
|
15
|
+
act(() => mounted.splice(0).forEach((root) => root.unmount()));
|
|
13
16
|
document.body.innerHTML = "";
|
|
14
17
|
});
|
|
15
18
|
|
|
@@ -31,6 +34,7 @@ function render(
|
|
|
31
34
|
const host = document.createElement("div");
|
|
32
35
|
document.body.append(host);
|
|
33
36
|
const root = createRoot(host);
|
|
37
|
+
mounted.push(root);
|
|
34
38
|
const onSetElementAttributeLive = vi.fn();
|
|
35
39
|
const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined);
|
|
36
40
|
const onRevertElementAttributeLive = vi.fn();
|
|
@@ -97,6 +101,14 @@ describe("TimelineClipFades", () => {
|
|
|
97
101
|
act(() => root.unmount());
|
|
98
102
|
});
|
|
99
103
|
|
|
104
|
+
it("clips the ramps to the clip's rounded corners", () => {
|
|
105
|
+
const { host, root } = render(clip, { showHandles: false, provide: false });
|
|
106
|
+
const ramps = host.querySelector<SVGElement>('[data-testid="clip-fade-ramps"]');
|
|
107
|
+
expect(ramps?.style.overflow).toBe("hidden");
|
|
108
|
+
expect(ramps?.style.borderRadius).toBe("inherit");
|
|
109
|
+
act(() => root.unmount());
|
|
110
|
+
});
|
|
111
|
+
|
|
100
112
|
it("renders nothing for a clip without fades when the handles are hidden", () => {
|
|
101
113
|
const { host, root } = render(
|
|
102
114
|
{ ...clip, fadeIn: undefined, fadeOut: undefined },
|
|
@@ -113,7 +125,7 @@ describe("TimelineClipFades", () => {
|
|
|
113
125
|
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
114
126
|
if (!handle) throw new Error("expected a fade-in handle");
|
|
115
127
|
armCapture(handle);
|
|
116
|
-
//
|
|
128
|
+
// 150 px of rightward travel adds 1.5 s to the 1 s fade-in, wherever the press lands.
|
|
117
129
|
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
118
130
|
act(() => handle.dispatchEvent(pointer("pointermove", 200)));
|
|
119
131
|
act(() => handle.dispatchEvent(pointer("pointermove", 250)));
|
|
@@ -134,7 +146,7 @@ describe("TimelineClipFades", () => {
|
|
|
134
146
|
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
135
147
|
if (!handle) throw new Error("expected a fade-out handle");
|
|
136
148
|
armCapture(handle);
|
|
137
|
-
//
|
|
149
|
+
// 300 px of leftward travel adds 3 s to the 2 s fade-out, wherever the press lands.
|
|
138
150
|
act(() => handle.dispatchEvent(pointer("pointerdown", 1000)));
|
|
139
151
|
act(() => handle.dispatchEvent(pointer("pointermove", 700)));
|
|
140
152
|
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
|
|
@@ -179,6 +191,30 @@ describe("TimelineClipFades", () => {
|
|
|
179
191
|
act(() => root.unmount());
|
|
180
192
|
});
|
|
181
193
|
|
|
194
|
+
it("cancels a drag on Escape pressed while focus is elsewhere, and saves nothing", () => {
|
|
195
|
+
const { host, onSetElementAttributeQuiet, onRevertElementAttributeLive } = render(clip);
|
|
196
|
+
const elsewhere = document.createElement("button");
|
|
197
|
+
document.body.append(elsewhere);
|
|
198
|
+
elsewhere.focus();
|
|
199
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
200
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
201
|
+
armCapture(handle);
|
|
202
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
203
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 300)));
|
|
204
|
+
expect(document.activeElement).toBe(elsewhere);
|
|
205
|
+
act(() => {
|
|
206
|
+
document.activeElement?.dispatchEvent(
|
|
207
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
act(() => handle.dispatchEvent(pointer("pointerup", 300)));
|
|
211
|
+
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
|
212
|
+
expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
|
|
213
|
+
expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
|
|
214
|
+
"0,0 100,0 0,100",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
182
218
|
it.each([
|
|
183
219
|
["above the window", 300, -40],
|
|
184
220
|
["on the window's right edge", window.innerWidth, 10],
|
|
@@ -200,6 +236,70 @@ describe("TimelineClipFades", () => {
|
|
|
200
236
|
act(() => root.unmount());
|
|
201
237
|
});
|
|
202
238
|
|
|
239
|
+
it.each([
|
|
240
|
+
["in", "Fade in 0.25 s"],
|
|
241
|
+
["out", "Fade out 0.4 s"],
|
|
242
|
+
])(
|
|
243
|
+
"gives the fade-%s handle a 24 px target and a tooltip naming its length",
|
|
244
|
+
async (edge, text) => {
|
|
245
|
+
const { host, root } = render({ ...clip, fadeIn: 0.25, fadeOut: 0.4 });
|
|
246
|
+
const handle = host.querySelector<HTMLElement>(`[data-testid="clip-fade-handle-${edge}"]`);
|
|
247
|
+
if (!handle) throw new Error(`expected a fade-${edge} handle`);
|
|
248
|
+
expect(parseFloat(handle.style.width)).toBeGreaterThanOrEqual(24);
|
|
249
|
+
expect(parseFloat(handle.style.height)).toBeGreaterThanOrEqual(24);
|
|
250
|
+
expect(handle.getAttribute("title")).toBe("");
|
|
251
|
+
act(() => handle.focus());
|
|
252
|
+
await act(async () => {
|
|
253
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
254
|
+
});
|
|
255
|
+
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(text);
|
|
256
|
+
act(() => root.unmount());
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
it.each([0.3, 3])("pins the handles to the top corners for a %s s fade-in", (fadeIn) => {
|
|
261
|
+
const { host } = render({ ...clip, fadeIn, fadeOut: 0.4 });
|
|
262
|
+
const inHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
263
|
+
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
264
|
+
expect(inHandle?.style.left).toBe("0px");
|
|
265
|
+
expect(fadeOut?.style.left).toBe(`${1000 - 24}px`);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("lengthens a fade by the pointer's inward travel from wherever the press lands", () => {
|
|
269
|
+
const { host, onSetElementAttributeLive } = render(clip);
|
|
270
|
+
const fadeIn = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
271
|
+
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
272
|
+
if (!fadeIn || !fadeOut) throw new Error("expected both handles");
|
|
273
|
+
armCapture(fadeIn);
|
|
274
|
+
armCapture(fadeOut);
|
|
275
|
+
act(() => fadeIn.dispatchEvent(pointer("pointerdown", 12)));
|
|
276
|
+
act(() => fadeIn.dispatchEvent(pointer("pointermove", 162)));
|
|
277
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "2.5");
|
|
278
|
+
act(() => fadeIn.dispatchEvent(pointer("pointerup", 162)));
|
|
279
|
+
act(() => fadeOut.dispatchEvent(pointer("pointerdown", 988, 2)));
|
|
280
|
+
act(() => fadeOut.dispatchEvent(pointer("pointermove", 888, 2)));
|
|
281
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "3");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("paints the handles above the clip's waveform and thumbnail layers", () => {
|
|
285
|
+
const { host } = render(clip);
|
|
286
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
287
|
+
expect(Number(handle?.style.zIndex)).toBeGreaterThan(10);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("splits a clip narrower than two targets between the handles", () => {
|
|
291
|
+
const { host } = render({ ...clip, duration: 0.3, fadeIn: 0.1, fadeOut: 0.1 });
|
|
292
|
+
const fadeIn = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
293
|
+
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
294
|
+
expect([fadeIn?.style.left, fadeIn?.style.width]).toEqual(["0px", "15px"]);
|
|
295
|
+
expect([fadeOut?.style.left, fadeOut?.style.width]).toEqual(["15px", "15px"]);
|
|
296
|
+
const dot = fadeIn?.firstElementChild as HTMLElement;
|
|
297
|
+
expect(getComputedStyle(dot).width).toBe("10px");
|
|
298
|
+
// Layout is not real here; tests/e2e/fade-handles.mjs measures the box in Chrome.
|
|
299
|
+
expect(getComputedStyle(dot).flexShrink).toBe("0");
|
|
300
|
+
expect(getComputedStyle(dot).pointerEvents).toBe("none");
|
|
301
|
+
});
|
|
302
|
+
|
|
203
303
|
it("does not let a press on the dot start the clip's own move gesture", () => {
|
|
204
304
|
const outer = vi.fn();
|
|
205
305
|
const { host, root } = render(clip, { onClipPointerDown: outer });
|