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
@@ -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">