fix(studio): gesture recording replaces existing position keyframes (#1360)

Gesture recording uses replace-with-keyframes mutation to replace existing
position-group tween. Fix N1 sign inversion and N9 wheel startPointer
with pointerElementOffset subtraction.
This commit is contained in:
Miguel Ángel
2026-06-12 00:20:08 -04:00
committed by GitHub
parent 191a33dc2c
commit d49ee416a3
16 changed files with 468 additions and 217 deletions
@@ -434,6 +434,10 @@ type GsapMutationRequest =
| {
type: "split-into-property-groups";
animationId: string;
}
| {
type: "delete-all-for-selector";
targetSelector: string;
};
// ── GSAP mutation executor ──────────────────────────────────────────────────
@@ -528,6 +532,17 @@ async function executeGsapMutation(
}
return removeAnimationFromScript(block.scriptText, body.animationId);
}
case "delete-all-for-selector": {
const parsed = parseGsapScript(block.scriptText);
const matching = parsed.animations.filter((a) => a.targetSelector === body.targetSelector);
if (matching.length === 0) return block.scriptText;
stripStudioEditsFromTarget(block.document, body.targetSelector);
let script = block.scriptText;
for (const anim of matching.reverse()) {
script = removeAnimationFromScript(script, anim.id);
}
return script;
}
case "add-property": {
const r = requireAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
@@ -108,6 +108,7 @@ export function StudioPreviewArea({
handlePreviewCanvasPointerMove,
handlePreviewCanvasPointerLeave,
applyDomSelection,
buildDomSelectionFromTarget,
handleBlockedDomMove,
handleDomManualDragStart,
handleDomPathOffsetCommit,
@@ -119,7 +120,7 @@ export function StudioPreviewArea({
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapConvertToKeyframes,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
} = useDomEditContext();
const [snapPrefs, setSnapPrefs] = useState(() => {
@@ -153,10 +154,9 @@ export function StudioPreviewArea({
onRazorSplit={handleRazorSplit}
onRazorSplitAll={handleRazorSplitAll}
onSelectTimelineElement={handleTimelineElementSelect}
onDeleteAllKeyframes={(_elId) => {
for (const anim of selectedGsapAnimations) {
handleGsapDeleteAnimation(anim.id);
}
onDeleteAllKeyframes={(elId) => {
const rawId = elId.includes("#") ? elId.split("#").pop()! : elId;
handleGsapDeleteAllForElement(`#${rawId}`);
}}
onDeleteKeyframe={(_elId, pct) => {
const cacheKey = domEditSelection?.id ?? "";
@@ -185,11 +185,23 @@ export function StudioPreviewArea({
selectedGsapAnimations.find((a) => a.keyframes);
if (!anim?.keyframes) return;
const tweenOldPct = cachedKf?.tweenPercentage ?? oldPct;
const kf = anim.keyframes.keyframes.find((k) => k.percentage === oldPct);
const kf = anim.keyframes.keyframes.find(
(k) => Math.abs(k.percentage - tweenOldPct) < 0.2,
);
if (!kf) return;
const tweenStart = anim.resolvedStart ?? 0;
const tweenDur = anim.duration ?? 1;
const newAbsTime = _el.start + (newPct / 100) * _el.duration;
const tweenNewPct =
tweenDur > 0
? Math.max(
0,
Math.min(100, Math.round(((newAbsTime - tweenStart) / tweenDur) * 1000) / 10),
)
: 0;
handleGsapRemoveKeyframe(anim.id, tweenOldPct);
for (const [prop, val] of Object.entries(kf.properties)) {
handleGsapAddKeyframe(anim.id, newPct, prop, val);
handleGsapAddKeyframe(anim.id, tweenNewPct, prop, val);
}
}}
onToggleKeyframeAtPlayhead={(el) => {
@@ -279,6 +291,14 @@ export function StudioPreviewArea({
onRotationCommit={handleDomRotationCommit}
gridVisible={snapPrefs.gridVisible}
gridSpacing={snapPrefs.gridSpacing}
onSelectElementById={async (id) => {
const iframe = previewIframeRef.current;
const el = iframe?.contentDocument?.getElementById(id);
if (!el) return null;
const sel = await buildDomSelectionFromTarget(el);
if (sel) applyDomSelection(sel, { revealPanel: true });
return sel;
}}
/>
<SnapToolbar onSnapChange={setSnapPrefs} />
{gestureOverlay}
@@ -17,26 +17,6 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { canSplitElement } from "../utils/timelineElementSplit";
function AutoKeyframeToggle() {
const enabled = usePlayerStore((s) => s.autoKeyframeEnabled);
return (
<Tooltip label={enabled ? "Auto-keyframe ON" : "Auto-keyframe OFF"}>
<button
type="button"
onClick={() => usePlayerStore.getState().setAutoKeyframeEnabled(!enabled)}
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
enabled ? "text-red-400" : "text-neutral-600 hover:text-neutral-400"
}`}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
{enabled && <circle cx="7" cy="7" r="3" fill="currentColor" />}
</svg>
</button>
</Tooltip>
);
}
interface DomEditSessionSlice extends EnableKeyframesSession {
domEditSelection: DomEditSelection | null;
selectedGsapAnimations: GsapAnimation[];
@@ -169,7 +149,6 @@ export function TimelineToolbar({
</svg>
</button>
</Tooltip>
<AutoKeyframeToggle />
</>
)}
{onSplitElement &&
@@ -13,6 +13,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
import { GridOverlay } from "./GridOverlay";
import { useOffScreenIndicators } from "./useOffScreenIndicators";
// Re-exports for external consumers — preserving existing import paths.
export {
@@ -54,6 +55,7 @@ interface DomEditOverlayProps {
) => void;
onBlockedMove: (selection: DomEditSelection) => void;
onManualDragStart?: () => void;
onSelectElementById?: (id: string) => Promise<DomEditSelection | null>;
onPathOffsetCommit: (
selection: DomEditSelection,
next: { x: number; y: number },
@@ -83,6 +85,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
gridVisible = false,
gridSpacing = 50,
onManualDragStart,
onSelectElementById,
onPathOffsetCommit,
onGroupPathOffsetCommit,
onBoxSizeCommit,
@@ -212,6 +215,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
return () => cancelAnimationFrame(frame);
});
const offScreenIndicators = useOffScreenIndicators({ iframeRef, overlayRef, compRect });
const gestures = createDomEditOverlayGestureHandlers({
overlayRef,
iframeRef,
@@ -263,6 +268,22 @@ export const DomEditOverlay = memo(function DomEditOverlay({
}
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
// Don't re-resolve selection when clicking outside the composition bounds —
// the iframe can't resolve elements there, so it would clear the selection.
if (selection && compRect.width > 0) {
const overlayEl = overlayRef.current;
if (overlayEl) {
const overlayRect = overlayEl.getBoundingClientRect();
const clickX = event.clientX - overlayRect.left;
const clickY = event.clientY - overlayRect.top;
const outsideComp =
clickX < compRect.left ||
clickX > compRect.left + compRect.width ||
clickY < compRect.top ||
clickY > compRect.top + compRect.height;
if (outsideComp) return;
}
}
onCanvasMouseDown(event, { preferClipAncestor: false });
if (event.shiftKey) {
suppressNextBoxMouseDownRef.current = true;
@@ -500,6 +521,64 @@ export const DomEditOverlay = memo(function DomEditOverlay({
}}
/>
))}
{offScreenIndicators.length > 0 &&
compRect.width > 0 &&
offScreenIndicators.map((ind) => {
const isSelected = selection?.id === ind.elementId;
return (
<div
key={`offscreen-${ind.key}`}
className={`absolute rounded-sm ${isSelected ? "pointer-events-none" : "cursor-grab"}`}
style={{
left: ind.left,
top: ind.top,
width: ind.width,
height: ind.height,
border: `1.5px dashed var(--panel-accent, #34d399)`,
opacity: isSelected ? 0.3 : 0.5,
zIndex: isSelected ? 1 : 5,
}}
onPointerDown={
isSelected
? undefined
: (e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const el = e.currentTarget;
el.setPointerCapture(e.pointerId);
let deltaX = 0;
let deltaY = 0;
let moved = false;
const onMove = (me: PointerEvent) => {
deltaX = me.clientX - startX;
deltaY = me.clientY - startY;
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) moved = true;
if (moved) {
el.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
}
};
const onUp = async (ue: PointerEvent) => {
el.releasePointerCapture(ue.pointerId);
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerup", onUp);
el.style.transform = "";
const sel = await onSelectElementById?.(ind.elementId);
if (moved && sel && onPathOffsetCommit) {
const scale = compRect.scaleX || 1;
onPathOffsetCommit(sel, { x: deltaX / scale, y: deltaY / scale });
}
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerup", onUp);
}
}
title={isSelected ? undefined : `Drag #${ind.elementId}`}
/>
);
})}
<GridOverlay
visible={gridVisible}
spacing={gridSpacing}
@@ -90,7 +90,7 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"],
false,
true,
);
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
@@ -0,0 +1,197 @@
/**
* Detects GSAP-animated elements whose center is outside the visible composition
* area and returns edge-clamped indicator positions for each.
*/
import { useRef, useState, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
export interface OffScreenIndicator {
key: string;
elementId: string;
left: number;
top: number;
width: number;
height: number;
}
interface CompRect {
left: number;
top: number;
width: number;
height: number;
scaleX: number;
scaleY: number;
}
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
function isHtmlElement(node: unknown): node is HTMLElement {
return (
typeof node === "object" &&
node !== null &&
typeof (node as HTMLElement).getBoundingClientRect === "function" &&
typeof (node as HTMLElement).tagName === "string"
);
}
function collectGsapTargetElements(iframe: HTMLIFrameElement): HTMLElement[] {
const win = iframe.contentWindow as
| (Window & { __timelines?: Record<string, TimelineLike> })
| null;
if (!win) return [];
let timelines: Record<string, TimelineLike> | undefined;
try {
timelines = win.__timelines;
} catch {
return [];
}
if (!timelines) return [];
const seen = new Set<HTMLElement>();
for (const tl of Object.values(timelines)) {
if (!tl?.getChildren) continue;
try {
for (const child of tl.getChildren(true)) {
if (!child.targets) continue;
for (const t of child.targets()) {
if (isHtmlElement(t)) seen.add(t);
}
}
} catch {
// cross-origin or detached timeline — skip
}
}
return Array.from(seen);
}
function indicatorsEqual(a: OffScreenIndicator[], b: OffScreenIndicator[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const ai = a[i]!;
const bi = b[i]!;
if (
ai.key !== bi.key ||
Math.abs(ai.left - bi.left) > 0.5 ||
Math.abs(ai.top - bi.top) > 0.5 ||
Math.abs(ai.width - bi.width) > 0.5 ||
Math.abs(ai.height - bi.height) > 0.5
)
return false;
}
return true;
}
export function useOffScreenIndicators({
iframeRef,
overlayRef,
compRect,
}: {
iframeRef: RefObject<HTMLIFrameElement | null>;
overlayRef: RefObject<HTMLDivElement | null>;
compRect: CompRect;
}): OffScreenIndicator[] {
const [indicators, setIndicators] = useState<OffScreenIndicator[]>([]);
const prevRef = useRef<OffScreenIndicator[]>([]);
const compRectRef = useRef(compRect);
compRectRef.current = compRect;
useMountEffect(() => {
let frame = 0;
const update = () => {
frame = requestAnimationFrame(update);
const iframe = iframeRef.current;
const overlayEl = overlayRef.current;
const cr = compRectRef.current;
if (!iframe || !overlayEl || cr.width <= 0 || cr.height <= 0) {
if (prevRef.current.length > 0) {
prevRef.current = [];
setIndicators([]);
}
return;
}
const iframeRect = iframe.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
const doc = iframe.contentDocument;
const root =
doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
if (!root) return;
const declaredWidth =
Number.parseFloat(root.getAttribute("data-width") ?? "") || iframeRect.width;
const declaredHeight =
Number.parseFloat(root.getAttribute("data-height") ?? "") || iframeRect.height;
const rootScaleX = iframeRect.width / declaredWidth;
const rootScaleY = iframeRect.height / declaredHeight;
const targets = collectGsapTargetElements(iframe);
if (targets.length === 0) {
if (prevRef.current.length > 0) {
prevRef.current = [];
setIndicators([]);
}
return;
}
// Composition bounds in overlay coordinates
const compLeft = cr.left;
const compTop = cr.top;
const compRight = compLeft + cr.width;
const compBottom = compTop + cr.height;
const next: OffScreenIndicator[] = [];
const keyCounts = new Map<string, number>();
for (const el of targets) {
if (!el.isConnected) continue;
const elRect = el.getBoundingClientRect();
if (elRect.width <= 0 && elRect.height <= 0) continue;
// Element rect in overlay coordinates
const elLeft = iframeRect.left - overlayRect.left + elRect.left * rootScaleX;
const elTop = iframeRect.top - overlayRect.top + elRect.top * rootScaleY;
const elW = elRect.width * rootScaleX;
const elH = elRect.height * rootScaleY;
// Check if the element is fully inside the composition
if (
elLeft >= compLeft &&
elTop >= compTop &&
elLeft + elW <= compRight &&
elTop + elH <= compBottom
) {
continue;
}
// Only elements with a real id attribute can be selected via getElementById
if (!el.id) continue;
const count = keyCounts.get(el.id) ?? 0;
keyCounts.set(el.id, count + 1);
const key = count > 0 ? `${el.id}:${count}` : el.id;
next.push({
key,
elementId: el.id,
left: elLeft,
top: elTop,
width: elW,
height: elH,
});
}
if (!indicatorsEqual(prevRef.current, next)) {
prevRef.current = next;
setIndicators(next);
}
};
frame = requestAnimationFrame(update);
return () => cancelAnimationFrame(frame);
});
return indicators;
}
@@ -366,25 +366,27 @@ export const NLELayout = memo(function NLELayout({
{/* Preview + player controls */}
<div className="flex-1 min-h-0 flex flex-col">
<div
className="flex-1 min-h-0 relative overflow-hidden"
className="flex-1 min-h-0 relative"
data-preview-pan-surface="true"
onDragOver={handlePreviewDragOver}
onDragLeave={handlePreviewDragLeave}
onDrop={handlePreviewDrop}
>
<NLEPreview
projectId={projectId}
iframeRef={iframeRef}
onIframeLoad={onIframeLoad}
onCompositionLoadingChange={setCompositionLoading}
portrait={portrait}
directUrl={directUrl}
suppressLoadingOverlay={hasLoadedOnceRef.current}
onStageRef={handleStageRef}
/>
{previewDragOver && (
<div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
)}
<div className="absolute inset-0 overflow-hidden">
<NLEPreview
projectId={projectId}
iframeRef={iframeRef}
onIframeLoad={onIframeLoad}
onCompositionLoadingChange={setCompositionLoading}
portrait={portrait}
directUrl={directUrl}
suppressLoadingOverlay={hasLoadedOnceRef.current}
onStageRef={handleStageRef}
/>
{previewDragOver && (
<div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
)}
</div>
{!isFullscreen && previewOverlay}
</div>
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
@@ -61,6 +61,7 @@ export function DomEditProvider({
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
@@ -134,6 +135,7 @@ export function DomEditProvider({
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
@@ -201,6 +203,7 @@ export function DomEditProvider({
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
+3 -16
View File
@@ -23,6 +23,7 @@ export interface GsapDragCommitCallbacks {
beforeReload?: () => void;
},
) => Promise<void>;
fetchAnimations?: () => Promise<GsapAnimation[]>;
}
// ── Percentage computation ─────────────────────────────────────────────────
@@ -150,7 +151,6 @@ async function commitKeyframedPosition(
): Promise<void> {
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
if (activeKeyframePct != null) setActiveKeyframePct(null);
await callbacks.commitMutation(
selection,
{
@@ -161,6 +161,7 @@ async function commitKeyframedPosition(
},
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
);
if (activeKeyframePct != null) setActiveKeyframePct(null);
}
/**
@@ -275,21 +276,7 @@ export async function commitGsapPositionFromDrag(
{ label: "Split from() for drag", skipReload: true },
);
// Check if a position-group tween already exists (e.g. from gesture recording).
// If so, extend it instead of creating a duplicate.
const allAnims = await (async () => {
const pid = selection.sourceFile || "index.html";
try {
const r = await fetch(
`/api/projects/${encodeURIComponent(window.location.hash.match(/project\/([^?/]+)/)?.[1] ?? "")}/gsap-animations/${encodeURIComponent(pid)}`,
);
if (!r.ok) return [];
const parsed = await r.json();
return (parsed?.animations ?? []) as GsapAnimation[];
} catch {
return [];
}
})();
const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
const existingPosAnim = allAnims.find(
(a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
);
@@ -226,6 +226,7 @@ export async function tryGsapDragIntercept(
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});
return true;
}
+16 -15
View File
@@ -262,6 +262,7 @@ export function useDomEditSession({
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
deleteAllForSelector,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
@@ -329,11 +330,15 @@ export function useDomEditSession({
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
const handleGsapAwarePathOffsetCommit = useCallback(
async (selection: DomEditSelection, next: { x: number; y: number }) => {
if (
STUDIO_GSAP_DRAG_INTERCEPT_ENABLED &&
gsapCommitMutation &&
usePlayerStore.getState().autoKeyframeEnabled
) {
const hasGsapAnims = selectedGsapAnimations.length > 0;
if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
showToast(
"This element is GSAP-animated — dragging via CSS would corrupt keyframes",
"error",
);
return;
}
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
const handled = await tryGsapDragIntercept(
selection,
next,
@@ -360,6 +365,7 @@ export function useDomEditSession({
previewIframeRef,
projectId,
gsapSourceFile,
showToast,
],
);
@@ -379,11 +385,7 @@ export function useDomEditSession({
const handleGsapAwareBoxSizeCommit = useCallback(
async (selection: DomEditSelection, next: { width: number; height: number }) => {
if (
STUDIO_GSAP_DRAG_INTERCEPT_ENABLED &&
gsapCommitMutation &&
usePlayerStore.getState().autoKeyframeEnabled
) {
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
const handled = await tryGsapResizeIntercept(
selection,
next,
@@ -407,11 +409,7 @@ export function useDomEditSession({
const handleGsapAwareRotationCommit = useCallback(
async (selection: DomEditSelection, next: { angle: number }) => {
if (
STUDIO_GSAP_DRAG_INTERCEPT_ENABLED &&
gsapCommitMutation &&
usePlayerStore.getState().autoKeyframeEnabled
) {
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
const handled = await tryGsapRotationIntercept(
selection,
next.angle,
@@ -437,6 +435,7 @@ export function useDomEditSession({
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
@@ -454,6 +453,7 @@ export function useDomEditSession({
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
deleteAllForSelector,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
@@ -562,6 +562,7 @@ export function useDomEditSession({
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
+59 -44
View File
@@ -56,7 +56,9 @@ export function useGestureCommit({
// fallow-ignore-next-line complexity
const stopAndCommitRecording = useCallback(async () => {
clearInterval(recordingAutoStopRef.current);
if (commitInFlightRef.current) return;
if (commitInFlightRef.current) {
return;
}
commitInFlightRef.current = true;
gestureStateRef.current = "idle";
isGestureRecordingRef.current = false;
@@ -109,63 +111,73 @@ export function useGestureCommit({
percentage: pct,
properties: simplified.get(pct) as Record<string, number | string>,
}));
// Check if the recorded gesture contains position properties.
// If so, and a position-group tween already exists for this element,
// replace it atomically instead of adding a duplicate.
const hasPositionProps = keyframes.some((kf) =>
Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
);
const allAnims = liveSession.selectedGsapAnimations ?? [];
const existingPositionTween = hasPositionProps
? liveSession.selectedGsapAnimations?.find(
(a) => a.propertyGroup === "position" && a.targetSelector === selector,
)
? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector)
: undefined;
if (existingPositionTween) {
const tweenStart = existingPositionTween.resolvedStart ?? 0;
const tweenDur = existingPositionTween.duration ?? duration;
const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
const tweenEnd = tweenStart + tweenDur;
const recEnd = recStart + duration;
// Map the recording window (recStart..recStart+duration) into the
// existing tween's 0-100% space so we can merge instead of nuke.
const rangeStartPct = tweenDur > 0 ? ((recStart - tweenStart) / tweenDur) * 100 : 0;
const rangeEndPct =
tweenDur > 0 ? ((recStart + duration - tweenStart) / tweenDur) * 100 : 100;
// Only merge if the recording overlaps the existing tween's time range.
// No overlap → fall through to add-with-keyframes (creates a separate tween).
const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05;
// Keep existing keyframes that fall outside the recording window
const preserved = existingKfs
.filter(
(kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
)
.map((kf) => ({
percentage: kf.percentage,
if (overlaps) {
const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
const rangeStartPct =
tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0;
const rangeEndPct =
tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100;
const preserved = existingKfs
.filter(
(kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
)
.map((kf) => ({
percentage: kf.percentage,
properties: kf.properties,
...(kf.ease ? { ease: kf.ease } : {}),
}));
const mapped = keyframes.map((kf) => ({
percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
properties: kf.properties,
...(kf.ease ? { ease: kf.ease } : {}),
}));
// Map recorded keyframes (0-100% of recording) into tween percentage space
const mapped = keyframes.map((kf) => ({
percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
properties: kf.properties,
}));
const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
await liveSession.commitMutation(
{
type: "replace-with-keyframes",
animationId: existingPositionTween.id,
targetSelector: selector,
position:
typeof existingPositionTween.position === "number"
? existingPositionTween.position
: tweenStart,
duration: tweenDur,
keyframes: merged,
},
{ label: "Gesture recording (merge)", softReload: true },
);
await liveSession.commitMutation(
{
type: "replace-with-keyframes",
animationId: existingPositionTween.id,
targetSelector: selector,
position:
typeof existingPositionTween.position === "number"
? existingPositionTween.position
: tweenStart,
duration: tweenDur,
keyframes: merged,
},
{ label: "Gesture recording (merge)", softReload: true },
);
} else {
await liveSession.commitMutation(
{
type: "add-with-keyframes",
targetSelector: selector,
position: Math.round(recStart * 1000) / 1000,
duration: Math.round(duration * 1000) / 1000,
keyframes,
},
{ label: "Gesture recording (new range)", softReload: true },
);
}
} else {
await liveSession.commitMutation(
{
@@ -180,6 +192,9 @@ export function useGestureCommit({
}
}
showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
} catch (err) {
console.error("[GR:error]", err);
showToast(`Gesture commit failed: ${err}`, "error");
} finally {
store.requestSeek(recordingStartTimeRef.current);
gestureRecording.clearSamples();
@@ -260,6 +260,16 @@ export function useGsapScriptCommits({
},
[commitMutation],
);
const deleteAllForSelector = useCallback(
(selection: DomEditSelection, targetSelector: string) => {
void commitMutation(
selection,
{ type: "delete-all-for-selector", targetSelector },
{ label: "Delete all animations for element" },
);
},
[commitMutation],
);
const addGsapAnimation = useCallback(
// fallow-ignore-next-line complexity
async (
@@ -558,6 +568,7 @@ export function useGsapScriptCommits({
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
deleteAllForSelector,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
import { usePlayerStore } from "../player";
@@ -13,6 +13,7 @@ export function useGsapSelectionHandlers({
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
deleteAllForSelector,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
@@ -40,6 +41,7 @@ export function useGsapSelectionHandlers({
updates: { duration?: number; ease?: string; position?: number },
) => void;
deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => void;
addGsapAnimation: (
sel: DomEditSelection,
method: "to" | "from" | "set" | "fromTo",
@@ -79,6 +81,9 @@ export function useGsapSelectionHandlers({
handleDomManualEditsReset: (sel: DomEditSelection) => void;
selectedGsapAnimations: { id: string; keyframes?: unknown }[];
}) {
const lastSelectionRef = useRef<DomEditSelection | null>(null);
if (domEditSelection) lastSelectionRef.current = domEditSelection;
const handleGsapUpdateProperty = useCallback(
(animId: string, prop: string, value: number | string) => {
if (!domEditSelection) return;
@@ -97,12 +102,22 @@ export function useGsapSelectionHandlers({
const handleGsapDeleteAnimation = useCallback(
(animId: string) => {
if (!domEditSelection) return;
deleteGsapAnimation(domEditSelection, animId);
const sel = domEditSelection ?? lastSelectionRef.current;
if (!sel) return;
deleteGsapAnimation(sel, animId);
},
[domEditSelection, deleteGsapAnimation],
);
const handleGsapDeleteAllForElement = useCallback(
(targetSelector: string) => {
const sel = domEditSelection ?? lastSelectionRef.current;
if (!sel) return;
deleteAllForSelector(sel, targetSelector);
},
[domEditSelection, deleteAllForSelector],
);
const handleGsapAddAnimation = useCallback(
(method: "to" | "from" | "set" | "fromTo") => {
if (!domEditSelection) return;
@@ -205,6 +220,7 @@ export function useGsapSelectionHandlers({
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
@@ -1,4 +1,5 @@
import { memo } from "react";
import { createPortal } from "react-dom";
import type { TimelineElement } from "../store/playerStore";
import { canSplitElement } from "../../utils/timelineElementSplit";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
@@ -24,8 +25,11 @@ export const ClipContextMenu = memo(function ClipContextMenu({
}: ClipContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
const adjustedX = Math.min(x, window.innerWidth - 200);
const adjustedY = Math.min(y, window.innerHeight - 200);
const menuWidth = 200;
const menuHeight = 80;
const overflowY = y + menuHeight - window.innerHeight;
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
const canSplit =
@@ -37,7 +41,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
? `Split at ${currentTime.toFixed(2)}s`
: "Split (move playhead inside clip)";
return (
return createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
@@ -78,6 +82,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
<span>Delete</span>
<span className="text-neutral-500 text-[10px] ml-3"></span>
</button>
</div>
</div>,
document.body,
);
});
@@ -1,5 +1,5 @@
import { memo, useRef } from "react";
import { EASE_LABELS } from "../../components/editor/gsapAnimationConstants";
import { memo } from "react";
import { createPortal } from "react-dom";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
export interface KeyframeDiamondContextMenuState {
@@ -16,99 +16,30 @@ interface KeyframeDiamondContextMenuProps {
onClose: () => void;
onDelete: (elementId: string, percentage: number) => void;
onDeleteAll: (elementId: string) => void;
onChangeEase: (elementId: string, percentage: number, ease: string) => void;
onCopyProperties: (elementId: string, percentage: number) => void;
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
onCopyProperties?: (elementId: string, percentage: number) => void;
}
const EASE_PRESETS = [
"none",
"power1.out",
"power2.out",
"power3.out",
"power1.in",
"power2.in",
"power1.inOut",
"power2.inOut",
"back.out",
"elastic.out",
"bounce.out",
"expo.out",
] as const;
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
state,
onClose,
onDelete,
onDeleteAll,
onChangeEase,
onCopyProperties,
}: KeyframeDiamondContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
const easeSubmenuRef = useRef<HTMLDivElement>(null);
const adjustedX = Math.min(state.x, window.innerWidth - 200);
const adjustedY = Math.min(state.y, window.innerHeight - 300);
const menuWidth = 200;
const menuHeight = 70;
const overflowY = state.y + menuHeight - window.innerHeight;
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
const currentEaseLabel = state.currentEase
? (EASE_LABELS[state.currentEase] ?? state.currentEase)
: "Default";
return (
return createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
style={{ left: adjustedX, top: adjustedY }}
>
{/* Ease submenu */}
<div className="relative group">
<button
type="button"
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
>
<span>
Ease: <span className="text-neutral-500">{currentEaseLabel}</span>
</span>
<svg width="8" height="8" viewBox="0 0 8 8" className="text-neutral-500 ml-2">
<path d="M3 1l4 3-4 3" fill="none" stroke="currentColor" strokeWidth="1.2" />
</svg>
</button>
<div
ref={easeSubmenuRef}
className="absolute left-full top-0 ml-0.5 hidden group-hover:block bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px] max-h-[300px] overflow-y-auto"
>
{EASE_PRESETS.map((ease) => (
<button
key={ease}
type="button"
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-neutral-800 cursor-pointer text-left ${
ease === state.currentEase ? "text-white font-medium" : "text-neutral-300"
}`}
onClick={() => {
onChangeEase(state.elementId, state.percentage, ease);
onClose();
}}
>
{ease === state.currentEase && (
<svg
width="8"
height="8"
viewBox="0 0 8 8"
className="text-green-400 flex-shrink-0"
>
<path d="M1 4l2 2 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
</svg>
)}
<span className={ease === state.currentEase ? "" : "ml-[16px]"}>
{EASE_LABELS[ease] ?? ease}
</span>
</button>
))}
</div>
</div>
{/* Separator */}
<div className="my-1 border-t border-neutral-700/60" />
{/* Delete */}
<button
type="button"
@@ -131,18 +62,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
>
Delete All Keyframes
</button>
{/* Copy Properties */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
onClick={() => {
onCopyProperties(state.elementId, state.percentage);
onClose();
}}
>
Copy Properties
</button>
</div>
</div>,
document.body,
);
});