@hyperframes/studio 0.7.17 → 0.7.19

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 (44) hide show
  1. package/dist/assets/hyperframes-player-DNLS_l47.js +459 -0
  2. package/dist/assets/{index-BDx28x9R.js → index-CURAsFZX.js} +1 -1
  3. package/dist/assets/index-DXWeH-EY.js +396 -0
  4. package/dist/assets/{index-BCAdHPz_.js → index-hueu10iU.js} +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1446 -866
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -7
  10. package/src/App.tsx +2 -1
  11. package/src/components/StudioPreviewArea.tsx +92 -17
  12. package/src/components/TimelineToolbar.tsx +8 -0
  13. package/src/components/editor/MotionPathOverlay.tsx +4 -2
  14. package/src/components/editor/keyframeDrag.test.ts +195 -0
  15. package/src/components/editor/keyframeDrag.ts +105 -0
  16. package/src/components/editor/keyframeRetime.test.ts +140 -0
  17. package/src/components/editor/keyframeRetime.ts +121 -0
  18. package/src/contexts/DomEditContext.tsx +12 -0
  19. package/src/contexts/TimelineEditContext.tsx +2 -0
  20. package/src/hooks/gsapDragCommit.ts +28 -1
  21. package/src/hooks/gsapPositionDetection.ts +98 -0
  22. package/src/hooks/gsapRuntimeBridge.test.ts +33 -0
  23. package/src/hooks/gsapRuntimeBridge.ts +41 -97
  24. package/src/hooks/useDomEditSession.ts +22 -0
  25. package/src/hooks/useDomEditWiring.ts +17 -0
  26. package/src/hooks/useDomSelection.ts +4 -1
  27. package/src/hooks/useGsapKeyframeOps.test.tsx +101 -0
  28. package/src/hooks/useGsapKeyframeOps.ts +63 -2
  29. package/src/hooks/useGsapSelectionHandlers.ts +72 -1
  30. package/src/hooks/useGsapTweenCache.ts +16 -7
  31. package/src/hooks/useKeyframeKeyboard.ts +29 -32
  32. package/src/hooks/useStudioSelectionPublisher.ts +102 -0
  33. package/src/player/components/KeyframeDiamondContextMenu.tsx +21 -2
  34. package/src/player/components/PlayerControls.tsx +40 -13
  35. package/src/player/components/Timeline.tsx +8 -0
  36. package/src/player/components/TimelineCanvas.tsx +7 -0
  37. package/src/player/components/TimelineClipDiamonds.tsx +109 -17
  38. package/src/player/components/timelineCallbacks.ts +6 -0
  39. package/src/utils/gsapSoftReload.test.ts +29 -0
  40. package/src/utils/gsapSoftReload.ts +19 -0
  41. package/src/utils/studioSelectionSnapshot.test.ts +47 -0
  42. package/src/utils/studioSelectionSnapshot.ts +72 -0
  43. package/dist/assets/hyperframes-player-BGW5hpsb.js +0 -425
  44. package/dist/assets/index-hiqLDAtz.js +0 -375
@@ -18,6 +18,7 @@ import { useGsapScriptCommits } from "./useGsapScriptCommits";
18
18
  import { useGsapCacheVersion } from "./useGsapTweenCache";
19
19
  import { useDomEditWiring } from "./useDomEditWiring";
20
20
  import { useGsapAwareEditing } from "./useGsapAwareEditing";
21
+ import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
21
22
 
22
23
  // ── Types ──
23
24
 
@@ -54,6 +55,7 @@ export interface UseDomEditSessionParams {
54
55
  projectIdRef: React.MutableRefObject<string | null>;
55
56
  previewIframe: HTMLIFrameElement | null;
56
57
  refreshKey: number;
58
+ previewDocumentVersion: number;
57
59
  rightPanelTab: RightPanelTab;
58
60
  applyStudioManualEditsToPreviewRef: React.MutableRefObject<
59
61
  (iframe: HTMLIFrameElement) => Promise<void>
@@ -96,6 +98,7 @@ export function useDomEditSession({
96
98
  projectIdRef,
97
99
  previewIframe,
98
100
  refreshKey,
101
+ previewDocumentVersion,
99
102
  rightPanelTab,
100
103
  applyStudioManualEditsToPreviewRef,
101
104
  syncPreviewHistoryHotkey,
@@ -168,6 +171,15 @@ export function useDomEditSession({
168
171
  domEditSelection,
169
172
  });
170
173
 
174
+ useStudioSelectionPublisher({
175
+ projectId,
176
+ domEditSelection,
177
+ domEditSelectionRef,
178
+ refreshKey,
179
+ previewDocumentVersion,
180
+ refreshDomEditSelectionFromPreview,
181
+ });
182
+
171
183
  // ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
172
184
 
173
185
  const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
@@ -189,6 +201,8 @@ export function useDomEditSession({
189
201
  addKeyframe,
190
202
  addKeyframeBatch,
191
203
  removeKeyframe,
204
+ moveKeyframe,
205
+ resizeKeyframedTween,
192
206
  convertToKeyframes,
193
207
  removeAllKeyframes,
194
208
  setArcPath,
@@ -344,6 +358,9 @@ export function useDomEditSession({
344
358
  handleGsapAddKeyframe,
345
359
  handleGsapAddKeyframeBatch,
346
360
  handleGsapRemoveKeyframe,
361
+ handleGsapMoveKeyframeToPlayhead,
362
+ handleGsapMoveKeyframe,
363
+ handleGsapResizeKeyframedTween,
347
364
  handleGsapConvertToKeyframes,
348
365
  handleGsapRemoveAllKeyframes,
349
366
  handleResetSelectedElementKeyframes,
@@ -381,6 +398,8 @@ export function useDomEditSession({
381
398
  addKeyframe,
382
399
  addKeyframeBatch,
383
400
  removeKeyframe,
401
+ moveKeyframe,
402
+ resizeKeyframedTween,
384
403
  convertToKeyframes,
385
404
  removeAllKeyframes,
386
405
  handleDomManualEditsReset,
@@ -541,6 +560,9 @@ export function useDomEditSession({
541
560
  handleGsapAddKeyframe,
542
561
  handleGsapAddKeyframeBatch,
543
562
  handleGsapRemoveKeyframe,
563
+ handleGsapMoveKeyframeToPlayhead,
564
+ handleGsapMoveKeyframe,
565
+ handleGsapResizeKeyframedTween,
544
566
  handleGsapConvertToKeyframes,
545
567
  handleGsapRemoveAllKeyframes,
546
568
  handleResetSelectedElementKeyframes,
@@ -87,6 +87,19 @@ export interface UseDomEditWiringParams {
87
87
  properties: Record<string, number | string>,
88
88
  ) => Promise<void>;
89
89
  removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
90
+ moveKeyframe: (
91
+ sel: DomEditSelection,
92
+ animId: string,
93
+ fromPercentage: number,
94
+ toPercentage: number,
95
+ ) => void;
96
+ resizeKeyframedTween: (
97
+ sel: DomEditSelection,
98
+ animId: string,
99
+ position: number,
100
+ duration: number,
101
+ pctRemap: Array<{ from: number; to: number }>,
102
+ ) => void;
90
103
  convertToKeyframes: (
91
104
  sel: DomEditSelection,
92
105
  animId: string,
@@ -131,6 +144,8 @@ export function useDomEditWiring({
131
144
  addKeyframe,
132
145
  addKeyframeBatch,
133
146
  removeKeyframe,
147
+ moveKeyframe,
148
+ resizeKeyframedTween,
134
149
  convertToKeyframes,
135
150
  removeAllKeyframes,
136
151
  handleDomManualEditsReset,
@@ -224,6 +239,8 @@ export function useDomEditWiring({
224
239
  addKeyframe,
225
240
  addKeyframeBatch,
226
241
  removeKeyframe,
242
+ moveKeyframe,
243
+ resizeKeyframedTween,
227
244
  convertToKeyframes,
228
245
  removeAllKeyframes,
229
246
  handleDomManualEditsReset,
@@ -381,7 +381,10 @@ export function useDomSelection({
381
381
  if (!doc) return;
382
382
 
383
383
  const element = findElementForSelection(doc, selection, activeCompPath);
384
- if (!element) return;
384
+ if (!element) {
385
+ applyDomSelection(null, { revealPanel: false });
386
+ return;
387
+ }
385
388
 
386
389
  const nextSelection = await buildDomSelectionFromTarget(element);
387
390
  if (nextSelection) {
@@ -0,0 +1,101 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ // Tell React this is an act-capable environment so act(...) flushes effects.
7
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
+
9
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
+ import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
11
+
12
+ type HookApi = ReturnType<typeof useGsapKeyframeOps>;
13
+
14
+ let cleanup: (() => void) | null = null;
15
+ afterEach(() => {
16
+ cleanup?.();
17
+ cleanup = null;
18
+ });
19
+
20
+ const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection;
21
+
22
+ function renderKeyframeOps(over: {
23
+ commitMutation: (...args: unknown[]) => Promise<unknown>;
24
+ trackGsapSaveFailure: (...args: unknown[]) => void;
25
+ }) {
26
+ const captured: { api: HookApi | null } = { api: null };
27
+ function Probe() {
28
+ captured.api = useGsapKeyframeOps({
29
+ activeCompPath: "index.html",
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
31
+ commitMutation: over.commitMutation as any,
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
33
+ commitMutationSafely: (() => {}) as any,
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles
35
+ trackGsapSaveFailure: over.trackGsapSaveFailure as any,
36
+ sdkSession: null,
37
+ sdkDeps: null,
38
+ });
39
+ return null;
40
+ }
41
+ const container = document.createElement("div");
42
+ const root = createRoot(container);
43
+ act(() => {
44
+ root.render(<Probe />);
45
+ });
46
+ cleanup = () => act(() => root.unmount());
47
+ if (!captured.api) throw new Error("hook did not initialize");
48
+ return captured.api;
49
+ }
50
+
51
+ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
52
+ it("issues a resize-keyframed-tween mutation with the remap + window", async () => {
53
+ const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
54
+ ok: true,
55
+ }));
56
+ const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
57
+ const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
58
+
59
+ const pctRemap = [
60
+ { from: 0, to: 0 },
61
+ { from: 100, to: 100 },
62
+ ];
63
+ await act(async () => {
64
+ api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
65
+ });
66
+
67
+ expect(commitMutation).toHaveBeenCalledTimes(1);
68
+ const [sel, mutation] = commitMutation.mock.calls[0]!;
69
+ expect(sel).toBe(selection);
70
+ expect(mutation).toEqual({
71
+ type: "resize-keyframed-tween",
72
+ animationId: "box-to-0-opacity",
73
+ position: 0.2,
74
+ duration: 2,
75
+ pctRemap,
76
+ });
77
+ expect(trackGsapSaveFailure).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("routes a rejected commit to trackGsapSaveFailure (no unhandled rejection)", async () => {
81
+ const error = new Error("network down");
82
+ const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => {
83
+ throw error;
84
+ });
85
+ const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
86
+ const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
87
+
88
+ await act(async () => {
89
+ api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [{ from: 100, to: 100 }]);
90
+ // let the rejected commit promise settle inside act
91
+ await Promise.resolve();
92
+ });
93
+
94
+ expect(trackGsapSaveFailure).toHaveBeenCalledTimes(1);
95
+ const [errArg, selArg, mutationArg, labelArg] = trackGsapSaveFailure.mock.calls[0]!;
96
+ expect(errArg).toBe(error);
97
+ expect(selArg).toBe(selection);
98
+ expect((mutationArg as { type: string }).type).toBe("resize-keyframed-tween");
99
+ expect(labelArg).toBe("Retime keyframe (resize tween)");
100
+ });
101
+ });
@@ -12,7 +12,11 @@ import {
12
12
  } from "../utils/sdkCutover";
13
13
  import type { KeyframeCacheEntry } from "../player/store/playerStore";
14
14
  import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
15
- import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
15
+ import {
16
+ clearKeyframeCacheForElement,
17
+ readKeyframeSnapshot,
18
+ writeKeyframeCache,
19
+ } from "./gsapKeyframeCacheHelpers";
16
20
  import type {
17
21
  CommitMutation,
18
22
  SafeGsapCommitMutation,
@@ -201,6 +205,56 @@ export function useGsapKeyframeOps({
201
205
  [activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
202
206
  );
203
207
 
208
+ const moveKeyframe = useCallback(
209
+ (
210
+ selection: DomEditSelection,
211
+ animationId: string,
212
+ fromPercentage: number,
213
+ toPercentage: number,
214
+ ) => {
215
+ const mutation = { type: "move-keyframe", animationId, fromPercentage, toPercentage };
216
+ // No SDK persist helper exists for retime — server path only. The post-commit
217
+ // updateKeyframeCacheFromParsed re-keys the diamond from the fresh parse, so no
218
+ // optimistic cache write is needed (mapping the tween-% to clip-% here would
219
+ // duplicate that math). softReload mirrors remove-keyframe.
220
+ void commitMutation(selection, mutation, {
221
+ label: `Move keyframe to ${toPercentage}%`,
222
+ softReload: true,
223
+ }).catch((error) => {
224
+ trackGsapSaveFailure(error, selection, mutation, `Move keyframe to ${toPercentage}%`);
225
+ });
226
+ },
227
+ [commitMutation, trackGsapSaveFailure],
228
+ );
229
+
230
+ const resizeKeyframedTween = useCallback(
231
+ (
232
+ selection: DomEditSelection,
233
+ animationId: string,
234
+ position: number,
235
+ duration: number,
236
+ pctRemap: Array<{ from: number; to: number }>,
237
+ ) => {
238
+ const mutation = {
239
+ type: "resize-keyframed-tween",
240
+ animationId,
241
+ position,
242
+ duration,
243
+ pctRemap,
244
+ };
245
+ // Boundary drag-to-retime: the server re-keys keyframes in place + grows the
246
+ // tween window, preserving _auto / per-keyframe ease / easeEach / outer ease.
247
+ // softReload re-keys the diamonds from the fresh parse (mirrors moveKeyframe).
248
+ void commitMutation(selection, mutation, {
249
+ label: "Retime keyframe (resize tween)",
250
+ softReload: true,
251
+ }).catch((error) => {
252
+ trackGsapSaveFailure(error, selection, mutation, "Retime keyframe (resize tween)");
253
+ });
254
+ },
255
+ [commitMutation, trackGsapSaveFailure],
256
+ );
257
+
204
258
  const convertToKeyframes = useCallback(
205
259
  async (
206
260
  selection: DomEditSelection,
@@ -233,8 +287,13 @@ export function useGsapKeyframeOps({
233
287
 
234
288
  const removeAllKeyframes = useCallback(
235
289
  async (selection: DomEditSelection, animationId: string) => {
290
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
291
+ // remove-all-keyframes collapses the tween to a static hold and the commit
292
+ // path doesn't return parsed animations, so the keyframe cache is never
293
+ // refreshed — clear it here so the timeline diamonds disappear immediately.
294
+ const elementId = selection.id ?? selection.selector?.match(/^#([\w-]+)/)?.[1] ?? null;
295
+ if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
236
296
  if (sdkSession && sdkDeps) {
237
- const targetPath = selection.sourceFile || activeCompPath || "index.html";
238
297
  const handled = await sdkGsapRemoveAllKeyframesPersist(
239
298
  targetPath,
240
299
  animationId,
@@ -267,6 +326,8 @@ export function useGsapKeyframeOps({
267
326
  addKeyframe,
268
327
  addKeyframeBatch,
269
328
  removeKeyframe,
329
+ moveKeyframe,
330
+ resizeKeyframedTween,
270
331
  convertToKeyframes,
271
332
  removeAllKeyframes,
272
333
  commitKeyframeAtTime,
@@ -1,6 +1,8 @@
1
1
  import { useCallback, useRef } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
3
  import type { DomEditSelection } from "../components/editor/domEditing";
3
4
  import { usePlayerStore } from "../player";
5
+ import { computeCurrentPercentage } from "./gsapDragCommit";
4
6
  import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
5
7
  import { trackStudioEvent } from "../utils/studioTelemetry";
6
8
 
@@ -25,6 +27,8 @@ export function useGsapSelectionHandlers({
25
27
  addKeyframe,
26
28
  addKeyframeBatch,
27
29
  removeKeyframe,
30
+ moveKeyframe,
31
+ resizeKeyframedTween,
28
32
  convertToKeyframes,
29
33
  removeAllKeyframes,
30
34
  handleDomManualEditsReset,
@@ -73,6 +77,19 @@ export function useGsapSelectionHandlers({
73
77
  properties: Record<string, number | string>,
74
78
  ) => Promise<void>;
75
79
  removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
80
+ moveKeyframe: (
81
+ sel: DomEditSelection,
82
+ animId: string,
83
+ fromPercentage: number,
84
+ toPercentage: number,
85
+ ) => void;
86
+ resizeKeyframedTween: (
87
+ sel: DomEditSelection,
88
+ animId: string,
89
+ position: number,
90
+ duration: number,
91
+ pctRemap: Array<{ from: number; to: number }>,
92
+ ) => void;
76
93
  convertToKeyframes: (
77
94
  sel: DomEditSelection,
78
95
  animId: string,
@@ -82,7 +99,7 @@ export function useGsapSelectionHandlers({
82
99
  removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
83
100
 
84
101
  handleDomManualEditsReset: (sel: DomEditSelection) => void;
85
- selectedGsapAnimations: { id: string; keyframes?: unknown }[];
102
+ selectedGsapAnimations: GsapAnimation[];
86
103
  }) {
87
104
  const lastSelectionRef = useRef<DomEditSelection | null>(null);
88
105
  if (domEditSelection) lastSelectionRef.current = domEditSelection;
@@ -233,6 +250,57 @@ export function useGsapSelectionHandlers({
233
250
  [domEditSelection, removeKeyframe],
234
251
  );
235
252
 
253
+ const handleGsapMoveKeyframeToPlayhead = useCallback(
254
+ (animId: string, fromPercentage: number, selectionOverride?: DomEditSelection | null) => {
255
+ const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
256
+ if (!sel) return;
257
+ // Retime the keyframe to the playhead, preserving its value + ease. The
258
+ // playhead's tween-relative percentage is the move target.
259
+ const anim = selectedGsapAnimations.find((a) => a.id === animId);
260
+ const toPercentage = computeCurrentPercentage(sel, anim);
261
+ trackStudioEvent("keyframe", { action: "move_to_playhead" });
262
+ moveKeyframe(sel, animId, fromPercentage, toPercentage);
263
+ },
264
+ [domEditSelection, selectedGsapAnimations, moveKeyframe],
265
+ );
266
+
267
+ const handleGsapMoveKeyframe = useCallback(
268
+ (
269
+ animId: string,
270
+ fromPercentage: number,
271
+ toPercentage: number,
272
+ selectionOverride?: DomEditSelection | null,
273
+ ) => {
274
+ const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
275
+ if (!sel) return;
276
+ // Atomic retime: preserves the keyframe's value + per-keyframe ease. Both
277
+ // percentages are tween-relative (the drag handler converts the drop
278
+ // position before calling). No optimistic runtime hold — the soft-reload
279
+ // re-keys the diamond from source.
280
+ trackStudioEvent("keyframe", { action: "retime" });
281
+ moveKeyframe(sel, animId, fromPercentage, toPercentage);
282
+ },
283
+ [domEditSelection, moveKeyframe],
284
+ );
285
+
286
+ const handleGsapResizeKeyframedTween = useCallback(
287
+ (
288
+ animId: string,
289
+ position: number,
290
+ duration: number,
291
+ pctRemap: Array<{ from: number; to: number }>,
292
+ selectionOverride?: DomEditSelection | null,
293
+ ) => {
294
+ const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
295
+ if (!sel) return;
296
+ // Boundary drag-to-retime: grows/shifts the tween window + re-keys keyframes
297
+ // in place. Distinct telemetry action so resize is separable from in-window move.
298
+ trackStudioEvent("keyframe", { action: "retime_resize" });
299
+ resizeKeyframedTween(sel, animId, position, duration, pctRemap);
300
+ },
301
+ [domEditSelection, resizeKeyframedTween],
302
+ );
303
+
236
304
  const handleGsapConvertToKeyframes = useCallback(
237
305
  (animId: string, resolvedFromValues?: Record<string, number | string>, duration?: number) => {
238
306
  if (!domEditSelection) return Promise.resolve();
@@ -280,6 +348,9 @@ export function useGsapSelectionHandlers({
280
348
  handleGsapAddKeyframe,
281
349
  handleGsapAddKeyframeBatch,
282
350
  handleGsapRemoveKeyframe,
351
+ handleGsapMoveKeyframeToPlayhead,
352
+ handleGsapMoveKeyframe,
353
+ handleGsapResizeKeyframedTween,
283
354
  handleGsapConvertToKeyframes,
284
355
  handleGsapRemoveAllKeyframes,
285
356
  handleResetSelectedElementKeyframes,
@@ -128,7 +128,7 @@ export async function fetchParsedAnimations(
128
128
  * Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
129
129
  * data-composition-src is stripped in the rendered DOM, so we can't query it).
130
130
  */
131
- function resolveClipTimingBasis(
131
+ export function resolveClipTimingBasis(
132
132
  elementId: string,
133
133
  sourceFile: string,
134
134
  elements: ReadonlyArray<{
@@ -346,9 +346,16 @@ export function useGsapAnimationsForElement(
346
346
  let ease: string | undefined;
347
347
  let easeEach: string | undefined;
348
348
  for (const anim of animations) {
349
+ // A static position hold (only x/y, no real motion) is a `set`, not a
350
+ // keyframe — don't synthesize a diamond for it. Covers both `tl.set(...)`
351
+ // and the `tl.to({ duration: 0, immediateRender: true })` hold that
352
+ // remove-all-keyframes collapses to (which is otherwise shown as a stray
353
+ // 0% keyframe).
349
354
  if (
350
- anim.method === "set" &&
351
- Object.keys(anim.properties).every((k) => k === "x" || k === "y")
355
+ !anim.keyframes &&
356
+ Object.keys(anim.properties).length > 0 &&
357
+ Object.keys(anim.properties).every((k) => k === "x" || k === "y") &&
358
+ (anim.method === "set" || (anim.duration ?? 0) === 0)
352
359
  )
353
360
  continue;
354
361
  const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
@@ -454,11 +461,13 @@ export function usePopulateKeyframeCacheForFile(
454
461
  const mergedByElement = new Map<string, GsapKeyframesData>();
455
462
  for (const anim of parsed.animations) {
456
463
  if (anim.hasUnresolvedKeyframes) continue;
457
- // Position-only set tweens are static holds (created by drag), not
458
- // keyframed animations skip them so they don't show timeline diamonds.
459
- if (anim.method === "set") {
464
+ // Position-only static holds are not keyframed animations skip them so
465
+ // they don't draw a timeline diamond. Covers both a `tl.set(...)` and the
466
+ // `tl.to({ duration: 0, immediateRender: true })` that remove-all-keyframes
467
+ // collapses a keyframed tween to.
468
+ if (!anim.keyframes && (anim.method === "set" || (anim.duration ?? 0) === 0)) {
460
469
  const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
461
- if (propKeys.every((k) => k === "x" || k === "y")) {
470
+ if (propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y")) {
462
471
  continue;
463
472
  }
464
473
  }
@@ -33,53 +33,48 @@ export function useKeyframeKeyboard({
33
33
  (e: KeyboardEvent) => {
34
34
  if (!enabled) return;
35
35
  if (isTextInput(document.activeElement)) return;
36
+ if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
36
37
 
37
38
  const hasSelectedKeyframes = usePlayerStore.getState().selectedKeyframes.size > 0;
38
39
 
40
+ // Only consume a key we can actually act on. The fall-through matters:
41
+ // these keys (k/j/arrows) double as JKL playback shortcuts in
42
+ // usePlaybackKeyboard, so when a handler is absent we must let the event
43
+ // continue. When we DO act, stopImmediatePropagation prevents the playback
44
+ // handler from also firing (e.g. K pausing instead of adding a keyframe).
45
+ // The listener is registered in the capture phase so it runs first.
46
+ const consume = (run: () => void) => {
47
+ e.preventDefault();
48
+ e.stopImmediatePropagation();
49
+ run();
50
+ };
51
+
39
52
  switch (e.key.toLowerCase()) {
40
53
  case "k":
41
- if (!e.metaKey && !e.ctrlKey) {
42
- e.preventDefault();
43
- onAddKeyframe?.();
44
- }
54
+ if (onAddKeyframe) consume(onAddKeyframe);
45
55
  break;
46
56
  case "delete":
47
57
  case "backspace":
48
- if (hasSelectedKeyframes) {
49
- e.preventDefault();
50
- onDeleteKeyframe?.();
51
- }
58
+ if (onDeleteKeyframe && hasSelectedKeyframes) consume(onDeleteKeyframe);
52
59
  break;
53
- case "j":
54
- if (!e.metaKey && !e.ctrlKey) {
55
- e.preventDefault();
56
- if (e.shiftKey) onNextKeyframe?.();
57
- else onPrevKeyframe?.();
58
- }
60
+ case "j": {
61
+ const nav = e.shiftKey ? onNextKeyframe : onPrevKeyframe;
62
+ if (nav) consume(nav);
59
63
  break;
64
+ }
60
65
  case "h":
61
- if (!e.metaKey && !e.ctrlKey && hasSelectedKeyframes) {
62
- e.preventDefault();
63
- onToggleHold?.();
64
- }
66
+ if (onToggleHold && hasSelectedKeyframes) consume(onToggleHold);
65
67
  break;
66
68
  case "u":
67
- if (!e.metaKey && !e.ctrlKey) {
68
- e.preventDefault();
69
- onToggleExpand?.();
70
- }
69
+ if (onToggleExpand) consume(onToggleExpand);
71
70
  break;
72
71
  case "arrowleft":
73
- if (hasSelectedKeyframes && !e.metaKey && !e.ctrlKey && !e.altKey) {
74
- e.preventDefault();
75
- onNudgeKeyframe?.(-1, e.shiftKey);
76
- }
72
+ if (onNudgeKeyframe && hasSelectedKeyframes && !e.altKey)
73
+ consume(() => onNudgeKeyframe(-1, e.shiftKey));
77
74
  break;
78
75
  case "arrowright":
79
- if (hasSelectedKeyframes && !e.metaKey && !e.ctrlKey && !e.altKey) {
80
- e.preventDefault();
81
- onNudgeKeyframe?.(1, e.shiftKey);
82
- }
76
+ if (onNudgeKeyframe && hasSelectedKeyframes && !e.altKey)
77
+ consume(() => onNudgeKeyframe(1, e.shiftKey));
83
78
  break;
84
79
  }
85
80
  },
@@ -97,7 +92,9 @@ export function useKeyframeKeyboard({
97
92
 
98
93
  useEffect(() => {
99
94
  if (!enabled) return;
100
- window.addEventListener("keydown", handler);
101
- return () => window.removeEventListener("keydown", handler);
95
+ // Capture phase: run before usePlaybackKeyboard's (bubble-phase) JKL handler
96
+ // so an active keyframe shortcut can claim the key.
97
+ window.addEventListener("keydown", handler, { capture: true });
98
+ return () => window.removeEventListener("keydown", handler, { capture: true });
102
99
  }, [enabled, handler]);
103
100
  }
@@ -0,0 +1,102 @@
1
+ import { useEffect, useRef, type MutableRefObject } from "react";
2
+ import type { DomEditSelection } from "../components/editor/domEditing";
3
+ import { usePlayerStore } from "../player";
4
+ import { buildStudioSelectionSnapshot } from "../utils/studioSelectionSnapshot";
5
+ import { trackStudioEvent } from "../utils/studioTelemetry";
6
+
7
+ interface UseStudioSelectionPublisherParams {
8
+ projectId: string | null;
9
+ domEditSelection: DomEditSelection | null;
10
+ domEditSelectionRef: MutableRefObject<DomEditSelection | null>;
11
+ refreshKey: number;
12
+ previewDocumentVersion: number;
13
+ refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
14
+ }
15
+
16
+ function reportSelectionPublishError(error: unknown): void {
17
+ if (error instanceof Error && error.name === "AbortError") return;
18
+ const errorName = error instanceof Error ? error.name : typeof error;
19
+ const errorMessage = error instanceof Error ? error.message : String(error);
20
+ trackStudioEvent("studio_selection_publish_failed", {
21
+ error_name: errorName,
22
+ error_message: errorMessage.slice(0, 500),
23
+ });
24
+ // eslint-disable-next-line no-console
25
+ console.warn("[Studio] Failed to update agent selection context", error);
26
+ }
27
+
28
+ function putSelection(projectId: string, selection: unknown, signal?: AbortSignal): Promise<void> {
29
+ return fetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, {
30
+ method: "PUT",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify({ selection }),
33
+ signal,
34
+ }).then(() => undefined);
35
+ }
36
+
37
+ export function useStudioSelectionPublisher({
38
+ projectId,
39
+ domEditSelection,
40
+ domEditSelectionRef,
41
+ refreshKey,
42
+ previewDocumentVersion,
43
+ refreshDomEditSelectionFromPreview,
44
+ }: UseStudioSelectionPublisherParams): void {
45
+ const lastSelectionRefreshKeyRef = useRef(refreshKey);
46
+ const pendingSelectionRefreshKeyRef = useRef<number | null>(null);
47
+
48
+ // eslint-disable-next-line no-restricted-syntax
49
+ useEffect(() => {
50
+ if (!projectId) return;
51
+ const selection = domEditSelection?.element.isConnected
52
+ ? buildStudioSelectionSnapshot({
53
+ projectId,
54
+ selection: domEditSelection,
55
+ currentTime: usePlayerStore.getState().currentTime,
56
+ })
57
+ : null;
58
+ const controller = new AbortController();
59
+ void putSelection(projectId, selection, controller.signal).catch(reportSelectionPublishError);
60
+ return () => controller.abort();
61
+ }, [domEditSelection, projectId]);
62
+
63
+ // Clear server-side agent context when Studio leaves a project. Without this,
64
+ // a long-running multi-project preview server can keep serving the last
65
+ // selected element for a project after its tab/session unmounts.
66
+ // eslint-disable-next-line no-restricted-syntax
67
+ useEffect(() => {
68
+ if (!projectId) return;
69
+ return () => {
70
+ void putSelection(projectId, null).catch(reportSelectionPublishError);
71
+ };
72
+ }, [projectId]);
73
+
74
+ // On external file edits, the iframe reloads while React keeps the previous
75
+ // DOM selection object alive. Clear the agent-facing snapshot immediately so
76
+ // `preview --context` never serves a detached or stale target, then let the
77
+ // post-load preview document refresh below re-resolve the selection if it
78
+ // still exists in the new document.
79
+ // eslint-disable-next-line no-restricted-syntax
80
+ useEffect(() => {
81
+ if (lastSelectionRefreshKeyRef.current === refreshKey) return;
82
+ lastSelectionRefreshKeyRef.current = refreshKey;
83
+ pendingSelectionRefreshKeyRef.current = domEditSelectionRef.current ? refreshKey : null;
84
+ if (!projectId || !domEditSelectionRef.current) return;
85
+ const controller = new AbortController();
86
+ void putSelection(projectId, null, controller.signal).catch(reportSelectionPublishError);
87
+ return () => controller.abort();
88
+ }, [domEditSelectionRef, projectId, refreshKey]);
89
+
90
+ // `refreshPreviewDocumentVersion` ticks after iframe load and shortly after.
91
+ // Consume one pending refresh per external reload: enough to re-resolve the
92
+ // selected element once the new document is queryable, without republishing
93
+ // the same snapshot on every follow-up 80/300ms tick.
94
+ // eslint-disable-next-line no-restricted-syntax
95
+ useEffect(() => {
96
+ if (pendingSelectionRefreshKeyRef.current === null) return;
97
+ pendingSelectionRefreshKeyRef.current = null;
98
+ const selection = domEditSelectionRef.current;
99
+ if (!selection) return;
100
+ void refreshDomEditSelectionFromPreview(selection);
101
+ }, [domEditSelectionRef, previewDocumentVersion, refreshDomEditSelectionFromPreview]);
102
+ }