@hyperframes/studio 0.7.90 → 0.7.92

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 (86) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-9qOa08Tv.js} +1 -1
  2. package/dist/assets/{index-Bi-CwQDX.js → index-Bw4lNdtO.js} +1 -1
  3. package/dist/assets/{index-CquRJgm-.js → index-CDTN2ZVa.js} +1 -1
  4. package/dist/assets/index-DIntkrQl.js +428 -0
  5. package/dist/index.d.ts +20 -16
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +6495 -5429
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioRightPanel.tsx +1 -1
  11. package/src/components/editor/GsapAnimationList.tsx +84 -0
  12. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  13. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  14. package/src/components/editor/PropertyPanel.tsx +1 -1
  15. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  16. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  17. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  18. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  19. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  20. package/src/components/editor/propertyPanelTypes.ts +1 -1
  21. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  22. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  23. package/src/components/renders/useRenderQueue.ts +16 -3
  24. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  25. package/src/contexts/DomEditContext.tsx +1 -1
  26. package/src/hooks/useDomEditSession.test.tsx +1 -1
  27. package/src/hooks/useDomEditSession.ts +1 -1
  28. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  29. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  30. package/src/player/components/BeatStrip.test.tsx +374 -0
  31. package/src/player/components/BeatStrip.tsx +295 -37
  32. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  33. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  34. package/src/player/components/Timeline.test.ts +2 -3
  35. package/src/player/components/Timeline.tsx +47 -48
  36. package/src/player/components/TimelineCanvas.tsx +14 -71
  37. package/src/player/components/TimelineClip.tsx +6 -3
  38. package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
  39. package/src/player/components/TimelineClipDiamonds.tsx +66 -211
  40. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  41. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  42. package/src/player/components/TimelineLanes.test.tsx +0 -1
  43. package/src/player/components/TimelineLanes.tsx +3 -2
  44. package/src/player/components/TimelineOverlays.test.ts +161 -0
  45. package/src/player/components/TimelineOverlays.tsx +104 -11
  46. package/src/player/components/timelineCallbacks.ts +1 -1
  47. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  48. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  49. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  50. package/src/player/components/timelineClipDragPreview.ts +18 -12
  51. package/src/player/components/timelineClipDragTypes.ts +12 -0
  52. package/src/player/components/timelineDiamondTypes.ts +5 -15
  53. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  54. package/src/player/components/timelineDragDrop.ts +152 -30
  55. package/src/player/components/timelineLaneProps.ts +0 -1
  56. package/src/player/components/timelineMarquee.test.ts +73 -25
  57. package/src/player/components/timelineMarquee.ts +44 -16
  58. package/src/player/components/timelineTestViewport.ts +13 -0
  59. package/src/player/components/timelineViewModel.ts +4 -4
  60. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  61. package/src/player/components/useTimelineClipDrag.ts +112 -70
  62. package/src/player/components/useTimelineClipRenderWindow.ts +4 -4
  63. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  64. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  65. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  66. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  67. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  68. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  69. package/src/player/components/useTimelineRowVirtualization.ts +11 -4
  70. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  71. package/src/player/components/useTrackGapMenu.ts +32 -9
  72. package/src/player/store/keyframeSlice.ts +59 -18
  73. package/src/player/store/playerStore.test.ts +76 -0
  74. package/src/player/store/playerStore.ts +19 -11
  75. package/src/telemetry/canary.test.ts +325 -0
  76. package/src/telemetry/canary.ts +268 -0
  77. package/src/telemetry/client.test.ts +8 -3
  78. package/src/telemetry/client.ts +13 -43
  79. package/src/telemetry/config.ts +16 -2
  80. package/src/telemetry/distinctId.test.ts +35 -0
  81. package/src/telemetry/distinctId.ts +11 -2
  82. package/src/telemetry/policy.test.ts +81 -0
  83. package/src/telemetry/policy.ts +104 -0
  84. package/src/utils/studioTelemetry.test.ts +76 -0
  85. package/src/utils/studioTelemetry.ts +14 -6
  86. package/dist/assets/index-Cql15Yur.js +0 -428
@@ -1,20 +1,13 @@
1
1
  import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
2
- import { useState } from "react";
3
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
3
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
5
4
  import type { DomEditSelection } from "./domEditing";
6
5
  import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
7
6
  import { parseTimingValue } from "./propertyPanelTimingSection";
8
7
  import { CommitField } from "./propertyPanelPrimitives";
9
- import { AnimationCard } from "./AnimationCard";
10
- import {
11
- type GsapAnimationEditCallbacks,
12
- withTrackedGsapAnimationCallbacks,
13
- clearFocusedEaseSegment,
14
- } from "./gsapAnimationCallbacks";
8
+ import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
15
9
  import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
16
- import { usePlayerStore } from "../../player";
17
- import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
10
+ import { GsapAnimationList } from "./GsapAnimationList";
18
11
 
19
12
  export function FlatTimingRow({
20
13
  element,
@@ -136,19 +129,11 @@ export function FlatMotionSection({
136
129
  onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
137
130
  onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
138
131
  } & GsapAnimationEditCallbacks) {
139
- const track = useTrackDesignInput();
140
- const [addMenuOpen, setAddMenuOpen] = useState(false);
141
- const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
142
- const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
143
132
  // Only consume a focus request aimed at the element THIS panel renders (not
144
133
  // the store's selectedElementId, which flips synchronously during async
145
134
  // selection resolution), so a shared class-selector animation id can't open
146
135
  // the wrong element's editor.
147
136
  const renderedElementId = scopedElementKey(element);
148
- const focusedHere =
149
- focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
150
- ? focusedEaseSegment
151
- : null;
152
137
 
153
138
  return (
154
139
  <div className="space-y-3">
@@ -174,26 +159,13 @@ export function FlatMotionSection({
174
159
  </p>
175
160
  )}
176
161
  {!multipleTimelines && !unsupportedTimelinePattern && (
177
- <div className="space-y-2">
178
- {animations.map((anim, index) => (
179
- <AnimationCard
180
- {...trackedCallbacks}
181
- key={anim.id}
182
- animation={anim}
183
- defaultExpanded={index === 0}
184
- flat
185
- focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
186
- onFocusSegmentConsumed={clearFocusedEaseSegment}
187
- />
188
- ))}
189
- <GsapAddAnimationControl
190
- open={addMenuOpen}
191
- setOpen={setAddMenuOpen}
192
- onAddAnimation={onAddAnimation}
193
- track={track}
194
- variant="flat"
195
- />
196
- </div>
162
+ <GsapAnimationList
163
+ {...callbacks}
164
+ elementId={renderedElementId}
165
+ animations={animations}
166
+ onAddAnimation={onAddAnimation}
167
+ variant="flat"
168
+ />
197
169
  )}
198
170
  </>
199
171
  )}
@@ -123,8 +123,8 @@ export interface PropertyPanelProps {
123
123
  ) => void;
124
124
  onRemoveKeyframe?: (animationId: string, percentage: number) => void;
125
125
  onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
126
- onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
127
126
  onUpdateSegmentEase?: NonNullable<GsapAnimationEditCallbacks["onUpdateSegmentEase"]>;
127
+ onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
128
128
  onConvertToKeyframes?: (animationId: string, duration?: number) => void;
129
129
  onCommitAnimatedProperty?: (
130
130
  selection: DomEditSelection,
@@ -317,17 +317,44 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
317
317
  });
318
318
 
319
319
  it("deletes all keyframes through the clicked non-selected element's identity", async () => {
320
- const { circle, selection } = arrangeClickedCircle();
320
+ const circle: TimelineElement = {
321
+ ...element,
322
+ id: "circle",
323
+ key: "scenes/main.html#circle",
324
+ domId: "circle",
325
+ sourceFile: "scenes/main.html",
326
+ };
327
+ const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
328
+ const scaleAnimation: GsapAnimation = {
329
+ ...otherKeyframedAnimation,
330
+ id: "circle-to-0-scale",
331
+ properties: {},
332
+ propertyGroup: "scale",
333
+ keyframes: {
334
+ format: "percentage",
335
+ keyframes: [
336
+ { percentage: 0, properties: { scale: 1 } },
337
+ { percentage: 100, properties: { scale: 2 } },
338
+ ],
339
+ },
340
+ };
341
+ usePlayerStore.setState({
342
+ elements: [element, circle],
343
+ gsapAnimations: new Map([
344
+ ["scenes/main.html#circle", [otherKeyframedAnimation, scaleAnimation]],
345
+ ]),
346
+ });
347
+ mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
321
348
  const view = renderCallbacks();
322
349
 
323
350
  await act(async () => {
324
- view.callbacks.onDeleteAllKeyframes?.(circle);
351
+ view.callbacks.onDeleteAllKeyframes?.(circle, scaleAnimation.id);
325
352
  await Promise.resolve();
326
353
  });
327
354
 
328
355
  expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
329
- otherKeyframedAnimation.id,
330
- selection,
356
+ scaleAnimation.id,
357
+ circleSelection,
331
358
  );
332
359
  view.unmount();
333
360
  });
@@ -360,6 +387,19 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
360
387
  view.unmount();
361
388
  });
362
389
 
390
+ it("does not delete a different lane when an explicit animation identity is stale", async () => {
391
+ const { circle } = arrangeClickedCircle();
392
+ const view = renderCallbacks();
393
+
394
+ await act(async () => {
395
+ view.callbacks.onDeleteAllKeyframes?.(circle, "missing-animation-id");
396
+ await Promise.resolve();
397
+ });
398
+
399
+ expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
400
+ view.unmount();
401
+ });
402
+
363
403
  it("aborts every mutation when the clicked element resolves no selection", async () => {
364
404
  const { circle } = arrangeClickedCircle();
365
405
  mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
@@ -391,6 +431,40 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
391
431
  view.unmount();
392
432
  });
393
433
 
434
+ it("does not delete a different keyframe when its explicit animation identity is stale", () => {
435
+ const view = renderCallbacks();
436
+
437
+ act(() => {
438
+ view.callbacks.onDeleteKeyframe?.("box", {
439
+ percentage: 100,
440
+ propertyGroup: "position",
441
+ tweenPercentage: 100,
442
+ animationId: "missing-animation-id",
443
+ });
444
+ });
445
+
446
+ expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
447
+ expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
448
+ view.unmount();
449
+ });
450
+
451
+ it("does not move a different keyframe when its explicit animation identity is stale", async () => {
452
+ const view = renderCallbacks();
453
+
454
+ await act(async () => {
455
+ view.callbacks.onMoveKeyframeToPlayhead?.(element, {
456
+ percentage: 100,
457
+ propertyGroup: "position",
458
+ tweenPercentage: 100,
459
+ animationId: "missing-animation-id",
460
+ });
461
+ await Promise.resolve();
462
+ });
463
+
464
+ expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
465
+ view.unmount();
466
+ });
467
+
394
468
  it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
395
469
  const { circle, selection } = arrangeClickedCircle();
396
470
  const view = renderCallbacks();
@@ -189,17 +189,22 @@ export function useTimelineEditCallbacks({
189
189
  onSplitElement: handleTimelineElementSplit,
190
190
  onRazorSplit: handleRazorSplit,
191
191
  onRazorSplitAll: handleRazorSplitAll,
192
- onDeleteAllKeyframes: (element) => {
192
+ onDeleteAllKeyframes: (element, animationId) => {
193
193
  // Hold the element where it is (collapse keyframes to a static set) rather
194
194
  // than deleting the whole animation — deleting strands a stale GSAP base
195
195
  // that the next drag adds to, flinging the element off-screen.
196
196
  const elementKey = getTimelineElementIdentity(element);
197
- // Every keyframed tween on the layer, not just the first: a layer with
198
- // position AND opacity keyframes left the second one keyframed, so
199
- // "Delete All Keyframes" visibly did half the job.
200
- const anims = resolveElementAnimations(elementKey).filter(
201
- (animation) => animation.keyframes,
202
- );
197
+ // An explicit animation id scopes the delete to the lane whose menu was
198
+ // opened; without one this is the layer-wide action, and that means
199
+ // EVERY keyframed tween, not just the first. A layer with position AND
200
+ // opacity keyframes used to leave the second one keyframed, so "Delete
201
+ // All Keyframes" visibly did half the job. A stale id matches nothing
202
+ // and deletes nothing, which is the point: it never falls back to a
203
+ // lane the user did not click.
204
+ const animations = resolveElementAnimations(elementKey);
205
+ const anims = animationId
206
+ ? animations.filter((animation) => animation.id === animationId)
207
+ : animations.filter((animation) => animation.keyframes);
203
208
  if (anims.length === 0) return;
204
209
  void buildDomSelectionForTimelineElement(element).then(async (selection) => {
205
210
  if (!selection) return;
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
2
  import type { CanvasResolution } from "@hyperframes/parsers";
3
3
  import { trackStudioRenderStart } from "../../telemetry/events";
4
4
  import { getAnonymousId } from "../../telemetry/config";
5
+ import { browserTelemetryAllowed } from "../../telemetry/policy";
5
6
  import { generateId } from "../../utils/generateId";
6
7
 
7
8
  export interface RenderJob {
@@ -157,16 +158,28 @@ export function useRenderQueue(projectId: string | null) {
157
158
  resolution?: string;
158
159
  composition?: string;
159
160
  variables?: Record<string, unknown>;
160
- telemetryDistinctId: string;
161
+ telemetryDistinctId?: string;
162
+ telemetryOptOut?: boolean;
161
163
  } = {
162
164
  fps,
163
165
  quality,
164
166
  format,
167
+ };
168
+ // The id is MINTED by getAnonymousId(), so calling it unconditionally
169
+ // created a telemetry identity for a profile that had opted out — and
170
+ // then shipped it to the server. The server's own policy cannot see this
171
+ // browser's localStorage or DoNotTrack, so it has to be told: an
172
+ // explicit `telemetryOptOut` suppresses the render outcome, which
173
+ // omitting the id alone does NOT (an old client omits it too, and that
174
+ // falls back to the install id).
175
+ if (browserTelemetryAllowed()) {
165
176
  // So the server-emitted render_complete/render_error is attributed to
166
177
  // this browser user (same id studio_* events use), making the render
167
178
  // funnel joinable. Matches studio_render_start fired just above.
168
- telemetryDistinctId: getAnonymousId(),
169
- };
179
+ body.telemetryDistinctId = getAnonymousId();
180
+ } else {
181
+ body.telemetryOptOut = true;
182
+ }
170
183
  if (resolution && resolution !== "auto") body.resolution = resolution;
171
184
  if (composition) body.composition = composition;
172
185
  if (opts.variables && Object.keys(opts.variables).length > 0) {
@@ -0,0 +1,104 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // The render request body is the only place the browser can tell the CLI that
4
+ // this profile opted out. The CLI's own policy cannot see localStorage or
5
+ // DoNotTrack in someone else's browser, so if the body says nothing, the
6
+ // server emits render_complete / render_error anyway — attributed to the
7
+ // install id rather than the user's, which is worse than attributing it
8
+ // correctly.
9
+
10
+ import { act } from "react";
11
+ import { createRoot, type Root } from "react-dom/client";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ const policyState = { allowed: true };
15
+ const mintCalls = vi.fn(() => "browser-user-123");
16
+
17
+ vi.mock("../../telemetry/policy", () => ({
18
+ browserTelemetryAllowed: () => policyState.allowed,
19
+ }));
20
+ vi.mock("../../telemetry/config", () => ({
21
+ getAnonymousId: () => mintCalls(),
22
+ }));
23
+ vi.mock("../../telemetry/events", () => ({
24
+ trackStudioRenderStart: vi.fn(),
25
+ }));
26
+
27
+ const { useRenderQueue } = await import("./useRenderQueue");
28
+
29
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
30
+
31
+ let root: Root | null = null;
32
+
33
+ /** Body of the POST the hook makes when a render is started. */
34
+ async function startRenderBody(): Promise<Record<string, unknown>> {
35
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
36
+ Promise.resolve(
37
+ new Response(JSON.stringify({ jobId: "j1", status: "rendering" }), {
38
+ status: 200,
39
+ headers: { "content-type": "application/json" },
40
+ }),
41
+ ),
42
+ );
43
+ vi.stubGlobal("fetch", fetchMock);
44
+ vi.stubGlobal(
45
+ "EventSource",
46
+ class {
47
+ close(): void {}
48
+ addEventListener(): void {}
49
+ },
50
+ );
51
+
52
+ let api: ReturnType<typeof useRenderQueue> | null = null;
53
+ function Harness(): null {
54
+ api = useRenderQueue("demo");
55
+ return null;
56
+ }
57
+ const host = document.createElement("div");
58
+ document.body.append(host);
59
+ root = createRoot(host);
60
+ act(() => {
61
+ root?.render(<Harness />);
62
+ });
63
+ await act(async () => {
64
+ await api?.startRender({ fps: 30, quality: "standard", format: "mp4" });
65
+ });
66
+
67
+ const post = fetchMock.mock.calls.find(([, init]) => init?.method === "POST");
68
+ const body = post?.[1]?.body;
69
+ if (body === undefined || body === null) throw new Error("hook made no POST with a body");
70
+ return JSON.parse(String(body)) as Record<string, unknown>;
71
+ }
72
+
73
+ beforeEach(() => {
74
+ policyState.allowed = true;
75
+ mintCalls.mockClear();
76
+ });
77
+
78
+ afterEach(() => {
79
+ if (root) act(() => root?.unmount());
80
+ root = null;
81
+ document.body.innerHTML = "";
82
+ vi.unstubAllGlobals();
83
+ });
84
+
85
+ describe("render request telemetry fields", () => {
86
+ it("sends the browser id when this profile allows telemetry", async () => {
87
+ const body = await startRenderBody();
88
+ expect(body["telemetryDistinctId"]).toBe("browser-user-123");
89
+ expect(body["telemetryOptOut"]).toBeUndefined();
90
+ });
91
+
92
+ it("mints no id and says so explicitly when the profile opted out", async () => {
93
+ policyState.allowed = false;
94
+ const body = await startRenderBody();
95
+ // Minting is itself the leak: getAnonymousId() CREATES and persists an
96
+ // identity, so calling it for an opted-out profile is wrong even if the
97
+ // value were never sent.
98
+ expect(mintCalls).not.toHaveBeenCalled();
99
+ expect(body["telemetryDistinctId"]).toBeUndefined();
100
+ // Omission alone would be read as an old client and fall back to the
101
+ // install id — the flag is what actually suppresses the server event.
102
+ expect(body["telemetryOptOut"]).toBe(true);
103
+ });
104
+ });
@@ -200,8 +200,8 @@ export function DomEditProvider({
200
200
  commitMutation,
201
201
  applyMarqueeSelection,
202
202
  handleUpdateKeyframeEase,
203
- handleSetAllKeyframeEases,
204
203
  handleUpdateSegmentEase,
204
+ handleSetAllKeyframeEases,
205
205
  },
206
206
  children,
207
207
  }: {
@@ -57,9 +57,9 @@ const recordResolverParity = vi.fn<(...args: unknown[]) => Promise<void>>(async
57
57
  const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined } = {
58
58
  fn: undefined,
59
59
  };
60
-
61
60
  const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
62
61
  const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
62
+
63
63
  vi.mock("../utils/sdkResolverShadow", () => ({
64
64
  runResolverShadow: vi.fn(),
65
65
  recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
@@ -537,11 +537,11 @@ export function useDomEditSession({
537
537
  handleGsapRemoveAllKeyframes,
538
538
  handleResetSelectedElementKeyframes,
539
539
  handleUpdateKeyframeEase,
540
+ handleUpdateSegmentEase,
540
541
  handleSetAllKeyframeEases,
541
542
  commitAnimatedProperty,
542
543
  commitAnimatedProperties,
543
544
  handleSetArcPath,
544
- handleUpdateSegmentEase,
545
545
  handleUpdateArcSegment,
546
546
  handleUnroll,
547
547
  invalidateGsapCache: bumpGsapCache,
@@ -7,9 +7,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
8
 
9
9
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
+ import type { KeyframeCacheEntry } from "../player/store/playerStore";
11
+ import { usePlayerStore } from "../player/store/playerStore";
10
12
  import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
11
13
 
12
14
  type HookApi = ReturnType<typeof useGsapKeyframeOps>;
15
+ type CommitResult = { ok: boolean; changed: boolean };
16
+ type CommitOptions = { onResult?: (result: CommitResult) => void };
17
+ type CommitCall = [selection: unknown, mutation: unknown, options: CommitOptions];
18
+
19
+ function readCommitOptions(args: unknown[]): CommitOptions {
20
+ // The hook accepts the production writer type; these test doubles expose only
21
+ // the third tuple member they exercise.
22
+ return (args as unknown as CommitCall)[2];
23
+ }
13
24
 
14
25
  let cleanup: (() => void) | null = null;
15
26
  afterEach(() => {
@@ -21,15 +32,14 @@ const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSe
21
32
 
22
33
  function successfulCommitMutation() {
23
34
  return vi.fn<(...args: unknown[]) => Promise<unknown>>(async (...args) => {
24
- const options = args[2] as {
25
- onResult?: (result: { ok: boolean; changed: boolean }) => void;
26
- };
35
+ const options = readCommitOptions(args);
27
36
  options.onResult?.({ ok: true, changed: true });
28
37
  });
29
38
  }
30
39
 
31
40
  function renderKeyframeOps(over: {
32
41
  commitMutation: (...args: unknown[]) => Promise<unknown>;
42
+ commitMutationSafely?: (...args: unknown[]) => Promise<void>;
33
43
  trackGsapSaveFailure: (...args: unknown[]) => void;
34
44
  }) {
35
45
  const captured: { api: HookApi | null } = { api: null };
@@ -41,7 +51,7 @@ function renderKeyframeOps(over: {
41
51
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
42
52
  commitMutation: over.commitMutation as any,
43
53
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
44
- commitMutationSafely: (() => {}) as any,
54
+ commitMutationSafely: (over.commitMutationSafely ?? (async () => {})) as any,
45
55
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
46
56
  trackGsapSaveFailure: over.trackGsapSaveFailure as any,
47
57
  sdkSession: null,
@@ -135,7 +145,7 @@ describe("useGsapKeyframeOps — moveKeyframe settlement", () => {
135
145
 
136
146
  it("returns false when the writer accepts but does not change the keyframe", async () => {
137
147
  const commitMutation = vi.fn(async (...args: unknown[]) => {
138
- const options = args[2] as { onResult?: (result: { ok: boolean; changed: boolean }) => void };
148
+ const options = readCommitOptions(args);
139
149
  options.onResult?.({ ok: true, changed: false });
140
150
  });
141
151
  const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
@@ -203,6 +213,74 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => {
203
213
  );
204
214
  });
205
215
 
216
+ it("clears delete-all cache only when persistence confirms the change", async () => {
217
+ let finishCommit: (() => void) | undefined;
218
+ const commitMutationSafely = vi.fn(
219
+ (...args: unknown[]) =>
220
+ new Promise<void>((resolve) => {
221
+ const options = readCommitOptions(args);
222
+ finishCommit = () => {
223
+ options.onResult?.({ ok: true, changed: true });
224
+ resolve();
225
+ };
226
+ }),
227
+ );
228
+ const api = renderKeyframeOps({
229
+ commitMutation: successfulCommitMutation(),
230
+ commitMutationSafely,
231
+ trackGsapSaveFailure: vi.fn(),
232
+ });
233
+ const cached: KeyframeCacheEntry = {
234
+ format: "percentage",
235
+ keyframes: [
236
+ { percentage: 0, properties: { x: 0 } },
237
+ { percentage: 100, properties: { x: 200 } },
238
+ ],
239
+ };
240
+ usePlayerStore.setState({ keyframeCache: new Map([["index.html#box", cached]]) });
241
+
242
+ const pending = api.removeAllKeyframes(selection, "box-to-0-position");
243
+ expect(commitMutationSafely).toHaveBeenCalledWith(
244
+ selection,
245
+ { type: "remove-all-keyframes", animationId: "box-to-0-position" },
246
+ expect.objectContaining({ label: "Remove all keyframes", softReload: true }),
247
+ );
248
+ expect(usePlayerStore.getState().keyframeCache.get("index.html#box")).toBe(cached);
249
+
250
+ finishCommit?.();
251
+ await pending;
252
+ expect(usePlayerStore.getState().keyframeCache.has("index.html#box")).toBe(false);
253
+ });
254
+
255
+ it("clears the live DOM identity for a selector-only selection", async () => {
256
+ const element = document.createElement("div");
257
+ element.id = "box";
258
+ const selectorOnlySelection: DomEditSelection = {
259
+ ...selection,
260
+ id: undefined,
261
+ selector: ".box",
262
+ element,
263
+ };
264
+ const commitMutationSafely = vi.fn(async (...args: unknown[]) => {
265
+ const options = readCommitOptions(args);
266
+ options.onResult?.({ ok: true, changed: true });
267
+ });
268
+ const api = renderKeyframeOps({
269
+ commitMutation: successfulCommitMutation(),
270
+ commitMutationSafely,
271
+ trackGsapSaveFailure: vi.fn(),
272
+ });
273
+ const cached: KeyframeCacheEntry = {
274
+ format: "percentage",
275
+ keyframes: [{ percentage: 0, properties: { x: 0 } }],
276
+ };
277
+ usePlayerStore.setState({ keyframeCache: new Map([["index.html#box", cached]]) });
278
+
279
+ await api.removeAllKeyframes(selectorOnlySelection, "box-to-0-position");
280
+
281
+ expect(usePlayerStore.getState().keyframeCache.has("index.html#box")).toBe(false);
282
+ });
283
+
206
284
  it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => {
207
285
  const commitMutation = successfulCommitMutation();
208
286
  const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
@@ -15,7 +15,6 @@ import {
15
15
  } from "../utils/sdkCutover";
16
16
  import type { KeyframeCacheEntry } from "../player/store/playerStore";
17
17
  import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
18
- import { idFromSelector } from "./gsapShared";
19
18
  import {
20
19
  clearKeyframeCacheForElement,
21
20
  readKeyframeSnapshot,
@@ -336,11 +335,9 @@ export function useGsapKeyframeOps({
336
335
  const removeAllKeyframes = useCallback(
337
336
  async (selection: DomEditSelection, animationId: string) => {
338
337
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
339
- // remove-all-keyframes collapses the tween to a static hold and the commit
340
- // path doesn't return parsed animations, so the keyframe cache is never
341
- // refreshed clear it here so the timeline diamonds disappear immediately.
342
- const elementId = selection.id ?? idFromSelector(selection.selector);
343
- if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
338
+ // A class/descendant selector can resolve a live element whose selection
339
+ // deliberately has no id. The cache is still keyed by that DOM id.
340
+ const cacheElementId = selection.id || selection.element?.id;
344
341
  if (sdkSession && sdkDeps) {
345
342
  const handled = await sdkGsapRemoveAllKeyframesPersist(
346
343
  targetPath,
@@ -349,12 +346,26 @@ export function useGsapKeyframeOps({
349
346
  sdkDeps,
350
347
  { label: "Remove all keyframes" },
351
348
  );
352
- if (cutoverCommittedOrThrow(handled)) return;
349
+ if (cutoverCommittedOrThrow(handled)) {
350
+ if (cacheElementId) clearKeyframeCacheForElement(targetPath, cacheElementId);
351
+ return;
352
+ }
353
353
  }
354
- commitMutationSafely(
354
+ await commitMutationSafely(
355
355
  selection,
356
356
  { type: "remove-all-keyframes", animationId },
357
- { label: "Remove all keyframes", softReload: true },
357
+ {
358
+ label: "Remove all keyframes",
359
+ softReload: true,
360
+ // The committed result is the single success boundary: clearing
361
+ // before it makes failed saves lie, while waiting for the reload leaves
362
+ // stale diamonds visible during the source round-trip.
363
+ onResult: (result) => {
364
+ if (result.changed !== false && cacheElementId) {
365
+ clearKeyframeCacheForElement(targetPath, cacheElementId);
366
+ }
367
+ },
368
+ },
358
369
  );
359
370
  },
360
371
  [commitMutationSafely, activeCompPath, sdkSession, sdkDeps],