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 {
|
import {
|
||||||
computeTrackExpansionHeight,
|
computeTrackExpansionHeight,
|
||||||
getTrackExpandedRows,
|
getTrackExpandedRows,
|
||||||
getExpansionHeight,
|
|
||||||
getPropertyLabel,
|
getPropertyLabel,
|
||||||
type ExpandedRow,
|
type ExpandedRow,
|
||||||
} from "./expanded-layout";
|
} from "./expanded-layout";
|
||||||
|
|
@ -75,6 +74,7 @@ import { TimelineBookmarksRow } from "./bookmarks";
|
||||||
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
|
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
|
||||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||||
import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
|
import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
|
||||||
|
import { useTimelineResize } from "@/hooks/timeline/use-timeline-resize";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||||
|
|
@ -119,7 +119,9 @@ export function Timeline() {
|
||||||
} = useElementSelection();
|
} = useElementSelection();
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const timeline = editor.timeline;
|
const timeline = editor.timeline;
|
||||||
const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull());
|
const scene = useEditor((currentEditor) =>
|
||||||
|
currentEditor.scenes.getActiveSceneOrNull(),
|
||||||
|
);
|
||||||
const tracks = useMemo<TimelineTrack[]>(
|
const tracks = useMemo<TimelineTrack[]>(
|
||||||
() =>
|
() =>
|
||||||
scene
|
scene
|
||||||
|
|
@ -140,7 +142,6 @@ export function Timeline() {
|
||||||
const playheadRef = useRef<HTMLDivElement>(null);
|
const playheadRef = useRef<HTMLDivElement>(null);
|
||||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
|
||||||
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
|
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
@ -148,15 +149,6 @@ export function Timeline() {
|
||||||
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
|
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
|
||||||
setCurrentSnapPoint(snapPoint);
|
setCurrentSnapPoint(snapPoint);
|
||||||
}, []);
|
}, []);
|
||||||
const handleResizeStateChange = useCallback(
|
|
||||||
({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
|
|
||||||
setIsResizing(nextIsResizing);
|
|
||||||
if (!nextIsResizing) {
|
|
||||||
setCurrentSnapPoint(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const timelineDuration = timeline.getTotalDuration() || 0;
|
const timelineDuration = timeline.getTotalDuration() || 0;
|
||||||
const minZoomLevel = getTimelineZoomMin({
|
const minZoomLevel = getTimelineZoomMin({
|
||||||
|
|
@ -176,6 +168,10 @@ export function Timeline() {
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
rulerScrollRef,
|
rulerScrollRef,
|
||||||
});
|
});
|
||||||
|
const { isResizing, handleResizeStart } = useTimelineResize({
|
||||||
|
zoomLevel,
|
||||||
|
onSnapPointChange: handleSnapPointChange,
|
||||||
|
});
|
||||||
|
|
||||||
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
|
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
|
||||||
|
|
||||||
|
|
@ -366,7 +362,10 @@ export function Timeline() {
|
||||||
|
|
||||||
const containerWidth =
|
const containerWidth =
|
||||||
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
|
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
|
||||||
const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel });
|
const contentWidth = timelineTimeToPixels({
|
||||||
|
time: timelineDuration,
|
||||||
|
zoomLevel,
|
||||||
|
});
|
||||||
const paddingPx = getTimelinePaddingPx({
|
const paddingPx = getTimelinePaddingPx({
|
||||||
containerWidth,
|
containerWidth,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
|
|
@ -513,7 +512,10 @@ export function Timeline() {
|
||||||
TRACKS_CONTAINER_HEIGHT.min,
|
TRACKS_CONTAINER_HEIGHT.min,
|
||||||
Math.min(
|
Math.min(
|
||||||
TRACKS_CONTAINER_HEIGHT.max,
|
TRACKS_CONTAINER_HEIGHT.max,
|
||||||
getTotalTracksHeight({ tracks, getExtraHeight: getTrackExpansionHeight }),
|
getTotalTracksHeight({
|
||||||
|
tracks,
|
||||||
|
getExtraHeight: getTrackExpansionHeight,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
) + TIMELINE_CONTENT_TOP_PADDING_PX
|
) + TIMELINE_CONTENT_TOP_PADDING_PX
|
||||||
}px`,
|
}px`,
|
||||||
|
|
@ -533,14 +535,13 @@ export function Timeline() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
<TimelineTrackRows
|
<TimelineTrackRows
|
||||||
mainTrackId={mainTrackId}
|
mainTrackId={mainTrackId}
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
dragState={dragState}
|
dragState={dragState}
|
||||||
tracksScrollRef={tracksScrollRef}
|
tracksScrollRef={tracksScrollRef}
|
||||||
lastMouseXRef={lastMouseXRef}
|
lastMouseXRef={lastMouseXRef}
|
||||||
onSnapPointChange={handleSnapPointChange}
|
onResizeStart={handleResizeStart}
|
||||||
onResizeStateChange={handleResizeStateChange}
|
|
||||||
onElementMouseDown={handleElementMouseDown}
|
onElementMouseDown={handleElementMouseDown}
|
||||||
onElementClick={handleElementClick}
|
onElementClick={handleElementClick}
|
||||||
onTrackMouseDown={(event) => {
|
onTrackMouseDown={(event) => {
|
||||||
|
|
@ -718,8 +719,7 @@ function TimelineTrackRows({
|
||||||
dragState,
|
dragState,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
lastMouseXRef,
|
lastMouseXRef,
|
||||||
onSnapPointChange,
|
onResizeStart,
|
||||||
onResizeStateChange,
|
|
||||||
onElementMouseDown,
|
onElementMouseDown,
|
||||||
onElementClick,
|
onElementClick,
|
||||||
onTrackMouseDown,
|
onTrackMouseDown,
|
||||||
|
|
@ -733,8 +733,9 @@ function TimelineTrackRows({
|
||||||
dragState: ElementDragState;
|
dragState: ElementDragState;
|
||||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
lastMouseXRef: React.RefObject<number>;
|
lastMouseXRef: React.RefObject<number>;
|
||||||
onSnapPointChange: (snapPoint: SnapPoint | null) => void;
|
onResizeStart: React.ComponentProps<
|
||||||
onResizeStateChange: (params: { isResizing: boolean }) => void;
|
typeof TimelineTrackContent
|
||||||
|
>["onResizeStart"];
|
||||||
onElementMouseDown: React.ComponentProps<
|
onElementMouseDown: React.ComponentProps<
|
||||||
typeof TimelineTrackContent
|
typeof TimelineTrackContent
|
||||||
>["onElementMouseDown"];
|
>["onElementMouseDown"];
|
||||||
|
|
@ -798,8 +799,7 @@ function TimelineTrackRows({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-0 left-0 transition-colors",
|
"absolute right-0 left-0 transition-colors",
|
||||||
tracksWithSelection.has(track.id) &&
|
tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS,
|
||||||
SELECTED_TRACK_ROW_CLASS,
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
|
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
|
||||||
|
|
@ -813,8 +813,7 @@ function TimelineTrackRows({
|
||||||
rulerScrollRef={tracksScrollRef}
|
rulerScrollRef={tracksScrollRef}
|
||||||
tracksScrollRef={tracksScrollRef}
|
tracksScrollRef={tracksScrollRef}
|
||||||
lastMouseXRef={lastMouseXRef}
|
lastMouseXRef={lastMouseXRef}
|
||||||
onSnapPointChange={onSnapPointChange}
|
onResizeStart={onResizeStart}
|
||||||
onResizeStateChange={onResizeStateChange}
|
|
||||||
onElementMouseDown={onElementMouseDown}
|
onElementMouseDown={onElementMouseDown}
|
||||||
onElementClick={onElementClick}
|
onElementClick={onElementClick}
|
||||||
onTrackMouseDown={onTrackMouseDown}
|
onTrackMouseDown={onTrackMouseDown}
|
||||||
|
|
@ -929,12 +928,10 @@ function TrackToggleIcon({
|
||||||
function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
|
function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col overflow-hidden">
|
<div className="flex flex-col overflow-hidden">
|
||||||
{rows.map((row, index) => (
|
{rows.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={row.propertyPath}
|
key={row.propertyPath}
|
||||||
className={cn(
|
className={cn("flex shrink-0 items-center px-3 bg-muted/50")}
|
||||||
"flex shrink-0 items-center px-3 bg-muted/50",
|
|
||||||
)}
|
|
||||||
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
|
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground truncate text-xs leading-none">
|
<span className="text-muted-foreground truncate text-xs leading-none">
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
} from "@/hooks/timeline/element/use-keyframe-drag";
|
} from "@/hooks/timeline/element/use-keyframe-drag";
|
||||||
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
|
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
|
||||||
import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select";
|
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 { SelectionBox } from "@/lib/selection/selection-box";
|
||||||
import { getElementKeyframes } from "@/lib/animation";
|
import { getElementKeyframes } from "@/lib/animation";
|
||||||
import {
|
import {
|
||||||
|
|
@ -86,7 +85,6 @@ import {
|
||||||
getExpansionHeight,
|
getExpansionHeight,
|
||||||
type ExpandedRow,
|
type ExpandedRow,
|
||||||
} from "./expanded-layout";
|
} from "./expanded-layout";
|
||||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
|
||||||
|
|
||||||
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
|
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
|
||||||
const ELEMENT_RING_WIDTH_PX = 1.5;
|
const ELEMENT_RING_WIDTH_PX = 1.5;
|
||||||
|
|
@ -197,8 +195,12 @@ interface TimelineElementProps {
|
||||||
track: TimelineTrack;
|
track: TimelineTrack;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
onResizeStart: (params: {
|
||||||
onResizeStateChange?: (params: { isResizing: boolean }) => void;
|
event: React.MouseEvent;
|
||||||
|
element: TimelineElementType;
|
||||||
|
track: TimelineTrack;
|
||||||
|
side: "left" | "right";
|
||||||
|
}) => void;
|
||||||
onElementMouseDown: (
|
onElementMouseDown: (
|
||||||
event: React.MouseEvent,
|
event: React.MouseEvent,
|
||||||
element: TimelineElementType,
|
element: TimelineElementType,
|
||||||
|
|
@ -216,8 +218,7 @@ export function TimelineElement({
|
||||||
track,
|
track,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSnapPointChange,
|
onResizeStart,
|
||||||
onResizeStateChange,
|
|
||||||
onElementMouseDown,
|
onElementMouseDown,
|
||||||
onElementClick,
|
onElementClick,
|
||||||
dragState,
|
dragState,
|
||||||
|
|
@ -231,18 +232,6 @@ export function TimelineElement({
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
fallback: element,
|
fallback: element,
|
||||||
});
|
});
|
||||||
const {
|
|
||||||
currentDuration,
|
|
||||||
currentStartTime,
|
|
||||||
handleResizeStart,
|
|
||||||
isResizing,
|
|
||||||
} = useTimelineElementResize({
|
|
||||||
element,
|
|
||||||
track,
|
|
||||||
zoomLevel,
|
|
||||||
onSnapPointChange,
|
|
||||||
onResizeStateChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
let mediaAsset: MediaAsset | null = null;
|
let mediaAsset: MediaAsset | null = null;
|
||||||
|
|
||||||
|
|
@ -267,13 +256,9 @@ export function TimelineElement({
|
||||||
const elementStartTime =
|
const elementStartTime =
|
||||||
isBeingDragged && dragState.isDragging
|
isBeingDragged && dragState.isDragging
|
||||||
? dragState.currentTime + dragTimeOffset
|
? dragState.currentTime + dragTimeOffset
|
||||||
: isResizing
|
: renderElement.startTime;
|
||||||
? currentStartTime
|
|
||||||
: renderElement.startTime;
|
|
||||||
const displayedStartTime = elementStartTime;
|
const displayedStartTime = elementStartTime;
|
||||||
const displayedDuration = isResizing
|
const displayedDuration = renderElement.duration;
|
||||||
? currentDuration
|
|
||||||
: renderElement.duration;
|
|
||||||
const elementWidth = timelineTimeToPixels({
|
const elementWidth = timelineTimeToPixels({
|
||||||
time: displayedDuration,
|
time: displayedDuration,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
|
|
@ -282,22 +267,6 @@ export function TimelineElement({
|
||||||
time: displayedStartTime,
|
time: displayedStartTime,
|
||||||
zoomLevel,
|
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
|
const keyframeIndicators = isSelected
|
||||||
? getKeyframeIndicators({
|
? getKeyframeIndicators({
|
||||||
keyframes: getElementKeyframes({ animations: element.animations }),
|
keyframes: getElementKeyframes({ animations: element.animations }),
|
||||||
|
|
@ -329,9 +298,7 @@ export function TimelineElement({
|
||||||
);
|
);
|
||||||
const expandedRows = useMemo(
|
const expandedRows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isExpanded
|
isExpanded ? getExpandedRows({ animations: element.animations }) : [],
|
||||||
? getExpandedRows({ animations: element.animations })
|
|
||||||
: [],
|
|
||||||
[isExpanded, element.animations],
|
[isExpanded, element.animations],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -422,7 +389,7 @@ export function TimelineElement({
|
||||||
expandedContent={expandedContent}
|
expandedContent={expandedContent}
|
||||||
onElementClick={onElementClick}
|
onElementClick={onElementClick}
|
||||||
onElementMouseDown={onElementMouseDown}
|
onElementMouseDown={onElementMouseDown}
|
||||||
onResizeStart={handleElementResizeStart}
|
onResizeStart={onResizeStart}
|
||||||
isDropTarget={isDropTarget}
|
isDropTarget={isDropTarget}
|
||||||
/>
|
/>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
|
|
@ -811,8 +778,7 @@ function ExpandedKeyframeLanes({
|
||||||
[...keyframes]
|
[...keyframes]
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
a.time - b.time ||
|
a.time - b.time || a.propertyPath.localeCompare(b.propertyPath),
|
||||||
a.propertyPath.localeCompare(b.propertyPath),
|
|
||||||
)
|
)
|
||||||
.map((kf) => ({
|
.map((kf) => ({
|
||||||
trackId,
|
trackId,
|
||||||
|
|
@ -824,6 +790,8 @@ function ExpandedKeyframeLanes({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative flex flex-col"
|
className="relative flex flex-col"
|
||||||
|
|
@ -837,9 +805,7 @@ function ExpandedKeyframeLanes({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.propertyPath}
|
key={row.propertyPath}
|
||||||
className={cn(
|
className={cn("relative flex items-center bg-muted/50")}
|
||||||
"relative flex items-center bg-muted/50",
|
|
||||||
)}
|
|
||||||
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
|
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
|
||||||
>
|
>
|
||||||
{laneKeyframes.map((kf) => {
|
{laneKeyframes.map((kf) => {
|
||||||
|
|
@ -849,8 +815,9 @@ function ExpandedKeyframeLanes({
|
||||||
propertyPath: row.propertyPath,
|
propertyPath: row.propertyPath,
|
||||||
keyframeId: kf.id,
|
keyframeId: kf.id,
|
||||||
};
|
};
|
||||||
const isBeingDragged =
|
const isBeingDragged = keyframeDragState.draggingKeyframeIds.has(
|
||||||
keyframeDragState.draggingKeyframeIds.has(kf.id);
|
kf.id,
|
||||||
|
);
|
||||||
const kfLeft = timelineTimeToSnappedPixels({
|
const kfLeft = timelineTimeToSnappedPixels({
|
||||||
time: displayedStartTime + kf.time,
|
time: displayedStartTime + kf.time,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
|
|
@ -898,9 +865,7 @@ function ExpandedKeyframeLanes({
|
||||||
icon={KeyframeIcon}
|
icon={KeyframeIcon}
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-3.5 text-black mr-1",
|
"size-3.5 text-black mr-1",
|
||||||
isSelected
|
isSelected ? "fill-primary" : "fill-white",
|
||||||
? "fill-primary"
|
|
||||||
: "fill-white",
|
|
||||||
)}
|
)}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,129 +1,132 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||||
import { TimelineElement } from "./timeline-element";
|
import { TimelineElement } from "./timeline-element";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { TimelineTrack } from "@/lib/timeline";
|
||||||
import type { TimelineElement as TimelineElementType } 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 { TIMELINE_LAYERS } from "./layers";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
import type { ElementDragState } from "@/lib/timeline";
|
||||||
import type { ElementDragState } from "@/lib/timeline";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
|
||||||
|
interface TimelineTrackContentProps {
|
||||||
interface TimelineTrackContentProps {
|
track: TimelineTrack;
|
||||||
track: TimelineTrack;
|
zoomLevel: number;
|
||||||
zoomLevel: number;
|
dragState: ElementDragState;
|
||||||
dragState: ElementDragState;
|
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
|
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
lastMouseXRef: React.RefObject<number>;
|
||||||
lastMouseXRef: React.RefObject<number>;
|
onResizeStart: (params: {
|
||||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
event: React.MouseEvent;
|
||||||
onResizeStateChange?: (params: { isResizing: boolean }) => void;
|
element: TimelineElementType;
|
||||||
onElementMouseDown: (params: {
|
track: TimelineTrack;
|
||||||
event: React.MouseEvent;
|
side: "left" | "right";
|
||||||
element: TimelineElementType;
|
}) => void;
|
||||||
track: TimelineTrack;
|
onElementMouseDown: (params: {
|
||||||
}) => void;
|
event: React.MouseEvent;
|
||||||
onElementClick: (params: {
|
element: TimelineElementType;
|
||||||
event: React.MouseEvent;
|
track: TimelineTrack;
|
||||||
element: TimelineElementType;
|
}) => void;
|
||||||
track: TimelineTrack;
|
onElementClick: (params: {
|
||||||
}) => void;
|
event: React.MouseEvent;
|
||||||
onTrackMouseDown?: (event: React.MouseEvent) => void;
|
element: TimelineElementType;
|
||||||
onTrackMouseUp?: (event: React.MouseEvent) => void;
|
track: TimelineTrack;
|
||||||
shouldIgnoreClick?: () => boolean;
|
}) => void;
|
||||||
targetElementId?: string | null;
|
onTrackMouseDown?: (event: React.MouseEvent) => void;
|
||||||
}
|
onTrackMouseUp?: (event: React.MouseEvent) => void;
|
||||||
|
shouldIgnoreClick?: () => boolean;
|
||||||
export function TimelineTrackContent({
|
targetElementId?: string | null;
|
||||||
track,
|
}
|
||||||
zoomLevel,
|
|
||||||
dragState,
|
export function TimelineTrackContent({
|
||||||
rulerScrollRef,
|
track,
|
||||||
tracksScrollRef,
|
zoomLevel,
|
||||||
lastMouseXRef,
|
dragState,
|
||||||
onSnapPointChange,
|
rulerScrollRef,
|
||||||
onResizeStateChange,
|
tracksScrollRef,
|
||||||
onElementMouseDown,
|
lastMouseXRef,
|
||||||
onElementClick,
|
onResizeStart,
|
||||||
onTrackMouseDown,
|
onElementMouseDown,
|
||||||
onTrackMouseUp,
|
onElementClick,
|
||||||
shouldIgnoreClick,
|
onTrackMouseDown,
|
||||||
targetElementId = null,
|
onTrackMouseUp,
|
||||||
}: TimelineTrackContentProps) {
|
shouldIgnoreClick,
|
||||||
const { isElementSelected } = useElementSelection();
|
targetElementId = null,
|
||||||
const duration = useEditor((e) => e.timeline.getTotalDuration());
|
}: TimelineTrackContentProps) {
|
||||||
|
const { isElementSelected } = useElementSelection();
|
||||||
useEdgeAutoScroll({
|
const duration = useEditor((e) => e.timeline.getTotalDuration());
|
||||||
isActive: dragState.isDragging,
|
|
||||||
getMouseClientX: () => lastMouseXRef.current ?? 0,
|
useEdgeAutoScroll({
|
||||||
rulerScrollRef,
|
isActive: dragState.isDragging,
|
||||||
tracksScrollRef,
|
getMouseClientX: () => lastMouseXRef.current ?? 0,
|
||||||
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
|
rulerScrollRef,
|
||||||
});
|
tracksScrollRef,
|
||||||
|
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
|
||||||
return (
|
});
|
||||||
<div className="relative size-full">
|
|
||||||
<button
|
return (
|
||||||
type="button"
|
<div className="relative size-full">
|
||||||
className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
|
<button
|
||||||
aria-label={`Select ${track.name} track`}
|
type="button"
|
||||||
onMouseUp={(event) => {
|
className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
|
||||||
if (shouldIgnoreClick?.()) return;
|
aria-label={`Select ${track.name} track`}
|
||||||
onTrackMouseUp?.(event);
|
onMouseUp={(event) => {
|
||||||
}}
|
if (shouldIgnoreClick?.()) return;
|
||||||
onMouseDown={(event) => {
|
onTrackMouseUp?.(event);
|
||||||
event.preventDefault();
|
}}
|
||||||
onTrackMouseDown?.(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"
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
|
||||||
style={{ zIndex: TIMELINE_LAYERS.trackContent }}
|
<div
|
||||||
onMouseUp={(event) => {
|
className="relative h-full min-w-full"
|
||||||
if (event.target !== event.currentTarget) return;
|
style={{ zIndex: TIMELINE_LAYERS.trackContent }}
|
||||||
if (shouldIgnoreClick?.()) return;
|
onMouseUp={(event) => {
|
||||||
onTrackMouseUp?.(event);
|
if (event.target !== event.currentTarget) return;
|
||||||
}}
|
if (shouldIgnoreClick?.()) return;
|
||||||
onMouseDown={(event) => {
|
onTrackMouseUp?.(event);
|
||||||
if (event.target !== event.currentTarget) return;
|
}}
|
||||||
event.preventDefault();
|
onMouseDown={(event) => {
|
||||||
onTrackMouseDown?.(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.length === 0 ? (
|
||||||
track.elements.map((element) => {
|
<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" />
|
||||||
const isSelected = isElementSelected({
|
) : (
|
||||||
trackId: track.id,
|
track.elements.map((element) => {
|
||||||
elementId: element.id,
|
const isSelected = isElementSelected({
|
||||||
});
|
trackId: track.id,
|
||||||
|
elementId: element.id,
|
||||||
return (
|
});
|
||||||
<TimelineElement
|
|
||||||
key={element.id}
|
return (
|
||||||
element={element}
|
<TimelineElement
|
||||||
track={track}
|
key={element.id}
|
||||||
zoomLevel={zoomLevel}
|
element={element}
|
||||||
isSelected={isSelected}
|
track={track}
|
||||||
onSnapPointChange={onSnapPointChange}
|
zoomLevel={zoomLevel}
|
||||||
onResizeStateChange={onResizeStateChange}
|
isSelected={isSelected}
|
||||||
onElementMouseDown={(event, element) =>
|
onResizeStart={({ event, element, side }) =>
|
||||||
onElementMouseDown({ event, element, track })
|
onResizeStart({ event, element, track, side })
|
||||||
}
|
}
|
||||||
onElementClick={(event, element) =>
|
onElementMouseDown={(event, element) =>
|
||||||
onElementClick({ event, element, track })
|
onElementMouseDown({ event, element, track })
|
||||||
}
|
}
|
||||||
dragState={dragState}
|
onElementClick={(event, element) =>
|
||||||
isDropTarget={element.id === targetElementId}
|
onElementClick({ event, element, track })
|
||||||
/>
|
}
|
||||||
);
|
dragState={dragState}
|
||||||
})
|
isDropTarget={element.id === targetElementId}
|
||||||
)}
|
/>
|
||||||
</div>
|
);
|
||||||
</div>
|
})
|
||||||
);
|
)}
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@ import type {
|
||||||
RetimeConfig,
|
RetimeConfig,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { calculateTotalDuration } from "@/lib/timeline";
|
import { calculateTotalDuration } from "@/lib/timeline";
|
||||||
import {
|
import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
|
||||||
findTrackInSceneTracks,
|
|
||||||
} from "@/lib/timeline/track-element-update";
|
|
||||||
import {
|
import {
|
||||||
canElementBeHidden,
|
canElementBeHidden,
|
||||||
canElementHaveAudio,
|
canElementHaveAudio,
|
||||||
|
|
@ -57,6 +55,10 @@ import {
|
||||||
ToggleSourceAudioSeparationCommand,
|
ToggleSourceAudioSeparationCommand,
|
||||||
} from "@/lib/commands/timeline";
|
} from "@/lib/commands/timeline";
|
||||||
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
||||||
|
import type {
|
||||||
|
PlannedElementMove,
|
||||||
|
PlannedTrackCreation,
|
||||||
|
} from "@/lib/timeline/group-move";
|
||||||
|
|
||||||
export class TimelineManager {
|
export class TimelineManager {
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
|
|
@ -149,25 +151,20 @@ export class TimelineManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
moveElement({
|
moveElements({
|
||||||
sourceTrackId,
|
moves,
|
||||||
targetTrackId,
|
createTracks,
|
||||||
elementId,
|
|
||||||
newStartTime,
|
|
||||||
createTrack,
|
|
||||||
}: {
|
}: {
|
||||||
sourceTrackId: string;
|
moves: PlannedElementMove[];
|
||||||
targetTrackId: string;
|
createTracks?: PlannedTrackCreation[];
|
||||||
elementId: string;
|
|
||||||
newStartTime: number;
|
|
||||||
createTrack?: { type: TrackType; index: number };
|
|
||||||
}): void {
|
}): void {
|
||||||
|
if (moves.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const command = new MoveElementCommand({
|
const command = new MoveElementCommand({
|
||||||
sourceTrackId,
|
moves,
|
||||||
targetTrackId,
|
createTracks,
|
||||||
elementId,
|
|
||||||
newStartTime,
|
|
||||||
createTrack,
|
|
||||||
});
|
});
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
@ -580,14 +577,7 @@ export class TimelineManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
const commands = keyframes.map(
|
const commands = keyframes.map(
|
||||||
({
|
({ trackId, elementId, propertyPath, componentKey, keyframeId, patch }) =>
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
propertyPath,
|
|
||||||
componentKey,
|
|
||||||
keyframeId,
|
|
||||||
patch,
|
|
||||||
}) =>
|
|
||||||
new UpdateScalarKeyframeCurveCommand({
|
new UpdateScalarKeyframeCurveCommand({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
|
|
@ -711,7 +701,9 @@ export class TimelineManager {
|
||||||
private applyPreviewOverlay(tracks: SceneTracks): SceneTracks {
|
private applyPreviewOverlay(tracks: SceneTracks): SceneTracks {
|
||||||
if (this.previewOverlay.size === 0) return tracks;
|
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) =>
|
const hasOverlay = track.elements.some((element) =>
|
||||||
this.previewOverlay.has(element.id),
|
this.previewOverlay.has(element.id),
|
||||||
);
|
);
|
||||||
|
|
@ -803,7 +795,11 @@ export class TimelineManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
getPreviewTracks(): SceneTracks | null {
|
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 {
|
subscribe(listener: () => void): () => void {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ import {
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
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 { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
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 { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
|
||||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||||
import { generateUUID } from "@/utils/id";
|
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 { registerCanceller } from "@/lib/cancel-interaction";
|
||||||
import type {
|
import type {
|
||||||
DropTarget,
|
DropTarget,
|
||||||
|
ElementRef,
|
||||||
ElementDragState,
|
ElementDragState,
|
||||||
SceneTracks,
|
SceneTracks,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
|
|
@ -55,6 +63,7 @@ const initialDragState: ElementDragState = {
|
||||||
interface PendingDragState {
|
interface PendingDragState {
|
||||||
elementId: string;
|
elementId: string;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
|
selectedElements: ElementRef[];
|
||||||
startMouseX: number;
|
startMouseX: number;
|
||||||
startMouseY: number;
|
startMouseY: number;
|
||||||
startElementTime: number;
|
startElementTime: number;
|
||||||
|
|
@ -116,11 +125,9 @@ function getDragDropTarget({
|
||||||
const scrollContainer = tracksScrollRef.current;
|
const scrollContainer = tracksScrollRef.current;
|
||||||
if (!containerRect || !scrollContainer) return null;
|
if (!containerRect || !scrollContainer) return null;
|
||||||
|
|
||||||
const sourceTrack = [
|
const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find(
|
||||||
...tracks.overlay,
|
({ id }) => id === trackId,
|
||||||
tracks.main,
|
);
|
||||||
...tracks.audio,
|
|
||||||
].find(({ id }) => id === trackId);
|
|
||||||
const movingElement = sourceTrack?.elements.find(
|
const movingElement = sourceTrack?.elements.find(
|
||||||
({ id }) => id === elementId,
|
({ id }) => id === elementId,
|
||||||
);
|
);
|
||||||
|
|
@ -153,7 +160,7 @@ function getDragDropTarget({
|
||||||
interface StartDragParams
|
interface StartDragParams
|
||||||
extends Omit<
|
extends Omit<
|
||||||
ElementDragState,
|
ElementDragState,
|
||||||
"isDragging" | "currentTime" | "currentMouseY" | "dragElementIds" | "dragTimeOffsets"
|
"isDragging" | "currentTime" | "currentMouseY"
|
||||||
> {
|
> {
|
||||||
initialCurrentTime: number;
|
initialCurrentTime: number;
|
||||||
initialCurrentMouseY: number;
|
initialCurrentMouseY: number;
|
||||||
|
|
@ -171,11 +178,6 @@ export function useElementInteraction({
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const isShiftHeldRef = useShiftKey();
|
const isShiftHeldRef = useShiftKey();
|
||||||
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||||
const tracks = [
|
|
||||||
...sceneTracks.overlay,
|
|
||||||
sceneTracks.main,
|
|
||||||
...sceneTracks.audio,
|
|
||||||
];
|
|
||||||
const {
|
const {
|
||||||
selectedElements,
|
selectedElements,
|
||||||
isElementSelected,
|
isElementSelected,
|
||||||
|
|
@ -188,12 +190,16 @@ export function useElementInteraction({
|
||||||
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
|
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
|
||||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||||
const pendingDragRef = useRef<PendingDragState | null>(null);
|
const pendingDragRef = useRef<PendingDragState | null>(null);
|
||||||
|
const moveGroupRef = useRef<MoveGroup | null>(null);
|
||||||
|
const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
|
||||||
const lastMouseXRef = useRef(0);
|
const lastMouseXRef = useRef(0);
|
||||||
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
|
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
const startDrag = useCallback(
|
const startDrag = useCallback(
|
||||||
({
|
({
|
||||||
elementId,
|
elementId,
|
||||||
|
dragElementIds,
|
||||||
|
dragTimeOffsets,
|
||||||
trackId,
|
trackId,
|
||||||
startMouseX,
|
startMouseX,
|
||||||
startMouseY,
|
startMouseY,
|
||||||
|
|
@ -205,8 +211,8 @@ export function useElementInteraction({
|
||||||
setDragState({
|
setDragState({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
elementId,
|
elementId,
|
||||||
dragElementIds: elementId ? [elementId] : [],
|
dragElementIds,
|
||||||
dragTimeOffsets: {},
|
dragTimeOffsets,
|
||||||
trackId,
|
trackId,
|
||||||
startMouseX,
|
startMouseX,
|
||||||
startMouseY,
|
startMouseY,
|
||||||
|
|
@ -220,6 +226,8 @@ export function useElementInteraction({
|
||||||
);
|
);
|
||||||
|
|
||||||
const endDrag = useCallback(() => {
|
const endDrag = useCallback(() => {
|
||||||
|
moveGroupRef.current = null;
|
||||||
|
groupMoveResultRef.current = null;
|
||||||
setDragState(initialDragState);
|
setDragState(initialDragState);
|
||||||
setDragDropTarget(null);
|
setDragDropTarget(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -232,6 +240,70 @@ export function useElementInteraction({
|
||||||
onSnapPointChange?.(null);
|
onSnapPointChange?.(null);
|
||||||
}, [endDrag, onSnapPointChange]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!dragState.isDragging && !isPendingDrag) return;
|
if (!dragState.isDragging && !isPendingDrag) return;
|
||||||
|
|
||||||
|
|
@ -241,51 +313,29 @@ export function useElementInteraction({
|
||||||
const getDragSnapResult = useCallback(
|
const getDragSnapResult = useCallback(
|
||||||
({
|
({
|
||||||
frameSnappedTime,
|
frameSnappedTime,
|
||||||
movingElement,
|
group,
|
||||||
}: {
|
}: {
|
||||||
frameSnappedTime: number;
|
frameSnappedTime: number;
|
||||||
movingElement: TimelineElement | null | undefined;
|
group: MoveGroup | null;
|
||||||
}) => {
|
}) => {
|
||||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
if (!group || !snappingEnabled || isShiftHeldRef.current) {
|
||||||
if (!shouldSnap || !movingElement) {
|
|
||||||
return { snappedTime: frameSnappedTime, snapPoint: null };
|
return { snappedTime: frameSnappedTime, snapPoint: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const elementDuration = movingElement.duration;
|
const groupSnap = snapGroupEdges({
|
||||||
const playheadTime = editor.playback.getCurrentTime();
|
group,
|
||||||
|
anchorStartTime: frameSnappedTime,
|
||||||
const startSnap = snapElementEdge({
|
|
||||||
targetTime: frameSnappedTime,
|
|
||||||
elementDuration,
|
|
||||||
tracks: sceneTracks,
|
tracks: sceneTracks,
|
||||||
playheadTime,
|
playheadTime: editor.playback.getCurrentTime(),
|
||||||
zoomLevel,
|
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 {
|
return {
|
||||||
snappedTime: snapResult.snappedTime,
|
snappedTime: groupSnap.snappedAnchorStartTime,
|
||||||
snapPoint: snapResult.snapPoint,
|
snapPoint: groupSnap.snapPoint,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef],
|
[snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -314,17 +364,38 @@ export function useElementInteraction({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft,
|
scrollLeft,
|
||||||
});
|
});
|
||||||
const adjustedTime = Math.max(
|
const adjustedTime = Math.max(
|
||||||
0,
|
0,
|
||||||
mouseTime - pendingDragRef.current.clickOffsetTime,
|
mouseTime - pendingDragRef.current.clickOffsetTime,
|
||||||
);
|
);
|
||||||
const snappedTime = roundToFrame({
|
const snappedTime =
|
||||||
time: adjustedTime,
|
roundToFrame({
|
||||||
rate: activeProject.settings.fps,
|
time: adjustedTime,
|
||||||
}) ?? adjustedTime;
|
rate: activeProject.settings.fps,
|
||||||
startDrag({
|
}) ?? adjustedTime;
|
||||||
...pendingDragRef.current,
|
const moveGroup = buildMoveGroup({
|
||||||
initialCurrentTime: snappedTime,
|
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,
|
initialCurrentMouseY: clientY,
|
||||||
});
|
});
|
||||||
startedDragThisEvent = true;
|
startedDragThisEvent = true;
|
||||||
|
|
@ -364,15 +435,13 @@ export function useElementInteraction({
|
||||||
});
|
});
|
||||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||||
const fps = activeProject.settings.fps;
|
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 moveGroup = moveGroupRef.current;
|
||||||
const movingElement = sourceTrack?.elements.find(
|
|
||||||
({ id }) => id === dragState.elementId,
|
|
||||||
);
|
|
||||||
const { snappedTime, snapPoint } = getDragSnapResult({
|
const { snappedTime, snapPoint } = getDragSnapResult({
|
||||||
frameSnappedTime,
|
frameSnappedTime,
|
||||||
movingElement,
|
group: moveGroup,
|
||||||
});
|
});
|
||||||
setDragState((previousDragState) => ({
|
setDragState((previousDragState) => ({
|
||||||
...previousDragState,
|
...previousDragState,
|
||||||
|
|
@ -386,7 +455,7 @@ export function useElementInteraction({
|
||||||
startMouseY: dragState.startMouseY,
|
startMouseY: dragState.startMouseY,
|
||||||
currentMouseY: clientY,
|
currentMouseY: clientY,
|
||||||
});
|
});
|
||||||
const dropTarget = getDragDropTarget({
|
const anchorDropTarget = getDragDropTarget({
|
||||||
clientX,
|
clientX,
|
||||||
clientY,
|
clientY,
|
||||||
elementId: dragState.elementId,
|
elementId: dragState.elementId,
|
||||||
|
|
@ -399,7 +468,24 @@ export function useElementInteraction({
|
||||||
snappedTime,
|
snappedTime,
|
||||||
verticalDragDirection,
|
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,
|
tracksScrollRef,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
headerRef,
|
headerRef,
|
||||||
tracks,
|
|
||||||
isPendingDrag,
|
isPendingDrag,
|
||||||
startDrag,
|
startDrag,
|
||||||
getDragSnapResult,
|
getDragSnapResult,
|
||||||
|
resolveGroupDragMove,
|
||||||
|
sceneTracks,
|
||||||
onSnapPointChange,
|
onSnapPointChange,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -468,55 +555,45 @@ export function useElementInteraction({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const snappedTime = dragState.currentTime;
|
const snappedTime = dragState.currentTime;
|
||||||
|
const moveGroup = moveGroupRef.current;
|
||||||
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
|
if (!moveGroup) {
|
||||||
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
|
|
||||||
) {
|
|
||||||
endDrag();
|
endDrag();
|
||||||
onSnapPointChange?.(null);
|
onSnapPointChange?.(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dropTarget.isNewTrack) {
|
const groupMoveResult = resolveGroupDragMove({
|
||||||
const newTrackId = generateUUID();
|
group: moveGroup,
|
||||||
|
snappedTime,
|
||||||
editor.timeline.moveElement({
|
dropTarget,
|
||||||
sourceTrackId: dragState.trackId,
|
});
|
||||||
targetTrackId: newTrackId,
|
if (!groupMoveResult) {
|
||||||
elementId: dragState.elementId,
|
endDrag();
|
||||||
newStartTime: snappedTime,
|
onSnapPointChange?.(null);
|
||||||
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
|
return;
|
||||||
});
|
|
||||||
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 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();
|
endDrag();
|
||||||
onSnapPointChange?.(null);
|
onSnapPointChange?.(null);
|
||||||
};
|
};
|
||||||
|
|
@ -531,14 +608,14 @@ export function useElementInteraction({
|
||||||
dragState.trackId,
|
dragState.trackId,
|
||||||
dragState.currentTime,
|
dragState.currentTime,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
tracks,
|
|
||||||
endDrag,
|
endDrag,
|
||||||
onSnapPointChange,
|
onSnapPointChange,
|
||||||
editor.timeline,
|
editor.timeline,
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
headerRef,
|
headerRef,
|
||||||
selectElement,
|
resolveGroupDragMove,
|
||||||
|
sceneTracks,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -600,9 +677,17 @@ export function useElementInteraction({
|
||||||
elementRect: event.currentTarget.getBoundingClientRect(),
|
elementRect: event.currentTarget.getBoundingClientRect(),
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
});
|
});
|
||||||
|
const elementRef = {
|
||||||
|
trackId: track.id,
|
||||||
|
elementId: element.id,
|
||||||
|
};
|
||||||
|
const pendingSelectedElements = isElementSelected(elementRef)
|
||||||
|
? selectedElements
|
||||||
|
: [elementRef];
|
||||||
pendingDragRef.current = {
|
pendingDragRef.current = {
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
|
selectedElements: pendingSelectedElements,
|
||||||
startMouseX: event.clientX,
|
startMouseX: event.clientX,
|
||||||
startMouseY: event.clientY,
|
startMouseY: event.clientY,
|
||||||
startElementTime: element.startTime,
|
startElementTime: element.startTime,
|
||||||
|
|
@ -610,7 +695,7 @@ export function useElementInteraction({
|
||||||
};
|
};
|
||||||
setIsPendingDrag(true);
|
setIsPendingDrag(true);
|
||||||
},
|
},
|
||||||
[zoomLevel, isElementSelected, handleSelectionClick],
|
[zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleElementClick = useCallback(
|
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 {
|
import type {
|
||||||
SceneTracks,
|
SceneTracks,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
TrackType,
|
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import {
|
import {
|
||||||
buildEmptyTrack,
|
buildEmptyTrack,
|
||||||
validateElementTrackCompatibility,
|
validateElementTrackCompatibility,
|
||||||
enforceMainTrackStart,
|
|
||||||
} from "@/lib/timeline/placement";
|
} from "@/lib/timeline/placement";
|
||||||
import {
|
import type {
|
||||||
findTrackInSceneTracks,
|
PlannedElementMove,
|
||||||
updateTrackInSceneTracks,
|
PlannedTrackCreation,
|
||||||
} from "@/lib/timeline/track-element-update";
|
} from "@/lib/timeline/group-move";
|
||||||
|
import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
|
||||||
|
|
||||||
export class MoveElementCommand extends Command {
|
export class MoveElementCommand extends Command {
|
||||||
private savedState: SceneTracks | null = null;
|
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({
|
constructor({
|
||||||
sourceTrackId,
|
moves,
|
||||||
targetTrackId,
|
createTracks = [],
|
||||||
elementId,
|
|
||||||
newStartTime,
|
|
||||||
createTrack,
|
|
||||||
}: {
|
}: {
|
||||||
sourceTrackId: string;
|
moves: PlannedElementMove[];
|
||||||
targetTrackId: string;
|
createTracks?: PlannedTrackCreation[];
|
||||||
elementId: string;
|
|
||||||
newStartTime: number;
|
|
||||||
createTrack?: { type: TrackType; index: number };
|
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
this.sourceTrackId = sourceTrackId;
|
this.moves = moves;
|
||||||
this.targetTrackId = targetTrackId;
|
this.createTracks = createTracks;
|
||||||
this.elementId = elementId;
|
|
||||||
this.newStartTime = newStartTime;
|
|
||||||
this.createTrack = createTrack;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly moves: PlannedElementMove[];
|
||||||
|
private readonly createTracks: PlannedTrackCreation[];
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
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;
|
let tracksToUpdate = this.savedState;
|
||||||
if (!targetTrack && this.createTrack) {
|
for (const createTrack of [...this.createTracks].sort(
|
||||||
const newTrack = buildEmptyTrack({
|
(firstTrack, secondTrack) => firstTrack.index - secondTrack.index,
|
||||||
id: this.targetTrackId,
|
)) {
|
||||||
type: this.createTrack.type,
|
|
||||||
});
|
|
||||||
tracksToUpdate = insertTrackAtDisplayIndex({
|
tracksToUpdate = insertTrackAtDisplayIndex({
|
||||||
tracks: this.savedState,
|
tracks: tracksToUpdate,
|
||||||
track: newTrack,
|
track: buildEmptyTrack({
|
||||||
insertIndex: this.createTrack.index,
|
id: createTrack.id,
|
||||||
|
type: createTrack.type,
|
||||||
|
}),
|
||||||
|
insertIndex: createTrack.index,
|
||||||
});
|
});
|
||||||
targetTrack = newTrack;
|
|
||||||
}
|
|
||||||
if (!targetTrack) {
|
|
||||||
throw new Error("Target track not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = validateElementTrackCompatibility({
|
const movedElementsById = new Map<string, TimelineElement>();
|
||||||
element,
|
for (const move of this.moves) {
|
||||||
track: targetTrack,
|
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) {
|
const targetTrack = findTrackInSceneTracks({
|
||||||
throw new Error(validation.errorMessage);
|
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,
|
tracks: tracksToUpdate,
|
||||||
targetTrackId: this.targetTrackId,
|
update: (track) => ({
|
||||||
requestedStartTime: this.newStartTime,
|
...track,
|
||||||
excludeElementId: this.elementId,
|
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);
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
return undefined;
|
return {
|
||||||
|
select: this.moves.map(({ elementId, targetTrackId }) => ({
|
||||||
|
trackId: targetTrackId,
|
||||||
|
elementId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
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({
|
function insertTrackAtDisplayIndex({
|
||||||
tracks,
|
tracks,
|
||||||
track,
|
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",
|
id: "graphic-1",
|
||||||
type: "graphic",
|
type: "graphic",
|
||||||
elements: [
|
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";
|
import type { PlacementTimeSpan } from "./types";
|
||||||
|
|
||||||
|
interface TrackWithElements {
|
||||||
|
elements: TimelineElement[];
|
||||||
|
}
|
||||||
|
|
||||||
function wouldElementOverlap({
|
function wouldElementOverlap({
|
||||||
elements,
|
elements,
|
||||||
startTime,
|
startTime,
|
||||||
|
|
@ -26,7 +30,7 @@ export function canPlaceTimeSpansOnTrack({
|
||||||
track,
|
track,
|
||||||
timeSpans,
|
timeSpans,
|
||||||
}: {
|
}: {
|
||||||
track: TimelineTrack;
|
track: TrackWithElements;
|
||||||
timeSpans: PlacementTimeSpan[];
|
timeSpans: PlacementTimeSpan[];
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return timeSpans.every(({ startTime, duration, excludeElementId }) => {
|
return timeSpans.every(({ startTime, duration, excludeElementId }) => {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export function findSnapPoints({
|
||||||
tracks,
|
tracks,
|
||||||
playheadTime,
|
playheadTime,
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
|
excludeElementIds,
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
excludeBookmarkTime,
|
excludeBookmarkTime,
|
||||||
enableElementSnapping = true,
|
enableElementSnapping = true,
|
||||||
|
|
@ -32,6 +33,7 @@ export function findSnapPoints({
|
||||||
tracks: SceneTracks;
|
tracks: SceneTracks;
|
||||||
playheadTime: number;
|
playheadTime: number;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
|
excludeElementIds?: Set<string>;
|
||||||
bookmarks?: Array<Bookmark>;
|
bookmarks?: Array<Bookmark>;
|
||||||
excludeBookmarkTime?: number;
|
excludeBookmarkTime?: number;
|
||||||
enableElementSnapping?: boolean;
|
enableElementSnapping?: boolean;
|
||||||
|
|
@ -41,10 +43,14 @@ export function findSnapPoints({
|
||||||
}): SnapPoint[] {
|
}): SnapPoint[] {
|
||||||
const snapPoints: SnapPoint[] = [];
|
const snapPoints: SnapPoint[] = [];
|
||||||
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
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 track of orderedTracks) {
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if (element.id === excludeElementId) continue;
|
if (blockedElementIds.has(element.id)) continue;
|
||||||
|
|
||||||
if (enableElementSnapping) {
|
if (enableElementSnapping) {
|
||||||
snapPoints.push(
|
snapPoints.push(
|
||||||
|
|
@ -136,6 +142,7 @@ export function snapElementEdge({
|
||||||
playheadTime,
|
playheadTime,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
|
excludeElementIds,
|
||||||
snapToStart = true,
|
snapToStart = true,
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -145,6 +152,7 @@ export function snapElementEdge({
|
||||||
playheadTime: number;
|
playheadTime: number;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
|
excludeElementIds?: Set<string>;
|
||||||
snapToStart?: boolean;
|
snapToStart?: boolean;
|
||||||
bookmarks?: Array<Bookmark>;
|
bookmarks?: Array<Bookmark>;
|
||||||
}): SnapResult {
|
}): SnapResult {
|
||||||
|
|
@ -152,6 +160,7 @@ export function snapElementEdge({
|
||||||
tracks,
|
tracks,
|
||||||
playheadTime,
|
playheadTime,
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
|
excludeElementIds,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue