@hyperframes/studio 0.6.90 → 0.6.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 (58) hide show
  1. package/dist/assets/{index-DSLrl2tB.js → index-CDy8BuGq.js} +24 -24
  2. package/dist/assets/index-CmRIkCwI.js +251 -0
  3. package/dist/assets/index-rm9tn9nH.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2 -0
  7. package/src/components/StudioPreviewArea.tsx +54 -13
  8. package/src/components/TimelineToolbar.tsx +52 -35
  9. package/src/components/editor/DomEditOverlay.tsx +79 -0
  10. package/src/components/editor/PropertyPanel.tsx +19 -10
  11. package/src/components/editor/gsapAnimatesProperty.ts +30 -0
  12. package/src/components/editor/manualEditingAvailability.test.ts +12 -0
  13. package/src/components/editor/manualEditingAvailability.ts +16 -0
  14. package/src/components/editor/manualEditsDom.ts +25 -5
  15. package/src/components/editor/manualEditsDomPatches.test.ts +1 -0
  16. package/src/components/editor/manualEditsDomPatches.ts +17 -1
  17. package/src/components/editor/manualEditsSnapshot.ts +16 -0
  18. package/src/components/editor/propertyPanel3dTransform.tsx +19 -4
  19. package/src/components/editor/useOffScreenIndicators.ts +197 -0
  20. package/src/components/nle/NLELayout.tsx +22 -32
  21. package/src/components/nle/TimelineEditorNotice.tsx +2 -25
  22. package/src/contexts/DomEditContext.tsx +4 -0
  23. package/src/hooks/gsapDragCommit.ts +119 -43
  24. package/src/hooks/gsapKeyframeCacheHelpers.ts +9 -4
  25. package/src/hooks/gsapRuntimeBridge.ts +266 -41
  26. package/src/hooks/gsapRuntimeReaders.ts +16 -2
  27. package/src/hooks/useAnimatedPropertyCommit.ts +11 -5
  28. package/src/hooks/useAppHotkeys.ts +48 -1
  29. package/src/hooks/useContextMenuDismiss.ts +29 -0
  30. package/src/hooks/useDomEditCommits.ts +7 -1
  31. package/src/hooks/useDomEditSession.ts +20 -4
  32. package/src/hooks/useEnableKeyframes.ts +3 -1
  33. package/src/hooks/useGestureCommit.ts +99 -13
  34. package/src/hooks/useGestureRecording.ts +18 -2
  35. package/src/hooks/useGsapScriptCommits.ts +24 -3
  36. package/src/hooks/useGsapSelectionHandlers.ts +19 -3
  37. package/src/hooks/useGsapTweenCache.ts +30 -10
  38. package/src/hooks/useRazorSplit.ts +298 -0
  39. package/src/hooks/useTimelineEditing.ts +15 -98
  40. package/src/player/components/ClipContextMenu.tsx +14 -25
  41. package/src/player/components/KeyframeDiamondContextMenu.tsx +16 -112
  42. package/src/player/components/PlayheadIndicator.tsx +43 -0
  43. package/src/player/components/Timeline.tsx +45 -38
  44. package/src/player/components/TimelineCanvas.tsx +29 -22
  45. package/src/player/components/TimelineClipDiamonds.tsx +3 -1
  46. package/src/player/components/timelineCallbacks.ts +44 -0
  47. package/src/player/components/timelineDragDrop.ts +2 -14
  48. package/src/player/components/useTimelineZoom.ts +18 -0
  49. package/src/player/store/playerStore.ts +20 -0
  50. package/src/utils/globalTimeCompiler.test.ts +2 -2
  51. package/src/utils/globalTimeCompiler.ts +2 -1
  52. package/src/utils/gsapSoftReload.test.ts +16 -0
  53. package/src/utils/gsapSoftReload.ts +43 -8
  54. package/src/utils/rdpSimplify.ts +3 -2
  55. package/src/utils/timelineElementSplit.test.ts +50 -0
  56. package/src/utils/timelineElementSplit.ts +32 -0
  57. package/dist/assets/index-BKuDHMYl.js +0 -146
  58. package/dist/assets/index-D2NkPomd.css +0 -1
@@ -8,7 +8,7 @@
8
8
  * absolute positions back into the GSAP script, regardless of tween type,
9
9
  * easing, or seek position.
10
10
  */
11
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
11
+ import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
12
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
13
  import { usePlayerStore } from "../player/store/playerStore";
14
14
 
@@ -18,6 +18,7 @@ import {
18
18
  computeCurrentPercentage,
19
19
  materializeIfDynamic,
20
20
  } from "./gsapDragCommit";
21
+ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
21
22
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
22
23
 
23
24
  // ── Runtime reads ──────────────────────────────────────────────────────────
@@ -87,7 +88,7 @@ function findGsapPositionAnimation(
87
88
  if (a.keyframes) score += 5;
88
89
  if (selector && a.targetSelector === selector) score += 8;
89
90
  else if (a.targetSelector.includes(",")) score -= 5;
90
- const pos = typeof a.position === "number" ? a.position : 0;
91
+ const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
91
92
  const dur = a.duration ?? 0;
92
93
  if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 4;
93
94
  return { anim: a, score };
@@ -104,6 +105,74 @@ function selectorForSelection(selection: DomEditSelection): string | null {
104
105
  return null;
105
106
  }
106
107
 
108
+ // ── Property-group tween resolution ───────────────────────────────────────
109
+
110
+ /**
111
+ * Find the tween for a given property group, splitting a legacy mixed tween
112
+ * if necessary. Returns the resolved animation or null if none exists.
113
+ *
114
+ * Resolution order:
115
+ * 1. Tween already tagged with `propertyGroup === group`
116
+ * 2. Legacy mixed tween (`!propertyGroup`) → split via server mutation,
117
+ * re-fetch, then return the group tween
118
+ * 3. null — caller must handle the missing-tween case
119
+ */
120
+ async function resolveGroupTween(
121
+ group: PropertyGroupName,
122
+ animations: GsapAnimation[],
123
+ selection: DomEditSelection,
124
+ commitMutation: GsapDragCommitCallbacks["commitMutation"],
125
+ fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
126
+ ): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
127
+ // 1. Already-split group tween — prefer the one with the most keyframes
128
+ // to avoid targeting a stub when a gesture-recorded tween also exists.
129
+ const groupAnims = animations.filter((a) => a.propertyGroup === group);
130
+ const groupAnim =
131
+ groupAnims.length > 1
132
+ ? groupAnims.sort(
133
+ (a, b) => (b.keyframes?.keyframes.length ?? 0) - (a.keyframes?.keyframes.length ?? 0),
134
+ )[0]
135
+ : (groupAnims[0] ?? null);
136
+ if (groupAnim) return { anim: groupAnim, animations };
137
+
138
+ // 2. Legacy mixed tween — split it, then re-fetch
139
+ const legacyMixed = animations.find((a) => !a.propertyGroup);
140
+ if (legacyMixed) {
141
+ await commitMutation(
142
+ selection,
143
+ { type: "split-into-property-groups", animationId: legacyMixed.id },
144
+ { label: "Split mixed tween into property groups", skipReload: true },
145
+ );
146
+ if (fetchFallbackAnimations) {
147
+ const fresh = await fetchFallbackAnimations();
148
+ const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
149
+ if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
150
+ }
151
+ }
152
+
153
+ // 3. Try fallback fetch (no split needed, just wasn't in the initial list)
154
+ if (!legacyMixed && fetchFallbackAnimations) {
155
+ const fresh = await fetchFallbackAnimations();
156
+ const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
157
+ if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
158
+
159
+ // Fallback: legacy mixed in the fresh list
160
+ const freshLegacy = fresh.find((a) => !a.propertyGroup);
161
+ if (freshLegacy) {
162
+ await commitMutation(
163
+ selection,
164
+ { type: "split-into-property-groups", animationId: freshLegacy.id },
165
+ { label: "Split mixed tween into property groups", skipReload: true },
166
+ );
167
+ const reFetched = await fetchFallbackAnimations();
168
+ const reFetchedGroup = reFetched.find((a) => a.propertyGroup === group);
169
+ if (reFetchedGroup) return { anim: reFetchedGroup, animations: reFetched };
170
+ }
171
+ }
172
+
173
+ return null;
174
+ }
175
+
107
176
  // ── High-level intercept ───────────────────────────────────────────────────
108
177
 
109
178
  export type { GsapDragCommitCallbacks };
@@ -127,10 +196,24 @@ export async function tryGsapDragIntercept(
127
196
  const selector = selectorForSelection(selection);
128
197
  if (!selector) return false;
129
198
 
130
- let posAnim = findGsapPositionAnimation(animations, selector);
131
- if (!posAnim && fetchFallbackAnimations) {
132
- const fresh = await fetchFallbackAnimations();
133
- posAnim = findGsapPositionAnimation(fresh, selector);
199
+ // Resolve the position-group tween, splitting legacy mixed tweens if needed.
200
+ const resolved = await resolveGroupTween(
201
+ "position",
202
+ animations,
203
+ selection,
204
+ commitMutation,
205
+ fetchFallbackAnimations,
206
+ );
207
+
208
+ // Fallback: use the legacy scoring heuristic for compositions that don't
209
+ // have group-tagged tweens at all (e.g. hand-written scripts).
210
+ let posAnim = resolved?.anim ?? null;
211
+ if (!posAnim) {
212
+ posAnim = findGsapPositionAnimation(animations, selector);
213
+ if (!posAnim && fetchFallbackAnimations) {
214
+ const fresh = await fetchFallbackAnimations();
215
+ posAnim = findGsapPositionAnimation(fresh, selector);
216
+ }
134
217
  }
135
218
  if (!posAnim) return false;
136
219
 
@@ -143,6 +226,7 @@ export async function tryGsapDragIntercept(
143
226
 
144
227
  await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, {
145
228
  commitMutation,
229
+ fetchAnimations: fetchFallbackAnimations,
146
230
  });
147
231
  return true;
148
232
  }
@@ -151,6 +235,22 @@ export async function tryGsapDragIntercept(
151
235
 
152
236
  export { readGsapProperty, readAllAnimatedProperties };
153
237
 
238
+ // ── Identity-prop synthesis ───────────────────────────────────────────────
239
+
240
+ const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
241
+
242
+ /** Build identity (zero / one) values for each property in `source`. */
243
+ function synthesizeIdentityProps(
244
+ source: Record<string, number | string>,
245
+ ): Record<string, number | string> {
246
+ const id: Record<string, number | string> = {};
247
+ for (const [k, v] of Object.entries(source)) {
248
+ if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
249
+ else id[k] = v;
250
+ }
251
+ return id;
252
+ }
253
+
154
254
  // ── Resize intercept ──────────────────────────────────────────────────────
155
255
 
156
256
  export async function tryGsapResizeIntercept(
@@ -161,46 +261,155 @@ export async function tryGsapResizeIntercept(
161
261
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
162
262
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
163
263
  ): Promise<boolean> {
164
- let anim = animations.find(
165
- (a) => "width" in a.properties || "height" in a.properties || a.keyframes,
264
+ // If the element already has a scale-group tween, resize should modify scale
265
+ // (the user is resizing something whose visual size is driven by scale).
266
+ // Otherwise, use the size group (width/height).
267
+ const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale");
268
+ const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
269
+ const resolved = await resolveGroupTween(
270
+ resizeGroup,
271
+ animations,
272
+ selection,
273
+ commitMutation,
274
+ fetchFallbackAnimations,
166
275
  );
167
- if (!anim && fetchFallbackAnimations) {
168
- const fresh = await fetchFallbackAnimations();
169
- anim = fresh.find((a) => "width" in a.properties || "height" in a.properties || a.keyframes);
170
- }
171
- if (!anim) return false;
172
-
173
- const pct = computeCurrentPercentage(selection, anim);
174
276
 
175
- if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
176
- const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
177
- if (newId) anim = { ...anim, id: newId };
178
- } else if (!anim.keyframes) {
277
+ let anim = resolved?.anim ?? null;
278
+ if (!anim) {
279
+ // No size-group tween exists create one. Use the element's timing
280
+ // from any existing animation, or fall back to element data attributes.
281
+ const refAnim = animations[0];
282
+ const elStart =
283
+ refAnim?.resolvedStart ?? (Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0);
284
+ const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "5") || 5;
285
+ const ct = usePlayerStore.getState().currentTime;
286
+ const pct = elDuration > 0 ? Math.round(((ct - elStart) / elDuration) * 1000) / 10 : 0;
287
+ const sel = selectorForSelection(selection);
288
+ if (!sel) return false;
179
289
  await commitMutation(
180
290
  selection,
181
- { type: "convert-to-keyframes", animationId: anim.id },
182
- { label: "Convert to keyframes for resize", skipReload: true },
291
+ {
292
+ type: "add-with-keyframes",
293
+ targetSelector: sel,
294
+ position: Math.round(elStart * 1000) / 1000,
295
+ duration: Math.round(elDuration * 1000) / 1000,
296
+ keyframes: [
297
+ {
298
+ percentage: Math.max(0, Math.min(100, pct)),
299
+ properties: { width: Math.round(size.width), height: Math.round(size.height) },
300
+ },
301
+ ],
302
+ },
303
+ { label: "Resize (new size keyframe)", softReload: true },
183
304
  );
305
+ return true;
184
306
  }
185
307
 
308
+ const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
309
+ const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
310
+ if (activeKeyframePct != null) setActiveKeyframePct(null);
311
+ const coalesceKey = `gsap:resize:${anim.id}`;
312
+
186
313
  const selector = selectorForSelection(selection);
187
314
  const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
188
315
 
189
- const backfillDefaults: Record<string, number> = { ...runtimeProps };
190
- if (!("width" in runtimeProps)) {
191
- const cssW = readGsapProperty(iframe, selector, "width");
192
- backfillDefaults.width = cssW ?? Math.round(size.width);
316
+ let resizeProps: Record<string, number>;
317
+ if (resizeGroup === "scale") {
318
+ const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null;
319
+ // The resize draft modifies el.style.width, so read the ORIGINAL width
320
+ // saved by the draft system before it ran.
321
+ const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? "");
322
+ const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200;
323
+ const newScale = Math.round((size.width / cssW) * 1000) / 1000;
324
+ resizeProps = { scale: newScale };
325
+ } else {
326
+ resizeProps = {
327
+ width: Math.round(size.width),
328
+ height: Math.round(size.height),
329
+ };
330
+ }
331
+ const ct = usePlayerStore.getState().currentTime;
332
+ const ts = resolveTweenStart(anim);
333
+ const td = resolveTweenDuration(anim);
334
+ const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
335
+ // Outside-range uses the extend path which handles everything atomically.
336
+ if (!outsideRange) {
337
+ if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
338
+ const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
339
+ if (newId) anim = { ...anim, id: newId };
340
+ } else if (!anim.keyframes) {
341
+ const resolvedFromValues = selector
342
+ ? readAllAnimatedProperties(iframe, selector, anim)
343
+ : undefined;
344
+ await commitMutation(
345
+ selection,
346
+ { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
347
+ { label: "Convert to keyframes for resize", skipReload: true, coalesceKey },
348
+ );
349
+ }
193
350
  }
194
- if (!("height" in runtimeProps)) {
195
- const cssH = readGsapProperty(iframe, selector, "height");
196
- backfillDefaults.height = cssH ?? Math.round(size.height);
351
+
352
+ if (outsideRange && ts !== null) {
353
+ // For flat tweens, synthesize the keyframes from the tween's properties
354
+ const kfs =
355
+ anim.keyframes?.keyframes ??
356
+ (() => {
357
+ const fromProps =
358
+ anim.method === "from" || anim.method === "fromTo"
359
+ ? { ...anim.properties }
360
+ : synthesizeIdentityProps(anim.properties);
361
+ const toProps =
362
+ anim.method === "from"
363
+ ? synthesizeIdentityProps(anim.properties)
364
+ : { ...anim.properties };
365
+ return [
366
+ { percentage: 0, properties: fromProps },
367
+ { percentage: 100, properties: toProps },
368
+ ];
369
+ })();
370
+ const newStart = Math.min(ct, ts);
371
+ const newEnd = Math.max(ct, ts + td);
372
+ const newDuration = Math.max(0.01, newEnd - newStart);
373
+ const existingKfs = kfs;
374
+ const remapped: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
375
+ for (const kf of existingKfs) {
376
+ const absTime = ts + (kf.percentage / 100) * td;
377
+ const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
378
+ const props = { ...kf.properties };
379
+ // Only backfill properties that the animation already had (x, y, scale).
380
+ // Don't backfill width/height — they should only appear on the resize keyframe.
381
+ for (const k of Object.keys(resizeProps)) {
382
+ if (k in props) continue;
383
+ if (k === "width" || k === "height") continue;
384
+ props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
385
+ }
386
+ remapped.push({ percentage: newPct, properties: props });
387
+ }
388
+ const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10;
389
+ remapped.push({ percentage: targetPct, properties: resizeProps });
390
+ remapped.sort((a, b) => a.percentage - b.percentage);
391
+
392
+ await commitMutation(
393
+ selection,
394
+ {
395
+ type: "replace-with-keyframes",
396
+ animationId: anim.id,
397
+ targetSelector: anim.targetSelector,
398
+ position: Math.round(newStart * 1000) / 1000,
399
+ duration: Math.round(newDuration * 1000) / 1000,
400
+ keyframes: remapped,
401
+ },
402
+ { label: `Resize (extended to ${ct.toFixed(2)}s)`, softReload: true, coalesceKey },
403
+ );
404
+ return true;
197
405
  }
198
406
 
199
- const properties = {
200
- ...runtimeProps,
201
- width: Math.round(size.width),
202
- height: Math.round(size.height),
203
- };
407
+ const SIZE_PROPS = new Set(["width", "height"]);
408
+ const backfillDefaults: Record<string, number> = {};
409
+ for (const k of Object.keys(runtimeProps)) {
410
+ if (SIZE_PROPS.has(k)) continue;
411
+ backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
412
+ }
204
413
 
205
414
  await commitMutation(
206
415
  selection,
@@ -208,10 +417,10 @@ export async function tryGsapResizeIntercept(
208
417
  type: "add-keyframe",
209
418
  animationId: anim.id,
210
419
  percentage: pct,
211
- properties,
420
+ properties: resizeProps,
212
421
  backfillDefaults,
213
422
  },
214
- { label: `Resize (keyframe ${pct}%)`, softReload: true },
423
+ { label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey },
215
424
  );
216
425
  return true;
217
426
  }
@@ -226,10 +435,23 @@ export async function tryGsapRotationIntercept(
226
435
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
227
436
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
228
437
  ): Promise<boolean> {
229
- let anim = animations.find((a) => "rotation" in a.properties || a.keyframes);
230
- if (!anim && fetchFallbackAnimations) {
231
- const fresh = await fetchFallbackAnimations();
232
- anim = fresh.find((a) => "rotation" in a.properties || a.keyframes);
438
+ // Resolve the rotation-group tween, splitting legacy mixed tweens if needed.
439
+ const resolved = await resolveGroupTween(
440
+ "rotation",
441
+ animations,
442
+ selection,
443
+ commitMutation,
444
+ fetchFallbackAnimations,
445
+ );
446
+
447
+ // Fallback: legacy heuristic for hand-written scripts
448
+ let anim = resolved?.anim ?? null;
449
+ if (!anim) {
450
+ anim = animations.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
451
+ if (!anim && fetchFallbackAnimations) {
452
+ const fresh = await fetchFallbackAnimations();
453
+ anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
454
+ }
233
455
  }
234
456
  if (!anim) return false;
235
457
 
@@ -261,14 +483,17 @@ export async function tryGsapRotationIntercept(
261
483
  const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
262
484
  if (newId) anim = { ...anim, id: newId };
263
485
  } else if (!anim.keyframes) {
486
+ const resolvedFromValues = selector
487
+ ? readAllAnimatedProperties(iframe, selector, anim, "rotation")
488
+ : undefined;
264
489
  await commitMutation(
265
490
  selection,
266
- { type: "convert-to-keyframes", animationId: anim.id },
491
+ { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
267
492
  { label: "Convert to keyframes for rotation", skipReload: true },
268
493
  );
269
494
  }
270
495
 
271
- const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
496
+ const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation");
272
497
 
273
498
  const backfillDefaults: Record<string, number> = { ...runtimeProps };
274
499
  if (!("rotation" in runtimeProps)) {
@@ -2,6 +2,7 @@
2
2
  * Low-level GSAP runtime property readers shared by gsapRuntimeBridge and gsapDragCommit.
3
3
  */
4
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+ import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser";
5
6
 
6
7
  interface IframeGsap {
7
8
  getProperty: (el: Element, prop: string) => number;
@@ -19,7 +20,8 @@ export function readGsapProperty(
19
20
  const el = iframe.contentDocument?.querySelector(selector);
20
21
  if (!el) return null;
21
22
  const val = Number(gsap.getProperty(el, prop));
22
- return Number.isFinite(val) ? Math.round(val) : null;
23
+ if (!Number.isFinite(val)) return null;
24
+ return POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000;
23
25
  } catch {
24
26
  return null;
25
27
  }
@@ -51,6 +53,7 @@ export function readAllAnimatedProperties(
51
53
  iframe: HTMLIFrameElement | null,
52
54
  selector: string,
53
55
  anim: GsapAnimation,
56
+ group?: PropertyGroupName,
54
57
  ): Record<string, number> {
55
58
  const result: Record<string, number> = {};
56
59
  if (!iframe?.contentWindow) return result;
@@ -81,6 +84,13 @@ export function readAllAnimatedProperties(
81
84
  for (const p of Object.keys(anim.properties)) propKeys.add(p);
82
85
  }
83
86
 
87
+ // When a group filter is specified, only keep properties belonging to that group.
88
+ if (group) {
89
+ for (const p of propKeys) {
90
+ if (classifyPropertyGroup(p) !== group) propKeys.delete(p);
91
+ }
92
+ }
93
+
84
94
  for (const prop of propKeys) {
85
95
  const val = Number(gsap.getProperty(el, prop));
86
96
  if (Number.isFinite(val)) {
@@ -147,9 +157,13 @@ export function readAllAnimatedProperties(
147
157
  sepia: 0,
148
158
  invert: 0,
149
159
  };
160
+ // Collect all properties that ANY tween on this element explicitly targets.
161
+ // Only capture baseline values for these — GSAP reports non-default values
162
+ // (scaleZ=0, brightness=0) for untouched properties, polluting keyframes.
163
+ const allTweenedProps = new Set([...propKeys, ...otherTweenProps]);
150
164
  for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
151
165
  if (prop in result) continue;
152
- if (otherTweenProps.has(prop)) continue;
166
+ if (!allTweenedProps.has(prop)) continue;
153
167
  const val = Number(gsap.getProperty(el, prop));
154
168
  if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) {
155
169
  result[prop] = Math.round(val * 1000) / 1000;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { useCallback } from "react";
10
10
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
11
+ import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
11
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
12
13
  import { usePlayerStore } from "../player/store/playerStore";
13
14
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
@@ -38,7 +39,7 @@ interface CommitAnimatedPropertyDeps {
38
39
 
39
40
  function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): number {
40
41
  const currentTime = usePlayerStore.getState().currentTime;
41
- const tweenPos = typeof anim?.position === "number" ? anim.position : 0;
42
+ const tweenPos = anim?.resolvedStart ?? (typeof anim?.position === "number" ? anim.position : 0);
42
43
  const tweenDur = anim?.duration ?? 0;
43
44
  if (tweenDur > 0) {
44
45
  return Math.max(
@@ -56,18 +57,19 @@ function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): n
56
57
  function pickBestAnimation(
57
58
  animations: GsapAnimation[],
58
59
  selector: string | null,
60
+ property?: string,
59
61
  ): GsapAnimation | undefined {
60
62
  if (animations.length <= 1) return animations[0];
61
63
  const currentTime = usePlayerStore.getState().currentTime;
64
+ const targetGroup = property ? classifyPropertyGroup(property) : undefined;
62
65
 
63
66
  const scored = animations.map((a) => {
64
67
  let score = 0;
68
+ if (targetGroup && a.propertyGroup === targetGroup) score += 20;
65
69
  if (a.keyframes) score += 10;
66
- // Prefer single-element selectors over comma-separated groups
67
70
  if (selector && a.targetSelector === selector) score += 5;
68
71
  else if (a.targetSelector.includes(",")) score -= 3;
69
- // Prefer tweens active at the current time
70
- const pos = typeof a.position === "number" ? a.position : 0;
72
+ const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
71
73
  const dur = a.duration ?? 0;
72
74
  if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 8;
73
75
  return { anim: a, score };
@@ -102,7 +104,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
102
104
  const iframe = previewIframeRef.current;
103
105
  const selector = selectorFor(selection);
104
106
 
105
- let anim: GsapAnimation | undefined = pickBestAnimation(selectedGsapAnimations, selector);
107
+ let anim: GsapAnimation | undefined = pickBestAnimation(
108
+ selectedGsapAnimations,
109
+ selector,
110
+ property,
111
+ );
106
112
 
107
113
  // Case 3: No animation — create one first
108
114
  if (!anim) {
@@ -6,6 +6,8 @@ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
6
6
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
7
7
  import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
8
8
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
+ import { canSplitElement } from "../utils/timelineElementSplit";
10
+ import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
9
11
 
10
12
  /** Safely resolves contentWindow for a potentially cross-origin iframe. */
11
13
  function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
@@ -327,7 +329,7 @@ export function useAppHotkeys({
327
329
  const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
328
330
  if (
329
331
  element &&
330
- ["video", "audio", "img"].includes(element.tag) &&
332
+ canSplitElement(element) &&
331
333
  currentTime > element.start &&
332
334
  currentTime < element.start + element.duration
333
335
  ) {
@@ -338,6 +340,51 @@ export function useAppHotkeys({
338
340
  }
339
341
  }
340
342
 
343
+ // B — toggle razor tool
344
+ if (
345
+ STUDIO_RAZOR_TOOL_ENABLED &&
346
+ event.key.toLowerCase() === "b" &&
347
+ !event.metaKey &&
348
+ !event.ctrlKey &&
349
+ !event.altKey &&
350
+ !event.shiftKey &&
351
+ !isEditableTarget(event.target)
352
+ ) {
353
+ event.preventDefault();
354
+ const { activeTool, setActiveTool } = usePlayerStore.getState();
355
+ setActiveTool(activeTool === "razor" ? "select" : "razor");
356
+ return;
357
+ }
358
+
359
+ // V — return to selection tool
360
+ if (
361
+ event.key.toLowerCase() === "v" &&
362
+ !event.metaKey &&
363
+ !event.ctrlKey &&
364
+ !event.altKey &&
365
+ !event.shiftKey &&
366
+ !isEditableTarget(event.target)
367
+ ) {
368
+ event.preventDefault();
369
+ usePlayerStore.getState().setActiveTool("select");
370
+ return;
371
+ }
372
+
373
+ // Escape — exit razor mode (only when no selection to deselect first)
374
+ if (event.key === "Escape" && !isEditableTarget(event.target)) {
375
+ const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
376
+ usePlayerStore.getState();
377
+ if (activeTool === "razor") {
378
+ if (selectedElementId) {
379
+ setSelectedElementId(null);
380
+ } else {
381
+ setActiveTool("select");
382
+ }
383
+ event.preventDefault();
384
+ return;
385
+ }
386
+ }
387
+
341
388
  // Delete / Backspace — remove selected keyframes > reset keyframes > remove element
342
389
  if (
343
390
  (event.key === "Delete" || event.key === "Backspace") &&
@@ -0,0 +1,29 @@
1
+ import { useCallback, useEffect, useRef, type RefObject } from "react";
2
+
3
+ /**
4
+ * Shared dismiss logic for context menus: closes on outside click or Escape.
5
+ * Returns a ref to attach to the menu container element.
6
+ */
7
+ export function useContextMenuDismiss(onClose: () => void): RefObject<HTMLDivElement | null> {
8
+ const menuRef = useRef<HTMLDivElement>(null);
9
+
10
+ const dismiss = useCallback(
11
+ (e: MouseEvent | KeyboardEvent) => {
12
+ if (e instanceof KeyboardEvent && e.key !== "Escape") return;
13
+ if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
14
+ onClose();
15
+ },
16
+ [onClose],
17
+ );
18
+
19
+ useEffect(() => {
20
+ document.addEventListener("mousedown", dismiss);
21
+ document.addEventListener("keydown", dismiss);
22
+ return () => {
23
+ document.removeEventListener("mousedown", dismiss);
24
+ document.removeEventListener("keydown", dismiss);
25
+ };
26
+ }, [dismiss]);
27
+
28
+ return menuRef;
29
+ }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import { usePlayerStore } from "../player";
3
+ import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
3
4
  import { FONT_EXT } from "../utils/mediaTypes";
4
5
  import type { PatchOperation } from "../utils/sourcePatcher";
5
6
  import { trackStudioEvent } from "../utils/studioTelemetry";
@@ -41,10 +42,13 @@ import type { EditHistoryKind } from "../utils/editHistory";
41
42
  import { useDomEditTextCommits } from "./useDomEditTextCommits";
42
43
 
43
44
  // ── Helpers ──
44
-
45
45
  type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
46
46
 
47
+ // fallow-ignore-next-line complexity
47
48
  function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean {
49
+ // When the GSAP drag intercept is disabled for debugging, treat every
50
+ // element as un-targeted so commits take the plain CSS persist path.
51
+ if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false;
48
52
  if (!iframe?.contentWindow) return false;
49
53
  let timelines: Record<string, TimelineLike> | undefined;
50
54
  try {
@@ -168,6 +172,7 @@ export function useDomEditCommits({
168
172
 
169
173
  // fallow-ignore-next-line complexity
170
174
  const persistDomEditOperations: PersistDomEditOperations = useCallback(
175
+ // fallow-ignore-next-line complexity
171
176
  async (selection, operations, options) => {
172
177
  const pid = projectIdRef.current;
173
178
  if (!pid) throw new Error("No active project");
@@ -455,6 +460,7 @@ export function useDomEditCommits({
455
460
 
456
461
  // fallow-ignore-next-line complexity
457
462
  const handleDomEditElementDelete = useCallback(
463
+ // fallow-ignore-next-line complexity
458
464
  async (selection: DomEditSelection) => {
459
465
  const pid = projectIdRef.current;
460
466
  if (!pid) return;