diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 56af6bef2..8171302ab 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -266,8 +266,10 @@ export function StudioApp() { const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise>( async () => {}, ); - const domEditDeleteBridge = async (s: DomEditSelection) => - handleDomEditElementDeleteRef.current(s); + const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s); + const resetKeyframesRef = useRef<() => boolean>(() => false); + const deleteSelectedKeyframesRef = useRef<() => void>(() => {}); + const invalidateGsapCacheRef = useRef<() => void>(() => {}); const { handleCopy, handlePaste, handleCut } = useClipboard({ projectId, activeCompPath, @@ -299,8 +301,10 @@ export function StudioApp() { handleCopy, handlePaste, handleCut, + onResetKeyframes: () => resetKeyframesRef.current(), + onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), + onAfterUndoRedo: () => invalidateGsapCacheRef.current(), }); - const selectSidebarTabStable = useCallback( (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), [], @@ -345,11 +349,20 @@ export function StudioApp() { selectSidebarTab: selectSidebarTabStable, getSidebarTab: getSidebarTabStable, }); - domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; - + resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes; + invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache; + deleteSelectedKeyframesRef.current = () => { + const sk = usePlayerStore.getState().selectedKeyframes; + const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes); + if (!a || sk.size === 0) return; + sk.forEach((k) => { + const p = Number(k.split(":")[1]); + if (Number.isFinite(p)) domEditSession.handleGsapRemoveKeyframe(a.id, p); + }); + }; useCaptionDetection({ projectId, activeCompPath, @@ -470,12 +483,14 @@ export function StudioApp() { timelineVisible, toggleTimelineVisibility, }); - - if (resolving || waitingForServer || !projectId) { + if (resolving || waitingForServer || !projectId) return ; - } - - const timelineToolbar = ; + const timelineToolbar = ( + + ); return ( @@ -540,7 +555,6 @@ export function StudioApp() { {lintModal !== null && ( )} - {consoleErrors !== null && consoleErrors.length > 0 && ( setConsoleErrors(null)} /> )} - {domEditSession.agentModalOpen && domEditSession.domEditSelection && ( } - {appToast && (
{ + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim) handleGsapRemoveAllKeyframes(anim.id); + }} + onDeleteKeyframe={(_elId, pct) => { + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim) handleGsapRemoveKeyframe(anim.id, pct); + }} + onChangeKeyframeEase={(_elId, _pct, ease) => { + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim) handleGsapUpdateMeta(anim.id, { ease }); + }} + // fallow-ignore-next-line complexity + onMoveKeyframe={(_el, oldPct, newPct) => { + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (!anim?.keyframes) return; + const kf = anim.keyframes.keyframes.find((k) => k.percentage === oldPct); + if (!kf) return; + handleGsapRemoveKeyframe(anim.id, oldPct); + for (const [prop, val] of Object.entries(kf.properties)) { + handleGsapAddKeyframe(anim.id, newPct, prop, val); + } + }} + onToggleKeyframeAtPlayhead={(el) => { + const currentTime = usePlayerStore.getState().currentTime; + const pct = + el.duration > 0 + ? Math.max( + 0, + Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)), + ) + : 0; + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim?.keyframes) { + const existing = anim.keyframes.keyframes.find( + (k) => Math.abs(k.percentage - pct) <= 1, + ); + if (existing) { + handleGsapRemoveKeyframe(anim.id, existing.percentage); + } else { + handleGsapAddKeyframe(anim.id, pct, "x", 0); + } + } else { + const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes); + if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id); + } + }} onCompIdToSrcChange={setCompIdToSrc} onCompositionLoadingChange={setCompositionLoading} onCompositionChange={(compPath) => { diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 3849ce048..839d3f7b3 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -9,13 +9,29 @@ import { METHOD_LABELS, METHOD_TOOLTIPS, PERCENT_PROPS, + PROP_CONSTRAINTS, PROP_LABELS, PROP_TOOLTIPS, PROP_UNITS, + clampPropertyValue, } from "./gsapAnimationConstants"; import { buildTweenSummary } from "./gsapAnimationHelpers"; import { EaseCurveSection } from "./EaseCurveSection"; const BOOLEAN_PROPS = new Set(["visibility"]); +const STRING_PROPS = new Set(["filter", "clipPath"]); + +const FILTER_PRESETS = [ + { label: "Blur", value: "blur(4px)" }, + { label: "Bright", value: "brightness(1.5)" }, + { label: "Gray", value: "grayscale(1)" }, + { label: "None", value: "none" }, +]; + +const CLIP_PATH_PRESETS = [ + { label: "Circle", value: "circle(50% at 50% 50%)" }, + { label: "Inset", value: "inset(10%)" }, + { label: "None", value: "none" }, +]; function isPercentProp(prop: string): boolean { return PERCENT_PROPS.has(prop); @@ -27,7 +43,11 @@ function displayValue(prop: string, val: number | string): string { } function adjustedValue(prop: string, raw: string): string { - if (isPercentProp(prop)) return String(Math.max(0, Math.min(1, Number(raw) / 100))); + if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100)); + const num = Number(raw); + if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) { + return String(clampPropertyValue(prop, num)); + } return raw; } @@ -90,6 +110,48 @@ function PropertyRow({ ); } + if (STRING_PROPS.has(prop)) { + const presets = + prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : []; + return ( +
+
+
+ + {PROP_LABELS[prop] ?? prop} + + onCommit(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + /> +
+ +
+ {presets.length > 0 && ( +
+ {presets.map((p) => ( + + ))} +
+ )} +
+ ); + } + return (
@@ -292,8 +354,10 @@ export const AnimationCard = memo(function AnimationCard({ {methodLabel} - {typeof animation.position === "number" ? `${animation.position}s` : animation.position} –{" "} - {typeof endTime === "number" ? `${endTime.toFixed(1)}s` : endTime} + {typeof animation.position === "number" + ? `${parseFloat(animation.position.toFixed(3))}s` + : animation.position}{" "} + – {typeof endTime === "number" ? `${parseFloat(endTime.toFixed(3))}s` : endTime} {easeLabel} @@ -344,7 +408,7 @@ export const AnimationCard = memo(function AnimationCard({ value={ typeof animation.position === "string" ? animation.position - : String(Math.max(0, animation.position)) + : String(parseFloat(Math.max(0, animation.position).toFixed(3))) } suffix={typeof animation.position === "number" ? "s" : undefined} tooltip="When this effect begins on the timeline" diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index e93d2c1cd..19dc6e8d9 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -14,7 +14,9 @@ import { MetricField, Section } from "./propertyPanelPrimitives"; import { isMediaElement, MediaSection } from "./propertyPanelMediaSection"; import { TextSection, StyleSections } from "./propertyPanelSections"; import { GsapAnimationSection } from "./GsapAnimationSection"; -import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability"; +import { KeyframeNavigation } from "./KeyframeNavigation"; +import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability"; +import { usePlayerStore } from "../../player"; // Re-export helpers that external consumers import from this module export { @@ -65,6 +67,15 @@ interface PropertyPanelProps { onAddGsapFromProperty?: (animId: string, prop: string) => void; onRemoveGsapFromProperty?: (animId: string, prop: string) => void; onAddGsapAnimation?: (method: "to" | "from" | "set" | "fromTo") => void; + onAddKeyframe?: ( + animationId: string, + percentage: number, + property: string, + value: number | string, + ) => void; + onRemoveKeyframe?: (animationId: string, percentage: number) => void; + onConvertToKeyframes?: (animationId: string) => void; + onSeekToTime?: (time: number) => void; } /* ------------------------------------------------------------------ */ @@ -170,6 +181,10 @@ export const PropertyPanel = memo(function PropertyPanel({ onAddGsapFromProperty, onRemoveGsapFromProperty, onAddGsapAnimation, + onAddKeyframe, + onRemoveKeyframe, + onConvertToKeyframes, + onSeekToTime, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; @@ -223,6 +238,11 @@ export const PropertyPanel = memo(function PropertyPanel({ const commitManualOffset = (axis: "x" | "y", nextValue: string) => { const parsed = parsePxMetricValue(nextValue); if (parsed == null) return; + if (gsapKeyframes && gsapAnimId && onAddKeyframe) { + const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10)); + onAddKeyframe(gsapAnimId, pct, axis, parsed); + return; + } const current = readStudioPathOffset(element.element); onSetManualOffset(element, { x: axis === "x" ? parsed : current.x, @@ -256,6 +276,16 @@ export const PropertyPanel = memo(function PropertyPanel({ onSetManualRotation(element, { angle: parsed }); }; + // Keyframe navigation state + const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0; + const currentTime = usePlayerStore((s) => s.currentTime); + const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0; + + const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null; + const gsapAnimId = + gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null; + return (
@@ -317,39 +347,118 @@ export const PropertyPanel = memo(function PropertyPanel({
}>
- commitManualOffset("x", next)} - /> - commitManualOffset("y", next)} - /> - commitManualSize("width", next)} - /> - commitManualSize("height", next)} - /> - commitManualRotation(next.replace("°", ""))} - /> +
+
+ commitManualOffset("x", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "x", manualOffset.x)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualOffset("y", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "y", manualOffset.y)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualSize("width", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "width", resolvedWidth)} + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualSize("height", next)} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => + onAddKeyframe?.(gsapAnimId, pct, "height", resolvedHeight) + } + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+
+
+ commitManualRotation(next.replace("°", ""))} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => + onAddKeyframe?.(gsapAnimId, pct, "rotation", manualRotation.angle) + } + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
= { autoAlpha: "Visibility", visibility: "Visible", scaleX_alias: "Stretch X", + filter: "Filter", + clipPath: "Clip Path", + color: "Color", + backgroundColor: "Background", + borderColor: "Border Color", + borderRadius: "Radius", + fontSize: "Font Size", + letterSpacing: "Tracking", + skewX: "Skew X", + skewY: "Skew Y", }; export const PROP_UNITS: Record = { @@ -83,6 +93,11 @@ export const EASE_LABELS: Record = { "expo.out": "Very snappy stop", "expo.in": "Very slow start", "expo.inOut": "Dramatic ease", + "spring-gentle": "Gentle spring", + "spring-bouncy": "Bouncy spring", + "spring-stiff": "Stiff spring", + "spring-wobbly": "Wobbly spring", + "spring-heavy": "Heavy spring", }; export const EASE_CURVES: Record = { @@ -123,6 +138,33 @@ export function parseCustomEaseFromString(ease: string): { export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]); +export const PROP_CONSTRAINTS: Record = { + opacity: { min: 0, max: 1, step: 0.01 }, + autoAlpha: { min: 0, max: 1, step: 0.01 }, + scale: { min: -10, max: 10, step: 0.01 }, + scaleX: { min: -10, max: 10, step: 0.01 }, + scaleY: { min: -10, max: 10, step: 0.01 }, + rotation: { step: 1 }, + skewX: { min: -90, max: 90, step: 1 }, + skewY: { min: -90, max: 90, step: 1 }, + width: { min: 0, step: 1 }, + height: { min: 0, step: 1 }, + borderRadius: { min: 0, step: 1 }, + x: { step: 1 }, + y: { step: 1 }, + fontSize: { min: 1, step: 1 }, + letterSpacing: { step: 0.1 }, +}; + +export function clampPropertyValue(prop: string, value: number): number { + const constraint = PROP_CONSTRAINTS[prop]; + if (!constraint) return value; + let clamped = value; + if (constraint.min !== undefined) clamped = Math.max(constraint.min, clamped); + if (constraint.max !== undefined) clamped = Math.min(constraint.max, clamped); + return clamped; +} + export const ADD_METHODS = ["to", "from", "fromTo", "set"] as const; export const ADD_METHOD_LABELS: Record = { diff --git a/packages/studio/src/components/editor/gsapAnimationHelpers.ts b/packages/studio/src/components/editor/gsapAnimationHelpers.ts index 87cfca545..911cbcd3c 100644 --- a/packages/studio/src/components/editor/gsapAnimationHelpers.ts +++ b/packages/studio/src/components/editor/gsapAnimationHelpers.ts @@ -14,7 +14,8 @@ export function buildTweenSummary(animation: GsapAnimation): string { const props = Object.entries(animation.properties); const target = animation.targetSelector; const dur = animation.duration ?? 0; - const pos = animation.position; + const rawPos = animation.position; + const pos = typeof rawPos === "number" ? parseFloat(rawPos.toFixed(3)) : rawPos; const propDescs = props.map(([p, v]) => { const label = (PROP_LABELS[p] ?? p).toLowerCase(); return `${label} to ${formatPropValue(p, v)}`; diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 6845956a6..553bb5e8d 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -68,6 +68,12 @@ export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag( export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag( env, ["VITE_STUDIO_ENABLE_GSAP_PANEL", "VITE_STUDIO_GSAP_PANEL_ENABLED"], + true, +); + +export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"], false, ); diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index b08a8c567..c4e50a0d1 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -223,6 +223,7 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean { } function stripGsapTranslateFromTransform(element: HTMLElement): void { + if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return; const transform = element.style.getPropertyValue("transform"); if (!transform || transform === "none") return; const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null) @@ -233,8 +234,11 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void { if (m.m41 === 0 && m.m42 === 0) return; const offsetX = readPxCustomProperty(element, STUDIO_OFFSET_X_PROP); const offsetY = readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP); - m.m41 -= offsetX; - m.m42 -= offsetY; + const angle = Math.atan2(m.b, m.a); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + m.m41 -= offsetX * cos - offsetY * sin; + m.m42 -= offsetX * sin + offsetY * cos; if (Math.abs(m.m41) < 0.01 && Math.abs(m.m42) < 0.01 && isIdentityAfterTranslateStrip(m)) { element.style.removeProperty("transform"); } else { diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 67aae3397..9df465a00 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -236,9 +236,25 @@ export function createManualOffsetDragMember(input: { const gestureToken = beginStudioManualEditGesture(input.element); const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset); if (!measured.ok) { - restoreStudioPathOffset(input.element, initialPathOffset); - endStudioManualEditGesture(input.element, gestureToken); - return { ok: false, reason: measured.reason, selection: input.selection }; + // Fallback: when GSAP transforms interfere with probe measurement, use + // the preview scale as an approximation. The commit path reads the actual + // GSAP position from the iframe runtime, so visual imprecision during + // drag is acceptable — the final committed position is always exact. + const scaleX = input.rect.editScaleX || 1; + const scaleY = input.rect.editScaleY || 1; + return { + ok: true, + member: { + key: input.key, + selection: input.selection, + element: input.element, + initialOffset, + initialPathOffset, + gestureToken, + screenToOffset: { a: 1 / scaleX, b: 0, c: 0, d: 1 / scaleY }, + originRect: input.rect, + }, + }; } return { diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 07562e113..cad19d33f 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -70,6 +70,11 @@ interface NLELayoutProps { ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; + onDeleteKeyframe?: (elementId: string, percentage: number) => void; + onDeleteAllKeyframes?: (elementId: string) => void; + onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; + onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; + onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => void; /** Whether the timeline panel is visible (default: true) */ @@ -118,6 +123,11 @@ export const NLELayout = memo(function NLELayout({ onResizeElement, onBlockedEditAttempt, onSelectTimelineElement, + onDeleteKeyframe, + onDeleteAllKeyframes, + onChangeKeyframeEase, + onMoveKeyframe, + onToggleKeyframeAtPlayhead, onCompIdToSrcChange, timelineVisible, onToggleTimeline, @@ -448,6 +458,11 @@ export const NLELayout = memo(function NLELayout({ onResizeElement={onResizeElement} onBlockedEditAttempt={onBlockedEditAttempt} onSelectElement={onSelectTimelineElement} + onDeleteKeyframe={onDeleteKeyframe} + onDeleteAllKeyframes={onDeleteAllKeyframes} + onChangeKeyframeEase={onChangeKeyframeEase} + onMoveKeyframe={onMoveKeyframe} + onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead} />
{timelineFooter &&
{timelineFooter}
} diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index b91d54a49..39b7183da 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -65,6 +65,13 @@ export function DomEditProvider({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache, + previewIframeRef, }, children, }: { @@ -125,6 +132,13 @@ export function DomEditProvider({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache, + previewIframeRef, }), [ domEditSelection, @@ -179,6 +193,13 @@ export function DomEditProvider({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache, + previewIframeRef, ], ); return {children}; diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index a8841318d..3a967e5e6 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -146,7 +146,9 @@ export async function tryGsapDragIntercept( const gsapPos = readGsapPositionFromIframe(iframe, selector); if (!gsapPos) return false; - await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, { commitMutation }); + await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, { + commitMutation, + }); return true; } @@ -171,14 +173,33 @@ async function commitGsapPositionFromDrag( anim: GsapAnimation, studioOffset: { x: number; y: number }, gsapPos: { x: number; y: number }, + iframe: HTMLIFrameElement | null, + selector: string, callbacks: GsapDragCommitCallbacks, ): Promise { - const newX = Math.round(gsapPos.x + studioOffset.x); - const newY = Math.round(gsapPos.y + studioOffset.y); + // CSS composition: translate → rotate → transform. The studioOffset is in + // pre-rotation space (CSS translate), but GSAP x/y are in post-CSS-rotate + // space (CSS transform). Counter-rotate the offset to match GSAP's frame. + const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation"); + const rotDeg = Number.parseFloat(rotStyle) || 0; + const rad = (-rotDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const adjX = studioOffset.x * cos - studioOffset.y * sin; + const adjY = studioOffset.x * sin + studioOffset.y * cos; + const newX = Math.round(gsapPos.x + adjX); + const newY = Math.round(gsapPos.y + adjY); const clearOffset = () => clearStudioPathOffset(selection.element); if (anim.keyframes) { - await commitKeyframedPosition(selection, anim, newX, newY, callbacks, clearOffset); + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + await commitKeyframedPosition( + selection, + anim, + { ...runtimeProps, x: newX, y: newY }, + callbacks, + clearOffset, + ); } else if (anim.method === "from") { await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset); } else if (anim.method === "fromTo") { @@ -186,7 +207,14 @@ async function commitGsapPositionFromDrag( } else { // Flat to()/set() — convert to keyframes first so the drag position // is captured at the current seek time, not just the tween endpoint. - await commitFlatViaKeyframes(selection, anim, newX, newY, callbacks, clearOffset); + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + await commitFlatViaKeyframes( + selection, + anim, + { ...runtimeProps, x: newX, y: newY }, + callbacks, + clearOffset, + ); } } @@ -194,8 +222,7 @@ async function commitGsapPositionFromDrag( async function commitKeyframedPosition( selection: DomEditSelection, anim: GsapAnimation, - newX: number, - newY: number, + properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -207,7 +234,7 @@ async function commitKeyframedPosition( type: "add-keyframe", animationId: anim.id, percentage: pct, - properties: { x: newX, y: newY }, + properties, }, { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, ); @@ -222,8 +249,7 @@ async function commitKeyframedPosition( async function commitFlatViaKeyframes( selection: DomEditSelection, anim: GsapAnimation, - newX: number, - newY: number, + properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -241,7 +267,7 @@ async function commitFlatViaKeyframes( type: "add-keyframe", animationId: anim.id, percentage: pct, - properties: { x: newX, y: newY }, + properties, }, { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, ); @@ -305,3 +331,161 @@ async function commitFromToPosition( { label: "Move (GSAP to y)", softReload: true, beforeReload }, ); } + +// ── Runtime property reader ─────────────────────────────────────────────── + +function readAllAnimatedProperties( + iframe: HTMLIFrameElement | null, + selector: string, + anim: GsapAnimation, +): Record { + const result: Record = {}; + if (!iframe?.contentWindow) return result; + let gsap: IframeGsap | undefined; + try { + gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap; + } catch { + return result; + } + if (!gsap?.getProperty) return result; + let doc: Document | null = null; + try { + doc = iframe.contentDocument; + } catch { + return result; + } + const el = doc?.querySelector(selector); + if (!el) return result; + + const propKeys = new Set(); + if (anim.keyframes) { + for (const kf of anim.keyframes.keyframes) { + for (const p of Object.keys(kf.properties)) { + if (typeof kf.properties[p] === "number") propKeys.add(p); + } + } + } else { + for (const p of Object.keys(anim.properties)) propKeys.add(p); + } + + for (const prop of propKeys) { + const val = Number(gsap.getProperty(el, prop)); + if (Number.isFinite(val)) result[prop] = Math.round(val); + } + return result; +} + +// ── Resize intercept ────────────────────────────────────────────────────── + +export async function tryGsapResizeIntercept( + selection: DomEditSelection, + size: { width: number; height: number }, + animations: GsapAnimation[], + iframe: HTMLIFrameElement | null, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + fetchFallbackAnimations?: () => Promise, +): Promise { + let anim = animations.find( + (a) => "width" in a.properties || "height" in a.properties || a.keyframes, + ); + if (!anim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + anim = fresh.find((a) => "width" in a.properties || "height" in a.properties || a.keyframes); + } + if (!anim) return false; + + const pct = computeCurrentPercentage(selection); + + if (!anim.keyframes) { + await commitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id }, + { label: "Convert to keyframes for resize", skipReload: true }, + ); + } + + const selector = selectorForSelection(selection); + const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; + const properties = { + ...runtimeProps, + width: Math.round(size.width), + height: Math.round(size.height), + }; + + await commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties, + }, + { label: `Resize (keyframe ${pct}%)`, softReload: true }, + ); + return true; +} + +// ── Rotation intercept ──────────────────────────────────────────────────── + +export async function tryGsapRotationIntercept( + selection: DomEditSelection, + angle: number, + animations: GsapAnimation[], + iframe: HTMLIFrameElement | null, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + fetchFallbackAnimations?: () => Promise, +): Promise { + let anim = animations.find((a) => "rotation" in a.properties || a.keyframes); + if (!anim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + anim = fresh.find((a) => "rotation" in a.properties || a.keyframes); + } + if (!anim) return false; + + const selector = selectorForSelection(selection); + if (!selector) return false; + + let gsapRotation = 0; + if (iframe?.contentWindow) { + try { + const gsap = ( + iframe.contentWindow as unknown as { + gsap?: { getProperty: (el: Element, prop: string) => number }; + } + ).gsap; + const doc = iframe.contentDocument; + const el = doc?.querySelector(selector); + if (gsap?.getProperty && el) { + gsapRotation = Number(gsap.getProperty(el, "rotation")) || 0; + } + } catch { + /* cross-origin guard */ + } + } + + const pct = computeCurrentPercentage(selection); + const newRotation = Math.round(gsapRotation + angle); + + if (!anim.keyframes) { + await commitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id }, + { label: "Convert to keyframes for rotation", skipReload: true }, + ); + } + + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + const properties = { ...runtimeProps, rotation: newRotation }; + + await commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties, + }, + { label: `Rotate (keyframe ${pct}%)`, softReload: true }, + ); + return true; +} diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 658740151..0a995341f 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -352,23 +352,25 @@ export function useDomEditCommits({ const handleDomBoxSizeCommit = useCallback( (selection: DomEditSelection, next: { width: number; height: number }) => { applyStudioBoxSize(selection.element, next); + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), { label: "Resize layer box", coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml], + [commitPositionPatchToHtml, previewIframeRef], ); const handleDomRotationCommit = useCallback( (selection: DomEditSelection, next: { angle: number }) => { applyStudioRotation(selection.element, next); + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), { label: "Rotate layer", coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml], + [commitPositionPatchToHtml, previewIframeRef], ); const handleDomManualEditsReset = useCallback( diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 209be746a..5edaef835 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -23,7 +23,11 @@ import { fetchParsedAnimations, getAnimationsForElement, } from "./useGsapTweenCache"; -import { tryGsapDragIntercept } from "./gsapRuntimeBridge"; +import { + tryGsapDragIntercept, + tryGsapResizeIntercept, + tryGsapRotationIntercept, +} from "./gsapRuntimeBridge"; // ── Types ── @@ -326,6 +330,68 @@ export function useDomEditSession({ ], ); + const makeFetchFallback = useCallback( + (selection: DomEditSelection) => async () => { + const pid = projectId; + if (!pid) return []; + const parsed = await fetchParsedAnimations(pid, gsapSourceFile); + if (!parsed) return []; + return getAnimationsForElement(parsed.animations, { + id: selection.id ?? null, + selector: selection.selector ?? null, + }); + }, + [projectId, gsapSourceFile], + ); + + const handleGsapAwareBoxSizeCommit = useCallback( + async (selection: DomEditSelection, next: { width: number; height: number }) => { + if (gsapCommitMutation) { + const handled = await tryGsapResizeIntercept( + selection, + next, + selectedGsapAnimations, + previewIframeRef.current, + gsapCommitMutation, + makeFetchFallback(selection), + ); + if (handled) return; + } + handleDomBoxSizeCommit(selection, next); + }, + [ + handleDomBoxSizeCommit, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + makeFetchFallback, + ], + ); + + const handleGsapAwareRotationCommit = useCallback( + async (selection: DomEditSelection, next: { angle: number }) => { + if (gsapCommitMutation) { + const handled = await tryGsapRotationIntercept( + selection, + next.angle, + selectedGsapAnimations, + previewIframeRef.current, + gsapCommitMutation, + makeFetchFallback(selection), + ); + if (handled) return; + } + handleDomRotationCommit(selection, next); + }, + [ + handleDomRotationCommit, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + makeFetchFallback, + ], + ); + const handleGsapUpdateProperty = useCallback( (animId: string, prop: string, value: number | string) => { if (!domEditSelection) return; @@ -552,8 +618,8 @@ export function useDomEditSession({ handleDomHtmlAttributeCommit, handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit, handleDomGroupPathOffsetCommit, - handleDomBoxSizeCommit, - handleDomRotationCommit, + handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit, + handleDomRotationCommit: handleGsapAwareRotationCommit, handleDomManualEditsReset, handleDomMotionCommit, handleDomMotionClear, diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx index 9f410a0c4..593f27f61 100644 --- a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -13,6 +13,7 @@ interface KeyframeDiamondContextMenuProps { state: KeyframeDiamondContextMenuState; 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; } @@ -36,6 +37,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe state, onClose, onDelete, + onDeleteAll, onChangeEase, onCopyProperties, }: KeyframeDiamondContextMenuProps) { @@ -135,6 +137,17 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe Delete Keyframe + + {/* Copy Properties */}
@@ -511,6 +566,23 @@ export const Timeline = memo(function Timeline({ }} /> )} + + {kfContextMenu && ( + setKfContextMenu(null)} + onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)} + onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)} + onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)} + onCopyProperties={(elId, pct) => { + const kfData = keyframeCache.get(elId); + const kf = kfData?.keyframes.find((k) => k.percentage === pct); + if (kf) { + void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2)); + } + }} + /> + )}
); }); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index e9af933ed..1d9233930 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -1,5 +1,6 @@ import { memo, type ReactNode } from "react"; import { TimelineClip } from "./TimelineClip"; +import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelineRuler } from "./TimelineRuler"; import { getTimelineEditCapabilities, @@ -8,9 +9,10 @@ import { } from "./timelineEditing"; import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme"; import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; -import type { TimelineElement } from "../store/playerStore"; +import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore"; import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; import type { TrackVisualStyle } from "./timelineIcons"; +import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; interface TimelineCanvasProps { major: number[]; @@ -58,6 +60,14 @@ interface TimelineCanvasProps { } | null>; getPreviewElement: (element: TimelineElement) => TimelineElement; getTrackStyle: (tag: string) => TrackVisualStyle; + keyframeCache?: Map; + selectedKeyframes: Set; + currentTime: number; + onClickKeyframe?: (element: TimelineElement, percentage: number) => void; + onShiftClickKeyframe?: (elementId: string, percentage: number) => void; + onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; + onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; + onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; } export const TimelineCanvas = memo(function TimelineCanvas({ @@ -99,6 +109,14 @@ export const TimelineCanvas = memo(function TimelineCanvas({ shiftClickClipRef, getPreviewElement, getTrackStyle, + keyframeCache, + selectedKeyframes, + currentTime, + onClickKeyframe, + onShiftClickKeyframe, + onDragKeyframe, + onContextMenuKeyframe, + onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead, }: TimelineCanvasProps) { const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = @@ -328,6 +346,28 @@ export const TimelineCanvas = memo(function TimelineCanvas({ }} > {renderClipChildren(previewElement, clipStyle)} + {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && ( + 0 + ? ((currentTime - previewElement.start) / previewElement.duration) * 100 + : 0 + } + elementId={elementKey} + selectedKeyframes={selectedKeyframes} + onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)} + onShiftClickKeyframe={onShiftClickKeyframe} + onDragKeyframe={(oldPct, newPct) => + onDragKeyframe?.(previewElement, oldPct, newPct) + } + onContextMenuKeyframe={onContextMenuKeyframe} + /> + )} ); })} diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index a003a5cb0..c964545a8 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -69,7 +69,9 @@ export const TimelineClip = memo(function TimelineClip({