diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index bcbaf68d8..031fa63a6 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -10,10 +10,9 @@ import { usePanelLayout } from "./hooks/usePanelLayout"; import { useFileManager } from "./hooks/useFileManager"; import { usePreviewPersistence } from "./hooks/usePreviewPersistence"; import { useTimelineEditing } from "./hooks/useTimelineEditing"; -import { addBlockToProject } from "./utils/blockInstaller"; -import type { BlockParam } from "@hyperframes/core/registry"; import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import { useDomEditSession } from "./hooks/useDomEditSession"; +import { useBlockHandlers } from "./hooks/useBlockHandlers"; import { useAppHotkeys } from "./hooks/useAppHotkeys"; import { useClipboard } from "./hooks/useClipboard"; import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences"; @@ -35,8 +34,7 @@ import type { DomEditSelection } from "./components/editor/domEditing"; import { AskAgentModal } from "./components/AskAgentModal"; import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay"; import { StudioHeader } from "./components/StudioHeader"; -import { useGestureRecording } from "./hooks/useGestureRecording"; -import { simplifyGestureSamples } from "./utils/rdpSimplify"; +import { useGestureCommit } from "./hooks/useGestureCommit"; import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay"; import { StudioLeftSidebar } from "./components/StudioLeftSidebar"; @@ -82,12 +80,6 @@ export function StudioApp() { const [compositionLoading, setCompositionLoading] = useState(true); const [refreshKey, setRefreshKey] = useState(0); const [, setPreviewDocumentVersion] = useState(0); - const [activeBlockParams, setActiveBlockParams] = useState<{ - blockName: string; - blockTitle: string; - params: BlockParam[]; - compositionPath: string; - } | null>(null); const [blockPreview, setBlockPreview] = useState(null); const previewIframeRef = useRef(null); @@ -192,8 +184,15 @@ export function StudioApp() { isRecordingRef: isGestureRecordingRef, }); - const blockCtx = useMemo( - () => ({ + const { + activeBlockParams, + setActiveBlockParams, + handleAddBlock, + handleTimelineBlockDrop, + handlePreviewBlockDrop, + } = useBlockHandlers({ + projectId, + blockCtxDeps: { activeCompPath, timelineElements, readProjectFile: fileManager.readProjectFile, @@ -202,70 +201,11 @@ export function StudioApp() { refreshFileTree: fileManager.refreshFileTree, reloadPreview, showToast, - }), - [ - activeCompPath, - timelineElements, - fileManager, - editHistory.recordEdit, - reloadPreview, - showToast, - ], - ); - const handleAddBlock = useCallback( - (blockName: string) => { - if (!projectId) return; - void (async () => { - const result = await addBlockToProject({ - projectId, - blockName, - ...blockCtx, - previewIframe: previewIframeRef.current, - currentTime: usePlayerStore.getState().currentTime, - }); - const params = result?.block.type === "hyperframes:block" ? result.block.params : undefined; - if (params?.length) { - setActiveBlockParams({ - blockName: result!.block.name, - blockTitle: result!.block.title, - params, - compositionPath: result!.compositionPath, - }); - panelLayout.setRightCollapsed(false); - panelLayout.setRightPanelTab("block-params"); - } - })(); }, - [projectId, blockCtx, panelLayout], - ); - const handleTimelineBlockDrop = useCallback( - (blockName: string, placement: { start: number; track: number }) => { - if (!projectId) return; - void addBlockToProject({ - projectId, - blockName, - placement, - ...blockCtx, - previewIframe: previewIframeRef.current, - currentTime: usePlayerStore.getState().currentTime, - }); - }, - [projectId, blockCtx], - ); - const handlePreviewBlockDrop = useCallback( - (blockName: string, position: { left: number; top: number }) => { - if (!projectId) return; - void addBlockToProject({ - projectId, - blockName, - visualPosition: position, - ...blockCtx, - previewIframe: previewIframeRef.current, - currentTime: usePlayerStore.getState().currentTime, - }); - }, - [projectId, blockCtx], - ); + previewIframeRef, + setRightCollapsed: panelLayout.setRightCollapsed, + setRightPanelTab: panelLayout.setRightPanelTab, + }); const clearDomSelectionRef = useRef<() => void>(() => {}); const domEditSelectionBridgeRef = useRef(null); @@ -406,125 +346,16 @@ export function StudioApp() { const dragOverlay = useDragOverlay(fileManager.handleImportFiles); // Gesture recording - const gestureRecording = useGestureRecording(); - const [gestureState, setGestureState] = useState<"idle" | "recording">("idle"); - // Synchronous mirror of gestureState — immune to React batching. - // Prevents double-R-press within a single render cycle from swallowing the stop. - const gestureStateRef = useRef<"idle" | "recording">("idle"); - const recordingAutoStopRef = useRef>(undefined); - const recordingStartTimeRef = useRef(0); - const commitInFlightRef = useRef(false); const handleToggleRecordingRef = useRef<() => void>(() => {}); const domEditSessionRef = useRef(domEditSession); domEditSessionRef.current = domEditSession; - // Unmount: clear auto-stop interval - useEffect(() => () => clearInterval(recordingAutoStopRef.current), []); - - // fallow-ignore-next-line complexity - const stopAndCommitRecording = useCallback(async () => { - clearInterval(recordingAutoStopRef.current); - if (commitInFlightRef.current) return; - commitInFlightRef.current = true; - gestureStateRef.current = "idle"; - isGestureRecordingRef.current = false; - const frozenSamples = gestureRecording.stopRecording(); - const store = usePlayerStore.getState(); - store.setIsPlaying(false); - try { - const liveSession = domEditSessionRef.current; - const sel = liveSession.domEditSelection; - if (!sel) { - if (frozenSamples.length > 2) { - showToast("Selection lost during recording", "error"); - } - return; - } - const duration = frozenSamples.length > 0 ? frozenSamples[frozenSamples.length - 1]!.time : 0; - - if (frozenSamples.length <= 2) { - showToast("No gesture detected — move the pointer while recording", "error"); - return; - } - if (duration <= 0) { - showToast("Recording too short — try again", "error"); - return; - } - - const simplified = simplifyGestureSamples(frozenSamples, duration, 5); - const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b); - - // Always create a new tween scoped to the recording range. - // Injecting into an existing tween creates keyframes before the recording - // start (from the convert-to-keyframes step), causing wrong positions. - const selector = sel.id ? `#${sel.id}` : sel.selector; - if (!selector) { - showToast("Cannot save — element has no selector", "error"); - return; - } - if (liveSession.commitMutation) { - const recStart = recordingStartTimeRef.current; - const keyframes = sortedPcts.map((pct) => ({ - percentage: pct, - properties: simplified.get(pct) as Record, - })); - - 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", softReload: true }, - ); - } - showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); - } finally { - store.requestSeek(recordingStartTimeRef.current); - gestureRecording.clearSamples(); - setGestureState("idle"); - commitInFlightRef.current = false; - } - }, [gestureRecording, showToast]); - - const handleToggleRecording = useCallback(() => { - if (gestureStateRef.current === "recording") { - void stopAndCommitRecording(); - return; - } - const sel = domEditSessionRef.current.domEditSelection; - if (!sel) { - showToast("Select an element first", "error"); - return; - } - const iframe = previewIframeRef.current; - if (!iframe) { - showToast("Preview not ready — try again", "error"); - return; - } - - const store = usePlayerStore.getState(); - recordingStartTimeRef.current = store.currentTime; - const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; - const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0; - const elementEnd = elDur > 0 ? elStart + elDur : undefined; - gestureRecording.startRecording(sel.element, iframe, elementEnd); - gestureStateRef.current = "recording"; - isGestureRecordingRef.current = true; - setGestureState("recording"); - - clearInterval(recordingAutoStopRef.current); - const autoStopAt = elementEnd ?? Infinity; - recordingAutoStopRef.current = setInterval(() => { - const { currentTime: t, duration: d } = usePlayerStore.getState(); - const limit = Math.min(autoStopAt, d); - if (limit > 0 && t >= limit - 0.05) { - void stopAndCommitRecording(); - } - }, 100); - }, [gestureRecording, showToast, stopAndCommitRecording]); + const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({ + domEditSessionRef, + previewIframeRef, + showToast, + isGestureRecordingRef, + }); handleToggleRecordingRef.current = handleToggleRecording; const handlePreviewIframeRef = useCallback( diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index fea818d25..ece903078 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -7,11 +7,14 @@ import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID, + readGsapRuntimeValuesForPanel, + readGsapBorderRadiusForPanel, } from "./propertyPanelHelpers"; import { MetricField, Section } from "./propertyPanelPrimitives"; import { isMediaElement, MediaSection } from "./propertyPanelMediaSection"; import { TextSection, StyleSections } from "./propertyPanelSections"; import { GsapAnimationSection } from "./GsapAnimationSection"; +import { PropertyPanel3dTransform } from "./propertyPanel3dTransform"; import { KeyframeNavigation } from "./KeyframeNavigation"; import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability"; import { usePlayerStore } from "../../player"; @@ -188,69 +191,19 @@ export const PropertyPanel = memo(function PropertyPanel({ 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 gsapRuntimeValues = readGsapRuntimeValuesForPanel( + gsapAnimId, + gsapAnimations, + element, + previewIframeRef ?? { current: null }, + ); - const gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = (() => { - if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) { - const hasBRProp = gsapAnimations.some( - (a) => - "borderRadius" in a.properties || - a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties), - ); - if (!hasBRProp) return null; - } - const iframe = previewIframeRef?.current; - const selector = element.id ? `#${element.id}` : element.selector; - if (!iframe?.contentDocument || !selector) return null; - try { - const el = iframe.contentDocument.querySelector(selector); - if (!el) return null; - const cs = iframe.contentWindow!.getComputedStyle(el); - const parse = (v: string) => Number.parseFloat(v) || 0; - return { - tl: parse(cs.borderTopLeftRadius), - tr: parse(cs.borderTopRightRadius), - br: parse(cs.borderBottomRightRadius), - bl: parse(cs.borderBottomLeftRadius), - }; - } catch { - return null; - } - })(); + const gsapBorderRadius = readGsapBorderRadiusForPanel( + gsapRuntimeValues, + gsapAnimations, + element, + previewIframeRef ?? { current: null }, + ); const displayX = gsapRuntimeValues?.x ?? manualOffset.x; const displayY = gsapRuntimeValues?.y ?? manualOffset.y; @@ -510,97 +463,19 @@ 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); - } - }} - /> -
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( - onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={() => { - if (onCommitAnimatedProperty) { - void onCommitAnimatedProperty( - element, - "scale", - gsapRuntimeValues?.scale ?? 1, - ); - } - }} - onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)} - onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)} - /> - )} -
- { - 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); - } - }} - /> -
-
+ )}
diff --git a/packages/studio/src/components/editor/gsapAnimatesProperty.ts b/packages/studio/src/components/editor/gsapAnimatesProperty.ts new file mode 100644 index 000000000..a704862c4 --- /dev/null +++ b/packages/studio/src/components/editor/gsapAnimatesProperty.ts @@ -0,0 +1,52 @@ +/** + * Checks whether GSAP actively animates one or more CSS/GSAP properties on + * the given element by inspecting all registered `__timelines`. + */ +export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean { + const win = el.ownerDocument.defaultView as + | (Window & { + __timelines?: Record< + string, + { + getChildren?: ( + deep: boolean, + ) => 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; +} diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index a1961abbd..f372f4a23 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -32,6 +32,7 @@ import { } from "./manualEditsTypes"; import { roundRotationAngle } from "./manualEditsParsing"; import { applyStudioMotionFromDom } from "./studioMotion"; +import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; /* ── Gesture tracking ─────────────────────────────────────────────── */ let studioManualEditGestureId = 0; @@ -534,55 +535,6 @@ function reapplyPathOffsets(doc: Document): void { } } -function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean { - const win = el.ownerDocument.defaultView as - | (Window & { - __timelines?: Record< - string, - { - getChildren?: ( - deep: boolean, - ) => 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; diff --git a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx new file mode 100644 index 000000000..058e44fae --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx @@ -0,0 +1,133 @@ +import type { DomEditSelection } from "./domEditingTypes"; +import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability"; +import { MetricField } from "./propertyPanelPrimitives"; +import { KeyframeNavigation } from "./KeyframeNavigation"; +import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; + +type KeyframeEntry = Array<{ + percentage: number; + properties: Record; + ease?: string; +}> | null; + +interface PropertyPanel3dTransformProps { + gsapRuntimeValues: Record; + gsapAnimId: string | null; + gsapKeyframes: KeyframeEntry; + currentPct: number; + elStart: number; + elDuration: number; + element: DomEditSelection; + onCommitAnimatedProperty?: ( + element: DomEditSelection, + property: string, + value: number, + ) => Promise; + onSeekToTime?: (time: number) => void; + onRemoveKeyframe?: (animId: string, pct: number) => void; + onConvertToKeyframes?: (animId: string) => void; +} + +export function PropertyPanel3dTransform({ + gsapRuntimeValues, + gsapAnimId, + gsapKeyframes, + currentPct, + elStart, + elDuration, + element, + onCommitAnimatedProperty, + onSeekToTime, + onRemoveKeyframe, + onConvertToKeyframes, +}: PropertyPanel3dTransformProps) { + return ( +
+
+ 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); + } + }} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={() => { + if (onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1); + } + }} + onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+ { + 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); + } + }} + /> +
+
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index c3029fffa..f600e5d2f 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -2,6 +2,7 @@ import { parseCssColor, type ParsedColor } from "./colorValue"; import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog"; import type { DomEditSelection } from "./domEditing"; import type { ImportedFontAsset } from "./fontAssets"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; export interface PropertyPanelProps { projectId: string; @@ -505,3 +506,78 @@ export function computeFitToChildrenSize( const height = Math.round((maxY - minY) * scaleY); return width > 0 && height > 0 ? { width, height } : null; } + +// ── GSAP runtime value readers (used by PropertyPanel) ──────────────────── + +export function readGsapRuntimeValuesForPanel( + gsapAnimId: string | null, + gsapAnimations: GsapAnimation[], + element: DomEditSelection, + previewIframeRef: React.RefObject, +): 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; + } +} + +export function readGsapBorderRadiusForPanel( + gsapRuntimeValues: Record | null, + gsapAnimations: GsapAnimation[], + element: DomEditSelection, + previewIframeRef: React.RefObject, +): { tl: number; tr: number; br: number; bl: number } | null { + if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) { + const hasBRProp = gsapAnimations.some( + (a) => + "borderRadius" in a.properties || + a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties), + ); + if (!hasBRProp) return null; + } + const iframe = previewIframeRef?.current; + const selector = element.id ? `#${element.id}` : element.selector; + if (!iframe?.contentDocument || !selector) return null; + try { + const el = iframe.contentDocument.querySelector(selector); + if (!el) return null; + const cs = iframe.contentWindow!.getComputedStyle(el); + const parse = (v: string) => Number.parseFloat(v) || 0; + return { + tl: parse(cs.borderTopLeftRadius), + tr: parse(cs.borderTopRightRadius), + br: parse(cs.borderBottomRightRadius), + bl: parse(cs.borderBottomLeftRadius), + }; + } catch { + return null; + } +} diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts new file mode 100644 index 000000000..e26e63a62 --- /dev/null +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -0,0 +1,295 @@ +/** + * Low-level drag commit helpers for GSAP position mutations. + * Extracted from gsapRuntimeBridge.ts to keep file sizes under the 600-line limit. + */ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { usePlayerStore } from "../player/store/playerStore"; +import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; +import { + absoluteToPercentage, + resolveTweenStart, + resolveTweenDuration, +} from "../utils/globalTimeCompiler"; +import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders"; + +export interface GsapDragCommitCallbacks { + commitMutation: ( + selection: DomEditSelection, + mutation: Record, + options: { + label: string; + coalesceKey?: string; + softReload?: boolean; + skipReload?: boolean; + beforeReload?: () => void; + }, + ) => Promise; +} + +// ── Percentage computation ───────────────────────────────────────────────── + +export function computeCurrentPercentage( + selection: DomEditSelection, + animation?: GsapAnimation, +): number { + const currentTime = usePlayerStore.getState().currentTime; + if (animation) { + const start = resolveTweenStart(animation); + const duration = resolveTweenDuration(animation); + if (start !== null) { + return absoluteToPercentage(currentTime, start, duration); + } + } + const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; + return elDuration > 0 + ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10)) + : 0; +} + +// ── Dynamic keyframe materialization ────────────────────────────────────── + +export async function materializeIfDynamic( + anim: GsapAnimation, + iframe: HTMLIFrameElement | null, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + selection: DomEditSelection, +): Promise { + if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return; + + if (anim.hasUnresolvedSelector) { + const allScanned = scanAllRuntimeKeyframes(iframe); + if (allScanned.size === 0) return; + const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({ + selector: `#${id}`, + keyframes: data.keyframes, + easeEach: data.easeEach, + })); + await commitMutation( + selection, + { + type: "materialize-keyframes", + animationId: anim.id, + keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [], + allElements, + }, + { label: "Unroll dynamic animations", skipReload: true }, + ); + return `${anim.targetSelector}-to-0`; + } + + const runtime = readRuntimeKeyframes(iframe, anim.targetSelector); + if (!runtime || runtime.keyframes.length === 0) return; + await commitMutation( + selection, + { + type: "materialize-keyframes", + animationId: anim.id, + keyframes: runtime.keyframes, + easeEach: runtime.easeEach, + }, + { label: "Materialize dynamic keyframes", skipReload: true }, + ); +} + +// ── Extend tween ────────────────────────────────────────────────────────── + +/** + * Extend a tween's time range to cover `targetTime`, remap all existing + * keyframe percentages to preserve their absolute positions, then add + * a new keyframe at the target time. + */ +export async function extendTweenAndAddKeyframe( + selection: DomEditSelection, + anim: GsapAnimation, + properties: Record, + targetTime: number, + tweenStart: number, + tweenDuration: number, + callbacks: GsapDragCommitCallbacks, + beforeReload?: () => void, +): Promise { + const tweenEnd = tweenStart + tweenDuration; + const newStart = Math.min(targetTime, tweenStart); + const newEnd = Math.max(targetTime, tweenEnd); + const newDuration = Math.max(0.01, newEnd - newStart); + + const existingKfs = anim.keyframes?.keyframes ?? []; + const remappedKfs: Array<{ percentage: number; properties: Record }> = + []; + for (const kf of existingKfs) { + const absTime = tweenStart + (kf.percentage / 100) * tweenDuration; + const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; + remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } }); + } + + const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10; + remappedKfs.push({ percentage: targetPct, properties }); + remappedKfs.sort((a, b) => a.percentage - b.percentage); + + await callbacks.commitMutation( + selection, + { type: "delete", animationId: anim.id }, + { label: "Extend tween range", skipReload: true }, + ); + + const selector = anim.targetSelector; + await callbacks.commitMutation( + selection, + { + type: "add-with-keyframes", + targetSelector: selector, + position: Math.round(newStart * 1000) / 1000, + duration: Math.round(newDuration * 1000) / 1000, + keyframes: remappedKfs, + }, + { label: `Move layer (extended keyframe)`, softReload: true, beforeReload }, + ); +} + +// fallow-ignore-next-line complexity +export async function commitKeyframedPosition( + selection: DomEditSelection, + anim: GsapAnimation, + properties: Record, + callbacks: GsapDragCommitCallbacks, + beforeReload?: () => void, +): Promise { + const pct = computeCurrentPercentage(selection, anim); + + await callbacks.commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties, + }, + { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, + ); +} + +/** + * For flat to()/set() tweens, convert to keyframes first so we can place the + * drag position at the current percentage. + */ +// fallow-ignore-next-line complexity +export async function commitFlatViaKeyframes( + selection: DomEditSelection, + anim: GsapAnimation, + properties: Record, + callbacks: GsapDragCommitCallbacks, + beforeReload?: () => void, +): Promise { + await callbacks.commitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id }, + { label: "Convert to keyframes for drag", skipReload: true }, + ); + + const pct = computeCurrentPercentage(selection, anim); + + await callbacks.commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties, + }, + { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, + ); +} + +// ── Main drag commit ────────────────────────────────────────────────────── + +/** + * Compute the new GSAP position values from runtime-read positions + drag + * offset, then commit the mutation to the GSAP script. + */ +// fallow-ignore-next-line complexity +export async function commitGsapPositionFromDrag( + selection: DomEditSelection, + anim: GsapAnimation, + studioOffset: { x: number; y: number }, + gsapPos: { x: number; y: number }, + iframe: HTMLIFrameElement | null, + selector: string, + callbacks: GsapDragCommitCallbacks, +): Promise { + 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 el = selection.element; + const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0; + const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0; + const deltaX = studioOffset.x - origX; + const deltaY = studioOffset.y - origY; + const adjX = deltaX * cos - deltaY * sin; + const adjY = deltaX * sin + deltaY * cos; + const baseGsapX = + Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "") || gsapPos.x; + const baseGsapY = + Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "") || gsapPos.y; + const newX = Math.round(baseGsapX + adjX); + const newY = Math.round(baseGsapY + adjY); + const restoreOffset = () => { + el.style.setProperty("--hf-studio-offset-x", `${origX}px`); + el.style.setProperty("--hf-studio-offset-y", `${origY}px`); + el.removeAttribute("data-hf-drag-initial-offset-x"); + el.removeAttribute("data-hf-drag-initial-offset-y"); + }; + + if (anim.keyframes) { + const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); + const effectiveAnim = newId ? { ...anim, id: newId } : anim; + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + + const ct = usePlayerStore.getState().currentTime; + const ts = resolveTweenStart(effectiveAnim); + const td = resolveTweenDuration(effectiveAnim); + if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) { + await extendTweenAndAddKeyframe( + selection, + effectiveAnim, + { ...runtimeProps, x: newX, y: newY }, + ct, + ts, + td, + callbacks, + restoreOffset, + ); + } else { + await commitKeyframedPosition( + selection, + effectiveAnim, + { ...runtimeProps, x: newX, y: newY }, + callbacks, + restoreOffset, + ); + } + } else if (anim.method === "from" || anim.method === "fromTo") { + await callbacks.commitMutation( + selection, + { + type: "convert-to-keyframes", + animationId: anim.id, + resolvedFromValues: { x: newX, y: newY }, + }, + { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset }, + ); + } else { + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + await commitFlatViaKeyframes( + selection, + anim, + { ...runtimeProps, x: newX, y: newY }, + callbacks, + restoreOffset, + ); + } +} + diff --git a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts new file mode 100644 index 000000000..c2ad14b92 --- /dev/null +++ b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts @@ -0,0 +1,88 @@ +/** + * Helpers for reading/writing the GSAP keyframe cache in the player store. + * Extracted from useGsapScriptCommits to keep file sizes under the 600-line limit. + */ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore"; + +export function updateKeyframeCacheFromParsed( + animations: GsapAnimation[], + targetPath: string, + selectionId: string | undefined, + mutation: Record, +): void { + const { setKeyframeCache, elements } = usePlayerStore.getState(); + const idsWithKeyframes = new Set(); + const merged = new Map(); + for (const anim of animations) { + const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1]; + if (!id || !anim.keyframes) continue; + idsWithKeyframes.add(id); + + // Convert tween-relative percentages to clip-relative so diamonds + // render at the correct position within the timeline clip. + const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenDur = anim.duration ?? 1; + const timelineEl = elements.find( + (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`, + ); + const elStart = timelineEl?.start ?? 0; + const elDuration = timelineEl?.duration ?? 4; + const clipKeyframes = anim.keyframes.keyframes.map((kf) => { + const absTime = tweenPos + (kf.percentage / 100) * tweenDur; + const clipPct = + elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; + return { ...kf, percentage: clipPct }; + }); + + const existing = merged.get(id); + if (existing) { + const byPct = new Map(); + for (const kf of [...existing.keyframes, ...clipKeyframes]) { + const prev = byPct.get(kf.percentage); + if (prev) { + prev.properties = { ...prev.properties, ...kf.properties }; + if (kf.ease) prev.ease = kf.ease; + } else { + byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } }); + } + } + existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage); + } else { + merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes }); + } + } + for (const [id, entry] of merged) { + setKeyframeCache(`${targetPath}#${id}`, entry); + setKeyframeCache(id, entry); + if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry); + } + const targetId = + (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ?? + selectionId; + if (targetId && !idsWithKeyframes.has(targetId)) { + setKeyframeCache(`${targetPath}#${targetId}`, undefined); + if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined); + } +} + +export function buildCacheKey(sourceFile: string, elementId: string): string { + return `${sourceFile}#${elementId}`; +} + +export function readKeyframeSnapshot( + sourceFile: string, + elementId: string | null | undefined, +): KeyframeCacheEntry | undefined { + if (!elementId) return undefined; + return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId)); +} + +export function writeKeyframeCache( + sourceFile: string, + elementId: string | null | undefined, + data: KeyframeCacheEntry | undefined, +): void { + if (!elementId) return; + usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data); +} diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 3ac4036c9..5254b5059 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -11,13 +11,14 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { usePlayerStore } from "../player/store/playerStore"; -import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; +import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; +import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders"; import { - absoluteToPercentage, - resolveTweenStart, - resolveTweenDuration, -} from "../utils/globalTimeCompiler"; + commitGsapPositionFromDrag, + computeCurrentPercentage, + materializeIfDynamic, +} from "./gsapDragCommit"; +import type { GsapDragCommitCallbacks } from "./gsapDragCommit"; // ── Runtime reads ────────────────────────────────────────────────────────── @@ -94,94 +95,16 @@ function selectorForSelection(selection: DomEditSelection): string | null { return null; } -// ── Percentage computation ───────────────────────────────────────────────── - -function computeCurrentPercentage(selection: DomEditSelection, animation?: GsapAnimation): number { - const currentTime = usePlayerStore.getState().currentTime; - if (animation) { - const start = resolveTweenStart(animation); - const duration = resolveTweenDuration(animation); - if (start !== null) { - return absoluteToPercentage(currentTime, start, duration); - } - } - const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; - const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; - return elDuration > 0 - ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10)) - : 0; -} - -// ── Dynamic keyframe materialization ────────────────────────────────────── - -async function materializeIfDynamic( - anim: GsapAnimation, - iframe: HTMLIFrameElement | null, - commitMutation: GsapDragCommitCallbacks["commitMutation"], - selection: DomEditSelection, -): Promise { - if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return; - - if (anim.hasUnresolvedSelector) { - // Unroll: read ALL elements' keyframes from runtime and replace the loop - const allScanned = scanAllRuntimeKeyframes(iframe); - if (allScanned.size === 0) return; - const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({ - selector: `#${id}`, - keyframes: data.keyframes, - easeEach: data.easeEach, - })); - await commitMutation( - selection, - { - type: "materialize-keyframes", - animationId: anim.id, - keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [], - allElements, - }, - { label: "Unroll dynamic animations", skipReload: true }, - ); - return `${anim.targetSelector}-to-0`; - } - - const runtime = readRuntimeKeyframes(iframe, anim.targetSelector); - if (!runtime || runtime.keyframes.length === 0) return; - await commitMutation( - selection, - { - type: "materialize-keyframes", - animationId: anim.id, - keyframes: runtime.keyframes, - easeEach: runtime.easeEach, - }, - { label: "Materialize dynamic keyframes", skipReload: true }, - ); -} - // ── High-level intercept ─────────────────────────────────────────────────── -export interface GsapDragCommitCallbacks { - commitMutation: ( - selection: DomEditSelection, - mutation: Record, - options: { - label: string; - coalesceKey?: string; - softReload?: boolean; - skipReload?: boolean; - beforeReload?: () => void; - }, - ) => Promise; -} +export type { GsapDragCommitCallbacks }; /** * Attempt to handle a drag commit via the GSAP script mutation path. * * Returns a Promise that resolves to true if the drag was handled via GSAP * (caller should skip the CSS path), or false if no GSAP position animation - * exists. The promise resolves only AFTER the mutation has been persisted and - * the preview soft-reloaded — the CSS offset stays visible until then so the - * element doesn't snap back during the async gap. + * exists. */ // fallow-ignore-next-line complexity export async function tryGsapDragIntercept( @@ -202,10 +125,6 @@ export async function tryGsapDragIntercept( const selector = selectorForSelection(selection); if (!selector) return false; - // Keyframe writes at 0%/100% when outside the tween range. Acceptable - // trade-off — CSS path must NEVER touch GSAP-targeted elements because - // changing the CSS offset corrupts all existing keyframes (baked mismatch). - const gsapPos = readGsapPositionFromIframe(iframe, selector); if (!gsapPos) return false; @@ -215,294 +134,9 @@ export async function tryGsapDragIntercept( return true; } -// ── Commit helpers ───────────────────────────────────────────────────────── - -/** - * Compute the new GSAP position values from runtime-read positions + drag - * offset, then commit the mutation to the GSAP script. - * - * `gsap.getProperty` reads from GSAP's internal cache (element._gsap), not - * from the DOM transform matrix. The strip in `applyStudioPathOffset` does - * not affect the cached values, so the formula is simply: - * newValue = cachedGsapValue + dragOffset - * - * For flat tweens (to/set), the mutation would change the tween endpoint, - * which is invisible at t=0. Instead, we convert to keyframes first so the - * position is set at the exact seek percentage via a keyframe. - */ -// fallow-ignore-next-line complexity -async function commitGsapPositionFromDrag( - selection: DomEditSelection, - anim: GsapAnimation, - studioOffset: { x: number; y: number }, - gsapPos: { x: number; y: number }, - iframe: HTMLIFrameElement | null, - selector: string, - callbacks: GsapDragCommitCallbacks, -): Promise { - // 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 el = selection.element; - const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0; - const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0; - const deltaX = studioOffset.x - origX; - const deltaY = studioOffset.y - origY; - const adjX = deltaX * cos - deltaY * sin; - const adjY = deltaX * sin + deltaY * cos; - // Use the GSAP base captured at drag start — the live gsapPos is corrupted - // by the draft's gsap.set() calls during drag. - const baseGsapX = - Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "") || gsapPos.x; - const baseGsapY = - Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "") || gsapPos.y; - const newX = Math.round(baseGsapX + adjX); - const newY = Math.round(baseGsapY + adjY); - // Restore the CSS offset to pre-drag value so the baked translate stays - // consistent with existing keyframes. The drag is captured in the new keyframe. - const restoreOffset = () => { - el.style.setProperty("--hf-studio-offset-x", `${origX}px`); - el.style.setProperty("--hf-studio-offset-y", `${origY}px`); - el.removeAttribute("data-hf-drag-initial-offset-x"); - el.removeAttribute("data-hf-drag-initial-offset-y"); - }; - - if (anim.keyframes) { - const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); - const effectiveAnim = newId ? { ...anim, id: newId } : anim; - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); - - // Check if current time is outside the tween's range — extend the tween - // to cover the playhead, remap existing keyframes, then add the new one. - const ct = usePlayerStore.getState().currentTime; - const ts = resolveTweenStart(effectiveAnim); - const td = resolveTweenDuration(effectiveAnim); - if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) { - await extendTweenAndAddKeyframe( - selection, - effectiveAnim, - { ...runtimeProps, x: newX, y: newY }, - ct, - ts, - td, - callbacks, - restoreOffset, - ); - } else { - await commitKeyframedPosition( - selection, - effectiveAnim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - restoreOffset, - ); - } - } else if (anim.method === "from" || anim.method === "fromTo") { - // from()/fromTo() — convert to keyframes in a single mutation, placing - // the dragged position at the 100% (rest) keyframe. A single mutation - // avoids the stable-id flip (from→to) that breaks chained mutations. - await callbacks.commitMutation( - selection, - { - type: "convert-to-keyframes", - animationId: anim.id, - resolvedFromValues: { x: newX, y: newY }, - }, - { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset }, - ); - } else { - // Flat to()/set() — convert to keyframes then add at current percentage. - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); - await commitFlatViaKeyframes( - selection, - anim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - restoreOffset, - ); - } -} - -/** - * Extend a tween's time range to cover `targetTime`, remap all existing - * keyframe percentages to preserve their absolute positions, then add - * a new keyframe at the target time. - */ -async function extendTweenAndAddKeyframe( - selection: DomEditSelection, - anim: GsapAnimation, - properties: Record, - targetTime: number, - tweenStart: number, - tweenDuration: number, - callbacks: GsapDragCommitCallbacks, - beforeReload?: () => void, -): Promise { - const tweenEnd = tweenStart + tweenDuration; - const newStart = Math.min(targetTime, tweenStart); - const newEnd = Math.max(targetTime, tweenEnd); - const newDuration = Math.max(0.01, newEnd - newStart); - - // Step 1: Remap all existing keyframes to preserve their absolute times - // in the new range, then add the new keyframe. - const existingKfs = anim.keyframes?.keyframes ?? []; - const remappedKfs: Array<{ percentage: number; properties: Record }> = - []; - for (const kf of existingKfs) { - const absTime = tweenStart + (kf.percentage / 100) * tweenDuration; - const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; - remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } }); - } - - // Add the new keyframe at the target time - const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10; - remappedKfs.push({ percentage: targetPct, properties }); - - // Sort and dedupe - remappedKfs.sort((a, b) => a.percentage - b.percentage); - - // Step 2: Delete the old tween and create a new one with the extended range - // and all remapped keyframes. Using delete + add-with-keyframes as an atomic pair. - await callbacks.commitMutation( - selection, - { type: "delete", animationId: anim.id }, - { label: "Extend tween range", skipReload: true }, - ); - - const selector = anim.targetSelector; - await callbacks.commitMutation( - selection, - { - type: "add-with-keyframes", - targetSelector: selector, - position: Math.round(newStart * 1000) / 1000, - duration: Math.round(newDuration * 1000) / 1000, - keyframes: remappedKfs, - }, - { label: `Move layer (extended keyframe)`, softReload: true, beforeReload }, - ); -} - -// fallow-ignore-next-line complexity -async function commitKeyframedPosition( - selection: DomEditSelection, - anim: GsapAnimation, - properties: Record, - callbacks: GsapDragCommitCallbacks, - beforeReload?: () => void, -): Promise { - const pct = computeCurrentPercentage(selection, anim); - - await callbacks.commitMutation( - selection, - { - type: "add-keyframe", - animationId: anim.id, - percentage: pct, - properties, - }, - { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, - ); -} - -/** - * For flat to()/set() tweens, convert to keyframes first so we can place the - * drag position at the current percentage. Without conversion, the mutation - * only changes the tween endpoint, which is invisible at t=0. - */ -// fallow-ignore-next-line complexity -async function commitFlatViaKeyframes( - selection: DomEditSelection, - anim: GsapAnimation, - properties: Record, - callbacks: GsapDragCommitCallbacks, - beforeReload?: () => void, -): Promise { - await callbacks.commitMutation( - selection, - { type: "convert-to-keyframes", animationId: anim.id }, - { label: "Convert to keyframes for drag", skipReload: true }, - ); +// ── Runtime property readers (re-exported for external callers) ─────────── - const pct = computeCurrentPercentage(selection, anim); - - await callbacks.commitMutation( - selection, - { - type: "add-keyframe", - animationId: anim.id, - percentage: pct, - properties, - }, - { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, - ); -} - -// ── Runtime property reader ─────────────────────────────────────────────── - -export function readGsapProperty( - iframe: HTMLIFrameElement | null, - selector: string | null, - prop: string, -): number | null { - if (!iframe?.contentWindow || !selector) return null; - try { - const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap; - if (!gsap?.getProperty) return null; - const el = iframe.contentDocument?.querySelector(selector); - if (!el) return null; - const val = Number(gsap.getProperty(el, prop)); - return Number.isFinite(val) ? Math.round(val) : null; - } catch { - return null; - } -} - -export 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; -} +export { readGsapProperty, readAllAnimatedProperties }; // ── Resize intercept ────────────────────────────────────────────────────── diff --git a/packages/studio/src/hooks/gsapRuntimeReaders.ts b/packages/studio/src/hooks/gsapRuntimeReaders.ts new file mode 100644 index 000000000..07d08761a --- /dev/null +++ b/packages/studio/src/hooks/gsapRuntimeReaders.ts @@ -0,0 +1,67 @@ +/** + * Low-level GSAP runtime property readers shared by gsapRuntimeBridge and gsapDragCommit. + */ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; + +interface IframeGsap { + getProperty: (el: Element, prop: string) => number; +} + +export function readGsapProperty( + iframe: HTMLIFrameElement | null, + selector: string | null, + prop: string, +): number | null { + if (!iframe?.contentWindow || !selector) return null; + try { + const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap; + if (!gsap?.getProperty) return null; + const el = iframe.contentDocument?.querySelector(selector); + if (!el) return null; + const val = Number(gsap.getProperty(el, prop)); + return Number.isFinite(val) ? Math.round(val) : null; + } catch { + return null; + } +} + +export 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; +} diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts new file mode 100644 index 000000000..278d48f46 --- /dev/null +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -0,0 +1,145 @@ +import type { TimelineElement } from "../player"; +import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher"; +import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import type { EditHistoryKind } from "../utils/editHistory"; + +// ── Types ── + +interface RecordEditInput { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; +} + +export function buildPatchTarget(element: { + domId?: string; + hfId?: string; + selector?: string; + selectorIndex?: number; +}) { + if (element.domId) { + return { + id: element.domId, + hfId: element.hfId, + selector: element.selector, + selectorIndex: element.selectorIndex, + }; + } + if (element.hfId) { + return { hfId: element.hfId, selector: element.selector, selectorIndex: element.selectorIndex }; + } + if (element.selector) { + return { selector: element.selector, selectorIndex: element.selectorIndex }; + } + return null; +} + +export type PatchTarget = NonNullable>; + +// The runtime re-reads data-start/data-duration from the DOM on each sync tick +// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are +// picked up automatically on the next frame without a rebind call. +export function patchIframeDomTiming( + iframe: HTMLIFrameElement | null, + element: TimelineElement, + attrs: Array<[string, string]>, +): void { + try { + const doc = iframe?.contentDocument; + if (!doc) return; + const el = element.domId + ? doc.getElementById(element.domId) + : element.selector + ? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null) + : null; + if (!el) return; + for (const [name, value] of attrs) el.setAttribute(name, value); + } catch { + // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort. + } +} + +export function resolveResizePlaybackStart( + original: string, + target: PatchTarget, + element: TimelineElement, + updates: Pick, +): { attrName: string; value: number } | null { + if (updates.playbackStart != null) { + const attrName = + element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start"; + return { attrName, value: updates.playbackStart }; + } + const trimDelta = updates.start - element.start; + if (trimDelta === 0) return null; + const raw = + readAttributeByTarget(original, target, "playback-start") ?? + readAttributeByTarget(original, target, "media-start"); + const current = raw != null ? parseFloat(raw) : undefined; + if (current == null || !Number.isFinite(current)) return null; + const attrName = + element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start"; + return { + attrName, + value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)), + }; +} + +export interface PersistTimelineEditInput { + projectId: string; + element: TimelineElement; + activeCompPath: string | null; + label: string; + buildPatches: (original: string, target: PatchTarget) => string; + writeProjectFile: (path: string, content: string) => Promise; + recordEdit: (input: RecordEditInput) => Promise; + domEditSaveTimestampRef: React.MutableRefObject; + pendingTimelineEditPathRef: React.MutableRefObject>; +} + +export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise { + const targetPath = input.element.sourceFile || input.activeCompPath || "index.html"; + const originalContent = await readFileContent(input.projectId, targetPath); + + const patchTarget = buildPatchTarget(input.element); + if (!patchTarget) { + throw new Error(`Timeline element ${input.element.id} is missing a patchable target`); + } + + const patchedContent = input.buildPatches(originalContent, patchTarget); + if (patchedContent === originalContent) { + throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`); + } + + input.pendingTimelineEditPathRef.current.add(targetPath); + input.domEditSaveTimestampRef.current = Date.now(); + await saveProjectFilesWithHistory({ + projectId: input.projectId, + label: input.label, + kind: "timeline", + files: { [targetPath]: patchedContent }, + readFile: async () => originalContent, + writeFile: input.writeProjectFile, + recordEdit: input.recordEdit, + }); + input.domEditSaveTimestampRef.current = Date.now(); +} + +export async function readFileContent(projectId: string, targetPath: string): Promise { + const response = await fetch( + `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`, + ); + if (!response.ok) { + throw new Error(`Failed to read ${targetPath}`); + } + const data = (await response.json()) as { content?: string }; + if (typeof data.content !== "string") { + throw new Error(`Missing file contents for ${targetPath}`); + } + return data.content; +} + +// Re-export applyPatchByTarget for use in the hook (avoids double import in callers) +export { applyPatchByTarget, formatTimelineAttributeNumber }; diff --git a/packages/studio/src/hooks/useBlockHandlers.ts b/packages/studio/src/hooks/useBlockHandlers.ts new file mode 100644 index 000000000..444fcac49 --- /dev/null +++ b/packages/studio/src/hooks/useBlockHandlers.ts @@ -0,0 +1,157 @@ +/** + * Block drop/add handlers for the Studio. + * Extracted from App.tsx to keep file sizes under the 600-line limit. + */ +import { useCallback, useMemo, useState } from "react"; +import type { TimelineElement } from "../player"; +import { usePlayerStore } from "../player"; +import { addBlockToProject } from "../utils/blockInstaller"; +import type { BlockParam } from "@hyperframes/core/registry"; +import type { EditHistoryKind } from "../utils/editHistory"; +import type { RightPanelTab } from "../utils/studioHelpers"; + +interface BlockCtxDeps { + activeCompPath: string | null; + timelineElements: TimelineElement[]; + readProjectFile: (path: string) => Promise; + writeProjectFile: (path: string, content: string) => Promise; + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + refreshFileTree: () => Promise; + reloadPreview: () => void; + showToast: (message: string, tone?: "error" | "info") => void; +} + +interface UseBlockHandlersParams { + projectId: string | null; + blockCtxDeps: BlockCtxDeps; + previewIframeRef: React.RefObject; + setRightCollapsed: (collapsed: boolean) => void; + setRightPanelTab: (tab: RightPanelTab) => void; +} + +export interface UseBlockHandlersResult { + activeBlockParams: { + blockName: string; + blockTitle: string; + params: BlockParam[]; + compositionPath: string; + } | null; + setActiveBlockParams: React.Dispatch< + React.SetStateAction + >; + handleAddBlock: (blockName: string) => void; + handleTimelineBlockDrop: ( + blockName: string, + placement: { start: number; track: number }, + ) => void; + handlePreviewBlockDrop: ( + blockName: string, + position: { left: number; top: number }, + ) => void; +} + +export function useBlockHandlers({ + projectId, + blockCtxDeps, + previewIframeRef, + setRightCollapsed, + setRightPanelTab, +}: UseBlockHandlersParams): UseBlockHandlersResult { + const [activeBlockParams, setActiveBlockParams] = useState< + UseBlockHandlersResult["activeBlockParams"] + >(null); + + const blockCtx = useMemo( + () => ({ + activeCompPath: blockCtxDeps.activeCompPath, + timelineElements: blockCtxDeps.timelineElements, + readProjectFile: blockCtxDeps.readProjectFile, + writeProjectFile: blockCtxDeps.writeProjectFile, + recordEdit: blockCtxDeps.recordEdit, + refreshFileTree: blockCtxDeps.refreshFileTree, + reloadPreview: blockCtxDeps.reloadPreview, + showToast: blockCtxDeps.showToast, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + blockCtxDeps.activeCompPath, + blockCtxDeps.timelineElements, + blockCtxDeps.readProjectFile, + blockCtxDeps.writeProjectFile, + blockCtxDeps.recordEdit, + blockCtxDeps.refreshFileTree, + blockCtxDeps.reloadPreview, + blockCtxDeps.showToast, + ], + ); + + const handleAddBlock = useCallback( + (blockName: string) => { + if (!projectId) return; + void (async () => { + const result = await addBlockToProject({ + projectId, + blockName, + ...blockCtx, + previewIframe: previewIframeRef.current, + currentTime: usePlayerStore.getState().currentTime, + }); + const params = result?.block.type === "hyperframes:block" ? result.block.params : undefined; + if (params?.length) { + setActiveBlockParams({ + blockName: result!.block.name, + blockTitle: result!.block.title, + params, + compositionPath: result!.compositionPath, + }); + setRightCollapsed(false); + setRightPanelTab("block-params"); + } + })(); + }, + [projectId, blockCtx, previewIframeRef, setRightCollapsed, setRightPanelTab], + ); + + const handleTimelineBlockDrop = useCallback( + (blockName: string, placement: { start: number; track: number }) => { + if (!projectId) return; + void addBlockToProject({ + projectId, + blockName, + placement, + ...blockCtx, + previewIframe: previewIframeRef.current, + currentTime: usePlayerStore.getState().currentTime, + }); + }, + [projectId, blockCtx, previewIframeRef], + ); + + const handlePreviewBlockDrop = useCallback( + (blockName: string, position: { left: number; top: number }) => { + if (!projectId) return; + void addBlockToProject({ + projectId, + blockName, + visualPosition: position, + ...blockCtx, + previewIframe: previewIframeRef.current, + currentTime: usePlayerStore.getState().currentTime, + }); + }, + [projectId, blockCtx, previewIframeRef], + ); + + return { + activeBlockParams, + setActiveBlockParams, + handleAddBlock, + handleTimelineBlockDrop, + handlePreviewBlockDrop, + }; +} diff --git a/packages/studio/src/hooks/useDomEditPreviewSync.ts b/packages/studio/src/hooks/useDomEditPreviewSync.ts new file mode 100644 index 000000000..d2ea5d73a --- /dev/null +++ b/packages/studio/src/hooks/useDomEditPreviewSync.ts @@ -0,0 +1,128 @@ +/** + * Side effects for syncing the DOM edit selection with the preview iframe on + * load/refresh, and for auto-revealing source in the Code tab. + * Extracted from useDomEditSession to keep file sizes under the 600-line limit. + */ +import { useEffect, useRef } from "react"; +import { + STUDIO_INSPECTOR_PANELS_ENABLED, +} from "../components/editor/manualEditingAvailability"; +import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing"; +import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"; +import type { SidebarTab } from "../components/sidebar/LeftSidebar"; +import type { PatchTarget } from "../utils/sourcePatcher"; + +interface UseDomEditPreviewSyncParams { + previewIframe: HTMLIFrameElement | null; + activeCompPath: string | null; + captionEditMode: boolean; + domEditSelectionRef: React.MutableRefObject; + domEditSelection: DomEditSelection | null; + applyDomSelection: ( + selection: DomEditSelection | null, + options?: { revealPanel?: boolean; preserveGroup?: boolean }, + ) => void; + buildDomSelectionFromTarget: (element: HTMLElement) => Promise; + refreshPreviewDocumentVersion: () => void; + syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void; + applyStudioManualEditsToPreviewRef: React.MutableRefObject< + (iframe: HTMLIFrameElement) => Promise + >; + openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void; + getSidebarTab?: () => SidebarTab; +} + +export function useDomEditPreviewSync({ + previewIframe, + activeCompPath, + captionEditMode, + domEditSelectionRef, + domEditSelection, + applyDomSelection, + buildDomSelectionFromTarget, + refreshPreviewDocumentVersion, + syncPreviewHistoryHotkey, + applyStudioManualEditsToPreviewRef, + openSourceForSelection, + getSidebarTab, +}: UseDomEditPreviewSyncParams): void { + // Sync selection from preview document on load / refresh + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!previewIframe) return; + + // fallow-ignore-next-line complexity + const syncSelectionFromDocument = async () => { + if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return; + const currentSelection = domEditSelectionRef.current; + if (!currentSelection) return; + let doc: Document | null = null; + try { + doc = previewIframe.contentDocument; + } catch { + return; + } + if (!doc) return; + + reapplyPositionEditsAfterSeek(doc); + + const nextElement = findElementForSelection(doc, currentSelection, activeCompPath); + if (!nextElement) { + applyDomSelection(null, { revealPanel: false }); + return; + } + + const nextSelection = await buildDomSelectionFromTarget(nextElement); + if (nextSelection) { + applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true }); + } + }; + + syncPreviewHistoryHotkey(previewIframe); + void applyStudioManualEditsToPreviewRef.current(previewIframe); + void syncSelectionFromDocument(); + refreshPreviewDocumentVersion(); + + const handleLoad = () => { + syncPreviewHistoryHotkey(previewIframe); + void applyStudioManualEditsToPreviewRef.current(previewIframe); + void syncSelectionFromDocument(); + refreshPreviewDocumentVersion(); + }; + + previewIframe.addEventListener("load", handleLoad); + return () => { + previewIframe.removeEventListener("load", handleLoad); + }; + }, [ + activeCompPath, + applyDomSelection, + buildDomSelectionFromTarget, + captionEditMode, + domEditSelectionRef, + previewIframe, + refreshPreviewDocumentVersion, + syncPreviewHistoryHotkey, + applyStudioManualEditsToPreviewRef, + ]); + + // Auto-reveal source when an element is selected while the Code tab is active. + // Use a ref for the callback so the effect only fires on selection changes, + // not when openSourceForSelection is recreated due to editingFile content updates. + const openSourceRef = useRef(openSourceForSelection); + openSourceRef.current = openSourceForSelection; + useEffect( + // fallow-ignore-next-line complexity + () => { + if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return; + if (!domEditSelection.sourceFile) return; + if (getSidebarTab() !== "code") return; + openSourceRef.current(domEditSelection.sourceFile, { + id: domEditSelection.id, + selector: domEditSelection.selector, + selectorIndex: domEditSelection.selectorIndex, + }); + }, + [domEditSelection, getSidebarTab], + ); +} diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index f4fa2ce3c..11f6e7a80 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -2,11 +2,10 @@ import { useCallback, useEffect, useRef } from "react"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { - STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_GSAP_PANEL_ENABLED, } from "../components/editor/manualEditingAvailability"; -import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing"; -import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"; +import { type DomEditSelection } from "../components/editor/domEditing"; +import { useDomEditPreviewSync } from "./useDomEditPreviewSync"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; import type { RightPanelTab } from "../utils/studioHelpers"; @@ -482,85 +481,20 @@ export function useDomEditSession({ [domEditSelection, updateArcSegment], ); - // Sync selection from preview document on load / refresh - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - if (!previewIframe) return; - - // fallow-ignore-next-line complexity - const syncSelectionFromDocument = async () => { - if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return; - const currentSelection = domEditSelectionRef.current; - if (!currentSelection) return; - let doc: Document | null = null; - try { - doc = previewIframe.contentDocument; - } catch { - return; - } - if (!doc) return; - - reapplyPositionEditsAfterSeek(doc); - - const nextElement = findElementForSelection(doc, currentSelection, activeCompPath); - if (!nextElement) { - applyDomSelection(null, { revealPanel: false }); - return; - } - - const nextSelection = await buildDomSelectionFromTarget(nextElement); - if (nextSelection) { - applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true }); - } - }; - - syncPreviewHistoryHotkey(previewIframe); - void applyStudioManualEditsToPreviewRef.current(previewIframe); - void syncSelectionFromDocument(); - refreshPreviewDocumentVersion(); - - const handleLoad = () => { - syncPreviewHistoryHotkey(previewIframe); - void applyStudioManualEditsToPreviewRef.current(previewIframe); - void syncSelectionFromDocument(); - refreshPreviewDocumentVersion(); - }; - - previewIframe.addEventListener("load", handleLoad); - return () => { - previewIframe.removeEventListener("load", handleLoad); - }; - }, [ + useDomEditPreviewSync({ + previewIframe, activeCompPath, - applyDomSelection, - buildDomSelectionFromTarget, captionEditMode, domEditSelectionRef, - previewIframe, + domEditSelection, + applyDomSelection, + buildDomSelectionFromTarget, refreshPreviewDocumentVersion, syncPreviewHistoryHotkey, applyStudioManualEditsToPreviewRef, - ]); - - // Auto-reveal source when an element is selected while the Code tab is active. - // Use a ref for the callback so the effect only fires on selection changes, - // not when openSourceForSelection is recreated due to editingFile content updates. - const openSourceRef = useRef(openSourceForSelection); - openSourceRef.current = openSourceForSelection; - useEffect( - // fallow-ignore-next-line complexity - () => { - if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return; - if (!domEditSelection.sourceFile) return; - if (getSidebarTab() !== "code") return; - openSourceRef.current(domEditSelection.sourceFile, { - id: domEditSelection.id, - selector: domEditSelection.selector, - selectorIndex: domEditSelection.selectorIndex, - }); - }, - [domEditSelection, getSidebarTab], - ); + openSourceForSelection, + getSidebarTab, + }); return { // State diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts new file mode 100644 index 000000000..8c1a2185f --- /dev/null +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -0,0 +1,153 @@ +/** + * Manages gesture recording state and commit logic for the Studio. + * Extracted from App.tsx to keep file sizes under the 600-line limit. + */ +import { useState, useCallback, useRef, useEffect } from "react"; +import { useGestureRecording } from "./useGestureRecording"; +import { simplifyGestureSamples } from "../utils/rdpSimplify"; +import { usePlayerStore } from "../player"; +import type { DomEditSelection } from "../components/editor/domEditing"; + +// Minimal subset of the session used by gesture commit +interface GestureSessionRef { + domEditSelection: DomEditSelection | null; + commitMutation?: ( + mutation: Record, + options: { label: string; softReload?: boolean }, + ) => Promise; +} + +interface UseGestureCommitParams { + domEditSessionRef: React.MutableRefObject; + previewIframeRef: React.RefObject; + showToast: (message: string, tone?: "error" | "info") => void; + isGestureRecordingRef: React.MutableRefObject; +} + +export interface UseGestureCommitResult { + gestureState: "idle" | "recording"; + gestureRecording: ReturnType; + handleToggleRecording: () => void; +} + +// fallow-ignore-next-line complexity +export function useGestureCommit({ + domEditSessionRef, + previewIframeRef, + showToast, + isGestureRecordingRef, +}: UseGestureCommitParams): UseGestureCommitResult { + const gestureRecording = useGestureRecording(); + const [gestureState, setGestureState] = useState<"idle" | "recording">("idle"); + const gestureStateRef = useRef<"idle" | "recording">("idle"); + const recordingAutoStopRef = useRef>(undefined); + const recordingStartTimeRef = useRef(0); + const commitInFlightRef = useRef(false); + + // Unmount: clear auto-stop interval + useEffect(() => () => clearInterval(recordingAutoStopRef.current), []); + + // fallow-ignore-next-line complexity + const stopAndCommitRecording = useCallback(async () => { + clearInterval(recordingAutoStopRef.current); + if (commitInFlightRef.current) return; + commitInFlightRef.current = true; + gestureStateRef.current = "idle"; + isGestureRecordingRef.current = false; + const frozenSamples = gestureRecording.stopRecording(); + const store = usePlayerStore.getState(); + store.setIsPlaying(false); + try { + const liveSession = domEditSessionRef.current; + const sel = liveSession.domEditSelection; + if (!sel) { + if (frozenSamples.length > 2) { + showToast("Selection lost during recording", "error"); + } + return; + } + const duration = frozenSamples.length > 0 ? frozenSamples[frozenSamples.length - 1]!.time : 0; + + if (frozenSamples.length <= 2) { + showToast("No gesture detected — move the pointer while recording", "error"); + return; + } + if (duration <= 0) { + showToast("Recording too short — try again", "error"); + return; + } + + const simplified = simplifyGestureSamples(frozenSamples, duration, 5); + const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b); + + const selector = sel.id ? `#${sel.id}` : sel.selector; + if (!selector) { + showToast("Cannot save — element has no selector", "error"); + return; + } + if (liveSession.commitMutation) { + const recStart = recordingStartTimeRef.current; + const keyframes = sortedPcts.map((pct) => ({ + percentage: pct, + properties: simplified.get(pct) as Record, + })); + + 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", softReload: true }, + ); + } + showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); + } finally { + store.requestSeek(recordingStartTimeRef.current); + gestureRecording.clearSamples(); + setGestureState("idle"); + commitInFlightRef.current = false; + } + }, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]); + + const handleToggleRecording = useCallback(() => { + if (gestureStateRef.current === "recording") { + void stopAndCommitRecording(); + return; + } + const sel = domEditSessionRef.current.domEditSelection; + if (!sel) { + showToast("Select an element first", "error"); + return; + } + const iframe = previewIframeRef.current; + if (!iframe) { + showToast("Preview not ready — try again", "error"); + return; + } + + const store = usePlayerStore.getState(); + recordingStartTimeRef.current = store.currentTime; + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0; + const elementEnd = elDur > 0 ? elStart + elDur : undefined; + gestureRecording.startRecording(sel.element, iframe, elementEnd); + gestureStateRef.current = "recording"; + isGestureRecordingRef.current = true; + setGestureState("recording"); + + clearInterval(recordingAutoStopRef.current); + const autoStopAt = elementEnd ?? Infinity; + recordingAutoStopRef.current = setInterval(() => { + const { currentTime: t, duration: d } = usePlayerStore.getState(); + const limit = Math.min(autoStopAt, d); + if (limit > 0 && t >= limit - 0.05) { + void stopAndCommitRecording(); + } + }, 100); + }, [gestureRecording, showToast, stopAndCommitRecording, previewIframeRef, domEditSessionRef, isGestureRecordingRef]); + + return { gestureState, gestureRecording, handleToggleRecording }; +} diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index f215e6a8a..7daaf2845 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -6,6 +6,11 @@ import { applySoftReload } from "../utils/gsapSoftReload"; import { executeOptimistic } from "../utils/optimisticUpdate"; import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore"; import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit"; +import { + updateKeyframeCacheFromParsed, + readKeyframeSnapshot, + writeKeyframeCache, +} from "./gsapKeyframeCacheHelpers"; const PROPERTY_DEFAULTS: Record = { opacity: 1, @@ -72,84 +77,6 @@ async function mutateGsapScript( return null; } } -function updateKeyframeCacheFromParsed( - animations: GsapAnimation[], - targetPath: string, - selectionId: string | undefined, - mutation: Record, -): void { - const { setKeyframeCache, elements } = usePlayerStore.getState(); - const idsWithKeyframes = new Set(); - const merged = new Map(); - for (const anim of animations) { - const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1]; - if (!id || !anim.keyframes) continue; - idsWithKeyframes.add(id); - - // Convert tween-relative percentages to clip-relative so diamonds - // render at the correct position within the timeline clip. - const tweenPos = typeof anim.position === "number" ? anim.position : 0; - const tweenDur = anim.duration ?? 1; - const timelineEl = elements.find( - (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`, - ); - const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 4; - const clipKeyframes = anim.keyframes.keyframes.map((kf) => { - const absTime = tweenPos + (kf.percentage / 100) * tweenDur; - const clipPct = - elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; - return { ...kf, percentage: clipPct }; - }); - - const existing = merged.get(id); - if (existing) { - const byPct = new Map(); - for (const kf of [...existing.keyframes, ...clipKeyframes]) { - const prev = byPct.get(kf.percentage); - if (prev) { - prev.properties = { ...prev.properties, ...kf.properties }; - if (kf.ease) prev.ease = kf.ease; - } else { - byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } }); - } - } - existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage); - } else { - merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes }); - } - } - for (const [id, entry] of merged) { - setKeyframeCache(`${targetPath}#${id}`, entry); - setKeyframeCache(id, entry); - if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry); - } - const targetId = - (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ?? - selectionId; - if (targetId && !idsWithKeyframes.has(targetId)) { - setKeyframeCache(`${targetPath}#${targetId}`, undefined); - if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined); - } -} -function buildCacheKey(sourceFile: string, elementId: string): string { - return `${sourceFile}#${elementId}`; -} -function readKeyframeSnapshot( - sourceFile: string, - elementId: string | null | undefined, -): KeyframeCacheEntry | undefined { - if (!elementId) return undefined; - return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId)); -} -function writeKeyframeCache( - sourceFile: string, - elementId: string | null | undefined, - data: KeyframeCacheEntry | undefined, -): void { - if (!elementId) return; - usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data); -} interface GsapScriptCommitsParams { projectIdRef: React.MutableRefObject; activeCompPath: string | null; diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 196852204..b877f32bb 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -1,8 +1,6 @@ import { useCallback, useRef } from "react"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; -import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher"; -import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; import { buildTimelineAssetId, buildTimelineAssetInsertHtml, @@ -19,6 +17,16 @@ import { resolveDroppedAssetDuration, } from "../utils/studioHelpers"; import type { EditHistoryKind } from "../utils/editHistory"; +import { + buildPatchTarget, + patchIframeDomTiming, + resolveResizePlaybackStart, + persistTimelineEdit, + readFileContent, + applyPatchByTarget, + formatTimelineAttributeNumber, +} from "./timelineEditingHelpers"; +import type { PatchTarget, PersistTimelineEditInput } from "./timelineEditingHelpers"; // ── Types ── @@ -44,136 +52,6 @@ interface UseTimelineEditingOptions { isRecordingRef?: React.RefObject; } -// ── Helpers ── - -function buildPatchTarget(element: { - domId?: string; - hfId?: string; - selector?: string; - selectorIndex?: number; -}) { - if (element.domId) { - return { - id: element.domId, - hfId: element.hfId, - selector: element.selector, - selectorIndex: element.selectorIndex, - }; - } - if (element.hfId) { - return { hfId: element.hfId, selector: element.selector, selectorIndex: element.selectorIndex }; - } - if (element.selector) { - return { selector: element.selector, selectorIndex: element.selectorIndex }; - } - return null; -} - -// The runtime re-reads data-start/data-duration from the DOM on each sync tick -// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are -// picked up automatically on the next frame without a rebind call. -function patchIframeDomTiming( - iframe: HTMLIFrameElement | null, - element: TimelineElement, - attrs: Array<[string, string]>, -): void { - try { - const doc = iframe?.contentDocument; - if (!doc) return; - const el = element.domId - ? doc.getElementById(element.domId) - : element.selector - ? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null) - : null; - if (!el) return; - for (const [name, value] of attrs) el.setAttribute(name, value); - } catch { - // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort. - } -} - -function resolveResizePlaybackStart( - original: string, - target: PatchTarget, - element: TimelineElement, - updates: Pick, -): { attrName: string; value: number } | null { - if (updates.playbackStart != null) { - const attrName = - element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start"; - return { attrName, value: updates.playbackStart }; - } - const trimDelta = updates.start - element.start; - if (trimDelta === 0) return null; - const raw = - readAttributeByTarget(original, target, "playback-start") ?? - readAttributeByTarget(original, target, "media-start"); - const current = raw != null ? parseFloat(raw) : undefined; - if (current == null || !Number.isFinite(current)) return null; - const attrName = - element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start"; - return { - attrName, - value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)), - }; -} - -type PatchTarget = NonNullable>; - -interface PersistTimelineEditInput { - projectId: string; - element: TimelineElement; - activeCompPath: string | null; - label: string; - buildPatches: (original: string, target: PatchTarget) => string; - writeProjectFile: (path: string, content: string) => Promise; - recordEdit: (input: RecordEditInput) => Promise; - domEditSaveTimestampRef: React.MutableRefObject; - pendingTimelineEditPathRef: React.MutableRefObject>; -} - -async function persistTimelineEdit(input: PersistTimelineEditInput): Promise { - const targetPath = input.element.sourceFile || input.activeCompPath || "index.html"; - const originalContent = await readFileContent(input.projectId, targetPath); - - const patchTarget = buildPatchTarget(input.element); - if (!patchTarget) { - throw new Error(`Timeline element ${input.element.id} is missing a patchable target`); - } - - const patchedContent = input.buildPatches(originalContent, patchTarget); - if (patchedContent === originalContent) { - throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`); - } - - input.pendingTimelineEditPathRef.current.add(targetPath); - input.domEditSaveTimestampRef.current = Date.now(); - await saveProjectFilesWithHistory({ - projectId: input.projectId, - label: input.label, - kind: "timeline", - files: { [targetPath]: patchedContent }, - readFile: async () => originalContent, - writeFile: input.writeProjectFile, - recordEdit: input.recordEdit, - }); - input.domEditSaveTimestampRef.current = Date.now(); -} - -async function readFileContent(projectId: string, targetPath: string): Promise { - const response = await fetch( - `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`, - ); - if (!response.ok) { - throw new Error(`Failed to read ${targetPath}`); - } - const data = (await response.json()) as { content?: string }; - if (typeof data.content !== "string") { - throw new Error(`Missing file contents for ${targetPath}`); - } - return data.content; -} - // ── Hook ── export function useTimelineEditing({