summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/timeline/use-timeline-resize.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/hooks/timeline/use-timeline-resize.ts')
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-resize.ts322
1 files changed, 322 insertions, 0 deletions
diff --git a/apps/web/src/hooks/timeline/use-timeline-resize.ts b/apps/web/src/hooks/timeline/use-timeline-resize.ts
new file mode 100644
index 00000000..f246261a
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-timeline-resize.ts
@@ -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
+ );
+ });
+}