@hyperframes/studio 0.7.5 → 0.7.6

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 (61) hide show
  1. package/dist/assets/{index-BwFzbjZQ.js → index-B_NnmU6q.js} +1 -1
  2. package/dist/assets/{index-C5NAfiPa.js → index-DsBhGFPe.js} +1 -1
  3. package/dist/assets/{index-DzWIinxk.css → index-wJZf6FK3.css} +1 -1
  4. package/dist/assets/index-ysftPins.js +374 -0
  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 +1 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +2298 -2483
  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 +2 -0
  17. package/src/components/editor/AnimationCard.tsx +72 -251
  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 +6 -6
  22. package/src/components/editor/GsapAnimationSection.tsx +2 -0
  23. package/src/components/editor/KeyframeEaseList.tsx +63 -0
  24. package/src/components/editor/OffCanvasIndicators.tsx +111 -0
  25. package/src/components/editor/PropertyPanel.tsx +52 -54
  26. package/src/components/editor/gsapAnimationCallbacks.ts +1 -0
  27. package/src/components/editor/gsapAnimationConstants.ts +8 -0
  28. package/src/components/editor/manualOffsetDrag.ts +8 -0
  29. package/src/components/editor/marqueeCommit.ts +168 -0
  30. package/src/components/editor/propertyPanelHelpers.ts +1 -0
  31. package/src/components/editor/snapTargetCollection.ts +0 -5
  32. package/src/components/panels/SlideshowPanel.tsx +0 -1
  33. package/src/contexts/DomEditContext.tsx +8 -0
  34. package/src/hooks/gsapDragPositionCommit.ts +1 -2
  35. package/src/hooks/gsapRuntimeBridge.ts +6 -2
  36. package/src/hooks/gsapRuntimeReaders.ts +1 -6
  37. package/src/hooks/useDomEditCommits.ts +0 -4
  38. package/src/hooks/useDomEditPreviewSync.ts +5 -0
  39. package/src/hooks/useDomEditSession.ts +23 -0
  40. package/src/hooks/useDomEditTextCommits.ts +3 -12
  41. package/src/hooks/useDomSelection.ts +46 -0
  42. package/src/hooks/useGestureCommit.ts +26 -3
  43. package/src/hooks/useGestureRecording.ts +2 -16
  44. package/src/hooks/useGsapAnimationOps.ts +2 -2
  45. package/src/hooks/useGsapTweenCache.ts +32 -4
  46. package/src/player/components/Player.tsx +0 -5
  47. package/src/player/components/timelineIcons.tsx +2 -1
  48. package/src/player/hooks/useTimelinePlayer.ts +4 -14
  49. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
  50. package/src/player/lib/timelineIframeHelpers.ts +2 -6
  51. package/src/telemetry/client.ts +2 -0
  52. package/src/utils/editDebugLog.ts +3 -10
  53. package/src/utils/gestureSmoother.test.ts +48 -0
  54. package/src/utils/gestureSmoother.ts +46 -0
  55. package/src/utils/marqueeGeometry.test.ts +123 -0
  56. package/src/utils/marqueeGeometry.ts +172 -0
  57. package/src/utils/optimisticUpdate.ts +1 -2
  58. package/src/utils/sourcePatcher.ts +0 -10
  59. package/src/utils/velocityEaseFitter.test.ts +58 -0
  60. package/src/utils/velocityEaseFitter.ts +121 -0
  61. package/dist/assets/index-D_JGXmfx.js +0 -374
@@ -0,0 +1,111 @@
1
+ import React from "react";
2
+ import { type DomEditSelection } from "./domEditing";
3
+
4
+ export interface OffCanvasRect {
5
+ key: string;
6
+ left: number;
7
+ top: number;
8
+ width: number;
9
+ height: number;
10
+ }
11
+
12
+ interface OffCanvasIndicatorsProps {
13
+ rects: OffCanvasRect[];
14
+ elements: React.MutableRefObject<Map<string, HTMLElement>>;
15
+ compRect: { left: number; top: number; width: number; height: number };
16
+ selection: DomEditSelection | null;
17
+ groupSelections: DomEditSelection[];
18
+ activeCompositionPathRef: React.MutableRefObject<string | null>;
19
+ onSelectionChangeRef: React.MutableRefObject<
20
+ (selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
21
+ >;
22
+ }
23
+
24
+ /**
25
+ * Dashed teal indicators for elements whose bounds extend past the composition
26
+ * (the "gray zone"). The in-canvas portion is clipped away so only the
27
+ * protruding sliver is dashed; the inside portion gets a solid outline.
28
+ * Extracted from DomEditOverlay to keep that file under the 600-LOC cap.
29
+ */
30
+ export function OffCanvasIndicators({
31
+ rects,
32
+ elements,
33
+ compRect,
34
+ selection,
35
+ groupSelections,
36
+ activeCompositionPathRef,
37
+ onSelectionChangeRef,
38
+ }: OffCanvasIndicatorsProps): React.ReactElement {
39
+ return (
40
+ <>
41
+ {rects
42
+ .filter((r) => {
43
+ // Suppress the indicator for any currently-selected element (primary
44
+ // OR a marquee group member) — those already render a selection box.
45
+ const el = elements.current.get(r.key);
46
+ if (!el) return true;
47
+ if (selection?.element === el) return false;
48
+ return !groupSelections.some((g) => g.element === el);
49
+ })
50
+ .map((r) => {
51
+ const pos = { left: r.left, top: r.top, width: r.width, height: r.height };
52
+ const cL = Math.max(0, compRect.left - r.left);
53
+ const cT = Math.max(0, compRect.top - r.top);
54
+ const cR = Math.min(r.width, compRect.left + compRect.width - r.left);
55
+ const cB = Math.min(r.height, compRect.top + compRect.height - r.top);
56
+ const hasInside = cL < cR && cT < cB;
57
+ const clipOutside = hasInside
58
+ ? `polygon(evenodd, 0 0, ${r.width}px 0, ${r.width}px ${r.height}px, 0 ${r.height}px, 0 0, ${cL}px ${cT}px, ${cR}px ${cT}px, ${cR}px ${cB}px, ${cL}px ${cB}px, ${cL}px ${cT}px)`
59
+ : undefined;
60
+ const clipInside = `inset(${cT}px ${Math.max(0, r.width - cR)}px ${Math.max(0, r.height - cB)}px ${cL}px round 6px)`;
61
+ const selectOffCanvas = async () => {
62
+ const el = elements.current.get(r.key);
63
+ if (!el) return;
64
+ const { resolveDomEditSelection } = await import("./domEditingLayers");
65
+ const acp = activeCompositionPathRef.current ?? "index.html";
66
+ const sel = await resolveDomEditSelection(el, {
67
+ activeCompositionPath: acp,
68
+ isMasterView: !acp || acp === "index.html",
69
+ skipSourceProbe: true,
70
+ });
71
+ if (sel) onSelectionChangeRef.current(sel, { revealPanel: true });
72
+ };
73
+ const handleClick = (e: React.MouseEvent) => {
74
+ e.stopPropagation();
75
+ void selectOffCanvas();
76
+ };
77
+ return (
78
+ <div key={`offcanvas-${r.key}`} className="pointer-events-none absolute" style={pos}>
79
+ {/* Dashed layer — clipped to exclude canvas area.
80
+ Note: clip-path is visual only — hit-testing still covers the
81
+ full bounding rect, so clicking the in-canvas portion selects
82
+ via this handler. That's acceptable: it resolves the same
83
+ element the normal canvas path would, just with
84
+ skipSourceProbe (the element is already known here). */}
85
+ <div
86
+ role="button"
87
+ tabIndex={0}
88
+ aria-label={`Select off-canvas element ${r.key}`}
89
+ className="pointer-events-auto absolute inset-0 border-2 border-dashed border-studio-accent/60 rounded-md cursor-pointer hover:border-studio-accent hover:bg-studio-accent/10 transition-colors"
90
+ style={clipOutside ? { clipPath: clipOutside } : undefined}
91
+ title={`Off-canvas: ${r.key} — click to select`}
92
+ onClick={handleClick}
93
+ onKeyDown={(e) => {
94
+ if (e.key === "Enter" || e.key === " ") {
95
+ e.preventDefault();
96
+ e.stopPropagation();
97
+ void selectOffCanvas();
98
+ }
99
+ }}
100
+ />
101
+ {/* Solid layer — clipped to canvas bounds, covers inside portion */}
102
+ <div
103
+ className="pointer-events-none absolute inset-0 border border-studio-accent/80 rounded-md bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
104
+ style={{ clipPath: clipInside }}
105
+ />
106
+ </div>
107
+ );
108
+ })}
109
+ </>
110
+ );
111
+ }
@@ -84,6 +84,7 @@ export const PropertyPanel = memo(function PropertyPanel({
84
84
  onSetArcPath,
85
85
  onUpdateArcSegment,
86
86
  onUnroll,
87
+ onUpdateKeyframeEase,
87
88
  onAddKeyframe,
88
89
  onRemoveKeyframe,
89
90
  onConvertToKeyframes,
@@ -233,6 +234,55 @@ export const PropertyPanel = memo(function PropertyPanel({
233
234
  const displayH = gsapRuntimeValues?.height ?? resolvedHeight;
234
235
  const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
235
236
 
237
+ // fallow-ignore-next-line complexity
238
+ const handleCopyElementInfo = () => {
239
+ const file = element.sourceFile ?? "index.html";
240
+ let lineNum: number | null = null;
241
+ try {
242
+ const src = previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
243
+ if (src && element.id) {
244
+ const idx = src.indexOf(`id="${element.id}"`);
245
+ if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
246
+ }
247
+ if (!lineNum && element.selector) {
248
+ const tag = element.tagName.toLowerCase();
249
+ const cls = element.selector.startsWith(".")
250
+ ? element.selector.slice(1).split(".")[0]
251
+ : null;
252
+ const search = cls ? `class="${cls}` : `<${tag}`;
253
+ const idx = src.indexOf(search);
254
+ if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
255
+ }
256
+ } catch {}
257
+ const fileLoc = lineNum ? `${file}:${lineNum}` : file;
258
+ const lines = [
259
+ `Element: ${element.label} (${sourceLabel})`,
260
+ `File: ${fileLoc}`,
261
+ `Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
262
+ `Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
263
+ `Tag: <${element.tagName}>`,
264
+ ];
265
+ if (element.computedStyles["z-index"] && element.computedStyles["z-index"] !== "auto") {
266
+ lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
267
+ }
268
+ if (gsapAnimations.length > 0) {
269
+ const anim = gsapAnimations[0];
270
+ lines.push(
271
+ `Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
272
+ );
273
+ const props = Object.entries(anim.properties)
274
+ .map(([k, v]) => `${k}: ${v}`)
275
+ .join(", ");
276
+ if (props) lines.push(`Properties: ${props}`);
277
+ }
278
+ const text = lines.join("\n");
279
+ void navigator.clipboard.writeText(text);
280
+ showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
281
+ setClipboardCopied(true);
282
+ clearTimeout(clipboardTimerRef.current);
283
+ clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
284
+ };
285
+
236
286
  return (
237
287
  <div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
238
288
  <div className="px-4 py-3">
@@ -246,60 +296,7 @@ export const PropertyPanel = memo(function PropertyPanel({
246
296
  <div className="flex items-center gap-1">
247
297
  <button
248
298
  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
- }}
299
+ onClick={handleCopyElementInfo}
303
300
  className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
304
301
  clipboardCopied
305
302
  ? "text-studio-accent"
@@ -558,6 +555,7 @@ export const PropertyPanel = memo(function PropertyPanel({
558
555
  onSetArcPath={onSetArcPath}
559
556
  onUpdateArcSegment={onUpdateArcSegment}
560
557
  onUnroll={onUnroll}
558
+ onUpdateKeyframeEase={onUpdateKeyframeEase}
561
559
  />
562
560
  )}
563
561
 
@@ -28,6 +28,7 @@ export interface GsapAnimationEditCallbacks {
28
28
  segmentIndex: number,
29
29
  update: Partial<ArcPathSegment>,
30
30
  ) => void;
31
+ onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
31
32
  /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
32
33
  onUnroll?: (animationId: string) => void;
33
34
  }
@@ -119,6 +119,9 @@ export const EASE_LABELS: Record<string, string> = {
119
119
  "spring-stiff": "Stiff spring",
120
120
  "spring-wobbly": "Wobbly spring",
121
121
  "spring-heavy": "Heavy spring",
122
+ "ae-ease": "Easy Ease (AE)",
123
+ "ae-ease-in": "Easy Ease In (AE)",
124
+ "ae-ease-out": "Easy Ease Out (AE)",
122
125
  };
123
126
 
124
127
  export const EASE_CURVES: Record<string, [number, number, number, number]> = {
@@ -141,6 +144,11 @@ export const EASE_CURVES: Record<string, [number, number, number, number]> = {
141
144
  "expo.out": [0.16, 1, 0.3, 1],
142
145
  "expo.in": [0.7, 0, 0.84, 0],
143
146
  "expo.inOut": [0.87, 0, 0.13, 1],
147
+ // After Effects polarity: "in" eases into the keyframe (slow END, CP2 y=1),
148
+ // "out" eases out of it (slow START, CP1 y=0). Matches the "(AE)" labels.
149
+ "ae-ease": [0.333, 0, 0.667, 1],
150
+ "ae-ease-in": [0.333, 0.333, 0.667, 1],
151
+ "ae-ease-out": [0.333, 0, 0.667, 0.667],
144
152
  };
145
153
 
146
154
  export function parseCustomEaseFromString(ease: string): {
@@ -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,7 @@ 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;
69
70
  onConvertToKeyframes?: (animationId: string) => void;
70
71
  onCommitAnimatedProperty?: (
71
72
  selection: DomEditSelection,
@@ -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 };
@@ -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
  }
@@ -61,6 +61,8 @@ export interface DomEditActionsValue extends Pick<
61
61
  | "invalidateGsapCache"
62
62
  | "previewIframeRef"
63
63
  | "commitMutation"
64
+ | "applyMarqueeSelection"
65
+ | "handleUpdateKeyframeEase"
64
66
  > {}
65
67
 
66
68
  export interface DomEditSelectionValue extends Pick<
@@ -167,6 +169,8 @@ export function DomEditProvider({
167
169
  invalidateGsapCache,
168
170
  previewIframeRef,
169
171
  commitMutation,
172
+ applyMarqueeSelection,
173
+ handleUpdateKeyframeEase,
170
174
  },
171
175
  children,
172
176
  }: {
@@ -238,6 +242,8 @@ export function DomEditProvider({
238
242
  invalidateGsapCache,
239
243
  previewIframeRef,
240
244
  commitMutation: stableCommitMutation,
245
+ applyMarqueeSelection,
246
+ handleUpdateKeyframeEase,
241
247
  }),
242
248
  [
243
249
  handleTimelineElementSelect,
@@ -295,6 +301,8 @@ export function DomEditProvider({
295
301
  invalidateGsapCache,
296
302
  previewIframeRef,
297
303
  stableCommitMutation,
304
+ applyMarqueeSelection,
305
+ handleUpdateKeyframeEase,
298
306
  ],
299
307
  );
300
308
 
@@ -165,8 +165,7 @@ async function commitFlatViaKeyframes(
165
165
  if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
166
166
  }
167
167
  mainTl.seek(ct);
168
- } catch (err) {
169
- console.warn("[gsap-drag] start-value read failed; using identity from values", err);
168
+ } catch {
170
169
  for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key];
171
170
  } finally {
172
171
  if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues);
@@ -239,7 +239,9 @@ export async function tryGsapDragIntercept(
239
239
  // `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
240
240
  // place (idempotent), else add a new one. This also covers the stale-cache
241
241
  // phantom — committing a set is correct because the element genuinely has no live motion.
242
- if (!hasNonHoldTweenForElement(iframe, selector)) {
242
+ const hasNonHold = hasNonHoldTweenForElement(iframe, selector);
243
+
244
+ if (!hasNonHold) {
243
245
  const existingSet =
244
246
  posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
245
247
  ? posAnim
@@ -251,7 +253,9 @@ export async function tryGsapDragIntercept(
251
253
  return true;
252
254
  }
253
255
 
254
- if (!posAnim) return false;
256
+ if (!posAnim) {
257
+ return false;
258
+ }
255
259
 
256
260
  // Verify the anim ID is still valid in the current file. The React-state
257
261
  // `animations` list can lag behind the file after a prior mutation changed
@@ -115,12 +115,7 @@ export function readAllAnimatedProperties(
115
115
  }
116
116
  }
117
117
  }
118
- } catch (e) {
119
- console.warn(
120
- "Cross-tween guard failed — baseline capture may include values from other tweens",
121
- e,
122
- );
123
- }
118
+ } catch {}
124
119
  for (const p of propKeys) otherTweenProps.delete(p);
125
120
 
126
121
  // Tier 1: Transform + visual properties with universal CSS defaults.
@@ -224,10 +224,6 @@ export function useDomEditCommits({
224
224
  target_source_file: selection.sourceFile ?? undefined,
225
225
  composition: activeCompPath ?? undefined,
226
226
  });
227
- console.warn(
228
- `[studio] Element not found in source: ${targetKey}. ` +
229
- "This element may be generated at runtime and cannot be persisted.",
230
- );
231
227
  }
232
228
  }
233
229
  return;
@@ -68,6 +68,11 @@ export function useDomEditPreviewSync({
68
68
 
69
69
  const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
70
70
  if (!nextElement) {
71
+ // The selected element no longer resolves in the (re-synced) document
72
+ // — comp/hot reload, activeCompPath swap, or post-save replacement.
73
+ // Clear so overlay geometry isn't computed on a stale, detached node.
74
+ // (Drag-release-in-gray-zone is handled separately by
75
+ // suppressNextBoxClickRef; the dragged element still resolves here.)
71
76
  applyDomSelection(null, { revealPanel: false });
72
77
  return;
73
78
  }
@@ -1,3 +1,4 @@
1
+ import { useCallback } from "react";
1
2
  import type { TimelineElement } from "../player";
2
3
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
3
4
  import type { EditHistoryKind } from "../utils/editHistory";
@@ -123,6 +124,7 @@ export function useDomEditSession({
123
124
  buildDomSelectionForTimelineElement,
124
125
  handleTimelineElementSelect,
125
126
  refreshDomEditSelectionFromPreview,
127
+ applyMarqueeSelection,
126
128
  } = useDomSelection({
127
129
  projectId,
128
130
  activeCompPath,
@@ -389,6 +391,25 @@ export function useDomEditSession({
389
391
  updateArcSegment,
390
392
  });
391
393
 
394
+ const handleUpdateKeyframeEase = useCallback(
395
+ (animationId: string, percentage: number, ease: string) => {
396
+ const sel = domEditSelectionRef.current;
397
+ if (!sel) return;
398
+ gsapCommitMutation(
399
+ sel,
400
+ {
401
+ type: "update-keyframe",
402
+ animationId,
403
+ percentage,
404
+ properties: {},
405
+ ease,
406
+ },
407
+ { label: "Update keyframe ease", softReload: true },
408
+ );
409
+ },
410
+ [gsapCommitMutation, domEditSelectionRef],
411
+ );
412
+
392
413
  return {
393
414
  // State
394
415
  domEditSelection,
@@ -429,6 +450,7 @@ export function useDomEditSession({
429
450
  buildDomSelectionFromTarget,
430
451
  buildDomSelectionForTimelineElement,
431
452
  updateDomEditHoverSelection,
453
+ applyMarqueeSelection,
432
454
  resolveImportedFontAsset,
433
455
  setAgentModalOpen,
434
456
  setAgentPromptSelectionContext,
@@ -454,6 +476,7 @@ export function useDomEditSession({
454
476
  handleGsapConvertToKeyframes,
455
477
  handleGsapRemoveAllKeyframes,
456
478
  handleResetSelectedElementKeyframes,
479
+ handleUpdateKeyframeEase,
457
480
  commitAnimatedProperty,
458
481
  handleSetArcPath,
459
482
  handleUpdateArcSegment,