mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user