summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/hooks')
-rw-r--r--apps/web/src/hooks/actions/use-editor-actions.ts49
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts70
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts112
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-drag.ts302
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-selection.ts229
-rw-r--r--apps/web/src/hooks/timeline/use-selection-box.ts18
-rw-r--r--apps/web/src/hooks/timeline/use-snap-indicator-position.ts8
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts208
-rw-r--r--apps/web/src/hooks/use-effect-preview.ts47
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts96
10 files changed, 993 insertions, 146 deletions
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts
index 1ce890cb..e4f378d6 100644
--- a/apps/web/src/hooks/actions/use-editor-actions.ts
+++ b/apps/web/src/hooks/actions/use-editor-actions.ts
@@ -4,13 +4,19 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
+import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import { getElementsAtTime } from "@/lib/timeline";
export function useEditorActions() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const { selectedElements, setElementSelection } = useElementSelection();
- const { clipboard, setClipboard, toggleSnapping } = useTimelineStore();
+ const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
+ const clipboard = useTimelineStore((s) => s.clipboard);
+ const setClipboard = useTimelineStore((s) => s.setClipboard);
+ const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
+ const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
+ const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
useActionHandler(
"toggle-play",
@@ -158,11 +164,21 @@ export function useEditorActions() {
if (elementsToSplit.length === 0) return;
- editor.timeline.splitElements({
+ const rightSideElements = editor.timeline.splitElements({
elements: elementsToSplit,
splitTime: currentTime,
retainSide: "right",
+ rippleEnabled: rippleEditingEnabled,
});
+
+ if (rippleEditingEnabled && rightSideElements.length > 0) {
+ const firstRightElement = editor.timeline.getElementsWithTracks({
+ elements: [rightSideElements[0]],
+ })[0];
+ if (firstRightElement) {
+ editor.playback.seek({ time: firstRightElement.element.startTime });
+ }
+ }
},
undefined,
);
@@ -193,11 +209,17 @@ export function useEditorActions() {
useActionHandler(
"delete-selected",
() => {
+ if (selectedKeyframes.length > 0) {
+ editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
+ clearKeyframeSelection();
+ return;
+ }
if (selectedElements.length === 0) {
return;
}
editor.timeline.deleteElements({
elements: selectedElements,
+ rippleEnabled: rippleEditingEnabled,
});
editor.selection.clearSelection();
},
@@ -219,6 +241,19 @@ export function useEditorActions() {
);
useActionHandler(
+ "deselect-all",
+ () => {
+ setElementSelection({ elements: [] });
+ clearKeyframeSelection();
+ const activeElement = document.activeElement;
+ if (activeElement instanceof HTMLButtonElement) {
+ activeElement.blur();
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
"duplicate-selected",
() => {
editor.timeline.duplicateElements({
@@ -261,7 +296,7 @@ export function useEditorActions() {
elements: selectedElements,
});
const items = results.map(({ track, element }) => {
- const { ...elementWithoutId } = element;
+ const { id: _, ...elementWithoutId } = element;
return {
trackId: track.id,
trackType: track.type,
@@ -296,6 +331,14 @@ export function useEditorActions() {
);
useActionHandler(
+ "toggle-ripple-editing",
+ () => {
+ toggleRippleEditing();
+ },
+ undefined,
+ );
+
+ useActionHandler(
"undo",
() => {
editor.command.undo();
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index e7b93813..ea01c718 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -8,6 +8,7 @@ import {
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
+import { useTimelineStore } from "@/stores/timeline-store";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import {
DRAG_THRESHOLD_PX,
@@ -38,6 +39,8 @@ interface UseElementInteractionProps {
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
+const MOUSE_BUTTON_RIGHT = 2;
+
const initialDragState: ElementDragState = {
isDragging: false,
elementId: null,
@@ -162,6 +165,7 @@ export function useElementInteraction({
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
+ const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const {
@@ -441,27 +445,33 @@ export function useElementInteraction({
return;
}
- if (dropTarget.isNewTrack) {
- const newTrackId = generateUUID();
+ if (dropTarget.isNewTrack) {
+ const newTrackId = generateUUID();
+ editor.timeline.moveElement({
+ sourceTrackId: dragState.trackId,
+ targetTrackId: newTrackId,
+ elementId: dragState.elementId,
+ newStartTime: snappedTime,
+ createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
+ rippleEnabled: rippleEditingEnabled,
+ });
+ selectElement({ trackId: newTrackId, elementId: dragState.elementId });
+ } else {
+ const targetTrack = tracks[dropTarget.trackIndex];
+ if (targetTrack) {
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
- targetTrackId: newTrackId,
+ targetTrackId: targetTrack.id,
elementId: dragState.elementId,
newStartTime: snappedTime,
- createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
+ rippleEnabled: rippleEditingEnabled,
});
- } 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 });
}
}
+ }
endDrag();
onSnapPointChange?.(null);
@@ -483,6 +493,8 @@ export function useElementInteraction({
tracksContainerRef,
tracksScrollRef,
headerRef,
+ rippleEditingEnabled,
+ selectElement,
]);
useEffect(() => {
@@ -508,10 +520,10 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
- const isRightClick = event.button === 2;
+ const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
- // right-click: don't stop propagation so ContextMenu can open
- if (isRightClick) {
+ // right-click: don't stop propagation so ContextMenu can open
+ if (isRightClick) {
const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
@@ -526,14 +538,12 @@ export function useElementInteraction({
return;
}
- // left-click: stop propagation for drag operations
- event.stopPropagation();
- mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
+ event.stopPropagation();
+ mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
- const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
+ const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
- // multi-select: toggle selection
- if (isMultiSelect) {
+ if (isMultiSelect) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
@@ -541,8 +551,7 @@ export function useElementInteraction({
});
}
- // start drag
- const clickOffsetTime = getClickOffsetTime({
+ const clickOffsetTime = getClickOffsetTime({
clientX: event.clientX,
elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel,
@@ -570,10 +579,9 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
- event.stopPropagation();
+ event.stopPropagation();
- // was it a drag or a click?
- if (mouseDownLocationRef.current) {
+ if (mouseDownLocationRef.current) {
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
@@ -585,16 +593,18 @@ export function useElementInteraction({
// modifier keys already handled in mousedown
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
- // single click: select if not selected
- const alreadySelected = isElementSelected({
+ const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
if (!alreadySelected) {
selectElement({ trackId: track.id, elementId: element.id });
+ return;
}
+
+ editor.selection.clearKeyframeSelection();
},
- [isElementSelected, selectElement],
+ [editor.selection, isElementSelected, selectElement],
);
return {
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index 2b303ad9..3f49e798 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -40,7 +40,9 @@ export function useTimelineElementResize({
const activeProject = editor.project.getActive();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
-
+ const rippleEditingEnabled = useTimelineStore(
+ (state) => state.rippleEditingEnabled,
+ );
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
@@ -86,12 +88,8 @@ export function useTimelineElementResize({
};
const canExtendElementDuration = useCallback(() => {
- if (element.type === "text" || element.type === "image") {
- return true;
- }
-
- return false;
- }, [element.type]);
+ return element.sourceDuration == null;
+ }, [element.sourceDuration]);
const updateTrimFromMouseMove = useCallback(
({ clientX }: { clientX: number }) => {
@@ -141,18 +139,52 @@ export function useTimelineElementResize({
}
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 =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
+ const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound)
+ ? Math.max(
+ 0,
+ resizing.initialTrimStart +
+ (leftNeighborBound - resizing.initialStartTime),
+ )
+ : 0;
const maxAllowed =
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
- time: Math.min(maxAllowed, calculated),
+ time: Math.min(
+ maxAllowed,
+ Math.max(minTrimStartForNeighbor, calculated),
+ ),
fps: projectFps,
});
const trimDelta = newTrimStart - resizing.initialTrimStart;
@@ -175,7 +207,16 @@ export function useTimelineElementResize({
if (canExtendElementDuration()) {
const extensionAmount = Math.abs(calculated);
const maxExtension = resizing.initialStartTime;
- const actualExtension = Math.min(extensionAmount, maxExtension);
+ const actualExtension = Math.max(
+ 0,
+ Number.isFinite(leftNeighborBound)
+ ? Math.min(
+ extensionAmount,
+ maxExtension,
+ resizing.initialStartTime - leftNeighborBound,
+ )
+ : Math.min(extensionAmount, maxExtension),
+ );
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime - actualExtension,
fps: projectFps,
@@ -192,7 +233,8 @@ export function useTimelineElementResize({
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
- const trimDelta = 0 - resizing.initialTrimStart;
+ const trimDelta =
+ minTrimStartForNeighbor - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
@@ -202,10 +244,10 @@ export function useTimelineElementResize({
fps: projectFps,
});
- setCurrentTrimStart(0);
+ setCurrentTrimStart(minTrimStartForNeighbor);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
- currentTrimStartRef.current = 0;
+ currentTrimStartRef.current = minTrimStartForNeighbor;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
@@ -216,6 +258,9 @@ export function useTimelineElementResize({
resizing.initialDuration +
resizing.initialTrimEnd;
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
+ const maxAllowedDuration = Number.isFinite(rightNeighborBound)
+ ? rightNeighborBound - resizing.initialStartTime
+ : Infinity;
if (newTrimEnd < 0) {
if (canExtendElementDuration()) {
@@ -223,7 +268,7 @@ export function useTimelineElementResize({
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
- time: baseDuration + extensionNeeded,
+ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration),
fps: projectFps,
});
@@ -234,7 +279,10 @@ export function useTimelineElementResize({
} else {
const extensionToLimit = resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
- time: resizing.initialDuration + extensionToLimit,
+ time: Math.min(
+ resizing.initialDuration + extensionToLimit,
+ maxAllowedDuration,
+ ),
fps: projectFps,
});
@@ -244,9 +292,20 @@ export function useTimelineElementResize({
currentTrimEndRef.current = 0;
}
} else {
+ const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration)
+ ? Math.max(
+ 0,
+ resizing.initialDuration +
+ resizing.initialTrimEnd -
+ maxAllowedDuration,
+ )
+ : 0;
const maxTrimEnd =
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
- const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
+ const clampedTrimEnd = Math.min(
+ maxTrimEnd,
+ Math.max(minTrimEndForNeighbor, newTrimEnd),
+ );
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
fps: projectFps,
@@ -271,6 +330,7 @@ export function useTimelineElementResize({
snappingEnabled,
editor,
element.id,
+ track.elements,
onSnapPointChange,
canExtendElementDuration,
isShiftHeldRef,
@@ -289,26 +349,14 @@ export function useTimelineElementResize({
const startTimeChanged = finalStartTime !== resizing.initialStartTime;
const durationChanged = finalDuration !== resizing.initialDuration;
- if (trimStartChanged || trimEndChanged) {
+ if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) {
editor.timeline.updateElementTrim({
elementId: element.id,
trimStart: finalTrimStart,
trimEnd: finalTrimEnd,
- });
- }
-
- if (startTimeChanged) {
- editor.timeline.updateElementStartTime({
- elements: [{ trackId: track.id, elementId: element.id }],
- startTime: finalStartTime,
- });
- }
-
- if (durationChanged) {
- editor.timeline.updateElementDuration({
- trackId: track.id,
- elementId: element.id,
- duration: finalDuration,
+ startTime: startTimeChanged ? finalStartTime : undefined,
+ duration: durationChanged ? finalDuration : undefined,
+ rippleEnabled: rippleEditingEnabled,
});
}
@@ -319,9 +367,9 @@ export function useTimelineElementResize({
resizing,
editor.timeline,
element.id,
- track.id,
onResizeStateChange,
onSnapPointChange,
+ rippleEditingEnabled,
]);
useEffect(() => {
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
new file mode 100644
index 00000000..b18db6eb
--- /dev/null
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
@@ -0,0 +1,302 @@
+import {
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ type MouseEvent as ReactMouseEvent,
+} from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { useKeyframeSelection } from "./use-keyframe-selection";
+import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time";
+import { timelineTimeToSnappedPixels } from "@/lib/timeline";
+import {
+ DRAG_THRESHOLD_PX,
+ TIMELINE_CONSTANTS,
+} from "@/constants/timeline-constants";
+import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
+import { BatchCommand } from "@/lib/commands";
+import type { SelectedKeyframeRef } from "@/types/animation";
+import type { TimelineElement } from "@/types/timeline";
+import type { Command } from "@/lib/commands/base-command";
+export interface KeyframeDragState {
+ isDragging: boolean;
+ draggingKeyframeIds: Set<string>;
+ deltaTime: number;
+}
+
+const initialDragState: KeyframeDragState = {
+ isDragging: false,
+ draggingKeyframeIds: new Set(),
+ deltaTime: 0,
+};
+
+interface PendingKeyframeDrag {
+ keyframeRefs: SelectedKeyframeRef[];
+ startMouseX: number;
+}
+
+export function useKeyframeDrag({
+ zoomLevel,
+ element,
+ displayedStartTime,
+}: {
+ zoomLevel: number;
+ element: TimelineElement;
+ displayedStartTime: number;
+}) {
+ const editor = useEditor();
+ const {
+ selectedKeyframes,
+ isKeyframeSelected,
+ setKeyframeSelection,
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ } = useKeyframeSelection();
+
+ const [dragState, setDragState] =
+ useState<KeyframeDragState>(initialDragState);
+ const [isPendingDrag, setIsPendingDrag] = useState(false);
+
+ const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
+ const mouseDownXRef = useRef<number | null>(null);
+
+ const activeProject = editor.project.getActive();
+ const fps = activeProject.settings.fps;
+
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ const endDrag = useCallback(() => {
+ setDragState(initialDragState);
+ }, []);
+
+ const commitDrag = useCallback(
+ ({
+ keyframeRefs,
+ deltaTime,
+ }: {
+ keyframeRefs: SelectedKeyframeRef[];
+ deltaTime: number;
+ }) => {
+ const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
+ const channel = element.animations?.channels[keyframeRef.propertyPath];
+ const keyframe = channel?.keyframes.find(
+ (keyframe) => keyframe.id === keyframeRef.keyframeId,
+ );
+ if (!keyframe) return [];
+ const nextTime = Math.max(
+ 0,
+ Math.min(element.duration, keyframe.time + deltaTime),
+ );
+ return [
+ new RetimeKeyframeCommand({
+ trackId: keyframeRef.trackId,
+ elementId: keyframeRef.elementId,
+ propertyPath: keyframeRef.propertyPath,
+ keyframeId: keyframeRef.keyframeId,
+ nextTime,
+ }),
+ ];
+ });
+
+ if (commands.length === 1) {
+ editor.command.execute({ command: commands[0] });
+ } else if (commands.length > 1) {
+ editor.command.execute({ command: new BatchCommand(commands) });
+ }
+ },
+ [editor.command, element],
+ );
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ const handleMouseMove = ({ clientX }: MouseEvent) => {
+ if (isPendingDrag && pendingDragRef.current) {
+ const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
+ if (deltaX <= DRAG_THRESHOLD_PX) return;
+
+ const pending = pendingDragRef.current;
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ setDragState({
+ isDragging: true,
+ draggingKeyframeIds: new Set(
+ pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
+ ),
+ deltaTime: 0,
+ });
+ return;
+ }
+
+ if (!dragState.isDragging) return;
+
+ const startX = mouseDownXRef.current ?? clientX;
+ const rawDelta = (clientX - startX) / pixelsPerSecond;
+ const snappedDelta = snapTimeToFrame({ time: rawDelta, fps });
+
+ setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ return () => document.removeEventListener("mousemove", handleMouseMove);
+ }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]);
+
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseUp = () => {
+ const draggingRefs = selectedKeyframes.filter(
+ (keyframe) =>
+ keyframe.elementId === element.id &&
+ dragState.draggingKeyframeIds.has(keyframe.keyframeId),
+ );
+
+ if (draggingRefs.length > 0 && dragState.deltaTime !== 0) {
+ commitDrag({
+ keyframeRefs: draggingRefs,
+ deltaTime: dragState.deltaTime,
+ });
+ }
+
+ endDrag();
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [
+ dragState.isDragging,
+ dragState.draggingKeyframeIds,
+ dragState.deltaTime,
+ selectedKeyframes,
+ element.id,
+ commitDrag,
+ endDrag,
+ ]);
+
+ useEffect(() => {
+ if (!isPendingDrag) return;
+
+ const handleMouseUp = () => {
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [isPendingDrag]);
+
+ const handleKeyframeMouseDown = useCallback(
+ ({
+ event,
+ keyframes,
+ }: {
+ event: ReactMouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ mouseDownXRef.current = event.clientX;
+
+ const anySelected = keyframes.some((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+
+ const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
+ if (!anySelected && !isModifierKey) {
+ setKeyframeSelection({ keyframes });
+ }
+
+ const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes;
+
+ pendingDragRef.current = {
+ keyframeRefs: keyframeRefsToTrack,
+ startMouseX: event.clientX,
+ };
+ setIsPendingDrag(true);
+ },
+ [isKeyframeSelected, selectedKeyframes, setKeyframeSelection],
+);
+
+ const handleKeyframeClick = useCallback(
+ ({
+ event,
+ keyframes,
+ orderedKeyframes,
+ indicatorTime,
+ }: {
+ event: ReactMouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ orderedKeyframes: SelectedKeyframeRef[];
+ indicatorTime: number;
+ }) => {
+ event.stopPropagation();
+
+ const wasDrag =
+ mouseDownXRef.current !== null &&
+ Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX;
+ mouseDownXRef.current = null;
+
+ if (wasDrag) return;
+
+ const duration = editor.timeline.getTotalDuration();
+ const seekTime = getSnappedSeekTime({
+ rawTime: displayedStartTime + indicatorTime,
+ duration,
+ fps,
+ });
+ editor.playback.seek({ time: seekTime });
+
+ if (event.shiftKey) {
+ selectKeyframeRange({
+ orderedKeyframes,
+ targetKeyframes: keyframes,
+ isAdditive: event.metaKey || event.ctrlKey,
+ });
+ return;
+ }
+
+ toggleKeyframeSelection({
+ keyframes,
+ isMultiKey: event.metaKey || event.ctrlKey,
+ });
+ },
+ [toggleKeyframeSelection, selectKeyframeRange, editor, displayedStartTime, fps],
+ );
+
+ const getVisualOffsetPx = useCallback(
+ ({
+ indicatorTime,
+ indicatorOffsetPx,
+ isBeingDragged,
+ displayedStartTime,
+ elementLeft,
+ }: {
+ indicatorTime: number;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: number;
+ elementLeft: number;
+ }): number => {
+ if (!isBeingDragged) return indicatorOffsetPx;
+ const clampedTime = Math.max(
+ 0,
+ Math.min(element.duration, indicatorTime + dragState.deltaTime),
+ );
+ return (
+ timelineTimeToSnappedPixels({
+ time: displayedStartTime + clampedTime,
+ zoomLevel,
+ }) - elementLeft
+ );
+ },
+ [dragState.deltaTime, element.duration, zoomLevel],
+ );
+
+ return {
+ keyframeDragState: dragState,
+ handleKeyframeMouseDown,
+ handleKeyframeClick,
+ getVisualOffsetPx,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
new file mode 100644
index 00000000..0ab05fe3
--- /dev/null
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
@@ -0,0 +1,229 @@
+import { useCallback, useSyncExternalStore } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import type { SelectedKeyframeRef } from "@/types/animation";
+
+function getSelectedKeyframeId({
+ keyframe,
+}: {
+ keyframe: SelectedKeyframeRef;
+}): string {
+ return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
+}
+
+function mergeUniqueKeyframes({
+ keyframes,
+}: {
+ keyframes: SelectedKeyframeRef[];
+}): SelectedKeyframeRef[] {
+ const keyframesById = new Map<string, SelectedKeyframeRef>();
+ for (const keyframe of keyframes) {
+ keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
+ }
+ return [...keyframesById.values()];
+}
+
+export function useKeyframeSelection() {
+ const editor = useEditor();
+ const selectedKeyframes = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getSelectedKeyframes(),
+ );
+ const keyframeSelectionAnchor = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getKeyframeSelectionAnchor(),
+ );
+
+ const isKeyframeSelected = useCallback(
+ ({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
+ const keyframeId = getSelectedKeyframeId({ keyframe });
+ return selectedKeyframes.some(
+ (selectedKeyframe) =>
+ getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
+ );
+ },
+ [selectedKeyframes],
+ );
+
+ const setKeyframeSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
+ editor.selection.setSelectedKeyframes({
+ keyframes: uniqueKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
+ });
+ },
+ [editor],
+ );
+
+ const addKeyframesToSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const mergedKeyframes = mergeUniqueKeyframes({
+ keyframes: [...selectedKeyframes, ...keyframes],
+ });
+ editor.selection.setSelectedKeyframes({
+ keyframes: mergedKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
+ });
+ },
+ [selectedKeyframes, editor],
+ );
+
+ const removeKeyframesFromSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const keyframeIdsToRemove = new Set(
+ keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
+ );
+ const nextKeyframes = selectedKeyframes.filter(
+ (selectedKeyframe) =>
+ !keyframeIdsToRemove.has(
+ getSelectedKeyframeId({ keyframe: selectedKeyframe }),
+ ),
+ );
+ editor.selection.setSelectedKeyframes({
+ keyframes: nextKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
+ });
+ },
+ [selectedKeyframes, editor],
+ );
+
+ const clearKeyframeSelection = useCallback(() => {
+ editor.selection.clearKeyframeSelection();
+ }, [editor]);
+
+ const toggleKeyframeSelection = useCallback(
+ ({
+ keyframes,
+ isMultiKey,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ isMultiKey: boolean;
+ }) => {
+ const anchorKeyframe = keyframes[0];
+ const areAllKeyframesSelected = keyframes.every((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+ if (!isMultiKey) {
+ setKeyframeSelection({ keyframes, anchorKeyframe });
+ return;
+ }
+
+ if (areAllKeyframesSelected) {
+ removeKeyframesFromSelection({ keyframes, anchorKeyframe });
+ return;
+ }
+
+ addKeyframesToSelection({ keyframes, anchorKeyframe });
+ },
+ [
+ setKeyframeSelection,
+ isKeyframeSelected,
+ removeKeyframesFromSelection,
+ addKeyframesToSelection,
+ ],
+ );
+
+ const selectKeyframeRange = useCallback(
+ ({
+ orderedKeyframes,
+ targetKeyframes,
+ isAdditive,
+ }: {
+ orderedKeyframes: SelectedKeyframeRef[];
+ targetKeyframes: SelectedKeyframeRef[];
+ isAdditive: boolean;
+ }) => {
+ if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
+ return;
+ }
+
+ const anchorKeyframe =
+ keyframeSelectionAnchor ??
+ selectedKeyframes[selectedKeyframes.length - 1] ??
+ targetKeyframes[0];
+ if (!anchorKeyframe) {
+ return;
+ }
+
+ const targetKeyframeIds = new Set(
+ targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
+ );
+ const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
+ const anchorIndex = orderedKeyframes.findIndex(
+ (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
+ );
+ if (anchorIndex === -1) {
+ if (isAdditive) {
+ addKeyframesToSelection({
+ keyframes: targetKeyframes,
+ anchorKeyframe,
+ });
+ return;
+ }
+ setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
+ return;
+ }
+
+ const targetIndexes = orderedKeyframes
+ .map((keyframe, index) => ({
+ keyframeId: getSelectedKeyframeId({ keyframe }),
+ index,
+ }))
+ .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
+ .map(({ index }) => index);
+ if (targetIndexes.length === 0) {
+ return;
+ }
+
+ const rangeStart = Math.min(anchorIndex, ...targetIndexes);
+ const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
+ const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
+
+ if (isAdditive) {
+ addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
+ return;
+ }
+
+ setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
+ },
+ [
+ keyframeSelectionAnchor,
+ selectedKeyframes,
+ addKeyframesToSelection,
+ setKeyframeSelection,
+ ],
+ );
+
+ return {
+ selectedKeyframes,
+ keyframeSelectionAnchor,
+ isKeyframeSelected,
+ setKeyframeSelection,
+ addKeyframesToSelection,
+ removeKeyframesFromSelection,
+ clearKeyframeSelection,
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts
index 12c6bf8a..c4f8d6ce 100644
--- a/apps/web/src/hooks/timeline/use-selection-box.ts
+++ b/apps/web/src/hooks/timeline/use-selection-box.ts
@@ -5,6 +5,7 @@ import { useEditor } from "../use-editor";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement | null>;
+ headerRef: React.RefObject<HTMLElement | null>;
onSelectionComplete: (
elements: { trackId: string; elementId: string }[],
) => void;
@@ -88,6 +89,7 @@ function isRectangleIntersecting({
export function useSelectionBox({
containerRef,
+ headerRef,
onSelectionComplete,
isEnabled = true,
tracksScrollRef,
@@ -131,6 +133,8 @@ export function useSelectionBox({
endPos,
});
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const timelineHeaderHeight =
+ headerRef.current?.getBoundingClientRect().height ?? 0;
const selectedElements: { trackId: string; elementId: string }[] = [];
for (const [trackIndex, track] of tracks.entries()) {
@@ -139,8 +143,9 @@ export function useSelectionBox({
trackIndex,
});
const trackHeight = getTrackHeight({ type: track.type });
- const elementTop = trackTop;
- const elementBottom = trackTop + trackHeight;
+ const elementTop =
+ timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
+ const elementBottom = elementTop + trackHeight;
for (const element of track.elements) {
const elementLeft = element.startTime * pixelsPerSecond;
@@ -168,7 +173,14 @@ export function useSelectionBox({
}
onSelectionComplete(selectedElements);
},
- [containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel],
+ [
+ containerRef,
+ headerRef,
+ onSelectionComplete,
+ tracks,
+ tracksScrollRef,
+ zoomLevel,
+ ],
);
useEffect(() => {
diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
index d78eb04d..f3e63894 100644
--- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
+++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import type { TimelineTrack } from "@/types/timeline";
interface UseSnapIndicatorPositionParams {
@@ -50,8 +50,10 @@ export function useSnapIndicatorPosition({
? trackLabelsRef.current.offsetWidth
: 0;
- const timelinePosition =
- (snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const timelinePosition = timelineTimeToSnappedPixels({
+ time: snapPoint?.time ?? 0,
+ zoomLevel,
+ });
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
return {
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index 632d2c78..8a30785e 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -8,6 +8,7 @@ import {
buildTextElement,
buildStickerElement,
buildElementFromMedia,
+ buildEffectElement,
} from "@/lib/timeline/element-utils";
import type { Command } from "@/lib/commands/base-command";
import { AddMediaAssetCommand } from "@/lib/commands/media";
@@ -16,17 +17,23 @@ import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getDragData, hasDragData } from "@/lib/drag-data";
import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
-import type { MediaDragData, StickerDragData } from "@/types/drag";
+import type {
+ MediaDragData,
+ StickerDragData,
+ EffectDragData,
+} from "@/types/drag";
interface UseTimelineDragDropProps {
containerRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
+ tracksScrollRef?: RefObject<HTMLDivElement | null>;
zoomLevel: number;
}
export function useTimelineDragDrop({
containerRef,
headerRef,
+ tracksScrollRef,
zoomLevel,
}: UseTimelineDragDropProps) {
const editor = useEditor();
@@ -54,6 +61,7 @@ export function useTimelineDragDrop({
if (dragData.type === "text") return "text";
if (dragData.type === "sticker") return "sticker";
+ if (dragData.type === "effect") return "effect";
if (dragData.type === "media") {
return dragData.mediaType;
}
@@ -70,7 +78,11 @@ export function useTimelineDragDrop({
elementType: ElementType;
mediaId?: string;
}): number => {
- if (elementType === "text" || elementType === "sticker") {
+ if (
+ elementType === "text" ||
+ elementType === "sticker" ||
+ elementType === "effect"
+ ) {
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
if (mediaId) {
@@ -94,11 +106,16 @@ export function useTimelineDragDrop({
(e: React.DragEvent) => {
e.preventDefault();
- const rect = containerRef.current?.getBoundingClientRect();
- if (!rect) return;
+ const scrollContainer = tracksScrollRef?.current;
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ containerRef.current?.getBoundingClientRect();
+ if (!referenceRect) return;
const headerHeight =
headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
const hasFiles = e.dataTransfer.types.includes("Files");
const isExternal =
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
@@ -121,8 +138,15 @@ export function useTimelineDragDrop({
mediaId: dragData?.type === "media" ? dragData.id : undefined,
});
- const mouseX = e.clientX - rect.left;
- const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
+ const mouseX = e.clientX - referenceRect.left + scrollLeft;
+ const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
+
+ const targetElementTypes =
+ dragData?.type === "effect"
+ ? (dragData as EffectDragData).targetElementTypes
+ : dragData?.type === "media"
+ ? (dragData as MediaDragData).targetElementTypes
+ : undefined;
const target = computeDropTarget({
elementType,
@@ -134,6 +158,7 @@ export function useTimelineDragDrop({
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
+ targetElementTypes,
});
target.xPosition = getSnappedTime({ time: target.xPosition });
@@ -144,6 +169,7 @@ export function useTimelineDragDrop({
[
containerRef,
headerRef,
+ tracksScrollRef,
tracks,
currentTime,
zoomLevel,
@@ -182,19 +208,6 @@ export function useTimelineDragDrop({
target: DropTarget;
dragData: { name?: string; content?: string };
}) => {
- let trackId: string;
-
- if (target.isNewTrack) {
- trackId = editor.timeline.addTrack({
- type: "text",
- index: target.trackIndex,
- });
- } else {
- const track = tracks[target.trackIndex];
- if (!track) return;
- trackId = track.id;
- }
-
const element = buildTextElement({
raw: {
name: dragData.name ?? "",
@@ -203,12 +216,26 @@ export function useTimelineDragDrop({
startTime: target.xPosition,
});
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const track = tracks[target.trackIndex];
+ if (!track) return;
editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
+ placement: { mode: "explicit", trackId: track.id },
element,
});
},
- [editor.timeline, tracks],
+ [editor.command, editor.timeline, tracks],
);
const executeStickerDrop = useCallback(
@@ -219,52 +246,46 @@ export function useTimelineDragDrop({
target: DropTarget;
dragData: StickerDragData;
}) => {
- let trackId: string;
-
- if (target.isNewTrack) {
- trackId = editor.timeline.addTrack({
- type: "sticker",
- index: target.trackIndex,
- });
- } else {
- const track = tracks[target.trackIndex];
- if (!track) return;
- trackId = track.id;
- }
-
const element = buildStickerElement({
stickerId: dragData.stickerId,
name: dragData.name,
startTime: target.xPosition,
});
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const track = tracks[target.trackIndex];
+ if (!track) return;
editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
+ placement: { mode: "explicit", trackId: track.id },
element,
});
},
- [editor.timeline, tracks],
+ [editor.command, editor.timeline, tracks],
);
const executeMediaDrop = useCallback(
({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
+ if (target.targetElement) {
+ toast.info("Replace media source is coming soon!");
+ return;
+ }
+
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
if (!mediaAsset) return;
const trackType: TrackType =
dragData.mediaType === "audio" ? "audio" : "video";
- let trackId: string;
-
- if (target.isNewTrack) {
- trackId = editor.timeline.addTrack({
- type: trackType,
- index: target.trackIndex,
- });
- } else {
- const track = tracks[target.trackIndex];
- if (!track) return;
- trackId = track.id;
- }
const duration =
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
@@ -276,12 +297,80 @@ export function useTimelineDragDrop({
startTime: target.xPosition,
});
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor.command, editor.timeline, mediaAssets, tracks],
+ );
+
+ const executeEffectDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: EffectDragData;
+ }) => {
+ if (target.targetElement) {
+ editor.timeline.addClipEffect({
+ trackId: target.targetElement.trackId,
+ elementId: target.targetElement.elementId,
+ effectType: dragData.effectType,
+ });
+ return;
+ }
+
+ const effectTrack = tracks.find((t) => t.type === "effect");
+ let trackId: string;
+
+ if (effectTrack) {
+ trackId = effectTrack.id;
+ } else if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element: buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ }),
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ } else {
+ const track = tracks[target.trackIndex];
+ if (!track || track.type !== "effect") return;
+ trackId = track.id;
+ }
+
+ const element = buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ });
+
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
},
- [editor.timeline, mediaAssets, tracks],
+ [editor.command, editor.timeline, tracks],
);
const executeFileDrop = useCallback(
@@ -384,16 +473,27 @@ export function useTimelineDragDrop({
executeTextDrop({ target: currentTarget, dragData });
} else if (dragData.type === "sticker") {
executeStickerDrop({ target: currentTarget, dragData });
+ } else if (dragData.type === "effect") {
+ executeEffectDrop({
+ target: currentTarget,
+ dragData: dragData as EffectDragData,
+ });
} else {
executeMediaDrop({ target: currentTarget, dragData });
}
} else if (hasFiles) {
- const rect = containerRef.current?.getBoundingClientRect();
- if (!rect) return;
- const mouseX = e.clientX - rect.left;
+ const scrollContainer = tracksScrollRef?.current;
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ containerRef.current?.getBoundingClientRect();
+ if (!referenceRect) return;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+ const mouseX = e.clientX - referenceRect.left + scrollLeft;
const headerHeight =
headerRef?.current?.getBoundingClientRect().height ?? 0;
- const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
+ const mouseY =
+ e.clientY - referenceRect.top + scrollTop - headerHeight;
await executeFileDrop({
files: Array.from(e.dataTransfer.files),
mouseX,
@@ -410,9 +510,11 @@ export function useTimelineDragDrop({
executeTextDrop,
executeStickerDrop,
executeMediaDrop,
+ executeEffectDrop,
executeFileDrop,
containerRef,
headerRef,
+ tracksScrollRef,
],
);
diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts
new file mode 100644
index 00000000..a84781e0
--- /dev/null
+++ b/apps/web/src/hooks/use-effect-preview.ts
@@ -0,0 +1,47 @@
+import { useEffect, useRef } from "react";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
+import type { EffectParamValues } from "@/types/effects";
+
+export function useEffectPreview({
+ effectType,
+ params,
+ canvasRef,
+ isActive,
+}: {
+ effectType: string;
+ params: EffectParamValues;
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ isActive: boolean;
+}): void {
+ const requestRef = useRef<number>(0);
+
+ useEffect(() => {
+ if (!isActive) {
+ if (requestRef.current) {
+ cancelAnimationFrame(requestRef.current);
+ requestRef.current = 0;
+ }
+ return;
+ }
+
+ const loop = (): void => {
+ const canvas = canvasRef.current;
+ if (canvas) {
+ effectPreviewService.renderPreview({
+ effectType,
+ params,
+ targetCanvas: canvas,
+ });
+ }
+ requestRef.current = requestAnimationFrame(loop);
+ };
+
+ requestRef.current = requestAnimationFrame(loop);
+
+ return () => {
+ if (requestRef.current) {
+ cancelAnimationFrame(requestRef.current);
+ }
+ };
+ }, [effectType, params, canvasRef, isActive]);
+}
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
index 8be6420c..fb561396 100644
--- a/apps/web/src/hooks/use-transform-handles.ts
+++ b/apps/web/src/hooks/use-transform-handles.ts
@@ -1,7 +1,6 @@
-import { useCallback, useRef, useState } from "react";
+import { useCallback, useRef, useState, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
-import { useSyncExternalStore } from "react";
import {
getVisibleElementsWithBounds,
type ElementWithBounds,
@@ -18,7 +17,13 @@ import {
type SnapLine,
} from "@/lib/preview/preview-snap";
import { isVisualElement } from "@/lib/timeline/element-utils";
+import {
+ getElementLocalTime,
+ resolveTransformAtTime,
+ setChannel,
+} from "@/lib/animation";
import type { Transform } from "@/types/timeline";
+import type { ElementAnimations } from "@/types/animation";
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
type HandleType = Corner | "rotation";
@@ -32,6 +37,8 @@ interface ScaleState {
initialBoundsCy: number;
baseWidth: number;
baseHeight: number;
+ shouldClearScaleAnimation: boolean;
+ animationsWithoutScale: ElementAnimations | undefined;
}
interface RotationState {
@@ -78,16 +85,16 @@ function getCornerDistance({
};
corner: Corner;
}): number {
- const halfW = bounds.width / 2;
- const halfH = bounds.height / 2;
+ const halfWidth = bounds.width / 2;
+ const halfHeight = bounds.height / 2;
const angleRad = (bounds.rotation * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
const localX =
- corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
+ corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
const localY =
- corner === "top-left" || corner === "top-right" ? -halfH : halfH;
+ corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
const rotatedX = localX * cos - localY * sin;
const rotatedY = localX * sin + localY * cos;
@@ -114,6 +121,8 @@ export function useTransformHandles({
const tracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
+ const currentTimeRef = useRef(currentTime);
+ currentTimeRef.current = currentTime;
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
@@ -144,19 +153,41 @@ export function useTransformHandles({
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
+ const localTime = getElementLocalTime({
+ timelineTime: currentTimeRef.current,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
const initialDistance = getCornerDistance({ bounds, corner });
- const baseWidth = bounds.width / element.transform.scale;
- const baseHeight = bounds.height / element.transform.scale;
+ const baseWidth = bounds.width / resolvedTransform.scale;
+ const baseHeight = bounds.height / resolvedTransform.scale;
+ const shouldClearScaleAnimation =
+ !!element.animations?.channels["transform.scale"];
+ const animationsWithoutScale = shouldClearScaleAnimation
+ ? setChannel({
+ animations: element.animations,
+ propertyPath: "transform.scale",
+ channel: undefined,
+ })
+ : element.animations;
scaleStateRef.current = {
trackId,
elementId,
- initialTransform: element.transform,
+ initialTransform: resolvedTransform,
initialDistance,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
baseWidth,
baseHeight,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
};
setActiveHandle(corner);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
@@ -172,19 +203,30 @@ export function useTransformHandles({
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
+ const localTime = getElementLocalTime({
+ timelineTime: currentTimeRef.current,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
const position = screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
canvas: canvasRef.current,
});
- const dx = position.x - bounds.cx;
- const dy = position.y - bounds.cy;
- const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
+ const deltaX = position.x - bounds.cx;
+ const deltaY = position.y - bounds.cy;
+ const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
rotationStateRef.current = {
trackId,
elementId,
- initialTransform: element.transform,
+ initialTransform: resolvedTransform,
initialAngle,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
@@ -220,11 +262,13 @@ export function useTransformHandles({
initialBoundsCy,
baseWidth,
baseHeight,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
} = scaleStateRef.current;
- const dx = position.x - initialBoundsCx;
- const dy = position.y - initialBoundsCy;
- const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1;
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
const scaleFactor = currentDistance / initialDistance;
const proposedScale = Math.max(
MIN_SCALE,
@@ -258,14 +302,22 @@ export function useTransformHandles({
setSnapLines(activeLines);
}
+ const updates: {
+ transform: Transform;
+ animations?: ElementAnimations;
+ } = {
+ transform: { ...initialTransform, scale: snappedScale },
+ };
+ if (shouldClearScaleAnimation) {
+ updates.animations = animationsWithoutScale;
+ }
+
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
- updates: {
- transform: { ...initialTransform, scale: snappedScale },
- },
+ updates,
},
],
});
@@ -282,9 +334,9 @@ export function useTransformHandles({
initialBoundsCy,
} = rotationStateRef.current;
- const dx = position.x - initialBoundsCx;
- const dy = position.y - initialBoundsCy;
- const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
let deltaAngle = currentAngle - initialAngle;
if (deltaAngle > 180) deltaAngle -= 360;
if (deltaAngle < -180) deltaAngle += 360;