@hyperframes/studio 0.7.5 → 0.7.7

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 (77) hide show
  1. package/dist/assets/{index-BwFzbjZQ.js → index-B2YXvFxf.js} +1 -1
  2. package/dist/assets/index-BSkUuN8g.css +1 -0
  3. package/dist/assets/index-BeRh2hMe.js +375 -0
  4. package/dist/assets/{index-C5NAfiPa.js → index-BoASKOeE.js} +1 -1
  5. package/dist/chunk-KZXYQYIU.js +876 -0
  6. package/dist/chunk-KZXYQYIU.js.map +1 -0
  7. package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
  8. package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +3326 -2939
  12. package/dist/index.js.map +1 -1
  13. package/package.json +5 -5
  14. package/src/captions/hooks/useCaptionSync.ts +6 -1
  15. package/src/components/StudioPreviewArea.tsx +10 -3
  16. package/src/components/StudioRightPanel.tsx +4 -0
  17. package/src/components/editor/AnimationCard.tsx +77 -250
  18. package/src/components/editor/AnimationCardParts.tsx +220 -0
  19. package/src/components/editor/BlockParamsPanel.tsx +4 -8
  20. package/src/components/editor/DomEditOverlay.tsx +132 -48
  21. package/src/components/editor/EaseCurveSection.tsx +177 -101
  22. package/src/components/editor/GsapAnimationSection.tsx +4 -0
  23. package/src/components/editor/KeyframeEaseList.tsx +127 -0
  24. package/src/components/editor/OffCanvasIndicators.tsx +111 -0
  25. package/src/components/editor/PropertyPanel.tsx +63 -56
  26. package/src/components/editor/gsapAnimationCallbacks.ts +3 -0
  27. package/src/components/editor/gsapAnimationConstants.ts +11 -32
  28. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  29. package/src/components/editor/manualOffsetDrag.ts +8 -0
  30. package/src/components/editor/marqueeCommit.ts +168 -0
  31. package/src/components/editor/propertyPanelHelpers.ts +7 -0
  32. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  33. package/src/components/editor/snapTargetCollection.ts +0 -5
  34. package/src/components/editor/useMotionPathData.ts +2 -1
  35. package/src/components/panels/SlideshowPanel.tsx +0 -1
  36. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  37. package/src/components/sidebar/AssetsTab.tsx +542 -254
  38. package/src/components/sidebar/assetHelpers.ts +40 -0
  39. package/src/contexts/DomEditContext.tsx +12 -0
  40. package/src/hooks/gsapDragCommit.ts +101 -0
  41. package/src/hooks/gsapDragPositionCommit.ts +1 -2
  42. package/src/hooks/gsapRuntimeBridge.ts +28 -2
  43. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  44. package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
  45. package/src/hooks/gsapRuntimeReaders.ts +1 -6
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useDomEditPreviewSync.ts +5 -0
  48. package/src/hooks/useDomEditSession.ts +43 -0
  49. package/src/hooks/useDomEditTextCommits.ts +3 -12
  50. package/src/hooks/useDomEditWiring.ts +3 -0
  51. package/src/hooks/useDomSelection.ts +46 -0
  52. package/src/hooks/useGestureCommit.ts +26 -7
  53. package/src/hooks/useGestureRecording.ts +2 -16
  54. package/src/hooks/useGsapAnimationOps.ts +2 -2
  55. package/src/hooks/useGsapAwareEditing.ts +0 -4
  56. package/src/hooks/useGsapScriptCommits.ts +0 -12
  57. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  58. package/src/hooks/useGsapTweenCache.ts +151 -42
  59. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  60. package/src/hooks/useRazorSplit.ts +0 -3
  61. package/src/player/components/Player.tsx +0 -5
  62. package/src/player/components/timelineIcons.tsx +2 -1
  63. package/src/player/hooks/useTimelinePlayer.ts +4 -14
  64. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
  65. package/src/player/lib/timelineIframeHelpers.ts +2 -6
  66. package/src/telemetry/client.ts +2 -0
  67. package/src/utils/gestureSmoother.test.ts +48 -0
  68. package/src/utils/gestureSmoother.ts +46 -0
  69. package/src/utils/marqueeGeometry.test.ts +123 -0
  70. package/src/utils/marqueeGeometry.ts +172 -0
  71. package/src/utils/optimisticUpdate.ts +1 -2
  72. package/src/utils/sourcePatcher.ts +0 -10
  73. package/src/utils/velocityEaseFitter.test.ts +58 -0
  74. package/src/utils/velocityEaseFitter.ts +121 -0
  75. package/dist/assets/index-D_JGXmfx.js +0 -374
  76. package/dist/assets/index-DzWIinxk.css +0 -1
  77. package/src/utils/editDebugLog.ts +0 -16
@@ -84,6 +84,8 @@ export const PropertyPanel = memo(function PropertyPanel({
84
84
  onSetArcPath,
85
85
  onUpdateArcSegment,
86
86
  onUnroll,
87
+ onUpdateKeyframeEase,
88
+ onSetAllKeyframeEases,
87
89
  onAddKeyframe,
88
90
  onRemoveKeyframe,
89
91
  onConvertToKeyframes,
@@ -233,6 +235,55 @@ export const PropertyPanel = memo(function PropertyPanel({
233
235
  const displayH = gsapRuntimeValues?.height ?? resolvedHeight;
234
236
  const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
235
237
 
238
+ // fallow-ignore-next-line complexity
239
+ const handleCopyElementInfo = () => {
240
+ const file = element.sourceFile ?? "index.html";
241
+ let lineNum: number | null = null;
242
+ try {
243
+ const src = previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
244
+ if (src && element.id) {
245
+ const idx = src.indexOf(`id="${element.id}"`);
246
+ if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
247
+ }
248
+ if (!lineNum && element.selector) {
249
+ const tag = element.tagName.toLowerCase();
250
+ const cls = element.selector.startsWith(".")
251
+ ? element.selector.slice(1).split(".")[0]
252
+ : null;
253
+ const search = cls ? `class="${cls}` : `<${tag}`;
254
+ const idx = src.indexOf(search);
255
+ if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
256
+ }
257
+ } catch {}
258
+ const fileLoc = lineNum ? `${file}:${lineNum}` : file;
259
+ const lines = [
260
+ `Element: ${element.label} (${sourceLabel})`,
261
+ `File: ${fileLoc}`,
262
+ `Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
263
+ `Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
264
+ `Tag: <${element.tagName}>`,
265
+ ];
266
+ if (element.computedStyles["z-index"] && element.computedStyles["z-index"] !== "auto") {
267
+ lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
268
+ }
269
+ if (gsapAnimations.length > 0) {
270
+ const anim = gsapAnimations[0];
271
+ lines.push(
272
+ `Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
273
+ );
274
+ const props = Object.entries(anim.properties)
275
+ .map(([k, v]) => `${k}: ${v}`)
276
+ .join(", ");
277
+ if (props) lines.push(`Properties: ${props}`);
278
+ }
279
+ const text = lines.join("\n");
280
+ void navigator.clipboard.writeText(text);
281
+ showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
282
+ setClipboardCopied(true);
283
+ clearTimeout(clipboardTimerRef.current);
284
+ clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
285
+ };
286
+
236
287
  return (
237
288
  <div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
238
289
  <div className="px-4 py-3">
@@ -246,60 +297,7 @@ export const PropertyPanel = memo(function PropertyPanel({
246
297
  <div className="flex items-center gap-1">
247
298
  <button
248
299
  type="button"
249
- onClick={() => {
250
- const file = element.sourceFile ?? "index.html";
251
- let lineNum: number | null = null;
252
- try {
253
- const src =
254
- previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
255
- if (src && element.id) {
256
- const idx = src.indexOf(`id="${element.id}"`);
257
- if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
258
- }
259
- if (!lineNum && element.selector) {
260
- const tag = element.tagName.toLowerCase();
261
- const cls = element.selector.startsWith(".")
262
- ? element.selector.slice(1).split(".")[0]
263
- : null;
264
- const search = cls ? `class="${cls}` : `<${tag}`;
265
- const idx = src.indexOf(search);
266
- if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
267
- }
268
- } catch {}
269
- const fileLoc = lineNum ? `${file}:${lineNum}` : file;
270
- const lines = [
271
- `Element: ${element.label} (${sourceLabel})`,
272
- `File: ${fileLoc}`,
273
- `Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
274
- `Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
275
- `Tag: <${element.tagName}>`,
276
- ];
277
- if (
278
- element.computedStyles["z-index"] &&
279
- element.computedStyles["z-index"] !== "auto"
280
- ) {
281
- lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
282
- }
283
- if (gsapAnimations.length > 0) {
284
- const anim = gsapAnimations[0];
285
- lines.push(
286
- `Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
287
- );
288
- const props = Object.entries(anim.properties)
289
- .map(([k, v]) => `${k}: ${v}`)
290
- .join(", ");
291
- if (props) lines.push(`Properties: ${props}`);
292
- }
293
- const text = lines.join("\n");
294
- void navigator.clipboard.writeText(text);
295
- showToast(
296
- `Copied element info for ${element.label} — paste into any AI agent`,
297
- "info",
298
- );
299
- setClipboardCopied(true);
300
- clearTimeout(clipboardTimerRef.current);
301
- clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
302
- }}
300
+ onClick={handleCopyElementInfo}
303
301
  className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
304
302
  clipboardCopied
305
303
  ? "text-studio-accent"
@@ -350,8 +348,15 @@ export const PropertyPanel = memo(function PropertyPanel({
350
348
  onRemoveTextField={onRemoveTextField}
351
349
  />
352
350
 
353
- {element.dataAttributes.start != null && (
354
- <TimingSection element={element} onSetAttribute={onSetAttribute} />
351
+ {(element.dataAttributes.start != null || gsapAnimations.length > 0) && (
352
+ // Render whenever there's an authored clip range OR animations to infer
353
+ // one from — a pure-GSAP element with no data-start still gets a Timing
354
+ // range (TimingSection derives it from its tweens).
355
+ <TimingSection
356
+ element={element}
357
+ animations={gsapAnimations}
358
+ onSetAttribute={onSetAttribute}
359
+ />
355
360
  )}
356
361
  {isMediaElement(element) && (
357
362
  <MediaSection
@@ -558,6 +563,8 @@ export const PropertyPanel = memo(function PropertyPanel({
558
563
  onSetArcPath={onSetArcPath}
559
564
  onUpdateArcSegment={onUpdateArcSegment}
560
565
  onUnroll={onUnroll}
566
+ onUpdateKeyframeEase={onUpdateKeyframeEase}
567
+ onSetAllKeyframeEases={onSetAllKeyframeEases}
561
568
  />
562
569
  )}
563
570
 
@@ -28,6 +28,9 @@ export interface GsapAnimationEditCallbacks {
28
28
  segmentIndex: number,
29
29
  update: Partial<ArcPathSegment>,
30
30
  ) => void;
31
+ onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
32
+ /** Apply one ease to every keyframe segment at once (clears per-segment overrides). */
33
+ onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
31
34
  /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
32
35
  onUnroll?: (animationId: string) => void;
33
36
  }
@@ -88,38 +88,12 @@ export const PROP_TOOLTIPS: Record<string, string> = {
88
88
  innerText: "End value for a number roll-up (the number it counts up/down to)",
89
89
  };
90
90
 
91
- export const EASE_LABELS: Record<string, string> = {
92
- none: "Constant speed",
93
- "power1.out": "Gentle slowdown",
94
- "power2.out": "Smooth slowdown",
95
- "power3.out": "Snappy slowdown",
96
- "power4.out": "Sharp slowdown",
97
- "power1.in": "Gentle speedup",
98
- "power2.in": "Smooth speedup",
99
- "power3.in": "Strong speedup",
100
- "power4.in": "Sharp speedup",
101
- "power1.inOut": "Gentle ease",
102
- "power2.inOut": "Smooth ease",
103
- "power3.inOut": "Strong ease",
104
- "power4.inOut": "Sharp ease",
105
- "back.out": "Overshoot & settle",
106
- "back.in": "Pull back & go",
107
- "back.inOut": "Pull & overshoot",
108
- "elastic.out": "Springy bounce",
109
- "elastic.in": "Wind up spring",
110
- "elastic.inOut": "Full spring",
111
- "bounce.out": "Drop & bounce",
112
- "bounce.in": "Reverse bounce",
113
- "bounce.inOut": "Double bounce",
114
- "expo.out": "Very snappy stop",
115
- "expo.in": "Very slow start",
116
- "expo.inOut": "Dramatic ease",
117
- "spring-gentle": "Gentle spring",
118
- "spring-bouncy": "Bouncy spring",
119
- "spring-stiff": "Stiff spring",
120
- "spring-wobbly": "Wobbly spring",
121
- "spring-heavy": "Heavy spring",
122
- };
91
+ // Ease labels surface the raw GSAP token (e.g. "power2.out", "back.out") rather
92
+ // than friendly names — motion authors recognize the GSAP vocabulary, and the
93
+ // invented labels ("Smooth speedup") confused users. Every consumer reads
94
+ // `EASE_LABELS[token] ?? token`, so an empty map cleanly falls through to the
95
+ // token; re-add an entry here only to override a specific token's display.
96
+ export const EASE_LABELS: Record<string, string> = {};
123
97
 
124
98
  export const EASE_CURVES: Record<string, [number, number, number, number]> = {
125
99
  none: [0, 0, 1, 1],
@@ -141,6 +115,11 @@ export const EASE_CURVES: Record<string, [number, number, number, number]> = {
141
115
  "expo.out": [0.16, 1, 0.3, 1],
142
116
  "expo.in": [0.7, 0, 0.84, 0],
143
117
  "expo.inOut": [0.87, 0, 0.13, 1],
118
+ // After Effects polarity: "in" eases into the keyframe (slow END, CP2 y=1),
119
+ // "out" eases out of it (slow START, CP1 y=0). Matches the "(AE)" labels.
120
+ "ae-ease": [0.333, 0, 0.667, 1],
121
+ "ae-ease-in": [0.333, 0.333, 0.667, 1],
122
+ "ae-ease-out": [0.333, 0, 0.667, 0.667],
144
123
  };
145
124
 
146
125
  export function parseCustomEaseFromString(ease: string): {
@@ -73,7 +73,7 @@ describe("buildTweenSummary", () => {
73
73
  expect(s).toContain("[opacity 0%");
74
74
  expect(s).toContain("move x -50px");
75
75
  expect(s).toContain("opacity to 100%");
76
- expect(s).toContain("very snappy stop");
76
+ expect(s).toContain("expo.out");
77
77
  });
78
78
 
79
79
  it("handles fromTo with empty fromProperties", () => {
@@ -443,6 +443,14 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
443
443
  member.element.removeAttribute("data-hf-drag-initial-offset-y");
444
444
  member.element.removeAttribute("data-hf-drag-gsap-base-x");
445
445
  member.element.removeAttribute("data-hf-drag-gsap-base-y");
446
+ // Clear the draft's `translate: none` so the soft reload starts clean —
447
+ // otherwise button-less pointermoves after the reload compute deltas
448
+ // from a stale base and fling the element off-screen (#1673).
449
+ // Do NOT clearProps:"transform" — that nukes the committed GSAP position
450
+ // and causes a visual snap-back before the soft reload re-applies it.
451
+ if (member.element.style.getPropertyValue("translate") === "none") {
452
+ member.element.style.removeProperty("translate");
453
+ }
446
454
  resumeGsapTimelines(member.element);
447
455
  }
448
456
  }
@@ -0,0 +1,168 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import type { DomEditSelection } from "./domEditing";
3
+ import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
4
+ import { isElementComputedVisible } from "./domEditingElement";
5
+ import { coversComposition } from "../../utils/studioPreviewHelpers";
6
+ import { elementObbCorners, marqueeIntersectsObb } from "../../utils/marqueeGeometry";
7
+
8
+ interface MarqueeState {
9
+ startX: number;
10
+ startY: number;
11
+ currentX: number;
12
+ currentY: number;
13
+ pointerId: number;
14
+ pastThreshold: boolean;
15
+ }
16
+
17
+ const MARQUEE_THRESHOLD_PX = 4;
18
+
19
+ // fallow-ignore-next-line complexity
20
+ async function runMarqueeIntersection(
21
+ rect: { left: number; top: number; width: number; height: number },
22
+ iframe: HTMLIFrameElement,
23
+ overlayEl: HTMLDivElement,
24
+ activeCompositionPath: string,
25
+ ): Promise<DomEditSelection[]> {
26
+ const doc = iframe.contentDocument;
27
+ if (!doc) return [];
28
+
29
+ const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
30
+ const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
31
+ const items = collectDomEditLayerItems(root, { activeCompositionPath, isMasterView });
32
+
33
+ const rootEl = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement;
34
+ const declW = Number.parseFloat(rootEl?.getAttribute("data-width") ?? "");
35
+ const declH = Number.parseFloat(rootEl?.getAttribute("data-height") ?? "");
36
+ const viewport = {
37
+ width: declW > 0 ? declW : rootEl.getBoundingClientRect().width || 1,
38
+ height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1,
39
+ };
40
+
41
+ const hits: DomEditSelection[] = [];
42
+ for (const item of items) {
43
+ const el = item.element;
44
+ if (!isElementComputedVisible(el)) continue;
45
+ if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
46
+ const corners = elementObbCorners(el, overlayEl, iframe);
47
+ if (!corners) continue;
48
+ if (!marqueeIntersectsObb(rect, corners)) continue;
49
+ const sel = await resolveDomEditSelection(el, {
50
+ activeCompositionPath,
51
+ isMasterView,
52
+ skipSourceProbe: true,
53
+ });
54
+ if (sel) hits.push(sel);
55
+ }
56
+
57
+ return hits;
58
+ }
59
+
60
+ interface MarqueeGesturesDeps {
61
+ iframeRef: React.RefObject<HTMLIFrameElement | null>;
62
+ overlayRef: React.RefObject<HTMLDivElement | null>;
63
+ activeCompositionPathRef: React.RefObject<string | null>;
64
+ onMarqueeSelectRef: React.RefObject<
65
+ ((selections: DomEditSelection[], additive: boolean) => void) | undefined
66
+ >;
67
+ selectionRef: React.RefObject<DomEditSelection | null>;
68
+ gestures: {
69
+ onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
70
+ onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
71
+ clearPointerState: (ref: React.RefObject<DomEditSelection | null>) => void;
72
+ };
73
+ }
74
+
75
+ // fallow-ignore-next-line complexity
76
+ export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
77
+ const marqueeRef = useRef<MarqueeState | null>(null);
78
+ const [marqueeRect, setMarqueeRect] = useState<{
79
+ left: number;
80
+ top: number;
81
+ width: number;
82
+ height: number;
83
+ } | null>(null);
84
+
85
+ const commitMarquee = useCallback(
86
+ async (
87
+ rect: { left: number; top: number; width: number; height: number },
88
+ additive: boolean,
89
+ ) => {
90
+ const iframe = deps.iframeRef.current;
91
+ const overlay = deps.overlayRef.current;
92
+ if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
93
+ const acp = deps.activeCompositionPathRef.current ?? "index.html";
94
+ const hits = await runMarqueeIntersection(rect, iframe, overlay, acp);
95
+ deps.onMarqueeSelectRef.current(hits, additive);
96
+ },
97
+ [deps.iframeRef, deps.overlayRef, deps.onMarqueeSelectRef, deps.activeCompositionPathRef],
98
+ );
99
+
100
+ const onPointerMove = useCallback(
101
+ (event: React.PointerEvent<HTMLDivElement>) => {
102
+ const m = marqueeRef.current;
103
+ if (m) {
104
+ const oRect = deps.overlayRef.current?.getBoundingClientRect();
105
+ if (!oRect) return;
106
+ m.currentX = event.clientX - oRect.left;
107
+ m.currentY = event.clientY - oRect.top;
108
+ if (!m.pastThreshold) {
109
+ const dx = m.currentX - m.startX;
110
+ const dy = m.currentY - m.startY;
111
+ if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return;
112
+ m.pastThreshold = true;
113
+ }
114
+ setMarqueeRect({
115
+ left: Math.min(m.startX, m.currentX),
116
+ top: Math.min(m.startY, m.currentY),
117
+ width: Math.abs(m.currentX - m.startX),
118
+ height: Math.abs(m.currentY - m.startY),
119
+ });
120
+ return;
121
+ }
122
+ deps.gestures.onPointerMove(event);
123
+ },
124
+ [deps.gestures, deps.overlayRef],
125
+ );
126
+
127
+ const onPointerUp = useCallback(
128
+ (event: React.PointerEvent<HTMLDivElement>) => {
129
+ const m = marqueeRef.current;
130
+ if (m) {
131
+ marqueeRef.current = null;
132
+ try {
133
+ (event.currentTarget as HTMLElement).releasePointerCapture(m.pointerId);
134
+ } catch {
135
+ /* already released */
136
+ }
137
+ if (m.pastThreshold) {
138
+ commitMarquee(
139
+ {
140
+ left: Math.min(m.startX, m.currentX),
141
+ top: Math.min(m.startY, m.currentY),
142
+ width: Math.abs(m.currentX - m.startX),
143
+ height: Math.abs(m.currentY - m.startY),
144
+ },
145
+ event.shiftKey,
146
+ );
147
+ } else {
148
+ deps.onMarqueeSelectRef.current?.([], false);
149
+ }
150
+ setMarqueeRect(null);
151
+ return;
152
+ }
153
+ deps.gestures.onPointerUp(event);
154
+ },
155
+ [deps.gestures, commitMarquee, deps.onMarqueeSelectRef],
156
+ );
157
+
158
+ const onPointerCancel = useCallback(() => {
159
+ if (marqueeRef.current) {
160
+ marqueeRef.current = null;
161
+ setMarqueeRect(null);
162
+ return;
163
+ }
164
+ deps.gestures.clearPointerState(deps.selectionRef);
165
+ }, [deps.gestures, deps.selectionRef]);
166
+
167
+ return { marqueeRef, marqueeRect, onPointerMove, onPointerUp, onPointerCancel };
168
+ }
@@ -66,6 +66,8 @@ export interface PropertyPanelProps {
66
66
  value: number | string,
67
67
  ) => void;
68
68
  onRemoveKeyframe?: (animationId: string, percentage: number) => void;
69
+ onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
70
+ onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
69
71
  onConvertToKeyframes?: (animationId: string) => void;
70
72
  onCommitAnimatedProperty?: (
71
73
  selection: DomEditSelection,
@@ -210,7 +212,9 @@ export const LABEL = "text-[11px] font-medium text-panel-text-3";
210
212
  export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
211
213
  export const EMPTY_STYLES: Record<string, string> = {};
212
214
 
215
+ // fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
213
216
  export const EMPTY_FILTER_VALUE = "none";
217
+ // fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
214
218
  export const BOX_SHADOW_PRESETS = {
215
219
  none: "none",
216
220
  soft: "0 12px 36px rgba(0, 0, 0, 0.28)",
@@ -271,6 +275,7 @@ export function parsePxMetricValue(value: string): number | null {
271
275
  return token.value;
272
276
  }
273
277
 
278
+ // fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
274
279
  export function clampPanelNumber(
275
280
  value: number,
276
281
  min: number,
@@ -319,6 +324,7 @@ export function normalizeTextMetricValue(
319
324
  function splitCssFunctions(value: string): string[] {
320
325
  const functions: string[] = [];
321
326
  let current = "";
327
+ // fallow-ignore-next-line code-duplication -- pre-existing; surfaced in this file's diff by an unrelated line shift
322
328
  let depth = 0;
323
329
 
324
330
  for (const char of value.trim()) {
@@ -484,6 +490,7 @@ export function extractBackgroundImageUrl(value: string | undefined): string {
484
490
 
485
491
  // ── GSAP runtime value readers (used by PropertyPanel) ────────────────────
486
492
 
493
+ // fallow-ignore-next-line complexity -- pre-existing; surfaced in this file's diff by an unrelated line shift
487
494
  export function readGsapRuntimeValuesForPanel(
488
495
  gsapAnimId: string | null,
489
496
  gsapAnimations: GsapAnimation[],
@@ -1,3 +1,4 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
1
2
  import { Clock } from "../../icons/SystemIcons";
2
3
  import type { DomEditSelection } from "./domEditing";
3
4
  import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
@@ -9,18 +10,45 @@ function parseTimingValue(input: string): number | null {
9
10
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
10
11
  }
11
12
 
13
+ /**
14
+ * Derive a time range from the element's GSAP tweens (earliest start → latest
15
+ * end) so an element animated purely by GSAP — with no `data-start` /
16
+ * `data-duration` — still shows a meaningful Timing range instead of 0s.
17
+ */
18
+ function deriveTimingFromAnimations(
19
+ animations: GsapAnimation[],
20
+ ): { start: number; duration: number } | null {
21
+ let lo = Infinity;
22
+ let hi = -Infinity;
23
+ for (const a of animations) {
24
+ const s = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
25
+ const d = a.duration ?? 0;
26
+ lo = Math.min(lo, s);
27
+ hi = Math.max(hi, s + d);
28
+ }
29
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null;
30
+ return { start: lo, duration: hi - lo };
31
+ }
32
+
12
33
  export function TimingSection({
13
34
  element,
35
+ animations = [],
14
36
  onSetAttribute,
15
37
  }: {
16
38
  element: DomEditSelection;
39
+ animations?: GsapAnimation[];
17
40
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
18
41
  }) {
19
- const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
20
- const duration =
42
+ const explicitStart = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
43
+ const explicitDuration =
21
44
  Number.parseFloat(
22
45
  element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
23
46
  ) || 0;
47
+
48
+ // No authored clip timing → infer the range from the element's animations.
49
+ const derived = explicitDuration > 0 ? null : deriveTimingFromAnimations(animations);
50
+ const start = derived ? derived.start : explicitStart;
51
+ const duration = derived ? derived.duration : explicitDuration;
24
52
  const end = start + duration;
25
53
 
26
54
  const commitStart = (nextValue: string) => {
@@ -54,6 +82,11 @@ export function TimingSection({
54
82
  onCommit={commitDuration}
55
83
  />
56
84
  </div>
85
+ {derived && (
86
+ <p className="mt-2 text-[10px] leading-snug text-neutral-500">
87
+ Inferred from this element’s animation — edit to pin an explicit clip range.
88
+ </p>
89
+ )}
57
90
  </Section>
58
91
  );
59
92
  }
@@ -100,11 +100,6 @@ export function collectSnapContext(input: {
100
100
 
101
101
  const MAX_SNAP_TARGETS = 80;
102
102
  const elements = collectVisibleElements(root, input.excludeElements, MAX_SNAP_TARGETS);
103
- if (elements.length >= MAX_SNAP_TARGETS) {
104
- console.warn(
105
- `[snap] Target cap reached (${MAX_SNAP_TARGETS}). Elements beyond this limit are excluded from snap alignment.`,
106
- );
107
- }
108
103
 
109
104
  const entries: Array<{
110
105
  rect: { left: number; top: number; width: number; height: number };
@@ -119,7 +119,8 @@ export function useMotionPathData(
119
119
  return;
120
120
  }
121
121
  const recompute = () => {
122
- const read = readRuntimeKeyframes(iframeRef.current, selector);
122
+ // Position-only: never let a co-located size/scale tween shadow the path.
123
+ const read = readRuntimeKeyframes(iframeRef.current, selector, undefined, ["x", "y"]);
123
124
  const next = buildMotionPathGeometry(read);
124
125
  setGeometry((prev) =>
125
126
  prev?.points === next?.points && prev?.kind === next?.kind ? prev : next,
@@ -50,7 +50,6 @@ export function safeParseManifest(html: string): SlideshowManifest {
50
50
  try {
51
51
  return parseSlideshowManifest(html) ?? { slides: [] };
52
52
  } catch {
53
- console.warn("[SlideshowPanel] Failed to parse slideshow manifest; using empty manifest");
54
53
  return { slides: [] };
55
54
  }
56
55
  }
@@ -0,0 +1,97 @@
1
+ export function ContextMenu({
2
+ x,
3
+ y,
4
+ asset,
5
+ onClose,
6
+ onCopy,
7
+ onDelete,
8
+ onRename,
9
+ }: {
10
+ x: number;
11
+ y: number;
12
+ asset: string;
13
+ onClose: () => void;
14
+ onCopy: (path: string) => void;
15
+ onDelete?: (path: string) => void;
16
+ onRename?: (oldPath: string, newPath: string) => void;
17
+ }) {
18
+ return (
19
+ <div
20
+ className="fixed inset-0 z-[200]"
21
+ onClick={onClose}
22
+ onContextMenu={(e) => {
23
+ e.preventDefault();
24
+ onClose();
25
+ }}
26
+ >
27
+ <div
28
+ className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[140px] text-xs"
29
+ style={{ left: x, top: y }}
30
+ >
31
+ <button
32
+ onClick={(e) => {
33
+ e.stopPropagation();
34
+ onCopy(asset);
35
+ onClose();
36
+ }}
37
+ className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
38
+ >
39
+ Copy path
40
+ </button>
41
+ {onRename && (
42
+ <button
43
+ onClick={(e) => {
44
+ e.stopPropagation();
45
+ onClose();
46
+ }}
47
+ className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
48
+ >
49
+ Rename
50
+ </button>
51
+ )}
52
+ {onDelete && (
53
+ <button
54
+ onClick={(e) => {
55
+ e.stopPropagation();
56
+ onDelete(asset);
57
+ onClose();
58
+ }}
59
+ className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-neutral-800 transition-colors"
60
+ >
61
+ Delete
62
+ </button>
63
+ )}
64
+ </div>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ export function DeleteConfirm({
70
+ name,
71
+ onConfirm,
72
+ onCancel,
73
+ }: {
74
+ name: string;
75
+ onConfirm: () => void;
76
+ onCancel: () => void;
77
+ }) {
78
+ return (
79
+ <div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex items-center justify-between gap-2">
80
+ <span className="text-[10px] text-red-400 truncate">Delete {name}?</span>
81
+ <div className="flex items-center gap-1 flex-shrink-0">
82
+ <button
83
+ onClick={onConfirm}
84
+ className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white hover:bg-red-500 transition-colors"
85
+ >
86
+ Delete
87
+ </button>
88
+ <button
89
+ onClick={onCancel}
90
+ className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
91
+ >
92
+ Cancel
93
+ </button>
94
+ </div>
95
+ </div>
96
+ );
97
+ }