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

View File

@ -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">

View File

@ -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}
/>

View File

@ -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>
);
}

View File

@ -8,9 +8,7 @@ import type {
RetimeConfig,
} from "@/lib/timeline";
import { calculateTotalDuration } from "@/lib/timeline";
import {
findTrackInSceneTracks,
} from "@/lib/timeline/track-element-update";
import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
import {
canElementBeHidden,
canElementHaveAudio,
@ -57,6 +55,10 @@ import {
ToggleSourceAudioSeparationCommand,
} from "@/lib/commands/timeline";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
import type {
PlannedElementMove,
PlannedTrackCreation,
} from "@/lib/timeline/group-move";
export class TimelineManager {
private listeners = new Set<() => void>();
@ -149,25 +151,20 @@ export class TimelineManager {
});
}
moveElement({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
moveElements({
moves,
createTracks,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
moves: PlannedElementMove[];
createTracks?: PlannedTrackCreation[];
}): void {
if (moves.length === 0) {
return;
}
const command = new MoveElementCommand({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
moves,
createTracks,
});
this.editor.command.execute({ command });
}
@ -580,14 +577,7 @@ export class TimelineManager {
}
const commands = keyframes.map(
({
trackId,
elementId,
propertyPath,
componentKey,
keyframeId,
patch,
}) =>
({ trackId, elementId, propertyPath, componentKey, keyframeId, patch }) =>
new UpdateScalarKeyframeCurveCommand({
trackId,
elementId,
@ -711,7 +701,9 @@ export class TimelineManager {
private applyPreviewOverlay(tracks: SceneTracks): SceneTracks {
if (this.previewOverlay.size === 0) return tracks;
const applyTrackOverlay = <TTrack extends TimelineTrack>(track: TTrack): TTrack => {
const applyTrackOverlay = <TTrack extends TimelineTrack>(
track: TTrack,
): TTrack => {
const hasOverlay = track.elements.some((element) =>
this.previewOverlay.has(element.id),
);
@ -803,7 +795,11 @@ export class TimelineManager {
}
getPreviewTracks(): SceneTracks | null {
return this.previewTracks ?? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null;
return (
this.previewTracks ??
this.editor.scenes.getActiveSceneOrNull()?.tracks ??
null
);
}
subscribe(listener: () => void): () => void {

View File

@ -9,6 +9,13 @@ import {
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import {
buildMoveGroup,
resolveGroupMove,
snapGroupEdges,
type GroupMoveResult,
type MoveGroup,
} from "@/lib/timeline/group-move";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
@ -16,10 +23,11 @@ import { roundToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { registerCanceller } from "@/lib/cancel-interaction";
import type {
DropTarget,
ElementRef,
ElementDragState,
SceneTracks,
TimelineElement,
@ -55,6 +63,7 @@ const initialDragState: ElementDragState = {
interface PendingDragState {
elementId: string;
trackId: string;
selectedElements: ElementRef[];
startMouseX: number;
startMouseY: number;
startElementTime: number;
@ -116,11 +125,9 @@ function getDragDropTarget({
const scrollContainer = tracksScrollRef.current;
if (!containerRect || !scrollContainer) return null;
const sourceTrack = [
...tracks.overlay,
tracks.main,
...tracks.audio,
].find(({ id }) => id === trackId);
const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find(
({ id }) => id === trackId,
);
const movingElement = sourceTrack?.elements.find(
({ id }) => id === elementId,
);
@ -153,7 +160,7 @@ function getDragDropTarget({
interface StartDragParams
extends Omit<
ElementDragState,
"isDragging" | "currentTime" | "currentMouseY" | "dragElementIds" | "dragTimeOffsets"
"isDragging" | "currentTime" | "currentMouseY"
> {
initialCurrentTime: number;
initialCurrentMouseY: number;
@ -171,11 +178,6 @@ export function useElementInteraction({
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const sceneTracks = editor.scenes.getActiveScene().tracks;
const tracks = [
...sceneTracks.overlay,
sceneTracks.main,
...sceneTracks.audio,
];
const {
selectedElements,
isElementSelected,
@ -188,12 +190,16 @@ export function useElementInteraction({
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingDragState | null>(null);
const moveGroupRef = useRef<MoveGroup | null>(null);
const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
const lastMouseXRef = useRef(0);
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
const startDrag = useCallback(
({
elementId,
dragElementIds,
dragTimeOffsets,
trackId,
startMouseX,
startMouseY,
@ -205,8 +211,8 @@ export function useElementInteraction({
setDragState({
isDragging: true,
elementId,
dragElementIds: elementId ? [elementId] : [],
dragTimeOffsets: {},
dragElementIds,
dragTimeOffsets,
trackId,
startMouseX,
startMouseY,
@ -220,6 +226,8 @@ export function useElementInteraction({
);
const endDrag = useCallback(() => {
moveGroupRef.current = null;
groupMoveResultRef.current = null;
setDragState(initialDragState);
setDragDropTarget(null);
}, []);
@ -232,6 +240,70 @@ export function useElementInteraction({
onSnapPointChange?.(null);
}, [endDrag, onSnapPointChange]);
const resolveGroupDragMove = useCallback(
({
group,
snappedTime,
dropTarget,
}: {
group: MoveGroup;
snappedTime: number;
dropTarget: DropTarget | null;
}): GroupMoveResult | null => {
if (!dropTarget) {
return null;
}
if (dropTarget.isNewTrack) {
return resolveGroupMove({
group,
tracks: sceneTracks,
anchorStartTime: snappedTime,
target: {
kind: "newTracks",
anchorInsertIndex: dropTarget.trackIndex,
newTrackIds: group.members.map(() => generateUUID()),
},
});
}
const orderedTracks = [
...sceneTracks.overlay,
sceneTracks.main,
...sceneTracks.audio,
];
const targetTrack = orderedTracks[dropTarget.trackIndex];
if (!targetTrack) {
return null;
}
const existingTrackResult = resolveGroupMove({
group,
tracks: sceneTracks,
anchorStartTime: snappedTime,
target: {
kind: "existingTrack",
anchorTargetTrackId: targetTrack.id,
},
});
if (existingTrackResult) {
return existingTrackResult;
}
return resolveGroupMove({
group,
tracks: sceneTracks,
anchorStartTime: snappedTime,
target: {
kind: "newTracks",
anchorInsertIndex: dropTarget.trackIndex,
newTrackIds: group.members.map(() => generateUUID()),
},
});
},
[sceneTracks],
);
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
@ -241,51 +313,29 @@ export function useElementInteraction({
const getDragSnapResult = useCallback(
({
frameSnappedTime,
movingElement,
group,
}: {
frameSnappedTime: number;
movingElement: TimelineElement | null | undefined;
group: MoveGroup | null;
}) => {
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (!shouldSnap || !movingElement) {
if (!group || !snappingEnabled || isShiftHeldRef.current) {
return { snappedTime: frameSnappedTime, snapPoint: null };
}
const elementDuration = movingElement.duration;
const playheadTime = editor.playback.getCurrentTime();
const startSnap = snapElementEdge({
targetTime: frameSnappedTime,
elementDuration,
const groupSnap = snapGroupEdges({
group,
anchorStartTime: frameSnappedTime,
tracks: sceneTracks,
playheadTime,
playheadTime: editor.playback.getCurrentTime(),
zoomLevel,
excludeElementId: movingElement.id,
snapToStart: true,
});
const endSnap = snapElementEdge({
targetTime: frameSnappedTime,
elementDuration,
tracks: sceneTracks,
playheadTime,
zoomLevel,
excludeElementId: movingElement.id,
snapToStart: false,
});
const snapResult =
startSnap.snapDistance <= endSnap.snapDistance ? startSnap : endSnap;
if (!snapResult.snapPoint) {
return { snappedTime: frameSnappedTime, snapPoint: null };
}
return {
snappedTime: snapResult.snappedTime,
snapPoint: snapResult.snapPoint,
snappedTime: groupSnap.snappedAnchorStartTime,
snapPoint: groupSnap.snapPoint,
};
},
[snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef],
[snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef],
);
useEffect(() => {
@ -314,17 +364,38 @@ export function useElementInteraction({
zoomLevel,
scrollLeft,
});
const adjustedTime = Math.max(
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime = roundToFrame({
time: adjustedTime,
rate: activeProject.settings.fps,
}) ?? adjustedTime;
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
const adjustedTime = Math.max(
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime =
roundToFrame({
time: adjustedTime,
rate: activeProject.settings.fps,
}) ?? adjustedTime;
const moveGroup = buildMoveGroup({
anchorRef: {
trackId: pendingDragRef.current.trackId,
elementId: pendingDragRef.current.elementId,
},
selectedElements: pendingDragRef.current.selectedElements,
tracks: sceneTracks,
});
if (!moveGroup) {
return;
}
moveGroupRef.current = moveGroup;
groupMoveResultRef.current = null;
const dragTimeOffsets: Record<string, number> = {};
for (const member of moveGroup.members) {
dragTimeOffsets[member.elementId] = member.timeOffset;
}
startDrag({
...pendingDragRef.current,
dragElementIds: moveGroup.members.map((member) => member.elementId),
dragTimeOffsets,
initialCurrentTime: snappedTime,
initialCurrentMouseY: clientY,
});
startedDragThisEvent = true;
@ -364,15 +435,13 @@ export function useElementInteraction({
});
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = activeProject.settings.fps;
const frameSnappedTime = roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
const frameSnappedTime =
roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
({ id }) => id === dragState.elementId,
);
const moveGroup = moveGroupRef.current;
const { snappedTime, snapPoint } = getDragSnapResult({
frameSnappedTime,
movingElement,
group: moveGroup,
});
setDragState((previousDragState) => ({
...previousDragState,
@ -386,7 +455,7 @@ export function useElementInteraction({
startMouseY: dragState.startMouseY,
currentMouseY: clientY,
});
const dropTarget = getDragDropTarget({
const anchorDropTarget = getDragDropTarget({
clientX,
clientY,
elementId: dragState.elementId,
@ -399,7 +468,24 @@ export function useElementInteraction({
snappedTime,
verticalDragDirection,
});
setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null);
const nextGroupMoveResult =
moveGroup && anchorDropTarget
? resolveGroupDragMove({
group: moveGroup,
snappedTime,
dropTarget: anchorDropTarget,
})
: null;
groupMoveResultRef.current = nextGroupMoveResult;
setDragDropTarget(
anchorDropTarget &&
(anchorDropTarget.isNewTrack || !nextGroupMoveResult)
? {
...anchorDropTarget,
isNewTrack: true,
}
: null,
);
}
};
@ -419,10 +505,11 @@ export function useElementInteraction({
tracksScrollRef,
tracksContainerRef,
headerRef,
tracks,
isPendingDrag,
startDrag,
getDragSnapResult,
resolveGroupDragMove,
sceneTracks,
onSnapPointChange,
]);
@ -468,55 +555,45 @@ export function useElementInteraction({
return;
}
const snappedTime = dragState.currentTime;
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
if (!sourceTrack) {
endDrag();
onSnapPointChange?.(null);
return;
}
const movingElement =
sourceTrack.elements.find(({ id }) => id === dragState.elementId) ?? null;
if (
movingElement &&
!dropTarget.isNewTrack &&
tracks[dropTarget.trackIndex]?.id === dragState.trackId &&
snappedTime === movingElement.startTime
) {
const moveGroup = moveGroupRef.current;
if (!moveGroup) {
endDrag();
onSnapPointChange?.(null);
return;
}
if (dropTarget.isNewTrack) {
const newTrackId = generateUUID();
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: newTrackId,
elementId: dragState.elementId,
newStartTime: snappedTime,
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
});
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
} else {
const targetTrack = tracks[dropTarget.trackIndex];
if (targetTrack) {
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: targetTrack.id,
elementId: dragState.elementId,
newStartTime: snappedTime,
});
if (targetTrack.id !== dragState.trackId) {
selectElement({
trackId: targetTrack.id,
elementId: dragState.elementId,
});
}
}
const groupMoveResult = resolveGroupDragMove({
group: moveGroup,
snappedTime,
dropTarget,
});
if (!groupMoveResult) {
endDrag();
onSnapPointChange?.(null);
return;
}
const didMove = groupMoveResult.moves.some((move) => {
const currentMember = moveGroup.members.find(
(member) => member.elementId === move.elementId,
);
const originalStartTime =
dragState.startElementTime + (currentMember?.timeOffset ?? 0);
return (
currentMember?.trackId !== move.targetTrackId ||
originalStartTime !== move.newStartTime
);
});
if (!didMove && groupMoveResult.createTracks.length === 0) {
endDrag();
onSnapPointChange?.(null);
return;
}
editor.timeline.moveElements({
moves: groupMoveResult.moves,
createTracks: groupMoveResult.createTracks,
});
endDrag();
onSnapPointChange?.(null);
};
@ -531,14 +608,14 @@ export function useElementInteraction({
dragState.trackId,
dragState.currentTime,
zoomLevel,
tracks,
endDrag,
onSnapPointChange,
editor.timeline,
tracksContainerRef,
tracksScrollRef,
headerRef,
selectElement,
resolveGroupDragMove,
sceneTracks,
]);
useEffect(() => {
@ -600,9 +677,17 @@ export function useElementInteraction({
elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel,
});
const elementRef = {
trackId: track.id,
elementId: element.id,
};
const pendingSelectedElements = isElementSelected(elementRef)
? selectedElements
: [elementRef];
pendingDragRef.current = {
elementId: element.id,
trackId: track.id,
selectedElements: pendingSelectedElements,
startMouseX: event.clientX,
startMouseY: event.clientY,
startElementTime: element.startTime,
@ -610,7 +695,7 @@ export function useElementInteraction({
};
setIsPendingDrag(true);
},
[zoomLevel, isElementSelected, handleSelectionClick],
[zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
);
const handleElementClick = useCallback(

View File

@ -1,503 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { roundToFrame } from "opencut-wasm";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
findSnapPoints,
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
import { isRetimableElement } from "@/lib/timeline";
import {
getSourceSpanAtClipTime,
getTimelineDurationForSourceSpan,
} from "@/lib/retime";
import { useTimelineStore } from "@/stores/timeline-store";
import { registerCanceller } from "@/lib/cancel-interaction";
export interface ResizeState {
elementId: string;
side: "left" | "right";
startX: number;
initialTrimStart: number;
initialTrimEnd: number;
initialStartTime: number;
initialDuration: number;
}
interface UseTimelineElementResizeProps {
element: TimelineElement;
track: TimelineTrack;
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
}
export function useTimelineElementResize({
element,
track,
zoomLevel,
onSnapPointChange,
onResizeStateChange,
}: UseTimelineElementResizeProps) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
const [currentTrimEnd, setCurrentTrimEnd] = useState(element.trimEnd);
const [currentStartTime, setCurrentStartTime] = useState(element.startTime);
const [currentDuration, setCurrentDuration] = useState(element.duration);
const currentTrimStartRef = useRef(element.trimStart);
const currentTrimEndRef = useRef(element.trimEnd);
const currentStartTimeRef = useRef(element.startTime);
const currentDurationRef = useRef(element.duration);
const handleResizeStart = ({
event,
elementId,
side,
}: {
event: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => {
event.stopPropagation();
event.preventDefault();
setResizing({
elementId,
side,
startX: event.clientX,
initialTrimStart: element.trimStart,
initialTrimEnd: element.trimEnd,
initialStartTime: element.startTime,
initialDuration: element.duration,
});
setCurrentTrimStart(element.trimStart);
setCurrentTrimEnd(element.trimEnd);
setCurrentStartTime(element.startTime);
setCurrentDuration(element.duration);
currentTrimStartRef.current = element.trimStart;
currentTrimEndRef.current = element.trimEnd;
currentStartTimeRef.current = element.startTime;
currentDurationRef.current = element.duration;
onResizeStateChange?.({ isResizing: true });
};
const canExtendElementDuration = useCallback(() => {
return element.sourceDuration == null;
}, [element.sourceDuration]);
const getSourceDeltaForClipDelta = useCallback(
(clipDelta: number) => {
if (!isRetimableElement(element)) {
return clipDelta;
}
return clipDelta >= 0
? getSourceSpanAtClipTime({
clipTime: clipDelta,
retime: element.retime,
})
: -getSourceSpanAtClipTime({
clipTime: Math.abs(clipDelta),
retime: element.retime,
});
},
[element],
);
const getVisibleSourceSpanForDuration = useCallback(
(duration: number) => {
if (!isRetimableElement(element)) {
return duration;
}
return getSourceSpanAtClipTime({
clipTime: duration,
retime: element.retime,
});
},
[element],
);
const getDurationForVisibleSourceSpan = useCallback(
(sourceSpan: number) => {
if (!isRetimableElement(element)) {
return sourceSpan;
}
return getTimelineDurationForSourceSpan({
sourceSpan,
retime: element.retime,
});
},
[element],
);
const getSourceDuration = useCallback(
({
trimStart,
duration,
trimEnd,
}: {
trimStart: number;
duration: number;
trimEnd: number;
}) => {
if (typeof element.sourceDuration === "number") {
return element.sourceDuration;
}
return trimStart + getVisibleSourceSpanForDuration(duration) + trimEnd;
},
[element.sourceDuration, getVisibleSourceSpanForDuration],
);
const cancelResize = useCallback(() => {
if (!resizing) return;
setCurrentTrimStart(resizing.initialTrimStart);
setCurrentTrimEnd(resizing.initialTrimEnd);
setCurrentStartTime(resizing.initialStartTime);
setCurrentDuration(resizing.initialDuration);
currentTrimStartRef.current = resizing.initialTrimStart;
currentTrimEndRef.current = resizing.initialTrimEnd;
currentStartTimeRef.current = resizing.initialStartTime;
currentDurationRef.current = resizing.initialDuration;
setResizing(null);
onResizeStateChange?.({ isResizing: false });
onSnapPointChange?.(null);
}, [resizing, onResizeStateChange, onSnapPointChange]);
useEffect(() => {
if (!resizing) return;
return registerCanceller({ fn: cancelResize });
}, [resizing, cancelResize]);
const updateTrimFromMouseMove = useCallback(
({ clientX }: { clientX: number }) => {
if (!resizing) return;
const deltaX = clientX - resizing.startX;
let deltaTime = Math.round(
(deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * TICKS_PER_SECOND,
);
let resizeSnapPoint: SnapPoint | null = null;
const projectFps = editor.project.getActive().settings.fps;
const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (shouldSnap) {
const tracks = editor.scenes.getActiveScene().tracks;
const playheadTime = editor.playback.getCurrentTime();
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementId: element.id,
});
if (resizing.side === "left") {
const targetStartTime = resizing.initialStartTime + deltaTime;
const snapResult = snapToNearestPoint({
targetTime: targetStartTime,
snapPoints,
zoomLevel,
});
resizeSnapPoint = snapResult.snapPoint;
if (snapResult.snapPoint) {
deltaTime = snapResult.snappedTime - resizing.initialStartTime;
}
} else {
const baseEndTime =
resizing.initialStartTime + resizing.initialDuration;
const targetEndTime = baseEndTime + deltaTime;
const snapResult = snapToNearestPoint({
targetTime: targetEndTime,
snapPoints,
zoomLevel,
});
resizeSnapPoint = snapResult.snapPoint;
if (snapResult.snapPoint) {
deltaTime = snapResult.snappedTime - baseEndTime;
}
}
}
onSnapPointChange?.(resizeSnapPoint);
const otherElements = track.elements.filter(
({ id }) => id !== element.id,
);
const initialEndTime =
resizing.initialStartTime + resizing.initialDuration;
const rightNeighborBound =
resizing.side === "right"
? otherElements
.filter(({ startTime }) => startTime >= initialEndTime)
.reduce(
(min, { startTime }) => Math.min(min, startTime),
Infinity,
)
: Infinity;
const leftNeighborBound =
resizing.side === "left"
? otherElements
.filter(
({ startTime, duration }) =>
startTime + duration <= resizing.initialStartTime,
)
.reduce(
(max, { startTime, duration }) =>
Math.max(max, startTime + duration),
-Infinity,
)
: -Infinity;
if (resizing.side === "left") {
const sourceDuration = getSourceDuration({
trimStart: resizing.initialTrimStart,
duration: resizing.initialDuration,
trimEnd: resizing.initialTrimEnd,
});
const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound)
? Math.max(
0,
resizing.initialTrimStart +
getSourceDeltaForClipDelta(
leftNeighborBound - resizing.initialStartTime,
),
)
: 0;
const maxAllowed =
sourceDuration -
resizing.initialTrimEnd -
getVisibleSourceSpanForDuration(minDuration);
const calculated =
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = roundToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), rate: projectFps }) ?? Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated));
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = roundToFrame({ time: resizing.initialStartTime + trimDelta, rate: projectFps }) ?? resizing.initialStartTime + trimDelta;
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = newTrimStart;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else if (calculated < 0) {
if (canExtendElementDuration()) {
const extensionAmount = Math.abs(calculated);
const maxExtension = resizing.initialStartTime;
const actualExtension = Math.max(
0,
Number.isFinite(leftNeighborBound)
? Math.min(
extensionAmount,
maxExtension,
resizing.initialStartTime - leftNeighborBound,
)
: Math.min(extensionAmount, maxExtension),
);
const newStartTime = roundToFrame({ time: resizing.initialStartTime - actualExtension, rate: projectFps }) ?? resizing.initialStartTime - actualExtension;
const newDuration = roundToFrame({ time: resizing.initialDuration + actualExtension, rate: projectFps }) ?? resizing.initialDuration + actualExtension;
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = 0;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
const leftBound = Number.isFinite(leftNeighborBound)
? leftNeighborBound
: 0;
const trimDeltaFromTrimStart =
minTrimStartForNeighbor - resizing.initialTrimStart;
const trimDeltaFromStartTime = getSourceDeltaForClipDelta(
leftBound - resizing.initialStartTime,
);
const trimDelta = Math.max(
trimDeltaFromTrimStart,
trimDeltaFromStartTime,
);
const newTrimStart = resizing.initialTrimStart + trimDelta;
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
const newStartTime = roundToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), rate: projectFps }) ?? resizing.initialStartTime + (resizing.initialDuration - newDuration);
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = newTrimStart;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
}
} else {
const sourceDuration = getSourceDuration({
trimStart: resizing.initialTrimStart,
duration: resizing.initialDuration,
trimEnd: resizing.initialTrimEnd,
});
const newTrimEnd =
resizing.initialTrimEnd - getSourceDeltaForClipDelta(deltaTime);
const maxAllowedDuration = Number.isFinite(rightNeighborBound)
? rightNeighborBound - resizing.initialStartTime
: Infinity;
if (newTrimEnd < 0) {
if (canExtendElementDuration()) {
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = roundToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), rate: projectFps }) ?? Math.min(baseDuration + extensionNeeded, maxAllowedDuration);
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
} else {
const unclampedDuration = getDurationForVisibleSourceSpan(
Math.max(0, sourceDuration - resizing.initialTrimStart),
);
const newDuration = roundToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), rate: projectFps }) ?? Math.min(unclampedDuration, maxAllowedDuration);
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
}
} else {
const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration)
? Math.max(
0,
sourceDuration -
resizing.initialTrimStart -
getVisibleSourceSpanForDuration(maxAllowedDuration),
)
: 0;
const maxTrimEnd =
sourceDuration -
resizing.initialTrimStart -
getVisibleSourceSpanForDuration(minDuration);
const clampedTrimEnd = Math.min(
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
);
const finalTrimEnd = roundToFrame({ time: clampedTrimEnd, rate: projectFps }) ?? clampedTrimEnd;
const visibleSourceSpan = Math.max(
0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
);
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
currentTrimEndRef.current = finalTrimEnd;
currentDurationRef.current = newDuration;
}
}
},
[
resizing,
zoomLevel,
snappingEnabled,
editor,
element.id,
track.elements,
onSnapPointChange,
canExtendElementDuration,
getDurationForVisibleSourceSpan,
getSourceDeltaForClipDelta,
getSourceDuration,
getVisibleSourceSpanForDuration,
isShiftHeldRef,
],
);
const handleResizeEnd = useCallback(() => {
if (!resizing) return;
const finalTrimStart = currentTrimStartRef.current;
const finalTrimEnd = currentTrimEndRef.current;
const finalStartTime = currentStartTimeRef.current;
const finalDuration = currentDurationRef.current;
const trimStartChanged = finalTrimStart !== resizing.initialTrimStart;
const trimEndChanged = finalTrimEnd !== resizing.initialTrimEnd;
const startTimeChanged = finalStartTime !== resizing.initialStartTime;
const durationChanged = finalDuration !== resizing.initialDuration;
if (
trimStartChanged ||
trimEndChanged ||
startTimeChanged ||
durationChanged
) {
editor.timeline.updateElementTrim({
elementId: element.id,
trimStart: finalTrimStart,
trimEnd: finalTrimEnd,
startTime: startTimeChanged ? finalStartTime : undefined,
duration: durationChanged ? finalDuration : undefined,
});
}
setResizing(null);
onResizeStateChange?.({ isResizing: false });
onSnapPointChange?.(null);
}, [
resizing,
editor.timeline,
element.id,
onResizeStateChange,
onSnapPointChange,
]);
useEffect(() => {
if (!resizing) return;
const handleDocumentMouseMove = ({ clientX }: MouseEvent) => {
updateTrimFromMouseMove({ clientX });
};
const handleDocumentMouseUp = () => {
handleResizeEnd();
};
document.addEventListener("mousemove", handleDocumentMouseMove);
document.addEventListener("mouseup", handleDocumentMouseUp);
return () => {
document.removeEventListener("mousemove", handleDocumentMouseMove);
document.removeEventListener("mouseup", handleDocumentMouseUp);
};
}, [resizing, handleResizeEnd, updateTrimFromMouseMove]);
return {
resizing,
isResizing: resizing !== null,
handleResizeStart,
currentTrimStart,
currentTrimEnd,
currentStartTime,
currentDuration,
};
}

View File

@ -0,0 +1,322 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { EditorCore } from "@/core";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useTimelineStore } from "@/stores/timeline-store";
import {
computeGroupResize,
type GroupResizeMember,
type GroupResizeResult,
type ResizeSide,
} from "@/lib/timeline/group-resize";
import {
findSnapPoints,
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
import type {
SceneTracks,
TimelineElement,
TimelineTrack,
} from "@/lib/timeline";
import { isRetimableElement } from "@/lib/timeline";
import { registerCanceller } from "@/lib/cancel-interaction";
interface ResizeInteractionState {
side: ResizeSide;
startX: number;
members: GroupResizeMember[];
}
export function useTimelineResize({
zoomLevel,
onSnapPointChange,
}: {
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const { selectedElements } = useElementSelection();
const [resizeState, setResizeState] = useState<ResizeInteractionState | null>(
null,
);
const latestResultRef = useRef<GroupResizeResult | null>(null);
const cancelResize = useCallback(() => {
editor.timeline.discardPreview();
setResizeState(null);
latestResultRef.current = null;
onSnapPointChange?.(null);
}, [editor.timeline, onSnapPointChange]);
useEffect(() => {
if (!resizeState) {
return;
}
return registerCanceller({ fn: cancelResize });
}, [resizeState, cancelResize]);
const handleResizeStart = useCallback(
({
event,
element,
track,
side,
}: {
event: React.MouseEvent;
element: TimelineElement;
track: TimelineTrack;
side: ResizeSide;
}) => {
event.stopPropagation();
event.preventDefault();
const elementRef = {
trackId: track.id,
elementId: element.id,
};
const activeSelection = selectedElements.some(
(selectedElement) =>
selectedElement.trackId === track.id &&
selectedElement.elementId === element.id,
)
? selectedElements
: [elementRef];
const members = buildResizeMembers({
tracks: editor.scenes.getActiveScene().tracks,
selectedElements: activeSelection,
});
if (members.length === 0) {
return;
}
editor.timeline.discardPreview();
latestResultRef.current = null;
setResizeState({
side,
startX: event.clientX,
members,
});
},
[selectedElements, editor],
);
useEffect(() => {
if (!resizeState) {
return;
}
const handleMouseMove = ({ clientX }: MouseEvent) => {
const deltaX = clientX - resizeState.startX;
const rawDeltaTime = Math.round(
(deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) *
TICKS_PER_SECOND,
);
const snappedDeltaTime = getSnappedResizeDelta({
editor,
resizeState,
rawDeltaTime,
zoomLevel,
shouldSnap: snappingEnabled && !isShiftHeldRef.current,
onSnapPointChange,
});
const fps = editor.project.getActive().settings.fps;
const result = computeGroupResize({
members: resizeState.members,
side: resizeState.side,
deltaTime: snappedDeltaTime.deltaTime,
fps,
});
latestResultRef.current = result;
editor.timeline.previewElements({
updates: result.updates.map(({ trackId, elementId, patch }) => ({
trackId,
elementId,
updates: patch,
})),
});
};
const handleMouseUp = () => {
const result = latestResultRef.current;
editor.timeline.discardPreview();
if (
result &&
hasResizeChanges({ members: resizeState.members, result })
) {
editor.timeline.updateElements({
updates: result.updates.map(({ trackId, elementId, patch }) => ({
trackId,
elementId,
patch,
})),
});
}
setResizeState(null);
latestResultRef.current = null;
onSnapPointChange?.(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [
resizeState,
zoomLevel,
snappingEnabled,
isShiftHeldRef,
editor,
onSnapPointChange,
]);
return {
isResizing: resizeState !== null,
handleResizeStart,
};
}
function buildResizeMembers({
tracks,
selectedElements,
}: {
tracks: SceneTracks;
selectedElements: Array<{ trackId: string; elementId: string }>;
}): GroupResizeMember[] {
const selectedElementIds = new Set(
selectedElements.map((selectedElement) => selectedElement.elementId),
);
const trackMap = new Map(
[...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
track.id,
track,
]),
);
return selectedElements.flatMap(({ trackId, elementId }) => {
const track = trackMap.get(trackId);
const element = track?.elements.find(
(trackElement) => trackElement.id === elementId,
);
if (!track || !element) {
return [];
}
const otherElements = track.elements.filter(
(trackElement) => !selectedElementIds.has(trackElement.id),
);
const leftNeighborBound = otherElements
.filter(
(trackElement) =>
trackElement.startTime + trackElement.duration <= element.startTime,
)
.reduce((bound, trackElement) => {
return Math.max(bound, trackElement.startTime + trackElement.duration);
}, -Infinity);
const rightNeighborBound = otherElements
.filter(
(trackElement) =>
trackElement.startTime >= element.startTime + element.duration,
)
.reduce((bound, trackElement) => {
return Math.min(bound, trackElement.startTime);
}, Infinity);
return [
{
trackId,
elementId,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
sourceDuration: element.sourceDuration,
retime: isRetimableElement(element) ? element.retime : undefined,
leftNeighborBound,
rightNeighborBound,
},
];
});
}
function getSnappedResizeDelta({
editor,
resizeState,
rawDeltaTime,
zoomLevel,
shouldSnap,
onSnapPointChange,
}: {
editor: EditorCore;
resizeState: ResizeInteractionState;
rawDeltaTime: number;
zoomLevel: number;
shouldSnap: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}): { deltaTime: number } {
if (!shouldSnap) {
onSnapPointChange?.(null);
return { deltaTime: rawDeltaTime };
}
const snapPoints = findSnapPoints({
tracks: editor.scenes.getActiveScene().tracks,
playheadTime: editor.playback.getCurrentTime(),
excludeElementIds: new Set(
resizeState.members.map((member) => member.elementId),
),
});
let closestSnapPoint: SnapPoint | null = null;
let closestSnapDistance = Infinity;
let deltaTime = rawDeltaTime;
for (const member of resizeState.members) {
const baseEdgeTime =
resizeState.side === "left"
? member.startTime
: member.startTime + member.duration;
const snapResult = snapToNearestPoint({
targetTime: baseEdgeTime + rawDeltaTime,
snapPoints,
zoomLevel,
});
if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) {
closestSnapDistance = snapResult.snapDistance;
closestSnapPoint = snapResult.snapPoint;
deltaTime = snapResult.snappedTime - baseEdgeTime;
}
}
onSnapPointChange?.(closestSnapPoint);
return { deltaTime };
}
function hasResizeChanges({
members,
result,
}: {
members: GroupResizeMember[];
result: GroupResizeResult;
}): boolean {
return result.updates.some((update) => {
const member = members.find(
(candidateMember) => candidateMember.elementId === update.elementId,
);
return (
member?.trimStart !== update.patch.trimStart ||
member?.trimEnd !== update.patch.trimEnd ||
member?.startTime !== update.patch.startTime ||
member?.duration !== update.patch.duration
);
});
}

View File

@ -3,140 +3,123 @@ import { EditorCore } from "@/core";
import type {
SceneTracks,
TimelineElement,
TrackType,
TimelineTrack,
} from "@/lib/timeline";
import {
buildEmptyTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/placement";
import {
findTrackInSceneTracks,
updateTrackInSceneTracks,
} from "@/lib/timeline/track-element-update";
import type {
PlannedElementMove,
PlannedTrackCreation,
} from "@/lib/timeline/group-move";
import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
export class MoveElementCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly sourceTrackId: string;
private readonly targetTrackId: string;
private readonly elementId: string;
private readonly newStartTime: number;
private readonly createTrack: { type: TrackType; index: number } | undefined;
constructor({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
moves,
createTracks = [],
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
moves: PlannedElementMove[];
createTracks?: PlannedTrackCreation[];
}) {
super();
this.sourceTrackId = sourceTrackId;
this.targetTrackId = targetTrackId;
this.elementId = elementId;
this.newStartTime = newStartTime;
this.createTrack = createTrack;
this.moves = moves;
this.createTracks = createTracks;
}
private readonly moves: PlannedElementMove[];
private readonly createTracks: PlannedTrackCreation[];
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks;
const sourceTrack = findTrackInSceneTracks({
tracks: this.savedState,
trackId: this.sourceTrackId,
});
const element = sourceTrack?.elements.find(
(trackElement) => trackElement.id === this.elementId,
);
if (!sourceTrack || !element) {
throw new Error("Source track or element not found");
}
let targetTrack = findTrackInSceneTracks({
tracks: this.savedState,
trackId: this.targetTrackId,
});
let tracksToUpdate = this.savedState;
if (!targetTrack && this.createTrack) {
const newTrack = buildEmptyTrack({
id: this.targetTrackId,
type: this.createTrack.type,
});
for (const createTrack of [...this.createTracks].sort(
(firstTrack, secondTrack) => firstTrack.index - secondTrack.index,
)) {
tracksToUpdate = insertTrackAtDisplayIndex({
tracks: this.savedState,
track: newTrack,
insertIndex: this.createTrack.index,
tracks: tracksToUpdate,
track: buildEmptyTrack({
id: createTrack.id,
type: createTrack.type,
}),
insertIndex: createTrack.index,
});
targetTrack = newTrack;
}
if (!targetTrack) {
throw new Error("Target track not found");
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
const movedElementsById = new Map<string, TimelineElement>();
for (const move of this.moves) {
const sourceTrack = findTrackInSceneTracks({
tracks: this.savedState,
trackId: move.sourceTrackId,
});
const sourceElement = sourceTrack?.elements.find(
(trackElement) => trackElement.id === move.elementId,
);
if (!sourceTrack || !sourceElement) {
throw new Error("Source track or element not found");
}
if (!validation.isValid) {
throw new Error(validation.errorMessage);
const targetTrack = findTrackInSceneTracks({
tracks: tracksToUpdate,
trackId: move.targetTrackId,
});
if (!targetTrack) {
throw new Error("Target track not found");
}
const validation = validateElementTrackCompatibility({
element: sourceElement,
track: targetTrack,
});
if (!validation.isValid) {
throw new Error(validation.errorMessage);
}
movedElementsById.set(move.elementId, {
...sourceElement,
startTime: move.newStartTime,
});
}
const adjustedStartTime = enforceMainTrackStart({
const movedElementIds = new Set(this.moves.map((move) => move.elementId));
const movedElementsByTargetTrackId = new Map<string, TimelineElement[]>();
for (const move of this.moves) {
const movedElement = movedElementsById.get(move.elementId);
if (!movedElement) {
continue;
}
const nextTargetElements =
movedElementsByTargetTrackId.get(move.targetTrackId) ?? [];
nextTargetElements.push(movedElement);
movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements);
}
const updatedTracks = mapSceneTracks({
tracks: tracksToUpdate,
targetTrackId: this.targetTrackId,
requestedStartTime: this.newStartTime,
excludeElementId: this.elementId,
update: (track) => ({
...track,
elements: [
...track.elements.filter(
(element) => !movedElementIds.has(element.id),
),
...(movedElementsByTargetTrackId.get(track.id) ?? []),
],
}),
});
// keyframe times remain clip-local, so moving only changes element startTime.
const movedElement: TimelineElement = {
...element,
startTime: adjustedStartTime,
};
const isSameTrack = this.sourceTrackId === this.targetTrackId;
const updatedTracks = isSameTrack
? updateTrackInSceneTracks({
tracks: tracksToUpdate,
trackId: this.sourceTrackId,
update: (track) => ({
...track,
elements: track.elements.map((trackElement) =>
trackElement.id === this.elementId ? movedElement : trackElement,
),
}),
})
: updateTrackInSceneTracks({
tracks: updateTrackInSceneTracks({
tracks: tracksToUpdate,
trackId: this.sourceTrackId,
update: (track) => ({
...track,
elements: track.elements.filter(
(trackElement) => trackElement.id !== this.elementId,
),
}),
}),
trackId: this.targetTrackId,
update: (track) => ({
...track,
elements: [...track.elements, movedElement],
}),
});
editor.timeline.updateTracks(updatedTracks);
return undefined;
return {
select: this.moves.map(({ elementId, targetTrackId }) => ({
trackId: targetTrackId,
elementId,
})),
};
}
undo(): void {
@ -147,6 +130,20 @@ export class MoveElementCommand extends Command {
}
}
function mapSceneTracks({
tracks,
update,
}: {
tracks: SceneTracks;
update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
}): SceneTracks {
return {
overlay: tracks.overlay.map((track) => update(track)),
main: update(tracks.main),
audio: tracks.audio.map((track) => update(track)),
};
}
function insertTrackAtDisplayIndex({
tracks,
track,

View File

@ -0,0 +1,287 @@
import { describe, expect, test } from "bun:test";
import type {
AudioTrack,
OverlayTrack,
SceneTracks,
TextElement,
TextTrack,
VideoElement,
VideoTrack,
} from "@/lib/timeline/types";
import type { Transform } from "@/lib/rendering";
import { buildMoveGroup } from "@/lib/timeline/group-move/build-group";
import { resolveGroupMove } from "@/lib/timeline/group-move/resolve-move";
function buildTransform(): Transform {
return {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
};
}
function buildVideoElement({
id,
startTime,
duration,
}: {
id: string;
startTime: number;
duration: number;
}): VideoElement {
return {
id,
type: "video",
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
mediaId: `${id}-media`,
transform: buildTransform(),
opacity: 1,
};
}
function buildTextElement({
id,
startTime,
duration,
}: {
id: string;
startTime: number;
duration: number;
}): TextElement {
return {
id,
type: "text",
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
content: id,
fontSize: 32,
fontFamily: "Inter",
color: "#ffffff",
background: {
enabled: false,
color: "#000000",
},
textAlign: "left",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
transform: buildTransform(),
opacity: 1,
};
}
function buildVideoTrack({
id,
elements = [],
}: {
id: string;
elements?: VideoTrack["elements"];
}): VideoTrack {
return {
id,
type: "video",
name: id,
elements,
muted: false,
hidden: false,
};
}
function buildTextTrack({
id,
elements = [],
}: {
id: string;
elements?: TextTrack["elements"];
}): TextTrack {
return {
id,
type: "text",
name: id,
elements,
hidden: false,
};
}
function buildTracks({
overlay,
main,
audio = [],
}: {
overlay: OverlayTrack[];
main: VideoTrack;
audio?: AudioTrack[];
}): SceneTracks {
return {
overlay,
main,
audio,
};
}
describe("group move", () => {
test("buildMoveGroup keeps rigid time offsets", () => {
const tracks = buildTracks({
overlay: [
buildVideoTrack({
id: "overlay-video",
elements: [
buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }),
],
}),
buildTextTrack({
id: "overlay-text",
elements: [
buildTextElement({ id: "text-1", startTime: 8, duration: 3 }),
],
}),
],
main: buildVideoTrack({ id: "main" }),
});
const group = buildMoveGroup({
anchorRef: { trackId: "overlay-video", elementId: "video-1" },
selectedElements: [
{ trackId: "overlay-video", elementId: "video-1" },
{ trackId: "overlay-text", elementId: "text-1" },
],
tracks,
});
expect(group).not.toBeNull();
expect(group?.members.map((member) => member.timeOffset)).toEqual([0, 3]);
});
test("resolveGroupMove preserves order and clamps rigidly at time zero", () => {
const tracks = buildTracks({
overlay: [
buildTextTrack({
id: "overlay-text",
elements: [
buildTextElement({ id: "text-1", startTime: 0, duration: 3 }),
],
}),
buildVideoTrack({
id: "overlay-video",
elements: [
buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }),
],
}),
],
main: buildVideoTrack({ id: "main" }),
});
const group = buildMoveGroup({
anchorRef: { trackId: "overlay-video", elementId: "video-1" },
selectedElements: [
{ trackId: "overlay-video", elementId: "video-1" },
{ trackId: "overlay-text", elementId: "text-1" },
],
tracks,
});
if (!group) {
throw new Error("Expected group");
}
const result = resolveGroupMove({
group,
tracks,
anchorStartTime: 7,
target: {
kind: "existingTrack",
anchorTargetTrackId: "main",
},
});
expect(result?.moves).toEqual([
{
sourceTrackId: "overlay-video",
elementId: "video-1",
targetTrackId: "main",
newStartTime: 5,
},
{
sourceTrackId: "overlay-text",
elementId: "text-1",
targetTrackId: "overlay-text",
newStartTime: 0,
},
]);
});
test("resolveGroupMove falls back to new overlay tracks when the group cannot cross main", () => {
const tracks = buildTracks({
overlay: [
buildVideoTrack({
id: "overlay-video",
elements: [
buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }),
],
}),
buildTextTrack({
id: "overlay-text",
elements: [
buildTextElement({ id: "text-1", startTime: 5, duration: 3 }),
],
}),
],
main: buildVideoTrack({ id: "main" }),
});
const group = buildMoveGroup({
anchorRef: { trackId: "overlay-video", elementId: "video-1" },
selectedElements: [
{ trackId: "overlay-video", elementId: "video-1" },
{ trackId: "overlay-text", elementId: "text-1" },
],
tracks,
});
if (!group) {
throw new Error("Expected group");
}
const result = resolveGroupMove({
group,
tracks,
anchorStartTime: 6,
target: {
kind: "newTracks",
anchorInsertIndex: 2,
newTrackIds: ["new-video-track", "new-text-track"],
},
});
expect(result?.createTracks).toEqual([
{
id: "new-video-track",
type: "video",
index: 2,
},
{
id: "new-text-track",
type: "text",
index: 3,
},
]);
expect(result?.moves).toEqual([
{
sourceTrackId: "overlay-video",
elementId: "video-1",
targetTrackId: "new-video-track",
newStartTime: 6,
},
{
sourceTrackId: "overlay-text",
elementId: "text-1",
targetTrackId: "new-text-track",
newStartTime: 6,
},
]);
});
});

View File

@ -0,0 +1,87 @@
import type { ElementRef, SceneTracks } from "@/lib/timeline";
import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
import type { GroupMember, MoveGroup } from "./types";
import { getTrackPlacementById } from "./track-placement";
export function buildMoveGroup({
anchorRef,
selectedElements,
tracks,
}: {
anchorRef: ElementRef;
selectedElements: ElementRef[];
tracks: SceneTracks;
}): MoveGroup | null {
const anchorTrack = findTrackInSceneTracks({
tracks,
trackId: anchorRef.trackId,
});
const anchorElement = anchorTrack?.elements.find(
(element) => element.id === anchorRef.elementId,
);
const anchorPlacement = getTrackPlacementById({
tracks,
trackId: anchorRef.trackId,
});
if (!anchorTrack || !anchorElement || !anchorPlacement) {
return null;
}
const seen = new Set<string>();
const orderedRefs = [anchorRef, ...selectedElements].filter((elementRef) => {
if (seen.has(elementRef.elementId)) {
return false;
}
seen.add(elementRef.elementId);
return true;
});
const members = orderedRefs.flatMap((elementRef): GroupMember[] => {
const track = findTrackInSceneTracks({
tracks,
trackId: elementRef.trackId,
});
const element = track?.elements.find(
(trackElement) => trackElement.id === elementRef.elementId,
);
const placement = getTrackPlacementById({
tracks,
trackId: elementRef.trackId,
});
if (!track || !element || !placement) {
return [];
}
return [
{
trackId: track.id,
elementId: element.id,
elementType: element.type,
duration: element.duration,
timeOffset: element.startTime - anchorElement.startTime,
trackSection: placement.section,
sectionIndex: placement.sectionIndex,
displayIndex: placement.displayIndex,
},
];
});
if (members.length === 0) {
return null;
}
const anchor = members.find(
(member) =>
member.trackId === anchorRef.trackId &&
member.elementId === anchorRef.elementId,
);
if (!anchor) {
return null;
}
return {
anchor,
members,
};
}

View File

@ -0,0 +1,12 @@
export { buildMoveGroup } from "./build-group";
export { resolveGroupMove } from "./resolve-move";
export { snapGroupEdges } from "./snap";
export type {
GroupMember,
GroupMoveResult,
GroupTrackSection,
MoveGroup,
PlannedElementMove,
PlannedTrackCreation,
} from "./types";

View File

@ -0,0 +1,470 @@
import type { SceneTracks } from "@/lib/timeline";
import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility";
import { canPlaceTimeSpansOnTrack } from "@/lib/timeline/placement/overlap";
import type {
GroupMoveResult,
MoveGroup,
PlannedElementMove,
PlannedTrackCreation,
} from "./types";
import {
getDisplayTracks,
getTrackPlacementByDisplayIndex,
getTrackPlacementById,
} from "./track-placement";
type GroupMoveTarget =
| {
kind: "existingTrack";
anchorTargetTrackId: string;
}
| {
kind: "newTracks";
anchorInsertIndex: number;
newTrackIds: string[];
};
export function resolveGroupMove({
group,
tracks,
anchorStartTime,
target,
}: {
group: MoveGroup;
tracks: SceneTracks;
anchorStartTime: number;
target: GroupMoveTarget;
}): GroupMoveResult | null {
if (target.kind === "newTracks") {
return resolveNewTrackMove({
group,
tracks,
anchorStartTime,
anchorInsertIndex: target.anchorInsertIndex,
newTrackIds: target.newTrackIds,
});
}
return resolveExistingTrackMove({
group,
tracks,
anchorStartTime,
anchorTargetTrackId: target.anchorTargetTrackId,
});
}
function resolveExistingTrackMove({
group,
tracks,
anchorStartTime,
anchorTargetTrackId,
}: {
group: MoveGroup;
tracks: SceneTracks;
anchorStartTime: number;
anchorTargetTrackId: string;
}): GroupMoveResult | null {
const anchorTargetPlacement = getTrackPlacementById({
tracks,
trackId: anchorTargetTrackId,
});
if (!anchorTargetPlacement) {
return null;
}
const targetTrackIdsByElementId = resolveExistingTrackIdsByElementId({
group,
tracks,
anchorTargetDisplayIndex: anchorTargetPlacement.displayIndex,
});
if (!targetTrackIdsByElementId) {
return null;
}
const clampedAnchorStartTime = clampAnchorStartTime({
group,
tracks,
anchorStartTime,
targetTrackIdsByElementId,
});
const moves = group.members.map((member) => ({
sourceTrackId: member.trackId,
targetTrackId:
targetTrackIdsByElementId.get(member.elementId) ?? member.trackId,
elementId: member.elementId,
newStartTime: clampedAnchorStartTime + member.timeOffset,
}));
if (!canApplyMovesToExistingTracks({ tracks, moves })) {
return null;
}
return {
moves,
createTracks: [],
targetSelection: moves.map(({ elementId, targetTrackId }) => ({
trackId: targetTrackId,
elementId,
})),
};
}
function resolveNewTrackMove({
group,
tracks,
anchorStartTime,
anchorInsertIndex,
newTrackIds,
}: {
group: MoveGroup;
tracks: SceneTracks;
anchorStartTime: number;
anchorInsertIndex: number;
newTrackIds: string[];
}): GroupMoveResult | null {
const sortedMembers = [...group.members].sort(
(leftMember, rightMember) =>
leftMember.displayIndex - rightMember.displayIndex,
);
const anchorMemberIndex = sortedMembers.findIndex(
(member) => member.elementId === group.anchor.elementId,
);
if (anchorMemberIndex < 0 || newTrackIds.length < sortedMembers.length) {
return null;
}
const hasAudioMember = sortedMembers.some(
(member) => member.trackSection === "audio",
);
const hasNonAudioMember = sortedMembers.some(
(member) => member.trackSection !== "audio",
);
if (hasAudioMember && hasNonAudioMember) {
return null;
}
const clampedAnchorStartTime = clampAnchorStartTime({
group,
tracks,
anchorStartTime,
targetTrackIdsByElementId: new Map(),
});
const blockStartIndex = hasAudioMember
? clampAudioInsertIndex({
tracks,
insertIndex: anchorInsertIndex - anchorMemberIndex,
})
: Math.max(
0,
Math.min(anchorInsertIndex - anchorMemberIndex, tracks.overlay.length),
);
const createTracks: PlannedTrackCreation[] = sortedMembers.map(
(member, memberIndex) => ({
id: newTrackIds[memberIndex],
type: getTrackTypeForElementType({
elementType: member.elementType,
}),
index: blockStartIndex + memberIndex,
}),
);
const moves = sortedMembers.map((member, memberIndex) => ({
sourceTrackId: member.trackId,
targetTrackId: newTrackIds[memberIndex],
elementId: member.elementId,
newStartTime: clampedAnchorStartTime + member.timeOffset,
}));
return {
moves,
createTracks,
targetSelection: moves.map(({ elementId, targetTrackId }) => ({
trackId: targetTrackId,
elementId,
})),
};
}
function clampAudioInsertIndex({
tracks,
insertIndex,
}: {
tracks: SceneTracks;
insertIndex: number;
}): number {
const minimumAudioInsertIndex = tracks.overlay.length + 1;
return Math.max(
minimumAudioInsertIndex,
Math.min(insertIndex, minimumAudioInsertIndex + tracks.audio.length),
);
}
function resolveExistingTrackIdsByElementId({
group,
tracks,
anchorTargetDisplayIndex,
}: {
group: MoveGroup;
tracks: SceneTracks;
anchorTargetDisplayIndex: number;
}): Map<string, string> | null {
const sortedMembers = [...group.members].sort(
(leftMember, rightMember) =>
leftMember.displayIndex - rightMember.displayIndex,
);
const anchorMemberIndex = sortedMembers.findIndex(
(member) => member.elementId === group.anchor.elementId,
);
if (anchorMemberIndex < 0) {
return null;
}
const targetTrackIdsByElementId = new Map<string, string>();
const usedTrackIds = new Set<string>();
const anchorPlacement = getTrackPlacementByDisplayIndex({
tracks,
displayIndex: anchorTargetDisplayIndex,
});
if (!anchorPlacement) {
return null;
}
targetTrackIdsByElementId.set(
group.anchor.elementId,
anchorPlacement.trackId,
);
usedTrackIds.add(anchorPlacement.trackId);
let upperBoundaryIndex = anchorTargetDisplayIndex;
for (
let memberIndex = anchorMemberIndex - 1;
memberIndex >= 0;
memberIndex -= 1
) {
const member = sortedMembers[memberIndex];
const targetPlacement = findCompatibleTrackPlacement({
tracks,
requiredTrackType: getTrackTypeForElementType({
elementType: member.elementType,
}),
startDisplayIndex: upperBoundaryIndex - 1,
step: -1,
usedTrackIds,
});
if (!targetPlacement) {
return null;
}
targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId);
usedTrackIds.add(targetPlacement.trackId);
upperBoundaryIndex = targetPlacement.displayIndex;
}
let lowerBoundaryIndex = anchorTargetDisplayIndex;
for (
let memberIndex = anchorMemberIndex + 1;
memberIndex < sortedMembers.length;
memberIndex += 1
) {
const member = sortedMembers[memberIndex];
const targetPlacement = findCompatibleTrackPlacement({
tracks,
requiredTrackType: getTrackTypeForElementType({
elementType: member.elementType,
}),
startDisplayIndex: lowerBoundaryIndex + 1,
step: 1,
usedTrackIds,
});
if (!targetPlacement) {
return null;
}
targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId);
usedTrackIds.add(targetPlacement.trackId);
lowerBoundaryIndex = targetPlacement.displayIndex;
}
return targetTrackIdsByElementId;
}
function findCompatibleTrackPlacement({
tracks,
requiredTrackType,
startDisplayIndex,
step,
usedTrackIds,
}: {
tracks: SceneTracks;
requiredTrackType: ReturnType<typeof getTrackTypeForElementType>;
startDisplayIndex: number;
step: -1 | 1;
usedTrackIds: Set<string>;
}) {
for (
let displayIndex = startDisplayIndex;
displayIndex >= 0 &&
displayIndex < tracks.overlay.length + 1 + tracks.audio.length;
displayIndex += step
) {
const placement = getTrackPlacementByDisplayIndex({
tracks,
displayIndex,
});
if (!placement) {
continue;
}
if (
placement.trackType === requiredTrackType &&
!usedTrackIds.has(placement.trackId)
) {
return placement;
}
}
return null;
}
function clampAnchorStartTime({
group,
tracks,
anchorStartTime,
targetTrackIdsByElementId,
}: {
group: MoveGroup;
tracks: SceneTracks;
anchorStartTime: number;
targetTrackIdsByElementId: Map<string, string>;
}): number {
const minimumAnchorStartTime = Math.max(
0,
...group.members.map((member) => -member.timeOffset),
);
let clampedAnchorStartTime = Math.max(
minimumAnchorStartTime,
anchorStartTime,
);
const memberOnMainTrack = group.members.find(
(member) =>
targetTrackIdsByElementId.get(member.elementId) === tracks.main.id,
);
if (!memberOnMainTrack) {
return clampedAnchorStartTime;
}
const movingElementIds = new Set(
group.members.map((member) => member.elementId),
);
const requestedMainStartTime =
clampedAnchorStartTime + memberOnMainTrack.timeOffset;
const earliestStationaryMainStartTime = tracks.main.elements
.filter((element) => !movingElementIds.has(element.id))
.reduce<number | null>((earliestStartTime, element) => {
if (earliestStartTime == null || element.startTime < earliestStartTime) {
return element.startTime;
}
return earliestStartTime;
}, null);
if (
earliestStationaryMainStartTime == null ||
requestedMainStartTime <= earliestStationaryMainStartTime
) {
clampedAnchorStartTime = Math.max(
minimumAnchorStartTime,
-memberOnMainTrack.timeOffset,
);
}
return clampedAnchorStartTime;
}
function canApplyMovesToExistingTracks({
tracks,
moves,
}: {
tracks: SceneTracks;
moves: PlannedElementMove[];
}): boolean {
const movingElementIds = new Set(moves.map((move) => move.elementId));
const sourceElements = new Map(
getDisplayTracks({ tracks }).flatMap((track) =>
track.elements.map((element) => [element.id, element] as const),
),
);
const movesByTargetTrackId = new Map<string, PlannedElementMove[]>();
for (const move of moves) {
const targetMoves = movesByTargetTrackId.get(move.targetTrackId) ?? [];
targetMoves.push(move);
movesByTargetTrackId.set(move.targetTrackId, targetMoves);
}
for (const [targetTrackId, targetMoves] of movesByTargetTrackId) {
const targetPlacement = getTrackPlacementById({
tracks,
trackId: targetTrackId,
});
if (!targetPlacement) {
return false;
}
const targetTrack = getDisplayTracks({ tracks })[
targetPlacement.displayIndex
];
if (!targetTrack) {
return false;
}
const timeSpans = targetMoves.map((move) => {
const sourceElement = sourceElements.get(move.elementId);
return {
startTime: move.newStartTime,
duration: sourceElement?.duration ?? 0,
};
});
if (hasOverlappingTimeSpans({ timeSpans })) {
return false;
}
if (
!canPlaceTimeSpansOnTrack({
track: {
elements: targetTrack.elements.filter(
(element) => !movingElementIds.has(element.id),
),
},
timeSpans,
})
) {
return false;
}
}
return true;
}
function hasOverlappingTimeSpans({
timeSpans,
}: {
timeSpans: Array<{ startTime: number; duration: number }>;
}): boolean {
const sortedSpans = [...timeSpans].sort(
(leftSpan, rightSpan) => leftSpan.startTime - rightSpan.startTime,
);
for (let spanIndex = 1; spanIndex < sortedSpans.length; spanIndex += 1) {
const previousSpan = sortedSpans[spanIndex - 1];
const currentSpan = sortedSpans[spanIndex];
if (
previousSpan.startTime + previousSpan.duration >
currentSpan.startTime
) {
return true;
}
}
return false;
}

View File

@ -0,0 +1,71 @@
import type { SceneTracks } from "@/lib/timeline";
import {
findSnapPoints,
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
import type { MoveGroup } from "./types";
export function snapGroupEdges({
group,
anchorStartTime,
tracks,
playheadTime,
zoomLevel,
}: {
group: MoveGroup;
anchorStartTime: number;
tracks: SceneTracks;
playheadTime: number;
zoomLevel: number;
}): {
snappedAnchorStartTime: number;
snapPoint: SnapPoint | null;
} {
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementIds: new Set(group.members.map((member) => member.elementId)),
});
let closestSnapDistance = Infinity;
let snappedAnchorStartTime = anchorStartTime;
let snapPoint: SnapPoint | null = null;
for (const member of group.members) {
const memberStartTime = anchorStartTime + member.timeOffset;
const memberStartSnap = snapToNearestPoint({
targetTime: memberStartTime,
snapPoints,
zoomLevel,
});
if (
memberStartSnap.snapPoint &&
memberStartSnap.snapDistance < closestSnapDistance
) {
closestSnapDistance = memberStartSnap.snapDistance;
snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset;
snapPoint = memberStartSnap.snapPoint;
}
const memberEndSnap = snapToNearestPoint({
targetTime: memberStartTime + member.duration,
snapPoints,
zoomLevel,
});
if (
memberEndSnap.snapPoint &&
memberEndSnap.snapDistance < closestSnapDistance
) {
closestSnapDistance = memberEndSnap.snapDistance;
snappedAnchorStartTime =
memberEndSnap.snappedTime - member.duration - member.timeOffset;
snapPoint = memberEndSnap.snapPoint;
}
}
return {
snappedAnchorStartTime,
snapPoint,
};
}

View File

@ -0,0 +1,83 @@
import type { SceneTracks, TimelineTrack } from "@/lib/timeline";
import type { GroupTrackSection } from "./types";
export interface TrackPlacement {
trackId: string;
trackType: TimelineTrack["type"];
section: GroupTrackSection;
sectionIndex: number;
displayIndex: number;
}
export function getDisplayTracks({
tracks,
}: {
tracks: SceneTracks;
}): TimelineTrack[] {
return [...tracks.overlay, tracks.main, ...tracks.audio];
}
export function getTrackPlacementById({
tracks,
trackId,
}: {
tracks: SceneTracks;
trackId: string;
}): TrackPlacement | null {
if (tracks.main.id === trackId) {
return {
trackId,
trackType: tracks.main.type,
section: "main",
sectionIndex: -1,
displayIndex: tracks.overlay.length,
};
}
const overlayTrackIndex = tracks.overlay.findIndex(
(track) => track.id === trackId,
);
if (overlayTrackIndex >= 0) {
return {
trackId,
trackType: tracks.overlay[overlayTrackIndex].type,
section: "overlay",
sectionIndex: overlayTrackIndex,
displayIndex: overlayTrackIndex,
};
}
const audioTrackIndex = tracks.audio.findIndex(
(track) => track.id === trackId,
);
if (audioTrackIndex >= 0) {
return {
trackId,
trackType: tracks.audio[audioTrackIndex].type,
section: "audio",
sectionIndex: audioTrackIndex,
displayIndex: tracks.overlay.length + 1 + audioTrackIndex,
};
}
return null;
}
export function getTrackPlacementByDisplayIndex({
tracks,
displayIndex,
}: {
tracks: SceneTracks;
displayIndex: number;
}): TrackPlacement | null {
const displayTracks = getDisplayTracks({ tracks });
const track = displayTracks[displayIndex];
if (!track) {
return null;
}
return getTrackPlacementById({
tracks,
trackId: track.id,
});
}

View File

@ -0,0 +1,36 @@
import type { ElementRef, ElementType, TrackType } from "@/lib/timeline";
export type GroupTrackSection = "overlay" | "main" | "audio";
export interface GroupMember extends ElementRef {
elementType: ElementType;
duration: number;
timeOffset: number;
trackSection: GroupTrackSection;
sectionIndex: number;
displayIndex: number;
}
export interface MoveGroup {
anchor: GroupMember;
members: GroupMember[];
}
export interface PlannedTrackCreation {
id: string;
type: TrackType;
index: number;
}
export interface PlannedElementMove {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
}
export interface GroupMoveResult {
moves: PlannedElementMove[];
createTracks: PlannedTrackCreation[];
targetSelection: ElementRef[];
}

View File

@ -0,0 +1,290 @@
import { roundToFrame } from "opencut-wasm";
import {
getSourceSpanAtClipTime,
getTimelineDurationForSourceSpan,
} from "@/lib/retime";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import type {
ComputeGroupResizeArgs,
GroupResizeMember,
GroupResizeResult,
GroupResizeUpdate,
ResizeSide,
} from "./types";
export function computeGroupResize({
members,
side,
deltaTime,
fps,
}: ComputeGroupResizeArgs): GroupResizeResult {
const minDuration = Math.round(
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
);
const minimumDeltaTime = Math.max(
...members.map((member) =>
getMinimumAllowedDeltaTime({
member,
side,
minDuration,
}),
),
);
const maximumDeltaTime = Math.min(
...members.map((member) =>
getMaximumAllowedDeltaTime({
member,
side,
minDuration,
}),
),
);
const clampedDeltaTime =
minimumDeltaTime > maximumDeltaTime
? minimumDeltaTime
: Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime));
return {
deltaTime: Object.is(clampedDeltaTime, -0) ? 0 : clampedDeltaTime,
updates: members.map((member) =>
buildResizeUpdate({
member,
side,
deltaTime: clampedDeltaTime,
fps,
}),
),
};
}
function buildResizeUpdate({
member,
side,
deltaTime,
fps,
}: {
member: GroupResizeMember;
side: ResizeSide;
deltaTime: number;
fps: ComputeGroupResizeArgs["fps"];
}): GroupResizeUpdate {
const totalSourceDuration = getSourceDuration({ member });
const sourceDelta = getSourceDeltaForClipDelta({
member,
clipDelta: deltaTime,
});
const visibleSourceSpan = getVisibleSourceSpanForDuration({
member,
duration: member.duration,
});
if (side === "left") {
if (deltaTime < 0 && member.sourceDuration == null) {
const rawStartTime = member.startTime + deltaTime;
const rawDuration = member.duration - deltaTime;
return {
trackId: member.trackId,
elementId: member.elementId,
patch: {
trimStart: Math.max(0, member.trimStart + sourceDelta),
trimEnd: member.trimEnd,
startTime:
roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime,
duration:
roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
},
};
}
const nextTrimStart = Math.max(0, member.trimStart + sourceDelta);
const nextVisibleSourceSpan = Math.max(
0,
totalSourceDuration - nextTrimStart - member.trimEnd,
);
const rawDuration = getDurationForVisibleSourceSpan({
member,
sourceSpan: nextVisibleSourceSpan,
});
const nextDuration =
roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration;
const rawStartTime = member.startTime + (member.duration - nextDuration);
return {
trackId: member.trackId,
elementId: member.elementId,
patch: {
trimStart:
roundToFrame({ time: nextTrimStart, rate: fps }) ?? nextTrimStart,
trimEnd: member.trimEnd,
startTime:
roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime,
duration: nextDuration,
},
};
}
const nextVisibleSourceSpan = Math.max(0, visibleSourceSpan + sourceDelta);
if (deltaTime > 0 && member.sourceDuration == null) {
const rawDuration = member.duration + deltaTime;
return {
trackId: member.trackId,
elementId: member.elementId,
patch: {
trimStart: member.trimStart,
trimEnd: Math.max(0, member.trimEnd - sourceDelta),
startTime: member.startTime,
duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
},
};
}
const nextTrimEnd = Math.max(
0,
totalSourceDuration - member.trimStart - nextVisibleSourceSpan,
);
const rawDuration = getDurationForVisibleSourceSpan({
member,
sourceSpan: nextVisibleSourceSpan,
});
return {
trackId: member.trackId,
elementId: member.elementId,
patch: {
trimStart: member.trimStart,
trimEnd: roundToFrame({ time: nextTrimEnd, rate: fps }) ?? nextTrimEnd,
startTime: member.startTime,
duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
},
};
}
function getMinimumAllowedDeltaTime({
member,
side,
minDuration,
}: {
member: GroupResizeMember;
side: ResizeSide;
minDuration: number;
}): number {
if (side === "right") {
return minDuration - member.duration;
}
const leftNeighborFloor = Number.isFinite(member.leftNeighborBound)
? member.leftNeighborBound - member.startTime
: -member.startTime;
if (member.sourceDuration == null) {
return leftNeighborFloor;
}
const maximumSourceExtension =
getDurationForVisibleSourceSpan({
member,
sourceSpan:
getVisibleSourceSpanForDuration({
member,
duration: member.duration,
}) + member.trimStart,
}) - member.duration;
return Math.max(leftNeighborFloor, -maximumSourceExtension);
}
function getMaximumAllowedDeltaTime({
member,
side,
minDuration,
}: {
member: GroupResizeMember;
side: ResizeSide;
minDuration: number;
}): number {
if (side === "left") {
return member.duration - minDuration;
}
const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound)
? member.rightNeighborBound - (member.startTime + member.duration)
: Infinity;
if (member.sourceDuration == null) {
return rightNeighborCeiling;
}
const maximumVisibleSourceSpan =
getSourceDuration({ member }) - member.trimStart;
const maximumDuration = getDurationForVisibleSourceSpan({
member,
sourceSpan: maximumVisibleSourceSpan,
});
return Math.min(rightNeighborCeiling, maximumDuration - member.duration);
}
function getSourceDeltaForClipDelta({
member,
clipDelta,
}: {
member: GroupResizeMember;
clipDelta: number;
}): number {
if (!member.retime) {
return clipDelta;
}
return clipDelta >= 0
? getSourceSpanAtClipTime({
clipTime: clipDelta,
retime: member.retime,
})
: -getSourceSpanAtClipTime({
clipTime: Math.abs(clipDelta),
retime: member.retime,
});
}
function getVisibleSourceSpanForDuration({
member,
duration,
}: {
member: GroupResizeMember;
duration: number;
}): number {
if (!member.retime) {
return duration;
}
return getSourceSpanAtClipTime({
clipTime: duration,
retime: member.retime,
});
}
function getDurationForVisibleSourceSpan({
member,
sourceSpan,
}: {
member: GroupResizeMember;
sourceSpan: number;
}): number {
if (!member.retime) {
return sourceSpan;
}
return getTimelineDurationForSourceSpan({
sourceSpan,
retime: member.retime,
});
}
function getSourceDuration({ member }: { member: GroupResizeMember }): number {
if (typeof member.sourceDuration === "number") {
return member.sourceDuration;
}
return (
member.trimStart +
getVisibleSourceSpanForDuration({
member,
duration: member.duration,
}) +
member.trimEnd
);
}

View File

@ -0,0 +1,9 @@
export { computeGroupResize } from "./compute-resize";
export type {
ComputeGroupResizeArgs,
GroupResizeMember,
GroupResizeResult,
GroupResizeUpdate,
ResizeSide,
} from "./types";

View File

@ -0,0 +1,36 @@
import type { FrameRate } from "opencut-wasm";
import type { ElementRef, RetimeConfig } from "@/lib/timeline/types";
export type ResizeSide = "left" | "right";
export interface GroupResizeMember extends ElementRef {
startTime: number;
duration: number;
trimStart: number;
trimEnd: number;
sourceDuration?: number;
retime?: RetimeConfig;
leftNeighborBound: number;
rightNeighborBound: number;
}
export interface GroupResizeUpdate extends ElementRef {
patch: {
trimStart: number;
trimEnd: number;
startTime: number;
duration: number;
};
}
export interface GroupResizeResult {
deltaTime: number;
updates: GroupResizeUpdate[];
}
export interface ComputeGroupResizeArgs {
members: GroupResizeMember[];
side: ResizeSide;
deltaTime: number;
fps: FrameRate;
}

View File

@ -329,7 +329,12 @@ describe("resolveTrackPlacement", () => {
id: "graphic-1",
type: "graphic",
elements: [
buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }),
buildElement({
id: "a",
type: "graphic",
startTime: 0,
duration: 5,
}),
],
}),
],

View File

@ -1,6 +1,10 @@
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import type { TimelineElement } from "@/lib/timeline";
import type { PlacementTimeSpan } from "./types";
interface TrackWithElements {
elements: TimelineElement[];
}
function wouldElementOverlap({
elements,
startTime,
@ -26,7 +30,7 @@ export function canPlaceTimeSpansOnTrack({
track,
timeSpans,
}: {
track: TimelineTrack;
track: TrackWithElements;
timeSpans: PlacementTimeSpan[];
}): boolean {
return timeSpans.every(({ startTime, duration, excludeElementId }) => {

View File

@ -22,6 +22,7 @@ export function findSnapPoints({
tracks,
playheadTime,
excludeElementId,
excludeElementIds,
bookmarks = [],
excludeBookmarkTime,
enableElementSnapping = true,
@ -32,6 +33,7 @@ export function findSnapPoints({
tracks: SceneTracks;
playheadTime: number;
excludeElementId?: string;
excludeElementIds?: Set<string>;
bookmarks?: Array<Bookmark>;
excludeBookmarkTime?: number;
enableElementSnapping?: boolean;
@ -41,10 +43,14 @@ export function findSnapPoints({
}): SnapPoint[] {
const snapPoints: SnapPoint[] = [];
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const blockedElementIds = new Set(excludeElementIds);
if (excludeElementId) {
blockedElementIds.add(excludeElementId);
}
for (const track of orderedTracks) {
for (const element of track.elements) {
if (element.id === excludeElementId) continue;
if (blockedElementIds.has(element.id)) continue;
if (enableElementSnapping) {
snapPoints.push(
@ -136,6 +142,7 @@ export function snapElementEdge({
playheadTime,
zoomLevel,
excludeElementId,
excludeElementIds,
snapToStart = true,
bookmarks = [],
}: {
@ -145,6 +152,7 @@ export function snapElementEdge({
playheadTime: number;
zoomLevel: number;
excludeElementId?: string;
excludeElementIds?: Set<string>;
snapToStart?: boolean;
bookmarks?: Array<Bookmark>;
}): SnapResult {
@ -152,6 +160,7 @@ export function snapElementEdge({
tracks,
playheadTime,
excludeElementId,
excludeElementIds,
bookmarks,
});