@hyperframes/studio 0.7.79 → 0.7.80
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-mFah2TZE.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-Bqj3h_1a.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-DlZMDyYs.js → index-BhdJN49y.js} +200 -200
- package/dist/assets/{index-hmnoiSEV.js → index-By-YiRxq.js} +1 -1
- package/dist/assets/index-D379YOKT.css +1 -0
- package/dist/index.d.ts +45 -3
- package/dist/index.html +2 -2
- package/dist/index.js +652 -465
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +2 -1
- package/src/components/editor/AnimationCard.test.tsx +225 -18
- package/src/components/editor/AnimationCard.tsx +24 -3
- package/src/components/editor/EaseCurveSection.test.tsx +147 -2
- package/src/components/editor/EaseCurveSection.tsx +77 -15
- package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
- package/src/components/editor/GsapAnimationSection.tsx +6 -1
- package/src/components/editor/KeyframeEaseList.tsx +4 -0
- package/src/components/editor/MotionPathOverlay.tsx +54 -19
- package/src/components/editor/PropertyPanel.tsx +4 -0
- package/src/components/editor/PropertyPanelFlat.tsx +6 -91
- package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
- package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
- package/src/components/editor/holdEaseSeek.test.ts +37 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
- package/src/components/editor/propertyPanelFlatProps.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +132 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +150 -50
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +4 -12
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useGsapTweenCache.ts +40 -34
- package/src/hooks/useKeyframeEaseCommits.ts +78 -0
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
- package/src/player/components/TimelineClipDiamonds.test.tsx +18 -6
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -85
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +29 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +24 -0
- package/src/player/store/keyframeSlice.ts +15 -5
- package/dist/assets/index-gGVKuFg5.css +0 -1
|
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import { shouldUseSdkCutover } from "../utils/sdkCutover";
|
|
6
6
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
7
7
|
import type { Composition } from "@hyperframes/sdk";
|
|
8
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
8
9
|
import type { UseDomEditSessionParams } from "./useDomEditSession";
|
|
9
10
|
|
|
10
11
|
const styleOp = (property: string, value: string): PatchOperation => ({
|
|
@@ -57,6 +58,8 @@ const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined }
|
|
|
57
58
|
fn: undefined,
|
|
58
59
|
};
|
|
59
60
|
|
|
61
|
+
const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
|
|
62
|
+
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
|
|
60
63
|
vi.mock("../utils/sdkResolverShadow", () => ({
|
|
61
64
|
runResolverShadow: vi.fn(),
|
|
62
65
|
recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
|
|
@@ -83,11 +86,11 @@ vi.mock("./useDomEditCommits", () => ({
|
|
|
83
86
|
}));
|
|
84
87
|
vi.mock("./useDomSelection", () => ({
|
|
85
88
|
useDomSelection: () => ({
|
|
86
|
-
domEditSelection:
|
|
89
|
+
domEditSelection: domEditSelectionRef.current,
|
|
87
90
|
domEditGroupSelections: [],
|
|
88
91
|
domEditHoverSelection: null,
|
|
89
92
|
activeGroupElement: null,
|
|
90
|
-
domEditSelectionRef
|
|
93
|
+
domEditSelectionRef,
|
|
91
94
|
domEditGroupSelectionsRef: { current: [] },
|
|
92
95
|
setActiveGroupElement: vi.fn(),
|
|
93
96
|
applyDomSelection: vi.fn(),
|
|
@@ -123,7 +126,7 @@ vi.mock("./useGsapTweenCache", () => ({
|
|
|
123
126
|
}));
|
|
124
127
|
vi.mock("./useGsapScriptCommits", () => ({
|
|
125
128
|
useGsapScriptCommits: () => ({
|
|
126
|
-
commitMutation:
|
|
129
|
+
commitMutation: gsapCommitMutation,
|
|
127
130
|
updateGsapProperty: vi.fn(),
|
|
128
131
|
updateGsapMeta: vi.fn(),
|
|
129
132
|
deleteGsapAnimation: vi.fn(),
|
|
@@ -277,3 +280,149 @@ describe("onReorderShadow source filter", () => {
|
|
|
277
280
|
}
|
|
278
281
|
});
|
|
279
282
|
});
|
|
283
|
+
|
|
284
|
+
describe("bulk segment ease commits", () => {
|
|
285
|
+
it("uses one ordered batch for many ids and sane paths for one or no ids", async () => {
|
|
286
|
+
const { useDomEditSession } = await import("./useDomEditSession");
|
|
287
|
+
const selection: DomEditSelection = {
|
|
288
|
+
id: "hero",
|
|
289
|
+
element: document.createElement("div"),
|
|
290
|
+
label: "Hero",
|
|
291
|
+
tagName: "DIV",
|
|
292
|
+
sourceFile: "index.html",
|
|
293
|
+
compositionPath: "index.html",
|
|
294
|
+
isCompositionHost: false,
|
|
295
|
+
isInsideLockedComposition: false,
|
|
296
|
+
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
|
|
297
|
+
textContent: null,
|
|
298
|
+
dataAttributes: {},
|
|
299
|
+
inlineStyles: {},
|
|
300
|
+
computedStyles: {},
|
|
301
|
+
textFields: [],
|
|
302
|
+
capabilities: {
|
|
303
|
+
canSelect: true,
|
|
304
|
+
canEditStyles: true,
|
|
305
|
+
canCrop: true,
|
|
306
|
+
canMove: true,
|
|
307
|
+
canResize: true,
|
|
308
|
+
canApplyManualOffset: true,
|
|
309
|
+
canApplyManualSize: true,
|
|
310
|
+
canApplyManualRotation: true,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
domEditSelectionRef.current = selection;
|
|
314
|
+
gsapCommitMutation.mockClear();
|
|
315
|
+
gsapCommitMutation.batch.mockClear();
|
|
316
|
+
let updateSegmentEase:
|
|
317
|
+
| ((targets: Array<{ animationId: string; tweenPercentage: number }>, ease: string) => void)
|
|
318
|
+
| undefined;
|
|
319
|
+
|
|
320
|
+
function Probe() {
|
|
321
|
+
const params: UseDomEditSessionParams = {
|
|
322
|
+
projectId: "proj-1",
|
|
323
|
+
activeCompPath: "index.html",
|
|
324
|
+
isMasterView: false,
|
|
325
|
+
compIdToSrc: new Map(),
|
|
326
|
+
captionEditMode: false,
|
|
327
|
+
compositionLoading: false,
|
|
328
|
+
previewIframeRef: { current: null },
|
|
329
|
+
timelineElements: [],
|
|
330
|
+
setSelectedTimelineElementId: vi.fn(),
|
|
331
|
+
setRightCollapsed: vi.fn(),
|
|
332
|
+
setRightPanelTab: vi.fn(),
|
|
333
|
+
showToast: vi.fn(),
|
|
334
|
+
refreshPreviewDocumentVersion: vi.fn(),
|
|
335
|
+
queueDomEditSave: async <T,>(save: () => Promise<T>) => save(),
|
|
336
|
+
readProjectFile: async () => "",
|
|
337
|
+
writeProjectFile: async () => {},
|
|
338
|
+
updateEditingFileContent: vi.fn(),
|
|
339
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
340
|
+
editHistory: { recordEdit: async () => {} },
|
|
341
|
+
fileTree: [],
|
|
342
|
+
importedFontAssetsRef: { current: [] },
|
|
343
|
+
projectDir: null,
|
|
344
|
+
projectIdRef: { current: "proj-1" },
|
|
345
|
+
previewIframe: null,
|
|
346
|
+
refreshKey: 0,
|
|
347
|
+
previewDocumentVersion: 0,
|
|
348
|
+
rightPanelTab: "design",
|
|
349
|
+
applyStudioManualEditsToPreviewRef: { current: async () => {} },
|
|
350
|
+
syncPreviewHistoryHotkey: vi.fn(),
|
|
351
|
+
reloadPreview: vi.fn(),
|
|
352
|
+
setRefreshKey: vi.fn(),
|
|
353
|
+
};
|
|
354
|
+
updateSegmentEase = useDomEditSession(params).handleUpdateSegmentEase;
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const container = document.createElement("div");
|
|
359
|
+
const root = createRoot(container);
|
|
360
|
+
act(() => root.render(<Probe />));
|
|
361
|
+
try {
|
|
362
|
+
expect(updateSegmentEase).toBeTypeOf("function");
|
|
363
|
+
if (!updateSegmentEase) return;
|
|
364
|
+
const options = { label: "Update segment ease", softReload: true };
|
|
365
|
+
const targets = [
|
|
366
|
+
{ animationId: "move-x", tweenPercentage: 20 },
|
|
367
|
+
{ animationId: "move-y", tweenPercentage: 50 },
|
|
368
|
+
{ animationId: "fade", tweenPercentage: 80 },
|
|
369
|
+
];
|
|
370
|
+
updateSegmentEase(targets, "power2.inOut");
|
|
371
|
+
|
|
372
|
+
expect(gsapCommitMutation).not.toHaveBeenCalled();
|
|
373
|
+
expect(gsapCommitMutation.batch).toHaveBeenCalledTimes(1);
|
|
374
|
+
expect(gsapCommitMutation.batch).toHaveBeenCalledWith(
|
|
375
|
+
targets.map(({ animationId, tweenPercentage }) => ({
|
|
376
|
+
selection,
|
|
377
|
+
mutation: {
|
|
378
|
+
type: "update-keyframe",
|
|
379
|
+
animationId,
|
|
380
|
+
percentage: tweenPercentage,
|
|
381
|
+
properties: {},
|
|
382
|
+
ease: "power2.inOut",
|
|
383
|
+
},
|
|
384
|
+
options,
|
|
385
|
+
})),
|
|
386
|
+
options,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
gsapCommitMutation.mockClear();
|
|
390
|
+
gsapCommitMutation.batch.mockClear();
|
|
391
|
+
updateSegmentEase([{ animationId: "fade", tweenPercentage: 25 }], "none");
|
|
392
|
+
expect(gsapCommitMutation).toHaveBeenCalledTimes(1);
|
|
393
|
+
expect(gsapCommitMutation).toHaveBeenCalledWith(
|
|
394
|
+
selection,
|
|
395
|
+
{
|
|
396
|
+
type: "update-keyframe",
|
|
397
|
+
animationId: "fade",
|
|
398
|
+
percentage: 25,
|
|
399
|
+
properties: {},
|
|
400
|
+
ease: "none",
|
|
401
|
+
},
|
|
402
|
+
{ label: "Update keyframe ease", softReload: true },
|
|
403
|
+
);
|
|
404
|
+
expect(gsapCommitMutation.batch).not.toHaveBeenCalled();
|
|
405
|
+
|
|
406
|
+
gsapCommitMutation.mockClear();
|
|
407
|
+
updateSegmentEase([], "linear");
|
|
408
|
+
expect(gsapCommitMutation).not.toHaveBeenCalled();
|
|
409
|
+
expect(gsapCommitMutation.batch).not.toHaveBeenCalled();
|
|
410
|
+
|
|
411
|
+
// A commit with no batch transport (a wrapped one, e.g. inside a gesture
|
|
412
|
+
// transaction) must still write every tween. Optional-chaining the batch
|
|
413
|
+
// call would drop the whole edit here and report nothing.
|
|
414
|
+
gsapCommitMutation.mockClear();
|
|
415
|
+
const restoreBatch = gsapCommitMutation.batch;
|
|
416
|
+
Reflect.deleteProperty(gsapCommitMutation, "batch");
|
|
417
|
+
try {
|
|
418
|
+
updateSegmentEase(targets, "power1.in");
|
|
419
|
+
expect(gsapCommitMutation).toHaveBeenCalledTimes(targets.length);
|
|
420
|
+
} finally {
|
|
421
|
+
gsapCommitMutation.batch = restoreBatch;
|
|
422
|
+
}
|
|
423
|
+
} finally {
|
|
424
|
+
domEditSelectionRef.current = null;
|
|
425
|
+
act(() => root.unmount());
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
});
|
|
@@ -19,8 +19,7 @@ import { useGsapCacheVersion } from "./useGsapTweenCache";
|
|
|
19
19
|
import { useDomEditWiring } from "./useDomEditWiring";
|
|
20
20
|
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
21
21
|
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
|
|
22
|
-
|
|
23
|
-
// ── Types ──
|
|
22
|
+
import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
|
|
24
23
|
|
|
25
24
|
interface RecordEditInput {
|
|
26
25
|
label: string;
|
|
@@ -71,8 +70,6 @@ export interface UseDomEditSessionParams {
|
|
|
71
70
|
forceReloadSdkSession?: () => void;
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
// ── Hook ──
|
|
75
|
-
|
|
76
73
|
export function useDomEditSession({
|
|
77
74
|
projectId,
|
|
78
75
|
activeCompPath,
|
|
@@ -113,8 +110,6 @@ export function useDomEditSession({
|
|
|
113
110
|
forceReloadSdkSession,
|
|
114
111
|
}: UseDomEditSessionParams) {
|
|
115
112
|
void _setRefreshKey;
|
|
116
|
-
// ── Selection ──
|
|
117
|
-
|
|
118
113
|
const {
|
|
119
114
|
domEditSelection,
|
|
120
115
|
domEditGroupSelections,
|
|
@@ -149,8 +144,6 @@ export function useDomEditSession({
|
|
|
149
144
|
rightPanelTab,
|
|
150
145
|
});
|
|
151
146
|
|
|
152
|
-
// ── Agent modal ──
|
|
153
|
-
|
|
154
147
|
const {
|
|
155
148
|
agentModalOpen,
|
|
156
149
|
agentModalAnchorPoint,
|
|
@@ -423,9 +416,6 @@ export function useDomEditSession({
|
|
|
423
416
|
removeAllKeyframes,
|
|
424
417
|
handleDomManualEditsReset,
|
|
425
418
|
});
|
|
426
|
-
|
|
427
|
-
// ── Preview interaction ──
|
|
428
|
-
|
|
429
419
|
const {
|
|
430
420
|
handlePreviewCanvasMouseDown,
|
|
431
421
|
handlePreviewCanvasPointerMove,
|
|
@@ -444,9 +434,6 @@ export function useDomEditSession({
|
|
|
444
434
|
setActiveGroupElement,
|
|
445
435
|
onClickToSource,
|
|
446
436
|
});
|
|
447
|
-
|
|
448
|
-
// ── GSAP-aware geometry intercepts + animated property commit ──
|
|
449
|
-
|
|
450
437
|
const {
|
|
451
438
|
handleGsapAwarePathOffsetCommit,
|
|
452
439
|
handleGsapAwareGroupPathOffsetCommit,
|
|
@@ -473,45 +460,8 @@ export function useDomEditSession({
|
|
|
473
460
|
setArcPath,
|
|
474
461
|
updateArcSegment,
|
|
475
462
|
});
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
(animationId: string, percentage: number, ease: string) => {
|
|
479
|
-
const sel = domEditSelectionRef.current;
|
|
480
|
-
if (!sel) return;
|
|
481
|
-
gsapCommitMutation(
|
|
482
|
-
sel,
|
|
483
|
-
{
|
|
484
|
-
type: "update-keyframe",
|
|
485
|
-
animationId,
|
|
486
|
-
percentage,
|
|
487
|
-
properties: {},
|
|
488
|
-
ease,
|
|
489
|
-
},
|
|
490
|
-
{ label: "Update keyframe ease", softReload: true },
|
|
491
|
-
);
|
|
492
|
-
},
|
|
493
|
-
[gsapCommitMutation, domEditSelectionRef],
|
|
494
|
-
);
|
|
495
|
-
|
|
496
|
-
// Apply one ease to every segment at once (AE select-all + F9): set easeEach
|
|
497
|
-
// and strip per-keyframe overrides in a single mutation.
|
|
498
|
-
const handleSetAllKeyframeEases = useCallback(
|
|
499
|
-
(animationId: string, ease: string) => {
|
|
500
|
-
const sel = domEditSelectionRef.current;
|
|
501
|
-
if (!sel) return;
|
|
502
|
-
gsapCommitMutation(
|
|
503
|
-
sel,
|
|
504
|
-
{
|
|
505
|
-
type: "update-meta",
|
|
506
|
-
animationId,
|
|
507
|
-
updates: { easeEach: ease, resetKeyframeEases: true },
|
|
508
|
-
},
|
|
509
|
-
{ label: "Apply ease to all segments", softReload: true },
|
|
510
|
-
);
|
|
511
|
-
},
|
|
512
|
-
[gsapCommitMutation, domEditSelectionRef],
|
|
513
|
-
);
|
|
514
|
-
|
|
463
|
+
const { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases } =
|
|
464
|
+
useKeyframeEaseCommits({ gsapCommitMutation, domEditSelectionRef });
|
|
515
465
|
return {
|
|
516
466
|
// State
|
|
517
467
|
domEditSelection,
|
|
@@ -591,6 +541,7 @@ export function useDomEditSession({
|
|
|
591
541
|
commitAnimatedProperty,
|
|
592
542
|
commitAnimatedProperties,
|
|
593
543
|
handleSetArcPath,
|
|
544
|
+
handleUpdateSegmentEase,
|
|
594
545
|
handleUpdateArcSegment,
|
|
595
546
|
handleUnroll,
|
|
596
547
|
invalidateGsapCache: bumpGsapCache,
|
|
@@ -5,6 +5,7 @@ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBrid
|
|
|
5
5
|
import {
|
|
6
6
|
clearKeyframeCacheForElement,
|
|
7
7
|
pruneKeyframeCacheToFiles,
|
|
8
|
+
publishKeyframeCache,
|
|
8
9
|
writeGsapAnimationsForElement,
|
|
9
10
|
} from "./gsapKeyframeCacheHelpers";
|
|
10
11
|
import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
deduplicateKeyframes,
|
|
13
14
|
isStaticPositionHold,
|
|
14
15
|
synthesizeFlatTweenKeyframes,
|
|
16
|
+
type MergeableKeyframe,
|
|
15
17
|
} from "./gsapTweenSynth";
|
|
16
18
|
import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
|
|
17
19
|
|
|
@@ -266,13 +268,7 @@ export function useGsapAnimationsForElement(
|
|
|
266
268
|
domClipChildren,
|
|
267
269
|
);
|
|
268
270
|
|
|
269
|
-
const allKeyframes:
|
|
270
|
-
GsapKeyframesData["keyframes"][0] & {
|
|
271
|
-
tweenPercentage?: number;
|
|
272
|
-
propertyGroup?: string;
|
|
273
|
-
animationId?: string;
|
|
274
|
-
}
|
|
275
|
-
> = [];
|
|
271
|
+
const allKeyframes: MergeableKeyframe[] = [];
|
|
276
272
|
let format: GsapKeyframesData["format"] = "percentage";
|
|
277
273
|
let ease: string | undefined;
|
|
278
274
|
let easeEach: string | undefined;
|
|
@@ -318,11 +314,15 @@ export function useGsapAnimationsForElement(
|
|
|
318
314
|
...(ease ? { ease } : {}),
|
|
319
315
|
...(easeEach ? { easeEach } : {}),
|
|
320
316
|
};
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
317
|
+
// PropertyPanel reads the cache by bare elementId (without sourceFile
|
|
318
|
+
// prefix), so the same entry is written under the bare key for
|
|
319
|
+
// cross-component lookups. Both keys land in one publish: a reader that woke
|
|
320
|
+
// between two separate writes saw the prefixed key updated and the bare one
|
|
321
|
+
// still stale.
|
|
322
|
+
publishKeyframeCache((draft) => {
|
|
323
|
+
draft.keyframeCache.set(`${sourceFile}#${elementId}`, merged);
|
|
324
|
+
draft.keyframeCache.set(elementId, merged);
|
|
325
|
+
});
|
|
326
326
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
327
327
|
}, [elementId, sourceFile, animations, domClipChildrenKey]);
|
|
328
328
|
|
|
@@ -421,29 +421,35 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
421
421
|
}
|
|
422
422
|
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
|
|
423
423
|
if (scanned.size === 0) return false;
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
424
|
+
// One publish for the whole scan: a scan of a 120-clip composition used to
|
|
425
|
+
// emit up to three store notifications per element, and every subscriber
|
|
426
|
+
// in between re-rendered against a cache only partly filled in.
|
|
427
|
+
publishKeyframeCache((draft) => {
|
|
428
|
+
for (const [id, data] of scanned) {
|
|
429
|
+
const cacheKey = `${sf}#${id}`;
|
|
430
|
+
const fallbackKey = `index.html#${id}`;
|
|
431
|
+
const alreadyCached =
|
|
432
|
+
draft.keyframeCache.has(cacheKey) ||
|
|
433
|
+
draft.keyframeCache.has(fallbackKey) ||
|
|
434
|
+
draft.keyframeCache.has(id);
|
|
435
|
+
if (alreadyCached) continue;
|
|
436
|
+
// Skip position-only set tweens from runtime too, same filter as AST path
|
|
437
|
+
const isPosOnly =
|
|
438
|
+
data.keyframes.length === 1 &&
|
|
439
|
+
Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
|
|
440
|
+
if (isPosOnly) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const entry = {
|
|
444
|
+
format: "percentage" as const,
|
|
445
|
+
keyframes: data.keyframes,
|
|
446
|
+
...(data.easeEach ? { easeEach: data.easeEach } : {}),
|
|
447
|
+
};
|
|
448
|
+
draft.keyframeCache.set(cacheKey, entry);
|
|
449
|
+
if (sf !== "index.html") draft.keyframeCache.set(fallbackKey, entry);
|
|
450
|
+
draft.keyframeCache.set(id, entry);
|
|
437
451
|
}
|
|
438
|
-
|
|
439
|
-
format: "percentage" as const,
|
|
440
|
-
keyframes: data.keyframes,
|
|
441
|
-
...(data.easeEach ? { easeEach: data.easeEach } : {}),
|
|
442
|
-
};
|
|
443
|
-
setKeyframeCache(cacheKey, entry);
|
|
444
|
-
if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
|
|
445
|
-
setKeyframeCache(id, entry);
|
|
446
|
-
}
|
|
452
|
+
});
|
|
447
453
|
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
|
|
448
454
|
return true;
|
|
449
455
|
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useCallback, type RefObject } from "react";
|
|
2
|
+
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
3
|
+
import type { AnimationKeyframeTarget } from "./gsapTweenSynth";
|
|
4
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
+
|
|
6
|
+
interface KeyframeEaseCommitsInput {
|
|
7
|
+
gsapCommitMutation: CommitMutation;
|
|
8
|
+
domEditSelectionRef: RefObject<DomEditSelection | null>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function useKeyframeEaseCommits({
|
|
12
|
+
gsapCommitMutation,
|
|
13
|
+
domEditSelectionRef,
|
|
14
|
+
}: KeyframeEaseCommitsInput) {
|
|
15
|
+
const handleUpdateSegmentEase = useCallback(
|
|
16
|
+
(targets: AnimationKeyframeTarget[], ease: string) => {
|
|
17
|
+
const selection = domEditSelectionRef.current;
|
|
18
|
+
if (!selection || targets.length === 0) return;
|
|
19
|
+
const options = {
|
|
20
|
+
label: targets.length === 1 ? "Update keyframe ease" : "Update segment ease",
|
|
21
|
+
softReload: true,
|
|
22
|
+
};
|
|
23
|
+
const calls = targets.map(({ animationId, tweenPercentage }) => ({
|
|
24
|
+
selection,
|
|
25
|
+
mutation: {
|
|
26
|
+
type: "update-keyframe",
|
|
27
|
+
animationId,
|
|
28
|
+
percentage: tweenPercentage,
|
|
29
|
+
properties: {},
|
|
30
|
+
ease,
|
|
31
|
+
},
|
|
32
|
+
options,
|
|
33
|
+
}));
|
|
34
|
+
if (calls.length === 1) {
|
|
35
|
+
const call = calls[0];
|
|
36
|
+
if (call) void gsapCommitMutation(call.selection, call.mutation, call.options);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const batch = gsapCommitMutation.batch;
|
|
40
|
+
if (batch) {
|
|
41
|
+
void batch(calls, options);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// A wrapped commit (a gesture transaction, say) carries no batch
|
|
45
|
+
// transport. Serial keeps the edit correct at the cost of one round trip
|
|
46
|
+
// per tween and one undo entry per tween; an optional-chained call here
|
|
47
|
+
// would silently drop the whole edit instead.
|
|
48
|
+
for (const call of calls)
|
|
49
|
+
void gsapCommitMutation(call.selection, call.mutation, call.options);
|
|
50
|
+
},
|
|
51
|
+
[gsapCommitMutation, domEditSelectionRef],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const handleUpdateKeyframeEase = useCallback(
|
|
55
|
+
(animationId: string, percentage: number, ease: string) =>
|
|
56
|
+
handleUpdateSegmentEase([{ animationId, tweenPercentage: percentage }], ease),
|
|
57
|
+
[handleUpdateSegmentEase],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const handleSetAllKeyframeEases = useCallback(
|
|
61
|
+
(animationId: string, ease: string) => {
|
|
62
|
+
const sel = domEditSelectionRef.current;
|
|
63
|
+
if (!sel) return;
|
|
64
|
+
gsapCommitMutation(
|
|
65
|
+
sel,
|
|
66
|
+
{
|
|
67
|
+
type: "update-meta",
|
|
68
|
+
animationId,
|
|
69
|
+
updates: { easeEach: ease, resetKeyframeEases: true },
|
|
70
|
+
},
|
|
71
|
+
{ label: "Apply ease to all segments", softReload: true },
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
[gsapCommitMutation, domEditSelectionRef],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases };
|
|
78
|
+
}
|
|
@@ -66,4 +66,25 @@ describe("KeyframeDiamondContextMenu", () => {
|
|
|
66
66
|
expect(onDelete).toHaveBeenCalledWith("box", target);
|
|
67
67
|
expect(onMoveToPlayhead).toHaveBeenCalledWith(element, target);
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
// An arc waypoint on a two-anchor path cannot be dropped on its own, so the
|
|
71
|
+
// caller withholds onDelete rather than offering an entry that does nothing.
|
|
72
|
+
it("hides the single-node delete when no handler is given", () => {
|
|
73
|
+
const host = document.createElement("div");
|
|
74
|
+
document.body.appendChild(host);
|
|
75
|
+
const root = createRoot(host);
|
|
76
|
+
act(() =>
|
|
77
|
+
root.render(
|
|
78
|
+
<KeyframeDiamondContextMenu state={state} onClose={() => {}} onDeleteAll={vi.fn()} />,
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const labels = Array.from(document.body.querySelectorAll("button")).map(
|
|
83
|
+
(button) => button.textContent,
|
|
84
|
+
);
|
|
85
|
+
expect(labels).toEqual(["Delete All Keyframes"]);
|
|
86
|
+
|
|
87
|
+
act(() => root.unmount());
|
|
88
|
+
host.remove();
|
|
89
|
+
});
|
|
69
90
|
});
|
|
@@ -19,7 +19,10 @@ export interface KeyframeDiamondContextMenuState {
|
|
|
19
19
|
interface KeyframeDiamondContextMenuProps {
|
|
20
20
|
state: KeyframeDiamondContextMenuState;
|
|
21
21
|
onClose: () => void;
|
|
22
|
-
|
|
22
|
+
/** Omitted where this node cannot be deleted on its own (see the arc-waypoint
|
|
23
|
+
* floor in removeMotionPathPointInScript): an entry that silently no-ops is
|
|
24
|
+
* worse than no entry. */
|
|
25
|
+
onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
23
26
|
onDeleteAll: (element: TimelineElement) => void;
|
|
24
27
|
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
|
25
28
|
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
@@ -43,7 +46,9 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
43
46
|
};
|
|
44
47
|
|
|
45
48
|
const menuWidth = 200;
|
|
46
|
-
|
|
49
|
+
// Measured off the rendered rows, so the flip-up test below stays right as
|
|
50
|
+
// optional entries drop out.
|
|
51
|
+
const menuHeight = 10 + (1 + (onMoveToPlayhead ? 1 : 0) + (onDelete ? 1 : 0)) * 30;
|
|
47
52
|
const overflowY = state.y + menuHeight - window.innerHeight;
|
|
48
53
|
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
|
|
49
54
|
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
|
|
@@ -71,16 +76,18 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
71
76
|
)}
|
|
72
77
|
|
|
73
78
|
{/* Delete */}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
{onDelete && (
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
|
83
|
+
onClick={() => {
|
|
84
|
+
onDelete(state.elementId, keyframe);
|
|
85
|
+
onClose();
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
Delete Keyframe
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
84
91
|
|
|
85
92
|
<button
|
|
86
93
|
type="button"
|
|
@@ -658,7 +658,18 @@ describe("TimelineClipDiamonds", () => {
|
|
|
658
658
|
<TimelineDiamondLane
|
|
659
659
|
keyframesData={{
|
|
660
660
|
format: "percentage",
|
|
661
|
-
keyframes: [
|
|
661
|
+
keyframes: [
|
|
662
|
+
kf(0),
|
|
663
|
+
kf(50),
|
|
664
|
+
kf(100, {
|
|
665
|
+
collidingAnimationTargets: lastAmbiguous
|
|
666
|
+
? [
|
|
667
|
+
{ animationId: "anim-1", tweenPercentage: 100 },
|
|
668
|
+
{ animationId: "anim-2", tweenPercentage: 75 },
|
|
669
|
+
]
|
|
670
|
+
: undefined,
|
|
671
|
+
}),
|
|
672
|
+
],
|
|
662
673
|
}}
|
|
663
674
|
clipWidthPx={clipWidthPx}
|
|
664
675
|
clipHeightPx={48}
|
|
@@ -675,15 +686,16 @@ describe("TimelineClipDiamonds", () => {
|
|
|
675
686
|
return { host, root };
|
|
676
687
|
};
|
|
677
688
|
|
|
678
|
-
it("
|
|
679
|
-
//
|
|
680
|
-
// keyframe
|
|
689
|
+
it("shows the inline ease button on a colliding merged segment (bulk edit)", () => {
|
|
690
|
+
// Both segments (0->50, 50->100) render their ease button; the 50->100
|
|
691
|
+
// segment ends on a keyframe shared by two animations and the button now
|
|
692
|
+
// bulk-edits both rather than being hidden.
|
|
681
693
|
const { host, root } = renderSegmentLane(true);
|
|
682
|
-
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(
|
|
694
|
+
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
|
683
695
|
act(() => root.unmount());
|
|
684
696
|
});
|
|
685
697
|
|
|
686
|
-
it("
|
|
698
|
+
it("shows the inline ease button on single-animation merged segments", () => {
|
|
687
699
|
const { host, root } = renderSegmentLane(false);
|
|
688
700
|
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
|
689
701
|
act(() => root.unmount());
|