Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ export function StudioApp() {
designPanelActive,
inspectorPanelActive,
inspectorButtonActive,
shouldShowMotionPath,
shouldShowSelectedDomBounds,
} = useInspectorState(
panelLayout.rightPanelTab,
Expand Down Expand Up @@ -554,6 +555,7 @@ export function StudioApp() {
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
setCompIdToSrc={setCompIdToSrc}
setCompositionLoading={setCompositionLoading}
shouldShowMotionPath={shouldShowMotionPath}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
isGestureRecording={gestureState === "recording"}
recordingState={gestureState}
Expand Down
3 changes: 3 additions & 0 deletions packages/studio/src/components/EditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
) => Promise<void> | void;
setCompIdToSrc: (map: Map<string, string>) => void;
setCompositionLoading: (loading: boolean) => void;
shouldShowMotionPath: boolean;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
isGestureRecording?: boolean;
Expand Down Expand Up @@ -85,6 +86,7 @@ export function EditorShell({
handleRazorSplitAll,
setCompIdToSrc,
setCompositionLoading,
shouldShowMotionPath,
shouldShowSelectedDomBounds,
isGestureRecording,
recordingState,
Expand Down Expand Up @@ -143,6 +145,7 @@ export function EditorShell({
onDeleteElement={handleTimelineElementDelete}
previewOverlay={
<PreviewOverlays
shouldShowMotionPath={shouldShowMotionPath}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
blockPreview={blockPreview}
isGestureRecording={isGestureRecording}
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/components/nle/PreviewOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { GestureRecordingState } from "../editor/GestureRecordControl";
import type { ReactNode } from "react";

export interface PreviewOverlaysProps {
shouldShowMotionPath: boolean;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
isGestureRecording?: boolean;
Expand Down Expand Up @@ -132,6 +133,7 @@ export function resolveZIndexEntries(

// fallow-ignore-next-line complexity
export function PreviewOverlays({
shouldShowMotionPath,
shouldShowSelectedDomBounds,
blockPreview,
isGestureRecording,
Expand Down Expand Up @@ -274,7 +276,7 @@ export function PreviewOverlays({
{STUDIO_KEYFRAMES_ENABLED && (
<MotionPathOverlay
iframeRef={previewIframeRef}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
selection={shouldShowMotionPath ? domEditSelection : null}
compositionSize={compositionDimensions}
isPlaying={isPlaying}
/>
Expand Down
126 changes: 75 additions & 51 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,35 @@ import { defaultTimelineTheme } from "./timelineTheme";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
import { getTrackStyle } from "./timelineIcons";
import { useTimelineZoom } from "./useTimelineZoom";
import { useTimelineAssetDrop } from "./timelineDragDrop";
import { TimelineEmptyState } from "./TimelineEmptyState";
import { TimelineCanvas } from "./TimelineCanvas";
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { TimelineOverlays } from "./TimelineOverlays";
import { animationContributesLane } from "./TimelinePropertyLanes";
import { useTimelineEditPinning } from "./useTimelineEditPinning";
import { useTimelineStackingSync } from "./useTimelineStackingSync";
import { useTimelineGeometry } from "./useTimelineGeometry";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
import { GUTTER, TRACKS_LEFT_PAD, generateTicks, getTimelineCanvasHeight } from "./timelineLayout";
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
import {
GUTTER,
LABEL_COL_W,
generateTicks,
getTimelineContentXFromClient,
} from "./timelineLayout";
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
import { STUDIO_PREVIEW_FPS } from "../lib/time";
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
import type { TimelineProps } from "./TimelineTypes";
import {
getTrackStyle,
useTimelineDisplayLayout,
useTimelineTrackLayout,
} from "./useTimelineTrackLayout";
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
import { useTrackGapMenu } from "./useTrackGapMenu";
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";

Expand Down Expand Up @@ -99,6 +111,22 @@ export const Timeline = memo(function Timeline({
const timelineReady = usePlayerStore((s) => s.timelineReady);
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
const hasKeyframedClips = useMemo(
() =>
Array.from(gsapAnimations.values()).some((list) =>
// Same lane-contribution predicate the layout uses: real keyframes OR a
// synthesizable flat tween. Checking animation.keyframes alone left a
// flat-tween-only comp without its reserved label column.
list.some((animation) => animationContributesLane(animation)),
),
[gsapAnimations],
);
const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips;
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER;
const contentGutter = labelMode ? GUTTER : 0;
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const currentTime = usePlayerStore((s) => s.currentTime);
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
Expand Down Expand Up @@ -147,9 +175,10 @@ export const Timeline = memo(function Timeline({
return Number.isFinite(result) ? result : safeDur;
}, [rawElements, duration]);

const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
const trackOrderRef = useRef(trackOrder);
trackOrderRef.current = trackOrder;
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
useAutoExpandKeyframedClips(gsapAnimations);
const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowHeights, rowHeightsRef } =
useTimelineTrackLayout(expandedElements, gsapAnimations, selectedElementId, selectedElementIds);
const expandedElementsRef = useRef(expandedElements);
expandedElementsRef.current = expandedElements;

Expand Down Expand Up @@ -214,6 +243,7 @@ export const Timeline = memo(function Timeline({
ppsRef,
durationRef,
trackOrderRef,
rowHeightsRef,
onMoveElement: pinnedOnMoveElement,
onMoveElements: pinnedOnMoveElements,
onResizeElement: pinnedOnResizeElement,
Expand All @@ -231,25 +261,31 @@ export const Timeline = memo(function Timeline({
ppsRef,
durationRef,
trackOrderRef,
rowHeightsRef,
contentOrigin,
onFileDrop: pinnedOnFileDrop,
onAssetDrop: pinnedOnAssetDrop,
onBlockDrop: pinnedOnBlockDrop,
});

const displayTrackOrder = useMemo(() => {
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
}, [draggedClip, trackOrder]);

const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights);
const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
timelineReady,
expandedElements.length,
totalH,
displayLayout.totalH,
]);
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
useTimelineKeyframeHandlers({
expandedElements,
keyframeCache,
onSelectElement,
onSeek,
setSelectedElementId,
setKfContextMenu,
toggleSelectedKeyframe,
});

const selectedElement = useMemo(
() =>
Expand Down Expand Up @@ -280,6 +316,7 @@ export const Timeline = memo(function Timeline({
isDragging,
scrollRef,
lastScrollLeftRef,
contentOrigin,
});

const laneGapStrips = useTimelineGapHighlights({
Expand Down Expand Up @@ -312,6 +349,7 @@ export const Timeline = memo(function Timeline({
setZoomMode,
setManualZoomPercent,
onSeek,
contentOrigin,
});
useTimelineActiveClips({
scrollRef,
Expand Down Expand Up @@ -341,7 +379,9 @@ export const Timeline = memo(function Timeline({
setShowPopover,
elementsRef: expandedElementsRef,
trackOrderRef,
rowHeightsRef,
onSelectElement,
contentOrigin,
});
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag

Expand Down Expand Up @@ -424,10 +464,17 @@ export const Timeline = memo(function Timeline({
onDragLeave={() => clearDropPreview()}
onDrop={handleAssetDrop}
onPointerDown={(e) => {
// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
// their own clicks — scrubbing here would preventDefault and eat them.
if ((e.target as HTMLElement).closest("button, input, select, a")) return;
if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
const rect = scrollRef.current.getBoundingClientRect();
const x =
e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
const x = getTimelineContentXFromClient({
clientX: e.clientX,
rectLeft: rect.left,
scrollLeft: scrollRef.current.scrollLeft,
contentOrigin,
});
const splitTime = Math.max(0, x / pps);
onRazorSplitAll?.(splitTime);
return;
Expand All @@ -442,18 +489,22 @@ export const Timeline = memo(function Timeline({
major={major}
minor={minor}
pps={pps}
contentOrigin={contentOrigin}
contentGutter={contentGutter}
trackContentWidth={displayContentWidth}
totalH={totalH}
totalH={displayLayout.totalH}
effectiveDuration={effectiveDuration}
majorTickInterval={majorTickInterval}
rangeSelection={rangeSelection}
marqueeRect={marqueeRect}
laneGapStrips={laneGapStrips}
theme={theme}
displayTrackOrder={displayTrackOrder}
displayTrackOrder={displayLayout.displayTrackOrder}
rowHeights={displayLayout.displayRowHeights}
trackOrder={trackOrder}
tracks={tracks}
trackStyles={trackStyles}
laneCounts={laneCounts}
selectedElementId={selectedElementId}
selectedElementIds={selectedElementIds}
hoveredClip={hoveredClip}
Expand All @@ -479,43 +530,16 @@ export const Timeline = memo(function Timeline({
getPreviewElement={getPreviewElement}
getTrackStyle={getTrackStyle}
keyframeCache={keyframeCache}
gsapAnimations={gsapAnimations}
selectedKeyframes={selectedKeyframes}
currentTime={currentTime}
onSeek={onSeek}
beatAnalysis={adjustedBeatAnalysis}
onClickKeyframe={(el, pct) => {
usePlayerStore.getState().clearSelectedKeyframes();
const elKey = el.key ?? el.id;
setSelectedElementId(elKey);
onSelectElement?.(el);
// Select the clicked diamond (matches shift-click); cleared above so this single-selects.
toggleSelectedKeyframe(`${elKey}:${pct}`);
const absTime = el.start + (pct / 100) * el.duration;
onSeek?.(absTime);
const kfData = keyframeCache?.get(elKey);
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
}}
onShiftClickKeyframe={(elId, pct) => {
toggleSelectedKeyframe(`${elId}:${pct}`);
}}
onSelectSegment={onSelectSegment}
onClickKeyframe={onClickKeyframe}
onShiftClickKeyframe={onShiftClickKeyframe}
onMoveKeyframe={onMoveKeyframe}
onContextMenuKeyframe={(e, elId, pct) => {
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
if (el) {
setSelectedElementId(elId);
onSelectElement?.(el);
}
const kfData = keyframeCache.get(elId);
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
setKfContextMenu({
x: e.clientX + 4,
y: e.clientY + 2,
elementId: elId,
percentage: pct,
tweenPercentage: kf?.tweenPercentage,
currentEase: kf?.ease ?? kfData?.ease,
});
}}
onContextMenuKeyframe={onContextMenuKeyframe}
onContextMenuClip={(e, el) => {
e.preventDefault();
setSelectedElementId(el.key ?? el.id);
Expand Down
Loading
Loading