feat: move/resize multiple elements
This commit is contained in:
parent
6312fc5b07
commit
ac43a5a73f
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export { computeGroupResize } from "./compute-resize";
|
||||
|
||||
export type {
|
||||
ComputeGroupResizeArgs,
|
||||
GroupResizeMember,
|
||||
GroupResizeResult,
|
||||
GroupResizeUpdate,
|
||||
ResizeSide,
|
||||
} from "./types";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue