@hyperframes/studio 0.6.121 → 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.
- package/dist/assets/index-CbNg8Ghm.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
- package/dist/assets/{index-CLsc2FW3.js → index-DsxCdV8b.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3757 -2232
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -39
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +7 -1
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-20PAVS4k.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- 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
|
|
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
|
|
93
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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);
|