diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 64fcc1831b..b95330fe44 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -35,6 +35,8 @@ "packages/studio/src/components/nle/TimelineEditorNotice.tsx", // Zoom hook extracted for downstream razor-blade PRs (#1330, #1331). "packages/studio/src/player/components/useTimelineZoom.ts", + // Preview helper consumed dynamically from the studio iframe bridge. + "packages/studio/src/hooks/gsapRuntimePreview.ts", ], "ignorePatterns": [ "docs/**", diff --git a/lefthook-local.yml b/lefthook-local.yml new file mode 100644 index 0000000000..438febff55 --- /dev/null +++ b/lefthook-local.yml @@ -0,0 +1,14 @@ +commit-msg: + commands: + coauthor: + run: | + MSG_FILE="{1}" + TRAILER1="Co-authored-by: Miguel Ángel " + if ! grep -qF "$TRAILER1" "$MSG_FILE"; then + # Ensure blank line before trailers + if [ -n "$(tail -c1 "$MSG_FILE")" ]; then + echo "" >> "$MSG_FILE" + fi + echo "" >> "$MSG_FILE" + echo "$TRAILER1" >> "$MSG_FILE" + fi diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 075b4e7c7a..becb3e2ac4 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useMemo, useEffect } from "react"; +import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react"; import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar"; import { useRenderQueue } from "./components/renders/useRenderQueue"; import { usePlayerStore } from "./player"; @@ -37,13 +37,12 @@ import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay"; import { StudioHeader } from "./components/StudioHeader"; import { useGestureCommit } from "./hooks/useGestureCommit"; import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability"; - import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay"; import { StudioLeftSidebar } from "./components/StudioLeftSidebar"; import { StudioPreviewArea } from "./components/StudioPreviewArea"; import { StudioRightPanel } from "./components/StudioRightPanel"; import { TimelineToolbar } from "./components/TimelineToolbar"; -import { StudioProvider } from "./contexts/StudioContext"; +import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext"; import { PanelLayoutProvider } from "./contexts/PanelLayoutContext"; import { FileManagerProvider } from "./contexts/FileManagerContext"; import { DomEditProvider } from "./contexts/DomEditContext"; @@ -57,15 +56,13 @@ import { import { trackStudioSessionStart } from "./telemetry/events"; import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config"; +type CanvasRect = { left: number; top: number; width: number; height: number }; // fallow-ignore-next-line complexity export function StudioApp() { const { projectId, resolving, waitingForServer } = useServerConnection(); const initialUrlStateRef = useRef(readStudioUrlStateFromWindow()); - // Fire once per browser tab session — sessionStorage-backed so HMR - // remounts, route changes, and any future StudioApp remount within the - // same tab don't refire `studio_session_start`. `has_project` lets us - // tell scratch-open from project-context-open. + // sessionStorage-backed: fires once per tab, survives HMR remounts useEffect(() => { if (resolving || waitingForServer) return; if (hasFiredSessionStart()) return; @@ -83,7 +80,6 @@ export function StudioApp() { const [refreshKey, setRefreshKey] = useState(0); const [, setPreviewDocumentVersion] = useState(0); const [blockPreview, setBlockPreview] = useState(null); - const previewIframeRef = useRef(null); const activeCompPathRef = useRef(activeCompPath); activeCompPathRef.current = activeCompPath; @@ -107,12 +103,22 @@ export function StudioApp() { : 0; return Math.max(timelineDuration, maxEnd); }, [timelineDuration, timelineElements]); + const refreshTimersRef = useRef([]); const refreshPreviewDocumentVersion = useCallback(() => { + for (const id of refreshTimersRef.current) clearTimeout(id); + refreshTimersRef.current = []; setPreviewDocumentVersion((v) => v + 1); - window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80); - window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300); + refreshTimersRef.current.push( + window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80), + window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300), + ); }, []); - + useEffect( + () => () => { + for (const id of refreshTimersRef.current) clearTimeout(id); + }, + [], + ); const [timelineVisible, setTimelineVisible] = useState( () => initialUrlStateRef.current.timelineVisible ?? @@ -137,7 +143,6 @@ export function StudioApp() { const reloadPreview = useCallback(() => { setRefreshKey((k) => k + 1); }, []); - const fileManager = useFileManager({ projectId, showToast, @@ -145,11 +150,9 @@ export function StudioApp() { domEditSaveTimestampRef, setRefreshKey, }); - useEffect(() => { if (activeCompPathHydrated) return; if (!fileManager.fileTreeLoaded) return; - const nextCompPath = normalizeStudioCompositionPath( initialUrlStateRef.current.activeCompPath, fileManager.fileTree, @@ -157,7 +160,6 @@ export function StudioApp() { setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath)); setActiveCompPathHydrated(true); }, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]); - const previewPersistence = usePreviewPersistence({ projectId, showToast, @@ -170,7 +172,6 @@ export function StudioApp() { reloadPreview: () => setRefreshKey((k) => k + 1), pendingTimelineEditPathRef, }); - const timelineEditing = useTimelineEditing({ projectId, activeCompPath, @@ -185,7 +186,6 @@ export function StudioApp() { uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, }); - const { activeBlockParams, setActiveBlockParams, @@ -208,7 +208,6 @@ export function StudioApp() { setRightCollapsed: panelLayout.setRightCollapsed, setRightPanelTab: panelLayout.setRightPanelTab, }); - const clearDomSelectionRef = useRef<() => void>(() => {}); const domEditSelectionBridgeRef = useRef(null); const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise>( @@ -265,7 +264,6 @@ export function StudioApp() { () => leftSidebarRef.current?.getTab() ?? "compositions", [], ); - const domEditSession = useDomEditSession({ projectId, activeCompPath, @@ -325,20 +323,17 @@ export function StudioApp() { captionSync, setRightCollapsed: panelLayout.setRightCollapsed, }); - const renderClipContent = useRenderClipContent({ projectIdRef: fileManager.projectIdRef, compIdToSrc, activePreviewUrl, effectiveTimelineDuration, }); - const compositionDimensions = useCompositionDimensions(); - const { lintModal, linting, handleLint, closeLintModal, findingsByElement, findingsByFile } = - useLintModal(projectId, refreshKey); - useEffect(() => { - usePlayerStore.getState().setLintFindingsByElement(findingsByElement); - }, [findingsByElement]); + const { lintModal, linting, handleLint, closeLintModal, findingsByFile } = useLintModal( + projectId, + refreshKey, + ); const frameCapture = useFrameCapture({ projectId, activeCompPath, @@ -350,14 +345,12 @@ export function StudioApp() { setConsoleErrors, resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); - const dragOverlay = useDragOverlay(fileManager.handleImportFiles); // Gesture recording const handleToggleRecordingRef = useRef<() => void>(() => {}); const domEditSessionRef = useRef(domEditSession); domEditSessionRef.current = domEditSession; - const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({ domEditSessionRef, previewIframeRef, @@ -365,6 +358,15 @@ export function StudioApp() { isGestureRecordingRef, }); handleToggleRecordingRef.current = handleToggleRecording; + const canvasRectRef = useRef(null); + useLayoutEffect(() => { + if (gestureState !== "recording" || !previewIframe) { + canvasRectRef.current = null; + return; + } + const r = previewIframe.getBoundingClientRect(); + canvasRectRef.current = { left: r.left, top: r.top, width: r.width, height: r.height }; + }, [gestureState, previewIframe]); const handlePreviewIframeRef = useCallback( (iframe: HTMLIFrameElement | null) => { @@ -388,7 +390,6 @@ export function StudioApp() { }, [projectId, fileManager], ); - const { designPanelActive, inspectorPanelActive, @@ -400,7 +401,6 @@ export function StudioApp() { isPlaying, gestureState === "recording", ); - useStudioUrlState({ projectId, activeCompPath, @@ -418,7 +418,17 @@ export function StudioApp() { applyDomSelection: domEditSession.applyDomSelection, initialState: initialUrlStateRef.current, }); - + const { jobs, isRendering, deleteRender, clearCompleted, startRender } = renderQueue; + const stableRenderQueue = useMemo( + () => ({ + jobs, + isRendering, + deleteRender, + clearCompleted, + startRender: startRender as (options: unknown) => Promise, + }), + [jobs, isRendering, deleteRender, clearCompleted, startRender], + ); const studioCtxValue = buildStudioContextValue({ projectId: projectId!, activeCompPath, @@ -434,13 +444,7 @@ export function StudioApp() { editHistory, handleUndo: appHotkeys.handleUndo, handleRedo: appHotkeys.handleRedo, - renderQueue: { - jobs: renderQueue.jobs, - isRendering: renderQueue.isRendering, - deleteRender: renderQueue.deleteRender, - clearCompleted: renderQueue.clearCompleted, - startRender: renderQueue.startRender as (options: unknown) => Promise, - }, + renderQueue: stableRenderQueue, compositionDimensions, waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, handlePreviewIframeRef, @@ -450,145 +454,145 @@ export function StudioApp() { }); if (resolving || waitingForServer || !projectId) return ; - const timelineToolbar = ( - + const timelineToolbar = useMemo( + () => ( + + ), + [toggleTimelineVisibility, domEditSession, timelineEditing.handleTimelineElementSplit], ); return ( - - - - -
- void renderQueue.startRender()} - /> - - {previewPersistence.domEditSaveQueuePaused && ( - + + + + +
+ void renderQueue.startRender()} /> - )} - -
- - { - const r = previewIframe.getBoundingClientRect(); - return { left: r.left, top: r.top, width: r.width, height: r.height }; - })()} - compositionSize={compositionDimensions ?? undefined} - mode="recording" - /> - ) : undefined - } - /> - - {!panelLayout.rightCollapsed && ( - { - setActiveBlockParams(null); - panelLayout.setRightPanelTab("design"); - }} + {previewPersistence.domEditSaveQueuePaused && ( + + )} +
+ + + ) : undefined + } /> - )} -
- - {lintModal !== null && ( - - )} - {consoleErrors !== null && consoleErrors.length > 0 && ( - setConsoleErrors(null)} - /> - )} - {domEditSession.agentModalOpen && domEditSession.domEditSelection && ( - { + setActiveBlockParams(null); + panelLayout.setRightPanelTab("design"); + }} + recordingState={gestureState} + recordingDuration={gestureRecording.recordingDuration} + onToggleRecording={ + STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined + } + /> )} - anchorPoint={domEditSession.agentModalAnchorPoint} - onSubmit={domEditSession.handleAgentModalSubmit} - onClose={() => { - domEditSession.setAgentModalOpen(false); - domEditSession.setAgentPromptSelectionContext(undefined); - domEditSession.setAgentModalAnchorPoint(null); - }} - /> - )} +
+ {lintModal !== null && ( + + )} + {consoleErrors !== null && consoleErrors.length > 0 && ( + setConsoleErrors(null)} + /> + )} + {domEditSession.agentModalOpen && domEditSession.domEditSelection && ( + { + domEditSession.setAgentModalOpen(false); + domEditSession.setAgentPromptSelectionContext(undefined); + domEditSession.setAgentModalAnchorPoint(null); + }} + /> + )} - {dragOverlay.active && } - {appToast && ( - - )} -
-
-
-
- + {dragOverlay.active && } + {appToast && ( + + )} +
+
+
+
+ + ); } diff --git a/packages/studio/src/captions/store.ts b/packages/studio/src/captions/store.ts index 4c62c3b4e5..b14a127da0 100644 --- a/packages/studio/src/captions/store.ts +++ b/packages/studio/src/captions/store.ts @@ -59,7 +59,7 @@ const initialState = { sourceFilePath: null, }; -export const useCaptionStore = create((set) => ({ +export const useCaptionStore = create((set, get) => ({ ...initialState, // Basic @@ -82,15 +82,11 @@ export const useCaptionStore = create((set) => ({ return { selectedSegmentIds: new Set([id]), selectedGroupId: null }; }), - selectGroup: (id) => - set((state) => { - const group = state.model?.groups.get(id); - if (!group) return {}; - return { - selectedSegmentIds: new Set(group.segmentIds), - selectedGroupId: id, - }; - }), + selectGroup: (id) => { + const group = get().model?.groups.get(id); + if (!group) return; + set({ selectedSegmentIds: new Set(group.segmentIds), selectedGroupId: id }); + }, selectAll: () => set((state) => { @@ -101,7 +97,11 @@ export const useCaptionStore = create((set) => ({ }; }), - clearSelection: () => set({ selectedSegmentIds: new Set(), selectedGroupId: null }), + clearSelection: () => { + const { selectedSegmentIds, selectedGroupId } = get(); + if (selectedSegmentIds.size === 0 && selectedGroupId === null) return; + set({ selectedSegmentIds: new Set(), selectedGroupId: null }); + }, // Segment mutations updateSegmentStyle: (segmentId, style) => diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index 3404df15c9..9d0e11b56c 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -5,9 +5,9 @@ import { STUDIO_MANUAL_EDITING_DISABLED_TITLE, } from "./editor/manualEditingAvailability"; import { getHistoryShortcutLabel } from "../utils/studioHelpers"; -import { useStudioContext } from "../contexts/StudioContext"; +import { useStudioShellContext } from "../contexts/StudioContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; -import { useDomEditContext } from "../contexts/DomEditContext"; +import { useDomEditActionsContext } from "../contexts/DomEditContext"; import { trackStudioEvent } from "../utils/studioTelemetry"; export interface StudioHeaderProps { @@ -150,9 +150,9 @@ export function StudioHeader({ inspectorPanelActive, onExport, }: StudioHeaderProps) { - const { projectId, editHistory, handleUndo, handleRedo } = useStudioContext(); + const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext(); const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext(); - const { clearDomSelection } = useDomEditContext(); + const { clearDomSelection } = useDomEditActionsContext(); return (
diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx index b8a31b1f32..bb39133a3b 100644 --- a/packages/studio/src/components/StudioLeftSidebar.tsx +++ b/packages/studio/src/components/StudioLeftSidebar.tsx @@ -4,7 +4,7 @@ import { LeftSidebar, type LeftSidebarHandle } from "./sidebar/LeftSidebar"; import { MediaPreview } from "./MediaPreview"; import { isMediaFile } from "../utils/mediaTypes"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; -import { useStudioContext } from "../contexts/StudioContext"; +import { useStudioShellContext } from "../contexts/StudioContext"; import { useFileManagerContext } from "../contexts/FileManagerContext"; import { getPersistedRenderSettings } from "./renders/renderSettings"; import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; @@ -39,7 +39,7 @@ export function StudioLeftSidebar({ handlePanelResizeMove, handlePanelResizeEnd, } = usePanelLayoutContext(); - const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioContext(); + const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioShellContext(); const { compositions, assets, diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 7571373383..dd6fa11aac 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { useState, useMemo, type ReactNode } from "react"; import { NLELayout } from "./nle/NLELayout"; import { CaptionOverlay } from "../captions/components/CaptionOverlay"; import { CaptionTimeline } from "../captions/components/CaptionTimeline"; @@ -13,8 +13,9 @@ import { STUDIO_PREVIEW_MANUAL_EDITING_ENABLED, STUDIO_PREVIEW_SELECTION_ENABLED, } from "./editor/manualEditingAvailability"; -import { useStudioContext } from "../contexts/StudioContext"; +import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; import { useDomEditContext } from "../contexts/DomEditContext"; +import { TimelineEditProvider } from "../contexts/TimelineEditContext"; import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; import { readStudioUiPreferences } from "../utils/studioUiPreferences"; import type { GestureRecordingState } from "./editor/GestureRecordControl"; @@ -91,18 +92,20 @@ export function StudioPreviewArea({ }: StudioPreviewAreaProps) { const { projectId, - refreshKey, activeCompPath, setActiveCompPath, - captionEditMode, - compositionLoading, - isPlaying, previewIframeRef, - refreshPreviewDocumentVersion, handlePreviewIframeRef, timelineVisible, toggleTimelineVisibility, - } = useStudioContext(); + } = useStudioShellContext(); + const { + refreshKey, + captionEditMode, + compositionLoading, + isPlaying, + refreshPreviewDocumentVersion, + } = useStudioPlaybackContext(); const { domEditHoverSelection, @@ -137,187 +140,208 @@ export function StudioPreviewArea({ }; }); + // fallow-ignore-next-line complexity + const timelineEditCallbacks = useMemo( + () => ({ + onMoveElement: handleTimelineElementMove, + onResizeElement: handleTimelineElementResize, + onBlockedEditAttempt: handleBlockedTimelineEdit, + onSplitElement: handleTimelineElementSplit, + onRazorSplit: handleRazorSplit, + onRazorSplitAll: handleRazorSplitAll, + onDeleteAllKeyframes: (elId: string) => { + const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId; + handleGsapDeleteAllForElement(`#${rawId}`); + }, + onDeleteKeyframe: (_elId: string, pct: number) => { + const cacheKey = domEditSelection?.id ?? ""; + const cached = usePlayerStore.getState().keyframeCache.get(cacheKey); + const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); + const group = kf?.propertyGroup; + const anim = + (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ?? + selectedGsapAnimations.find((a) => a.keyframes); + if (!anim) return; + handleGsapRemoveKeyframe(anim.id, kf?.tweenPercentage ?? pct); + }, + onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => { + for (const anim of selectedGsapAnimations) { + if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease }); + } + }, + onMoveKeyframe: (_el: TimelineElement, oldPct: number, newPct: number) => { + const cacheKey = domEditSelection?.id ?? ""; + const cached = usePlayerStore.getState().keyframeCache.get(cacheKey); + const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2); + const group = cachedKf?.propertyGroup; + const anim = + (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ?? + selectedGsapAnimations.find((a) => a.keyframes); + if (!anim?.keyframes) return; + const tweenOldPct = cachedKf?.tweenPercentage ?? oldPct; + const kf = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - tweenOldPct) < 0.2); + if (!kf) return; + const tweenStart = anim.resolvedStart ?? 0; + const tweenDur = anim.duration ?? 1; + const newAbsTime = _el.start + (newPct / 100) * _el.duration; + const tweenNewPct = + tweenDur > 0 + ? Math.max( + 0, + Math.min(100, Math.round(((newAbsTime - tweenStart) / tweenDur) * 1000) / 10), + ) + : 0; + handleGsapRemoveKeyframe(anim.id, tweenOldPct); + for (const [prop, val] of Object.entries(kf.properties)) { + handleGsapAddKeyframe(anim.id, tweenNewPct, prop, val); + } + }, + onToggleKeyframeAtPlayhead: (el: TimelineElement) => { + const currentTime = usePlayerStore.getState().currentTime; + const pct = + el.duration > 0 + ? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100))) + : 0; + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim?.keyframes) { + const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1); + if (existing) { + handleGsapRemoveKeyframe(anim.id, existing.percentage); + } else { + handleGsapAddKeyframe(anim.id, pct, "x", 0); + } + } else { + const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes); + if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id); + } + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + handleTimelineElementMove, + handleTimelineElementResize, + handleBlockedTimelineEdit, + handleTimelineElementSplit, + handleRazorSplit, + handleRazorSplitAll, + handleGsapDeleteAllForElement, + domEditSelection?.id, + selectedGsapAnimations, + handleGsapRemoveKeyframe, + handleGsapUpdateMeta, + handleGsapAddKeyframe, + handleGsapConvertToKeyframes, + ], + ); + return (
- { - const rawId = elId.includes("#") ? elId.split("#").pop()! : elId; - handleGsapDeleteAllForElement(`#${rawId}`); - }} - onDeleteKeyframe={(_elId, pct) => { - const cacheKey = domEditSelection?.id ?? ""; - const cached = usePlayerStore.getState().keyframeCache.get(cacheKey); - const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); - const group = kf?.propertyGroup; - const anim = - (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ?? - selectedGsapAnimations.find((a) => a.keyframes); - if (!anim) return; - handleGsapRemoveKeyframe(anim.id, kf?.tweenPercentage ?? pct); - }} - onChangeKeyframeEase={(_elId, _pct, ease) => { - for (const anim of selectedGsapAnimations) { - if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease }); - } - }} - // fallow-ignore-next-line complexity - onMoveKeyframe={(_el, oldPct, newPct) => { - const cacheKey = domEditSelection?.id ?? ""; - const cached = usePlayerStore.getState().keyframeCache.get(cacheKey); - const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2); - const group = cachedKf?.propertyGroup; - const anim = - (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ?? - selectedGsapAnimations.find((a) => a.keyframes); - if (!anim?.keyframes) return; - const tweenOldPct = cachedKf?.tweenPercentage ?? oldPct; - const kf = anim.keyframes.keyframes.find( - (k) => Math.abs(k.percentage - tweenOldPct) < 0.2, - ); - if (!kf) return; - const tweenStart = anim.resolvedStart ?? 0; - const tweenDur = anim.duration ?? 1; - const newAbsTime = _el.start + (newPct / 100) * _el.duration; - const tweenNewPct = - tweenDur > 0 - ? Math.max( - 0, - Math.min(100, Math.round(((newAbsTime - tweenStart) / tweenDur) * 1000) / 10), - ) - : 0; - handleGsapRemoveKeyframe(anim.id, tweenOldPct); - for (const [prop, val] of Object.entries(kf.properties)) { - handleGsapAddKeyframe(anim.id, tweenNewPct, prop, val); - } - }} - onToggleKeyframeAtPlayhead={(el) => { - const currentTime = usePlayerStore.getState().currentTime; - const pct = - el.duration > 0 - ? Math.max( - 0, - Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)), - ) - : 0; - const anim = selectedGsapAnimations.find((a) => a.keyframes); - if (anim?.keyframes) { - const existing = anim.keyframes.keyframes.find( - (k) => Math.abs(k.percentage - pct) <= 1, - ); - if (existing) { - handleGsapRemoveKeyframe(anim.id, existing.percentage); - } else { - handleGsapAddKeyframe(anim.id, pct, "x", 0); + + { + // Sync activeCompPath when user drills down via timeline double-click + // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync. + // Guard against no-op updates to prevent circular refresh cascades + // between activeCompPath → compositionStack → onCompositionChange. + if (compPath !== activeCompPath) { + setActiveCompPath(compPath); + refreshPreviewDocumentVersion(); } - } else { - const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes); - if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id); - } - }} - onCompIdToSrcChange={setCompIdToSrc} - onCompositionLoadingChange={setCompositionLoading} - onCompositionChange={(compPath) => { - // Sync activeCompPath when user drills down via timeline double-click - // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync. - // Guard against no-op updates to prevent circular refresh cascades - // between activeCompPath → compositionStack → onCompositionChange. - if (compPath !== activeCompPath) { - setActiveCompPath(compPath); - refreshPreviewDocumentVersion(); - } - }} - onIframeRef={handlePreviewIframeRef} - previewOverlay={ - blockPreview ? ( -
- {blockPreview.videoUrl ? ( -
+ ) : captionEditMode ? ( + + ) : STUDIO_INSPECTOR_PANELS_ENABLED ? ( + <> + - ) : null} -
- ) : captionEditMode ? ( - - ) : STUDIO_INSPECTOR_PANELS_ENABLED ? ( - <> - - - {gestureOverlay} - - ) : null - } - timelineFooter={ - captionEditMode ? ( -
-
- - Captions - + + {gestureOverlay} + + ) : null + } + timelineFooter={ + captionEditMode ? ( +
+
+ + Captions + +
+
- -
- ) : undefined - } - timelineVisible={timelineVisible} - onToggleTimeline={toggleTimelineVisibility} - /> + ) : undefined + } + timelineVisible={timelineVisible} + onToggleTimeline={toggleTimelineVisibility} + /> +
diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 1cb60fc78b..0bc4a8945c 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -8,7 +8,7 @@ import type { RenderJob } from "./renders/useRenderQueue"; import type { BlockParam } from "@hyperframes/core/registry"; import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability"; -import { useStudioContext } from "../contexts/StudioContext"; +import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { useFileManagerContext } from "../contexts/FileManagerContext"; import { useDomEditContext } from "../contexts/DomEditContext"; @@ -47,14 +47,14 @@ export function StudioRightPanel({ } = usePanelLayoutContext(); const { - captionEditMode, previewIframeRef, projectId, activeCompPath, compositionDimensions, waitForPendingDomEditSaves, renderQueue, - } = useStudioContext(); + } = useStudioShellContext(); + const { captionEditMode } = useStudioPlaybackContext(); const { domEditSelection, diff --git a/packages/studio/src/components/editor/DopesheetStrip.tsx b/packages/studio/src/components/editor/DopesheetStrip.tsx deleted file mode 100644 index 10fab3e66c..0000000000 --- a/packages/studio/src/components/editor/DopesheetStrip.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { memo, useCallback, useRef } from "react"; - -interface DopesheetKeyframe { - percentage: number; - properties: Record; - ease?: string; -} - -interface DopesheetStripProps { - keyframes: DopesheetKeyframe[]; - selectedPercentage: number | null; - currentPercentage: number; - accentColor?: string; - onSelectKeyframe: (percentage: number) => void; - onDragKeyframe?: (fromPct: number, toPct: number) => void; -} - -const DIAMOND_SIZE = 8; -const HALF = DIAMOND_SIZE / 2; -const STRIP_HEIGHT = 20; -const PADDING_X = 8; - -export const DopesheetStrip = memo(function DopesheetStrip({ - keyframes, - selectedPercentage, - currentPercentage, - accentColor = "#3CE6AC", - onSelectKeyframe, - onDragKeyframe, -}: DopesheetStripProps) { - const containerRef = useRef(null); - const dragRef = useRef<{ startX: number; startPct: number } | null>(null); - - const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage); - - const handlePointerDown = useCallback( - (e: React.PointerEvent, pct: number) => { - if (e.button !== 0) return; - e.stopPropagation(); - const startX = e.clientX; - - const handleMove = (me: PointerEvent) => { - if (Math.abs(me.clientX - startX) > 4) { - dragRef.current = { startX, startPct: pct }; - } - }; - - const handleUp = (ue: PointerEvent) => { - document.removeEventListener("pointermove", handleMove); - document.removeEventListener("pointerup", handleUp); - if (dragRef.current && containerRef.current && onDragKeyframe) { - const rect = containerRef.current.getBoundingClientRect(); - const usableWidth = rect.width - PADDING_X * 2; - const dx = ue.clientX - dragRef.current.startX; - const dpct = (dx / usableWidth) * 100; - const newPct = Math.max(0, Math.min(100, Math.round((pct + dpct) * 10) / 10)); - if (newPct !== pct) onDragKeyframe(pct, newPct); - } else { - onSelectKeyframe(pct); - } - dragRef.current = null; - }; - - document.addEventListener("pointermove", handleMove); - document.addEventListener("pointerup", handleUp); - }, - [onSelectKeyframe, onDragKeyframe], - ); - - return ( -
- {/* Playhead indicator */} -
- - {/* Diamond markers */} - - {sorted.map((kf) => { - const x = PADDING_X + (kf.percentage / 100) * (100 - PADDING_X * 2); - const y = STRIP_HEIGHT / 2; - const isSelected = - selectedPercentage !== null && Math.abs(kf.percentage - selectedPercentage) < 0.5; - const isHold = kf.ease === "steps(1)"; - const fillColor = isSelected ? accentColor : "#737373"; - - return ( - handlePointerDown(e, kf.percentage)} - style={{ cursor: "pointer" }} - > - {isHold ? ( - - ) : ( - - )} - - ); - })} - - - {/* Time labels */} - {sorted.length > 0 && ( -
- {sorted[0].percentage}% - {sorted.length > 1 && {sorted[sorted.length - 1].percentage}%} -
- )} -
- ); -}); diff --git a/packages/studio/src/components/editor/EaseCurveSection.tsx b/packages/studio/src/components/editor/EaseCurveSection.tsx index 5eef531d00..3752fd49ac 100644 --- a/packages/studio/src/components/editor/EaseCurveSection.tsx +++ b/packages/studio/src/components/editor/EaseCurveSection.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useRef, useState } from "react"; import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants"; +import { roundToCenti } from "../../utils/rounding"; const PRESET_GRID_EASES = [ "none", @@ -75,9 +76,7 @@ const EasePresetGrid = memo(function EasePresetGrid({ ); }); -function round2(n: number): number { - return Math.round(n * 100) / 100; -} +const round2 = roundToCenti; export function EaseCurveSection({ ease, diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx index d56202bd5c..6bbb7196d8 100644 --- a/packages/studio/src/components/editor/LayersPanel.tsx +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -5,7 +5,7 @@ import { resolveDomEditSelection, type DomEditLayerItem, } from "./domEditing"; -import { useStudioContext } from "../../contexts/StudioContext"; +import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext"; import { useDomEditContext } from "../../contexts/DomEditContext"; import { usePlayerStore } from "../../player"; import { @@ -54,14 +54,8 @@ interface CollapsedState { // fallow-ignore-next-line complexity export const LayersPanel = memo(function LayersPanel() { - const { - previewIframeRef, - activeCompPath, - refreshKey, - compositionLoading, - timelineElements, - showToast, - } = useStudioContext(); + const { previewIframeRef, activeCompPath, showToast } = useStudioShellContext(); + const { refreshKey, compositionLoading, timelineElements } = useStudioPlaybackContext(); const currentTime = usePlayerStore((s) => s.currentTime); const { domEditSelection, diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 803c770717..bf77e7c71d 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -1,6 +1,6 @@ import { memo, useEffect, useRef, useState } from "react"; import { Eye, Layers, Move, X } from "../../icons/SystemIcons"; -import { useStudioContext } from "../../contexts/StudioContext"; +import { useStudioShellContext } from "../../contexts/StudioContext"; import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits"; import { EMPTY_STYLES, @@ -83,7 +83,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onToggleRecording, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; - const { showToast } = useStudioContext(); + const { showToast } = useStudioShellContext(); const [clipboardCopied, setClipboardCopied] = useState(false); const clipboardTimerRef = useRef>(undefined); const storeTime = usePlayerStore((s) => s.currentTime); diff --git a/packages/studio/src/components/editor/StaggerControls.tsx b/packages/studio/src/components/editor/StaggerControls.tsx deleted file mode 100644 index 4f91275206..0000000000 --- a/packages/studio/src/components/editor/StaggerControls.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { memo, useState } from "react"; -import { MetricField } from "./propertyPanelPrimitives"; - -export type StaggerOrder = "dom" | "reverse" | "center" | "edges" | "random"; - -interface StaggerControlsProps { - elementCount: number; - onApplyStagger: (offsetMs: number, order: StaggerOrder) => void; -} - -const ORDER_OPTIONS: StaggerOrder[] = ["dom", "reverse", "center", "edges", "random"]; -const ORDER_LABELS: Record = { - dom: "DOM order", - reverse: "Reverse", - center: "Center out", - edges: "Edges in", - random: "Random", -}; - -export const StaggerControls = memo(function StaggerControls({ - elementCount, - onApplyStagger, -}: StaggerControlsProps) { - const [offsetMs, setOffsetMs] = useState(80); - const [order, setOrder] = useState("dom"); - - if (elementCount < 2) return null; - - return ( -
- Stagger - { - const v = Number.parseInt(raw, 10); - if (Number.isFinite(v) && v >= 0) setOffsetMs(v); - }} - /> - - -
- ); -}); diff --git a/packages/studio/src/components/editor/TimelineLayerPanel.test.ts b/packages/studio/src/components/editor/TimelineLayerPanel.test.ts deleted file mode 100644 index 20364ef8a1..0000000000 --- a/packages/studio/src/components/editor/TimelineLayerPanel.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Window } from "happy-dom"; -import type { DomEditLayerItem } from "./domEditing"; -import { getTimelineLayerPanelSummary } from "./TimelineLayerPanel"; - -function createLayer(overrides: Partial = {}): DomEditLayerItem { - const window = new Window(); - return { - childCount: 0, - depth: 0, - element: window.document.createElement(overrides.tagName ?? "div"), - key: "layer", - label: "Layer", - sourceFile: "index.html", - tagName: "div", - ...overrides, - }; -} - -describe("TimelineLayerPanel", () => { - it("describes a leaf media clip as a single selectable layer", () => { - expect( - getTimelineLayerPanelSummary([ - createLayer({ key: "alpha-video", label: "Alpha Video", tagName: "video" }), - ]), - ).toBe("Single selectable media layer"); - }); - - it("describes real nested layers with the nested count", () => { - expect( - getTimelineLayerPanelSummary([ - createLayer({ key: "root", childCount: 2 }), - createLayer({ key: "title", depth: 1 }), - createLayer({ key: "subtitle", depth: 1 }), - ]), - ).toBe("2 nested selectable layers"); - }); - - it("keeps empty layer lists explicit", () => { - expect(getTimelineLayerPanelSummary([])).toBe("No selectable layers"); - }); -}); diff --git a/packages/studio/src/components/editor/TimelineLayerPanel.tsx b/packages/studio/src/components/editor/TimelineLayerPanel.tsx deleted file mode 100644 index 3a0447a0fc..0000000000 --- a/packages/studio/src/components/editor/TimelineLayerPanel.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { DomEditLayerItem } from "./domEditing"; - -const MEDIA_LAYER_TAGS = new Set(["audio", "canvas", "img", "picture", "svg", "video"]); - -export function getTimelineLayerPanelSummary(layers: readonly DomEditLayerItem[]): string { - const childCount = Math.max(0, layers.length - 1); - if (childCount > 0) { - return `${childCount} nested selectable layer${childCount === 1 ? "" : "s"}`; - } - const layer = layers[0]; - if (!layer) return "No selectable layers"; - return MEDIA_LAYER_TAGS.has(layer.tagName.trim().toLowerCase()) - ? "Single selectable media layer" - : "Single selectable layer"; -} diff --git a/packages/studio/src/components/editor/colorValue.ts b/packages/studio/src/components/editor/colorValue.ts index d1d04fb5a2..b2cb7bef8b 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -1,3 +1,5 @@ +import { roundToCenti } from "../../utils/rounding"; + export interface ParsedColor { red: number; green: number; @@ -24,7 +26,7 @@ function toHex(value: number): string { } function formatAlpha(value: number): string { - return `${Math.round(clampAlpha(value) * 100) / 100}`; + return `${roundToCenti(clampAlpha(value))}`; } export function parseCssColor(value: string): ParsedColor | null { diff --git a/packages/studio/src/components/editor/gradientValue.ts b/packages/studio/src/components/editor/gradientValue.ts index ae6ddca4eb..95f9c5a138 100644 --- a/packages/studio/src/components/editor/gradientValue.ts +++ b/packages/studio/src/components/editor/gradientValue.ts @@ -1,3 +1,5 @@ +import { roundToCenti } from "../../utils/rounding"; + export type GradientKind = "linear" | "radial" | "conic"; export type RadialSizeKeyword = @@ -124,9 +126,7 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -function round(value: number): number { - return Math.round(value * 100) / 100; -} +const round = roundToCenti; function parsePercent(value: string | undefined, fallback: number): number { const parsed = parseCssNumber(value); diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 46f599b880..98d05bde31 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -506,18 +506,6 @@ export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle ); } -/* ── HTML patch builders (re-exported from manualEditsDomPatches) ── */ -export { - buildPathOffsetPatches, - buildClearPathOffsetPatches, - buildBoxSizePatches, - buildClearBoxSizePatches, - buildRotationPatches, - buildClearRotationPatches, - buildMotionPatches, - buildClearMotionPatches, -} from "./manualEditsDomPatches"; - /* ── Seek reapply (position + motion) ────────────────────────────── */ function queryStudioElements(doc: Document, attr: string): HTMLElement[] { diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index f600e5d2ff..3c3ed84dc4 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -3,6 +3,7 @@ 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"; +import { roundToCenti } from "../../utils/rounding"; export interface PropertyPanelProps { projectId: string; @@ -239,8 +240,13 @@ export function parseNumericValue(value: string | undefined): number | null { return Number.isFinite(parsed) ? parsed : null; } +export function formatTimingValue(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "0.00s"; + return `${seconds.toFixed(2)}s`; +} + export function formatNumericValue(value: number): string { - const rounded = Math.round(value * 100) / 100; + const rounded = roundToCenti(value); return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); @@ -473,40 +479,6 @@ export function extractBackgroundImageUrl(value: string | undefined): string { return value.slice(index, endParen).trim(); } -// ── Fit to children ────────────────────────────────────────────────── - -export function computeFitToChildrenSize( - element: DomEditSelection, -): { width: number; height: number } | null { - const el = element.element; - const win = el.ownerDocument?.defaultView; - const children = Array.from(el.children).filter((c): c is HTMLElement => c.nodeType === 1); - if (children.length === 0) return null; - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - for (const child of children) { - if (win) { - const cs = win.getComputedStyle(child); - if (cs.visibility === "hidden" || cs.display === "none") continue; - } - const r = child.getBoundingClientRect(); - if (r.width === 0 && r.height === 0) continue; - minX = Math.min(minX, r.left); - minY = Math.min(minY, r.top); - maxX = Math.max(maxX, r.right); - maxY = Math.max(maxY, r.bottom); - } - if (!isFinite(minX)) return null; - const parentRect = el.getBoundingClientRect(); - const scaleX = parentRect.width > 0 ? element.boundingBox.width / parentRect.width : 1; - const scaleY = parentRect.height > 0 ? element.boundingBox.height / parentRect.height : 1; - const width = Math.round((maxX - minX) * scaleX); - 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( @@ -541,7 +513,7 @@ export function readGsapRuntimeValuesForPanel( 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; + if (Number.isFinite(v)) result[prop] = roundToCenti(v); } return Object.keys(result).length > 0 ? result : null; } catch { @@ -568,8 +540,8 @@ export function readGsapBorderRadiusForPanel( if (!iframe?.contentDocument || !selector) return null; try { const el = iframe.contentDocument.querySelector(selector); - if (!el) return null; - const cs = iframe.contentWindow!.getComputedStyle(el); + if (!el || !iframe.contentWindow) return null; + const cs = iframe.contentWindow.getComputedStyle(el); const parse = (v: string) => Number.parseFloat(v) || 0; return { tl: parse(cs.borderTopLeftRadius), diff --git a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx index eeff73077b..a7f32be026 100644 --- a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx @@ -3,6 +3,7 @@ import { Check, ClipboardList, Film, Music } from "../../icons/SystemIcons"; import type { DomEditSelection } from "./domEditing"; import { formatNumericValue, + formatTimingValue, LABEL, parseNumericValue, RESPONSIVE_GRID, @@ -15,11 +16,6 @@ export function isMediaElement(element: DomEditSelection): boolean { return MEDIA_TAGS.has(element.tagName); } -function formatTimingValue(seconds: number): string { - if (!Number.isFinite(seconds) || seconds < 0) return "0.00s"; - return `${seconds.toFixed(2)}s`; -} - export function MediaSection({ projectDir, element, diff --git a/packages/studio/src/components/editor/propertyPanelTimingSection.tsx b/packages/studio/src/components/editor/propertyPanelTimingSection.tsx index 362e86dedb..46a349339d 100644 --- a/packages/studio/src/components/editor/propertyPanelTimingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelTimingSection.tsx @@ -1,13 +1,8 @@ import { Clock } from "../../icons/SystemIcons"; import type { DomEditSelection } from "./domEditing"; -import { RESPONSIVE_GRID } from "./propertyPanelHelpers"; +import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { MetricField, Section } from "./propertyPanelPrimitives"; -function formatTimingValue(seconds: number): string { - if (!Number.isFinite(seconds) || seconds < 0) return "0.00s"; - return `${seconds.toFixed(2)}s`; -} - function parseTimingValue(input: string): number | null { const cleaned = input.replace(/s$/i, "").trim(); const parsed = Number.parseFloat(cleaned); diff --git a/packages/studio/src/components/editor/studioMotionOps.test.ts b/packages/studio/src/components/editor/studioMotionOps.test.ts index 1aed2cad20..2f221f804b 100644 --- a/packages/studio/src/components/editor/studioMotionOps.test.ts +++ b/packages/studio/src/components/editor/studioMotionOps.test.ts @@ -11,7 +11,7 @@ import { STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, } from "./studioMotionTypes"; -import { buildMotionPatches, buildClearMotionPatches } from "./manualEditsDom"; +import { buildMotionPatches, buildClearMotionPatches } from "./manualEditsDomPatches"; import { applyPatchByTarget, readAttributeByTarget } from "../../utils/sourcePatcher"; function createElement(markup: string): HTMLElement { diff --git a/packages/studio/src/components/editor/studioMotionOps.ts b/packages/studio/src/components/editor/studioMotionOps.ts index 8d6c7fcedf..f81ab4936a 100644 --- a/packages/studio/src/components/editor/studioMotionOps.ts +++ b/packages/studio/src/components/editor/studioMotionOps.ts @@ -18,6 +18,7 @@ import { type StudioMotionManifest, type StudioMotionTarget, } from "./studioMotionTypes"; +import { roundTo3 } from "../../utils/rounding"; // ── Private helpers ── @@ -34,7 +35,7 @@ function sanitizeEase(value: string): string { } function roundEaseNumber(value: number): number { - return Math.round(value * 1000) / 1000; + return roundTo3(value); } function clampRange(value: number, min: number, max: number, fallback: number): number { diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 0f9c37eb78..0a221f9a0c 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -10,7 +10,6 @@ import { import { useMountEffect } from "../../hooks/useMountEffect"; import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player"; import type { TimelineElement } from "../../player"; -import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks"; import { NLEPreview } from "./NLEPreview"; import { CompositionBreadcrumb } from "./CompositionBreadcrumb"; import { usePreviewBlockDrop } from "./usePreviewBlockDrop"; @@ -20,7 +19,7 @@ import { getTimelineToggleTitle, } from "../../utils/timelineDiscovery"; -interface NLELayoutProps extends TimelineEditCallbacks { +interface NLELayoutProps { projectId: string; portrait?: boolean; /** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */ @@ -104,18 +103,7 @@ export const NLELayout = memo(function NLELayout({ onAssetDrop, onBlockDrop, onPreviewBlockDrop, - onMoveElement, - onResizeElement, - onBlockedEditAttempt, - onSplitElement, - onRazorSplit, - onRazorSplitAll, onSelectTimelineElement, - onDeleteKeyframe, - onDeleteAllKeyframes, - onChangeKeyframeEase, - onMoveKeyframe, - onToggleKeyframeAtPlayhead, onCompIdToSrcChange, timelineVisible, onToggleTimeline, @@ -444,18 +432,7 @@ export const NLELayout = memo(function NLELayout({ onDeleteElement={onDeleteElement} onAssetDrop={onAssetDrop} onBlockDrop={onBlockDrop} - onMoveElement={onMoveElement} - onResizeElement={onResizeElement} - onBlockedEditAttempt={onBlockedEditAttempt} - onSplitElement={onSplitElement} - onRazorSplit={onRazorSplit} - onRazorSplitAll={onRazorSplitAll} onSelectElement={onSelectTimelineElement} - onDeleteKeyframe={onDeleteKeyframe} - onDeleteAllKeyframes={onDeleteAllKeyframes} - onChangeKeyframeEase={onChangeKeyframeEase} - onMoveKeyframe={onMoveKeyframe} - onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead} />
{timelineFooter &&
{timelineFooter}
} diff --git a/packages/studio/src/components/nle/TimelineEditorNotice.tsx b/packages/studio/src/components/nle/TimelineEditorNotice.tsx deleted file mode 100644 index e8213325e6..0000000000 --- a/packages/studio/src/components/nle/TimelineEditorNotice.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery"; -import { PlayheadIndicator } from "../../player/components/PlayheadIndicator"; - -interface TimelineEditorNoticeProps { - onDismiss: () => void; -} - -export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) { - return ( - - ); -} diff --git a/packages/studio/src/components/sidebar/BlocksTab.tsx b/packages/studio/src/components/sidebar/BlocksTab.tsx index 755e469607..845a24d8cb 100644 --- a/packages/studio/src/components/sidebar/BlocksTab.tsx +++ b/packages/studio/src/components/sidebar/BlocksTab.tsx @@ -8,7 +8,7 @@ import { } from "../../utils/blockCategories"; import { usePlayerStore } from "../../player"; import { formatTime } from "../../player/lib/time"; -import { useStudioContext } from "../../contexts/StudioContext"; +import { useStudioShellContext } from "../../contexts/StudioContext"; export interface BlockPreviewInfo { videoUrl?: string; posterUrl?: string; @@ -345,7 +345,7 @@ function BlockCard({ [onAdd, adding], ); - const { activeCompPath, compositionDimensions } = useStudioContext(); + const { activeCompPath, compositionDimensions } = useStudioShellContext(); const handleShowPrompt = useCallback( (e: React.MouseEvent) => { diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index a0ee1403cf..7995f14352 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -1,17 +1,101 @@ // fallow-ignore-file code-duplication -import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react"; import type { useDomEditSession } from "../hooks/useDomEditSession"; type DomEditValue = ReturnType; -const DomEditContext = createContext(null); +export interface DomEditActionsValue extends Pick< + DomEditValue, + | "handleTimelineElementSelect" + | "handlePreviewCanvasMouseDown" + | "handlePreviewCanvasPointerMove" + | "handlePreviewCanvasPointerLeave" + | "applyDomSelection" + | "clearDomSelection" + | "handleDomStyleCommit" + | "handleDomAttributeCommit" + | "handleDomHtmlAttributeCommit" + | "handleDomPathOffsetCommit" + | "handleDomGroupPathOffsetCommit" + | "handleDomZIndexReorderCommit" + | "handleDomBoxSizeCommit" + | "handleDomRotationCommit" + | "handleDomManualEditsReset" + | "handleDomTextCommit" + | "handleDomTextFieldStyleCommit" + | "handleDomAddTextField" + | "handleDomRemoveTextField" + | "handleAskAgent" + | "handleAgentModalSubmit" + | "handleBlockedDomMove" + | "handleDomManualDragStart" + | "handleDomEditElementDelete" + | "buildDomSelectionFromTarget" + | "buildDomSelectionForTimelineElement" + | "updateDomEditHoverSelection" + | "resolveImportedFontAsset" + | "setAgentModalOpen" + | "setAgentPromptSelectionContext" + | "setAgentModalAnchorPoint" + | "handleGsapUpdateProperty" + | "handleGsapUpdateMeta" + | "handleGsapDeleteAnimation" + | "handleGsapDeleteAllForElement" + | "handleGsapAddAnimation" + | "handleGsapAddProperty" + | "handleGsapRemoveProperty" + | "handleGsapUpdateFromProperty" + | "handleGsapAddFromProperty" + | "handleGsapRemoveFromProperty" + | "handleGsapAddKeyframe" + | "handleGsapAddKeyframeBatch" + | "handleGsapRemoveKeyframe" + | "handleGsapConvertToKeyframes" + | "handleGsapRemoveAllKeyframes" + | "handleResetSelectedElementKeyframes" + | "commitAnimatedProperty" + | "handleSetArcPath" + | "handleUpdateArcSegment" + | "invalidateGsapCache" + | "previewIframeRef" + | "commitMutation" +> {} -export function useDomEditContext(): DomEditValue { - const ctx = useContext(DomEditContext); - if (!ctx) throw new Error("useDomEditContext must be used within DomEditProvider"); +export interface DomEditSelectionValue extends Pick< + DomEditValue, + | "domEditSelection" + | "domEditGroupSelections" + | "domEditHoverSelection" + | "domEditSelectionRef" + | "selectedGsapAnimations" + | "gsapMultipleTimelines" + | "gsapUnsupportedTimelinePattern" + | "agentModalOpen" + | "agentModalAnchorPoint" + | "copiedAgentPrompt" + | "agentPromptSelectionContext" +> {} + +const DomEditActionsContext = createContext(null); +const DomEditSelectionContext = createContext(null); + +export function useDomEditActionsContext(): DomEditActionsValue { + const ctx = useContext(DomEditActionsContext); + if (!ctx) throw new Error("useDomEditActionsContext must be used within DomEditProvider"); + return ctx; +} + +export function useDomEditSelectionContext(): DomEditSelectionValue { + const ctx = useContext(DomEditSelectionContext); + if (!ctx) throw new Error("useDomEditSelectionContext must be used within DomEditProvider"); return ctx; } +/** @deprecated Prefer useDomEditActionsContext or useDomEditSelectionContext. */ +export function useDomEditContext(): DomEditValue { + return { ...useDomEditActionsContext(), ...useDomEditSelectionContext() }; +} + export function DomEditProvider({ value: { domEditSelection, @@ -85,16 +169,16 @@ export function DomEditProvider({ value: DomEditValue; children: ReactNode; }) { - const stable = useMemo( + const commitMutationRef = useRef(commitMutation); + commitMutationRef.current = commitMutation; + + const stableCommitMutation = useCallback( + (mutation, options) => commitMutationRef.current(mutation, options), + [], + ); + + const actions = useMemo( () => ({ - domEditSelection, - domEditGroupSelections, - domEditHoverSelection, - agentModalOpen, - agentModalAnchorPoint, - copiedAgentPrompt, - agentPromptSelectionContext, - domEditSelectionRef, handleTimelineElementSelect, handlePreviewCanvasMouseDown, handlePreviewCanvasPointerMove, @@ -126,9 +210,6 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, - selectedGsapAnimations, - gsapMultipleTimelines, - gsapUnsupportedTimelinePattern, handleGsapUpdateProperty, handleGsapUpdateMeta, handleGsapDeleteAnimation, @@ -150,17 +231,9 @@ export function DomEditProvider({ handleUpdateArcSegment, invalidateGsapCache, previewIframeRef, - commitMutation, + commitMutation: stableCommitMutation, }), [ - domEditSelection, - domEditGroupSelections, - domEditHoverSelection, - agentModalOpen, - agentModalAnchorPoint, - copiedAgentPrompt, - agentPromptSelectionContext, - domEditSelectionRef, handleTimelineElementSelect, handlePreviewCanvasMouseDown, handlePreviewCanvasPointerMove, @@ -192,9 +265,6 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, - selectedGsapAnimations, - gsapMultipleTimelines, - gsapUnsupportedTimelinePattern, handleGsapUpdateProperty, handleGsapUpdateMeta, handleGsapDeleteAnimation, @@ -216,8 +286,41 @@ export function DomEditProvider({ handleUpdateArcSegment, invalidateGsapCache, previewIframeRef, - commitMutation, + stableCommitMutation, ], ); - return {children}; + + const selection = useMemo( + () => ({ + domEditSelection, + domEditGroupSelections, + domEditHoverSelection, + domEditSelectionRef, + selectedGsapAnimations, + gsapMultipleTimelines, + gsapUnsupportedTimelinePattern, + agentModalOpen, + agentModalAnchorPoint, + copiedAgentPrompt, + agentPromptSelectionContext, + }), + [ + domEditSelection, + domEditGroupSelections, + domEditHoverSelection, + domEditSelectionRef, + selectedGsapAnimations, + gsapMultipleTimelines, + gsapUnsupportedTimelinePattern, + agentModalOpen, + agentModalAnchorPoint, + copiedAgentPrompt, + agentPromptSelectionContext, + ], + ); + return ( + + {children} + + ); } diff --git a/packages/studio/src/contexts/StudioContext.tsx b/packages/studio/src/contexts/StudioContext.tsx index 48787d076c..710a2c46b1 100644 --- a/packages/studio/src/contexts/StudioContext.tsx +++ b/packages/studio/src/contexts/StudioContext.tsx @@ -2,18 +2,12 @@ import { createContext, useContext, useMemo, type ReactNode } from "react"; import type { TimelineElement } from "../player"; import type { CompositionDimensions } from "../components/renders/RenderQueue"; -export interface StudioContextValue { +export interface StudioShellValue { projectId: string; activeCompPath: string | null; setActiveCompPath: (path: string | null) => void; showToast: (message: string, tone?: "error" | "info") => void; previewIframeRef: React.MutableRefObject; - captionEditMode: boolean; - compositionLoading: boolean; - refreshKey: number; - setRefreshKey: React.Dispatch>; - timelineElements: TimelineElement[]; - isPlaying: boolean; editHistory: { canUndo: boolean; canRedo: boolean; @@ -32,24 +26,49 @@ export interface StudioContextValue { compositionDimensions: CompositionDimensions | null; waitForPendingDomEditSaves: () => Promise; handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void; - refreshPreviewDocumentVersion: () => void; timelineVisible: boolean; toggleTimelineVisibility: () => void; } -const StudioContext = createContext(null); +export interface StudioPlaybackValue { + captionEditMode: boolean; + compositionLoading: boolean; + refreshKey: number; + setRefreshKey: React.Dispatch>; + timelineElements: TimelineElement[]; + isPlaying: boolean; + refreshPreviewDocumentVersion: () => void; +} + +export type StudioContextValue = StudioShellValue & StudioPlaybackValue; -export function useStudioContext(): StudioContextValue { - const ctx = useContext(StudioContext); - if (!ctx) throw new Error("useStudioContext must be used within StudioProvider"); +const StudioShellContext = createContext(null); +const StudioPlaybackContext = createContext(null); + +export function useStudioShellContext(): StudioShellValue { + const ctx = useContext(StudioShellContext); + if (!ctx) throw new Error("useStudioShellContext must be used within StudioShellProvider"); return ctx; } -export function StudioProvider({ +export function useStudioPlaybackContext(): StudioPlaybackValue { + const ctx = useContext(StudioPlaybackContext); + if (!ctx) throw new Error("useStudioPlaybackContext must be used within StudioPlaybackProvider"); + return ctx; +} + +/** @deprecated Use useStudioShellContext and/or useStudioPlaybackContext instead. */ +export function useStudioContext(): StudioContextValue { + const shell = useStudioShellContext(); + const playback = useStudioPlaybackContext(); + return useMemo(() => ({ ...shell, ...playback }), [shell, playback]); +} + +export function StudioShellProvider({ value, children, }: { - value: StudioContextValue; + value: StudioShellValue; children: ReactNode; }) { const { @@ -58,12 +77,6 @@ export function StudioProvider({ setActiveCompPath, showToast, previewIframeRef, - captionEditMode, - compositionLoading, - refreshKey, - setRefreshKey, - timelineElements, - isPlaying, editHistory, handleUndo, handleRedo, @@ -71,24 +84,17 @@ export function StudioProvider({ compositionDimensions, waitForPendingDomEditSaves, handlePreviewIframeRef, - refreshPreviewDocumentVersion, timelineVisible, toggleTimelineVisibility, } = value; - const stable = useMemo( + const stable = useMemo( () => ({ projectId, activeCompPath, setActiveCompPath, showToast, previewIframeRef, - captionEditMode, - compositionLoading, - refreshKey, - setRefreshKey, - timelineElements, - isPlaying, editHistory, handleUndo, handleRedo, @@ -96,36 +102,80 @@ export function StudioProvider({ compositionDimensions, waitForPendingDomEditSaves, handlePreviewIframeRef, - refreshPreviewDocumentVersion, timelineVisible, toggleTimelineVisibility, }), - // Representative subset of deps that actually change — stable callbacks - // (showToast, setActiveCompPath, etc.) are included for correctness but - // won't trigger re-renders on their own. [ projectId, activeCompPath, - captionEditMode, - compositionLoading, - refreshKey, - isPlaying, compositionDimensions, timelineVisible, editHistory, - timelineElements, renderQueue, setActiveCompPath, showToast, previewIframeRef, - setRefreshKey, handleUndo, handleRedo, waitForPendingDomEditSaves, handlePreviewIframeRef, - refreshPreviewDocumentVersion, toggleTimelineVisibility, ], ); - return {children}; + return {children}; +} + +export function StudioPlaybackProvider({ + value, + children, +}: { + value: StudioPlaybackValue; + children: ReactNode; +}) { + const { + captionEditMode, + compositionLoading, + refreshKey, + setRefreshKey, + timelineElements, + isPlaying, + refreshPreviewDocumentVersion, + } = value; + + const stable = useMemo( + () => ({ + captionEditMode, + compositionLoading, + refreshKey, + setRefreshKey, + timelineElements, + isPlaying, + refreshPreviewDocumentVersion, + }), + [ + captionEditMode, + compositionLoading, + refreshKey, + timelineElements, + isPlaying, + setRefreshKey, + refreshPreviewDocumentVersion, + ], + ); + return {children}; +} + +/** @deprecated Use StudioShellProvider and StudioPlaybackProvider instead. */ +export function StudioProvider({ + value, + children, +}: { + value: StudioContextValue; + children: ReactNode; +}) { + return ( + + {children} + + ); } diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx new file mode 100644 index 0000000000..5ff84cf953 --- /dev/null +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -0,0 +1,47 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks"; + +const TimelineEditContext = createContext(null); + +export function useTimelineEditContext(): TimelineEditCallbacks { + const ctx = useContext(TimelineEditContext); + if (!ctx) throw new Error("useTimelineEditContext must be used within TimelineEditProvider"); + return ctx; +} + +/** + * Optional access — returns an empty object when outside a provider. + * Useful in components that can render both inside and outside the NLE. + */ +export function useTimelineEditContextOptional(): TimelineEditCallbacks { + return useContext(TimelineEditContext) ?? {}; +} + +export function TimelineEditProvider({ + value, + children, +}: { + value: TimelineEditCallbacks; + children: ReactNode; +}) { + const memoized = useMemo( + () => value, + // Each callback is a stable reference from the parent — memoize the bag + // so consumers don't re-render when unrelated parent state changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + value.onMoveElement, + value.onResizeElement, + value.onBlockedEditAttempt, + value.onSplitElement, + value.onRazorSplit, + value.onRazorSplitAll, + value.onDeleteKeyframe, + value.onDeleteAllKeyframes, + value.onChangeKeyframeEase, + value.onMoveKeyframe, + value.onToggleKeyframeAtPlayhead, + ], + ); + return {children}; +} diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 0867526143..d26c12675b 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -6,11 +6,9 @@ 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 { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; +import { roundTo3 } from "../utils/rounding"; +import { computeElementPercentage } from "./gsapShared"; export interface GsapDragCommitCallbacks { commitMutation: ( selection: DomEditSelection, @@ -26,25 +24,12 @@ export interface GsapDragCommitCallbacks { fetchAnimations?: () => Promise; } -// ── Percentage computation ───────────────────────────────────────────────── - +// Re-export for backward compatibility with existing imports. 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; + return computeElementPercentage(usePlayerStore.getState().currentTime, selection, animation); } // ── Dynamic keyframe materialization ────────────────────────────────────── @@ -133,8 +118,8 @@ async function extendTweenAndAddKeyframe( type: "replace-with-keyframes", animationId: anim.id, targetSelector: anim.targetSelector, - position: Math.round(newStart * 1000) / 1000, - duration: Math.round(newDuration * 1000) / 1000, + position: roundTo3(newStart), + duration: roundTo3(newDuration), keyframes: remappedKfs, }, { label: `Move layer (extended keyframe)`, softReload: true, beforeReload }, @@ -330,8 +315,8 @@ export async function commitGsapPositionFromDrag( { type: "add-with-keyframes", targetSelector: anim.targetSelector, - position: Math.round(newStart * 1000) / 1000, - duration: Math.round(newDuration * 1000) / 1000, + position: roundTo3(newStart), + duration: roundTo3(newDuration), keyframes, }, { label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset }, diff --git a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts index 4d41eb1f3a..bf21f67929 100644 --- a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts +++ b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts @@ -4,6 +4,7 @@ */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore"; +import { toAbsoluteTime } from "./gsapShared"; export function updateKeyframeCacheFromParsed( animations: GsapAnimation[], @@ -29,7 +30,7 @@ export function updateKeyframeCacheFromParsed( const elStart = timelineEl?.start ?? 0; const elDuration = timelineEl?.duration ?? 1; const clipKeyframes = anim.keyframes.keyframes.map((kf) => { - const absTime = tweenPos + (kf.percentage / 100) * tweenDur; + const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage); const clipPct = elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; return { diff --git a/packages/studio/src/hooks/gsapKeyframeCommit.ts b/packages/studio/src/hooks/gsapKeyframeCommit.ts index 18c294a317..c2715984eb 100644 --- a/packages/studio/src/hooks/gsapKeyframeCommit.ts +++ b/packages/studio/src/hooks/gsapKeyframeCommit.ts @@ -1,18 +1,8 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/globalTimeCompiler"; - -const PROPERTY_DEFAULTS: Record = { - opacity: 1, - x: 0, - y: 0, - scale: 1, - scaleX: 1, - scaleY: 1, - rotation: 0, - width: 100, - height: 100, -}; +import { PROPERTY_DEFAULTS, selectorFromSelection } from "./gsapShared"; +import { roundToCenti } from "../utils/rounding"; type CommitFn = ( selection: DomEditSelection, @@ -32,7 +22,7 @@ export async function commitKeyframeAtTimeImpl( properties: Record, commitMutation: CommitFn, ): Promise { - const selector = selection.id ? `#${selection.id}` : selection.selector; + const selector = selectorFromSelection(selection); if (!selector) return; const tween = findTweenAtTime(absoluteTime, animations, selector); @@ -64,7 +54,7 @@ export async function commitKeyframeAtTimeImpl( backfillDefaults, }, { - label: `Add keyframe at ${Math.round(absoluteTime * 100) / 100}s`, + label: `Add keyframe at ${roundToCenti(absoluteTime)}s`, coalesceKey: `keyframe:${tween.id}:${pct}`, softReload: true, }, @@ -84,7 +74,7 @@ export async function commitKeyframeAtTimeImpl( ], }, { - label: `New animation at ${Math.round(absoluteTime * 100) / 100}s`, + label: `New animation at ${roundToCenti(absoluteTime)}s`, softReload: true, }, ); diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 775c4ef91e..1d2b282272 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -20,37 +20,20 @@ import { } from "./gsapDragCommit"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import type { GsapDragCommitCallbacks } from "./gsapDragCommit"; +import { getIframeGsap, queryIframeElement, selectorFromSelection } from "./gsapShared"; +import { roundTo3 } from "../utils/rounding"; // ── Runtime reads ────────────────────────────────────────────────────────── -interface IframeGsap { - getProperty: (el: Element, prop: string) => number; -} - // fallow-ignore-next-line complexity function readGsapPositionFromIframe( iframe: HTMLIFrameElement | null, elementSelector: string, ): { x: number; y: number } | null { - if (!iframe?.contentWindow) return null; - - let gsap: IframeGsap | undefined; - try { - gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap; - } catch { - return null; - } - if (!gsap?.getProperty) return null; - - let doc: Document | null = null; - try { - doc = iframe.contentDocument; - } catch { - return null; - } - if (!doc) return null; + const gsap = getIframeGsap(iframe); + if (!gsap) return null; - const element = doc.querySelector(elementSelector); + const element = queryIframeElement(iframe, elementSelector); if (!element) return null; const x = Number(gsap.getProperty(element, "x")) || 0; @@ -99,12 +82,6 @@ function findGsapPositionAnimation( // ── Selector resolution ──────────────────────────────────────────────────── -function selectorForSelection(selection: DomEditSelection): string | null { - if (selection.id) return `#${selection.id}`; - if (selection.selector) return selection.selector; - return null; -} - // ── Property-group tween resolution ─────────────────────────────────────── /** @@ -193,7 +170,7 @@ export async function tryGsapDragIntercept( commitMutation: GsapDragCommitCallbacks["commitMutation"], fetchFallbackAnimations?: () => Promise, ): Promise { - const selector = selectorForSelection(selection); + const selector = selectorFromSelection(selection); if (!selector) return false; // Resolve the position-group tween, splitting legacy mixed tweens if needed. @@ -284,15 +261,15 @@ export async function tryGsapResizeIntercept( const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "5") || 5; const ct = usePlayerStore.getState().currentTime; const pct = elDuration > 0 ? Math.round(((ct - elStart) / elDuration) * 1000) / 10 : 0; - const sel = selectorForSelection(selection); + const sel = selectorFromSelection(selection); if (!sel) return false; await commitMutation( selection, { type: "add-with-keyframes", targetSelector: sel, - position: Math.round(elStart * 1000) / 1000, - duration: Math.round(elDuration * 1000) / 1000, + position: roundTo3(elStart), + duration: roundTo3(elDuration), keyframes: [ { percentage: Math.max(0, Math.min(100, pct)), @@ -310,7 +287,7 @@ export async function tryGsapResizeIntercept( if (activeKeyframePct != null) setActiveKeyframePct(null); const coalesceKey = `gsap:resize:${anim.id}`; - const selector = selectorForSelection(selection); + const selector = selectorFromSelection(selection); const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; let resizeProps: Record; @@ -320,7 +297,7 @@ export async function tryGsapResizeIntercept( // saved by the draft system before it ran. const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? ""); const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200; - const newScale = Math.round((size.width / cssW) * 1000) / 1000; + const newScale = roundTo3(size.width / cssW); resizeProps = { scale: newScale }; } else { resizeProps = { @@ -395,8 +372,8 @@ export async function tryGsapResizeIntercept( type: "replace-with-keyframes", animationId: anim.id, targetSelector: anim.targetSelector, - position: Math.round(newStart * 1000) / 1000, - duration: Math.round(newDuration * 1000) / 1000, + position: roundTo3(newStart), + duration: roundTo3(newDuration), keyframes: remapped, }, { label: `Resize (extended to ${ct.toFixed(2)}s)`, softReload: true, coalesceKey }, @@ -455,25 +432,14 @@ export async function tryGsapRotationIntercept( } if (!anim) return false; - const selector = selectorForSelection(selection); + const selector = selectorFromSelection(selection); if (!selector) return false; let gsapRotation = 0; - if (iframe?.contentWindow) { - try { - const gsap = ( - iframe.contentWindow as unknown as { - gsap?: { getProperty: (el: Element, prop: string) => number }; - } - ).gsap; - const doc = iframe.contentDocument; - const el = doc?.querySelector(selector); - if (gsap?.getProperty && el) { - gsapRotation = Number(gsap.getProperty(el, "rotation")) || 0; - } - } catch { - /* cross-origin guard */ - } + const gsap = getIframeGsap(iframe); + const rotEl = gsap ? queryIframeElement(iframe, selector) : null; + if (gsap && rotEl) { + gsapRotation = Number(gsap.getProperty(rotEl, "rotation")) || 0; } const pct = computeCurrentPercentage(selection, anim); diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 1029fd6dfb..981e7a5eae 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -3,6 +3,8 @@ * Used to discover dynamic keyframes that the AST parser can't resolve * (loops, variables, computed selectors). */ +import { parsePercentageKeyframes } from "./gsapShared"; +import { roundTo3 } from "../utils/rounding"; interface RuntimeTween { targets?: () => Element[]; @@ -66,33 +68,8 @@ export function readRuntimeKeyframes( const vars = tween.vars; if (!vars.keyframes || typeof vars.keyframes !== "object") continue; - const kfObj = vars.keyframes as Record; - const result: Array<{ percentage: number; properties: Record }> = []; - let easeEach: string | undefined; - - for (const [key, val] of Object.entries(kfObj)) { - if (key === "easeEach") { - if (typeof val === "string") easeEach = val; - continue; - } - const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/); - if (!pctMatch || !val || typeof val !== "object") continue; - const percentage = parseFloat(pctMatch[1]); - const properties: Record = {}; - for (const [pk, pv] of Object.entries(val as Record)) { - if (pk === "ease") continue; - if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; - else if (typeof pv === "string") properties[pk] = pv; - } - if (Object.keys(properties).length > 0) { - result.push({ percentage, properties }); - } - } - - if (result.length > 0) { - result.sort((a, b) => a.percentage - b.percentage); - return { keyframes: result, easeEach }; - } + const parsed = parsePercentageKeyframes(vars.keyframes as Record); + if (parsed) return parsed; } return null; } @@ -133,38 +110,12 @@ export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map< const vars = tween.vars; if (vars.keyframes && typeof vars.keyframes === "object") { - const kfObj = vars.keyframes as Record; - const keyframes: Array<{ - percentage: number; - properties: Record; - }> = []; - let easeEach: string | undefined; - - for (const [key, val] of Object.entries(kfObj)) { - if (key === "easeEach") { - if (typeof val === "string") easeEach = val; - continue; - } - const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/); - if (!pctMatch || !val || typeof val !== "object") continue; - const percentage = parseFloat(pctMatch[1]); - const properties: Record = {}; - for (const [pk, pv] of Object.entries(val as Record)) { - if (pk === "ease") continue; - if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; - else if (typeof pv === "string") properties[pk] = pv; - } - if (Object.keys(properties).length > 0) { - keyframes.push({ percentage, properties }); - } - } - - if (keyframes.length > 0) { - keyframes.sort((a, b) => a.percentage - b.percentage); + const parsed = parsePercentageKeyframes(vars.keyframes as Record); + if (parsed) { for (const target of tween.targets()) { const id = (target as HTMLElement).id; if (id && !result.has(id)) { - result.set(id, { keyframes, easeEach }); + result.set(id, parsed); } } continue; @@ -195,7 +146,7 @@ export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map< ]); for (const [k, v] of Object.entries(vars)) { if (skip.has(k)) continue; - if (typeof v === "number") properties[k] = Math.round(v * 1000) / 1000; + if (typeof v === "number") properties[k] = roundTo3(v); else if (typeof v === "string") properties[k] = v; } if (Object.keys(properties).length === 0) continue; diff --git a/packages/studio/src/hooks/gsapRuntimePreview.ts b/packages/studio/src/hooks/gsapRuntimePreview.ts deleted file mode 100644 index 16afb98c31..0000000000 --- a/packages/studio/src/hooks/gsapRuntimePreview.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function previewKeyframeChange( - iframe: HTMLIFrameElement | null, - selector: string, - properties: Record, -): boolean { - if (!iframe?.contentWindow) return false; - try { - const gsap = ( - iframe.contentWindow as unknown as { - gsap?: { set: (target: string, vars: Record) => void }; - } - ).gsap; - if (!gsap?.set) return false; - gsap.set(selector, properties); - return true; - } catch { - return false; - } -} diff --git a/packages/studio/src/hooks/gsapRuntimeReaders.ts b/packages/studio/src/hooks/gsapRuntimeReaders.ts index 0cac04524f..2094ef3653 100644 --- a/packages/studio/src/hooks/gsapRuntimeReaders.ts +++ b/packages/studio/src/hooks/gsapRuntimeReaders.ts @@ -3,31 +3,29 @@ */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser"; - -interface IframeGsap { - getProperty: (el: Element, prop: string) => number; -} +import { getIframeGsap, queryIframeElement } from "./gsapShared"; +import { roundTo3 } from "../utils/rounding"; export function readGsapProperty( iframe: HTMLIFrameElement | null, selector: string | null, prop: string, ): number | null { - if (!iframe?.contentWindow || !selector) return null; + if (!selector) return null; + const gsap = getIframeGsap(iframe); + if (!gsap) return null; + const el = queryIframeElement(iframe, selector); + if (!el) 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)); if (!Number.isFinite(val)) return null; - return POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000; + return POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val); } catch { return null; } } -const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]); +export const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]); const GSAP_CONFIG_KEYS = new Set([ "duration", "ease", @@ -56,22 +54,17 @@ export function readAllAnimatedProperties( group?: PropertyGroupName, ): 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; + if (!iframe) return result; + const gsap = getIframeGsap(iframe); + if (!gsap) return result; + const el = queryIframeElement(iframe, selector); + if (!el) return result; let doc: Document | null = null; try { - doc = iframe.contentDocument; + doc = iframe?.contentDocument ?? null; } catch { - return result; + /* cross-origin guard — doc stays null */ } - const el = doc?.querySelector(selector); - if (!el) return result; const propKeys = new Set(); if (anim.keyframes) { @@ -94,7 +87,7 @@ export function readAllAnimatedProperties( for (const prop of propKeys) { const val = Number(gsap.getProperty(el, prop)); if (Number.isFinite(val)) { - result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000; + result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val); } } @@ -166,7 +159,7 @@ export function readAllAnimatedProperties( if (!allTweenedProps.has(prop)) continue; const val = Number(gsap.getProperty(el, prop)); if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) { - result[prop] = Math.round(val * 1000) / 1000; + result[prop] = roundTo3(val); } } @@ -208,7 +201,7 @@ export function readAllAnimatedProperties( } if (Number.isFinite(cssVal) && Math.round(gsapVal * 1000) === Math.round(cssVal * 1000)) continue; - result[prop] = Math.round(gsapVal * 1000) / 1000; + result[prop] = roundTo3(gsapVal); } return result; diff --git a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts index 44c6ad9a61..4e8da55b45 100644 --- a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts +++ b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts @@ -1,17 +1,7 @@ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -export const PROPERTY_DEFAULTS: Record = { - opacity: 1, - x: 0, - y: 0, - scale: 1, - scaleX: 1, - scaleY: 1, - rotation: 0, - width: 100, - height: 100, -}; +export { PROPERTY_DEFAULTS } from "./gsapShared"; export function ensureElementAddressable(selection: DomEditSelection): { selector: string; diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts new file mode 100644 index 0000000000..b8a4bca357 --- /dev/null +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -0,0 +1,58 @@ +import type { ParsedGsap } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import type { EditHistoryKind } from "../utils/editHistory"; + +export interface MutationResult { + ok: boolean; + changed?: boolean; + parsed?: ParsedGsap; + before?: string; + after?: string; + scriptText?: string; +} + +export interface CommitMutationOptions { + label: string; + coalesceKey?: string; + softReload?: boolean; + skipReload?: boolean; + beforeReload?: () => void; +} + +export type CommitMutation = ( + selection: DomEditSelection, + mutation: Record, + options: CommitMutationOptions, +) => Promise; + +export type SafeGsapCommitMutation = ( + selection: DomEditSelection, + mutation: Record, + options: CommitMutationOptions, +) => void; + +export type TrackGsapSaveFailure = ( + error: unknown, + selection: DomEditSelection, + mutation: Record, + label?: string, +) => void; + +export interface GsapScriptCommitsParams { + projectIdRef: React.MutableRefObject; + activeCompPath: string | null; + previewIframeRef: React.RefObject; + editHistory: { + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + }; + domEditSaveTimestampRef: React.MutableRefObject; + reloadPreview: () => void; + onCacheInvalidate: () => void; + onFileContentChanged?: (path: string, content: string) => void; + showToast: (message: string, tone?: "error" | "info") => void; +} diff --git a/packages/studio/src/hooks/gsapShared.ts b/packages/studio/src/hooks/gsapShared.ts new file mode 100644 index 0000000000..e299f15a53 --- /dev/null +++ b/packages/studio/src/hooks/gsapShared.ts @@ -0,0 +1,157 @@ +/** + * Shared GSAP primitives used across multiple hook files. + * Centralises duplicated interfaces, constants, and small utilities + * to reduce drift risk. + */ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { + absoluteToPercentage, + resolveTweenStart, + resolveTweenDuration, +} from "../utils/globalTimeCompiler"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** Canonical interface for the iframe-hosted GSAP runtime. */ +export interface IframeGsap { + getProperty: (el: Element, prop: string) => number; + set?: (target: string, vars: Record) => void; +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +export const PROPERTY_DEFAULTS: Record = { + opacity: 1, + x: 0, + y: 0, + scale: 1, + scaleX: 1, + scaleY: 1, + rotation: 0, + width: 100, + height: 100, +}; + +// ── Selector resolution ─────────────────────────────────────────────────────── + +/** + * Get a CSS selector string from a DomEditSelection. + * Returns `#id` if the selection has an id, otherwise the raw selector, + * or null if neither exists. + */ +export function selectorFromSelection(selection: DomEditSelection): string | null { + if (selection.id) return `#${selection.id}`; + if (selection.selector) return selection.selector; + return null; +} + +// ── Percentage computation ──────────────────────────────────────────────────── + +/** + * Compute the current playback percentage within an element's animation range. + * Uses the animation's resolved timing if available, otherwise falls back to + * the element's data-start / data-duration attributes. + */ +export function computeElementPercentage( + currentTime: number, + selection: DomEditSelection, + animation?: GsapAnimation | null, +): number { + 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; +} + +// ── Iframe accessors ────────────────────────────────────────────────────────── + +/** Safely retrieve the GSAP runtime from the preview iframe. */ +export function getIframeGsap(iframe: HTMLIFrameElement | null): IframeGsap | null { + if (!iframe?.contentWindow) return null; + try { + const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap; + return gsap?.getProperty ? gsap : null; + } catch { + return null; + } +} + +/** Safely query an element inside the preview iframe's document. */ +export function queryIframeElement( + iframe: HTMLIFrameElement | null, + selector: string, +): Element | null { + try { + return iframe?.contentDocument?.querySelector(selector) ?? null; + } catch { + return null; + } +} + +/** Safely access an iframe's contentDocument, returning null on cross-origin errors. */ +export function getIframeDocument(iframe: HTMLIFrameElement | null): Document | null { + if (!iframe) return null; + try { + return iframe.contentDocument; + } catch { + return null; + } +} + +// ── Keyframe parsing ────────────────────────────────────────────────────────── + +export interface ParsedPercentageKeyframes { + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; +} + +/** + * Parse a GSAP percentage-keyframe object (`{ "0%": { x: 10 }, "100%": { x: 200 } }`) + * into a sorted array of `{ percentage, properties }` entries. + * Returns `null` when the object contains no valid keyframe entries. + */ +export function parsePercentageKeyframes( + kfObj: Record, +): ParsedPercentageKeyframes | null { + const keyframes: ParsedPercentageKeyframes["keyframes"] = []; + let easeEach: string | undefined; + + for (const [key, val] of Object.entries(kfObj)) { + if (key === "easeEach") { + if (typeof val === "string") easeEach = val; + continue; + } + const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/); + if (!pctMatch || !val || typeof val !== "object") continue; + const percentage = parseFloat(pctMatch[1]); + const properties: Record = {}; + for (const [pk, pv] of Object.entries(val as Record)) { + if (pk === "ease") continue; + if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; + else if (typeof pv === "string") properties[pk] = pv; + } + if (Object.keys(properties).length > 0) { + keyframes.push({ percentage, properties }); + } + } + + if (keyframes.length === 0) return null; + keyframes.sort((a, b) => a.percentage - b.percentage); + return { keyframes, easeEach }; +} + +// ── Time conversion ─────────────────────────────────────────────────────────── + +/** Convert a tween-relative percentage to an absolute time. */ +export function toAbsoluteTime(tweenPos: number, tweenDur: number, percentage: number): number { + return tweenPos + (percentage / 100) * tweenDur; +} diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index 75a3d42a09..e6e50a4349 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -12,6 +12,7 @@ import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge"; +import { selectorFromSelection, computeElementPercentage } from "./gsapShared"; interface CommitAnimatedPropertyDeps { selectedGsapAnimations: GsapAnimation[]; @@ -37,23 +38,6 @@ interface CommitAnimatedPropertyDeps { bumpGsapCache: () => void; } -function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): number { - const currentTime = usePlayerStore.getState().currentTime; - const tweenPos = anim?.resolvedStart ?? (typeof anim?.position === "number" ? anim.position : 0); - const tweenDur = anim?.duration ?? 0; - if (tweenDur > 0) { - return Math.max( - 0, - Math.min(100, Math.round(((currentTime - tweenPos) / tweenDur) * 1000) / 10), - ); - } - 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; -} - function pickBestAnimation( animations: GsapAnimation[], selector: string | null, @@ -78,12 +62,6 @@ function pickBestAnimation( return scored[0]?.anim; } -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, @@ -102,7 +80,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { if (!gsapCommitMutation) return; const iframe = previewIframeRef.current; - const selector = selectorFor(selection); + const selector = selectorFromSelection(selection); let anim: GsapAnimation | undefined = pickBestAnimation( selectedGsapAnimations, @@ -132,7 +110,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { ); } - const pct = computePercentage(selection, anim); + const pct = computeElementPercentage(usePlayerStore.getState().currentTime, selection, anim); // Read all currently animated properties from runtime for backfill const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 6695ccf85f..022297257b 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -9,7 +9,6 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers"; import { canSplitElement } from "../utils/timelineElementSplit"; import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability"; -/** Safely resolves contentWindow for a potentially cross-origin iframe. */ function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null { try { return iframe?.contentWindow ?? null; @@ -18,10 +17,21 @@ function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null { } } -/** - * Handles Cmd/Ctrl+Z (undo) and Cmd/Ctrl+Shift+Z / Ctrl+Y (redo) key events. - * Returns true if the event was handled, false otherwise. - */ +function safeAddListener(t: EventTarget | null, type: string, h: EventListener, capture = false) { + try { + t?.addEventListener(type, h, capture); + } catch { + /* cross-origin */ + } +} +function safeRemoveListener(t: EventTarget | null, type: string, h: EventListener) { + try { + t?.removeEventListener(type, h); + } catch { + /* cross-origin */ + } +} + // fallow-ignore-next-line complexity function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: () => void): boolean { const key = event.key.toLowerCase(); @@ -40,25 +50,19 @@ function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: () // ── Types ── +interface HistoryResult { + ok: boolean; + reason?: string; + label?: string; + paths?: string[]; +} +interface HistoryFileCallbacks { + readFile: (path: string) => Promise; + writeFile: (path: string, content: string) => Promise; +} interface EditHistoryHandle { - undo: (callbacks: { - readFile: (path: string) => Promise; - writeFile: (path: string, content: string) => Promise; - }) => Promise<{ - ok: boolean; - reason?: string; - label?: string; - paths?: string[]; - }>; - redo: (callbacks: { - readFile: (path: string) => Promise; - writeFile: (path: string, content: string) => Promise; - }) => Promise<{ - ok: boolean; - reason?: string; - label?: string; - paths?: string[]; - }>; + undo: (cb: HistoryFileCallbacks) => Promise; + redo: (cb: HistoryFileCallbacks) => Promise; } interface UseAppHotkeysParams { @@ -86,6 +90,156 @@ interface UseAppHotkeysParams { onToggleRecording?: () => void; } +// ── Extracted keydown dispatch (pure function, no hooks) ── + +interface HotkeyCallbacks { + toggleTimelineVisibility: () => void; + handleTimelineElementDelete: (element: TimelineElement) => Promise; + handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise; + handleDomEditElementDelete: (selection: DomEditSelection) => Promise; + handleUndo: () => Promise; + handleRedo: () => Promise; + handleCopy: () => boolean; + handlePaste: () => Promise; + handleCut: () => Promise; + onResetKeyframes: () => boolean; + onDeleteSelectedKeyframes: () => void; + onToggleRecording?: () => void; + leftSidebarRef: React.RefObject; + domEditSelectionRef: React.MutableRefObject; +} + +function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean { + if ( + !shouldIgnoreHistoryShortcut(event.target) && + handleUndoRedoKey( + event, + () => void cb.handleUndo(), + () => void cb.handleRedo(), + ) + ) + return true; + + if (event.key === "1") { + event.preventDefault(); + cb.leftSidebarRef.current?.selectTab("compositions"); + return true; + } + if (event.key === "2") { + event.preventDefault(); + cb.leftSidebarRef.current?.selectTab("assets"); + return true; + } + + if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) { + if (key === "c") { + if (cb.handleCopy()) event.preventDefault(); + return true; + } + if (key === "v") { + event.preventDefault(); + void cb.handlePaste(); + return true; + } + if (key === "x") { + if (usePlayerStore.getState().selectedElementId || cb.domEditSelectionRef.current) { + event.preventDefault(); + void cb.handleCut(); + } + return true; + } + } + return false; +} + +// fallow-ignore-next-line complexity +function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void { + if (key === "f" && !event.shiftKey && !event.altKey) { + event.preventDefault(); + if (document.fullscreenElement) void document.exitFullscreen(); + else + document.querySelector("[data-studio-fullscreen-target]")?.requestFullscreen(); + return; + } + + if (event.key === "s" && !event.altKey) { + const { selectedElementId, elements, currentTime } = usePlayerStore.getState(); + if (selectedElementId) { + const el = elements.find((e) => (e.key ?? e.id) === selectedElementId); + if ( + el && + canSplitElement(el) && + currentTime > el.start && + currentTime < el.start + el.duration + ) { + event.preventDefault(); + void cb.handleTimelineElementSplit(el, currentTime); + return; + } + } + } + + if (STUDIO_RAZOR_TOOL_ENABLED && key === "b" && !event.shiftKey && !event.altKey) { + event.preventDefault(); + const { activeTool, setActiveTool } = usePlayerStore.getState(); + setActiveTool(activeTool === "razor" ? "select" : "razor"); + return; + } + + if (key === "v" && !event.shiftKey && !event.altKey) { + event.preventDefault(); + usePlayerStore.getState().setActiveTool("select"); + return; + } + + if (event.key === "Escape") { + const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } = + usePlayerStore.getState(); + if (activeTool === "razor") { + if (selectedElementId) setSelectedElementId(null); + else setActiveTool("select"); + event.preventDefault(); + return; + } + } + + if ((event.key === "Delete" || event.key === "Backspace") && !event.altKey) { + if (usePlayerStore.getState().selectedKeyframes.size > 0) { + cb.onDeleteSelectedKeyframes(); + usePlayerStore.getState().clearSelectedKeyframes(); + event.preventDefault(); + return; + } + if (event.key === "Backspace") { + const { selectedElementId, keyframeCache } = usePlayerStore.getState(); + if (selectedElementId && keyframeCache.has(selectedElementId) && cb.onResetKeyframes()) { + event.preventDefault(); + return; + } + } + const { selectedElementId, elements } = usePlayerStore.getState(); + if (selectedElementId) { + const el = elements.find((e) => (e.key ?? e.id) === selectedElementId); + if (el) { + event.preventDefault(); + void cb.handleTimelineElementDelete(el); + return; + } + } + const domSel = cb.domEditSelectionRef.current; + if (domSel) { + event.preventDefault(); + void cb.handleDomEditElementDelete(domSel); + } + return; + } + + if (event.key === "r" && !event.shiftKey && !event.altKey && cb.onToggleRecording) { + event.preventDefault(); + cb.onToggleRecording(); + } +} + // ── Hook ── export function useAppHotkeys({ @@ -112,10 +266,7 @@ export function useAppHotkeys({ onToggleRecording, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); - const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined); - const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null); - - // ── Timeline toggle hotkey ── + const previewHistoryCleanupRef = useRef<(() => void) | null>(null); const handleTimelineToggleHotkey = useCallback( (event: KeyboardEvent) => { @@ -126,16 +277,14 @@ export function useAppHotkeys({ [toggleTimelineVisibility], ); - // ── History file read/write helpers ── + // ── Undo / Redo ── - const readHistoryProjectFile = useCallback( - async (path: string): Promise => { - return path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path); - }, + const readHistoryFile = useCallback( + (path: string): Promise => + path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path), [readOptionalProjectFile, readProjectFile], ); - - const writeHistoryProjectFile = useCallback( + const writeHistoryFile = useCallback( async (path: string, content: string): Promise => { domEditSaveTimestampRef.current = Date.now(); await writeProjectFile(path, content); @@ -143,376 +292,125 @@ export function useAppHotkeys({ [domEditSaveTimestampRef, writeProjectFile], ); - // ── Undo / Redo ── - - const handleUndo = useCallback(async () => { - await waitForPendingDomEditSaves(); - const result = await editHistory.undo({ - readFile: readHistoryProjectFile, - writeFile: writeHistoryProjectFile, - }); - if (!result.ok && result.reason === "content-mismatch") { - showToast("File changed outside Studio. Undo history was not applied.", "info"); - return; - } - if (result.ok && result.label) { - onAfterUndoRedo?.(); - await syncHistoryPreviewAfterApply(result.paths); - showToast(`Undid ${result.label}`, "info"); - } - }, [ - editHistory, - readHistoryProjectFile, - showToast, - syncHistoryPreviewAfterApply, - waitForPendingDomEditSaves, - writeHistoryProjectFile, - onAfterUndoRedo, - ]); - - const handleRedo = useCallback(async () => { - await waitForPendingDomEditSaves(); - const result = await editHistory.redo({ - readFile: readHistoryProjectFile, - writeFile: writeHistoryProjectFile, - }); - if (!result.ok && result.reason === "content-mismatch") { - showToast("File changed outside Studio. Redo history was not applied.", "info"); - return; - } - if (result.ok && result.label) { - onAfterUndoRedo?.(); - await syncHistoryPreviewAfterApply(result.paths); - showToast(`Redid ${result.label}`, "info"); - } - }, [ - editHistory, - readHistoryProjectFile, - showToast, - syncHistoryPreviewAfterApply, - waitForPendingDomEditSaves, - writeHistoryProjectFile, - onAfterUndoRedo, - ]); - - // ── Stable refs for the consolidated keydown handler ── - - const handleToggleRef = useRef(handleTimelineToggleHotkey); - 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); - handleUndoRef.current = handleUndo; - const handleRedoRef = useRef(handleRedo); - handleRedoRef.current = handleRedo; - const handleCopyRef = useRef(handleCopy); - handleCopyRef.current = handleCopy; - const handlePasteRef = useRef(handlePaste); - handlePasteRef.current = handlePaste; - const handleCutRef = useRef(handleCut); - handleCutRef.current = handleCut; - const onResetKeyframesRef = useRef(onResetKeyframes); - onResetKeyframesRef.current = onResetKeyframes; - const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes); - onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes; - const onToggleRecordingRef = useRef(onToggleRecording); - onToggleRecordingRef.current = onToggleRecording; - - // ── Consolidated keydown handler ── - - handleAppKeyDownRef.current = (event: KeyboardEvent) => { - // Shift+T — toggle timeline - handleToggleRef.current(event); - - // Cmd/Ctrl+Z — undo, Cmd/Ctrl+Shift+Z or Ctrl+Y — redo - if (event.metaKey || event.ctrlKey) { - if ( - !shouldIgnoreHistoryShortcut(event.target) && - handleUndoRedoKey( - event, - () => void handleUndoRef.current(), - () => void handleRedoRef.current(), - ) - ) { - return; - } - - // Cmd/Ctrl+1 — sidebar: Compositions tab - if (event.key === "1") { - event.preventDefault(); - leftSidebarRef.current?.selectTab("compositions"); - return; - } - - // Cmd/Ctrl+2 — sidebar: Assets tab - if (event.key === "2") { - event.preventDefault(); - leftSidebarRef.current?.selectTab("assets"); + const applyHistory = useCallback( + async (direction: "undo" | "redo") => { + await waitForPendingDomEditSaves(); + const result = await editHistory[direction]({ + readFile: readHistoryFile, + writeFile: writeHistoryFile, + }); + if (!result.ok && result.reason === "content-mismatch") { + showToast( + `File changed outside Studio. ${direction === "undo" ? "Undo" : "Redo"} history was not applied.`, + "info", + ); return; } - - // Cmd/Ctrl+C — copy (only preventDefault if we actually have something to copy) - const copyPasteKey = event.key.toLowerCase(); - if ( - copyPasteKey === "c" && - !event.shiftKey && - !event.altKey && - !isEditableTarget(event.target) - ) { - if (handleCopyRef.current()) { - event.preventDefault(); - } - return; + if (result.ok && result.label) { + onAfterUndoRedo?.(); + await syncHistoryPreviewAfterApply(result.paths); + showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info"); } + }, + [ + editHistory, + readHistoryFile, + showToast, + syncHistoryPreviewAfterApply, + waitForPendingDomEditSaves, + writeHistoryFile, + onAfterUndoRedo, + ], + ); - // Cmd/Ctrl+V — paste - if ( - copyPasteKey === "v" && - !event.shiftKey && - !event.altKey && - !isEditableTarget(event.target) - ) { - event.preventDefault(); - void handlePasteRef.current(); - return; - } + const handleUndo = useCallback(() => applyHistory("undo"), [applyHistory]); + const handleRedo = useCallback(() => applyHistory("redo"), [applyHistory]); - // Cmd/Ctrl+X — cut (only preventDefault if there's a selected element to cut) - if ( - copyPasteKey === "x" && - !event.shiftKey && - !event.altKey && - !isEditableTarget(event.target) - ) { - const hasSelection = - !!usePlayerStore.getState().selectedElementId || !!domEditSelectionRef.current; - if (hasSelection) { - event.preventDefault(); - void handleCutRef.current(); - } - return; - } - } + // ── Stable callback ref (one ref replaces fifteen) ── - // F — toggle fullscreen preview - if ( - event.key.toLowerCase() === "f" && - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - !isEditableTarget(event.target) - ) { - event.preventDefault(); - if (document.fullscreenElement) { - void document.exitFullscreen(); - } else { - document.querySelector("[data-studio-fullscreen-target]")?.requestFullscreen(); - } - return; - } + const cbRef = useRef(null!); + cbRef.current = { + toggleTimelineVisibility, + handleTimelineElementDelete, + handleTimelineElementSplit, + handleDomEditElementDelete, + handleUndo, + handleRedo, + handleCopy, + handlePaste, + handleCut, + onResetKeyframes, + onDeleteSelectedKeyframes, + onToggleRecording, + leftSidebarRef, + domEditSelectionRef, + }; - // 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 && - canSplitElement(element) && - currentTime > element.start && - currentTime < element.start + element.duration - ) { - event.preventDefault(); - void handleSplitRef.current(element, currentTime); - return; - } - } - } + // ── Keydown dispatch ── - // B — toggle razor tool - if ( - STUDIO_RAZOR_TOOL_ENABLED && - event.key.toLowerCase() === "b" && - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - !isEditableTarget(event.target) - ) { + const handleAppKeyDown = useCallback((event: KeyboardEvent) => { + const cb = cbRef.current; + if (shouldHandleTimelineToggleHotkey(event)) { event.preventDefault(); - const { activeTool, setActiveTool } = usePlayerStore.getState(); - setActiveTool(activeTool === "razor" ? "select" : "razor"); + cb.toggleTimelineVisibility(); return; } - - // V — return to selection tool - if ( - event.key.toLowerCase() === "v" && - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - !isEditableTarget(event.target) - ) { - event.preventDefault(); - usePlayerStore.getState().setActiveTool("select"); + const key = event.key.toLowerCase(); + if (event.metaKey || event.ctrlKey) { + dispatchModifierKey(event, key, cb); return; } - - // Escape — exit razor mode (only when no selection to deselect first) - if (event.key === "Escape" && !isEditableTarget(event.target)) { - const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } = - usePlayerStore.getState(); - if (activeTool === "razor") { - if (selectedElementId) { - setSelectedElementId(null); - } else { - setActiveTool("select"); - } - event.preventDefault(); - return; - } - } - - // Delete / Backspace — remove selected keyframes > reset keyframes > remove element - if ( - (event.key === "Delete" || event.key === "Backspace") && - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !isEditableTarget(event.target) - ) { - // Priority: selected keyframes take precedence over clip deletion - const { selectedKeyframes } = usePlayerStore.getState(); - if (selectedKeyframes.size > 0) { - onDeleteSelectedKeyframesRef.current(); - usePlayerStore.getState().clearSelectedKeyframes(); - event.preventDefault(); - return; - } - - // Backspace: try resetting keyframes first; fall through to delete if none found - if (event.key === "Backspace") { - const { selectedElementId, keyframeCache } = usePlayerStore.getState(); - if (selectedElementId && keyframeCache.has(selectedElementId)) { - if (onResetKeyframesRef.current()) { - event.preventDefault(); - return; - } - } - } - - const { selectedElementId, elements } = usePlayerStore.getState(); - if (selectedElementId) { - const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); - if (element) { - event.preventDefault(); - void handleDeleteRef.current(element); - return; - } - } - const domSelection = domEditSelectionRef.current; - if (domSelection) { - event.preventDefault(); - void handleDomEditDeleteRef.current(domSelection); - } - } - - // R — toggle gesture recording - if ( - event.key === "r" && - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - !isEditableTarget(event.target) && - onToggleRecordingRef.current - ) { - event.preventDefault(); - onToggleRecordingRef.current(); - } - }; - - // ── Window keydown listener ── + if (!isEditableTarget(event.target)) dispatchPlainKey(event, key, cb); + }, []); // eslint-disable-next-line no-restricted-syntax useEffect(() => { - function handleAppKeyDown(event: KeyboardEvent) { - handleAppKeyDownRef.current?.(event); - } window.addEventListener("keydown", handleAppKeyDown, true); return () => window.removeEventListener("keydown", handleAppKeyDown, true); - }, []); + }, [handleAppKeyDown]); - // ── Preview iframe keydown forwarding ── - - const previewAppKeyDownHandler = useCallback((event: KeyboardEvent) => { - handleAppKeyDownRef.current?.(event); - }, []); + // ── Preview iframe forwarding ── const syncPreviewTimelineHotkey = useCallback( (iframe: HTMLIFrameElement | null) => { const nextWindow = iframeContentWindow(iframe); if (previewHotkeyWindowRef.current === nextWindow) return; - if (previewHotkeyWindowRef.current) { - try { - previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler); - } catch { - /* cross-origin iframe */ - } - } + safeRemoveListener( + previewHotkeyWindowRef.current, + "keydown", + handleAppKeyDown as EventListener, + ); previewHotkeyWindowRef.current = nextWindow; - try { - nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true); - } catch { - /* cross-origin iframe */ - } + safeAddListener(nextWindow, "keydown", handleAppKeyDown as EventListener, true); }, - [previewAppKeyDownHandler], + [handleAppKeyDown], ); useEffect( () => () => { - if (previewHotkeyWindowRef.current) { - try { - previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler); - } catch { - /* cross-origin iframe */ - } - previewHotkeyWindowRef.current = null; - } + safeRemoveListener( + previewHotkeyWindowRef.current, + "keydown", + handleAppKeyDown as EventListener, + ); + previewHotkeyWindowRef.current = null; }, - [previewAppKeyDownHandler], + [handleAppKeyDown], ); - // ── History hotkey for iframe forwarding ── - const handleHistoryHotkey = useCallback((event: KeyboardEvent) => { - if (!(event.metaKey || event.ctrlKey)) return; - if (shouldIgnoreHistoryShortcut(event.target)) return; + if (!(event.metaKey || event.ctrlKey) || shouldIgnoreHistoryShortcut(event.target)) return; handleUndoRedoKey( event, - () => void handleUndoRef.current(), - () => void handleRedoRef.current(), + () => void cbRef.current.handleUndo(), + () => void cbRef.current.handleRedo(), ); }, []); const syncPreviewHistoryHotkey = useCallback( (iframe: HTMLIFrameElement | null) => { - previewHistoryHotkeyCleanupRef.current?.(); - previewHistoryHotkeyCleanupRef.current = null; - + previewHistoryCleanupRef.current?.(); + previewHistoryCleanupRef.current = null; const win = iframeContentWindow(iframe); let doc: Document | null = null; try { @@ -521,19 +419,11 @@ export function useAppHotkeys({ doc = null; } if (!win && !doc) return; - - try { - win?.addEventListener("keydown", handleHistoryHotkey, true); - } catch { - /* cross-origin */ - } + const handler = handleHistoryHotkey as EventListener; + safeAddListener(win, "keydown", handler, true); doc?.addEventListener("keydown", handleHistoryHotkey, true); - previewHistoryHotkeyCleanupRef.current = () => { - try { - win?.removeEventListener("keydown", handleHistoryHotkey, true); - } catch { - /* cross-origin */ - } + previewHistoryCleanupRef.current = () => { + safeRemoveListener(win, "keydown", handler); doc?.removeEventListener("keydown", handleHistoryHotkey, true); }; }, @@ -542,8 +432,8 @@ export function useAppHotkeys({ useEffect( () => () => { - previewHistoryHotkeyCleanupRef.current?.(); - previewHistoryHotkeyCleanupRef.current = null; + previewHistoryCleanupRef.current?.(); + previewHistoryCleanupRef.current = null; }, [], ); diff --git a/packages/studio/src/hooks/useConsoleErrorCapture.ts b/packages/studio/src/hooks/useConsoleErrorCapture.ts index 7ac559f328..f49ccf619b 100644 --- a/packages/studio/src/hooks/useConsoleErrorCapture.ts +++ b/packages/studio/src/hooks/useConsoleErrorCapture.ts @@ -17,15 +17,38 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null) // eslint-disable-next-line no-restricted-syntax useEffect(() => { if (!previewIframe) return; + + let patchedWin: (Window & typeof globalThis) | null = null; + let origConsoleError: ((...args: unknown[]) => void) | null = null; + let errorHandler: ((e: ErrorEvent) => void) | null = null; + + const detachErrorCapture = () => { + const win = patchedWin; + if (!win) return; + patchedWin = null; + try { + // origConsoleError and errorHandler are always set alongside patchedWin + win.console.error = origConsoleError!; + win.removeEventListener("error", errorHandler!); + delete (win as unknown as Record).__hfErrorCapture; + } catch { + /* cross-origin or destroyed window */ + } + origConsoleError = null; + errorHandler = null; + }; + const attachErrorCapture = () => { + detachErrorCapture(); try { const win = previewIframe.contentWindow as (Window & typeof globalThis) | null; if (!win) return; if ((win as unknown as Record).__hfErrorCapture) return; (win as unknown as Record).__hfErrorCapture = true; - const origError = win.console.error.bind(win.console); + patchedWin = win; + origConsoleError = win.console.error.bind(win.console); win.console.error = function (...args: unknown[]) { - origError(...args); + origConsoleError!(...args); const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" "); if (text.includes("favicon")) return; consoleErrorsRef.current = [ @@ -34,18 +57,20 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null) ]; setConsoleErrors([...consoleErrorsRef.current]); }; - win.addEventListener("error", (e: ErrorEvent) => { + errorHandler = (e: ErrorEvent) => { const text = e.message || String(e); consoleErrorsRef.current = [ ...consoleErrorsRef.current, { severity: "error", message: text }, ]; setConsoleErrors([...consoleErrorsRef.current]); - }); + }; + win.addEventListener("error", errorHandler); } catch { /* same-origin only */ } }; + attachErrorCapture(); const handleLoad = () => { consoleErrorsRef.current = []; @@ -53,7 +78,10 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null) attachErrorCapture(); }; previewIframe.addEventListener("load", handleLoad); - return () => previewIframe.removeEventListener("load", handleLoad); + return () => { + previewIframe.removeEventListener("load", handleLoad); + detachErrorCapture(); + }; }, [previewIframe]); return { consoleErrors, setConsoleErrors, resetErrors }; diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index c7f27031ba..719036fa01 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -1,43 +1,22 @@ import { useCallback, useRef } from "react"; import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation"; -import { usePlayerStore } from "../player"; -import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability"; import { FONT_EXT } from "../utils/mediaTypes"; import type { PatchOperation } from "../utils/sourcePatcher"; import { trackStudioEvent } from "../utils/studioTelemetry"; -import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { primaryFontFamilyValue } from "../utils/studioFontHelpers"; import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics"; -import { - buildDomEditPatchTarget, - getDomEditTargetKey, - readHfId, - type DomEditSelection, -} from "../components/editor/domEditing"; -import { - applyStudioPathOffset, - applyStudioBoxSize, - applyStudioRotation, - clearStudioPathOffset, - clearStudioBoxSize, - clearStudioRotation, -} from "../components/editor/manualEdits"; -import { - buildPathOffsetPatches, - buildBoxSizePatches, - buildRotationPatches, - buildClearPathOffsetPatches, - buildClearBoxSizePatches, - buildClearRotationPatches, -} from "../components/editor/manualEditsDom"; +import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing"; import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; -import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; import type { EditHistoryKind } from "../utils/editHistory"; import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit"; import { useDomEditTextCommits } from "./useDomEditTextCommits"; +import { useDomGeometryCommits } from "./useDomGeometryCommits"; +import { useElementLifecycleOps } from "./useElementLifecycleOps"; + +// Re-export so existing consumers keep their import path +export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits"; // ── Helpers ── -type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; function formatUnsafeFieldList(fields: Array<{ path: string }>): string { return fields.map((field) => field.path).join(", "); @@ -60,40 +39,6 @@ function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } return `Couldn't save edit: ${body.error}${suffix}`; } -export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE = - "This element is GSAP-animated — dragging via CSS would corrupt keyframes"; - -// fallow-ignore-next-line complexity -function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean { - // When the GSAP drag intercept is disabled for debugging, treat every - // element as un-targeted so commits take the plain CSS persist path. - if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false; - if (!iframe?.contentWindow) return false; - let timelines: Record | undefined; - try { - timelines = (iframe.contentWindow as Window & { __timelines?: Record }) - .__timelines; - } catch { - return false; - } - if (!timelines) return false; - const id = element.id; - for (const tl of Object.values(timelines)) { - if (!tl?.getChildren) continue; - try { - for (const child of tl.getChildren(true)) { - if (!child.targets) continue; - for (const t of child.targets()) { - if (t === element || (id && t.id === id)) return true; - } - } - } catch { - continue; - } - } - return false; -} - // ── Types ── interface RecordEditInput { @@ -322,6 +267,8 @@ export function useDomEditCommits({ resolveImportedFontAsset, }); + // ── Position patch helper (shared by geometry + lifecycle hooks) ── + const commitPositionPatchToHtml = useDomEditPositionPatchCommit({ activeCompPath, persistDomEditOperations, @@ -329,229 +276,33 @@ export function useDomEditCommits({ showToast, }); - // ── Position commits ── - - const handleDomPathOffsetCommit = useCallback( - (selection: DomEditSelection, next: { x: number; y: number }) => { - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { - const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); - showToast(error.message, "error"); - return Promise.reject(error); - } - applyStudioPathOffset(selection.element, next); - return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { - label: "Move layer", - coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, - }); - }, - [commitPositionPatchToHtml, previewIframeRef, showToast], - ); - - const handleDomGroupPathOffsetCommit = useCallback( - (updates: DomEditGroupPathOffsetCommit[]) => { - if (updates.length === 0) return Promise.resolve(); - const blockedUpdate = updates.find(({ selection }) => - isElementGsapTargeted(previewIframeRef.current, selection.element), - ); - if (blockedUpdate) { - const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); - showToast(error.message, "error"); - return Promise.reject(error); - } - const coalesceKey = updates - .map((u) => getDomEditTargetKey(u.selection)) - .sort() - .join(":"); - const saves = updates.map(({ selection, next }) => { - applyStudioPathOffset(selection.element, next); - return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { - label: `Move ${updates.length} layers`, - coalesceKey: `group-path-offset:${coalesceKey}`, - }); - }); - return Promise.all(saves).then(() => undefined); - }, - [commitPositionPatchToHtml, previewIframeRef, showToast], - ); - - const handleDomBoxSizeCommit = useCallback( - (selection: DomEditSelection, next: { width: number; height: number }) => { - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { - const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); - showToast(error.message, "error"); - return Promise.reject(error); - } - applyStudioBoxSize(selection.element, next); - return commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), { - label: "Resize layer box", - coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, - }); - }, - [commitPositionPatchToHtml, previewIframeRef, showToast], - ); - - const handleDomRotationCommit = useCallback( - (selection: DomEditSelection, next: { angle: number }) => { - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { - const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); - showToast(error.message, "error"); - return Promise.reject(error); - } - applyStudioRotation(selection.element, next); - return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), { - label: "Rotate layer", - coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, - }); - }, - [commitPositionPatchToHtml, previewIframeRef, showToast], - ); - - const handleDomManualEditsReset = useCallback( - (selection: DomEditSelection) => { - const element = selection.element; - const clearPatches = [ - ...buildClearPathOffsetPatches(element), - ...buildClearBoxSizePatches(element), - ...buildClearRotationPatches(element), - ]; - clearStudioPathOffset(element); - clearStudioBoxSize(element); - clearStudioRotation(element); - // skipRefresh:false triggers reloadPreview() which re-syncs selection on load - void commitPositionPatchToHtml(selection, clearPatches, { - label: "Reset layer edits", - coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`, - skipRefresh: false, - }).catch(() => undefined); - }, - [commitPositionPatchToHtml], - ); - - // fallow-ignore-next-line complexity - const handleDomEditElementDelete = useCallback( - // fallow-ignore-next-line complexity - async (selection: DomEditSelection) => { - const pid = projectIdRef.current; - if (!pid) return; - const label = selection.label || selection.id || selection.selector || selection.tagName; - - const targetPath = selection.sourceFile || activeCompPath || "index.html"; - try { - const response = await fetch( - `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, - ); - if (!response.ok) { - throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`); - } - - const data = (await response.json()) as { content?: string }; - const originalContent = data.content; - if (typeof originalContent !== "string") - throw new Error(`Missing file contents for ${targetPath}`); + // ── Geometry commits (path offset, box size, rotation) ── - const patchTarget = buildDomEditPatchTarget(selection); - if (!patchTarget.id && !patchTarget.selector && !patchTarget.hfId) { - throw new Error("Selected element has no patchable target"); - } + const { + handleDomPathOffsetCommit, + handleDomGroupPathOffsetCommit, + handleDomBoxSizeCommit, + handleDomRotationCommit, + handleDomManualEditsReset, + } = useDomGeometryCommits({ + previewIframeRef, + showToast, + commitPositionPatchToHtml, + }); - domEditSaveTimestampRef.current = Date.now(); - const removeResponse = await fetch( - `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target: patchTarget }), - }, - ); - if (!removeResponse.ok) { - throw await createStudioSaveHttpError( - removeResponse, - `Failed to delete element from ${targetPath}`, - ); - } + // ── Element lifecycle (delete, z-index reorder) ── - const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string }; - const patchedContent = - typeof removeData.content === "string" ? removeData.content : originalContent; - await saveProjectFilesWithHistory({ - projectId: pid, - label: "Delete element", - kind: "timeline", - files: { [targetPath]: patchedContent }, - readFile: async () => originalContent, - writeFile: writeProjectFile, - recordEdit: editHistory.recordEdit, - }); - - clearDomSelection(); - usePlayerStore.getState().setSelectedElementId(null); - reloadPreview(); - showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to delete element"; - showToast(message); - } - }, - [ - activeCompPath, - clearDomSelection, - domEditSaveTimestampRef, - editHistory.recordEdit, - projectIdRef, - reloadPreview, - showToast, - writeProjectFile, - ], - ); - - const handleDomZIndexReorderCommit = useCallback( - ( - entries: Array<{ - element: HTMLElement; - zIndex: number; - id?: string; - selector?: string; - selectorIndex?: number; - sourceFile: string; - }>, - ) => { - if (entries.length === 0) return; - const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`; - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - entry.element.style.zIndex = String(entry.zIndex); - const patches: Array<{ type: "inline-style"; property: string; value: string }> = [ - { type: "inline-style", property: "z-index", value: String(entry.zIndex) }, - ]; - try { - const win = entry.element.ownerDocument?.defaultView; - if (win && win.getComputedStyle(entry.element).position === "static") { - entry.element.style.position = "relative"; - patches.push({ type: "inline-style", property: "position", value: "relative" }); - } - } catch { - /* cross-origin or detached — skip */ - } - void commitPositionPatchToHtml( - { - element: entry.element, - id: entry.id ?? null, - hfId: readHfId(entry.element), - selector: entry.selector, - selectorIndex: entry.selectorIndex, - sourceFile: entry.sourceFile, - } as unknown as DomEditSelection, - patches, - { - label: "Reorder layers", - coalesceKey, - skipRefresh: i < entries.length - 1, - }, - ).catch(() => undefined); - } - }, - [commitPositionPatchToHtml], - ); + const { handleDomEditElementDelete, handleDomZIndexReorderCommit } = useElementLifecycleOps({ + activeCompPath, + showToast, + writeProjectFile, + domEditSaveTimestampRef, + editHistory, + projectIdRef, + reloadPreview, + clearDomSelection, + commitPositionPatchToHtml, + }); return { resolveImportedFontAsset, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index e11ebd495d..c6aa36a19f 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,12 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; import type { TimelineElement } from "../player"; -import { usePlayerStore } from "../player"; -import { - STUDIO_GSAP_DRAG_INTERCEPT_ENABLED, - STUDIO_GSAP_PANEL_ENABLED, -} from "../components/editor/manualEditingAvailability"; -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"; @@ -15,22 +7,11 @@ import type { SidebarTab } from "../components/sidebar/LeftSidebar"; import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; -import { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, useDomEditCommits } from "./useDomEditCommits"; +import { useDomEditCommits } from "./useDomEditCommits"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; -import { - useGsapAnimationsForElement, - useGsapCacheVersion, - usePopulateKeyframeCacheForFile, -} from "./useGsapTweenCache"; -import { - tryGsapDragIntercept, - tryGsapResizeIntercept, - tryGsapRotationIntercept, -} from "./gsapRuntimeBridge"; -import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit"; -import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback"; -import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry"; -import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers"; +import { useGsapCacheVersion } from "./useGsapTweenCache"; +import { useDomEditWiring } from "./useDomEditWiring"; +import { useGsapAwareEditing } from "./useGsapAwareEditing"; // ── Types ── @@ -81,7 +62,6 @@ export interface UseDomEditSessionParams { // ── Hook ── -// fallow-ignore-next-line complexity export function useDomEditSession({ projectId, activeCompPath, @@ -118,22 +98,9 @@ export function useDomEditSession({ getSidebarTab, }: UseDomEditSessionParams) { void _setRefreshKey; + void _readProjectFile; - const onClickToSource = useCallback( - (selection: DomEditSelection) => { - if (!openSourceForSelection || !selectSidebarTab) return; - if (!selection.sourceFile) return; - selectSidebarTab("code"); - openSourceForSelection(selection.sourceFile, { - id: selection.id, - selector: selection.selector, - selectorIndex: selection.selectorIndex, - }); - }, - [openSourceForSelection, selectSidebarTab], - ); - - // ── Selection (delegated to useDomSelection) ── + // ── Selection ── const { domEditSelection, @@ -165,7 +132,7 @@ export function useDomEditSession({ rightPanelTab, }); - // ── Agent modal (delegated to useAskAgentModal) ── + // ── Agent modal ── const { agentModalOpen, @@ -187,75 +154,11 @@ export function useDomEditSession({ domEditSelection, }); - // ── Preview interaction (delegated to usePreviewInteraction) ── - - const { - handlePreviewCanvasMouseDown, - handlePreviewCanvasPointerMove, - handlePreviewCanvasPointerLeave, - handleBlockedDomMove, - handleDomManualDragStart, - } = usePreviewInteraction({ - captionEditMode, - compositionLoading, - previewIframeRef, - showToast, - applyDomSelection, - resolveDomSelectionFromPreviewPoint, - resolveAllDomSelectionsFromPreviewPoint, - updateDomEditHoverSelection, - 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 ── + // ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ── const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion(); - // Bump GSAP cache when refreshKey changes (code-tab edits trigger iframe - // reload via refreshKey but don't go through commitMutation, so the cache - // would otherwise retain stale keyframe entries). - const prevRefreshKeyRef = useRef(refreshKey); - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - if (refreshKey !== prevRefreshKeyRef.current) { - prevRefreshKeyRef.current = refreshKey; - bumpGsapCache(); - } - }, [refreshKey, bumpGsapCache]); - - const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html"; - - usePopulateKeyframeCacheForFile( - STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, - gsapSourceFile, - gsapCacheVersion, - previewIframeRef, - ); - - const { - animations: selectedGsapAnimations, - multipleTimelines: gsapMultipleTimelines, - unsupportedTimelinePattern: gsapUnsupportedTimelinePattern, - } = useGsapAnimationsForElement( - STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, - gsapSourceFile, - domEditSelection - ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null } - : null, - gsapCacheVersion, - ); + // ── GSAP script commits ── const { commitMutation: gsapCommitMutation, @@ -288,7 +191,7 @@ export function useDomEditSession({ showToast, }); - // ── Commit handlers (delegated to useDomEditCommits) ── + // ── DOM commit handlers ── const { resolveImportedFontAsset, @@ -326,108 +229,15 @@ export function useDomEditSession({ buildDomSelectionFromTarget, }); - const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast); - - const makeFetchFallback = useGsapAnimationFetchFallback(projectId, gsapSourceFile); - - // GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated. - const handleGsapAwarePathOffsetCommit = useCallback( - async (selection: DomEditSelection, next: { x: number; y: number }) => { - const hasGsapAnims = selectedGsapAnimations.length > 0; - if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { - showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error"); - throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); - } - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { - try { - const handled = await tryGsapDragIntercept( - selection, - next, - selectedGsapAnimations, - previewIframeRef.current, - gsapCommitMutation, - makeFetchFallback(selection), - ); - if (handled) return; - } catch (error) { - trackGsapInteractionFailure(error, selection, "drag", "Move animated layer"); - throw error; - } - } - return handleDomPathOffsetCommit(selection, next); - }, - [ - handleDomPathOffsetCommit, - selectedGsapAnimations, - gsapCommitMutation, - previewIframeRef, - makeFetchFallback, - trackGsapInteractionFailure, - showToast, - ], - ); - - const handleGsapAwareBoxSizeCommit = useCallback( - async (selection: DomEditSelection, next: { width: number; height: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { - try { - const handled = await tryGsapResizeIntercept( - selection, - next, - selectedGsapAnimations, - previewIframeRef.current, - gsapCommitMutation, - makeFetchFallback(selection), - ); - if (handled) return; - } catch (error) { - trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer"); - throw error; - } - } - return handleDomBoxSizeCommit(selection, next); - }, - [ - handleDomBoxSizeCommit, - selectedGsapAnimations, - gsapCommitMutation, - previewIframeRef, - makeFetchFallback, - trackGsapInteractionFailure, - ], - ); - - const handleGsapAwareRotationCommit = useCallback( - async (selection: DomEditSelection, next: { angle: number }) => { - if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { - try { - const handled = await tryGsapRotationIntercept( - selection, - next.angle, - selectedGsapAnimations, - previewIframeRef.current, - gsapCommitMutation, - makeFetchFallback(selection), - ); - if (handled) return; - } catch (error) { - trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer"); - throw error; - } - } - return handleDomRotationCommit(selection, next); - }, - [ - handleDomRotationCommit, - selectedGsapAnimations, - gsapCommitMutation, - previewIframeRef, - makeFetchFallback, - trackGsapInteractionFailure, - ], - ); + // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ── const { + onClickToSource, + selectedGsapAnimations, + gsapMultipleTimelines, + gsapUnsupportedTimelinePattern, + trackGsapInteractionFailure, + makeFetchFallback, handleGsapUpdateProperty, handleGsapUpdateMeta, handleGsapDeleteAnimation, @@ -444,8 +254,26 @@ export function useDomEditSession({ handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, - } = useGsapSelectionHandlers({ + } = useDomEditWiring({ + projectId, + activeCompPath, domEditSelection, + domEditSelectionRef, + previewIframeRef, + previewIframe, + captionEditMode, + refreshKey, + gsapCacheVersion, + bumpGsapCache, + showToast, + refreshPreviewDocumentVersion, + syncPreviewHistoryHotkey, + applyStudioManualEditsToPreviewRef, + applyDomSelection, + buildDomSelectionFromTarget, + openSourceForSelection, + selectSidebarTab, + getSidebarTab, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, @@ -462,47 +290,54 @@ export function useDomEditSession({ convertToKeyframes, removeAllKeyframes, handleDomManualEditsReset, - selectedGsapAnimations, }); - const commitAnimatedProperty = useAnimatedPropertyCommit({ - selectedGsapAnimations, - gsapCommitMutation, - addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time), - convertToKeyframes: (sel, animId) => convertToKeyframes(sel, animId), + // ── Preview interaction ── + + const { + handlePreviewCanvasMouseDown, + handlePreviewCanvasPointerMove, + handlePreviewCanvasPointerLeave, + handleBlockedDomMove, + handleDomManualDragStart, + } = usePreviewInteraction({ + captionEditMode, + compositionLoading, previewIframeRef, - bumpGsapCache, + showToast, + applyDomSelection, + resolveDomSelectionFromPreviewPoint, + resolveAllDomSelectionsFromPreviewPoint, + updateDomEditHoverSelection, + onClickToSource, }); - const handleSetArcPath = useCallback( - (animId: string, config: Parameters[2]) => { - if (!domEditSelection) return; - setArcPath(domEditSelection, animId, config); - }, - [domEditSelection, setArcPath], - ); + // ── GSAP-aware geometry intercepts + animated property commit ── - const handleUpdateArcSegment = useCallback( - (animId: string, segmentIndex: number, update: Parameters[3]) => { - if (!domEditSelection) return; - updateArcSegment(domEditSelection, animId, segmentIndex, update); - }, - [domEditSelection, updateArcSegment], - ); - - useDomEditPreviewSync({ - previewIframe, - activeCompPath, - captionEditMode, - domEditSelectionRef, + const { + handleGsapAwarePathOffsetCommit, + handleGsapAwareBoxSizeCommit, + handleGsapAwareRotationCommit, + commitAnimatedProperty, + handleSetArcPath, + handleUpdateArcSegment, + commitMutation, + } = useGsapAwareEditing({ domEditSelection, - applyDomSelection, - buildDomSelectionFromTarget, - refreshPreviewDocumentVersion, - syncPreviewHistoryHotkey, - applyStudioManualEditsToPreviewRef, - openSourceForSelection, - getSidebarTab, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + showToast, + bumpGsapCache, + makeFetchFallback, + trackGsapInteractionFailure, + handleDomPathOffsetCommit, + handleDomBoxSizeCommit, + handleDomRotationCommit, + addGsapAnimation, + convertToKeyframes, + setArcPath, + updateArcSegment, }); return { @@ -574,12 +409,6 @@ export function useDomEditSession({ handleUpdateArcSegment, invalidateGsapCache: bumpGsapCache, previewIframeRef, - commitMutation: async ( - mutation: Record, - options: { label: string; softReload?: boolean }, - ) => { - if (!domEditSelection) return; - await gsapCommitMutation(domEditSelection, mutation, options); - }, + commitMutation, }; } diff --git a/packages/studio/src/hooks/useDomEditWiring.ts b/packages/studio/src/hooks/useDomEditWiring.ts new file mode 100644 index 0000000000..6c085184a0 --- /dev/null +++ b/packages/studio/src/hooks/useDomEditWiring.ts @@ -0,0 +1,255 @@ +/** + * Wiring layer for DOM edit sessions: click-to-source navigation, + * DOM selection to timeline sync, GSAP cache invalidation on refresh, + * GSAP cache population, animation resolution for the selected element, + * and preview sync side-effects. + * + * Extracted from useDomEditSession to isolate orchestration wiring from + * the GSAP-aware geometry intercept logic. + */ +import { useCallback, useEffect, useRef } from "react"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { STUDIO_GSAP_PANEL_ENABLED } from "../components/editor/manualEditingAvailability"; +import { usePlayerStore } from "../player"; +import { useDomEditPreviewSync } from "./useDomEditPreviewSync"; +import { useGsapAnimationsForElement, usePopulateKeyframeCacheForFile } from "./useGsapTweenCache"; +import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback"; +import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry"; +import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers"; +import type { PatchTarget } from "../utils/sourcePatcher"; +import type { SidebarTab } from "../components/sidebar/LeftSidebar"; + +export interface UseDomEditWiringParams { + projectId: string | null; + activeCompPath: string | null; + domEditSelection: DomEditSelection | null; + domEditSelectionRef: React.MutableRefObject; + previewIframeRef: React.RefObject; + previewIframe: HTMLIFrameElement | null; + captionEditMode: boolean; + refreshKey: number; + gsapCacheVersion: number; + bumpGsapCache: () => void; + showToast: (message: string, tone?: "error" | "info") => void; + refreshPreviewDocumentVersion: () => void; + syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void; + applyStudioManualEditsToPreviewRef: React.MutableRefObject< + (iframe: HTMLIFrameElement) => Promise + >; + applyDomSelection: ( + selection: DomEditSelection | null, + options?: { revealPanel?: boolean; preserveGroup?: boolean }, + ) => void; + buildDomSelectionFromTarget: (element: HTMLElement) => Promise; + openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void; + selectSidebarTab?: (tab: SidebarTab) => void; + getSidebarTab?: () => SidebarTab; + // GSAP script commit ops (from useGsapScriptCommits) + updateGsapProperty: ( + sel: DomEditSelection, + animId: string, + prop: string, + value: number | string, + ) => void; + updateGsapMeta: ( + sel: DomEditSelection, + animId: string, + updates: { duration?: number; ease?: string; position?: number }, + ) => void; + deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void; + deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => void; + addGsapAnimation: ( + sel: DomEditSelection, + method: "to" | "from" | "set" | "fromTo", + time: number, + ) => Promise; + addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void; + removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void; + updateGsapFromProperty: ( + sel: DomEditSelection, + animId: string, + prop: string, + value: number | string, + ) => void; + addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void; + removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void; + addKeyframe: ( + sel: DomEditSelection, + animId: string, + percentage: number, + property: string, + value: number | string, + ) => void; + addKeyframeBatch: ( + sel: DomEditSelection, + animId: string, + percentage: number, + properties: Record, + ) => Promise; + removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void; + convertToKeyframes: ( + sel: DomEditSelection, + animId: string, + resolvedFromValues?: Record, + ) => Promise; + removeAllKeyframes: (sel: DomEditSelection, animId: string) => void; + handleDomManualEditsReset: (sel: DomEditSelection) => void; +} + +// fallow-ignore-next-line complexity +export function useDomEditWiring({ + projectId, + activeCompPath, + domEditSelection, + domEditSelectionRef, + previewIframeRef, + previewIframe, + captionEditMode, + refreshKey, + gsapCacheVersion, + bumpGsapCache, + showToast, + refreshPreviewDocumentVersion, + syncPreviewHistoryHotkey, + applyStudioManualEditsToPreviewRef, + applyDomSelection, + buildDomSelectionFromTarget, + openSourceForSelection, + selectSidebarTab, + getSidebarTab, + updateGsapProperty, + updateGsapMeta, + deleteGsapAnimation, + deleteAllForSelector, + addGsapAnimation, + addGsapProperty, + removeGsapProperty, + updateGsapFromProperty, + addGsapFromProperty, + removeGsapFromProperty, + addKeyframe, + addKeyframeBatch, + removeKeyframe, + convertToKeyframes, + removeAllKeyframes, + handleDomManualEditsReset, +}: UseDomEditWiringParams) { + // ── Click-to-source navigation ── + + const onClickToSource = useCallback( + (selection: DomEditSelection) => { + if (!openSourceForSelection || !selectSidebarTab) return; + if (!selection.sourceFile) return; + selectSidebarTab("code"); + openSourceForSelection(selection.sourceFile, { + id: selection.id, + selector: selection.selector, + selectorIndex: selection.selectorIndex, + }); + }, + [openSourceForSelection, selectSidebarTab], + ); + + // ── DOM selection -> timeline element sync ── + + 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 cache sync ── + + // Bump GSAP cache when refreshKey changes (code-tab edits trigger iframe + // reload via refreshKey but don't go through commitMutation, so the cache + // would otherwise retain stale keyframe entries). + const prevRefreshKeyRef = useRef(refreshKey); + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (refreshKey !== prevRefreshKeyRef.current) { + prevRefreshKeyRef.current = refreshKey; + bumpGsapCache(); + } + }, [refreshKey, bumpGsapCache]); + + const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html"; + + usePopulateKeyframeCacheForFile( + STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, + gsapSourceFile, + gsapCacheVersion, + previewIframeRef, + ); + + const { + animations: selectedGsapAnimations, + multipleTimelines: gsapMultipleTimelines, + unsupportedTimelinePattern: gsapUnsupportedTimelinePattern, + } = useGsapAnimationsForElement( + STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, + gsapSourceFile, + domEditSelection + ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null } + : null, + gsapCacheVersion, + ); + + // ── Telemetry & fallback ── + + const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast); + const makeFetchFallback = useGsapAnimationFetchFallback(projectId, gsapSourceFile); + + // ── GSAP selection handlers ── + + const gsapSelectionHandlers = useGsapSelectionHandlers({ + domEditSelection, + updateGsapProperty, + updateGsapMeta, + deleteGsapAnimation, + deleteAllForSelector, + addGsapAnimation, + addGsapProperty, + removeGsapProperty, + updateGsapFromProperty, + addGsapFromProperty, + removeGsapFromProperty, + addKeyframe, + addKeyframeBatch, + removeKeyframe, + convertToKeyframes, + removeAllKeyframes, + handleDomManualEditsReset, + selectedGsapAnimations, + }); + + // ── Preview sync side-effects ── + + useDomEditPreviewSync({ + previewIframe, + activeCompPath, + captionEditMode, + domEditSelectionRef, + domEditSelection, + applyDomSelection, + buildDomSelectionFromTarget, + refreshPreviewDocumentVersion, + syncPreviewHistoryHotkey, + applyStudioManualEditsToPreviewRef, + openSourceForSelection, + getSidebarTab, + }); + + return { + onClickToSource, + selectedGsapAnimations, + gsapMultipleTimelines, + gsapUnsupportedTimelinePattern, + trackGsapInteractionFailure, + makeFetchFallback, + ...gsapSelectionHandlers, + }; +} diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts new file mode 100644 index 0000000000..be8e03c4a9 --- /dev/null +++ b/packages/studio/src/hooks/useDomGeometryCommits.ts @@ -0,0 +1,181 @@ +import { useCallback } from "react"; +import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability"; +import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing"; +import { + applyStudioPathOffset, + applyStudioBoxSize, + applyStudioRotation, + clearStudioPathOffset, + clearStudioBoxSize, + clearStudioRotation, +} from "../components/editor/manualEdits"; +import { + buildPathOffsetPatches, + buildBoxSizePatches, + buildRotationPatches, + buildClearPathOffsetPatches, + buildClearBoxSizePatches, + buildClearRotationPatches, +} from "../components/editor/manualEditsDomPatches"; +import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay"; +import type { PatchOperation } from "../utils/sourcePatcher"; + +export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE = + "This element is GSAP-animated — dragging via CSS would corrupt keyframes"; + +// ── Helpers ── + +type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; + +// fallow-ignore-next-line complexity +function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean { + // When the GSAP drag intercept is disabled for debugging, treat every + // element as un-targeted so commits take the plain CSS persist path. + if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false; + if (!iframe?.contentWindow) return false; + let timelines: Record | undefined; + try { + timelines = (iframe.contentWindow as Window & { __timelines?: Record }) + .__timelines; + } catch { + return false; + } + if (!timelines) return false; + const id = element.id; + for (const tl of Object.values(timelines)) { + if (!tl?.getChildren) continue; + try { + for (const child of tl.getChildren(true)) { + if (!child.targets) continue; + for (const t of child.targets()) { + if (t === element || (id && t.id === id)) return true; + } + } + } catch { + continue; + } + } + return false; +} + +// ── Hook ── + +interface UseDomGeometryCommitsParams { + previewIframeRef: React.MutableRefObject; + showToast: (message: string, tone?: "error" | "info") => void; + commitPositionPatchToHtml: ( + selection: DomEditSelection, + patches: PatchOperation[], + options: { label: string; coalesceKey: string; skipRefresh?: boolean }, + ) => Promise; +} + +export function useDomGeometryCommits({ + previewIframeRef, + showToast, + commitPositionPatchToHtml, +}: UseDomGeometryCommitsParams) { + const handleDomPathOffsetCommit = useCallback( + (selection: DomEditSelection, next: { x: number; y: number }) => { + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { + const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); + showToast(error.message, "error"); + return Promise.reject(error); + } + applyStudioPathOffset(selection.element, next); + return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { + label: "Move layer", + coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, + }); + }, + [commitPositionPatchToHtml, previewIframeRef, showToast], + ); + + const handleDomGroupPathOffsetCommit = useCallback( + (updates: DomEditGroupPathOffsetCommit[]) => { + if (updates.length === 0) return Promise.resolve(); + const blockedUpdate = updates.find(({ selection }) => + isElementGsapTargeted(previewIframeRef.current, selection.element), + ); + if (blockedUpdate) { + const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); + showToast(error.message, "error"); + return Promise.reject(error); + } + const coalesceKey = updates + .map((u) => getDomEditTargetKey(u.selection)) + .sort() + .join(":"); + const saves = updates.map(({ selection, next }) => { + applyStudioPathOffset(selection.element, next); + return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { + label: `Move ${updates.length} layers`, + coalesceKey: `group-path-offset:${coalesceKey}`, + }); + }); + return Promise.all(saves).then(() => undefined); + }, + [commitPositionPatchToHtml, previewIframeRef, showToast], + ); + + const handleDomBoxSizeCommit = useCallback( + (selection: DomEditSelection, next: { width: number; height: number }) => { + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { + const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); + showToast(error.message, "error"); + return Promise.reject(error); + } + applyStudioBoxSize(selection.element, next); + return commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), { + label: "Resize layer box", + coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, + }); + }, + [commitPositionPatchToHtml, previewIframeRef, showToast], + ); + + const handleDomRotationCommit = useCallback( + (selection: DomEditSelection, next: { angle: number }) => { + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { + const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); + showToast(error.message, "error"); + return Promise.reject(error); + } + applyStudioRotation(selection.element, next); + return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), { + label: "Rotate layer", + coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, + }); + }, + [commitPositionPatchToHtml, previewIframeRef, showToast], + ); + + const handleDomManualEditsReset = useCallback( + (selection: DomEditSelection) => { + const element = selection.element; + const clearPatches = [ + ...buildClearPathOffsetPatches(element), + ...buildClearBoxSizePatches(element), + ...buildClearRotationPatches(element), + ]; + clearStudioPathOffset(element); + clearStudioBoxSize(element); + clearStudioRotation(element); + // skipRefresh:false triggers reloadPreview() which re-syncs selection on load + void commitPositionPatchToHtml(selection, clearPatches, { + label: "Reset layer edits", + coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`, + skipRefresh: false, + }).catch(() => undefined); + }, + [commitPositionPatchToHtml], + ); + + return { + handleDomPathOffsetCommit, + handleDomGroupPathOffsetCommit, + handleDomBoxSizeCommit, + handleDomRotationCommit, + handleDomManualEditsReset, + }; +} diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 2554d1b661..e6db3e33ac 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -389,47 +389,30 @@ export function useDomSelection({ // ── Effects ── - // Clear hover on caption mode change - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - if (captionEditMode) updateDomEditHoverSelection(null); - }, [captionEditMode, updateDomEditHoverSelection]); - - // Clear hover on composition/project/preview change + // Clear hover unconditionally on composition/project/preview change // eslint-disable-next-line no-restricted-syntax useEffect(() => { updateDomEditHoverSelection(null); }, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]); - // Clear hover when matching selection + // Clear hover conditionally (caption mode, matches selection, disconnected element) // eslint-disable-next-line no-restricted-syntax useEffect(() => { if (!domEditHoverSelection) return; - const hoverMatchesSelection = domEditSelectionsTargetSame( - domEditHoverSelection, - domEditSelection, - ); - const hoverMatchesGroup = domEditSelectionInGroup( - domEditGroupSelections, - domEditHoverSelection, - ); - if (!hoverMatchesSelection && !hoverMatchesGroup) return; - updateDomEditHoverSelection(null); + const shouldClear = + captionEditMode || + domEditSelectionsTargetSame(domEditHoverSelection, domEditSelection) || + domEditSelectionInGroup(domEditGroupSelections, domEditHoverSelection) || + !domEditHoverSelection.element.isConnected; + if (shouldClear) updateDomEditHoverSelection(null); }, [ - domEditGroupSelections, + captionEditMode, domEditHoverSelection, domEditSelection, + domEditGroupSelections, updateDomEditHoverSelection, ]); - // Clear hover when element disconnected - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - if (!domEditHoverSelection) return; - if (domEditHoverSelection.element.isConnected) return; - updateDomEditHoverSelection(null); - }, [domEditHoverSelection, updateDomEditHoverSelection]); - // Clear selection on caption mode change // eslint-disable-next-line no-restricted-syntax useEffect(() => { diff --git a/packages/studio/src/hooks/useEditorSave.ts b/packages/studio/src/hooks/useEditorSave.ts new file mode 100644 index 0000000000..a84f69394a --- /dev/null +++ b/packages/studio/src/hooks/useEditorSave.ts @@ -0,0 +1,82 @@ +import { useCallback, useRef } from "react"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import type { EditHistoryKind } from "../utils/editHistory"; +import { trackStudioEvent } from "../utils/studioTelemetry"; + +interface RecordEditInput { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; +} + +interface UseEditorSaveOptions { + editingPathRef: React.RefObject; + projectIdRef: React.RefObject; + readProjectFile: (path: string) => Promise; + writeProjectFile: (path: string, content: string) => Promise; + recordEdit: (input: RecordEditInput) => Promise; + domEditSaveTimestampRef: React.MutableRefObject; + setRefreshKey: React.Dispatch>; +} + +export function useEditorSave({ + editingPathRef, + projectIdRef, + readProjectFile, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + setRefreshKey, +}: UseEditorSaveOptions) { + const saveRafRef = useRef(null); + const refreshRafRef = useRef(null); + + const handleContentChange = useCallback( + (content: string) => { + const pid = projectIdRef.current; + if (!pid) return; + const path = editingPathRef.current; + if (!path) return; + + if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current); + saveRafRef.current = requestAnimationFrame(() => { + domEditSaveTimestampRef.current = Date.now(); + saveProjectFilesWithHistory({ + projectId: pid, + label: "Edit source", + kind: "source", + coalesceKey: `source:${path}`, + files: { [path]: content }, + readFile: readProjectFile, + writeFile: writeProjectFile, + recordEdit, + }) + .then(() => { + if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current); + refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1)); + }) + .catch((error) => { + trackStudioEvent("save_failure", { + source: "code_editor", + error_message: error instanceof Error ? error.message : "unknown", + }); + }); + }); + }, + [ + domEditSaveTimestampRef, + editingPathRef, + projectIdRef, + readProjectFile, + recordEdit, + setRefreshKey, + writeProjectFile, + ], + ); + + return { + saveRafRef, + handleContentChange, + }; +} diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts new file mode 100644 index 0000000000..0429e9c742 --- /dev/null +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -0,0 +1,177 @@ +import { useCallback } from "react"; +import { usePlayerStore } from "../player"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics"; +import { + buildDomEditPatchTarget, + readHfId, + type DomEditSelection, +} from "../components/editor/domEditing"; +import type { PatchOperation } from "../utils/sourcePatcher"; +import type { EditHistoryKind } from "../utils/editHistory"; + +interface RecordEditInput { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; +} + +interface UseElementLifecycleOpsParams { + activeCompPath: string | null; + showToast: (message: string, tone?: "error" | "info") => void; + writeProjectFile: (path: string, content: string) => Promise; + domEditSaveTimestampRef: React.MutableRefObject; + editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; + projectIdRef: React.MutableRefObject; + reloadPreview: () => void; + clearDomSelection: () => void; + commitPositionPatchToHtml: ( + selection: DomEditSelection, + patches: PatchOperation[], + options: { label: string; coalesceKey: string; skipRefresh?: boolean }, + ) => Promise; +} + +export function useElementLifecycleOps({ + activeCompPath, + showToast, + writeProjectFile, + domEditSaveTimestampRef, + editHistory, + projectIdRef, + reloadPreview, + clearDomSelection, + commitPositionPatchToHtml, +}: UseElementLifecycleOpsParams) { + // fallow-ignore-next-line complexity + const handleDomEditElementDelete = useCallback( + // fallow-ignore-next-line complexity + async (selection: DomEditSelection) => { + const pid = projectIdRef.current; + if (!pid) return; + const label = selection.label || selection.id || selection.selector || selection.tagName; + + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + try { + const response = await fetch( + `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, + ); + if (!response.ok) { + throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`); + } + + const data = (await response.json()) as { content?: string }; + const originalContent = data.content; + if (typeof originalContent !== "string") + throw new Error(`Missing file contents for ${targetPath}`); + + const patchTarget = buildDomEditPatchTarget(selection); + if (!patchTarget.id && !patchTarget.selector && !patchTarget.hfId) { + throw new Error("Selected element has no patchable target"); + } + + domEditSaveTimestampRef.current = Date.now(); + const removeResponse = await fetch( + `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target: patchTarget }), + }, + ); + if (!removeResponse.ok) { + throw await createStudioSaveHttpError( + removeResponse, + `Failed to delete element from ${targetPath}`, + ); + } + + const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string }; + const patchedContent = + typeof removeData.content === "string" ? removeData.content : originalContent; + await saveProjectFilesWithHistory({ + projectId: pid, + label: "Delete element", + kind: "timeline", + files: { [targetPath]: patchedContent }, + readFile: async () => originalContent, + writeFile: writeProjectFile, + recordEdit: editHistory.recordEdit, + }); + + clearDomSelection(); + usePlayerStore.getState().setSelectedElementId(null); + reloadPreview(); + showToast(`Deleted ${label}. Use Undo to restore it.`, "info"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete element"; + showToast(message); + } + }, + [ + activeCompPath, + clearDomSelection, + domEditSaveTimestampRef, + editHistory.recordEdit, + projectIdRef, + reloadPreview, + showToast, + writeProjectFile, + ], + ); + + const handleDomZIndexReorderCommit = useCallback( + ( + entries: Array<{ + element: HTMLElement; + zIndex: number; + id?: string; + selector?: string; + selectorIndex?: number; + sourceFile: string; + }>, + ) => { + if (entries.length === 0) return; + const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + entry.element.style.zIndex = String(entry.zIndex); + const patches: Array<{ type: "inline-style"; property: string; value: string }> = [ + { type: "inline-style", property: "z-index", value: String(entry.zIndex) }, + ]; + try { + const win = entry.element.ownerDocument?.defaultView; + if (win && win.getComputedStyle(entry.element).position === "static") { + entry.element.style.position = "relative"; + patches.push({ type: "inline-style", property: "position", value: "relative" }); + } + } catch { + /* cross-origin or detached — skip */ + } + void commitPositionPatchToHtml( + { + element: entry.element, + id: entry.id ?? null, + hfId: readHfId(entry.element), + selector: entry.selector, + selectorIndex: entry.selectorIndex, + sourceFile: entry.sourceFile, + } as unknown as DomEditSelection, + patches, + { + label: "Reorder layers", + coalesceKey, + skipRefresh: i < entries.length - 1, + }, + ).catch(() => undefined); + } + }, + [commitPositionPatchToHtml], + ); + + return { + handleDomEditElementDelete, + handleDomZIndexReorderCommit, + }; +} diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index ca6ea8e194..4a7858fa8b 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -12,6 +12,9 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache"; +import { selectorFromSelection, computeElementPercentage } from "./gsapShared"; +import { POSITION_PROPS } from "./gsapRuntimeReaders"; +import { roundTo3 } from "../utils/rounding"; export interface EnableKeyframesSession { domEditSelection: DomEditSelection | null; @@ -52,12 +55,11 @@ function readElementPosition( const element = sel.element; if (!element?.isConnected || !gsap?.getProperty) return result; - const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]); const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"]; for (const prop of props) { const val = Number(gsap.getProperty(element, prop)); if (!Number.isFinite(val)) continue; - result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000; + result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val); } return result; @@ -75,13 +77,6 @@ async function fetchAnimationsForElement(sel: DomEditSelection): Promise, @@ -104,7 +99,7 @@ export function useEnableKeyframes( const flatAnim = anims.find((a) => !a.keyframes); if (kfAnim?.keyframes) { - const pct = computePercentage(t, sel); + const pct = computeElementPercentage(t, sel); const existing = kfAnim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1); if (existing) { session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); @@ -120,17 +115,17 @@ export function useEnableKeyframes( await session.handleGsapConvertToKeyframes(flatAnim.id, hasPosition ? position : undefined); - const pct = computePercentage(t, sel); + const pct = computeElementPercentage(t, sel); if (pct > 1 && pct < 99 && hasPosition && session.handleGsapAddKeyframeBatch) { await session.handleGsapAddKeyframeBatch(flatAnim.id, pct, position); await session.handleGsapAddKeyframeBatch(flatAnim.id, 100, position); } } else { const position = readElementPosition(iframe, sel, null); - const pct = computePercentage(t, sel); + const pct = computeElementPercentage(t, sel); const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; - const selector = sel.id ? `#${sel.id}` : sel.selector; + const selector = selectorFromSelection(sel); if (!selector) { session.handleGsapAddAnimation("to"); @@ -159,8 +154,8 @@ export function useEnableKeyframes( { type: "add-with-keyframes", targetSelector: selector, - position: Math.round(elStart * 1000) / 1000, - duration: Math.round(elDuration * 1000) / 1000, + position: roundTo3(elStart), + duration: roundTo3(elDuration), keyframes, }, { label: "Enable keyframes", softReload: true }, diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index 6309b42dd1..753dce7d8e 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -1,16 +1,16 @@ -import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { useState, useCallback, useRef } from "react"; import type { EditingFile } from "../utils/studioHelpers"; import { FONT_EXT, isMediaFile } from "../utils/mediaTypes"; import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; -import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import type { EditHistoryKind } from "../utils/editHistory"; import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher"; -import { trackStudioEvent } from "../utils/studioTelemetry"; import { createStudioSaveHttpError, retryStudioSave, StudioSaveNetworkError, } from "../utils/studioSaveDiagnostics"; +import { useFileTree } from "./useFileTree"; +import { useEditorSave } from "./useEditorSave"; // ── Types ── @@ -38,54 +38,31 @@ export function useFileManager({ domEditSaveTimestampRef, setRefreshKey, }: UseFileManagerOptions) { - // ── State ── + // ── Shared refs ── const [editingFile, setEditingFile] = useState(null); - const [projectDir, setProjectDir] = useState(null); - const [fileTree, setFileTree] = useState([]); - const [compositionPaths, setCompositionPaths] = useState([]); - const [fileTreeLoaded, setFileTreeLoaded] = useState(false); const [revealSourceOffset, setRevealSourceOffset] = useState(null); - // ── Refs ── - const editingPathRef = useRef(editingFile?.path); editingPathRef.current = editingFile?.path; const projectIdRef = useRef(projectId); projectIdRef.current = projectId; - const saveRafRef = useRef(null); - const refreshRafRef = useRef(null); const importedFontAssetsRef = useRef([]); - // ── Load file tree when projectId changes ── + // ── File tree ── - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - if (!projectId) { - setFileTreeLoaded(false); - return; - } - let cancelled = false; - setFileTreeLoaded(false); - fetch(`/api/projects/${projectId}`) - .then((r) => r.json()) - .then((data: { files?: string[]; dir?: string; compositions?: string[] }) => { - if (!cancelled && data.files) setFileTree(data.files); - if (!cancelled && data.compositions) setCompositionPaths(data.compositions); - if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null); - }) - .catch(() => { - if (!cancelled) setProjectDir(null); - }) - .finally(() => { - if (!cancelled) setFileTreeLoaded(true); - }); - return () => { - cancelled = true; - }; - }, [projectId]); + const { + projectDir, + fileTree, + setFileTree, + fileTreeLoaded, + refreshFileTree, + compositions, + assets, + fontAssets, + } = useFileTree({ projectId, projectIdRef }); // ── Core file I/O ── @@ -139,8 +116,23 @@ export function useFileManager({ return typeof data.content === "string" ? data.content : ""; }, []); + // ── Editor save (debounced content change) ── + + const { saveRafRef, handleContentChange } = useEditorSave({ + editingPathRef, + projectIdRef, + readProjectFile, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + setRefreshKey, + }); + // ── File select ── + const revealRequestIdRef = useRef(0); + const revealAbortRef = useRef(null); + const handleFileSelect = useCallback((path: string) => { const pid = projectIdRef.current; if (!pid) return; @@ -162,47 +154,7 @@ export function useFileManager({ .catch(() => {}); }, []); - // ── Content change (debounced save) ── - - const handleContentChange = useCallback( - (content: string) => { - const pid = projectIdRef.current; - if (!pid) return; - const path = editingPathRef.current; - if (!path) return; - - if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current); - saveRafRef.current = requestAnimationFrame(() => { - domEditSaveTimestampRef.current = Date.now(); - saveProjectFilesWithHistory({ - projectId: pid, - label: "Edit source", - kind: "source", - coalesceKey: `source:${path}`, - files: { [path]: content }, - readFile: readProjectFile, - writeFile: writeProjectFile, - recordEdit, - }) - .then(() => { - if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current); - refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1)); - }) - .catch((error) => { - trackStudioEvent("save_failure", { - source: "code_editor", - error_message: error instanceof Error ? error.message : "unknown", - }); - }); - }); - }, - [domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile], - ); - - // ── Open source for selection (click-to-source) ── - - const revealRequestIdRef = useRef(0); - const revealAbortRef = useRef(null); + // ── Click-to-source ── const openSourceForSelection = useCallback( (sourceFile: string, target: PatchTarget) => { @@ -235,16 +187,6 @@ export function useFileManager({ [editingFile?.content], ); - // ── File tree refresh ── - - const refreshFileTree = useCallback(async () => { - const pid = projectIdRef.current; - if (!pid) return; - const res = await fetch(`/api/projects/${pid}`); - const data = await res.json(); - if (data.files) setFileTree(data.files); - }, []); - // ── Upload ── const uploadProjectFiles = useCallback( @@ -289,7 +231,7 @@ export function useFileManager({ [refreshFileTree, setRefreshKey, showToast], ); - // ── File management handlers ── + // ── File CRUD ── const handleCreateFile = useCallback( async (path: string) => { @@ -320,7 +262,6 @@ export function useFileManager({ async (path: string) => { const pid = projectIdRef.current; if (!pid) return; - // Create a .gitkeep inside the folder so it appears in the tree const res = await fetch( `/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`, { @@ -371,7 +312,6 @@ export function useFileManager({ handleFileSelect(newPath); } await refreshFileTree(); - // Refresh preview — references in compositions may have been updated setRefreshKey((k) => k + 1); } else { const err = await res.json().catch(() => ({ error: "unknown" })); @@ -437,28 +377,6 @@ export function useFileManager({ [uploadProjectFiles], ); - // ── Derived state ── - - const compositions = compositionPaths; - - const assets = useMemo( - () => - fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")), - [fileTree], - ); - - const fontAssets = useMemo( - () => - assets - .filter((asset) => FONT_EXT.test(asset)) - .map((asset) => ({ - family: fontFamilyFromAssetPath(asset), - path: asset, - url: `/api/projects/${projectId}/preview/${asset}`, - })), - [assets, projectId], - ); - // ── Return ── return { diff --git a/packages/studio/src/hooks/useFileTree.ts b/packages/studio/src/hooks/useFileTree.ts new file mode 100644 index 0000000000..bb4d42227b --- /dev/null +++ b/packages/studio/src/hooks/useFileTree.ts @@ -0,0 +1,80 @@ +import { useState, useCallback, useEffect, useMemo } from "react"; +import { FONT_EXT } from "../utils/mediaTypes"; +import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; + +interface UseFileTreeOptions { + projectId: string | null; + projectIdRef: React.RefObject; +} + +export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) { + const [projectDir, setProjectDir] = useState(null); + const [fileTree, setFileTree] = useState([]); + const [compositionPaths, setCompositionPaths] = useState([]); + const [fileTreeLoaded, setFileTreeLoaded] = useState(false); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!projectId) { + setFileTreeLoaded(false); + return; + } + let cancelled = false; + setFileTreeLoaded(false); + fetch(`/api/projects/${projectId}`) + .then((r) => r.json()) + .then((data: { files?: string[]; dir?: string; compositions?: string[] }) => { + if (!cancelled && data.files) setFileTree(data.files); + if (!cancelled && data.compositions) setCompositionPaths(data.compositions); + if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null); + }) + .catch(() => { + if (!cancelled) setProjectDir(null); + }) + .finally(() => { + if (!cancelled) setFileTreeLoaded(true); + }); + return () => { + cancelled = true; + }; + }, [projectId]); + + const refreshFileTree = useCallback(async () => { + const pid = projectIdRef.current; + if (!pid) return; + const res = await fetch(`/api/projects/${pid}`); + const data = await res.json(); + if (data.files) setFileTree(data.files); + }, [projectIdRef]); + + const compositions = compositionPaths; + + const assets = useMemo( + () => + fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")), + [fileTree], + ); + + const fontAssets = useMemo( + () => + assets + .filter((asset) => FONT_EXT.test(asset)) + .map((asset) => ({ + family: fontFamilyFromAssetPath(asset), + path: asset, + url: `/api/projects/${projectId}/preview/${asset}`, + })), + [assets, projectId], + ); + + return { + projectDir, + fileTree, + setFileTree, + fileTreeLoaded, + refreshFileTree, + compositions, + assets, + fontAssets, + }; +} diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts index fcb19a7d64..35bd494270 100644 --- a/packages/studio/src/hooks/useGestureCommit.ts +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -8,6 +8,7 @@ import { simplifyGestureSamples } from "../utils/rdpSimplify"; import { usePlayerStore } from "../player"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { roundTo3 } from "../utils/rounding"; import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; // Minimal subset of the session used by gesture commit @@ -74,7 +75,8 @@ export function useGestureCommit({ } return; } - const duration = frozenSamples.length > 0 ? frozenSamples[frozenSamples.length - 1]!.time : 0; + const duration = + frozenSamples.length > 0 ? (frozenSamples[frozenSamples.length - 1]?.time ?? 0) : 0; if (frozenSamples.length <= 2) { showToast("No gesture detected — move the pointer while recording", "error"); @@ -171,8 +173,8 @@ export function useGestureCommit({ { type: "add-with-keyframes", targetSelector: selector, - position: Math.round(recStart * 1000) / 1000, - duration: Math.round(duration * 1000) / 1000, + position: roundTo3(recStart), + duration: roundTo3(duration), keyframes, }, { label: "Gesture recording (new range)", softReload: true }, @@ -183,8 +185,8 @@ export function useGestureCommit({ { type: "add-with-keyframes", targetSelector: selector, - position: Math.round(recStart * 1000) / 1000, - duration: Math.round(duration * 1000) / 1000, + position: roundTo3(recStart), + duration: roundTo3(duration), keyframes, }, { label: "Gesture recording", softReload: true }, diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index 73bfd94ca1..8dabae8e09 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -430,7 +430,7 @@ export function useGestureRecording() { r.cleanup?.(); r.cleanup = null; const frozen = r.samples.slice(); - setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1]!.time : 0); + setRecordingDuration(frozen.length > 0 ? (frozen[frozen.length - 1]?.time ?? 0) : 0); setIsRecording(false); return frozen; }, []); // No deps — uses refs only diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts new file mode 100644 index 0000000000..2289125e4d --- /dev/null +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -0,0 +1,122 @@ +import { useCallback } from "react"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { roundTo3 } from "../utils/rounding"; +import { + assignGsapTargetAutoIdIfNeeded, + ensureElementAddressable, +} from "./gsapScriptCommitHelpers"; +import type { CommitMutation, SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; + +interface GsapAnimationOpsParams { + projectIdRef: React.MutableRefObject; + activeCompPath: string | null; + commitMutation: CommitMutation; + commitMutationSafely: SafeGsapCommitMutation; + showToast: (message: string, tone?: "error" | "info") => void; +} + +export function useGsapAnimationOps({ + projectIdRef, + activeCompPath, + commitMutation, + commitMutationSafely, + showToast, +}: GsapAnimationOpsParams) { + const updateGsapMeta = useCallback( + ( + selection: DomEditSelection, + animationId: string, + updates: { duration?: number; ease?: string; position?: number }, + ) => { + commitMutationSafely( + selection, + { type: "update-meta", animationId, updates }, + { + label: "Edit GSAP animation", + coalesceKey: `gsap:${animationId}:meta`, + }, + ); + }, + [commitMutationSafely], + ); + + const deleteGsapAnimation = useCallback( + (selection: DomEditSelection, animationId: string) => { + commitMutationSafely( + selection, + { type: "delete", animationId, stripStudioEdits: true }, + { label: "Delete GSAP animation" }, + ); + }, + [commitMutationSafely], + ); + + const deleteAllForSelector = useCallback( + (selection: DomEditSelection, targetSelector: string) => { + void commitMutation( + selection, + { type: "delete-all-for-selector", targetSelector }, + { label: "Delete all animations for element" }, + ); + }, + [commitMutation], + ); + + const addGsapAnimation = useCallback( + async ( + selection: DomEditSelection, + method: "to" | "from" | "set" | "fromTo", + _currentTime?: number, + ) => { + const { selector, autoId } = ensureElementAddressable(selection); + + if (autoId) { + const pid = projectIdRef.current; + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + if (!pid) return; + const assigned = await assignGsapTargetAutoIdIfNeeded({ + projectId: pid, + targetPath, + selection, + autoId, + showToast, + }); + if (!assigned) return; + } + + const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; + const position = roundTo3(elStart); + const duration = roundTo3(elDuration); + const toDefaults: Record> = { + from: { opacity: 0 }, + to: { x: 0, y: 0, opacity: 1 }, + set: { opacity: 1 }, + fromTo: { x: 0, y: 0, opacity: 1 }, + }; + + await commitMutation( + selection, + { + type: "add", + targetSelector: selector, + method, + position, + duration: method === "set" ? undefined : duration, + ease: method === "set" ? undefined : "power2.out", + properties: toDefaults[method] ?? { opacity: 1 }, + fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, + }, + { label: `Add GSAP ${method} animation` }, + ); + }, + [activeCompPath, commitMutation, projectIdRef, showToast], + ); + + return { + updateGsapMeta, + deleteGsapAnimation, + deleteAllForSelector, + addGsapAnimation, + }; +} diff --git a/packages/studio/src/hooks/useGsapArcPathOps.ts b/packages/studio/src/hooks/useGsapArcPathOps.ts new file mode 100644 index 0000000000..785d5a726e --- /dev/null +++ b/packages/studio/src/hooks/useGsapArcPathOps.ts @@ -0,0 +1,61 @@ +import { useCallback } from "react"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; + +export function useGsapArcPathOps(commitMutationSafely: SafeGsapCommitMutation) { + const setArcPath = useCallback( + ( + selection: DomEditSelection, + animationId: string, + config: { + enabled: boolean; + autoRotate?: boolean | number; + segments?: Array<{ + curviness: number; + cp1?: { x: number; y: number }; + cp2?: { x: number; y: number }; + }>; + }, + ) => { + commitMutationSafely( + selection, + { type: "set-arc-path" as const, animationId, ...config }, + { label: config.enabled ? "Enable arc path" : "Disable arc path", softReload: true }, + ); + }, + [commitMutationSafely], + ); + + const updateArcSegment = useCallback( + ( + selection: DomEditSelection, + animationId: string, + segmentIndex: number, + update: { + curviness?: number; + cp1?: { x: number; y: number }; + cp2?: { x: number; y: number }; + }, + ) => { + commitMutationSafely( + selection, + { type: "update-arc-segment" as const, animationId, segmentIndex, ...update }, + { label: "Update arc segment", softReload: true }, + ); + }, + [commitMutationSafely], + ); + + const removeArcPath = useCallback( + (selection: DomEditSelection, animationId: string) => { + commitMutationSafely( + selection, + { type: "remove-arc-path" as const, animationId }, + { label: "Remove arc path", softReload: true }, + ); + }, + [commitMutationSafely], + ); + + return { setArcPath, updateArcSegment, removeArcPath }; +} diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts new file mode 100644 index 0000000000..1a82ecdada --- /dev/null +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -0,0 +1,242 @@ +/** + * GSAP-aware move/resize/rotation wrappers that intercept geometry commits + * for animated elements and route them through script mutation instead of + * CSS patching. Also exposes the animated-property commit, arc-path ops, + * and the thin `commitMutation` facade. + * + * Extracted from useDomEditSession to isolate the GSAP intercept routing + * from the rest of the editing orchestration. + */ +import { useCallback } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability"; +import { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits"; +import { + tryGsapDragIntercept, + tryGsapResizeIntercept, + tryGsapRotationIntercept, +} from "./gsapRuntimeBridge"; +import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit"; +import type { CommitMutation } from "./gsapScriptCommitTypes"; + +export interface UseGsapAwareEditingParams { + domEditSelection: DomEditSelection | null; + selectedGsapAnimations: GsapAnimation[]; + gsapCommitMutation: CommitMutation | null; + previewIframeRef: React.RefObject; + showToast: (message: string, tone?: "error" | "info") => void; + bumpGsapCache: () => void; + makeFetchFallback: (selection: DomEditSelection) => () => Promise; + trackGsapInteractionFailure: ( + error: unknown, + selection: DomEditSelection, + mutationType: string, + label: string, + ) => void; + // DOM fallbacks (from useDomEditCommits) + handleDomPathOffsetCommit: ( + selection: DomEditSelection, + next: { x: number; y: number }, + ) => Promise; + handleDomBoxSizeCommit: ( + selection: DomEditSelection, + next: { width: number; height: number }, + ) => Promise; + handleDomRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise; + // GSAP script commit ops (from useGsapScriptCommits) + addGsapAnimation: ( + sel: DomEditSelection, + method: "to" | "from" | "set" | "fromTo", + time?: number, + ) => Promise; + convertToKeyframes: (sel: DomEditSelection, animId: string) => void; + setArcPath: ( + sel: DomEditSelection, + animId: string, + config: { + enabled: boolean; + autoRotate?: boolean | number; + segments?: Array<{ + curviness: number; + cp1?: { x: number; y: number }; + cp2?: { x: number; y: number }; + }>; + }, + ) => void; + updateArcSegment: ( + sel: DomEditSelection, + animId: string, + segmentIndex: number, + update: { + curviness?: number; + cp1?: { x: number; y: number }; + cp2?: { x: number; y: number }; + }, + ) => void; +} + +export function useGsapAwareEditing({ + domEditSelection, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + showToast, + bumpGsapCache, + makeFetchFallback, + trackGsapInteractionFailure, + handleDomPathOffsetCommit, + handleDomBoxSizeCommit, + handleDomRotationCommit, + addGsapAnimation, + convertToKeyframes, + setArcPath, + updateArcSegment, +}: UseGsapAwareEditingParams) { + // ── GSAP-aware geometry commits ── + + const handleGsapAwarePathOffsetCommit = useCallback( + async (selection: DomEditSelection, next: { x: number; y: number }) => { + const hasGsapAnims = selectedGsapAnimations.length > 0; + if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { + showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error"); + throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE); + } + if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + try { + const handled = await tryGsapDragIntercept( + selection, + next, + selectedGsapAnimations, + previewIframeRef.current, + gsapCommitMutation, + makeFetchFallback(selection), + ); + if (handled) return; + } catch (error) { + trackGsapInteractionFailure(error, selection, "drag", "Move animated layer"); + throw error; + } + } + return handleDomPathOffsetCommit(selection, next); + }, + [ + handleDomPathOffsetCommit, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + makeFetchFallback, + trackGsapInteractionFailure, + showToast, + ], + ); + + const handleGsapAwareBoxSizeCommit = useCallback( + async (selection: DomEditSelection, next: { width: number; height: number }) => { + if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + try { + const handled = await tryGsapResizeIntercept( + selection, + next, + selectedGsapAnimations, + previewIframeRef.current, + gsapCommitMutation, + makeFetchFallback(selection), + ); + if (handled) return; + } catch (error) { + trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer"); + throw error; + } + } + return handleDomBoxSizeCommit(selection, next); + }, + [ + handleDomBoxSizeCommit, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + makeFetchFallback, + trackGsapInteractionFailure, + ], + ); + + const handleGsapAwareRotationCommit = useCallback( + async (selection: DomEditSelection, next: { angle: number }) => { + if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) { + try { + const handled = await tryGsapRotationIntercept( + selection, + next.angle, + selectedGsapAnimations, + previewIframeRef.current, + gsapCommitMutation, + makeFetchFallback(selection), + ); + if (handled) return; + } catch (error) { + trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer"); + throw error; + } + } + return handleDomRotationCommit(selection, next); + }, + [ + handleDomRotationCommit, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + makeFetchFallback, + trackGsapInteractionFailure, + ], + ); + + // ── Animated property commit ── + + const commitAnimatedProperty = useAnimatedPropertyCommit({ + selectedGsapAnimations, + gsapCommitMutation, + addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time), + convertToKeyframes: (sel, animId) => convertToKeyframes(sel, animId), + previewIframeRef, + bumpGsapCache, + }); + + // ── Arc path wrappers ── + + const handleSetArcPath = useCallback( + (animId: string, config: Parameters[2]) => { + if (!domEditSelection) return; + setArcPath(domEditSelection, animId, config); + }, + [domEditSelection, setArcPath], + ); + + const handleUpdateArcSegment = useCallback( + (animId: string, segmentIndex: number, update: Parameters[3]) => { + if (!domEditSelection) return; + updateArcSegment(domEditSelection, animId, segmentIndex, update); + }, + [domEditSelection, updateArcSegment], + ); + + // ── Thin commitMutation facade ── + + const commitMutation = useCallback( + async (mutation: Record, options: { label: string; softReload?: boolean }) => { + if (!domEditSelection) return; + await gsapCommitMutation?.(domEditSelection, mutation, options); + }, + [domEditSelection, gsapCommitMutation], + ); + + return { + handleGsapAwarePathOffsetCommit, + handleGsapAwareBoxSizeCommit, + handleGsapAwareRotationCommit, + commitAnimatedProperty, + handleSetArcPath, + handleUpdateArcSegment, + commitMutation, + }; +} diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts new file mode 100644 index 0000000000..44b6635407 --- /dev/null +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -0,0 +1,167 @@ +import { useCallback } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { executeOptimistic } from "../utils/optimisticUpdate"; +import type { KeyframeCacheEntry } from "../player/store/playerStore"; +import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit"; +import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers"; +import type { + CommitMutation, + SafeGsapCommitMutation, + TrackGsapSaveFailure, +} from "./gsapScriptCommitTypes"; + +function executeOptimisticKeyframeCacheUpdate(options: { + sourceFile: string; + elementId: string | null | undefined; + apply: (entry: KeyframeCacheEntry) => KeyframeCacheEntry; + persist: () => Promise; +}): Promise { + return executeOptimistic({ + apply: () => { + const prev = readKeyframeSnapshot(options.sourceFile, options.elementId); + if (prev) writeKeyframeCache(options.sourceFile, options.elementId, options.apply(prev)); + return prev; + }, + persist: options.persist, + rollback: (prev) => { + writeKeyframeCache(options.sourceFile, options.elementId, prev); + }, + }); +} + +interface GsapKeyframeOpsParams { + activeCompPath: string | null; + commitMutation: CommitMutation; + commitMutationSafely: SafeGsapCommitMutation; + trackGsapSaveFailure: TrackGsapSaveFailure; +} + +export function useGsapKeyframeOps({ + activeCompPath, + commitMutation, + commitMutationSafely, + trackGsapSaveFailure, +}: GsapKeyframeOpsParams) { + const addKeyframe = useCallback( + ( + selection: DomEditSelection, + animationId: string, + percentage: number, + property: string, + value: number | string, + ) => { + const sourceFile = selection.sourceFile || activeCompPath || "index.html"; + const mutation = { + type: "add-keyframe", + animationId, + percentage, + properties: { [property]: value }, + }; + void executeOptimisticKeyframeCacheUpdate({ + sourceFile, + elementId: selection.id, + apply: (prev) => ({ + ...prev, + keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort( + (a, b) => a.percentage - b.percentage, + ), + }), + persist: () => + commitMutation(selection, mutation, { + label: `Add keyframe at ${percentage}%`, + softReload: true, + }), + }).catch((error) => { + trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`); + }); + }, + [activeCompPath, commitMutation, trackGsapSaveFailure], + ); + + const addKeyframeBatch = useCallback( + ( + selection: DomEditSelection, + animationId: string, + percentage: number, + properties: Record, + ) => { + return commitMutation( + selection, + { type: "add-keyframe", animationId, percentage, properties }, + { label: `Add keyframe at ${percentage}%`, softReload: true }, + ); + }, + [commitMutation], + ); + + const removeKeyframe = useCallback( + (selection: DomEditSelection, animationId: string, percentage: number) => { + const sourceFile = selection.sourceFile || activeCompPath || "index.html"; + const mutation = { type: "remove-keyframe", animationId, percentage }; + void executeOptimisticKeyframeCacheUpdate({ + sourceFile, + elementId: selection.id, + apply: (prev) => ({ + ...prev, + keyframes: prev.keyframes.filter( + (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2, + ), + }), + persist: () => + commitMutation(selection, mutation, { + label: `Remove keyframe at ${percentage}%`, + softReload: true, + }), + }).catch((error) => { + trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`); + }); + }, + [activeCompPath, commitMutation, trackGsapSaveFailure], + ); + + const convertToKeyframes = useCallback( + ( + selection: DomEditSelection, + animationId: string, + resolvedFromValues?: Record, + ) => { + return commitMutation( + selection, + { type: "convert-to-keyframes", animationId, resolvedFromValues }, + { label: "Convert to keyframes" }, + ); + }, + [commitMutation], + ); + + const removeAllKeyframes = useCallback( + (selection: DomEditSelection, animationId: string) => { + commitMutationSafely( + selection, + { type: "remove-all-keyframes", animationId }, + { label: "Remove all keyframes", softReload: true }, + ); + }, + [commitMutationSafely], + ); + + const commitKeyframeAtTime = useCallback( + ( + selection: DomEditSelection, + absoluteTime: number, + animations: GsapAnimation[], + properties: Record, + ) => commitKeyframeAtTimeImpl(selection, absoluteTime, animations, properties, commitMutation), + [commitMutation], + ); + + return { + addKeyframe, + addKeyframeBatch, + removeKeyframe, + convertToKeyframes, + removeAllKeyframes, + commitKeyframeAtTime, + }; +} diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.ts new file mode 100644 index 0000000000..26f00e2a28 --- /dev/null +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers"; +import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes"; + +const DEBOUNCE_MS = 150; + +export function useGsapPropertyDebounce(commitMutationSafely: SafeGsapCommitMutation) { + const pendingPropertyEditRef = useRef<{ + selection: DomEditSelection; + animationId: string; + property: string; + value: number | string; + } | null>(null); + const debounceTimerRef = useRef | null>(null); + + const flushPendingPropertyEdit = useCallback(() => { + const pending = pendingPropertyEditRef.current; + if (!pending) return; + pendingPropertyEditRef.current = null; + const { selection, animationId, property, value } = pending; + commitMutationSafely( + selection, + { type: "update-property", animationId, property, value }, + { + label: `Edit GSAP ${property}`, + coalesceKey: `gsap:${animationId}:${property}`, + softReload: true, + }, + ); + }, [commitMutationSafely]); + + const updateGsapProperty = useCallback( + ( + selection: DomEditSelection, + animationId: string, + property: string, + value: number | string, + ) => { + pendingPropertyEditRef.current = { selection, animationId, property, value }; + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = setTimeout(flushPendingPropertyEdit, DEBOUNCE_MS); + }, + [flushPendingPropertyEdit], + ); + + useEffect(() => { + return () => { + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + flushPendingPropertyEdit(); + }; + }, [flushPendingPropertyEdit]); + + const addGsapProperty = useCallback( + (selection: DomEditSelection, animationId: string, property: string) => { + let defaultValue = PROPERTY_DEFAULTS[property] ?? 0; + const el = selection.element; + if (property === "width" || property === "height") { + const rect = el.getBoundingClientRect(); + defaultValue = Math.round(property === "width" ? rect.width : rect.height); + } else if (property === "opacity" || property === "autoAlpha") { + const cs = el.ownerDocument.defaultView?.getComputedStyle(el); + defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1; + } + commitMutationSafely( + selection, + { type: "add-property", animationId, property, defaultValue }, + { label: `Add GSAP ${property}` }, + ); + }, + [commitMutationSafely], + ); + + const removeGsapProperty = useCallback( + (selection: DomEditSelection, animationId: string, property: string) => { + commitMutationSafely( + selection, + { type: "remove-property", animationId, property }, + { label: `Remove GSAP ${property}` }, + ); + }, + [commitMutationSafely], + ); + + const updateGsapFromProperty = useCallback( + ( + selection: DomEditSelection, + animationId: string, + property: string, + value: number | string, + ) => { + commitMutationSafely( + selection, + { type: "update-from-property", animationId, property, value }, + { + label: `Edit GSAP from-${property}`, + coalesceKey: `gsap:${animationId}:from:${property}`, + }, + ); + }, + [commitMutationSafely], + ); + + const addGsapFromProperty = useCallback( + (selection: DomEditSelection, animationId: string, property: string) => { + const defaultValue = PROPERTY_DEFAULTS[property] ?? 0; + commitMutationSafely( + selection, + { type: "add-from-property", animationId, property, defaultValue }, + { label: `Add GSAP from-${property}` }, + ); + }, + [commitMutationSafely], + ); + + const removeGsapFromProperty = useCallback( + (selection: DomEditSelection, animationId: string, property: string) => { + commitMutationSafely( + selection, + { type: "remove-from-property", animationId, property }, + { label: `Remove GSAP from-${property}` }, + ); + }, + [commitMutationSafely], + ); + + return { + updateGsapProperty, + addGsapProperty, + removeGsapProperty, + updateGsapFromProperty, + addGsapFromProperty, + removeGsapFromProperty, + }; +} diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 13136b31e7..9478d9e843 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -1,38 +1,26 @@ -import { useCallback, useEffect, useRef } from "react"; -import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser"; +import { useCallback } from "react"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import type { EditHistoryKind } from "../utils/editHistory"; import { applySoftReload } from "../utils/gsapSoftReload"; -import { executeOptimistic } from "../utils/optimisticUpdate"; -import type { KeyframeCacheEntry } from "../player/store/playerStore"; -import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit"; -import { - updateKeyframeCacheFromParsed, - readKeyframeSnapshot, - writeKeyframeCache, -} from "./gsapKeyframeCacheHelpers"; -import { - useGsapSaveFailureTelemetry, - useSafeGsapCommitMutation, -} from "./useSafeGsapCommitMutation"; +import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; import { GsapMutationHttpError, - assignGsapTargetAutoIdIfNeeded, - ensureElementAddressable, formatGsapMutationRejectionToast, - PROPERTY_DEFAULTS, readJsonResponseBody, } from "./gsapScriptCommitHelpers"; - -interface MutationResult { - ok: boolean; - changed?: boolean; - parsed?: ParsedGsap; - before?: string; - after?: string; - scriptText?: string; -} +import type { + CommitMutationOptions, + GsapScriptCommitsParams, + MutationResult, +} from "./gsapScriptCommitTypes"; +import { useGsapAnimationOps } from "./useGsapAnimationOps"; +import { useGsapArcPathOps } from "./useGsapArcPathOps"; +import { useGsapKeyframeOps } from "./useGsapKeyframeOps"; +import { useGsapPropertyDebounce } from "./useGsapPropertyDebounce"; +import { + useGsapSaveFailureTelemetry, + useSafeGsapCommitMutation, +} from "./useSafeGsapCommitMutation"; async function mutateGsapScript( projectId: string, @@ -47,554 +35,54 @@ async function mutateGsapScript( body: JSON.stringify(mutation), }, ); - if (!res.ok) { - throw new GsapMutationHttpError(res.status, await readJsonResponseBody(res)); - } + if (!res.ok) throw new GsapMutationHttpError(res.status, await readJsonResponseBody(res)); const result = (await res.json()) as MutationResult; - if (!result.ok) { - throw new Error(`Failed to update GSAP in ${sourceFile}`); - } + if (!result.ok) throw new Error(`Failed to update GSAP in ${sourceFile}`); return result; } -function executeOptimisticKeyframeCacheUpdate(options: { - sourceFile: string; - elementId: string | null | undefined; - apply: (entry: KeyframeCacheEntry) => KeyframeCacheEntry; - persist: () => Promise; -}): Promise { - return executeOptimistic({ - apply: () => { - const prev = readKeyframeSnapshot(options.sourceFile, options.elementId); - if (prev) writeKeyframeCache(options.sourceFile, options.elementId, options.apply(prev)); - return prev; - }, - persist: options.persist, - rollback: (prev) => { - writeKeyframeCache(options.sourceFile, options.elementId, prev); - }, - }); -} - -interface GsapScriptCommitsParams { - projectIdRef: React.MutableRefObject; - activeCompPath: string | null; - previewIframeRef: React.RefObject; - editHistory: { - recordEdit: (entry: { - label: string; - kind: EditHistoryKind; - coalesceKey?: string; - files: Record; - }) => Promise; - }; - domEditSaveTimestampRef: React.MutableRefObject; - reloadPreview: () => void; - onCacheInvalidate: () => void; - onFileContentChanged?: (path: string, content: string) => void; - showToast: (message: string, tone?: "error" | "info") => void; -} -const DEBOUNCE_MS = 150; - +// oxfmt-ignore // fallow-ignore-next-line complexity -export function useGsapScriptCommits({ - projectIdRef, - activeCompPath, - previewIframeRef, - editHistory, - domEditSaveTimestampRef, - reloadPreview, - onCacheInvalidate, - onFileContentChanged, - showToast, -}: GsapScriptCommitsParams) { - const pendingPropertyEditRef = useRef<{ - selection: DomEditSelection; - animationId: string; - property: string; - value: number | string; - } | null>(null); - const debounceTimerRef = useRef | null>(null); - /** Send a mutation and record the edit in undo history. */ - const commitMutation = useCallback( - // fallow-ignore-next-line complexity - async ( - selection: DomEditSelection, - mutation: Record, - options: { - label: string; - coalesceKey?: string; - softReload?: boolean; - skipReload?: boolean; - beforeReload?: () => void; - }, - ) => { - const pid = projectIdRef.current; - if (!pid) return; - const unsafeFields = findUnsafeMutationValues(mutation); - if (unsafeFields.length > 0) { - showToast?.( - "Couldn't read element layout — try again at a different playhead time", - "error", - ); - if (options.skipReload) return; - throw new Error( - `Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`, - ); - } - const targetPath = selection.sourceFile || activeCompPath || "index.html"; - let result: MutationResult; - try { - result = await mutateGsapScript(pid, targetPath, mutation); - } catch (error) { - if (error instanceof GsapMutationHttpError) { - showToast?.(formatGsapMutationRejectionToast(error), "error"); - } - if (options.skipReload) return; - throw error; - } - - if (result.changed === false) { - if (options.skipReload) return; - return; - } - - domEditSaveTimestampRef.current = Date.now(); - - if (result.before != null && result.after != null) { - await editHistory.recordEdit({ - label: options.label, - kind: "manual", - coalesceKey: options.coalesceKey, - files: { [targetPath]: { before: result.before, after: result.after } }, - }); - } - - if (result.after != null) { - onFileContentChanged?.(targetPath, result.after); - } - +export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast }: GsapScriptCommitsParams) { + const commitMutation = useCallback(async (selection: DomEditSelection, mutation: Record, options: CommitMutationOptions) => { + const pid = projectIdRef.current; + if (!pid) return; + const unsafeFields = findUnsafeMutationValues(mutation); + if (unsafeFields.length > 0) { + showToast?.("Couldn't read element layout — try again at a different playhead time", "error"); if (options.skipReload) return; - - // Write the keyframe cache immediately from the parsed response - // (synchronous — the timeline diamonds appear on the next render). - if (result.parsed?.animations) { - updateKeyframeCacheFromParsed( - result.parsed.animations, - targetPath, - selection.id ?? undefined, - mutation, - ); - } - - options.beforeReload?.(); - - if (options.softReload && result.scriptText) { - if (!applySoftReload(previewIframeRef.current, result.scriptText)) { - reloadPreview(); - } - } else { - reloadPreview(); - } - - // Bump the cache version AFTER reload so the async re-fetch in - // useGsapAnimationsForElement reads the post-reload script, not - // the stale pre-reload version that would overwrite fresh data. - onCacheInvalidate(); - }, - [ - projectIdRef, - activeCompPath, - previewIframeRef, - editHistory, - domEditSaveTimestampRef, - reloadPreview, - onCacheInvalidate, - onFileContentChanged, - showToast, - ], - ); - + throw new Error(`Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`); + } + const targetPath = selection.sourceFile || activeCompPath || "index.html"; + let result: MutationResult; + try { + result = await mutateGsapScript(pid, targetPath, mutation); + } catch (error) { + if (error instanceof GsapMutationHttpError) showToast?.(formatGsapMutationRejectionToast(error), "error"); + if (options.skipReload) return; + throw error; + } + if (result.changed === false) return; + domEditSaveTimestampRef.current = Date.now(); + if (result.before != null && result.after != null) { + await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } }); + } + if (result.after != null) onFileContentChanged?.(targetPath, result.after); + if (options.skipReload) return; + if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation); + options.beforeReload?.(); + if (options.softReload && result.scriptText) { + if (!applySoftReload(previewIframeRef.current, result.scriptText)) reloadPreview(); + } else { + reloadPreview(); + } + onCacheInvalidate(); + }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast]); const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath); - const commitMutationSafely = useSafeGsapCommitMutation( - commitMutation, - trackGsapSaveFailure, - showToast, - ); - - const flushPendingPropertyEdit = useCallback(() => { - const pending = pendingPropertyEditRef.current; - if (!pending) return; - pendingPropertyEditRef.current = null; - const { selection, animationId, property, value } = pending; - commitMutationSafely( - selection, - { type: "update-property", animationId, property, value }, - { - label: `Edit GSAP ${property}`, - coalesceKey: `gsap:${animationId}:${property}`, - softReload: true, - }, - ); - }, [commitMutationSafely]); - - const updateGsapProperty = useCallback( - ( - selection: DomEditSelection, - animationId: string, - property: string, - value: number | string, - ) => { - pendingPropertyEditRef.current = { selection, animationId, property, value }; - if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = setTimeout(flushPendingPropertyEdit, DEBOUNCE_MS); - }, - [flushPendingPropertyEdit], - ); - useEffect(() => { - return () => { - if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - flushPendingPropertyEdit(); - }; - }, [flushPendingPropertyEdit]); - - const updateGsapMeta = useCallback( - ( - selection: DomEditSelection, - animationId: string, - updates: { duration?: number; ease?: string; position?: number }, - ) => { - commitMutationSafely( - selection, - { type: "update-meta", animationId, updates }, - { - label: "Edit GSAP animation", - coalesceKey: `gsap:${animationId}:meta`, - }, - ); - }, - [commitMutationSafely], - ); - const deleteGsapAnimation = useCallback( - (selection: DomEditSelection, animationId: string) => { - commitMutationSafely( - selection, - { type: "delete", animationId, stripStudioEdits: true }, - { label: "Delete GSAP animation" }, - ); - }, - [commitMutationSafely], - ); - const deleteAllForSelector = useCallback( - (selection: DomEditSelection, targetSelector: string) => { - void commitMutation( - selection, - { type: "delete-all-for-selector", targetSelector }, - { label: "Delete all animations for element" }, - ); - }, - [commitMutation], - ); - const addGsapAnimation = useCallback( - // fallow-ignore-next-line complexity - async ( - selection: DomEditSelection, - method: "to" | "from" | "set" | "fromTo", - _currentTime?: number, - ) => { - const { selector, autoId } = ensureElementAddressable(selection); - - if (autoId) { - const pid = projectIdRef.current; - const targetPath = selection.sourceFile || activeCompPath || "index.html"; - if (!pid) return; - const assigned = await assignGsapTargetAutoIdIfNeeded({ - projectId: pid, - targetPath, - selection, - autoId, - showToast, - }); - if (!assigned) return; - } - - const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; - const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; - const position = Math.round(elStart * 1000) / 1000; - const duration = Math.round(elDuration * 1000) / 1000; - const toDefaults: Record> = { - from: { opacity: 0 }, - to: { x: 0, y: 0, opacity: 1 }, - set: { opacity: 1 }, - fromTo: { x: 0, y: 0, opacity: 1 }, - }; - - await commitMutation( - selection, - { - type: "add", - targetSelector: selector, - method, - position, - duration: method === "set" ? undefined : duration, - ease: method === "set" ? undefined : "power2.out", - properties: toDefaults[method] ?? { opacity: 1 }, - fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, - }, - { label: `Add GSAP ${method} animation` }, - ); - }, - [commitMutation, projectIdRef, activeCompPath, showToast], - ); - const addGsapProperty = useCallback( - // fallow-ignore-next-line complexity - (selection: DomEditSelection, animationId: string, property: string) => { - let defaultValue = PROPERTY_DEFAULTS[property] ?? 0; - const el = selection.element; - if (property === "width" || property === "height") { - const rect = el.getBoundingClientRect(); - defaultValue = Math.round(property === "width" ? rect.width : rect.height); - } else if (property === "opacity" || property === "autoAlpha") { - const cs = el.ownerDocument.defaultView?.getComputedStyle(el); - defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1; - } - commitMutationSafely( - selection, - { type: "add-property", animationId, property, defaultValue }, - { label: `Add GSAP ${property}` }, - ); - }, - [commitMutationSafely], - ); - const removeGsapProperty = useCallback( - (selection: DomEditSelection, animationId: string, property: string) => { - commitMutationSafely( - selection, - { type: "remove-property", animationId, property }, - { label: `Remove GSAP ${property}` }, - ); - }, - [commitMutationSafely], - ); - const updateGsapFromProperty = useCallback( - ( - selection: DomEditSelection, - animationId: string, - property: string, - value: number | string, - ) => { - commitMutationSafely( - selection, - { type: "update-from-property", animationId, property, value }, - { - label: `Edit GSAP from-${property}`, - coalesceKey: `gsap:${animationId}:from:${property}`, - }, - ); - }, - [commitMutationSafely], - ); - const addGsapFromProperty = useCallback( - (selection: DomEditSelection, animationId: string, property: string) => { - const defaultValue = PROPERTY_DEFAULTS[property] ?? 0; - commitMutationSafely( - selection, - { type: "add-from-property", animationId, property, defaultValue }, - { label: `Add GSAP from-${property}` }, - ); - }, - [commitMutationSafely], - ); - const removeGsapFromProperty = useCallback( - (selection: DomEditSelection, animationId: string, property: string) => { - commitMutationSafely( - selection, - { type: "remove-from-property", animationId, property }, - { label: `Remove GSAP from-${property}` }, - ); - }, - [commitMutationSafely], - ); - const addKeyframe = useCallback( - ( - selection: DomEditSelection, - animationId: string, - percentage: number, - property: string, - value: number | string, - ) => { - const sf = selection.sourceFile || activeCompPath || "index.html"; - const elementId = selection.id; - const mutation = { - type: "add-keyframe", - animationId, - percentage, - properties: { [property]: value }, - }; - void executeOptimisticKeyframeCacheUpdate({ - sourceFile: sf, - elementId, - apply: (prev) => ({ - ...prev, - keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort( - (a, b) => a.percentage - b.percentage, - ), - }), - persist: () => - commitMutation(selection, mutation, { - label: `Add keyframe at ${percentage}%`, - softReload: true, - }), - }).catch((error) => { - trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`); - }); - }, - [commitMutation, activeCompPath, trackGsapSaveFailure], - ); - const addKeyframeBatch = useCallback( - ( - selection: DomEditSelection, - animationId: string, - percentage: number, - properties: Record, - ) => { - return commitMutation( - selection, - { type: "add-keyframe", animationId, percentage, properties }, - { label: `Add keyframe at ${percentage}%`, softReload: true }, - ); - }, - [commitMutation], - ); - const removeKeyframe = useCallback( - (selection: DomEditSelection, animationId: string, percentage: number) => { - const sf = selection.sourceFile || activeCompPath || "index.html"; - const elementId = selection.id; - const mutation = { type: "remove-keyframe", animationId, percentage }; - void executeOptimisticKeyframeCacheUpdate({ - sourceFile: sf, - elementId, - apply: (prev) => ({ - ...prev, - keyframes: prev.keyframes.filter( - (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2, - ), - }), - persist: () => - commitMutation(selection, mutation, { - label: `Remove keyframe at ${percentage}%`, - softReload: true, - }), - }).catch((error) => { - trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`); - }); - }, - [commitMutation, activeCompPath, trackGsapSaveFailure], - ); - const convertToKeyframes = useCallback( - ( - selection: DomEditSelection, - animationId: string, - resolvedFromValues?: Record, - ) => { - return commitMutation( - selection, - { type: "convert-to-keyframes", animationId, resolvedFromValues }, - { label: "Convert to keyframes" }, - ); - }, - [commitMutation], - ); - const removeAllKeyframes = useCallback( - (selection: DomEditSelection, animationId: string) => { - commitMutationSafely( - selection, - { type: "remove-all-keyframes", animationId }, - { label: "Remove all keyframes", softReload: true }, - ); - }, - [commitMutationSafely], - ); - const setArcPath = useCallback( - ( - selection: DomEditSelection, - animationId: string, - config: { - enabled: boolean; - autoRotate?: boolean | number; - segments?: Array<{ - curviness: number; - cp1?: { x: number; y: number }; - cp2?: { x: number; y: number }; - }>; - }, - ) => { - commitMutationSafely( - selection, - { type: "set-arc-path" as const, animationId, ...config }, - { label: config.enabled ? "Enable arc path" : "Disable arc path", softReload: true }, - ); - }, - [commitMutationSafely], - ); - const updateArcSegment = useCallback( - ( - selection: DomEditSelection, - animationId: string, - segmentIndex: number, - update: { - curviness?: number; - cp1?: { x: number; y: number }; - cp2?: { x: number; y: number }; - }, - ) => { - commitMutationSafely( - selection, - { type: "update-arc-segment" as const, animationId, segmentIndex, ...update }, - { label: "Update arc segment", softReload: true }, - ); - }, - [commitMutationSafely], - ); - const removeArcPath = useCallback( - (selection: DomEditSelection, animationId: string) => { - commitMutationSafely( - selection, - { type: "remove-arc-path" as const, animationId }, - { label: "Remove arc path", softReload: true }, - ); - }, - [commitMutationSafely], - ); - const commitKeyframeAtTime = useCallback( - ( - selection: DomEditSelection, - absoluteTime: number, - animations: GsapAnimation[], - properties: Record, - ) => commitKeyframeAtTimeImpl(selection, absoluteTime, animations, properties, commitMutation), - [commitMutation], - ); - return { - commitMutation, - updateGsapProperty, - updateGsapMeta, - deleteGsapAnimation, - deleteAllForSelector, - addGsapAnimation, - addGsapProperty, - removeGsapProperty, - updateGsapFromProperty, - addGsapFromProperty, - removeGsapFromProperty, - addKeyframe, - addKeyframeBatch, - removeKeyframe, - convertToKeyframes, - removeAllKeyframes, - setArcPath, - updateArcSegment, - removeArcPath, - commitKeyframeAtTime, - }; + const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast); + const propertyOps = useGsapPropertyDebounce(commitMutationSafely); + const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast }); + const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure }); + const arcPathOps = useGsapArcPathOps(commitMutationSafely); + return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps }; } diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 17103e6221..7f1d1fb82b 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -3,6 +3,7 @@ import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/ import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; import { usePlayerStore } from "../player/store/playerStore"; import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge"; +import { PROPERTY_DEFAULTS, toAbsoluteTime } from "./gsapShared"; function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] { const byPct = new Map(); @@ -18,16 +19,6 @@ function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercenta return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage); } -const PROPERTY_DEFAULTS: Record = { - opacity: 1, - x: 0, - y: 0, - scale: 1, - scaleX: 1, - scaleY: 1, - rotation: 0, -}; - function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null { if (anim.method === "set") { return { @@ -133,12 +124,18 @@ export function useGsapAnimationsForElement( const [multipleTimelines, setMultipleTimelines] = useState(false); const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false); const lastFetchKeyRef = useRef(""); + const retryTimerRef = useRef | null>(null); useEffect(() => { const fetchKey = `${projectId}:${sourceFile}:${version}`; if (fetchKey === lastFetchKeyRef.current) return; lastFetchKeyRef.current = fetchKey; + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + if (!projectId) { setAllAnimations([]); setMultipleTimelines(false); @@ -158,26 +155,30 @@ export function useGsapAnimationsForElement( setAllAnimations(parsed.animations); setMultipleTimelines(parsed.multipleTimelines === true); setUnsupportedTimelinePattern(parsed.unsupportedTimelinePattern === true); + + // Retry once if initial fetch returned 0 animations — handles + // cold-load race where the sourceFile isn't resolved yet. + if (parsed.animations.length === 0 && target) { + retryTimerRef.current = setTimeout(() => { + if (cancelled) return; + fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => { + if (cancelled) return; + if (retryParsed && retryParsed.animations.length > 0) { + setAllAnimations(retryParsed.animations); + } + }); + }, 800); + } }); return () => { cancelled = true; + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } }; - }, [projectId, sourceFile, version]); - - // Retry fetch if we have a target but no animations — handles cold-load race - // where the initial fetch runs before the drilled-down sourceFile is resolved - useEffect(() => { - if (!projectId || !target || allAnimations.length > 0) return; - const timer = setTimeout(() => { - fetchParsedAnimations(projectId, sourceFile).then((parsed) => { - if (parsed && parsed.animations.length > 0) { - setAllAnimations(parsed.animations); - } - }); - }, 800); - return () => clearTimeout(timer); - }, [projectId, sourceFile, target, allAnimations.length]); + }, [projectId, sourceFile, version, target]); const targetId = target?.id ?? null; const targetSelector = target?.selector ?? null; @@ -281,7 +282,7 @@ export function useGsapAnimationsForElement( anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); const tweenDur = anim.duration ?? elDuration; for (const k of kf.keyframes) { - const absTime = tweenPos + (k.percentage / 100) * tweenDur; + const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage); const clipPct = elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 @@ -379,7 +380,7 @@ export function usePopulateKeyframeCacheForFile( const elStart = timelineEl?.start ?? 0; const elDuration = timelineEl?.duration ?? 1; const clipKeyframes = kfData.keyframes.map((kf) => { - const absTime = tweenPos + (kf.percentage / 100) * tweenDur; + const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage); const clipPct = elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 diff --git a/packages/studio/src/hooks/useLintModal.ts b/packages/studio/src/hooks/useLintModal.ts index c7c0df56e9..399fb75957 100644 --- a/packages/studio/src/hooks/useLintModal.ts +++ b/packages/studio/src/hooks/useLintModal.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import type { LintFinding } from "../components/LintModal"; +import { usePlayerStore } from "../player"; interface RawFinding { severity?: string; @@ -95,6 +96,12 @@ export function useLintModal(projectId: string | null, refreshKey?: number) { const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]); const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]); + // Sync lint findings directly to the player store — eliminates the + // mirroring useEffect that was previously in App.tsx. + useEffect(() => { + usePlayerStore.getState().setLintFindingsByElement(findingsByElement); + }, [findingsByElement]); + return { lintModal, linting, diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 699ebd4d38..dc237721cc 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -23,7 +23,8 @@ import { getTimelineCanvasHeight, shouldShowTimelineShortcutHint, } from "./timelineLayout"; -import type { TimelineEditCallbacks, TimelineDropCallbacks } from "./timelineCallbacks"; +import type { TimelineDropCallbacks } from "./timelineCallbacks"; +import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { @@ -40,7 +41,7 @@ export { getDefaultDroppedTrack, } from "./timelineLayout"; -interface TimelineProps extends TimelineEditCallbacks, TimelineDropCallbacks { +interface TimelineProps extends TimelineDropCallbacks { onSeek?: (time: number) => void; onDrillDown?: (element: TimelineElement) => void; renderClipContent?: ( @@ -62,20 +63,20 @@ export const Timeline = memo(function Timeline({ onAssetDrop, onBlockDrop, onDeleteElement: _onDeleteElement, - onMoveElement, - onResizeElement, - onBlockedEditAttempt, - onSplitElement, - onRazorSplit, - onRazorSplitAll, onSelectElement, - onDeleteKeyframe, - onDeleteAllKeyframes, - onChangeKeyframeEase, - onMoveKeyframe, - onToggleKeyframeAtPlayhead, theme: themeOverrides, }: TimelineProps = {}) { + const { + onMoveElement, + onResizeElement, + onBlockedEditAttempt, + onSplitElement, + onRazorSplitAll, + onDeleteKeyframe, + onDeleteAllKeyframes, + onChangeKeyframeEase, + onMoveKeyframe, + } = useTimelineEditContext(); const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); const elements = usePlayerStore((s) => s.elements); const duration = usePlayerStore((s) => s.duration); @@ -423,8 +424,6 @@ export const Timeline = memo(function Timeline({ renderClipContent={renderClipContent} renderClipOverlay={renderClipOverlay} playheadRef={playheadRef} - onResizeElement={onResizeElement} - onMoveElement={onMoveElement} onDrillDown={onDrillDown} onSelectElement={onSelectElement} setHoveredClip={setHoveredClip} @@ -440,7 +439,6 @@ export const Timeline = memo(function Timeline({ keyframeCache={keyframeCache} selectedKeyframes={selectedKeyframes} currentTime={currentTime} - onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead} onClickKeyframe={(el, pct) => { usePlayerStore.getState().clearSelectedKeyframes(); const elKey = el.key ?? el.id; @@ -483,8 +481,6 @@ export const Timeline = memo(function Timeline({ onSelectElement?.(el); setClipContextMenu({ x: e.clientX, y: e.clientY, element: el }); }} - onRazorSplit={onRazorSplit} - onRazorSplitAll={onRazorSplitAll} /> {activeTool === "razor" && razorGuideX !== null && (
s.lintFindingsByElement.get(element.key ?? element.id)); @@ -66,8 +67,6 @@ interface TimelineCanvasProps { ) => ReactNode; renderClipOverlay?: (element: TimelineElement) => ReactNode; playheadRef: React.RefObject; - onResizeElement?: unknown; - onMoveElement?: unknown; onDrillDown?: (element: TimelineElement) => void; onSelectElement?: (element: TimelineElement | null) => void; setHoveredClip: (key: string | null) => void; @@ -92,9 +91,6 @@ interface TimelineCanvasProps { 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; - onRazorSplit?: (element: TimelineElement, splitTime: number) => void; - onRazorSplitAll?: (splitTime: number) => void; } export const TimelineCanvas = memo(function TimelineCanvas({ @@ -122,8 +118,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({ renderClipContent, renderClipOverlay, playheadRef, - onResizeElement, - onMoveElement, onDrillDown, onSelectElement, setHoveredClip, @@ -144,10 +138,9 @@ export const TimelineCanvas = memo(function TimelineCanvas({ onDragKeyframe, onContextMenuKeyframe, onContextMenuClip, - onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead, - onRazorSplit, - onRazorSplitAll, }: TimelineCanvasProps) { + const { onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll } = + useTimelineEditContext(); const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = draggedClip?.started === true && draggedElement diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index 1c3ef35938..773f3ddaf8 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -1,10 +1,7 @@ import { formatTime } from "../lib/time"; +import { roundToCenti } from "../../utils/rounding"; -const TIME_PRECISION = 100; - -function roundToCentiseconds(value: number): number { - return Math.round(value * TIME_PRECISION) / TIME_PRECISION; -} +const roundToCentiseconds = roundToCenti; function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); diff --git a/packages/studio/src/player/components/timelineUtils.ts b/packages/studio/src/player/components/timelineUtils.ts deleted file mode 100644 index 4830d81db7..0000000000 --- a/packages/studio/src/player/components/timelineUtils.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { formatTime } from "../lib/time"; -import type { ZoomMode } from "../store/playerStore"; - -/* ── Layout constants ─────────────────────────────────────────────── */ -export const GUTTER = 32; -export const TRACK_H = 72; -export const RULER_H = 24; -export const CLIP_Y = 3; -export const CLIP_HANDLE_W = 18; -export const TIMELINE_SCROLL_BUFFER = 20; - -/* ── Tick Generation ────────────────────────────────────────────────── */ -function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number { - const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; - if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) { - const targetMajorPx = 128; - return ( - zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600 - ); - } - const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60]; - const target = duration / 6; - return durationIntervals.find((interval) => interval >= target) ?? 60; -} - -function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number { - let interval = majorInterval / 2; - if (majorInterval >= 30) interval = majorInterval / 6; - else if (majorInterval >= 15) interval = majorInterval / 3; - else if (majorInterval >= 5) interval = majorInterval / 5; - else if (majorInterval >= 1) interval = majorInterval / 4; - - if ( - Number.isFinite(pixelsPerSecond) && - (pixelsPerSecond ?? 0) > 0 && - interval * (pixelsPerSecond ?? 0) < 20 - ) { - return Math.max(0.25, majorInterval / 2); - } - return Math.max(0.25, interval); -} - -export function generateTicks( - duration: number, - pixelsPerSecond?: number, -): { major: number[]; minor: number[] } { - if (duration <= 0 || !Number.isFinite(duration) || duration > 7200) - return { major: [], minor: [] }; - const majorInterval = getMajorTickInterval(duration, pixelsPerSecond); - const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond); - const major: number[] = []; - const minor: number[] = []; - const maxTicks = 2000; - for ( - let t = 0; - t <= duration + 0.001 && major.length + minor.length < maxTicks; - t += minorInterval - ) { - const rounded = Math.round(t * 100) / 100; - const isMajor = - Math.abs(rounded % majorInterval) < 0.01 || - Math.abs((rounded % majorInterval) - majorInterval) < 0.01; - if (isMajor) major.push(rounded); - else minor.push(rounded); - } - return { major, minor }; -} - -export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) { - if (!Number.isFinite(time)) return "0:00"; - const safeTime = Math.max(0, time); - if (majorInterval < 1) { - const totalTenths = Math.round(safeTime * 10); - const wholeSeconds = Math.floor(totalTenths / 10); - const tenth = totalTenths % 10; - return `${formatTime(wholeSeconds)}.${tenth}`; - } - if (duration >= 3600 || safeTime >= 3600) { - const totalSeconds = Math.floor(safeTime); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - } - return formatTime(safeTime); -} - -export function shouldAutoScrollTimeline( - zoomMode: ZoomMode, - scrollWidth: number, - clientWidth: number, -): boolean { - if (zoomMode === "fit") return false; - if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false; - return scrollWidth - clientWidth > 1; -} - -export function getTimelineScrollLeftForZoomTransition( - previousZoomMode: ZoomMode | null, - nextZoomMode: ZoomMode, - currentScrollLeft: number, -): number { - if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0; - return currentScrollLeft; -} - -export function getTimelineScrollLeftForZoomAnchor(input: { - pointerX: number; - currentScrollLeft: number; - gutter: number; - currentPixelsPerSecond: number; - nextPixelsPerSecond: number; - duration: number; -}): number { - const currentPps = Math.max(0, input.currentPixelsPerSecond); - const nextPps = Math.max(0, input.nextPixelsPerSecond); - if ( - !Number.isFinite(input.pointerX) || - !Number.isFinite(input.currentScrollLeft) || - !Number.isFinite(input.duration) || - input.duration <= 0 || - currentPps <= 0 || - nextPps <= 0 - ) { - return Math.max(0, input.currentScrollLeft); - } - const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter); - const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps)); - return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX); -} - -export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number { - if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER; - return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond); -} - -export function getTimelineCanvasHeight(trackCount: number): number { - return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER; -} - -export function shouldShowTimelineShortcutHint( - scrollHeight: number, - clientHeight: number, -): boolean { - if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true; - return scrollHeight - clientHeight <= 1; -} - -export function shouldHandleTimelineDeleteKey(input: { - key: string; - metaKey?: boolean; - ctrlKey?: boolean; - altKey?: boolean; - target?: EventTarget | null; -}): boolean { - if (input.key !== "Delete" && input.key !== "Backspace") return false; - if (input.metaKey || input.ctrlKey || input.altKey) return false; - const target = - input.target && typeof input.target === "object" - ? (input.target as { - tagName?: string; - isContentEditable?: boolean; - closest?: (selector: string) => Element | null; - }) - : null; - if (target) { - const tag = target.tagName?.toLowerCase() ?? ""; - if (target.isContentEditable) return false; - if (["input", "textarea", "select"].includes(tag)) return false; - if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) { - return false; - } - } - return true; -} - -export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number { - if (trackOrder.length === 0) return 0; - if (rowIndex == null || rowIndex < 0) return trackOrder[0]; - if (rowIndex >= trackOrder.length) { - return Math.max(...trackOrder) + 1; - } - return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0; -} - -export function resolveTimelineAssetDrop( - input: { - rectLeft: number; - rectTop: number; - scrollLeft: number; - scrollTop: number; - pixelsPerSecond: number; - duration: number; - trackHeight: number; - trackOrder: number[]; - }, - clientX: number, - clientY: number, -): { start: number; track: number } { - const x = clientX - input.rectLeft + input.scrollLeft - GUTTER; - const y = clientY - input.rectTop + input.scrollTop - RULER_H; - const start = Math.max( - 0, - Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100), - ); - const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1)); - return { - start, - track: getDefaultDroppedTrack(input.trackOrder, rowIndex), - }; -} diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 194eb592e7..76799365c7 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -88,18 +88,6 @@ interface PlayerState { toggleSelectedElementId: (id: string) => void; clearSelectedElementIds: () => void; - /** Clipboard for keyframe copy/paste — stores keyframes with relative times. */ - keyframeClipboard: Array<{ - relativeTime: number; - properties: Record; - ease?: string; - }> | null; - setKeyframeClipboard: (data: PlayerState["keyframeClipboard"]) => void; - - /** Elements with expanded property rows in the timeline. */ - expandedTimelineElements: Set; - toggleExpandedElement: (id: string) => void; - /** Keyframe data per element id, populated from parsed GSAP animations. */ keyframeCache: Map; setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void; @@ -131,9 +119,6 @@ interface PlayerState { requestSeek: (time: number) => void; clearSeekRequest: () => void; - autoKeyframeEnabled: boolean; - setAutoKeyframeEnabled: (enabled: boolean) => void; - lintFindingsByElement: Map; setLintFindingsByElement: (map: Map) => void; } @@ -151,7 +136,7 @@ export const liveTime = { }, }; -export const usePlayerStore = create((set) => ({ +export const usePlayerStore = create((set, get) => ({ isPlaying: false, currentTime: 0, duration: 0, @@ -182,9 +167,6 @@ export const usePlayerStore = create((set) => ({ activeKeyframePct: null, setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }), - keyframeClipboard: null, - setKeyframeClipboard: (data) => set({ keyframeClipboard: data }), - selectedElementIds: new Set(), toggleSelectedElementId: (id: string) => set((s) => { @@ -195,15 +177,6 @@ export const usePlayerStore = create((set) => ({ }), clearSelectedElementIds: () => set({ selectedElementIds: new Set() }), - expandedTimelineElements: new Set(), - toggleExpandedElement: (id: string) => - set((s) => { - const next = new Set(s.expandedTimelineElements); - if (next.has(id)) next.delete(id); - else next.add(id); - return { expandedTimelineElements: next }; - }), - keyframeCache: new Map(), setKeyframeCache: (elementId, data) => set((s) => { @@ -217,13 +190,13 @@ export const usePlayerStore = create((set) => ({ requestSeek: (time) => set({ requestedSeekTime: time }), clearSeekRequest: () => set({ requestedSeekTime: null }), - autoKeyframeEnabled: true, - setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }), - lintFindingsByElement: new Map(), setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }), - setIsPlaying: (playing) => set({ isPlaying: playing }), + setIsPlaying: (playing) => { + if (get().isPlaying === playing) return; + set({ isPlaying: playing }); + }, setPlaybackRate: (rate) => { writeStudioUiPreferences({ playbackRate: rate }); set({ playbackRate: rate }); @@ -284,7 +257,6 @@ export const usePlayerStore = create((set) => ({ activeTool: "select", selectedKeyframes: new Set(), selectedElementIds: new Set(), - expandedTimelineElements: new Set(), keyframeCache: new Map(), }), })); diff --git a/packages/studio/src/utils/audioBeatDetection.ts b/packages/studio/src/utils/audioBeatDetection.ts deleted file mode 100644 index 158dea08a8..0000000000 --- a/packages/studio/src/utils/audioBeatDetection.ts +++ /dev/null @@ -1,58 +0,0 @@ -const WINDOW_SIZE = 1024; -const HOP_SIZE = 512; - -// fallow-ignore-next-line complexity -export async function detectBeats(audioBuffer: AudioBuffer): Promise { - const channelData = audioBuffer.getChannelData(0); - const sampleRate = audioBuffer.sampleRate; - - const energies: number[] = []; - for (let i = 0; i < channelData.length - WINDOW_SIZE; i += HOP_SIZE) { - let sum = 0; - for (let j = 0; j < WINDOW_SIZE; j++) { - const sample = channelData[i + j]!; - sum += sample * sample; - } - energies.push(sum / WINDOW_SIZE); - } - - const beats: number[] = []; - const localWindowSize = 20; - - for (let i = localWindowSize; i < energies.length - localWindowSize; i++) { - let localMean = 0; - for (let j = i - localWindowSize; j < i + localWindowSize; j++) { - localMean += energies[j]!; - } - localMean /= localWindowSize * 2; - - const threshold = localMean * 1.5; - const current = energies[i]!; - - if ( - current > threshold && - current > (energies[i - 1] ?? 0) && - current > (energies[i + 1] ?? 0) - ) { - const timeInSeconds = (i * HOP_SIZE) / sampleRate; - if (beats.length === 0 || timeInSeconds - beats[beats.length - 1]! > 0.1) { - beats.push(Math.round(timeInSeconds * 1000) / 1000); - } - } - } - - return beats; -} - -// fallow-ignore-next-line complexity -export async function detectBeatsFromUrl(url: string): Promise { - const audioContext = new AudioContext(); - try { - const response = await fetch(url); - const arrayBuffer = await response.arrayBuffer(); - const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); - return detectBeats(audioBuffer); - } finally { - await audioContext.close(); - } -} diff --git a/packages/studio/src/utils/clipboardPayload.ts b/packages/studio/src/utils/clipboardPayload.ts index 340dac468c..61e9739324 100644 --- a/packages/studio/src/utils/clipboardPayload.ts +++ b/packages/studio/src/utils/clipboardPayload.ts @@ -1,3 +1,5 @@ +import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./studioHelpers"; + const CLIPBOARD_MARKER = "hyperframes-clipboard:v1"; export interface ClipboardPayload { @@ -99,8 +101,7 @@ export function insertAsSibling( } // Fallback: insert after composition root opening tag (same as timeline clips) - const rootOpenTag = /<[^>]*data-composition-id="[^"]+"[^>]*>/i; - const rootMatch = rootOpenTag.exec(source); + const rootMatch = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source); if (rootMatch && rootMatch.index != null) { const insertAt = rootMatch.index + rootMatch[0].length; return source.slice(0, insertAt) + newHtml + source.slice(insertAt); diff --git a/packages/studio/src/utils/keyframeSnapping.test.ts b/packages/studio/src/utils/keyframeSnapping.test.ts deleted file mode 100644 index 7152c0640a..0000000000 --- a/packages/studio/src/utils/keyframeSnapping.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { computeSnapThreshold, snapKeyframe } from "./keyframeSnapping"; - -describe("snapKeyframe", () => { - test("snaps to frame boundary", () => { - const result = snapKeyframe(0.34, { fps: 30, keyframeTimes: [], threshold: 0.05 }); - expect(result.snapType).toBe("frame"); - expect(Math.abs(result.snappedTime - 1 / 3)).toBeLessThan(0.01); - }); - - test("snaps to cross-element keyframe when closest", () => { - const result = snapKeyframe(1.005, { fps: 30, keyframeTimes: [1.0], threshold: 0.05 }); - expect(result.snapType).toBe("keyframe"); - expect(result.snappedTime).toBe(1.0); - }); - - test("keyframe snap wins tie with frame at same position", () => { - const result = snapKeyframe(1.0, { fps: 30, keyframeTimes: [1.0], threshold: 0.05 }); - expect(result.snapType).toBe("keyframe"); - expect(result.snappedTime).toBe(1.0); - }); - - test("snaps to beat marker when closer than frame", () => { - const result = snapKeyframe(2.49, { - fps: 30, - keyframeTimes: [], - beatTimes: [2.5], - threshold: 0.05, - }); - expect(result.snapType).toBe("beat"); - expect(result.snappedTime).toBe(2.5); - }); - - test("disabled returns raw time", () => { - const result = snapKeyframe(1.5, { - fps: 30, - keyframeTimes: [1.5], - threshold: 0.05, - disabled: true, - }); - expect(result.snapType).toBeNull(); - expect(result.snappedTime).toBe(1.5); - }); - - test("no snap when outside threshold", () => { - const result = snapKeyframe(1.5, { - fps: 30, - keyframeTimes: [0.5], - threshold: 0.05, - }); - expect(result.snapType).toBe("frame"); - }); - - test("empty beat times is graceful", () => { - const result = snapKeyframe(0.5, { - fps: 30, - keyframeTimes: [], - beatTimes: [], - threshold: 0.05, - }); - expect(result.snapType).toBe("frame"); - }); -}); - -describe("computeSnapThreshold", () => { - test("returns threshold based on pixels per second", () => { - const threshold = computeSnapThreshold(100, 5); - expect(threshold).toBe(0.05); - }); - - test("fallback for zero pixels per second", () => { - expect(computeSnapThreshold(0)).toBe(0.1); - }); -}); diff --git a/packages/studio/src/utils/keyframeSnapping.ts b/packages/studio/src/utils/keyframeSnapping.ts deleted file mode 100644 index daa743e41b..0000000000 --- a/packages/studio/src/utils/keyframeSnapping.ts +++ /dev/null @@ -1,63 +0,0 @@ -export type SnapType = "frame" | "keyframe" | "beat" | null; - -export interface SnapResult { - snappedTime: number; - snapType: SnapType; -} - -export function snapKeyframe( - time: number, - options: { - fps: number; - keyframeTimes: number[]; - beatTimes?: number[]; - threshold: number; - disabled?: boolean; - }, -): SnapResult { - if (options.disabled) return { snappedTime: time, snapType: null }; - - const { fps, keyframeTimes, beatTimes = [], threshold } = options; - - let bestDist = threshold; - let bestTime = time; - let bestType: SnapType = null; - - // Priority: cross-element keyframes > beat markers > frame boundaries - // Higher priority snaps use strict < so they win on equal distance - if (fps > 0) { - const frameDuration = 1 / fps; - const nearestFrame = Math.round(time / frameDuration) * frameDuration; - const dist = Math.abs(time - nearestFrame); - if (dist < bestDist) { - bestDist = dist; - bestTime = nearestFrame; - bestType = "frame"; - } - } - - for (const bt of beatTimes) { - const dist = Math.abs(time - bt); - if (dist <= bestDist) { - bestDist = dist; - bestTime = bt; - bestType = "beat"; - } - } - - for (const kt of keyframeTimes) { - const dist = Math.abs(time - kt); - if (dist <= bestDist) { - bestDist = dist; - bestTime = kt; - bestType = "keyframe"; - } - } - - return { snappedTime: bestTime, snapType: bestType }; -} - -export function computeSnapThreshold(pixelsPerSecond: number, baseThresholdPx: number = 5): number { - if (pixelsPerSecond <= 0) return 0.1; - return baseThresholdPx / pixelsPerSecond; -} diff --git a/packages/studio/src/utils/rounding.ts b/packages/studio/src/utils/rounding.ts new file mode 100644 index 0000000000..d94e8f43bd --- /dev/null +++ b/packages/studio/src/utils/rounding.ts @@ -0,0 +1,9 @@ +/** Round to 3 decimal places (millisecond precision for GSAP values). */ +export function roundTo3(val: number): number { + return Math.round(val * 1000) / 1000; +} + +/** Round to 2 decimal places (centisecond precision for timeline values). */ +export function roundToCenti(val: number): number { + return Math.round(val * 100) / 100; +} diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts index 91da8232da..8537ccc58b 100644 --- a/packages/studio/src/utils/studioHelpers.ts +++ b/packages/studio/src/utils/studioHelpers.ts @@ -1,6 +1,7 @@ import type { TimelineElement } from "../player"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { TimelineAssetKind } from "./timelineAssetDrop"; +import { roundToCenti } from "./rounding"; export interface EditingFile { path: string; @@ -171,6 +172,9 @@ export function clampNumber(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } +/** Matches the opening tag of a composition root element (`data-composition-id`). */ +export const COMPOSITION_ROOT_OPEN_TAG_RE = /<[^>]*data-composition-id="[^"]+"[^>]*>/i; + export function collectHtmlIds(source: string): string[] { return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? ""); } @@ -205,7 +209,7 @@ export async function resolveDroppedAssetDuration( const raw = Number(media.duration); finalize( Number.isFinite(raw) && raw > 0 - ? Math.round(raw * 100) / 100 + ? roundToCenti(raw) : DEFAULT_TIMELINE_ASSET_DURATION[kind], ); }, diff --git a/packages/studio/src/utils/studioUrlState.ts b/packages/studio/src/utils/studioUrlState.ts index 816178e0e5..b961af5089 100644 --- a/packages/studio/src/utils/studioUrlState.ts +++ b/packages/studio/src/utils/studioUrlState.ts @@ -1,6 +1,7 @@ import type { RightPanelTab } from "./studioHelpers"; import { buildProjectHash, parseProjectHashRoute } from "./projectRouting"; import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability"; +import { roundTo3 } from "./rounding"; export interface StudioUrlSelectionState { sourceFile?: string; @@ -111,7 +112,7 @@ export function buildStudioHash(projectId: string, state: StudioUrlState): strin params.set("v", "1"); if (state.activeCompPath) params.set("comp", state.activeCompPath); if (state.currentTime != null && Number.isFinite(state.currentTime)) { - params.set("t", String(Math.max(0, Math.round(state.currentTime * 1000) / 1000))); + params.set("t", String(Math.max(0, roundTo3(state.currentTime)))); } if (state.rightPanelTab) params.set("tab", state.rightPanelTab); if (state.rightCollapsed != null) params.set("rc", state.rightCollapsed ? "1" : "0"); diff --git a/packages/studio/src/utils/timelineAssetDrop.ts b/packages/studio/src/utils/timelineAssetDrop.ts index 454078151d..8b2ff380ab 100644 --- a/packages/studio/src/utils/timelineAssetDrop.ts +++ b/packages/studio/src/utils/timelineAssetDrop.ts @@ -1,4 +1,6 @@ import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes"; +import { roundToCenti } from "./rounding"; +import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./studioHelpers"; export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset"; export const TIMELINE_BLOCK_MIME = "application/x-hyperframes-block"; @@ -51,13 +53,13 @@ export function buildTimelineFileDropPlacements( durations: number[], occupiedClips: Array<{ start: number; duration: number; track: number }> = [], ): Array<{ start: number; track: number }> { - let nextStart = Math.round(Math.max(0, placement.start) * 100) / 100; + let nextStart = roundToCenti(Math.max(0, placement.start)); const sequenceStart = nextStart; const resolvedDurations = durations.map((duration) => Number.isFinite(duration) && duration > 0 ? duration : FALLBACK_TIMELINE_FILE_DROP_DURATION, ); const sequenceEnd = resolvedDurations.reduce( - (end, duration) => Math.round((end + duration) * 100) / 100, + (end, duration) => roundToCenti(end + duration), sequenceStart, ); const overlapsDropTrack = occupiedClips.some((clip) => { @@ -72,7 +74,7 @@ export function buildTimelineFileDropPlacements( return resolvedDurations.map((duration) => { const start = nextStart; - nextStart = Math.round((nextStart + duration) * 100) / 100; + nextStart = roundToCenti(nextStart + duration); return { start, track }; }); } @@ -120,8 +122,7 @@ export function buildTimelineAssetInsertHtml(input: { } export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string { - const rootOpenTag = /<[^>]*data-composition-id="[^"]+"[^>]*>/i; - const match = rootOpenTag.exec(source); + const match = COMPOSITION_ROOT_OPEN_TAG_RE.exec(source); if (!match || match.index == null) { throw new Error("No composition root found in target source"); } diff --git a/packages/studio/src/utils/timelineInspector.test.ts b/packages/studio/src/utils/timelineInspector.test.ts deleted file mode 100644 index 54de6b4e07..0000000000 --- a/packages/studio/src/utils/timelineInspector.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Window } from "happy-dom"; -import { - canInspectTimelineElement, - getTimelineLayerVisibilityInPreview, - getTimelineElementKey, - isAudioTimelineElement, - isTimelineElementActiveAtTime, - isTimelineLayerVisibleInPreview, - shouldShowTimelineInspectorBounds, -} from "./timelineInspector"; - -function createDocument(markup: string): Document { - const window = new Window(); - window.document.body.innerHTML = markup; - return window.document; -} - -function attachVisibleBox(element: HTMLElement) { - Object.defineProperty(element, "getBoundingClientRect", { - configurable: true, - value: () => ({ - bottom: 34, - height: 24, - left: 10, - right: 90, - top: 10, - width: 80, - x: 10, - y: 10, - toJSON: () => ({}), - }), - }); -} - -describe("timeline inspector", () => { - it("keeps visual clips inspectable and audio-only clips out of the visual panel", () => { - expect(canInspectTimelineElement({ tag: "section" })).toBe(true); - expect(canInspectTimelineElement({ tag: "video", src: "assets/demo.mp4" })).toBe(true); - expect(canInspectTimelineElement({ tag: "audio" })).toBe(false); - expect(canInspectTimelineElement({ tag: "div", src: "assets/narration.mp3" })).toBe(false); - expect(isAudioTimelineElement({ tag: "sfx" })).toBe(true); - }); - - it("uses stable timeline keys and only shows bounds at clip edges", () => { - expect(getTimelineElementKey({ id: "card", key: "index.html#card" })).toBe("index.html#card"); - expect(shouldShowTimelineInspectorBounds(2, { start: 2, duration: 4 })).toBe(true); - expect(shouldShowTimelineInspectorBounds(6, { start: 2, duration: 4 })).toBe(true); - expect(shouldShowTimelineInspectorBounds(4, { start: 2, duration: 4 })).toBe(false); - }); - - it("keeps selected layer bounds visible only while the clip is active", () => { - expect(isTimelineElementActiveAtTime(1.99, { start: 2, duration: 4 }, 0)).toBe(false); - expect(isTimelineElementActiveAtTime(2, { start: 2, duration: 4 }, 0)).toBe(true); - expect(isTimelineElementActiveAtTime(4, { start: 2, duration: 4 }, 0)).toBe(true); - expect(isTimelineElementActiveAtTime(6, { start: 2, duration: 4 }, 0)).toBe(true); - expect(isTimelineElementActiveAtTime(6.01, { start: 2, duration: 4 }, 0)).toBe(false); - }); - - it("uses composite visibility for nested layers", () => { - const hiddenDoc = createDocument(`
Label
`); - const hiddenLabel = hiddenDoc.getElementById("label") as HTMLElement; - attachVisibleBox(hiddenLabel); - expect(isTimelineLayerVisibleInPreview(hiddenLabel)).toBe(false); - - const visibleDoc = createDocument( - `
Label
`, - ); - const visibleLabel = visibleDoc.getElementById("label") as HTMLElement; - attachVisibleBox(visibleLabel); - expect(isTimelineLayerVisibleInPreview(visibleLabel)).toBe(true); - expect(getTimelineLayerVisibilityInPreview(visibleLabel)).toMatchObject({ - compositeOpacity: 1, - hasBox: true, - inViewport: true, - visible: true, - }); - }); -}); diff --git a/packages/studio/src/utils/timelineInspector.ts b/packages/studio/src/utils/timelineInspector.ts deleted file mode 100644 index f9184d2b75..0000000000 --- a/packages/studio/src/utils/timelineInspector.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { TimelineElement } from "../player"; - -const TIMELINE_INSPECTOR_BOUNDARY_EPSILON_SECONDS = 0.08; - -const AUDIO_TIMELINE_TAGS = new Set(["audio", "music", "sfx", "sound", "narration"]); -const AUDIO_SOURCE_EXT_RE = /\.(aac|flac|m4a|mp3|ogg|opus|wav)(?:[?#].*)?$/i; - -export function getTimelineElementKey( - element: Pick | null | undefined, -): string | null { - if (!element) return null; - return element.key ?? element.id; -} - -export function isAudioTimelineElement( - element: Pick | null | undefined, -): boolean { - if (!element) return false; - const tag = element.tag.trim().toLowerCase(); - if (AUDIO_TIMELINE_TAGS.has(tag)) return true; - return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src)); -} - -export function canInspectTimelineElement( - element: Pick | null | undefined, -): boolean { - return !isAudioTimelineElement(element); -} - -export function shouldShowTimelineInspectorBounds( - currentTime: number, - element: Pick | null | undefined, - epsilonSeconds = TIMELINE_INSPECTOR_BOUNDARY_EPSILON_SECONDS, -): boolean { - if (!element) return false; - if (!Number.isFinite(currentTime)) return false; - if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return false; - const start = Math.max(0, element.start); - const end = Math.max(start, start + Math.max(0, element.duration)); - const epsilon = Math.max(0, epsilonSeconds); - return Math.abs(currentTime - start) <= epsilon || Math.abs(currentTime - end) <= epsilon; -} - -export function isTimelineElementActiveAtTime( - currentTime: number, - element: Pick | null | undefined, - epsilonSeconds = TIMELINE_INSPECTOR_BOUNDARY_EPSILON_SECONDS, -): boolean { - if (!element) return false; - if (!Number.isFinite(currentTime)) return false; - if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return false; - const start = Math.max(0, element.start); - const end = Math.max(start, start + Math.max(0, element.duration)); - const epsilon = Math.max(0, epsilonSeconds); - return currentTime >= start - epsilon && currentTime <= end + epsilon; -} - -export interface TimelineLayerVisibility { - visible: boolean; - compositeOpacity: number; - hasBox: boolean; - inViewport: boolean; -} - -export function getTimelineLayerVisibilityInPreview( - element: HTMLElement, - options: { minCompositeOpacity?: number } = {}, -): TimelineLayerVisibility { - const hidden: TimelineLayerVisibility = { - visible: false, - compositeOpacity: 0, - hasBox: false, - inViewport: false, - }; - if (!element.isConnected) return hidden; - const doc = element.ownerDocument; - const win = doc.defaultView; - if (!win) return hidden; - - const minCompositeOpacity = options.minCompositeOpacity ?? 0.01; - let compositeOpacity = 1; - let current: HTMLElement | null = element; - while (current && current !== doc.body && current !== doc.documentElement) { - const style = win.getComputedStyle(current); - if (style.display === "none" || style.visibility === "hidden") { - return { ...hidden, compositeOpacity }; - } - compositeOpacity *= Number.parseFloat(style.opacity || "1"); - if (compositeOpacity <= minCompositeOpacity) { - return { ...hidden, compositeOpacity }; - } - current = current.parentElement; - } - - const rect = element.getBoundingClientRect(); - const hasBox = rect.width > 0.5 && rect.height > 0.5; - if (!hasBox) return { visible: false, compositeOpacity, hasBox, inViewport: false }; - - const viewportWidth = win.innerWidth || doc.documentElement.clientWidth; - const viewportHeight = win.innerHeight || doc.documentElement.clientHeight; - const inViewport = - rect.right > 0 && rect.bottom > 0 && rect.left < viewportWidth && rect.top < viewportHeight; - return { - visible: inViewport, - compositeOpacity, - hasBox, - inViewport, - }; -} - -export function isTimelineLayerVisibleInPreview( - element: HTMLElement, - options: { minCompositeOpacity?: number } = {}, -): boolean { - return getTimelineLayerVisibilityInPreview(element, options).visible; -}