diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 3aa2fc69f..62feac775 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -6,7 +6,7 @@ */ export const SUPPORTED_PROPS = [ - // Transforms + // 2D Transforms "x", "y", "scale", @@ -15,6 +15,13 @@ export const SUPPORTED_PROPS = [ "rotation", "skewX", "skewY", + // 3D Transforms + "z", + "rotationX", + "rotationY", + "rotationZ", + "perspective", + "transformOrigin", // Visibility "opacity", "visibility", diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 8171302ab..52f10be23 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -286,6 +286,7 @@ export function StudioApp() { const appHotkeys = useAppHotkeys({ toggleTimelineVisibility, handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete, + handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit, handleDomEditElementDelete: domEditDeleteBridge, domEditSelectionRef: domEditSelectionBridgeRef, clearDomSelectionRef, @@ -489,6 +490,7 @@ export function StudioApp() { ); return ( @@ -532,6 +534,7 @@ export function StudioApp() { handleTimelineElementMove={timelineEditing.handleTimelineElementMove} handleTimelineElementResize={timelineEditing.handleTimelineElementResize} handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit} + handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit} setCompIdToSrc={setCompIdToSrc} setCompositionLoading={setCompositionLoading} shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index fd92d36ff..0dfbf0392 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -49,6 +49,7 @@ export interface StudioPreviewAreaProps { updates: Pick, ) => Promise | void; handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise | void; setCompIdToSrc: (map: Map) => void; setCompositionLoading: (loading: boolean) => void; shouldShowSelectedDomBounds: boolean; @@ -67,6 +68,7 @@ export function StudioPreviewArea({ handleTimelineElementMove, handleTimelineElementResize, handleBlockedTimelineEdit, + handleTimelineElementSplit, setCompIdToSrc, setCompositionLoading, shouldShowSelectedDomBounds, @@ -107,7 +109,7 @@ export function StudioPreviewArea({ handleGsapUpdateMeta, handleGsapAddKeyframe, handleGsapConvertToKeyframes, - handleGsapRemoveAllKeyframes, + handleGsapDeleteAnimation, } = useDomEditContext(); return ( @@ -127,10 +129,12 @@ export function StudioPreviewArea({ onMoveElement={handleTimelineElementMove} onResizeElement={handleTimelineElementResize} onBlockedEditAttempt={handleBlockedTimelineEdit} + onSplitElement={handleTimelineElementSplit} onSelectTimelineElement={handleTimelineElementSelect} onDeleteAllKeyframes={(_elId) => { - const anim = selectedGsapAnimations.find((a) => a.keyframes); - if (anim) handleGsapRemoveAllKeyframes(anim.id); + const anim = + selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0]; + if (anim) handleGsapDeleteAnimation(anim.id); }} onDeleteKeyframe={(_elId, pct) => { const anim = selectedGsapAnimations.find((a) => a.keyframes); diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 39409046c..a0adf87d4 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -91,6 +91,7 @@ export function StudioRightPanel({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + commitAnimatedProperty, } = useDomEditContext(); const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } = @@ -211,6 +212,7 @@ export function StudioRightPanel({ onImportAssets={handleImportFiles} fontAssets={fontAssets} onImportFonts={handleImportFonts} + previewIframeRef={previewIframeRef} gsapAnimations={selectedGsapAnimations} gsapMultipleTimelines={gsapMultipleTimelines} gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern} @@ -223,6 +225,7 @@ export function StudioRightPanel({ onAddGsapFromProperty={handleGsapAddFromProperty} onRemoveGsapFromProperty={handleGsapRemoveFromProperty} onAddGsapAnimation={handleGsapAddAnimation} + onCommitAnimatedProperty={commitAnimatedProperty} /> ) : motionPanelActive ? ( )} - {onSplitElement && ( - - - - )} + {onSplitElement && + (() => { + const { selectedElementId, elements, currentTime } = usePlayerStore.getState(); + const el = selectedElementId + ? elements.find((e) => (e.key ?? e.id) === selectedElementId) + : null; + const splittable = + el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag); + if (!splittable) return null; + const canSplit = currentTime > el.start && currentTime < el.start + el.duration; + return ( + + + + ); + })()}
diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 19dc6e8d9..4abbc018b 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -52,6 +52,7 @@ interface PropertyPanelProps { onImportAssets?: (files: FileList) => Promise; fontAssets?: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; + previewIframeRef?: React.RefObject; gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[]; gsapMultipleTimelines?: boolean; gsapUnsupportedTimelinePattern?: boolean; @@ -75,6 +76,11 @@ interface PropertyPanelProps { ) => void; onRemoveKeyframe?: (animationId: string, percentage: number) => void; onConvertToKeyframes?: (animationId: string) => void; + onCommitAnimatedProperty?: ( + selection: DomEditSelection, + property: string, + value: number | string, + ) => Promise; onSeekToTime?: (time: number) => void; } @@ -169,6 +175,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onImportAssets, fontAssets = [], onImportFonts, + previewIframeRef, gsapAnimations = [], gsapMultipleTimelines, gsapUnsupportedTimelinePattern, @@ -184,6 +191,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onAddKeyframe, onRemoveKeyframe, onConvertToKeyframes, + onCommitAnimatedProperty, onSeekToTime, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; @@ -238,6 +246,10 @@ export const PropertyPanel = memo(function PropertyPanel({ const commitManualOffset = (axis: "x" | "y", nextValue: string) => { const parsed = parsePxMetricValue(nextValue); if (parsed == null) return; + if (onCommitAnimatedProperty && (gsapAnimId || gsapAnimations.length > 0)) { + void onCommitAnimatedProperty(element, axis, parsed); + return; + } if (gsapKeyframes && gsapAnimId && onAddKeyframe) { const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10)); onAddKeyframe(gsapAnimId, pct, axis, parsed); @@ -286,6 +298,49 @@ export const PropertyPanel = memo(function PropertyPanel({ const gsapAnimId = gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null; + // Read ALL GSAP-interpolated values at the current seek time. + // Discovers animated properties from the animation's keyframes/tween vars. + const gsapRuntimeValues: Record | null = (() => { + if (!gsapAnimId || gsapAnimations.length === 0) return null; + const iframe = previewIframeRef?.current; + if (!iframe?.contentWindow) return null; + const selector = element.id ? `#${element.id}` : element.selector; + if (!selector) return null; + try { + const gsap = ( + iframe.contentWindow as unknown as { + gsap?: { getProperty: (el: Element, prop: string) => number | string }; + } + ).gsap; + if (!gsap?.getProperty) return null; + const el = iframe.contentDocument?.querySelector(selector); + if (!el) return null; + const propKeys = new Set(); + for (const anim of gsapAnimations) { + if (anim.keyframes) { + for (const kf of anim.keyframes.keyframes) { + for (const p of Object.keys(kf.properties)) propKeys.add(p); + } + } + for (const p of Object.keys(anim.properties)) propKeys.add(p); + } + const result: Record = {}; + for (const prop of propKeys) { + const v = Number(gsap.getProperty(el, prop)); + if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100; + } + return Object.keys(result).length > 0 ? result : null; + } catch { + return null; + } + })(); + + const displayX = gsapRuntimeValues?.x ?? manualOffset.x; + const displayY = gsapRuntimeValues?.y ?? manualOffset.y; + const displayW = gsapRuntimeValues?.width ?? resolvedWidth; + const displayH = gsapRuntimeValues?.height ?? resolvedHeight; + const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle; + return (
@@ -351,7 +406,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualOffset("x", next)} @@ -363,7 +418,10 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "x", manualOffset.x)} + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "x", displayX) + } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -373,7 +431,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualOffset("y", next)} @@ -385,7 +443,10 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "y", manualOffset.y)} + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "y", displayY) + } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -395,7 +456,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualSize("width", next)} @@ -407,7 +468,10 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "width", resolvedWidth)} + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "width", displayW) + } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -417,7 +481,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualSize("height", next)} @@ -429,8 +493,9 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => - onAddKeyframe?.(gsapAnimId, pct, "height", resolvedHeight) + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "height", displayH) } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} @@ -441,7 +506,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualRotation(next.replace("°", ""))} />
@@ -451,8 +516,9 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => - onAddKeyframe?.(gsapAnimId, pct, "rotation", manualRotation.angle) + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "rotation", displayR) } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} @@ -460,7 +526,80 @@ export const PropertyPanel = memo(function PropertyPanel({ )}
+ {gsapRuntimeValues && ( +
+
+ 3D Transform +
+
+
+
+ { + const v = parsePxMetricValue(next); + if (v != null && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "z", v); + } + }} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={() => { + if (onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0); + } + }} + onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+ { + const v = Number.parseFloat(next); + if (Number.isFinite(v) && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "scale", v); + } + }} + /> + { + const v = Number.parseFloat(next.replace("°", "")); + if (Number.isFinite(v) && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "rotationX", v); + } + }} + /> + { + const v = Number.parseFloat(next.replace("°", "")); + if (Number.isFinite(v) && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "rotationY", v); + } + }} + /> +
+
+ )}
+
+ Stacking +
Array<{ targets?: () => Element[]; vars?: Record }>; + } + >; + }) + | null; + if (!win?.__timelines) return false; + const propSet = new Set(props); + for (const tl of Object.values(win.__timelines)) { + if (!tl?.getChildren) continue; + try { + for (const child of tl.getChildren(true)) { + if (!child.targets || !child.vars) continue; + let targetsEl = false; + for (const t of child.targets()) { + if (t === el || (el.id && t.id === el.id)) { + targetsEl = true; + break; + } + } + if (!targetsEl) continue; + const vars = child.vars; + for (const p of propSet) { + if (p in vars) return true; + } + if (vars.keyframes && typeof vars.keyframes === "object") { + for (const kfVal of Object.values(vars.keyframes as Record)) { + if (kfVal && typeof kfVal === "object") { + for (const p of propSet) { + if (p in (kfVal as Record)) return true; + } + } + } + } + } + } catch { + /* */ + } + } + return false; +} + function reapplyBoxSizes(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) { + if (gsapAnimatesProperty(el, "width", "height")) continue; const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP)); const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP)); if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index cad19d33f..e49fb730e 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -69,6 +69,7 @@ interface NLELayoutProps { updates: Pick, ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + onSplitElement?: (element: TimelineElement, splitTime: number) => Promise | void; onSelectTimelineElement?: (element: TimelineElement | null) => void; onDeleteKeyframe?: (elementId: string, percentage: number) => void; onDeleteAllKeyframes?: (elementId: string) => void; @@ -122,6 +123,7 @@ export const NLELayout = memo(function NLELayout({ onMoveElement, onResizeElement, onBlockedEditAttempt, + onSplitElement, onSelectTimelineElement, onDeleteKeyframe, onDeleteAllKeyframes, @@ -457,6 +459,7 @@ export const NLELayout = memo(function NLELayout({ onMoveElement={onMoveElement} onResizeElement={onResizeElement} onBlockedEditAttempt={onBlockedEditAttempt} + onSplitElement={onSplitElement} onSelectElement={onSelectTimelineElement} onDeleteKeyframe={onDeleteKeyframe} onDeleteAllKeyframes={onDeleteAllKeyframes} diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index c556718f6..7fa2f1f8e 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -68,9 +68,9 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, + commitAnimatedProperty, invalidateGsapCache, previewIframeRef, }, @@ -136,9 +136,9 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, + commitAnimatedProperty, invalidateGsapCache, previewIframeRef, }), @@ -198,9 +198,9 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, + commitAnimatedProperty, invalidateGsapCache, previewIframeRef, ], diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 13c2ca665..58b604c00 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -383,7 +383,7 @@ async function commitFromToPosition( // ── Runtime property reader ─────────────────────────────────────────────── -function readGsapProperty( +export function readGsapProperty( iframe: HTMLIFrameElement | null, selector: string | null, prop: string, @@ -401,7 +401,7 @@ function readGsapProperty( } } -function readAllAnimatedProperties( +export function readAllAnimatedProperties( iframe: HTMLIFrameElement | null, selector: string, anim: GsapAnimation, diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts new file mode 100644 index 000000000..748c7463f --- /dev/null +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -0,0 +1,131 @@ +/** + * Unified helper for committing any GSAP property value from the design panel. + * + * Handles three cases: + * 1. Animation with keyframes → add-keyframe at current percentage + * 2. Flat animation (no keyframes) → convert to keyframes, then add-keyframe + * 3. No animation → create tl.to(), convert to keyframes, then add-keyframe + */ +import { useCallback } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { usePlayerStore } from "../player/store/playerStore"; +import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge"; + +interface CommitAnimatedPropertyDeps { + selectedGsapAnimations: GsapAnimation[]; + gsapCommitMutation: + | (( + selection: DomEditSelection, + mutation: Record, + options: { + label: string; + coalesceKey?: string; + softReload?: boolean; + skipReload?: boolean; + }, + ) => Promise) + | null; + addGsapAnimation: ( + selection: DomEditSelection, + method: "to" | "from" | "set" | "fromTo", + currentTime?: number, + ) => void; + convertToKeyframes: (selection: DomEditSelection, animId: string) => void; + previewIframeRef: React.RefObject; + bumpGsapCache: () => void; +} + +function computePercentage(selection: DomEditSelection): number { + const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; + const currentTime = usePlayerStore.getState().currentTime; + return elDuration > 0 + ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10)) + : 0; +} + +function selectorFor(selection: DomEditSelection): string | null { + if (selection.id) return `#${selection.id}`; + if (selection.selector) return selection.selector; + return null; +} + +export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { + const { + selectedGsapAnimations, + gsapCommitMutation, + addGsapAnimation, + previewIframeRef, + bumpGsapCache, + } = deps; + + const commitAnimatedProperty = useCallback( + async ( + selection: DomEditSelection, + property: string, + value: number | string, + ): Promise => { + if (!gsapCommitMutation) return; + + const iframe = previewIframeRef.current; + const selector = selectorFor(selection); + const pct = computePercentage(selection); + + let anim: GsapAnimation | undefined = + selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0]; + + // Case 3: No animation — create one first + if (!anim) { + addGsapAnimation(selection, "to"); + // The addGsapAnimation triggers a reload. We need to wait for the cache + // to update. Use a small delay then bump cache to re-fetch. + await new Promise((r) => setTimeout(r, 500)); + bumpGsapCache(); + // After creation, we can't proceed in this call — the animation isn't + // in our local state yet. The user's next edit will find it. + // For immediate feedback, trigger a convert-to-keyframes on the new animation. + return; + } + + // Case 2: Flat animation — convert to keyframes first + if (!anim.keyframes) { + await gsapCommitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id }, + { label: "Convert to keyframes", skipReload: true }, + ); + } + + // Read all currently animated properties from runtime for backfill + const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; + + // Build the properties object: all runtime props + the new value + const properties: Record = { ...runtimeProps }; + properties[property] = value; + + // Compute backfill defaults for properties not in existing keyframes + const backfillDefaults: Record = { ...runtimeProps }; + if (!(property in runtimeProps) && selector) { + const cssVal = readGsapProperty(iframe, selector, property); + if (cssVal != null) backfillDefaults[property] = cssVal; + } + backfillDefaults[property] = typeof value === "number" ? value : value; + + await gsapCommitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties, + backfillDefaults, + }, + { label: `Edit ${property} (keyframe ${pct}%)`, softReload: true }, + ); + }, + [selectedGsapAnimations, gsapCommitMutation, addGsapAnimation, previewIframeRef, bumpGsapCache], + ); + + return commitAnimatedProperty; +} diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 61d65af9f..5140afc31 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -62,6 +62,7 @@ interface EditHistoryHandle { interface UseAppHotkeysParams { toggleTimelineVisibility: () => void; handleTimelineElementDelete: (element: TimelineElement) => Promise; + handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise; handleDomEditElementDelete: (selection: DomEditSelection) => Promise; domEditSelectionRef: React.MutableRefObject; clearDomSelectionRef: React.MutableRefObject<() => void>; @@ -87,6 +88,7 @@ interface UseAppHotkeysParams { export function useAppHotkeys({ toggleTimelineVisibility, handleTimelineElementDelete, + handleTimelineElementSplit, handleDomEditElementDelete, domEditSelectionRef, editHistory, @@ -195,6 +197,8 @@ export function useAppHotkeys({ handleToggleRef.current = handleTimelineToggleHotkey; const handleDeleteRef = useRef(handleTimelineElementDelete); handleDeleteRef.current = handleTimelineElementDelete; + const handleSplitRef = useRef(handleTimelineElementSplit); + handleSplitRef.current = handleTimelineElementSplit; const handleDomEditDeleteRef = useRef(handleDomEditElementDelete); handleDomEditDeleteRef.current = handleDomEditElementDelete; const handleUndoRef = useRef(handleUndo); @@ -306,6 +310,30 @@ export function useAppHotkeys({ return; } + // S — split selected clip at playhead + if ( + event.key === "s" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !isEditableTarget(event.target) + ) { + const { selectedElementId, elements, currentTime } = usePlayerStore.getState(); + if (selectedElementId) { + const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); + if ( + element && + ["video", "audio", "img"].includes(element.tag) && + currentTime > element.start && + currentTime < element.start + element.duration + ) { + event.preventDefault(); + void handleSplitRef.current(element, currentTime); + return; + } + } + } + // Delete / Backspace — remove selected keyframes > reset keyframes > remove element if ( (event.key === "Delete" || event.key === "Backspace") && diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 92aed8b00..fce788233 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef } from "react"; import type { TimelineElement } from "../player"; +import { usePlayerStore } from "../player"; import { STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_GSAP_PANEL_ENABLED, @@ -27,8 +28,8 @@ import { tryGsapDragIntercept, tryGsapResizeIntercept, tryGsapRotationIntercept, - readRuntimeKeyframes, } from "./gsapRuntimeBridge"; +import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit"; // ── Types ── @@ -206,6 +207,18 @@ export function useDomEditSession({ onClickToSource, }); + // Sync DOM selection → timeline selectedElementId so that clip selection + // highlights and diamond playhead fills work on cold-load URL restore. + useEffect(() => { + if (!domEditSelection?.id) return; + const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState(); + const matchKey = elements.find( + (el) => el.domId === domEditSelection.id || el.id === domEditSelection.id, + ); + const key = matchKey ? (matchKey.key ?? matchKey.id) : null; + if (key && key !== selectedElementId) setSelectedElementId(key); + }, [domEditSelection?.id]); + // ── GSAP script editing ── const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion(); @@ -216,7 +229,6 @@ export function useDomEditSession({ STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, gsapSourceFile, gsapCacheVersion, - previewIframeRef, ); const { @@ -230,7 +242,6 @@ export function useDomEditSession({ ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null } : null, gsapCacheVersion, - previewIframeRef, ); const { @@ -494,49 +505,6 @@ export function useDomEditSession({ [domEditSelection, convertToKeyframes], ); - const handleGsapMaterializeKeyframes = useCallback( - async (animId: string) => { - if (!domEditSelection || !gsapCommitMutation) return; - const anim = selectedGsapAnimations.find((a) => a.id === animId); - if (!anim || (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) || !anim.keyframes) - return; - if (anim.hasUnresolvedSelector) { - const { scanAllRuntimeKeyframes } = await import("./gsapRuntimeKeyframes"); - const allScanned = scanAllRuntimeKeyframes(previewIframeRef.current); - if (allScanned.size === 0) return; - const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({ - selector: `#${id}`, - keyframes: data.keyframes, - easeEach: data.easeEach, - })); - await gsapCommitMutation( - domEditSelection, - { - type: "materialize-keyframes", - animationId: animId, - keyframes: allScanned.get(domEditSelection.id ?? "")?.keyframes ?? [], - allElements, - }, - { label: "Unroll dynamic animations", skipReload: true }, - ); - return; - } - const runtime = readRuntimeKeyframes(previewIframeRef.current, anim.targetSelector); - if (!runtime || runtime.keyframes.length === 0) return; - await gsapCommitMutation( - domEditSelection, - { - type: "materialize-keyframes", - animationId: animId, - keyframes: runtime.keyframes, - easeEach: runtime.easeEach, - }, - { label: "Materialize dynamic keyframes", skipReload: true }, - ); - }, - [domEditSelection, selectedGsapAnimations, gsapCommitMutation, previewIframeRef], - ); - const handleGsapRemoveAllKeyframes = useCallback( (animId: string) => { if (!domEditSelection) return; @@ -559,6 +527,15 @@ export function useDomEditSession({ return true; }, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]); + const commitAnimatedProperty = useAnimatedPropertyCommit({ + selectedGsapAnimations, + gsapCommitMutation, + addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time), + convertToKeyframes: (sel, animId) => convertToKeyframes(sel, animId), + previewIframeRef, + bumpGsapCache, + }); + // Sync selection from preview document on load / refresh // eslint-disable-next-line no-restricted-syntax useEffect(() => { @@ -702,9 +679,9 @@ export function useDomEditSession({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, + commitAnimatedProperty, invalidateGsapCache: bumpGsapCache, previewIframeRef, }; diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index e9045b960..f2b7c032a 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -471,13 +471,15 @@ export function useTimelineEditing({ const pid = projectIdRef.current; if (!pid) return; + const splittableTags = new Set(["video", "audio", "img"]); if ( element.timelineLocked || element.timingSource === "implicit" || + element.compositionSrc || + !splittableTags.has(element.tag) || !element.duration || !Number.isFinite(element.duration) ) { - showToast("This clip cannot be split.", "error"); return; } diff --git a/packages/studio/src/icons/SystemIcons.tsx b/packages/studio/src/icons/SystemIcons.tsx index 3a43f29fb..3e99f2365 100644 --- a/packages/studio/src/icons/SystemIcons.tsx +++ b/packages/studio/src/icons/SystemIcons.tsx @@ -20,6 +20,7 @@ import { Camera as PhCamera, ArrowClockwise, Gear, + Scissors as PhScissors, } from "@phosphor-icons/react"; import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react"; @@ -55,3 +56,4 @@ export const RotateCcw = makeIcon(ArrowCounterClockwise); export const Camera = makeIcon(PhCamera); export const RotateCw = makeIcon(ArrowClockwise); export const Settings = makeIcon(Gear); +export const Scissors = makeIcon(PhScissors); diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx index 4caf1a065..b9ae70e13 100644 --- a/packages/studio/src/player/components/ClipContextMenu.tsx +++ b/packages/studio/src/player/components/ClipContextMenu.tsx @@ -43,11 +43,15 @@ export const ClipContextMenu = memo(function ClipContextMenu({ const adjustedX = Math.min(x, window.innerWidth - 200); const adjustedY = Math.min(y, window.innerHeight - 200); - const canSplit = currentTime > element.start && currentTime < element.start + element.duration; + const isSplittable = ["video", "audio", "img"].includes(element.tag); + const canSplit = + isSplittable && currentTime > element.start && currentTime < element.start + element.duration; - const splitLabel = canSplit - ? `Split at ${currentTime.toFixed(2)}s` - : "Split (move playhead inside clip)"; + const splitLabel = !isSplittable + ? null + : canSplit + ? `Split at ${currentTime.toFixed(2)}s` + : "Split (move playhead inside clip)"; return (
- - -
+ {splitLabel && ( + <> + +
+ + )}
@@ -583,6 +597,18 @@ export const Timeline = memo(function Timeline({ }} /> )} + + {clipContextMenu && ( + setClipContextMenu(null)} + onSplit={(el, time) => onSplitElement?.(el, time)} + onDelete={(el) => _onDeleteElement?.(el)} + /> + )}
); }); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 1d9233930..215c55b97 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -67,6 +67,7 @@ interface TimelineCanvasProps { onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; + onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; } @@ -116,6 +117,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ onShiftClickKeyframe, onDragKeyframe, onContextMenuKeyframe, + onContextMenuClip, onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead, }: TimelineCanvasProps) { const draggedElement = draggedClip?.element ?? null; @@ -249,6 +251,10 @@ export const TimelineCanvas = memo(function TimelineCanvas({ return ( { + e.preventDefault(); + onContextMenuClip?.(e, el); + }} el={previewElement} pps={pps} clipY={CLIP_Y} diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index c964545a8..07b2e0d37 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -23,6 +23,7 @@ interface TimelineClipProps { onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void; onClick: (e: React.MouseEvent) => void; onDoubleClick: (e: React.MouseEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; children?: ReactNode; } @@ -44,6 +45,7 @@ export const TimelineClip = memo(function TimelineClip({ onResizeStart, onClick, onDoubleClick, + onContextMenu, children, }: TimelineClipProps) { const leftPx = el.start * pps; @@ -51,14 +53,14 @@ export const TimelineClip = memo(function TimelineClip({ const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging }); const borderColor = isSelected - ? trackStyle.accent + "60" + ? trackStyle.accent : isHovered ? theme.clipBorderHover : theme.clipBorder; const boxShadow = isDragging ? theme.clipShadowDragging : isSelected - ? `0 0 0 1px ${trackStyle.accent}40` + ? `0 0 0 1px ${trackStyle.accent}80, 0 0 8px ${trackStyle.accent}25` : isHovered ? theme.clipShadowHover : theme.clipShadow; @@ -98,6 +100,7 @@ export const TimelineClip = memo(function TimelineClip({ onPointerDown={onPointerDown} onClick={onClick} onDoubleClick={onDoubleClick} + onContextMenu={onContextMenu} > {/* Left accent stripe */}