@hyperframes/studio 0.6.122-alpha.0 → 0.7.0

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 (98) hide show
  1. package/dist/assets/index-CbNg8Ghm.js +374 -0
  2. package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
  3. package/dist/assets/{index-i-MLqhmm.js → index-DsxCdV8b.js} +1 -1
  4. package/dist/assets/index-DzWIinxk.css +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3757 -2232
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -39
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditSession.ts +0 -4
  61. package/src/hooks/useDomSelection.ts +12 -5
  62. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  63. package/src/hooks/useEnableKeyframes.ts +298 -45
  64. package/src/hooks/useGestureCommit.ts +121 -53
  65. package/src/hooks/useGestureRecording.ts +61 -42
  66. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  67. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  68. package/src/hooks/useGsapAnimationOps.ts +1 -1
  69. package/src/hooks/useGsapAwareEditing.ts +38 -40
  70. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  71. package/src/hooks/useGsapScriptCommits.ts +102 -6
  72. package/src/hooks/useGsapTweenCache.ts +7 -1
  73. package/src/hooks/useRazorSplit.ts +21 -2
  74. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  75. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  76. package/src/hooks/useServerConnection.ts +4 -1
  77. package/src/hooks/useStudioContextValue.ts +3 -2
  78. package/src/hooks/useStudioUrlState.ts +97 -37
  79. package/src/player/components/ShortcutsPanel.tsx +8 -0
  80. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  81. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  82. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  83. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  84. package/src/player/lib/mediaProbe.ts +7 -1
  85. package/src/player/store/playerStore.ts +38 -1
  86. package/src/telemetry/config.ts +3 -2
  87. package/src/utils/editDebugLog.ts +16 -0
  88. package/src/utils/generateId.ts +7 -0
  89. package/src/utils/gsapSoftReload.test.ts +217 -13
  90. package/src/utils/gsapSoftReload.ts +228 -50
  91. package/src/utils/studioHelpers.test.ts +31 -1
  92. package/src/utils/studioHelpers.ts +24 -0
  93. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  94. package/src/utils/studioPreviewHelpers.ts +30 -6
  95. package/src/utils/studioTelemetry.ts +3 -1
  96. package/dist/assets/index-Baz2T-9U.js +0 -269
  97. package/dist/assets/index-BvEhf7SS.css +0 -1
  98. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -0,0 +1,481 @@
1
+ import { memo, useEffect, useRef, useState, type RefObject } from "react";
2
+ import type { DomEditSelection } from "./domEditing";
3
+ import { useDomEditContext } from "../../contexts/DomEditContext";
4
+ import { usePlayerStore } from "../../player/store/playerStore";
5
+ import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
6
+ import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
7
+ import { editableAnimationId, selectorFor } from "./motionPathSelection";
8
+ import { ACCENT, MotionPathNode } from "./MotionPathNode";
9
+ import {
10
+ KeyframeDiamondContextMenu,
11
+ type KeyframeDiamondContextMenuState,
12
+ } from "../../player/components/KeyframeDiamondContextMenu";
13
+ import {
14
+ commitAddKeyframe,
15
+ commitAddWaypoint,
16
+ commitCreatePath,
17
+ commitNode,
18
+ commitRemoveWaypoint,
19
+ } from "./motionPathCommit";
20
+ import {
21
+ elementHome,
22
+ hasMotionPathPlugin,
23
+ isPreviewHtmlElement,
24
+ useMotionPathData,
25
+ } from "./useMotionPathData";
26
+
27
+ interface MotionPathOverlayProps {
28
+ iframeRef: RefObject<HTMLIFrameElement | null>;
29
+ selection: DomEditSelection | null;
30
+ compositionSize: { width: number; height: number } | null;
31
+ isPlaying: boolean;
32
+ }
33
+
34
+ type Draft = { index: number; x: number; y: number };
35
+ type DragState = {
36
+ index: number;
37
+ startX: number;
38
+ startY: number;
39
+ initX: number;
40
+ initY: number;
41
+ scale: number;
42
+ ref: MotionNodeRef;
43
+ };
44
+
45
+ const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
46
+ // Click-vs-drag cutoff in SCREEN pixels. Below this the pointer-up is a click
47
+ // (select the keyframe); at or above it the gesture commits a move. Screen-space
48
+ // (not composition px) so it behaves identically at any zoom.
49
+ const DRAG_THRESHOLD_PX = 3;
50
+
51
+ /**
52
+ * Draws the selected element's GSAP motion path over the canvas — a dashed
53
+ * polyline through its x/y keyframes (or motionPath waypoints) with a draggable
54
+ * node at each. Dragging an x/y node rewrites the keyframe; dragging a waypoint
55
+ * rewrites the motionPath point; both commit to source (undoable). Renders in
56
+ * declared composition coordinates so the path doesn't drift under GSAP
57
+ * transforms. Read-only (no drag) while playing or when the tween isn't
58
+ * statically editable. Nothing renders when the selection has no positional
59
+ * motion.
60
+ */
61
+ // fallow-ignore-next-line complexity
62
+ export const MotionPathOverlay = memo(function MotionPathOverlay({
63
+ iframeRef,
64
+ selection,
65
+ compositionSize,
66
+ isPlaying,
67
+ }: MotionPathOverlayProps) {
68
+ const {
69
+ commitMutation,
70
+ selectedGsapAnimations,
71
+ handleGsapRemoveKeyframe,
72
+ handleGsapDeleteAllForElement,
73
+ } = useDomEditContext();
74
+ const { rect, geometry, geometryResolved, visibleInPreview, home } = useMotionPathData(
75
+ iframeRef,
76
+ selectorFor(selection),
77
+ );
78
+ const [draft, setDraft] = useState<Draft | null>(null);
79
+ const [ghost, setGhost] = useState<{ x: number; y: number; segIndex: number } | null>(null);
80
+ const [hoverNode, setHoverNode] = useState<number | null>(null);
81
+ // Right-click context menu on a keyframe node — same delete actions as the
82
+ // timeline keyframe diamond.
83
+ const [kfMenu, setKfMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
84
+ // The keyframe % selected by clicking its node — highlighted, and the next drag
85
+ // modifies it rather than adding a keyframe.
86
+ const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
87
+ // Set-destination mode is armed from the preview toolbar (replaces the old
88
+ // double-click-on-canvas UX). See createMode effects below.
89
+ const armed = usePlayerStore((s) => s.motionPathArmed);
90
+ const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
91
+ const setMotionPathCreateAvailable = usePlayerStore((s) => s.setMotionPathCreateAvailable);
92
+ const dragRef = useRef<DragState | null>(null);
93
+ // Park-on-click is debounced so a double-click cancels the seek (see onUp).
94
+ const parkTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
95
+ // The animation id whose path is currently editable. Computed at hook level (not
96
+ // just in render, after the early returns) so the park-timer cleanup can key on
97
+ // it: a pending park seek belongs to the OLD animation, so firing it after the
98
+ // active animation changed would jump the playhead onto a stale keyframe.
99
+ const animId = editableAnimationId(selectedGsapAnimations ?? [], geometry?.kind ?? "linear");
100
+ // Clear the debounced park timer on unmount AND whenever the active animation id
101
+ // changes — not unmount-only, or a queued seek from the previous selection still
102
+ // fires against the new one.
103
+ useEffect(() => () => clearTimeout(parkTimerRef.current), [animId]);
104
+
105
+ // Create mode: a selected element with no positional motion can be given a new
106
+ // motionPath. Gated on `geometryResolved` so a fresh selection never counts as
107
+ // "no path" before the first runtime read confirms it (see useMotionPathData).
108
+ const createMode = geometryResolved && !geometry && Boolean(selection?.element) && !isPlaying;
109
+ const createSelector = createMode ? selectorFor(selection) : null;
110
+ const compW = compositionSize?.width ?? null;
111
+ const canCreate = createMode && hasMotionPathPlugin(iframeRef.current);
112
+
113
+ // Publish whether the selected element can take a path so the preview toolbar
114
+ // shows its "set destination" toggle. Drops to false when this overlay unmounts
115
+ // or the context changes, so the button never lingers for a stale selection.
116
+ useEffect(() => {
117
+ setMotionPathCreateAvailable(Boolean(canCreate));
118
+ return () => setMotionPathCreateAvailable(false);
119
+ }, [canCreate, setMotionPathCreateAvailable]);
120
+
121
+ // Disarm when set-destination is no longer possible (element gains a path, gets
122
+ // deselected, or playback starts) so a toggle left on can't fire later.
123
+ useEffect(() => {
124
+ if (armed && !canCreate) setMotionPathArmed(false);
125
+ }, [armed, canCreate, setMotionPathArmed]);
126
+
127
+ // While armed, the next canvas press sets the destination (replaces the old
128
+ // double-click). Scoped to the preview pan-surface in the CAPTURE phase, on
129
+ // pointerdown, so it fires before the selection/drag handler underneath — a
130
+ // press on empty canvas would otherwise deselect (and disarm) before a later
131
+ // click could land. stopPropagation keeps that handler from also running.
132
+ // fallow-ignore-next-line complexity
133
+ useEffect(() => {
134
+ if (!armed || !createSelector || !compW) return;
135
+ const surface =
136
+ (iframeRef.current?.ownerDocument?.querySelector(
137
+ "[data-preview-pan-surface]",
138
+ ) as HTMLElement | null) ?? null;
139
+ if (!surface) return;
140
+ const prevCursor = surface.style.cursor;
141
+ surface.style.cursor = "crosshair";
142
+ // fallow-ignore-next-line complexity
143
+ const onDown = (e: PointerEvent) => {
144
+ if (e.button !== 0) return; // primary press only
145
+ const frame = iframeRef.current;
146
+ if (!frame || !hasMotionPathPlugin(frame)) return;
147
+ const r = frame.getBoundingClientRect();
148
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) {
149
+ return;
150
+ }
151
+ // Resolve the element LIVE from the current iframe document — the selected
152
+ // node may be detached after a soft-reload, which would skew home.
153
+ const live = frame.contentDocument?.querySelector(createSelector);
154
+ if (!isPreviewHtmlElement(live, frame)) return;
155
+ e.stopPropagation();
156
+ e.preventDefault();
157
+ const sc = r.width / compW;
158
+ const elHome = elementHome(live);
159
+ const px = Math.round((e.clientX - r.left) / sc - elHome.x);
160
+ const py = Math.round((e.clientY - r.top) / sc - elHome.y);
161
+ const t = Math.round(usePlayerStore.getState().currentTime * 100) / 100;
162
+ void commitCreatePath(createSelector, t, px, py, commitMutation);
163
+ setMotionPathArmed(false);
164
+ };
165
+ surface.addEventListener("pointerdown", onDown, true);
166
+ return () => {
167
+ surface.removeEventListener("pointerdown", onDown, true);
168
+ surface.style.cursor = prevCursor;
169
+ };
170
+ }, [armed, createSelector, compW, iframeRef, commitMutation, setMotionPathArmed]);
171
+
172
+ if (!rect || rect.width <= 0 || !compositionSize || compositionSize.width <= 0) return null;
173
+ // Hide the whole overlay (path + create hint) when the element isn't painted —
174
+ // same "what you see in the preview" rule as the selection box.
175
+ if (!visibleInPreview) return null;
176
+ // No live anchor (element not in the current document) → can't place the path.
177
+ if (!home) return null;
178
+
179
+ if (!geometry) {
180
+ // Create mode draws nothing by default — the destination is set via the
181
+ // preview toolbar's "set destination" toggle (no text sprawled over the
182
+ // canvas). Only while armed do we show a faint ring at the element as a
183
+ // "click to place" cue (the surface cursor is also crosshair, set above).
184
+ if (!armed || !canCreate) return null;
185
+ const sc = rect.width / compositionSize.width;
186
+ const hr = (NODE_PX / sc) * 1.6;
187
+ return (
188
+ <svg
189
+ className="pointer-events-none absolute z-40"
190
+ style={{
191
+ left: rect.left,
192
+ top: rect.top,
193
+ width: rect.width,
194
+ height: rect.height,
195
+ // Don't clip the ring that extends past the canvas into the gray margin
196
+ // — only the preview viewport (`[data-preview-pan-surface]`,
197
+ // overflow-hidden) clips, so overlays reach the edge but never the panels.
198
+ overflow: "visible",
199
+ }}
200
+ viewBox={`0 0 ${compositionSize.width} ${compositionSize.height}`}
201
+ >
202
+ <circle
203
+ cx={home.x}
204
+ cy={home.y}
205
+ r={hr}
206
+ fill="none"
207
+ strokeWidth={1.5}
208
+ strokeDasharray="3 3"
209
+ vectorEffect="non-scaling-stroke"
210
+ style={{ stroke: ACCENT }}
211
+ opacity={0.85}
212
+ />
213
+ </svg>
214
+ );
215
+ }
216
+
217
+ const scale = rect.width / compositionSize.width;
218
+ const nodeR = NODE_PX / scale;
219
+ const interactive = Boolean(animId) && !isPlaying;
220
+ // The × "quick remove" badge applies to non-cubic motionPath arcs only (cubic
221
+ // anchors carry control points we don't synthesize; keyframe paths remove via
222
+ // the right-click menu instead).
223
+ const structural = geometry.kind === "arc" && interactive;
224
+ const removable = structural && geometry.nodes.length > 2;
225
+ // Click-on-path to insert a node works for both kinds: a motionPath waypoint
226
+ // (arc paths, including cubic — GSAP recomputes curves around the new point),
227
+ // or an x/y keyframe (linear paths) at the projected tween-%.
228
+ const addable = interactive;
229
+
230
+ const nodes = draft
231
+ ? geometry.nodes.map((n, i) => (i === draft.index ? { ...n, x: draft.x, y: draft.y } : n))
232
+ : geometry.nodes;
233
+ // ax/ay = absolute composition position (home + offset) for drawing; n.x/n.y
234
+ // stay offsets so the drag commit writes the right tween values.
235
+ const abs = nodes.map((n) => ({ ...n, ax: home.x + n.x, ay: home.y + n.y }));
236
+ const points = abs.map((p) => `${p.ax},${p.ay}`).join(" ");
237
+ // Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport
238
+ // rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the
239
+ // absolute-positioned SVG), so subtracting them from a viewport clientX/Y would
240
+ // offset the projection by the surface's gutter (panel/toolbar), and the add-
241
+ // ghost wouldn't track the cursor. `scale` is unaffected (width is stored raw).
242
+ const clientToComp = (e: React.PointerEvent) => {
243
+ const vr = iframeRef.current?.getBoundingClientRect();
244
+ const left = vr ? vr.left : rect.left;
245
+ const top = vr ? vr.top : rect.top;
246
+ return { x: (e.clientX - left) / scale, y: (e.clientY - top) / scale };
247
+ };
248
+
249
+ const onDown = (
250
+ e: React.PointerEvent,
251
+ index: number,
252
+ x: number,
253
+ y: number,
254
+ ref: MotionNodeRef,
255
+ ) => {
256
+ if (!interactive) return;
257
+ if (e.button !== 0) return; // primary button only — right-click is the context menu
258
+ e.stopPropagation();
259
+ (e.target as Element).setPointerCapture(e.pointerId);
260
+ dragRef.current = {
261
+ index,
262
+ startX: e.clientX,
263
+ startY: e.clientY,
264
+ initX: x,
265
+ initY: y,
266
+ scale,
267
+ ref,
268
+ };
269
+ setDraft({ index, x, y });
270
+ };
271
+ const onMove = (e: React.PointerEvent) => {
272
+ const d = dragRef.current;
273
+ if (!d) return;
274
+ setDraft({
275
+ index: d.index,
276
+ x: d.initX + (e.clientX - d.startX) / d.scale,
277
+ y: d.initY + (e.clientY - d.startY) / d.scale,
278
+ });
279
+ };
280
+ // fallow-ignore-next-line complexity
281
+ const onUp = (e: React.PointerEvent) => {
282
+ const d = dragRef.current;
283
+ if (!d) return;
284
+ dragRef.current = null;
285
+ setDraft(null);
286
+ if (!animId) return;
287
+ const screenDx = e.clientX - d.startX;
288
+ const screenDy = e.clientY - d.startY;
289
+ const x = Math.round(d.initX + screenDx / d.scale);
290
+ const y = Math.round(d.initY + screenDy / d.scale);
291
+ // Click-vs-drag is decided in SCREEN space, not composition px: the old guard
292
+ // compared rounded comp-px, which at high zoom (scale ≫ 1) swallowed real
293
+ // multi-px screen drags whose sub-comp-px delta rounds to 0 → the node would
294
+ // never move. A screen-distance threshold registers any genuine pointer drag
295
+ // at any zoom; below it the gesture is a click (select + park the playhead).
296
+ const movedScreenPx = Math.hypot(screenDx, screenDy);
297
+ if (movedScreenPx < DRAG_THRESHOLD_PX) {
298
+ // No drag → treat as a click: select this keyframe and park the playhead on
299
+ // it. Selecting it makes the next drag MODIFY this keyframe (honored via
300
+ // activeKeyframePct) instead of creating a new one.
301
+ if (d.ref.type === "keyframe") {
302
+ usePlayerStore.getState().setActiveKeyframePct(d.ref.pct);
303
+ const ref = d.ref;
304
+ // Debounce the playhead seek: a double-click cancels it (e.detail >= 2),
305
+ // so only a lone single-click parks the playhead on the keyframe.
306
+ clearTimeout(parkTimerRef.current);
307
+ if (e.detail < 2) {
308
+ parkTimerRef.current = setTimeout(() => {
309
+ const anim = selectedGsapAnimations?.find((a) => a.id === animId);
310
+ if (anim) parkPlayheadOnKeyframe(anim, ref.pct);
311
+ }, 250);
312
+ }
313
+ }
314
+ return; // no commit
315
+ }
316
+ // A real drag that still rounds to the same integer comp-px (sub-px move at
317
+ // high zoom) would commit an identical value — a no-op undo entry. Skip the
318
+ // commit, but don't treat it as a click either (the user did drag).
319
+ if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
320
+ void commitNode(d.ref, x, y, animId, commitMutation);
321
+ // Park the playhead on the edited keyframe's time so the element previews AT
322
+ // that keyframe. Without it, a playhead sitting before the tween renders the
323
+ // element's base pose — the edit (correct on the path) looks like it vanished.
324
+ if (d.ref.type === "keyframe") {
325
+ const anim = selectedGsapAnimations?.find((a) => a.id === animId);
326
+ if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct);
327
+ }
328
+ };
329
+
330
+ // Ghost "add" affordance: project the cursor onto the path; click inserts.
331
+ const onPathHover = (e: React.PointerEvent) => {
332
+ const c = clientToComp(e);
333
+ const np = nearestPointOnPath(
334
+ c.x,
335
+ c.y,
336
+ abs.map((p) => ({ x: p.ax, y: p.ay })),
337
+ );
338
+ setGhost(np ? { x: np.x, y: np.y, segIndex: np.segIndex } : null);
339
+ };
340
+ const onPathDown = (e: React.PointerEvent) => {
341
+ if (!animId) return;
342
+ // Compute the insertion point from the event directly so a click works
343
+ // without (or faster than) a preceding hover.
344
+ const c = clientToComp(e);
345
+ const np = nearestPointOnPath(
346
+ c.x,
347
+ c.y,
348
+ abs.map((p) => ({ x: p.ax, y: p.ay })),
349
+ );
350
+ if (!np) return;
351
+ const x = Math.round(np.x - home.x);
352
+ const y = Math.round(np.y - home.y);
353
+ if (geometry.kind === "arc") {
354
+ e.stopPropagation();
355
+ void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
356
+ } else {
357
+ // Linear keyframe path: interpolate the new stop's tween-% from the two
358
+ // keyframes bounding the clicked segment (np.t = fraction along it), then
359
+ // insert it. Lands ON the current line, so the dot doesn't jump — drag it
360
+ // after to bend the path.
361
+ const a = abs[np.segIndex]?.ref;
362
+ const b = abs[np.segIndex + 1]?.ref;
363
+ if (a?.type !== "keyframe" || b?.type !== "keyframe") return;
364
+ const pct = Math.round((a.pct + (b.pct - a.pct) * np.t) * 1000) / 1000;
365
+ e.stopPropagation();
366
+ void commitAddKeyframe(animId, pct, x, y, commitMutation);
367
+ }
368
+ setGhost(null);
369
+ };
370
+ const onRemove = (e: React.PointerEvent, index: number) => {
371
+ e.stopPropagation();
372
+ if (!animId) return;
373
+ setHoverNode(null);
374
+ void commitRemoveWaypoint(animId, index, commitMutation);
375
+ };
376
+
377
+ const elementId = selection?.id ?? null;
378
+ // Right-click a keyframe node → the timeline's keyframe context menu (delete
379
+ // this keyframe / delete all), so motion-path keyframes are removable in place.
380
+ const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
381
+ if (ref.type !== "keyframe" || !animId || !elementId) return;
382
+ e.preventDefault();
383
+ e.stopPropagation();
384
+ setKfMenu({
385
+ x: e.clientX,
386
+ y: e.clientY,
387
+ elementId,
388
+ percentage: ref.pct,
389
+ tweenPercentage: ref.pct,
390
+ });
391
+ };
392
+
393
+ return (
394
+ <>
395
+ <svg
396
+ className="pointer-events-none absolute z-40"
397
+ style={{
398
+ left: rect.left,
399
+ top: rect.top,
400
+ width: rect.width,
401
+ height: rect.height,
402
+ // Don't clip nodes/path past the canvas into the gray margin — only the
403
+ // preview viewport (overflow-hidden) clips, so overlays reach the edge
404
+ // but never the side panels.
405
+ overflow: "visible",
406
+ }}
407
+ viewBox={`0 0 ${compositionSize.width} ${compositionSize.height}`}
408
+ >
409
+ {/* Wide transparent hit path drives the add-ghost; drawn under the nodes.
410
+ Renders for keyframe paths and non-cubic arcs (see `addable`). */}
411
+ {addable && (
412
+ <polyline
413
+ points={points}
414
+ fill="none"
415
+ stroke="transparent"
416
+ strokeWidth={14 / scale}
417
+ className="pointer-events-auto"
418
+ style={{ cursor: "copy" }}
419
+ onPointerMove={onPathHover}
420
+ onPointerLeave={() => setGhost(null)}
421
+ onPointerDown={onPathDown}
422
+ />
423
+ )}
424
+ <polyline
425
+ points={points}
426
+ fill="none"
427
+ style={{ stroke: ACCENT }}
428
+ strokeWidth={1.5}
429
+ strokeDasharray="5 5"
430
+ strokeLinejoin="round"
431
+ strokeLinecap="round"
432
+ vectorEffect="non-scaling-stroke"
433
+ opacity={0.85}
434
+ />
435
+ {ghost && (
436
+ <rect
437
+ x={ghost.x - nodeR * 0.707}
438
+ y={ghost.y - nodeR * 0.707}
439
+ width={nodeR * 1.414}
440
+ height={nodeR * 1.414}
441
+ rx={nodeR * 0.24}
442
+ transform={`rotate(45 ${ghost.x} ${ghost.y})`}
443
+ fill="none"
444
+ strokeWidth={1.5}
445
+ strokeDasharray="2 2"
446
+ vectorEffect="non-scaling-stroke"
447
+ className="pointer-events-none"
448
+ style={{ stroke: ACCENT }}
449
+ />
450
+ )}
451
+ {abs.map((p, i) => (
452
+ <MotionPathNode
453
+ key={i}
454
+ cx={p.ax}
455
+ cy={p.ay}
456
+ r={nodeR}
457
+ interactive={interactive}
458
+ removable={removable && hoverNode === i}
459
+ grabbing={draft?.index === i}
460
+ selected={p.ref.type === "keyframe" && p.ref.pct === activeKeyframePct}
461
+ onEnter={() => setHoverNode(i)}
462
+ onLeave={() => setHoverNode((h) => (h === i ? null : h))}
463
+ onPointerDown={(e) => onDown(e, i, p.x, p.y, p.ref)}
464
+ onPointerMove={onMove}
465
+ onPointerUp={onUp}
466
+ onRemove={(e) => onRemove(e, i)}
467
+ onContextMenu={(e) => onNodeContextMenu(e, p.ref)}
468
+ />
469
+ ))}
470
+ </svg>
471
+ {kfMenu && (
472
+ <KeyframeDiamondContextMenu
473
+ state={kfMenu}
474
+ onClose={() => setKfMenu(null)}
475
+ onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)}
476
+ onDeleteAll={(elId) => handleGsapDeleteAllForElement(`#${elId}`)}
477
+ />
478
+ )}
479
+ </>
480
+ );
481
+ });
@@ -1,7 +1,7 @@
1
- // fallow-ignore-file unused-file
2
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
3
- import { MagnetStraight, GridFour } from "@phosphor-icons/react";
2
+ import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
4
3
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
4
+ import { usePlayerStore } from "../../player/store/playerStore";
5
5
 
6
6
  const SNAP_DEFAULTS = {
7
7
  snapEnabled: true,
@@ -34,6 +34,11 @@ interface SnapToolbarProps {
34
34
  export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolbarProps) {
35
35
  const [prefs, setPrefs] = useState(readSnapPrefs);
36
36
  const [gridPopoverOpen, setGridPopoverOpen] = useState(false);
37
+ // Motion-path "set destination" toggle — shown only when the selected element
38
+ // can take a path; arms a single canvas click to place it (MotionPathOverlay).
39
+ const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
40
+ const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
41
+ const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
37
42
  const popoverRef = useRef<HTMLDivElement>(null);
38
43
  const gridButtonRef = useRef<HTMLButtonElement>(null);
39
44
 
@@ -89,7 +94,27 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
89
94
  }, [gridPopoverOpen]);
90
95
 
91
96
  return (
92
- <div className="absolute top-2 right-2 z-50 flex items-center gap-1">
97
+ <div
98
+ className="absolute top-2 right-2 z-50 flex items-center gap-1"
99
+ onPointerDown={(e) => e.stopPropagation()}
100
+ >
101
+ {motionPathCreateAvailable && (
102
+ <button
103
+ type="button"
104
+ className={`rounded-md p-1.5 transition-colors ${
105
+ motionPathArmed
106
+ ? "bg-studio-accent/20 text-studio-accent"
107
+ : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
108
+ }`}
109
+ onClick={() => setMotionPathArmed(!motionPathArmed)}
110
+ title={
111
+ motionPathArmed ? "Click the canvas to set the destination" : "Set motion destination"
112
+ }
113
+ aria-label="Set motion destination"
114
+ >
115
+ <Path size={16} weight={motionPathArmed ? "fill" : "regular"} />
116
+ </button>
117
+ )}
93
118
  <button
94
119
  type="button"
95
120
  className={`rounded-md p-1.5 transition-colors ${
@@ -6,7 +6,7 @@ import {
6
6
  highlightActiveLine,
7
7
  highlightActiveLineGutter,
8
8
  } from "@codemirror/view";
9
- import { EditorState } from "@codemirror/state";
9
+ import { EditorState, Annotation } from "@codemirror/state";
10
10
  import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
11
11
  import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language";
12
12
  import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
@@ -18,6 +18,11 @@ import { css } from "@codemirror/lang-css";
18
18
  import { javascript } from "@codemirror/lang-javascript";
19
19
  import { markdown } from "@codemirror/lang-markdown";
20
20
 
21
+ // Marks a programmatic doc sync (external content push — e.g. a manual-edit
22
+ // commit writing the source) so the update listener doesn't mistake it for a
23
+ // user keystroke and trigger a re-save + preview reload.
24
+ const ExternalSync = Annotation.define<boolean>();
25
+
21
26
  const LANGUAGE_EXTENSIONS: Record<string, () => Extension> = {
22
27
  html: () => html(),
23
28
  css: () => css(),
@@ -89,9 +94,10 @@ export const SourceEditor = memo(function SourceEditor({
89
94
  const lang = language ?? (filePath ? detectLanguage(filePath) : "html");
90
95
 
91
96
  const updateListener = EditorView.updateListener.of((update) => {
92
- if (update.docChanged && onChangeRef.current) {
93
- onChangeRef.current(update.state.doc.toString());
94
- }
97
+ if (!update.docChanged || !onChangeRef.current) return;
98
+ // Ignore programmatic external syncs — only real user edits should save.
99
+ if (update.transactions.some((tr) => tr.annotation(ExternalSync))) return;
100
+ onChangeRef.current(update.state.doc.toString());
95
101
  });
96
102
 
97
103
  const state = EditorState.create({
@@ -130,11 +136,17 @@ export const SourceEditor = memo(function SourceEditor({
130
136
  const view = editorRef.current;
131
137
  if (!view) return;
132
138
  const current = view.state.doc.toString();
133
- if (current !== content) {
134
- view.dispatch({
135
- changes: { from: 0, to: current.length, insert: content },
136
- });
137
- }
139
+ if (current === content) return;
140
+ // If the user is actively typing (editor focused), a programmatic replace
141
+ // would clobber their in-flight keystrokes the ExternalSync annotation
142
+ // suppresses onChange, so those edits would be silently lost. Skip the
143
+ // external sync while focused; it re-runs on the next `content` change after
144
+ // they blur (or when a later commit lands with the editor unfocused).
145
+ if (view.hasFocus) return;
146
+ view.dispatch({
147
+ changes: { from: 0, to: current.length, insert: content },
148
+ annotations: [ExternalSync.of(true)],
149
+ });
138
150
  }, [content]);
139
151
 
140
152
  useEffect(() => {
@@ -25,6 +25,18 @@ export function isElementVisibleForOverlay(el: HTMLElement): boolean {
25
25
  return isElementVisibleThroughAncestors(el);
26
26
  }
27
27
 
28
+ // Sample points (as fractions of the element box) for the occlusion hit-test:
29
+ // the four inner corners plus the center. This is a coarse approximation of the
30
+ // element's painted area — we assume a sampled point that lands inside the box also
31
+ // lands on something the element actually paints.
32
+ //
33
+ // LIMITATION: a donut/ring-shaped element (a hole in the middle, content only around
34
+ // the edges) breaks that assumption — the center sample, and even the corner samples,
35
+ // can fall in the transparent hole and hit-test through to whatever is behind, so the
36
+ // element could read as occluded (or as covering) incorrectly. Today's scene element
37
+ // shapes (rectangular cards, text, full-bleed media) don't have interior holes, so this
38
+ // doesn't bite. If ring/cutout shapes become editable targets, sample more densely or
39
+ // hit-test against the element's actual painted geometry instead of its bounding box.
28
40
  function readPositiveDimension(value: string | null): number | null {
29
41
  if (!value) return null;
30
42
  const parsed = Number.parseFloat(value);
@@ -173,7 +173,11 @@ export type UseDomEditOverlayGesturesOptions = {
173
173
  onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
174
174
  onManualDragStartRef: RefObject<(() => void) | undefined>;
175
175
  onPathOffsetCommitRef: RefObject<
176
- (s: DomEditSelection, n: { x: number; y: number }) => Promise<void> | void
176
+ (
177
+ s: DomEditSelection,
178
+ n: { x: number; y: number },
179
+ m?: { altKey?: boolean },
180
+ ) => Promise<void> | void
177
181
  >;
178
182
  onGroupPathOffsetCommitRef: RefObject<
179
183
  (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
@@ -5,6 +5,7 @@
5
5
  import { type DomEditSelection } from "./domEditing";
6
6
  import {
7
7
  createManualOffsetDragMember,
8
+ readGsapRotation,
8
9
  restoreManualOffsetDragMembers,
9
10
  type ManualOffsetDragMember,
10
11
  } from "./manualOffsetDrag";
@@ -115,7 +116,10 @@ export function startGesture(
115
116
  return false;
116
117
 
117
118
  const size = readStudioBoxSize(sel.element);
118
- const rotation = readStudioRotation(sel.element);
119
+ // Single-source rotation base = the live GSAP transform rotation plus any legacy
120
+ // `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
121
+ // element's actual visual angle and commits an absolute angle to the timeline.
122
+ const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
119
123
  const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX;
120
124
  const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY;
121
125
  let initialPathOffset = captureStudioPathOffset(sel.element);