From c19a576a1879ba1ba19529428980356416d0c921 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:18 -0700 Subject: [PATCH] feat(studio): NLE shell assembly (unwired) What: EditorShell (the full editor layout replacing NLELayout + StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and useTimelineEditCallbacks (the callback bag bridging store edits to the timeline), all unwired. Why: the shell that App swaps to in the final step; reviewing it standalone keeps that swap PR small. How: new files against the coexistence layer; TEMP(studio-dnd) entries until App mounts EditorShell in the app-shell swap. Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit clean. --- .fallowrc.jsonc | 17 ++ .../studio/src/components/EditorShell.tsx | 251 ++++++++++++++++++ .../src/components/nle/TimelinePane.tsx | 210 +++++++++++++++ .../nle/useTimelineEditCallbacks.ts | 210 +++++++++++++++ 4 files changed, 688 insertions(+) create mode 100644 packages/studio/src/components/EditorShell.tsx create mode 100644 packages/studio/src/components/nle/TimelinePane.tsx create mode 100644 packages/studio/src/components/nle/useTimelineEditCallbacks.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 6a66d50816..ba95b95ebe 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -55,6 +55,11 @@ "packages/studio/src/hooks/gsapRuntimePreview.ts", // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. + "packages/studio/src/components/EditorShell.tsx", + "packages/studio/src/components/nle/TimelinePane.tsx", + "packages/studio/src/components/nle/useTimelineEditCallbacks.ts", + // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; + // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. "packages/studio/src/components/editor/DomEditSelectionChrome.tsx", "packages/studio/src/components/editor/useDomEditNudge.ts", "packages/studio/src/components/nle/PreviewOverlays.tsx", @@ -451,6 +456,18 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/components/StudioPreviewArea.tsx", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/components/nle/useTimelineEditCallbacks.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/components/EditorShell.tsx", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/components/nle/PreviewOverlays.tsx", // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); // studio-dnd pr22 removes this with the final config. "packages/studio/src/components/editor/DomEditOverlay.tsx", diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx new file mode 100644 index 0000000000..9e968babf4 --- /dev/null +++ b/packages/studio/src/components/EditorShell.tsx @@ -0,0 +1,251 @@ +import { useCallback, type ReactNode } from "react"; +import { PreviewPane } from "./nle/PreviewPane"; +import { TimelinePane } from "./nle/TimelinePane"; +import { PreviewOverlays } from "./nle/PreviewOverlays"; +import { + useTimelineEditCallbacks, + type TimelineEditCallbackDeps, +} from "./nle/useTimelineEditCallbacks"; +import { NLEProvider, useNLEContext } from "./nle/NLEContext"; +import { CaptionTimeline } from "../captions/components/CaptionTimeline"; +import { StudioFeedbackBar } from "./StudioFeedbackBar"; +import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; +import { useDomEditActionsContext } from "../contexts/DomEditContext"; +import { TimelineEditProvider } from "../contexts/TimelineEditContext"; +import type { TimelineElement } from "../player"; +import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; +import type { GestureRecordingState } from "./editor/GestureRecordControl"; + +type RenderClipContent = ( + element: TimelineElement, + style: { clip: string; label: string }, +) => ReactNode; +type TimelineDropPlacement = Pick; + +// The seven move/resize/split/razor handlers come from TimelineEditCallbackDeps +// (shared with useTimelineEditCallbacks); the rest are drop + wiring props. +export interface EditorShellProps extends TimelineEditCallbackDeps { + /** Left sidebar (media/library), rendered in the top row. */ + left: ReactNode; + /** Right panel (inspector/design) or null when collapsed, in the top row. */ + right: ReactNode; + /** Hide the whole shell (e.g. while the storyboard view is active). */ + hidden?: boolean; + timelineToolbar: ReactNode; + renderClipContent: RenderClipContent; + handleTimelineElementDelete: (element: TimelineElement) => Promise | void; + handleTimelineAssetDrop: ( + assetPath: string, + placement: TimelineDropPlacement, + ) => Promise | void; + handleTimelineBlockDrop?: ( + blockName: string, + placement: TimelineDropPlacement, + ) => Promise | void; + handlePreviewBlockDrop?: ( + blockName: string, + position: { left: number; top: number }, + ) => Promise | void; + handleTimelineFileDrop: ( + files: File[], + placement?: TimelineDropPlacement, + ) => Promise | void; + setCompIdToSrc: (map: Map) => void; + setCompositionLoading: (loading: boolean) => void; + shouldShowSelectedDomBounds: boolean; + blockPreview?: BlockPreviewInfo | null; + isGestureRecording?: boolean; + recordingState?: GestureRecordingState; + onToggleRecording?: () => void; + gestureOverlay?: ReactNode; +} + +// The CapCut-style shell: [left | preview | right] in a top row, with a +// full-width timeline spanning the bottom. Owns the shared player + +// composition-stack state via NLEProvider so both rows share one player. +export function EditorShell({ + left, + right, + hidden, + timelineToolbar, + renderClipContent, + handleTimelineElementDelete, + handleTimelineAssetDrop, + handleTimelineBlockDrop, + handlePreviewBlockDrop, + handleTimelineFileDrop, + handleTimelineElementMove, + handleTimelineElementsMove, + handleTimelineElementResize, + handleToggleTrackHidden, + handleBlockedTimelineEdit, + handleTimelineElementSplit, + handleRazorSplit, + handleRazorSplitAll, + setCompIdToSrc, + setCompositionLoading, + shouldShowSelectedDomBounds, + isGestureRecording, + recordingState, + onToggleRecording, + blockPreview, + gestureOverlay, +}: EditorShellProps) { + const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef } = + useStudioShellContext(); + const { refreshKey, captionEditMode, refreshPreviewDocumentVersion } = useStudioPlaybackContext(); + const { handleTimelineElementSelect } = useDomEditActionsContext(); + + const timelineEditCallbacks = useTimelineEditCallbacks({ + handleTimelineElementMove, + handleTimelineElementsMove, + handleTimelineElementResize, + handleToggleTrackHidden, + handleBlockedTimelineEdit, + handleTimelineElementSplit, + handleRazorSplit, + handleRazorSplitAll, + }); + + return ( +
+ + { + // Sync activeCompPath when the user drills down via the timeline or + // navigates back — keeps sidebar + thumbnails in sync. Guard no-ops to + // avoid circular refresh cascades (activeCompPath → stack → onChange). + if (compPath !== activeCompPath) { + setActiveCompPath(compPath); + refreshPreviewDocumentVersion(); + } + }} + > + + } + /> + + + +
+ ); +} + +interface EditorShellBodyProps { + left: ReactNode; + right: ReactNode; + captionEditMode: boolean; + previewOverlay: ReactNode; + onSelectTimelineElement: (element: TimelineElement | null) => void; + onPreviewBlockDrop?: ( + blockName: string, + position: { left: number; top: number }, + ) => Promise | void; + timelineToolbar: ReactNode; + renderClipContent: RenderClipContent; + onFileDrop: (files: File[], placement?: TimelineDropPlacement) => Promise | void; + onAssetDrop: (assetPath: string, placement: TimelineDropPlacement) => Promise | void; + onBlockDrop?: (blockName: string, placement: TimelineDropPlacement) => Promise | void; + onDeleteElement: (element: TimelineElement) => Promise | void; +} + +function EditorShellBody({ + left, + right, + captionEditMode, + previewOverlay, + onSelectTimelineElement, + onPreviewBlockDrop, + timelineToolbar, + renderClipContent, + onFileDrop, + onAssetDrop, + onBlockDrop, + onDeleteElement, +}: EditorShellBodyProps) { + const { compositionStack, updateCompositionStack, containerRef } = useNLEContext(); + + // Keyboard: Escape to pop composition level + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape" && compositionStack.length > 1) { + updateCompositionStack((prev) => prev.slice(0, -1)); + } + }, + [compositionStack.length, updateCompositionStack], + ); + + return ( +
+ {/* Top row: [left | preview | right] — outer padding + the 8px resize + seams give the panels CapCut-style separation on the dark canvas. */} +
+ {left} +
+ +
+ {right} +
+ + {/* Full-width timeline row */} + +
+ + Captions + +
+ +
+ ) : undefined + } + /> + + ); +} diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx new file mode 100644 index 0000000000..e82a64e691 --- /dev/null +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -0,0 +1,210 @@ +import { useCallback, type ReactNode } from "react"; +import { Timeline } from "../../player"; +import type { TimelineElement } from "../../player"; +import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; +import { TimelineResizeDivider } from "./TimelineResizeDivider"; +import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; +import { trackStudioExpandedClipEdit } from "../../telemetry/events"; +import { useNLEContext } from "./NLEContext"; + +export interface TimelinePaneProps { + /** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */ + timelineToolbar?: ReactNode; + /** Slot rendered below the timeline tracks */ + timelineFooter?: ReactNode; + /** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */ + renderClipContent?: ( + element: TimelineElement, + style: { clip: string; label: string }, + ) => ReactNode; + onFileDrop?: ( + files: File[], + placement?: Pick, + ) => Promise | void; + onDeleteElement?: (element: TimelineElement) => Promise | void; + onAssetDrop?: ( + assetPath: string, + placement: Pick, + ) => Promise | void; + onBlockDrop?: ( + blockName: string, + placement: Pick, + ) => Promise | void; + onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + onSelectTimelineElement?: (element: TimelineElement | null) => void; +} + +// fallow-ignore-next-line complexity +export function TimelinePane({ + timelineToolbar, + timelineFooter, + renderClipContent, + onFileDrop, + onDeleteElement, + onAssetDrop, + onBlockDrop, + onBlockedEditAttempt, + onSelectTimelineElement, +}: TimelinePaneProps) { + const { + seek, + handleDrillDown, + compositionStack, + updateCompositionStack, + timelineH, + setTimelineH, + persistTimelineH, + containerRef, + timelineDisabled, + } = useNLEContext(); + + // Move/resize/split come from the timeline edit context, not props — the + // wrappers below intercept expanded clips and must call the *real* handlers. + // (Delete is a direct prop; it stays that way.) + const { onMoveElement, onMoveElements, onResizeElement, onSplitElement } = + useTimelineEditContext(); + + // An expanded sub-comp child reaches the normal edit handlers in its own + // local coordinates: addressed by its real DOM id, with timeline time rebased + // onto the sub-comp it lives in. The handlers then save + reloadPreview exactly + // as they do for top-level clips — no separate live-DOM path. + const toLocalElement = useCallback( + (element: TimelineElement, basis: number): TimelineElement => ({ + ...element, + id: element.domId ?? element.id, + start: element.start - basis, + }), + [], + ); + + const handleMoveElement = useCallback( + (element: TimelineElement, updates: Pick) => { + const basis = element.expandedParentStart; + if (basis === undefined) return onMoveElement?.(element, updates); + trackStudioExpandedClipEdit({ action: "move" }); + onMoveElement?.(toLocalElement(element, basis), { + ...updates, + start: Math.max(0, updates.start - basis), + }); + }, + [onMoveElement, toLocalElement], + ); + + // Batched move (ripple / insert): rebase each expanded sub-comp child to its + // local coords, exactly as handleMoveElement does for a single clip. + const handleMoveElements = useCallback( + ( + edits: Array<{ element: TimelineElement; updates: Pick }>, + ) => + onMoveElements?.( + edits.map(({ element, updates }) => { + const basis = element.expandedParentStart; + if (basis === undefined) return { element, updates }; + return { + element: toLocalElement(element, basis), + updates: { ...updates, start: Math.max(0, updates.start - basis) }, + }; + }), + ), + [onMoveElements, toLocalElement], + ); + + const handleResizeElement = useCallback( + ( + element: TimelineElement, + updates: Pick, + ) => { + const basis = element.expandedParentStart; + if (basis === undefined) return onResizeElement?.(element, updates); + trackStudioExpandedClipEdit({ action: "resize" }); + onResizeElement?.(toLocalElement(element, basis), { + ...updates, + start: Math.max(0, updates.start - basis), + }); + }, + [onResizeElement, toLocalElement], + ); + + const handleDeleteElement = useCallback( + (element: TimelineElement) => { + const basis = element.expandedParentStart; + if (basis === undefined) return onDeleteElement?.(element); + trackStudioExpandedClipEdit({ action: "delete" }); + return onDeleteElement?.(toLocalElement(element, basis)); + }, + [onDeleteElement, toLocalElement], + ); + + const handleSplitElement = useCallback( + (element: TimelineElement, splitTime: number) => { + const basis = element.expandedParentStart; + if (basis === undefined) return onSplitElement?.(element, splitTime); + trackStudioExpandedClipEdit({ action: "split" }); + return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis)); + }, + [onSplitElement, toLocalElement], + ); + + return ( + <> + + + {/* Timeline section — inner padding (not margin) keeps the divider's + height math exact while giving the panel a gap from the shell edges. */} +
+
{ + if ((e.target as HTMLElement).closest("[data-clip]")) return; + if (timelineDisabled) return; + if (compositionStack.length > 1) { + updateCompositionStack((prev) => prev.slice(0, -1)); + } + }} + > +
{timelineToolbar}
+ +
+ {timelineFooter &&
{timelineFooter}
} + {timelineDisabled && ( +
event.preventDefault()} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => event.preventDefault()} + > + + Loading composition… + +
+ )} +
+ + ); +} diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts new file mode 100644 index 0000000000..169888b4cf --- /dev/null +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -0,0 +1,210 @@ +import { useCallback, useMemo } from "react"; +import type { TimelineElement } from "../../player"; +import { usePlayerStore } from "../../player/store/playerStore"; +import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; +import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks"; +import { useStudioShellContext } from "../../contexts/StudioContext"; +import { + useDomEditActionsContext, + useDomEditSelectionContext, +} from "../../contexts/DomEditContext"; +import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler"; +import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache"; +import { resolveKeyframeRetime } from "../editor/keyframeRetime"; + +export interface TimelineEditCallbackDeps { + handleTimelineElementMove: ( + element: TimelineElement, + updates: Pick, + ) => Promise | void; + handleTimelineElementsMove: ( + edits: Array<{ element: TimelineElement; updates: Pick }>, + ) => Promise | void; + handleTimelineElementResize: ( + element: TimelineElement, + updates: Pick, + ) => Promise | void; + handleToggleTrackHidden: (track: number, hidden: boolean) => Promise | void; + handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise | void; + handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise | void; + handleRazorSplitAll: (splitTime: number) => Promise | void; +} + +/** + * Builds the timeline edit callback bag (move/resize/split/razor plus the + * keyframe-diamond callbacks) provided to `` via TimelineEditProvider. + * The keyframe callbacks resolve the dragged diamond back to its GSAP anim id + + * tween-relative percentage, reading DOM-edit selection state from context. + */ +// fallow-ignore-next-line complexity +export function useTimelineEditCallbacks({ + handleTimelineElementMove, + handleTimelineElementsMove, + handleTimelineElementResize, + handleToggleTrackHidden, + handleBlockedTimelineEdit, + handleTimelineElementSplit, + handleRazorSplit, + handleRazorSplitAll, +}: TimelineEditCallbackDeps): TimelineEditCallbacks { + const { projectId, activeCompPath } = useStudioShellContext(); + const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext(); + const { + handleGsapRemoveKeyframe, + handleGsapMoveKeyframeToPlayhead, + handleGsapMoveKeyframe, + handleGsapResizeKeyframedTween, + handleGsapUpdateMeta, + handleGsapAddKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + buildDomSelectionForTimelineElement, + } = useDomEditActionsContext(); + + // Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its + // tween-relative percentage (shared by the delete/move keyframe callbacks): the + // diamond reports a clip-% but the script ops key on the tween-%. Prefers the + // anim in the keyframe's property group, falling back to the first keyframed one. + const resolveKeyframeTarget = useCallback( + // fallow-ignore-next-line complexity + (pct: number): { animId: string; tweenPct: number } | null => { + const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? ""); + 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); + return anim ? { animId: anim.id, tweenPct: kf?.tweenPercentage ?? pct } : null; + }, + [domEditSelection?.id, selectedGsapAnimations], + ); + + return useMemo( + () => ({ + onMoveElement: handleTimelineElementMove, + onMoveElements: handleTimelineElementsMove, + onResizeElement: handleTimelineElementResize, + onToggleTrackHidden: handleToggleTrackHidden, + onBlockedEditAttempt: handleBlockedTimelineEdit, + onSplitElement: handleTimelineElementSplit, + onRazorSplit: handleRazorSplit, + onRazorSplitAll: handleRazorSplitAll, + onDeleteAllKeyframes: () => { + // Hold the element where it is (collapse keyframes to a static set) rather + // than deleting the whole animation — deleting strands a stale GSAP base + // that the next drag adds to, flinging the element off-screen. + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (!anim) return; + handleGsapRemoveAllKeyframes(anim.id); + }, + onDeleteKeyframe: (_elId: string, pct: number) => { + const target = resolveKeyframeTarget(pct); + if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct); + }, + // Retime the keyframe to the playhead, preserving its value + ease. + onMoveKeyframeToPlayhead: (_elId: string, pct: number) => { + const target = resolveKeyframeTarget(pct); + if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct); + }, + // Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives + // the dragged keyframe's anim + tween-%. We convert the clip-% drop to an + // absolute time (via the clip's timing basis) and let resolveKeyframeRetime + // decide: a drop inside the tween window is a plain move (re-key tween-%); a + // drop past the boundary (last keyframe past the end, first before the start) + // resizes the tween — position/duration grow so the dragged keyframe lands at + // the drop while every other keyframe keeps its absolute time (value+ease too). + // fallow-ignore-next-line complexity + onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => { + const target = resolveKeyframeTarget(fromClipPct); + const sel = domEditSelection; + if (!target || !sel) return; + const anim = selectedGsapAnimations.find((a) => a.id === target.animId); + const tweenStart = anim ? resolveTweenStart(anim) : null; + if (!anim || tweenStart === null) return; + const tweenDuration = anim.duration ?? resolveTweenDuration(anim); + const sourceFile = sel.sourceFile || activeCompPath || "index.html"; + const { elements, domClipChildren } = usePlayerStore.getState(); + const { elStart, elDuration } = resolveClipTimingBasis( + sel.id ?? "", + sourceFile, + elements, + domClipChildren, + ); + const dropAbsTime = elStart + (toClipPct / 100) * elDuration; + const decision = resolveKeyframeRetime({ + keyframes: anim.keyframes?.keyframes ?? [], + draggedTweenPct: target.tweenPct, + tweenStart, + tweenDuration, + dropAbsTime, + }); + if (decision.kind === "move" && decision.toTweenPct != null) { + handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct); + } else if ( + decision.kind === "resize" && + decision.pctRemap && + decision.position != null && + decision.duration != null + ) { + handleGsapResizeKeyframedTween( + target.animId, + decision.position, + decision.duration, + decision.pctRemap, + ); + } + }, + onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => { + for (const anim of selectedGsapAnimations) { + if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease }); + } + }, + // fallow-ignore-next-line complexity + 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, + handleTimelineElementsMove, + handleTimelineElementResize, + handleToggleTrackHidden, + handleBlockedTimelineEdit, + handleTimelineElementSplit, + handleRazorSplit, + handleRazorSplitAll, + handleGsapRemoveAllKeyframes, + resolveKeyframeTarget, + selectedGsapAnimations, + handleGsapRemoveKeyframe, + handleGsapMoveKeyframeToPlayhead, + handleGsapMoveKeyframe, + handleGsapResizeKeyframedTween, + handleGsapUpdateMeta, + handleGsapAddKeyframe, + handleGsapConvertToKeyframes, + buildDomSelectionForTimelineElement, + projectId, + activeCompPath, + domEditSelection, + ], + ); +}