@hyperframes/studio 0.7.79 → 0.7.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/assets/{hyperframes-player-mFah2TZE.js → hyperframes-player-B32VouCm.js} +1 -1
  2. package/dist/assets/{index-DlZMDyYs.js → index-B5u2_pSh.js} +200 -200
  3. package/dist/assets/index-D379YOKT.css +1 -0
  4. package/dist/assets/{index-hmnoiSEV.js → index-DCTAKfpa.js} +1 -1
  5. package/dist/assets/{index-Bqj3h_1a.js → index-HeUw0EKI.js} +1 -1
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +652 -465
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +2 -1
  12. package/src/components/editor/AnimationCard.test.tsx +225 -18
  13. package/src/components/editor/AnimationCard.tsx +24 -3
  14. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  15. package/src/components/editor/EaseCurveSection.tsx +77 -15
  16. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  17. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  18. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +54 -19
  20. package/src/components/editor/PropertyPanel.tsx +4 -0
  21. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  22. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  23. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  24. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  27. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  28. package/src/components/editor/propertyPanelTypes.ts +2 -0
  29. package/src/contexts/DomEditContext.tsx +4 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +132 -43
  31. package/src/hooks/gsapKeyframeCacheHelpers.ts +150 -50
  32. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  33. package/src/hooks/gsapTweenSynth.ts +50 -23
  34. package/src/hooks/keyframeCacheAstLoad.ts +4 -12
  35. package/src/hooks/useDomEditSession.test.tsx +152 -3
  36. package/src/hooks/useDomEditSession.ts +4 -53
  37. package/src/hooks/useGsapTweenCache.ts +40 -34
  38. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  39. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  40. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  41. package/src/player/components/TimelineClipDiamonds.test.tsx +18 -6
  42. package/src/player/components/TimelineDiamondConnectors.tsx +118 -85
  43. package/src/player/components/timelineDiamondTypes.ts +4 -3
  44. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  45. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  46. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  47. package/src/player/hooks/useExpandedTimelineElements.test.ts +29 -0
  48. package/src/player/hooks/useExpandedTimelineElements.ts +24 -0
  49. package/src/player/store/keyframeSlice.ts +15 -5
  50. 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: null,
89
+ domEditSelection: domEditSelectionRef.current,
87
90
  domEditGroupSelections: [],
88
91
  domEditHoverSelection: null,
89
92
  activeGroupElement: null,
90
- domEditSelectionRef: { current: null },
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: vi.fn(),
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
- const handleUpdateKeyframeEase = useCallback(
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: Array<
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
- const { setKeyframeCache } = usePlayerStore.getState();
322
- setKeyframeCache(`${sourceFile}#${elementId}`, merged);
323
- // PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
324
- // so write a duplicate entry under the bare key for cross-component lookups.
325
- setKeyframeCache(elementId, merged);
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
- const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
425
- for (const [id, data] of scanned) {
426
- const cacheKey = `${sf}#${id}`;
427
- const fallbackKey = `index.html#${id}`;
428
- const alreadyCached =
429
- keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
430
- if (alreadyCached) continue;
431
- // Skip position-only set tweens from runtime too — same filter as AST path
432
- const isPosOnly =
433
- data.keyframes.length === 1 &&
434
- Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
435
- if (isPosOnly) {
436
- continue;
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
- const entry = {
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
- onDelete: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
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
- const menuHeight = onMoveToPlayhead ? 100 : 70;
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
- <button
75
- type="button"
76
- 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"
77
- onClick={() => {
78
- onDelete(state.elementId, keyframe);
79
- onClose();
80
- }}
81
- >
82
- Delete Keyframe
83
- </button>
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: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
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("hides the inline ease button on an ambiguous merged segment", () => {
679
- // Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous
680
- // keyframe, so its hover/ease-button area is not rendered.
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(1);
694
+ expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
683
695
  act(() => root.unmount());
684
696
  });
685
697
 
686
- it("keeps the inline ease button on unambiguous merged segments", () => {
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());