feat: move/resize multiple elements

This commit is contained in:
Maze Winther
2026-04-15 05:43:29 +02:00
parent 6312fc5b07
commit ac43a5a73f
21 changed files with 2223 additions and 962 deletions
@@ -61,7 +61,6 @@ import { SELECTED_TRACK_ROW_CLASS } from "./theme";
import {
computeTrackExpansionHeight,
getTrackExpandedRows,
getExpansionHeight,
getPropertyLabel,
type ExpandedRow,
} from "./expanded-layout";
@@ -75,6 +74,7 @@ import { TimelineBookmarksRow } from "./bookmarks";
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
import { useTimelineResize } from "@/hooks/timeline/use-timeline-resize";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
@@ -119,7 +119,9 @@ export function Timeline() {
} = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull());
const scene = useEditor((currentEditor) =>
currentEditor.scenes.getActiveSceneOrNull(),
);
const tracks = useMemo<TimelineTrack[]>(
() =>
scene
@@ -140,7 +142,6 @@ export function Timeline() {
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
);
@@ -148,15 +149,6 @@ export function Timeline() {
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
setCurrentSnapPoint(snapPoint);
}, []);
const handleResizeStateChange = useCallback(
({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
setIsResizing(nextIsResizing);
if (!nextIsResizing) {
setCurrentSnapPoint(null);
}
},
[],
);
const timelineDuration = timeline.getTotalDuration() || 0;
const minZoomLevel = getTimelineZoomMin({
@@ -176,6 +168,10 @@ export function Timeline() {
tracksScrollRef,
rulerScrollRef,
});
const { isResizing, handleResizeStart } = useTimelineResize({
zoomLevel,
onSnapPointChange: handleSnapPointChange,
});
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
@@ -366,7 +362,10 @@ export function Timeline() {
const containerWidth =
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel });
const contentWidth = timelineTimeToPixels({
time: timelineDuration,
zoomLevel,
});
const paddingPx = getTimelinePaddingPx({
containerWidth,
zoomLevel,
@@ -513,7 +512,10 @@ export function Timeline() {
TRACKS_CONTAINER_HEIGHT.min,
Math.min(
TRACKS_CONTAINER_HEIGHT.max,
getTotalTracksHeight({ tracks, getExtraHeight: getTrackExpansionHeight }),
getTotalTracksHeight({
tracks,
getExtraHeight: getTrackExpansionHeight,
}),
),
) + TIMELINE_CONTENT_TOP_PADDING_PX
}px`,
@@ -533,14 +535,13 @@ export function Timeline() {
}}
>
{tracks.length > 0 && (
<TimelineTrackRows
mainTrackId={mainTrackId}
<TimelineTrackRows
mainTrackId={mainTrackId}
zoomLevel={zoomLevel}
dragState={dragState}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
onSnapPointChange={handleSnapPointChange}
onResizeStateChange={handleResizeStateChange}
onResizeStart={handleResizeStart}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
onTrackMouseDown={(event) => {
@@ -718,8 +719,7 @@ function TimelineTrackRows({
dragState,
tracksScrollRef,
lastMouseXRef,
onSnapPointChange,
onResizeStateChange,
onResizeStart,
onElementMouseDown,
onElementClick,
onTrackMouseDown,
@@ -733,8 +733,9 @@ function TimelineTrackRows({
dragState: ElementDragState;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
lastMouseXRef: React.RefObject<number>;
onSnapPointChange: (snapPoint: SnapPoint | null) => void;
onResizeStateChange: (params: { isResizing: boolean }) => void;
onResizeStart: React.ComponentProps<
typeof TimelineTrackContent
>["onResizeStart"];
onElementMouseDown: React.ComponentProps<
typeof TimelineTrackContent
>["onElementMouseDown"];
@@ -798,8 +799,7 @@ function TimelineTrackRows({
<div
className={cn(
"absolute right-0 left-0 transition-colors",
tracksWithSelection.has(track.id) &&
SELECTED_TRACK_ROW_CLASS,
tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS,
)}
style={{
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
@@ -813,8 +813,7 @@ function TimelineTrackRows({
rulerScrollRef={tracksScrollRef}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
onSnapPointChange={onSnapPointChange}
onResizeStateChange={onResizeStateChange}
onResizeStart={onResizeStart}
onElementMouseDown={onElementMouseDown}
onElementClick={onElementClick}
onTrackMouseDown={onTrackMouseDown}
@@ -929,12 +928,10 @@ function TrackToggleIcon({
function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
return (
<div className="flex flex-col overflow-hidden">
{rows.map((row, index) => (
{rows.map((row) => (
<div
key={row.propertyPath}
className={cn(
"flex shrink-0 items-center px-3 bg-muted/50",
)}
className={cn("flex shrink-0 items-center px-3 bg-muted/50")}
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
>
<span className="text-muted-foreground truncate text-xs leading-none">
@@ -10,7 +10,6 @@ import {
} from "@/hooks/timeline/element/use-keyframe-drag";
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import { SelectionBox } from "@/lib/selection/selection-box";
import { getElementKeyframes } from "@/lib/animation";
import {
@@ -86,7 +85,6 @@ import {
getExpansionHeight,
type ExpandedRow,
} from "./expanded-layout";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
const ELEMENT_RING_WIDTH_PX = 1.5;
@@ -197,8 +195,12 @@ interface TimelineElementProps {
track: TimelineTrack;
zoomLevel: number;
isSelected: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
onResizeStart: (params: {
event: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
side: "left" | "right";
}) => void;
onElementMouseDown: (
event: React.MouseEvent,
element: TimelineElementType,
@@ -216,8 +218,7 @@ export function TimelineElement({
track,
zoomLevel,
isSelected,
onSnapPointChange,
onResizeStateChange,
onResizeStart,
onElementMouseDown,
onElementClick,
dragState,
@@ -231,18 +232,6 @@ export function TimelineElement({
elementId: element.id,
fallback: element,
});
const {
currentDuration,
currentStartTime,
handleResizeStart,
isResizing,
} = useTimelineElementResize({
element,
track,
zoomLevel,
onSnapPointChange,
onResizeStateChange,
});
let mediaAsset: MediaAsset | null = null;
@@ -267,13 +256,9 @@ export function TimelineElement({
const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime + dragTimeOffset
: isResizing
? currentStartTime
: renderElement.startTime;
: renderElement.startTime;
const displayedStartTime = elementStartTime;
const displayedDuration = isResizing
? currentDuration
: renderElement.duration;
const displayedDuration = renderElement.duration;
const elementWidth = timelineTimeToPixels({
time: displayedDuration,
zoomLevel,
@@ -282,22 +267,6 @@ export function TimelineElement({
time: displayedStartTime,
zoomLevel,
});
const handleElementResizeStart = ({
event,
element,
side,
}: {
event: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
side: "left" | "right";
}) => {
handleResizeStart({
event,
elementId: element.id,
side,
});
};
const keyframeIndicators = isSelected
? getKeyframeIndicators({
keyframes: getElementKeyframes({ animations: element.animations }),
@@ -329,9 +298,7 @@ export function TimelineElement({
);
const expandedRows = useMemo(
() =>
isExpanded
? getExpandedRows({ animations: element.animations })
: [],
isExpanded ? getExpandedRows({ animations: element.animations }) : [],
[isExpanded, element.animations],
);
@@ -422,7 +389,7 @@ export function TimelineElement({
expandedContent={expandedContent}
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
onResizeStart={handleElementResizeStart}
onResizeStart={onResizeStart}
isDropTarget={isDropTarget}
/>
{isSelected && (
@@ -811,8 +778,7 @@ function ExpandedKeyframeLanes({
[...keyframes]
.sort(
(a, b) =>
a.time - b.time ||
a.propertyPath.localeCompare(b.propertyPath),
a.time - b.time || a.propertyPath.localeCompare(b.propertyPath),
)
.map((kf) => ({
trackId,
@@ -824,6 +790,8 @@ function ExpandedKeyframeLanes({
);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface
// biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface
<div
ref={containerRef}
className="relative flex flex-col"
@@ -837,9 +805,7 @@ function ExpandedKeyframeLanes({
return (
<div
key={row.propertyPath}
className={cn(
"relative flex items-center bg-muted/50",
)}
className={cn("relative flex items-center bg-muted/50")}
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
>
{laneKeyframes.map((kf) => {
@@ -849,8 +815,9 @@ function ExpandedKeyframeLanes({
propertyPath: row.propertyPath,
keyframeId: kf.id,
};
const isBeingDragged =
keyframeDragState.draggingKeyframeIds.has(kf.id);
const isBeingDragged = keyframeDragState.draggingKeyframeIds.has(
kf.id,
);
const kfLeft = timelineTimeToSnappedPixels({
time: displayedStartTime + kf.time,
zoomLevel,
@@ -898,9 +865,7 @@ function ExpandedKeyframeLanes({
icon={KeyframeIcon}
className={cn(
"size-3.5 text-black mr-1",
isSelected
? "fill-primary"
: "fill-white",
isSelected ? "fill-primary" : "fill-white",
)}
strokeWidth={1.5}
/>
@@ -1,129 +1,132 @@
"use client";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { TimelineElement } from "./timeline-element";
import type { TimelineTrack } from "@/lib/timeline";
import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { TIMELINE_LAYERS } from "./layers";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import type { ElementDragState } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
interface TimelineTrackContentProps {
track: TimelineTrack;
zoomLevel: number;
dragState: ElementDragState;
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
lastMouseXRef: React.RefObject<number>;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
onElementMouseDown: (params: {
event: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
}) => void;
onElementClick: (params: {
event: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
}) => void;
onTrackMouseDown?: (event: React.MouseEvent) => void;
onTrackMouseUp?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
targetElementId?: string | null;
}
export function TimelineTrackContent({
track,
zoomLevel,
dragState,
rulerScrollRef,
tracksScrollRef,
lastMouseXRef,
onSnapPointChange,
onResizeStateChange,
onElementMouseDown,
onElementClick,
onTrackMouseDown,
onTrackMouseUp,
shouldIgnoreClick,
targetElementId = null,
}: TimelineTrackContentProps) {
const { isElementSelected } = useElementSelection();
const duration = useEditor((e) => e.timeline.getTotalDuration());
useEdgeAutoScroll({
isActive: dragState.isDragging,
getMouseClientX: () => lastMouseXRef.current ?? 0,
rulerScrollRef,
tracksScrollRef,
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
});
return (
<div className="relative size-full">
<button
type="button"
className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
aria-label={`Select ${track.name} track`}
onMouseUp={(event) => {
if (shouldIgnoreClick?.()) return;
onTrackMouseUp?.(event);
}}
onMouseDown={(event) => {
event.preventDefault();
onTrackMouseDown?.(event);
}}
/>
{/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
<div
className="relative h-full min-w-full"
style={{ zIndex: TIMELINE_LAYERS.trackContent }}
onMouseUp={(event) => {
if (event.target !== event.currentTarget) return;
if (shouldIgnoreClick?.()) return;
onTrackMouseUp?.(event);
}}
onMouseDown={(event) => {
if (event.target !== event.currentTarget) return;
event.preventDefault();
onTrackMouseDown?.(event);
}}
>
{track.elements.length === 0 ? (
<div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
) : (
track.elements.map((element) => {
const isSelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
return (
<TimelineElement
key={element.id}
element={element}
track={track}
zoomLevel={zoomLevel}
isSelected={isSelected}
onSnapPointChange={onSnapPointChange}
onResizeStateChange={onResizeStateChange}
onElementMouseDown={(event, element) =>
onElementMouseDown({ event, element, track })
}
onElementClick={(event, element) =>
onElementClick({ event, element, track })
}
dragState={dragState}
isDropTarget={element.id === targetElementId}
/>
);
})
)}
</div>
</div>
);
}
"use client";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { TimelineElement } from "./timeline-element";
import type { TimelineTrack } from "@/lib/timeline";
import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
import { TIMELINE_LAYERS } from "./layers";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import type { ElementDragState } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
interface TimelineTrackContentProps {
track: TimelineTrack;
zoomLevel: number;
dragState: ElementDragState;
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
lastMouseXRef: React.RefObject<number>;
onResizeStart: (params: {
event: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
side: "left" | "right";
}) => void;
onElementMouseDown: (params: {
event: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
}) => void;
onElementClick: (params: {
event: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
}) => void;
onTrackMouseDown?: (event: React.MouseEvent) => void;
onTrackMouseUp?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
targetElementId?: string | null;
}
export function TimelineTrackContent({
track,
zoomLevel,
dragState,
rulerScrollRef,
tracksScrollRef,
lastMouseXRef,
onResizeStart,
onElementMouseDown,
onElementClick,
onTrackMouseDown,
onTrackMouseUp,
shouldIgnoreClick,
targetElementId = null,
}: TimelineTrackContentProps) {
const { isElementSelected } = useElementSelection();
const duration = useEditor((e) => e.timeline.getTotalDuration());
useEdgeAutoScroll({
isActive: dragState.isDragging,
getMouseClientX: () => lastMouseXRef.current ?? 0,
rulerScrollRef,
tracksScrollRef,
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
});
return (
<div className="relative size-full">
<button
type="button"
className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
aria-label={`Select ${track.name} track`}
onMouseUp={(event) => {
if (shouldIgnoreClick?.()) return;
onTrackMouseUp?.(event);
}}
onMouseDown={(event) => {
event.preventDefault();
onTrackMouseDown?.(event);
}}
/>
{/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
<div
className="relative h-full min-w-full"
style={{ zIndex: TIMELINE_LAYERS.trackContent }}
onMouseUp={(event) => {
if (event.target !== event.currentTarget) return;
if (shouldIgnoreClick?.()) return;
onTrackMouseUp?.(event);
}}
onMouseDown={(event) => {
if (event.target !== event.currentTarget) return;
event.preventDefault();
onTrackMouseDown?.(event);
}}
>
{track.elements.length === 0 ? (
<div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
) : (
track.elements.map((element) => {
const isSelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
return (
<TimelineElement
key={element.id}
element={element}
track={track}
zoomLevel={zoomLevel}
isSelected={isSelected}
onResizeStart={({ event, element, side }) =>
onResizeStart({ event, element, track, side })
}
onElementMouseDown={(event, element) =>
onElementMouseDown({ event, element, track })
}
onElementClick={(event, element) =>
onElementClick({ event, element, track })
}
dragState={dragState}
isDropTarget={element.id === targetElementId}
/>
);
})
)}
</div>
</div>
);
}