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.ts89
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts83
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts231
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-selection.ts35
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-drag.ts18
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-selection.ts2
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts2
-rw-r--r--apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts42
-rw-r--r--apps/web/src/hooks/timeline/use-selection-box.ts255
-rw-r--r--apps/web/src/hooks/timeline/use-snap-indicator-position.ts14
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts223
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts178
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-zoom.ts28
-rw-r--r--apps/web/src/hooks/use-container-size.ts24
-rw-r--r--apps/web/src/hooks/use-editor.ts63
-rw-r--r--apps/web/src/hooks/use-effect-preview.ts47
-rw-r--r--apps/web/src/hooks/use-element-preview.ts38
-rw-r--r--apps/web/src/hooks/use-file-upload.ts12
-rw-r--r--apps/web/src/hooks/use-font-atlas.ts53
-rw-r--r--apps/web/src/hooks/use-keybindings.ts163
-rw-r--r--apps/web/src/hooks/use-mask-handles.ts258
-rw-r--r--apps/web/src/hooks/use-menu-preview.ts26
-rw-r--r--apps/web/src/hooks/use-paste-media.ts81
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts345
-rw-r--r--apps/web/src/hooks/use-resize-observer.ts26
-rw-r--r--apps/web/src/hooks/use-reveal-item.ts36
-rw-r--r--apps/web/src/hooks/use-storage-persistence.ts45
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts455
28 files changed, 1947 insertions, 925 deletions
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts
index e4f378d6..bfac931b 100644
--- a/apps/web/src/hooks/actions/use-editor-actions.ts
+++ b/apps/web/src/hooks/actions/use-editor-actions.ts
@@ -1,15 +1,22 @@
"use client";
+import { useEffect, useRef } from "react";
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";
+import { cancelInteraction } from "@/lib/cancel-interaction";
+import { invokeAction } from "@/lib/actions";
+import {
+ activateScope,
+ clearActiveScope,
+ type ScopeEntry,
+} from "@/lib/selection/scope";
export function useEditorActions() {
const editor = useEditor();
- const activeProject = editor.project.getActive();
const { selectedElements, setElementSelection } = useElementSelection();
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
const clipboard = useTimelineStore((s) => s.clipboard);
@@ -17,6 +24,39 @@ export function useEditorActions() {
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
+ const hasTimelineSelectionRef = useRef(false);
+ const clearTimelineSelectionRef = useRef(() => {});
+ const timelineScopeRef = useRef<ScopeEntry | null>(null);
+ const hasTimelineSelection =
+ selectedElements.length > 0 || selectedKeyframes.length > 0;
+
+ hasTimelineSelectionRef.current = hasTimelineSelection;
+ clearTimelineSelectionRef.current = () => {
+ setElementSelection({ elements: [] });
+ clearKeyframeSelection();
+ };
+
+ if (!timelineScopeRef.current) {
+ timelineScopeRef.current = {
+ hasSelection: () => hasTimelineSelectionRef.current,
+ clear: () => {
+ clearTimelineSelectionRef.current();
+ },
+ };
+ }
+
+ useEffect(() => {
+ if (!hasTimelineSelection) {
+ return;
+ }
+
+ const timelineScope = timelineScopeRef.current;
+ if (!timelineScope) {
+ return;
+ }
+
+ return activateScope({ entry: timelineScope });
+ }, [hasTimelineSelection]);
useActionHandler(
"toggle-play",
@@ -65,7 +105,7 @@ export function useEditorActions() {
useActionHandler(
"frame-step-forward",
() => {
- const fps = activeProject.settings.fps;
+ const fps = editor.project.getActive().settings.fps;
editor.playback.seek({
time: Math.min(
editor.timeline.getTotalDuration(),
@@ -79,7 +119,7 @@ export function useEditorActions() {
useActionHandler(
"frame-step-backward",
() => {
- const fps = activeProject.settings.fps;
+ const fps = editor.project.getActive().settings.fps;
editor.playback.seek({
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
});
@@ -241,13 +281,21 @@ export function useEditorActions() {
);
useActionHandler(
+ "cancel-interaction",
+ () => {
+ if (!cancelInteraction()) {
+ invokeAction("deselect-all");
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
"deselect-all",
() => {
- setElementSelection({ elements: [] });
- clearKeyframeSelection();
- const activeElement = document.activeElement;
- if (activeElement instanceof HTMLButtonElement) {
- activeElement.blur();
+ if (!clearActiveScope()) {
+ setElementSelection({ elements: [] });
+ clearKeyframeSelection();
}
},
undefined,
@@ -353,4 +401,29 @@ export function useEditorActions() {
},
undefined,
);
+
+ // todo: potnetially unify these two actions:
+ useActionHandler(
+ "remove-media-asset",
+ (args) => {
+ if (!args) return;
+ editor.media.removeMediaAsset({
+ projectId: args.projectId,
+ id: args.assetId,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "remove-media-assets",
+ (args) => {
+ if (!args) return;
+ editor.media.removeMediaAssets({
+ projectId: args.projectId,
+ ids: args.assetIds,
+ });
+ },
+ undefined,
+ );
}
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 ea01c718..fae8c527 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -18,16 +18,14 @@ import { snapTimeToFrame } from "@/lib/time";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
-import {
- snapElementEdge,
- type SnapPoint,
-} from "@/lib/timeline/snap-utils";
+import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
+import { registerCanceller } from "@/lib/cancel-interaction";
import type {
DropTarget,
ElementDragState,
TimelineElement,
TimelineTrack,
-} from "@/types/timeline";
+} from "@/lib/timeline";
interface UseElementInteractionProps {
zoomLevel: number;
@@ -213,6 +211,20 @@ export function useElementInteraction({
setDragDropTarget(null);
}, []);
+ const cancelCurrentDrag = useCallback(() => {
+ pendingDragRef.current = null;
+ mouseDownLocationRef.current = null;
+ setIsPendingDrag(false);
+ endDrag();
+ onSnapPointChange?.(null);
+ }, [endDrag, onSnapPointChange]);
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ return registerCanceller({ fn: cancelCurrentDrag });
+ }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
+
const getDragSnapResult = useCallback(
({
frameSnappedTime,
@@ -445,33 +457,36 @@ 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: targetTrack.id,
+ targetTrackId: newTrackId,
elementId: dragState.elementId,
newStartTime: snappedTime,
+ createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
rippleEnabled: rippleEditingEnabled,
});
- if (targetTrack.id !== dragState.trackId) {
- selectElement({ trackId: targetTrack.id, elementId: dragState.elementId });
+ 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,
+ rippleEnabled: rippleEditingEnabled,
+ });
+ if (targetTrack.id !== dragState.trackId) {
+ selectElement({
+ trackId: targetTrack.id,
+ elementId: dragState.elementId,
+ });
+ }
}
}
- }
endDrag();
onSnapPointChange?.(null);
@@ -520,10 +535,10 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
- const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
+ 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,
@@ -538,12 +553,12 @@ export function useElementInteraction({
return;
}
- 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;
- if (isMultiSelect) {
+ if (isMultiSelect) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
@@ -551,7 +566,7 @@ export function useElementInteraction({
});
}
- const clickOffsetTime = getClickOffsetTime({
+ const clickOffsetTime = getClickOffsetTime({
clientX: event.clientX,
elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel,
@@ -579,9 +594,9 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
- event.stopPropagation();
+ event.stopPropagation();
- 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) {
@@ -593,7 +608,7 @@ export function useElementInteraction({
// modifier keys already handled in mousedown
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
- const alreadySelected = isElementSelected({
+ const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
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 3f49e798..b41f82f9 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
@@ -9,7 +9,13 @@ import {
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;
@@ -37,7 +43,6 @@ export function useTimelineElementResize({
onResizeStateChange,
}: UseTimelineElementResizeProps) {
const editor = useEditor();
- const activeProject = editor.project.getActive();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const rippleEditingEnabled = useTimelineStore(
@@ -91,6 +96,94 @@ export function useTimelineElementResize({
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;
@@ -100,7 +193,7 @@ export function useTimelineElementResize({
deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
- const projectFps = activeProject.settings.fps;
+ const projectFps = editor.project.getActive().settings.fps;
const minDurationSeconds = 1 / projectFps;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (shouldSnap) {
@@ -139,14 +232,20 @@ export function useTimelineElementResize({
}
onSnapPointChange?.(resizeSnapPoint);
- const otherElements = track.elements.filter(({ id }) => id !== element.id);
- const initialEndTime = resizing.initialStartTime + resizing.initialDuration;
+ 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)
+ .reduce(
+ (min, { startTime }) => Math.min(min, startTime),
+ Infinity,
+ )
: Infinity;
const leftNeighborBound =
@@ -164,20 +263,26 @@ export function useTimelineElementResize({
: -Infinity;
if (resizing.side === "left") {
- const sourceDuration =
- resizing.initialTrimStart +
- resizing.initialDuration +
- resizing.initialTrimEnd;
+ const sourceDuration = getSourceDuration({
+ trimStart: resizing.initialTrimStart,
+ duration: resizing.initialDuration,
+ trimEnd: resizing.initialTrimEnd,
+ });
const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound)
? Math.max(
0,
resizing.initialTrimStart +
- (leftNeighborBound - resizing.initialStartTime),
+ getSourceDeltaForClipDelta(
+ leftNeighborBound - resizing.initialStartTime,
+ ),
)
: 0;
const maxAllowed =
- sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
- const calculated = resizing.initialTrimStart + deltaTime;
+ sourceDuration -
+ resizing.initialTrimEnd -
+ getVisibleSourceSpanForDuration(minDurationSeconds);
+ const calculated =
+ resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
@@ -187,13 +292,17 @@ export function useTimelineElementResize({
),
fps: projectFps,
});
- const trimDelta = newTrimStart - resizing.initialTrimStart;
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime + trimDelta,
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - newTrimStart - resizing.initialTrimEnd,
+ );
+ const newDuration = snapTimeToFrame({
+ time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
+ const trimDelta = resizing.initialDuration - newDuration;
+ const newStartTime = snapTimeToFrame({
+ time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
@@ -233,31 +342,50 @@ export function useTimelineElementResize({
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
- const trimDelta =
+ const leftBound = Number.isFinite(leftNeighborBound)
+ ? leftNeighborBound
+ : 0;
+ const trimDeltaFromTrimStart =
minTrimStartForNeighbor - resizing.initialTrimStart;
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime + trimDelta,
+ 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 = snapTimeToFrame({
+ time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
+ const newStartTime = snapTimeToFrame({
+ time:
+ resizing.initialStartTime +
+ (resizing.initialDuration - newDuration),
fps: projectFps,
});
- setCurrentTrimStart(minTrimStartForNeighbor);
+ setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
- currentTrimStartRef.current = minTrimStartForNeighbor;
+ currentTrimStartRef.current = newTrimStart;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
}
} else {
- const sourceDuration =
- resizing.initialTrimStart +
- resizing.initialDuration +
- resizing.initialTrimEnd;
- const newTrimEnd = resizing.initialTrimEnd - deltaTime;
+ 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;
@@ -268,7 +396,10 @@ export function useTimelineElementResize({
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
- time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration),
+ time: Math.min(
+ baseDuration + extensionNeeded,
+ maxAllowedDuration,
+ ),
fps: projectFps,
});
@@ -277,12 +408,11 @@ export function useTimelineElementResize({
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
} else {
- const extensionToLimit = resizing.initialTrimEnd;
+ const unclampedDuration = getDurationForVisibleSourceSpan(
+ Math.max(0, sourceDuration - resizing.initialTrimStart),
+ );
const newDuration = snapTimeToFrame({
- time: Math.min(
- resizing.initialDuration + extensionToLimit,
- maxAllowedDuration,
- ),
+ time: Math.min(unclampedDuration, maxAllowedDuration),
fps: projectFps,
});
@@ -295,13 +425,15 @@ export function useTimelineElementResize({
const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration)
? Math.max(
0,
- resizing.initialDuration +
- resizing.initialTrimEnd -
- maxAllowedDuration,
+ sourceDuration -
+ resizing.initialTrimStart -
+ getVisibleSourceSpanForDuration(maxAllowedDuration),
)
: 0;
const maxTrimEnd =
- sourceDuration - resizing.initialTrimStart - minDurationSeconds;
+ sourceDuration -
+ resizing.initialTrimStart -
+ getVisibleSourceSpanForDuration(minDurationSeconds);
const clampedTrimEnd = Math.min(
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
@@ -310,9 +442,12 @@ export function useTimelineElementResize({
time: clampedTrimEnd,
fps: projectFps,
});
- const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - resizing.initialTrimStart - finalTrimEnd,
+ );
const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
+ time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
@@ -326,13 +461,16 @@ export function useTimelineElementResize({
[
resizing,
zoomLevel,
- activeProject.settings.fps,
snappingEnabled,
editor,
element.id,
track.elements,
onSnapPointChange,
canExtendElementDuration,
+ getDurationForVisibleSourceSpan,
+ getSourceDeltaForClipDelta,
+ getSourceDuration,
+ getVisibleSourceSpanForDuration,
isShiftHeldRef,
],
);
@@ -349,7 +487,12 @@ export function useTimelineElementResize({
const startTimeChanged = finalStartTime !== resizing.initialStartTime;
const durationChanged = finalDuration !== resizing.initialDuration;
- if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) {
+ if (
+ trimStartChanged ||
+ trimEndChanged ||
+ startTimeChanged ||
+ durationChanged
+ ) {
editor.timeline.updateElementTrim({
elementId: element.id,
trimStart: finalTrimStart,
diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts
index c1e5b53f..dcfb5720 100644
--- a/apps/web/src/hooks/timeline/element/use-element-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts
@@ -1,14 +1,10 @@
-import { useCallback, useSyncExternalStore } from "react";
+import { useCallback } from "react";
import { useEditor } from "@/hooks/use-editor";
-
-type ElementRef = { trackId: string; elementId: string };
+import type { ElementRef } from "@/lib/timeline/types";
export function useElementSelection() {
const editor = useEditor();
- const selectedElements = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getSelectedElements(),
- );
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
const isElementSelected = useCallback(
({ trackId, elementId }: ElementRef) =>
@@ -82,6 +78,30 @@ export function useElementSelection() {
[editor],
);
+
+ /**
+ * Merges elements into the current selection, deduplicating by identity.
+ * Used for additive box-select where the pre-drag selection is preserved.
+ */
+ const mergeElementsIntoSelection = useCallback(
+ ({ elements }: { elements: ElementRef[] }) => {
+ const merged = [
+ ...selectedElements.filter(
+ (selectedElement) =>
+ !elements.some(
+ (element) =>
+ element.trackId === selectedElement.trackId &&
+ element.elementId === selectedElement.elementId,
+ ),
+ ),
+ ...elements,
+ ];
+ editor.selection.setSelectedElements({ elements: merged });
+ },
+ [selectedElements, editor],
+ );
+
+
/**
* Handles click interaction on an element.
* - Regular click: select only this element
@@ -107,6 +127,7 @@ export function useElementSelection() {
isElementSelected,
selectElement,
setElementSelection,
+ mergeElementsIntoSelection,
addElementToSelection,
removeElementFromSelection,
toggleElementSelection,
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
index b18db6eb..4f961a0a 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
@@ -15,9 +15,10 @@ import {
} 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 { SelectedKeyframeRef } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
import type { Command } from "@/lib/commands/base-command";
+import { registerCanceller } from "@/lib/cancel-interaction";
export interface KeyframeDragState {
isDragging: boolean;
draggingKeyframeIds: Set<string>;
@@ -69,6 +70,13 @@ export function useKeyframeDrag({
setDragState(initialDragState);
}, []);
+ const cancelDrag = useCallback(() => {
+ pendingDragRef.current = null;
+ mouseDownXRef.current = null;
+ setIsPendingDrag(false);
+ endDrag();
+ }, [endDrag]);
+
const commitDrag = useCallback(
({
keyframeRefs,
@@ -110,6 +118,12 @@ export function useKeyframeDrag({
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
+ return registerCanceller({ fn: cancelDrag });
+ }, [dragState.isDragging, isPendingDrag, cancelDrag]);
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
const handleMouseMove = ({ clientX }: MouseEvent) => {
if (isPendingDrag && pendingDragRef.current) {
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
index 0ab05fe3..8634cb48 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
@@ -1,6 +1,6 @@
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
-import type { SelectedKeyframeRef } from "@/types/animation";
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
function getSelectedKeyframeId({
keyframe,
diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
index 0e7fabd5..512db821 100644
--- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts
+++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
@@ -15,7 +15,7 @@ import {
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
-import type { Bookmark } from "@/types/timeline";
+import type { Bookmark } from "@/lib/timeline";
export interface BookmarkDragState {
isDragging: boolean;
diff --git a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts
new file mode 100644
index 00000000..50833f14
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts
@@ -0,0 +1,42 @@
+import { type RefObject, useLayoutEffect, useRef } from "react";
+
+interface UseInitialScrollBottomProps {
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef: RefObject<HTMLDivElement | null>;
+ onAfterScroll?: () => void;
+ /** Defers the scroll until there is at least one track to measure against. */
+ isReady: boolean;
+}
+
+/**
+ * Scrolls the timeline tracks viewport to the bottom once on mount.
+ * useLayoutEffect runs synchronously after React commits the DOM but before
+ * the browser paints, so the initial scroll position is never visible.
+ */
+export function useInitialScrollBottom({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ onAfterScroll,
+ isReady,
+}: UseInitialScrollBottomProps): void {
+ const hasScrolledRef = useRef(false);
+
+ useLayoutEffect(() => {
+ if (!isReady || hasScrolledRef.current) return;
+
+ const viewport = tracksScrollRef.current;
+ if (!viewport) return;
+
+ const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
+ if (maxScrollTop <= 0) return;
+
+ viewport.scrollTop = maxScrollTop;
+
+ if (trackLabelsScrollRef.current) {
+ trackLabelsScrollRef.current.scrollTop = maxScrollTop;
+ }
+
+ onAfterScroll?.();
+ hasScrolledRef.current = true;
+ }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]);
+}
diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts
deleted file mode 100644
index c4f8d6ce..00000000
--- a/apps/web/src/hooks/timeline/use-selection-box.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
-import { useEditor } from "../use-editor";
-
-interface UseSelectionBoxProps {
- containerRef: React.RefObject<HTMLElement | null>;
- headerRef: React.RefObject<HTMLElement | null>;
- onSelectionComplete: (
- elements: { trackId: string; elementId: string }[],
- ) => void;
- isEnabled?: boolean;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- zoomLevel: number;
-}
-
-interface SelectionBoxState {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- isActive: boolean;
-}
-
-interface SelectionRectangle {
- left: number;
- top: number;
- right: number;
- bottom: number;
-}
-
-function getNormalizedRectangle({
- startPos,
- endPos,
-}: {
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
-}): SelectionRectangle {
- return {
- left: Math.min(startPos.x, endPos.x),
- top: Math.min(startPos.y, endPos.y),
- right: Math.max(startPos.x, endPos.x),
- bottom: Math.max(startPos.y, endPos.y),
- };
-}
-
-function getSelectionRectangleInContent({
- container,
- scrollContainer,
- startPos,
- endPos,
-}: {
- container: HTMLElement;
- scrollContainer: HTMLDivElement | null;
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
-}): SelectionRectangle {
- const containerRect = container.getBoundingClientRect();
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 0;
-
- const adjustedStart = {
- x: startPos.x - containerRect.left + scrollLeft,
- y: startPos.y - containerRect.top + scrollTop,
- };
- const adjustedEnd = {
- x: endPos.x - containerRect.left + scrollLeft,
- y: endPos.y - containerRect.top + scrollTop,
- };
-
- return getNormalizedRectangle({
- startPos: adjustedStart,
- endPos: adjustedEnd,
- });
-}
-
-function isRectangleIntersecting({
- elementRectangle,
- selectionRectangle,
-}: {
- elementRectangle: SelectionRectangle;
- selectionRectangle: SelectionRectangle;
-}): boolean {
- return !(
- elementRectangle.right < selectionRectangle.left ||
- elementRectangle.left > selectionRectangle.right ||
- elementRectangle.bottom < selectionRectangle.top ||
- elementRectangle.top > selectionRectangle.bottom
- );
-}
-
-export function useSelectionBox({
- containerRef,
- headerRef,
- onSelectionComplete,
- isEnabled = true,
- tracksScrollRef,
- zoomLevel,
-}: UseSelectionBoxProps) {
- const editor = useEditor();
- const tracks = editor.timeline.getTracks();
- const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
- null,
- );
- const justFinishedSelectingRef = useRef(false);
-
- const handleMouseDown = useCallback(
- ({ clientX, clientY }: React.MouseEvent) => {
- if (!isEnabled) return;
-
- setSelectionBox({
- startPos: { x: clientX, y: clientY },
- currentPos: { x: clientX, y: clientY },
- isActive: false,
- });
- },
- [isEnabled],
- );
-
- const selectElementsInBox = useCallback(
- ({
- startPos,
- endPos,
- }: {
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
- }) => {
- if (!containerRef.current) return;
-
- const container = containerRef.current;
- const selectionRectangle = getSelectionRectangleInContent({
- container,
- scrollContainer: tracksScrollRef.current,
- startPos,
- 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()) {
- const trackTop = getCumulativeHeightBefore({
- tracks,
- trackIndex,
- });
- const trackHeight = getTrackHeight({ type: track.type });
- const elementTop =
- timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
- const elementBottom = elementTop + trackHeight;
-
- for (const element of track.elements) {
- const elementLeft = element.startTime * pixelsPerSecond;
- const elementRight = elementLeft + element.duration * pixelsPerSecond;
-
- const elementRectangle = {
- left: elementLeft,
- top: elementTop,
- right: elementRight,
- bottom: elementBottom,
- };
-
- const intersects = isRectangleIntersecting({
- elementRectangle,
- selectionRectangle,
- });
-
- if (intersects) {
- selectedElements.push({
- trackId: track.id,
- elementId: element.id,
- });
- }
- }
- }
- onSelectionComplete(selectedElements);
- },
- [
- containerRef,
- headerRef,
- onSelectionComplete,
- tracks,
- tracksScrollRef,
- zoomLevel,
- ],
- );
-
- useEffect(() => {
- if (!selectionBox) return;
-
- const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
- const deltaX = Math.abs(clientX - selectionBox.startPos.x);
- const deltaY = Math.abs(clientY - selectionBox.startPos.y);
- const shouldActivate = deltaX > 5 || deltaY > 5;
-
- const newSelectionBox = {
- ...selectionBox,
- currentPos: { x: clientX, y: clientY },
- isActive: shouldActivate || selectionBox.isActive,
- };
-
- setSelectionBox(newSelectionBox);
-
- if (newSelectionBox.isActive) {
- selectElementsInBox({
- startPos: newSelectionBox.startPos,
- endPos: newSelectionBox.currentPos,
- });
- }
- };
-
- const handleMouseUp = () => {
- if (selectionBox?.isActive) {
- justFinishedSelectingRef.current = true;
- requestAnimationFrame(() => {
- justFinishedSelectingRef.current = false;
- });
- }
- setSelectionBox(null);
- };
-
- window.addEventListener("mousemove", handleMouseMove);
- window.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- window.removeEventListener("mousemove", handleMouseMove);
- window.removeEventListener("mouseup", handleMouseUp);
- };
- }, [selectionBox, selectElementsInBox]);
-
- useEffect(() => {
- if (!selectionBox) return;
-
- const previousBodyUserSelect = document.body.style.userSelect;
- const container = containerRef.current;
- const previousContainerUserSelect = container?.style.userSelect ?? "";
-
- document.body.style.userSelect = "none";
- if (container) container.style.userSelect = "none";
-
- return () => {
- document.body.style.userSelect = previousBodyUserSelect;
- if (container) container.style.userSelect = previousContainerUserSelect;
- };
- }, [selectionBox, containerRef]);
-
- const shouldIgnoreClick = useCallback(() => {
- return justFinishedSelectingRef.current;
- }, []);
-
- return {
- selectionBox,
- handleMouseDown,
- isSelecting: selectionBox?.isActive || false,
- shouldIgnoreClick,
- };
-}
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 f3e63894..1a8f3c5d 100644
--- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
+++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
@@ -1,13 +1,10 @@
import { useEffect, useState } from "react";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import type { TimelineTrack } from "@/types/timeline";
-
+import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
- tracks: TimelineTrack[];
timelineRef: React.RefObject<HTMLDivElement | null>;
- trackLabelsRef?: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
}
@@ -20,9 +17,7 @@ interface SnapIndicatorPosition {
export function useSnapIndicatorPosition({
snapPoint,
zoomLevel,
- tracks,
timelineRef,
- trackLabelsRef,
tracksScrollRef,
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
const [scrollLeft, setScrollLeft] = useState(0);
@@ -45,16 +40,11 @@ export function useSnapIndicatorPosition({
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
- const trackLabelsWidth =
- tracks.length > 0 && trackLabelsRef?.current
- ? trackLabelsRef.current.offsetWidth
- : 0;
-
const timelinePosition = timelineTimeToSnappedPixels({
time: snapPoint?.time ?? 0,
zoomLevel,
});
- const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
+ const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft;
return {
leftPosition,
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 8a30785e..e8f6e49d 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -2,26 +2,28 @@ import { useState, useCallback, type RefObject } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import {
buildTextElement,
+ buildGraphicElement,
buildStickerElement,
buildElementFromMedia,
buildEffectElement,
} from "@/lib/timeline/element-utils";
-import type { Command } from "@/lib/commands/base-command";
-import { AddMediaAssetCommand } from "@/lib/commands/media";
import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
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 { isMainTrack } from "@/lib/timeline/track-utils";
+import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
import type {
MediaDragData,
+ GraphicDragData,
StickerDragData,
EffectDragData,
-} from "@/types/drag";
+} from "@/lib/timeline/drag";
interface UseTimelineDragDropProps {
containerRef: RefObject<HTMLDivElement | null>;
@@ -41,17 +43,12 @@ export function useTimelineDragDrop({
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [dragElementType, setElementType] = useState<ElementType | null>(null);
- const tracks = editor.timeline.getTracks();
- const currentTime = editor.playback.getCurrentTime();
- const mediaAssets = editor.media.getAssets();
- const activeProject = editor.project.getActive();
-
const getSnappedTime = useCallback(
({ time }: { time: number }) => {
- const projectFps = activeProject.settings.fps;
+ const projectFps = editor.project.getActive().settings.fps;
return snapTimeToFrame({ time, fps: projectFps });
},
- [activeProject.settings.fps],
+ [editor],
);
const getElementType = useCallback(
@@ -60,6 +57,7 @@ export function useTimelineDragDrop({
if (!dragData) return null;
if (dragData.type === "text") return "text";
+ if (dragData.type === "graphic") return "graphic";
if (dragData.type === "sticker") return "sticker";
if (dragData.type === "effect") return "effect";
if (dragData.type === "media") {
@@ -80,18 +78,20 @@ export function useTimelineDragDrop({
}): number => {
if (
elementType === "text" ||
+ elementType === "graphic" ||
elementType === "sticker" ||
elementType === "effect"
) {
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
if (mediaId) {
+ const mediaAssets = editor.media.getAssets();
const media = mediaAssets.find((m) => m.id === mediaId);
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
},
- [mediaAssets],
+ [editor],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
@@ -148,6 +148,8 @@ export function useTimelineDragDrop({
? (dragData as MediaDragData).targetElementTypes
: undefined;
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
const target = computeDropTarget({
elementType,
mouseX,
@@ -170,12 +172,11 @@ export function useTimelineDragDrop({
containerRef,
headerRef,
tracksScrollRef,
- tracks,
- currentTime,
zoomLevel,
getElementType,
getElementDuration,
getSnappedTime,
+ editor,
],
);
@@ -228,6 +229,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@@ -235,7 +237,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, tracks],
+ [editor],
);
const executeStickerDrop = useCallback(
@@ -253,7 +255,45 @@ export function useTimelineDragDrop({
});
if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex);
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeGraphicDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: GraphicDragData;
+ }) => {
+ const element = buildGraphicElement({
+ definitionId: dragData.definitionId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ params: dragData.params,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
@@ -264,6 +304,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@@ -271,7 +312,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, tracks],
+ [editor],
);
const executeMediaDrop = useCallback(
@@ -281,6 +322,7 @@ export function useTimelineDragDrop({
return;
}
+ const mediaAssets = editor.media.getAssets();
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
if (!mediaAsset) return;
@@ -309,6 +351,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@@ -316,7 +359,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, mediaAssets, tracks],
+ [editor],
);
const executeEffectDrop = useCallback(
@@ -336,6 +379,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const effectTrack = tracks.find((t) => t.type === "effect");
let trackId: string;
@@ -370,7 +414,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, tracks],
+ [editor],
);
const executeFileDrop = useCallback(
@@ -383,70 +427,98 @@ export function useTimelineDragDrop({
mouseX: number;
mouseY: number;
}) => {
+ const activeProject = editor.project.getActiveOrNull();
if (!activeProject) return;
- const processedAssets = await processMediaAssets({ files });
- const projectId = activeProject.metadata.id;
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+ const projectId = activeProject.metadata.id;
- for (const asset of processedAssets) {
- const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const currentTracks = editor.timeline.getTracks();
- const dropTarget = computeDropTarget({
- elementType: asset.type,
- mouseX,
- mouseY,
- tracks: currentTracks,
- playheadTime: currentTime,
- isExternalDrop: true,
- elementDuration: duration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
- zoomLevel,
- });
+ for (const asset of processedAssets) {
+ const createdAsset = await editor.media.addMediaAsset({
+ projectId,
+ asset,
+ });
+ if (!createdAsset) continue;
- const trackType: TrackType = asset.type === "audio" ? "audio" : "video";
- const addMediaCmd = new AddMediaAssetCommand(projectId, asset);
- const assetId = addMediaCmd.getAssetId();
+ const duration =
+ createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const currentTracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const onlyTrack = currentTracks[0];
+ const reuseMainTrackId =
+ createdAsset.type !== "audio" &&
+ currentTracks.length === 1 &&
+ !!onlyTrack &&
+ isMainTrack(onlyTrack) &&
+ onlyTrack.elements.length === 0
+ ? onlyTrack.id
+ : null;
+ const dropTarget = reuseMainTrackId
+ ? null
+ : computeDropTarget({
+ elementType: createdAsset.type,
+ mouseX,
+ mouseY,
+ tracks: currentTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ });
- const commands: Command[] = [addMediaCmd];
+ const trackType: TrackType =
+ createdAsset.type === "audio" ? "audio" : "video";
- let trackId: string | undefined;
- if (dropTarget.isNewTrack) {
- const addTrackCmd = new AddTrackCommand(
- trackType,
- dropTarget.trackIndex,
- );
- trackId = addTrackCmd.getTrackId();
- commands.unshift(addTrackCmd);
- } else {
- trackId = currentTracks[dropTarget.trackIndex]?.id;
- }
+ let trackId: string | undefined;
+ if (reuseMainTrackId) {
+ trackId = reuseMainTrackId;
+ } else {
+ if (!dropTarget) continue;
+ if (dropTarget.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(
+ trackType,
+ dropTarget.trackIndex,
+ );
+ trackId = addTrackCmd.getTrackId();
+ editor.command.execute({ command: addTrackCmd });
+ } else {
+ trackId = currentTracks[dropTarget.trackIndex]?.id;
+ }
+ }
- if (!trackId) return;
+ if (!trackId) continue;
- const element = buildElementFromMedia({
- mediaId: assetId,
- mediaType: asset.type,
- name: asset.name,
- duration,
- startTime: dropTarget.xPosition,
- buffer:
- asset.type === "audio"
- ? new AudioBuffer({ length: 1, sampleRate: 44100 })
- : undefined,
- });
+ const element = buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime: dropTarget?.xPosition ?? currentTime,
+ buffer:
+ createdAsset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId },
- });
- commands.push(insertCmd);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId },
+ });
+ editor.command.execute({ command: insertCmd });
+ }
- const batchCmd = new BatchCommand(commands);
- editor.command.execute({ command: batchCmd });
- }
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
},
- [activeProject, editor.command, editor.timeline, currentTime, zoomLevel],
+ [editor, zoomLevel],
);
const handleDrop = useCallback(
@@ -471,6 +543,11 @@ export function useTimelineDragDrop({
if (dragData.type === "text") {
executeTextDrop({ target: currentTarget, dragData });
+ } else if (dragData.type === "graphic") {
+ executeGraphicDrop({
+ target: currentTarget,
+ dragData: dragData as GraphicDragData,
+ });
} else if (dragData.type === "sticker") {
executeStickerDrop({ target: currentTarget, dragData });
} else if (dragData.type === "effect") {
@@ -502,12 +579,12 @@ export function useTimelineDragDrop({
}
} catch (err) {
console.error("Failed to process drop:", err);
- toast.error("Failed to process drop");
}
},
[
dropTarget,
executeTextDrop,
+ executeGraphicDrop,
executeStickerDrop,
executeMediaDrop,
executeEffectDrop,
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
index 1426a003..e6204f23 100644
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -1,12 +1,13 @@
import { getSnappedSeekTime } from "@/lib/time";
-import { useState, useEffect, useCallback, useRef } from "react";
+import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
+import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils";
import {
- findSnapPoints,
- snapToNearestPoint,
-} from "@/lib/timeline/snap-utils";
+ getCenteredLineLeft,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
@@ -25,27 +26,33 @@ export function useTimelinePlayhead({
playheadRef,
}: UseTimelinePlayheadProps) {
const editor = useEditor();
+ const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
const activeProject = editor.project.getActive();
- const currentTime = editor.playback.getCurrentTime();
const duration = editor.timeline.getTotalDuration();
- const isPlaying = editor.playback.getIsPlaying();
- const isScrubbing = editor.playback.getIsScrubbing();
const isShiftHeldRef = useShiftKey();
+ const zoomLevelRef = useRef(zoomLevel);
+ const durationRef = useRef(duration);
+ const isScrubbingRef = useRef(isScrubbing);
+ const isPlayingRef = useRef(false);
+
+ useEffect(() => {
+ zoomLevelRef.current = zoomLevel;
+ durationRef.current = duration;
+ isScrubbingRef.current = isScrubbing;
+ isPlayingRef.current = editor.playback.getIsPlaying();
+ }, [zoomLevel, duration, isScrubbing, editor.playback]);
+
const seek = useCallback(
({ time }: { time: number }) => editor.playback.seek({ time }),
[editor.playback],
);
- const [scrubTime, setScrubTime] = useState<number | null>(null);
-
- const [isDraggingRuler, setIsDraggingRuler] = useState(false);
- const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
+ const scrubTimeRef = useRef<number | null>(null);
+ const isDraggingRulerRef = useRef(false);
+ const hasDraggedRulerRef = useRef(false);
const lastMouseXRef = useRef<number>(0);
- const playheadPosition =
- isScrubbing && scrubTime !== null ? scrubTime : currentTime;
-
const handleScrub = useCallback(
({
event,
@@ -86,8 +93,7 @@ export function useTimelinePlayhead({
const time = (() => {
if (!shouldSnap) return frameTime;
const tracks = editor.timeline.getTracks();
- const bookmarks =
- editor.scenes.getActiveScene()?.bookmarks ?? [];
+ const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
const snapPoints = findSnapPoints({
tracks,
playheadTime: frameTime,
@@ -102,7 +108,7 @@ export function useTimelinePlayhead({
return snapResult.snapPoint ? snapResult.snappedTime : frameTime;
})();
- setScrubTime(time);
+ scrubTimeRef.current = time;
seek({ time });
lastMouseXRef.current = event.clientX;
@@ -132,17 +138,16 @@ export function useTimelinePlayhead({
const handleRulerMouseDown = useCallback(
({ event }: { event: React.MouseEvent }) => {
if (event.button !== 0) return;
-
if (playheadRef?.current?.contains(event.target as Node)) return;
event.preventDefault();
- setIsDraggingRuler(true);
- setHasDraggedRuler(false);
+ isDraggingRulerRef.current = true;
+ hasDraggedRulerRef.current = false;
- editor.playback.setScrubbing({ isScrubbing: true });
- handleScrub({ event, snappingEnabled: false });
- },
- [handleScrub, playheadRef, editor.playback],
+ editor.playback.setScrubbing({ isScrubbing: true });
+ handleScrub({ event, snappingEnabled: false });
+ },
+ [handleScrub, playheadRef, editor.playback],
);
const handlePlayheadMouseDownEvent = useCallback(
@@ -168,31 +173,32 @@ export function useTimelinePlayhead({
const handleMouseMove = ({ event }: { event: MouseEvent }) => {
handleScrub({ event });
- if (isDraggingRuler) {
- setHasDraggedRuler(true);
+ if (isDraggingRulerRef.current) {
+ hasDraggedRulerRef.current = true;
}
};
const handleMouseUp = ({ event }: { event: MouseEvent }) => {
editor.playback.setScrubbing({ isScrubbing: false });
- if (scrubTime !== null) {
- seek({ time: scrubTime });
+ const finalTime = scrubTimeRef.current;
+ if (finalTime !== null) {
+ seek({ time: finalTime });
editor.project.setTimelineViewState({
viewState: {
zoomLevel,
scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0,
- playheadTime: scrubTime,
+ playheadTime: finalTime,
},
});
}
- setScrubTime(null);
+ scrubTimeRef.current = null;
- if (isDraggingRuler) {
- setIsDraggingRuler(false);
- if (!hasDraggedRuler) {
+ if (isDraggingRulerRef.current) {
+ isDraggingRulerRef.current = false;
+ if (!hasDraggedRulerRef.current) {
handleScrub({ event, snappingEnabled: false });
}
- setHasDraggedRuler(false);
+ hasDraggedRulerRef.current = false;
}
};
@@ -206,55 +212,79 @@ export function useTimelinePlayhead({
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
- }, [
- isScrubbing,
- scrubTime,
- seek,
- handleScrub,
- isDraggingRuler,
- hasDraggedRuler,
- editor,
- tracksScrollRef,
- zoomLevel,
- ]);
+ }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]);
+
+ const updatePlayheadLeft = useCallback(
+ (time: number) => {
+ const playheadEl = playheadRef?.current;
+ if (!playheadEl) return;
+ const centerPosition = timelineTimeToSnappedPixels({
+ time,
+ zoomLevel: zoomLevelRef.current,
+ });
+ const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
+ const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0;
+ playheadEl.style.left = `${leftPosition - scrollLeft}px`;
+ },
+ [playheadRef, rulerScrollRef],
+ );
useEffect(() => {
- if (!isPlaying || isScrubbing) return;
+ const scrollEl = rulerScrollRef.current;
+ if (!scrollEl) return;
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- if (!rulerViewport || !tracksViewport) return;
+ const handleScroll = () => {
+ updatePlayheadLeft(editor.playback.getCurrentTime());
+ };
- const playheadPixels =
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const viewportWidth = rulerViewport.clientWidth;
- const scrollMinimum = 0;
- const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
+ scrollEl.addEventListener("scroll", handleScroll, { passive: true });
+ return () => scrollEl.removeEventListener("scroll", handleScroll);
+ }, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
- const needsScroll =
- playheadPixels < rulerViewport.scrollLeft ||
- playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+ useEffect(() => {
+ const handlePlaybackUpdate = (e: Event) => {
+ const time = (e as CustomEvent<{ time: number }>).detail.time;
+ updatePlayheadLeft(time);
- if (needsScroll) {
- const desiredScroll = Math.max(
- scrollMinimum,
- Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
- );
- rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
- }
- }, [
- playheadPosition,
- zoomLevel,
- rulerScrollRef,
- tracksScrollRef,
- isScrubbing,
- isPlaying,
- ]);
+ if (!isPlayingRef.current || isScrubbingRef.current) return;
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current;
+ if (!rulerViewport || !tracksViewport) return;
+
+ const playheadPixels =
+ time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevelRef.current;
+ const viewportWidth = rulerViewport.clientWidth;
+ const scrollMinimum = 0;
+ const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
+
+ const needsScroll =
+ playheadPixels < rulerViewport.scrollLeft ||
+ playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+
+ if (needsScroll) {
+ const desiredScroll = Math.max(
+ scrollMinimum,
+ Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
+ );
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ };
+
+ const initialTime = editor.playback.getCurrentTime();
+ handlePlaybackUpdate({
+ detail: { time: initialTime },
+ } as CustomEvent<{ time: number }>);
+
+ window.addEventListener("playback-update", handlePlaybackUpdate);
+ window.addEventListener("playback-seek", handlePlaybackUpdate);
+ return () => {
+ window.removeEventListener("playback-update", handlePlaybackUpdate);
+ window.removeEventListener("playback-seek", handlePlaybackUpdate);
+ };
+ }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
return {
- playheadPosition,
handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
handleRulerMouseDown: handleRulerMouseDownEvent,
- isDraggingRuler,
};
}
diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
index 095c64dc..64a0fb58 100644
--- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
@@ -81,18 +81,22 @@ export function useTimelineZoom({
return;
}
- // pinch-zoom (ctrl/meta + wheel)
- if (isZoomGesture) {
- const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
- setZoomLevel((prev) => {
- const nextZoom = Math.max(
- minZoom,
- Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
- );
- return nextZoom;
- });
- return;
- }
+ // pinch-zoom (ctrl/meta + wheel)
+ if (isZoomGesture) {
+ const normalizedDelta =
+ event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
+ const cappedDelta =
+ Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+ setZoomLevel((prev) => {
+ const nextZoom = Math.max(
+ minZoom,
+ Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomFactor),
+ );
+ return nextZoom;
+ });
+ return;
+ }
},
[minZoom, setZoomLevel],
);
diff --git a/apps/web/src/hooks/use-container-size.ts b/apps/web/src/hooks/use-container-size.ts
index 8b46f20e..6f6f89be 100644
--- a/apps/web/src/hooks/use-container-size.ts
+++ b/apps/web/src/hooks/use-container-size.ts
@@ -1,4 +1,5 @@
-import { useEffect, useState } from "react";
+import { useCallback, useState } from "react";
+import { useResizeObserver } from "./use-resize-observer";
export function useContainerSize({
containerRef,
@@ -7,23 +8,12 @@ export function useContainerSize({
}) {
const [size, setSize] = useState({ width: 0, height: 0 });
- useEffect(() => {
- const container = containerRef.current;
- if (!container) return;
+ const onResize = useCallback((entry: ResizeObserverEntry) => {
+ const { width, height } = entry.contentRect;
+ setSize({ width, height });
+ }, []);
- const observer = new ResizeObserver((entries) => {
- for (const entry of entries) {
- const { width, height } = entry.contentRect;
- setSize({ width, height });
- }
- });
-
- observer.observe(container);
-
- return () => {
- observer.disconnect();
- };
- }, [containerRef]);
+ useResizeObserver({ ref: containerRef, onResize });
return size;
}
diff --git a/apps/web/src/hooks/use-editor.ts b/apps/web/src/hooks/use-editor.ts
index ff3e2db1..e211a85b 100644
--- a/apps/web/src/hooks/use-editor.ts
+++ b/apps/web/src/hooks/use-editor.ts
@@ -1,39 +1,58 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import { EditorCore } from "@/core";
-export function useEditor(): EditorCore {
+function isShallowEqual(a: unknown, b: unknown): boolean {
+ if (Object.is(a, b)) return true;
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
+ if (a.length !== b.length) return false;
+ return a.every((item, i) => Object.is(item, b[i]));
+}
+
+const subscribeNone = () => () => {};
+
+export function useEditor(): EditorCore;
+export function useEditor<T>(selector: (editor: EditorCore) => T): T;
+export function useEditor<T>(
+ selector?: (editor: EditorCore) => T,
+): EditorCore | T {
const editor = useMemo(() => EditorCore.getInstance(), []);
- const versionRef = useRef(0);
+ const selectorRef = useRef(selector);
+ selectorRef.current = selector;
- const subscribe = useCallback(
- (onStoreChange: () => void) => {
- const handleStoreChange = () => {
- versionRef.current += 1;
- onStoreChange();
- };
+ const snapshotCacheRef = useRef<unknown>(undefined);
+ const subscribeAll = useCallback(
+ (onChange: () => void) => {
const unsubscribers = [
- editor.playback.subscribe(handleStoreChange),
- editor.timeline.subscribe(handleStoreChange),
- editor.scenes.subscribe(handleStoreChange),
- editor.project.subscribe(handleStoreChange),
- editor.media.subscribe(handleStoreChange),
- editor.renderer.subscribe(handleStoreChange),
- editor.selection.subscribe(handleStoreChange),
+ editor.playback.subscribe(onChange),
+ editor.timeline.subscribe(onChange),
+ editor.scenes.subscribe(onChange),
+ editor.project.subscribe(onChange),
+ editor.media.subscribe(onChange),
+ editor.renderer.subscribe(onChange),
+ editor.selection.subscribe(onChange),
];
-
return () => {
- for (const unsubscribe of unsubscribers) {
+ unsubscribers.forEach((unsubscribe) => {
unsubscribe();
- }
+ });
};
},
[editor],
);
- const getSnapshot = useCallback(() => versionRef.current, []);
-
- useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ const getSnapshot = useCallback(() => {
+ const next = selectorRef.current ? selectorRef.current(editor) : editor;
+ if (isShallowEqual(snapshotCacheRef.current, next)) {
+ return snapshotCacheRef.current;
+ }
+ snapshotCacheRef.current = next;
+ return next;
+ }, [editor]);
- return editor;
+ return useSyncExternalStore(
+ selector ? subscribeAll : subscribeNone,
+ getSnapshot,
+ getSnapshot,
+ ) as EditorCore | T;
}
diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts
deleted file mode 100644
index a84781e0..00000000
--- a/apps/web/src/hooks/use-effect-preview.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-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-element-preview.ts b/apps/web/src/hooks/use-element-preview.ts
new file mode 100644
index 00000000..8aa16501
--- /dev/null
+++ b/apps/web/src/hooks/use-element-preview.ts
@@ -0,0 +1,38 @@
+import { useEditor } from "@/hooks/use-editor";
+import type { TimelineElement } from "@/lib/timeline";
+
+/**
+ * Subscribes to render tracks and returns the live (preview-aware) version of
+ * an element alongside helpers for previewing and committing updates.
+ *
+ * Use this wherever property fields need to reflect in-progress preview state
+ * (e.g. a slider being dragged) rather than the last committed value.
+ */
+export function useElementPreview<T extends TimelineElement>({
+ trackId,
+ elementId,
+ fallback,
+}: {
+ trackId: string;
+ elementId: string;
+ fallback: T;
+}) {
+ const editor = useEditor();
+ useEditor((e) => e.timeline.getRenderTracks());
+
+ const renderElement =
+ (editor.timeline
+ .getRenderTracks()
+ .find((t) => t.id === trackId)
+ ?.elements.find((el) => el.id === elementId) as T | undefined) ??
+ fallback;
+
+ const previewUpdates = (updates: Partial<TimelineElement>) =>
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates }],
+ });
+
+ const commit = () => editor.timeline.commitPreview();
+
+ return { renderElement, previewUpdates, commit };
+}
diff --git a/apps/web/src/hooks/use-file-upload.ts b/apps/web/src/hooks/use-file-upload.ts
index 19badd98..d174e002 100644
--- a/apps/web/src/hooks/use-file-upload.ts
+++ b/apps/web/src/hooks/use-file-upload.ts
@@ -4,7 +4,7 @@ import { hasDragData } from "@/lib/drag-data";
interface UseFileUploadOptions {
accept?: string;
multiple?: boolean;
- onFilesSelected?: (files: FileList) => void;
+ onFilesSelected?: (files: File[]) => void;
}
function containsFiles(dataTransfer: DataTransfer): boolean {
@@ -29,8 +29,8 @@ export function useFileUpload({
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
- const files = event.target.files;
- if (files && files.length > 0 && onFilesSelected) {
+ const files = Array.from(event.target.files ?? []);
+ if (files.length > 0 && onFilesSelected) {
onFilesSelected(files);
}
@@ -71,15 +71,13 @@ export function useFileUpload({
dragCounterRef.current = 0;
if (onFilesSelected && containsFiles(e.dataTransfer)) {
- const files = e.dataTransfer.files;
+ const files = Array.from(e.dataTransfer.files);
const shouldUseMultiple = multiple ?? false;
if (shouldUseMultiple) {
onFilesSelected(files);
} else if (files.length > 0) {
- const dataTransfer = new DataTransfer();
- dataTransfer.items.add(files[0]);
- onFilesSelected(dataTransfer.files);
+ onFilesSelected([files[0]]);
}
}
}
diff --git a/apps/web/src/hooks/use-font-atlas.ts b/apps/web/src/hooks/use-font-atlas.ts
new file mode 100644
index 00000000..7d6b87e9
--- /dev/null
+++ b/apps/web/src/hooks/use-font-atlas.ts
@@ -0,0 +1,53 @@
+import { useState, useMemo, useCallback, useEffect } from "react";
+import {
+ getCachedFontAtlas,
+ loadFontAtlas,
+ clearFontAtlasCache,
+} from "@/lib/fonts/google-fonts";
+import type { FontAtlas } from "@/lib/fonts/types";
+import { SYSTEM_FONTS } from "@/constants/font-constants";
+
+type Status = "idle" | "loading" | "error";
+
+export function useFontAtlas({ open }: { open: boolean }) {
+ const [atlas, setAtlas] = useState<FontAtlas | null>(() =>
+ getCachedFontAtlas(),
+ );
+ const [status, setStatus] = useState<Status>(() =>
+ getCachedFontAtlas() ? "idle" : "loading",
+ );
+
+ useEffect(() => {
+ if (!open || atlas) return;
+
+ setStatus("loading");
+ loadFontAtlas().then((data) => {
+ if (data) {
+ setAtlas(data);
+ setStatus("idle");
+ } else {
+ setStatus("error");
+ }
+ });
+ }, [open, atlas]);
+
+ const retry = useCallback(() => {
+ clearFontAtlasCache();
+ setStatus("loading");
+ loadFontAtlas().then((data) => {
+ if (data) {
+ setAtlas(data);
+ setStatus("idle");
+ } else {
+ setStatus("error");
+ }
+ });
+ }, []);
+
+ const fontNames = useMemo(() => {
+ if (!atlas) return [];
+ return [...Object.keys(atlas.fonts), ...SYSTEM_FONTS].sort();
+ }, [atlas]);
+
+ return { atlas, status, fontNames, retry };
+}
diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts
index e59d1eaf..69a4e847 100644
--- a/apps/web/src/hooks/use-keybindings.ts
+++ b/apps/web/src/hooks/use-keybindings.ts
@@ -1,6 +1,8 @@
import { useEffect } from "react";
import { invokeAction } from "@/lib/actions";
import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { isTypableDOMElement } from "@/utils/browser";
/**
* a composable that hooks to the caller component's
@@ -8,31 +10,141 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
* the appropriate actions based on keybindings
*/
export function useKeybindingsListener() {
- const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } =
- useKeybindingsStore();
+ const {
+ keybindings,
+ getKeybindingString,
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ } = useKeybindingsStore();
+ const clipboard = useTimelineStore((state) => state.clipboard);
useEffect(() => {
const eventOptions: AddEventListenerOptions = { capture: true };
+ // #region agent log
+ fetch("http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H1",
+ location: "use-keybindings.ts:effect",
+ message: "Keybindings listener mounted",
+ data: {
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ keybindingCount: Object.keys(keybindings).length,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
const handleKeyDown = (ev: KeyboardEvent) => {
- // do not check keybinds if the mode is disabled
- if (!keybindingsEnabled) return;
- // ignore key events if user is changing keybindings
- if (isRecording) return;
+ const normalizedKey = (ev.key ?? "").toLowerCase();
+ const shouldLogKey =
+ ev.code === "Space" ||
+ ev.code.startsWith("Key") ||
+ ["escape", "delete", "backspace", "enter"].includes(normalizedKey);
- const binding = getKeybindingString(ev);
- if (!binding) return;
-
- const boundAction = keybindings[binding];
- if (!boundAction) return;
+ if (overlayDepth > 0 || isLoadingProject || isRecording) {
+ if (shouldLogKey) {
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H2",
+ location: "use-keybindings.ts:blocked",
+ message: "Shortcut blocked by runtime gate",
+ data: {
+ key: ev.key,
+ code: ev.code,
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ targetTag:
+ ev.target instanceof HTMLElement ? ev.target.tagName : null,
+ },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ }
+ return;
+ }
+ const binding = getKeybindingString(ev);
const activeElement = document.activeElement;
const isTextInput =
- activeElement &&
- (activeElement.tagName === "INPUT" ||
- activeElement.tagName === "TEXTAREA" ||
- (activeElement as HTMLElement).isContentEditable);
+ activeElement instanceof HTMLElement &&
+ isTypableDOMElement({ element: activeElement });
+ const boundAction = binding ? keybindings[binding] : undefined;
+
+ if (shouldLogKey || binding || boundAction) {
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: !binding ? "H3" : isTextInput ? "H5" : "H4",
+ location: "use-keybindings.ts:keydown",
+ message: "Shortcut keydown observed",
+ data: {
+ key: ev.key,
+ code: ev.code,
+ binding,
+ boundAction: boundAction ?? null,
+ isTextInput,
+ keybindingCount: Object.keys(keybindings).length,
+ activeTag:
+ activeElement instanceof HTMLElement
+ ? activeElement.tagName
+ : null,
+ targetTag:
+ ev.target instanceof HTMLElement ? ev.target.tagName : null,
+ },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ }
+
+ if (normalizedKey === "escape" && isTextInput) {
+ activeElement.blur();
+ return;
+ }
+
+ if (!binding) return;
+ if (!boundAction) return;
if (isTextInput) return;
+ if (boundAction === "paste-copied") {
+ if (!clipboard?.items.length) return;
+ ev.preventDefault();
+ invokeAction("paste-copied", undefined, "keypress");
+ return;
+ }
ev.preventDefault();
@@ -59,17 +171,12 @@ export function useKeybindingsListener() {
return () => {
document.removeEventListener("keydown", handleKeyDown, eventOptions);
};
- }, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
-}
-
-/**
- * this composable allows for the UI component to be disabled if the component in question is mounted
- */
-export function useKeybindingDisabler() {
- const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
-
- return {
- disableKeybindings,
- enableKeybindings,
- };
+ }, [
+ keybindings,
+ getKeybindingString,
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ clipboard,
+ ]);
}
diff --git a/apps/web/src/hooks/use-mask-handles.ts b/apps/web/src/hooks/use-mask-handles.ts
new file mode 100644
index 00000000..c3e83304
--- /dev/null
+++ b/apps/web/src/hooks/use-mask-handles.ts
@@ -0,0 +1,258 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { masksRegistry } from "@/lib/masks";
+import {
+ getMaskHandlePositions,
+ getLineMaskLinePoints,
+} from "@/lib/masks/handle-positions";
+import { snapMaskInteraction } from "@/lib/masks/snap";
+import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
+import {
+ SNAP_THRESHOLD_SCREEN_PIXELS,
+ type SnapLine,
+} from "@/lib/preview/preview-snap";
+import type { ParamValues } from "@/lib/params";
+import type {
+ BaseMaskParams,
+ MaskHandlePosition,
+ MaskLinePoints,
+} from "@/lib/masks/types";
+import type { MaskableElement } from "@/lib/timeline";
+import { registerCanceller } from "@/lib/cancel-interaction";
+
+interface DragState {
+ trackId: string;
+ elementId: string;
+ handleId: string;
+ startCanvasX: number;
+ startCanvasY: number;
+ startParams: BaseMaskParams & ParamValues;
+}
+
+export function useMaskHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: (lines: SnapLine[]) => void;
+}) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
+ const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
+ const dragStateRef = useRef<DragState | null>(null);
+ const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
+ null,
+ );
+
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const selectedWithMask =
+ selectedElements.length === 1
+ ? (() => {
+ const sel = selectedElements[0];
+ const entry = elementsWithBounds.find(
+ (item) =>
+ item.trackId === sel.trackId && item.elementId === sel.elementId,
+ );
+ if (!entry) return null;
+ const element = entry.element as MaskableElement;
+ if (!element.masks?.length) return null;
+ return { ...entry, element, mask: element.masks[0] };
+ })()
+ : null;
+
+ const handlePositions: MaskHandlePosition[] = selectedWithMask
+ ? (() => {
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+ const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
+ const displayScale = (scaleX + scaleY) / 2;
+ return getMaskHandlePositions({
+ overlayShape: def.overlayShape,
+ features: def.features,
+ params: selectedWithMask.mask.params,
+ bounds: selectedWithMask.bounds,
+ displayScale,
+ });
+ })()
+ : [];
+
+ const linePoints: MaskLinePoints | null =
+ selectedWithMask?.mask.type === "split"
+ ? getLineMaskLinePoints({
+ centerX: selectedWithMask.mask.params.centerX,
+ centerY: selectedWithMask.mask.params.centerY,
+ rotation: selectedWithMask.mask.params.rotation,
+ bounds: selectedWithMask.bounds,
+ })
+ : null;
+
+ const clearMaskHandleState = useCallback(() => {
+ dragStateRef.current = null;
+ setActiveHandleId(null);
+ onSnapLinesChange?.([]);
+ }, [onSnapLinesChange]);
+
+ const releaseCapturedPointer = useCallback(() => {
+ const capture = captureRef.current;
+ if (!capture) return;
+
+ if (capture.element.hasPointerCapture(capture.pointerId)) {
+ capture.element.releasePointerCapture(capture.pointerId);
+ }
+
+ captureRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ if (!activeHandleId) return;
+
+ return registerCanceller({
+ fn: () => {
+ editor.timeline.discardPreview();
+ clearMaskHandleState();
+ releaseCapturedPointer();
+ },
+ });
+ }, [
+ activeHandleId,
+ clearMaskHandleState,
+ editor.timeline,
+ releaseCapturedPointer,
+ ]);
+
+ const handlePointerDown = useCallback(
+ ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
+ if (!selectedWithMask) return;
+ event.stopPropagation();
+
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) return;
+
+ dragStateRef.current = {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ handleId,
+ startCanvasX: pos.x,
+ startCanvasY: pos.y,
+ startParams: { ...selectedWithMask.mask.params },
+ };
+ setActiveHandleId(handleId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [selectedWithMask, viewport],
+ );
+
+ const handlePointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const drag = dragStateRef.current;
+ if (!drag || !selectedWithMask) return;
+
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) return;
+
+ const deltaX = pos.x - drag.startCanvasX;
+ const deltaY = pos.y - drag.startCanvasY;
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+
+ const rawParams = def.computeParamUpdate({
+ handleId: drag.handleId,
+ startParams: drag.startParams,
+ deltaX,
+ deltaY,
+ startCanvasX: drag.startCanvasX,
+ startCanvasY: drag.startCanvasY,
+ bounds: selectedWithMask.bounds,
+ canvasSize,
+ });
+ const proposedParams = { ...drag.startParams, ...rawParams };
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { params: nextParams, activeLines } = isShiftHeldRef.current
+ ? { params: proposedParams, activeLines: [] as SnapLine[] }
+ : snapMaskInteraction({
+ handleId: drag.handleId,
+ startParams: drag.startParams,
+ proposedParams,
+ bounds: selectedWithMask.bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ onSnapLinesChange?.(activeLines);
+
+ const updatedMask = {
+ ...selectedWithMask.mask,
+ params: nextParams,
+ };
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: drag.trackId,
+ elementId: drag.elementId,
+ updates: {
+ masks: [
+ updatedMask,
+ ...(selectedWithMask.element.masks?.slice(1) ?? []),
+ ],
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ },
+ [
+ selectedWithMask,
+ canvasSize,
+ editor,
+ isShiftHeldRef,
+ onSnapLinesChange,
+ viewport,
+ ],
+ );
+
+ const handlePointerUp = useCallback(() => {
+ if (dragStateRef.current) {
+ editor.timeline.commitPreview();
+ clearMaskHandleState();
+ }
+ releaseCapturedPointer();
+ },
+ [clearMaskHandleState, editor, releaseCapturedPointer],
+ );
+
+ return {
+ selectedWithMask,
+ handlePositions,
+ linePoints,
+ activeHandleId,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ };
+}
diff --git a/apps/web/src/hooks/use-menu-preview.ts b/apps/web/src/hooks/use-menu-preview.ts
new file mode 100644
index 00000000..a17ee278
--- /dev/null
+++ b/apps/web/src/hooks/use-menu-preview.ts
@@ -0,0 +1,26 @@
+import { useRef } from "react";
+import { useEditor } from "@/hooks/use-editor";
+
+export function useMenuPreview() {
+ const editor = useEditor();
+ const didCommitRef = useRef(false);
+
+ const discard = () => {
+ if (!didCommitRef.current && editor.timeline.isPreviewActive()) {
+ editor.timeline.discardPreview();
+ }
+ };
+
+ const markCommitted = () => {
+ didCommitRef.current = true;
+ };
+
+ const onOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ discard();
+ didCommitRef.current = false;
+ }
+ };
+
+ return { onPointerLeave: discard, onOpenChange, markCommitted };
+}
diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts
index 4533ab28..8f362a12 100644
--- a/apps/web/src/hooks/use-paste-media.ts
+++ b/apps/web/src/hooks/use-paste-media.ts
@@ -1,14 +1,15 @@
import { useEffect } from "react";
-import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
+import { invokeAction } from "@/lib/actions";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { isTypableDOMElement } from "@/utils/browser";
-import type { MediaType } from "@/types/assets";
+import type { MediaType } from "@/lib/media/types";
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
@@ -40,6 +41,7 @@ export function usePasteMedia() {
useEffect(() => {
const handlePaste = async (event: ClipboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
+
if (activeElement && isTypableDOMElement({ element: activeElement })) {
return;
}
@@ -47,7 +49,11 @@ export function usePasteMedia() {
const files = extractMediaFilesFromClipboard({
clipboardData: event.clipboardData,
});
- if (files.length === 0) return;
+ if (files.length === 0) {
+ event.preventDefault();
+ invokeAction("paste-copied");
+ return;
+ }
event.preventDefault();
@@ -55,41 +61,50 @@ export function usePasteMedia() {
if (!activeProject) return;
try {
- const processedAssets = await processMediaAssets({ files });
- const startTime = editor.playback.getCurrentTime();
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+ const startTime = editor.playback.getCurrentTime();
+
+ for (const asset of processedAssets) {
+ const addMediaCmd = new AddMediaAssetCommand(
+ activeProject.metadata.id,
+ asset,
+ );
+ const assetId = addMediaCmd.getAssetId();
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const trackType = asset.type === "audio" ? "audio" : "video";
- for (const asset of processedAssets) {
- const addMediaCmd = new AddMediaAssetCommand(
- activeProject.metadata.id,
- asset,
- );
- const assetId = addMediaCmd.getAssetId();
- const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const trackType = asset.type === "audio" ? "audio" : "video";
+ const element = buildElementFromMedia({
+ mediaId: assetId,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime,
+ buffer:
+ asset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
- const element = buildElementFromMedia({
- mediaId: assetId,
- mediaType: asset.type,
- name: asset.name,
- duration,
- startTime,
- buffer:
- asset.type === "audio"
- ? new AudioBuffer({ length: 1, sampleRate: 44100 })
- : undefined,
- });
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "auto", trackType },
+ });
+ const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
+ editor.command.execute({ command: batchCmd });
+ }
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "auto", trackType },
- });
- const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
- editor.command.execute({ command: batchCmd });
- }
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
} catch (error) {
console.error("Failed to paste media:", error);
- toast.error("Failed to paste media");
}
};
diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts
index d29f2a4b..90fa0450 100644
--- a/apps/web/src/hooks/use-preview-interaction.ts
+++ b/apps/web/src/hooks/use-preview-interaction.ts
@@ -1,28 +1,50 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
-import type { TextElement, Transform } from "@/types/timeline";
-import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
-import { hitTest } from "@/lib/preview/hit-test";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import type { Transform } from "@/lib/rendering";
+import type { ElementRef, TextElement } from "@/lib/timeline";
import {
- screenPixelsToLogicalThreshold,
- screenToCanvas,
-} from "@/lib/preview/preview-coords";
+ getVisibleElementsWithBounds,
+ type ElementWithBounds,
+} from "@/lib/preview/element-bounds";
+import {
+ getHitElements,
+ hitTest,
+ resolvePreferredHit,
+} from "@/lib/preview/hit-test";
import { isVisualElement } from "@/lib/timeline/element-utils";
import {
SNAP_THRESHOLD_SCREEN_PIXELS,
snapPosition,
type SnapLine,
} from "@/lib/preview/preview-snap";
+import { registerCanceller } from "@/lib/cancel-interaction";
+
+export type OnSnapLinesChange = (lines: SnapLine[]) => void;
const MIN_DRAG_DISTANCE = 0.5;
-interface DragState {
+interface CapturedPointerState {
+ pointerId: number;
+ captureTarget: HTMLElement;
+}
+
+interface PendingGestureState extends CapturedPointerState {
+ startX: number;
+ startY: number;
+ topmostHit: ElementWithBounds | null;
+ selectedHit: ElementWithBounds | null;
+ selectedElements: ElementRef[];
+}
+
+interface DragState extends CapturedPointerState {
startX: number;
startY: number;
bounds: {
width: number;
height: number;
+ rotation: number;
};
elements: Array<{
trackId: string;
@@ -31,15 +53,56 @@ interface DragState {
}>;
}
+function isSameElementRef({
+ left,
+ right,
+}: {
+ left: ElementRef;
+ right: ElementRef;
+}): boolean {
+ return left.trackId === right.trackId && left.elementId === right.elementId;
+}
+
+function buildDragSelection({
+ selectedElements,
+ dragTarget,
+}: {
+ selectedElements: ElementRef[];
+ dragTarget: ElementWithBounds;
+}): ElementRef[] {
+ const dragTargetRef = {
+ trackId: dragTarget.trackId,
+ elementId: dragTarget.elementId,
+ };
+
+ if (
+ !selectedElements.some((selectedElement) =>
+ isSameElementRef({ left: selectedElement, right: dragTargetRef }),
+ )
+ ) {
+ return [dragTargetRef];
+ }
+
+ return [
+ dragTargetRef,
+ ...selectedElements.filter(
+ (selectedElement) =>
+ !isSameElementRef({ left: selectedElement, right: dragTargetRef }),
+ ),
+ ];
+}
+
export function usePreviewInteraction({
- canvasRef,
+ onSnapLinesChange,
+ isMaskMode = false,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ onSnapLinesChange?: OnSnapLinesChange;
+ isMaskMode?: boolean;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
const [isDragging, setIsDragging] = useState(false);
- const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
const [editingText, setEditingText] = useState<{
trackId: string;
elementId: string;
@@ -47,13 +110,30 @@ export function usePreviewInteraction({
originalOpacity: number;
} | null>(null);
const dragStateRef = useRef<DragState | null>(null);
+ const pendingGestureRef = useRef<PendingGestureState | null>(null);
const wasPlayingRef = useRef(editor.playback.getIsPlaying());
const editingTextRef = useRef(editingText);
editingTextRef.current = editingText;
+ const releaseCapturedPointer = useCallback(
+ (pointerState: CapturedPointerState | null) => {
+ if (!pointerState) return;
+
+ if (
+ !pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)
+ ) {
+ return;
+ }
+
+ pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
+ },
+ [],
+ );
+
const commitTextEdit = useCallback(() => {
const current = editingTextRef.current;
if (!current) return;
+ editingTextRef.current = null;
editor.timeline.previewElements({
updates: [
{
@@ -67,11 +147,6 @@ export function usePreviewInteraction({
setEditingText(null);
}, [editor.timeline]);
- const cancelTextEdit = useCallback(() => {
- editor.timeline.discardPreview();
- setEditingText(null);
- }, [editor.timeline]);
-
useEffect(() => {
const unsubscribe = editor.playback.subscribe(() => {
const isPlaying = editor.playback.getIsPlaying();
@@ -83,20 +158,38 @@ export function usePreviewInteraction({
return unsubscribe;
}, [editor.playback, commitTextEdit]);
+ useEffect(() => {
+ if (!isDragging) return;
+
+ return registerCanceller({
+ fn: () => {
+ const dragState = dragStateRef.current;
+ if (!dragState) return;
+
+ editor.timeline.discardPreview();
+ dragStateRef.current = null;
+ pendingGestureRef.current = null;
+ setIsDragging(false);
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(dragState);
+ },
+ });
+ }, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]);
+
const handleDoubleClick = useCallback(
({ clientX, clientY }: React.MouseEvent) => {
- if (!canvasRef.current || editingText) return;
+ if (editingText || isMaskMode) return;
const tracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
- const startPos = screenToCanvas({
+ const startPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!startPos) return;
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -130,7 +223,7 @@ export function usePreviewInteraction({
originalOpacity: textElement.opacity,
});
},
- [canvasRef, editor, editingText],
+ [editor, editingText, isMaskMode, viewport],
);
const handlePointerDown = useCallback(
@@ -141,8 +234,8 @@ export function usePreviewInteraction({
pointerId,
button,
}: React.PointerEvent) => {
- if (!canvasRef.current) return;
if (editingText) return;
+ if (isMaskMode) return;
if (button !== 0) return;
const tracks = editor.timeline.getTracks();
@@ -150,11 +243,11 @@ export function usePreviewInteraction({
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
- const startPos = screenToCanvas({
+ const startPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!startPos) return;
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -163,89 +256,134 @@ export function usePreviewInteraction({
mediaAssets,
});
- const hit = hitTest({
+ const hits = getHitElements({
canvasX: startPos.x,
canvasY: startPos.y,
elementsWithBounds,
});
+ const selectedElements = editor.selection.getSelectedElements();
+ const topmostHit = hits[0] ?? null;
- if (!hit) {
- editor.selection.clearSelection();
- return;
- }
-
- editor.selection.setSelectedElements({
- elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
- });
-
- const elementsWithTracks = editor.timeline.getElementsWithTracks({
- elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
- });
-
- const draggableElements = elementsWithTracks.filter(({ element }) =>
- isVisualElement(element),
- );
-
- if (draggableElements.length === 0) return;
-
- dragStateRef.current = {
+ pendingGestureRef.current = {
startX: startPos.x,
startY: startPos.y,
- bounds: {
- width: hit.bounds.width,
- height: hit.bounds.height,
- },
- elements: draggableElements.map(({ track, element }) => ({
- trackId: track.id,
- elementId: element.id,
- initialTransform: (element as { transform: Transform }).transform,
- })),
+ pointerId,
+ captureTarget: currentTarget as HTMLElement,
+ topmostHit,
+ selectedHit: resolvePreferredHit({
+ hits,
+ preferredElements: selectedElements,
+ }),
+ selectedElements,
};
-
- setIsDragging(true);
currentTarget.setPointerCapture(pointerId);
},
- [editor, canvasRef, editingText],
+ [editor, editingText, isMaskMode, viewport],
);
const handlePointerMove = useCallback(
({ clientX, clientY }: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
-
const canvasSize = editor.project.getActive().settings.canvasSize;
- const currentPos = screenToCanvas({
+ const currentPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!currentPos) return;
- const deltaX = currentPos.x - dragStateRef.current.startX;
- const deltaY = currentPos.y - dragStateRef.current.startY;
- const hasMovement =
- Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
- Math.abs(deltaY) > MIN_DRAG_DISTANCE;
- if (!hasMovement) {
- setSnapLines([]);
- return;
+ let dragState = dragStateRef.current;
+
+ if (!dragState) {
+ const pendingGesture = pendingGestureRef.current;
+ if (!pendingGesture) return;
+
+ const deltaX = currentPos.x - pendingGesture.startX;
+ const deltaY = currentPos.y - pendingGesture.startY;
+ const hasMovement =
+ Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
+ Math.abs(deltaY) > MIN_DRAG_DISTANCE;
+
+ if (!hasMovement) {
+ onSnapLinesChange?.([]);
+ return;
+ }
+
+ const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit;
+ if (!dragTarget) {
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
+ return;
+ }
+
+ const dragSelection = buildDragSelection({
+ selectedElements: pendingGesture.selectedElements,
+ dragTarget,
+ });
+ const elementsWithTracks = editor.timeline.getElementsWithTracks({
+ elements: dragSelection,
+ });
+ const draggableElements = elementsWithTracks.filter(({ element }) =>
+ isVisualElement(element),
+ );
+
+ if (draggableElements.length === 0) {
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
+ return;
+ }
+
+ if (pendingGesture.selectedHit === null) {
+ editor.selection.setSelectedElements({
+ elements: [
+ {
+ trackId: dragTarget.trackId,
+ elementId: dragTarget.elementId,
+ },
+ ],
+ });
+ }
+
+ dragState = {
+ startX: pendingGesture.startX,
+ startY: pendingGesture.startY,
+ pointerId: pendingGesture.pointerId,
+ captureTarget: pendingGesture.captureTarget,
+ bounds: {
+ width: dragTarget.bounds.width,
+ height: dragTarget.bounds.height,
+ rotation: dragTarget.bounds.rotation,
+ },
+ elements: draggableElements.map(({ track, element }) => ({
+ trackId: track.id,
+ elementId: element.id,
+ initialTransform: (element as { transform: Transform }).transform,
+ })),
+ };
+ dragStateRef.current = dragState;
+ pendingGestureRef.current = null;
+ setIsDragging(true);
}
- const firstElement = dragStateRef.current.elements[0];
+ const deltaX = currentPos.x - dragState.startX;
+ const deltaY = currentPos.y - dragState.startY;
+ const firstElement = dragState.elements[0];
const proposedPosition = {
x: firstElement.initialTransform.position.x + deltaX,
y: firstElement.initialTransform.position.y + deltaY,
};
const shouldSnap = !isShiftHeldRef.current;
- const snapThreshold = screenPixelsToLogicalThreshold({
- canvas: canvasRef.current,
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
const { snappedPosition, activeLines } = shouldSnap
? snapPosition({
proposedPosition,
canvasSize,
- elementSize: dragStateRef.current.bounds,
+ elementSize: dragState.bounds,
+ rotation: dragState.bounds.rotation,
snapThreshold,
})
: {
@@ -253,14 +391,14 @@ export function usePreviewInteraction({
activeLines: [] as SnapLine[],
};
- setSnapLines(activeLines);
+ onSnapLinesChange?.(activeLines);
const deltaSnappedX =
snappedPosition.x - firstElement.initialTransform.position.x;
const deltaSnappedY =
snappedPosition.y - firstElement.initialTransform.position.y;
- const updates = dragStateRef.current.elements.map(
+ const updates = dragState.elements.map(
({ trackId, elementId, initialTransform }) => ({
trackId,
elementId,
@@ -278,38 +416,51 @@ export function usePreviewInteraction({
editor.timeline.previewElements({ updates });
},
- [isDragging, canvasRef, editor, isShiftHeldRef],
+ [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
);
const handlePointerUp = useCallback(
- ({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
+ ({ type }: React.PointerEvent) => {
+ const dragState = dragStateRef.current;
+ if (dragState) {
+ if (type === "pointercancel") {
+ editor.timeline.discardPreview();
+ } else {
+ editor.timeline.commitPreview();
+ }
- const currentPos = screenToCanvas({
- clientX,
- clientY,
- canvas: canvasRef.current,
- });
-
- const deltaX = currentPos.x - dragStateRef.current.startX;
- const deltaY = currentPos.y - dragStateRef.current.startY;
+ dragStateRef.current = null;
+ pendingGestureRef.current = null;
+ setIsDragging(false);
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(dragState);
+ return;
+ }
- const hasMovement =
- Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
- Math.abs(deltaY) > MIN_DRAG_DISTANCE;
+ const pendingGesture = pendingGestureRef.current;
+ if (!pendingGesture) return;
- if (!hasMovement) {
- editor.timeline.discardPreview();
- } else {
- editor.timeline.commitPreview();
+ if (type !== "pointercancel") {
+ const clickTarget = pendingGesture.topmostHit;
+ if (!clickTarget) {
+ editor.selection.clearSelection();
+ } else {
+ editor.selection.setSelectedElements({
+ elements: [
+ {
+ trackId: clickTarget.trackId,
+ elementId: clickTarget.elementId,
+ },
+ ],
+ });
+ }
}
- dragStateRef.current = null;
- setIsDragging(false);
- setSnapLines([]);
- currentTarget.releasePointerCapture(pointerId);
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
},
- [isDragging, canvasRef, editor],
+ [editor, onSnapLinesChange, releaseCapturedPointer],
);
return {
@@ -317,9 +468,7 @@ export function usePreviewInteraction({
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onDoubleClick: handleDoubleClick,
- snapLines,
editingText,
commitTextEdit,
- cancelTextEdit,
};
}
diff --git a/apps/web/src/hooks/use-resize-observer.ts b/apps/web/src/hooks/use-resize-observer.ts
new file mode 100644
index 00000000..a4b3a603
--- /dev/null
+++ b/apps/web/src/hooks/use-resize-observer.ts
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+
+export function useResizeObserver({
+ ref,
+ onResize,
+}: {
+ ref: React.RefObject<HTMLElement | null>;
+ onResize: (entry: ResizeObserverEntry) => void;
+}) {
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) return;
+
+ const observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ onResize(entry);
+ }
+ });
+
+ observer.observe(element);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [ref, onResize]);
+}
diff --git a/apps/web/src/hooks/use-reveal-item.ts b/apps/web/src/hooks/use-reveal-item.ts
deleted file mode 100644
index 5004f60c..00000000
--- a/apps/web/src/hooks/use-reveal-item.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { useEffect, useState, useRef } from "react";
-
-export function useRevealItem(
- highlightId: string | null,
- onClearHighlight: () => void,
- highlightDuration = 1000,
-) {
- const [highlightedId, setHighlightedId] = useState<string | null>(null);
- const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
-
- const registerElement = (id: string, element: HTMLElement | null) => {
- if (element) {
- elementRefs.current.set(id, element);
- } else {
- elementRefs.current.delete(id);
- }
- };
-
- useEffect(() => {
- if (!highlightId) return;
-
- setHighlightedId(highlightId);
-
- const target = elementRefs.current.get(highlightId);
- target?.scrollIntoView({ block: "center" });
-
- const timeout = setTimeout(() => {
- setHighlightedId(null);
- onClearHighlight();
- }, highlightDuration);
-
- return () => clearTimeout(timeout);
- }, [highlightId, onClearHighlight, highlightDuration]);
-
- return { highlightedId, registerElement };
-}
diff --git a/apps/web/src/hooks/use-storage-persistence.ts b/apps/web/src/hooks/use-storage-persistence.ts
new file mode 100644
index 00000000..79e05069
--- /dev/null
+++ b/apps/web/src/hooks/use-storage-persistence.ts
@@ -0,0 +1,45 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+const DISMISSED_KEY = "opencut-storage-persist-dismissed";
+
+function isFirefox(): boolean {
+ return navigator.userAgent.toLowerCase().includes("firefox");
+}
+
+export function useStoragePersistence() {
+ const [showDialog, setShowDialog] = useState(false);
+
+ useEffect(() => {
+ if (!navigator.storage?.persist) return;
+
+ const run = async () => {
+ const alreadyPersisted = await navigator.storage.persisted();
+ if (alreadyPersisted) return;
+
+ const dismissed = localStorage.getItem(DISMISSED_KEY) === "true";
+ if (dismissed) return;
+
+ if (isFirefox()) {
+ setShowDialog(true);
+ } else {
+ await navigator.storage.persist();
+ }
+ };
+
+ run();
+ }, []);
+
+ const onConfirm = async () => {
+ setShowDialog(false);
+ await navigator.storage.persist();
+ };
+
+ const onDismiss = () => {
+ setShowDialog(false);
+ localStorage.setItem(DISMISSED_KEY, "true");
+ };
+
+ return { showDialog, onConfirm, onDismiss };
+}
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
index fb561396..a01fb361 100644
--- a/apps/web/src/hooks/use-transform-handles.ts
+++ b/apps/web/src/hooks/use-transform-handles.ts
@@ -1,4 +1,6 @@
-import { useCallback, useRef, useState, useSyncExternalStore } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
@@ -6,14 +8,12 @@ import {
type ElementWithBounds,
} from "@/lib/preview/element-bounds";
import {
- screenPixelsToLogicalThreshold,
- screenToCanvas,
-} from "@/lib/preview/preview-coords";
-import {
MIN_SCALE,
SNAP_THRESHOLD_SCREEN_PIXELS,
snapRotation,
snapScale,
+ snapScaleAxes,
+ type ScaleEdgePreference,
type SnapLine,
} from "@/lib/preview/preview-snap";
import { isVisualElement } from "@/lib/timeline/element-utils";
@@ -22,11 +22,25 @@ import {
resolveTransformAtTime,
setChannel,
} from "@/lib/animation";
-import type { Transform } from "@/types/timeline";
-import type { ElementAnimations } from "@/types/animation";
+import type { Transform } from "@/lib/rendering";
+import type { ElementAnimations } from "@/lib/animation/types";
+import { registerCanceller } from "@/lib/cancel-interaction";
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
-type HandleType = Corner | "rotation";
+type Edge = "right" | "left" | "bottom";
+type HandleType = Corner | Edge | "rotation";
+
+function getPreferredEdge({
+ edge,
+}: {
+ edge: Edge;
+}): ScaleEdgePreference {
+ return edge === "right"
+ ? { right: true }
+ : edge === "left"
+ ? { left: true }
+ : { bottom: true };
+}
interface ScaleState {
trackId: string;
@@ -50,26 +64,25 @@ interface RotationState {
initialBoundsCy: number;
}
-function areSnapLinesEqual({
- previousLines,
- nextLines,
-}: {
- previousLines: SnapLine[];
- nextLines: SnapLine[];
-}): boolean {
- if (previousLines.length !== nextLines.length) {
- return false;
- }
- for (const [index, line] of previousLines.entries()) {
- const nextLine = nextLines[index];
- if (!nextLine) {
- return false;
- }
- if (line.type !== nextLine.type || line.position !== nextLine.position) {
- return false;
- }
+interface EdgeScaleState {
+ trackId: string;
+ elementId: string;
+ initialTransform: Transform;
+ initialBoundsCx: number;
+ initialBoundsCy: number;
+ baseWidth: number;
+ baseHeight: number;
+ edge: Edge;
+ rotationRad: number;
+ shouldClearScaleAnimation: boolean;
+ animationsWithoutScale: ElementAnimations | undefined;
+}
+
+function clampScaleNonZero(scale: number): number {
+ if (Math.abs(scale) < MIN_SCALE) {
+ return scale < 0 ? -MIN_SCALE : MIN_SCALE;
}
- return true;
+ return scale;
}
function getCornerDistance({
@@ -102,29 +115,30 @@ function getCornerDistance({
}
export function useTransformHandles({
- canvasRef,
+ onSnapLinesChange,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ onSnapLinesChange?: OnSnapLinesChange;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
- const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
- const snapLinesRef = useRef<SnapLine[]>([]);
const scaleStateRef = useRef<ScaleState | null>(null);
const rotationStateRef = useRef<RotationState | null>(null);
-
- const selectedElements = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getSelectedElements(),
+ const edgeScaleStateRef = useRef<EdgeScaleState | null>(null);
+ const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
+ null,
);
- const tracks = editor.timeline.getTracks();
- const currentTime = editor.playback.getCurrentTime();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
- const mediaAssets = editor.media.getAssets();
- const canvasSize = editor.project.getActive().settings.canvasSize;
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -145,6 +159,37 @@ export function useTransformHandles({
const hasVisualSelection =
selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
+ const clearActiveHandleState = useCallback(() => {
+ scaleStateRef.current = null;
+ rotationStateRef.current = null;
+ edgeScaleStateRef.current = null;
+ setActiveHandle(null);
+ onSnapLinesChange?.([]);
+ }, [onSnapLinesChange]);
+
+ const releaseCapturedPointer = useCallback(() => {
+ const capture = captureRef.current;
+ if (!capture) return;
+
+ if (capture.element.hasPointerCapture(capture.pointerId)) {
+ capture.element.releasePointerCapture(capture.pointerId);
+ }
+
+ captureRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ if (!activeHandle) return;
+
+ return registerCanceller({
+ fn: () => {
+ editor.timeline.discardPreview();
+ clearActiveHandleState();
+ releaseCapturedPointer();
+ },
+ });
+ }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]);
+
const handleCornerPointerDown = useCallback(
({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
if (!selectedWithBounds) return;
@@ -165,14 +210,19 @@ export function useTransformHandles({
});
const initialDistance = getCornerDistance({ bounds, corner });
- const baseWidth = bounds.width / resolvedTransform.scale;
- const baseHeight = bounds.height / resolvedTransform.scale;
+ const baseWidth = bounds.width / resolvedTransform.scaleX;
+ const baseHeight = bounds.height / resolvedTransform.scaleY;
const shouldClearScaleAnimation =
- !!element.animations?.channels["transform.scale"];
+ !!element.animations?.channels["transform.scaleX"] ||
+ !!element.animations?.channels["transform.scaleY"];
const animationsWithoutScale = shouldClearScaleAnimation
? setChannel({
- animations: element.animations,
- propertyPath: "transform.scale",
+ animations: setChannel({
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ channel: undefined,
+ }),
+ propertyPath: "transform.scaleY",
channel: undefined,
})
: element.animations;
@@ -190,14 +240,19 @@ export function useTransformHandles({
animationsWithoutScale,
};
setActiveHandle(corner);
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
},
[selectedWithBounds],
);
const handleRotationPointerDown = useCallback(
({ event }: { event: React.PointerEvent }) => {
- if (!selectedWithBounds || !canvasRef.current) return;
+ if (!selectedWithBounds) return;
event.stopPropagation();
const { bounds, trackId, elementId, element } = selectedWithBounds;
@@ -214,14 +269,14 @@ export function useTransformHandles({
localTime,
});
- const position = screenToCanvas({
+ const position = viewport.screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
- canvas: canvasRef.current,
});
- const deltaX = position.x - bounds.cx;
- const deltaY = position.y - bounds.cy;
- const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ if (!position) return;
+ const deltaX = position.x - bounds.cx;
+ const deltaY = position.y - bounds.cy;
+ const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
rotationStateRef.current = {
trackId,
@@ -232,21 +287,91 @@ export function useTransformHandles({
initialBoundsCy: bounds.cy,
};
setActiveHandle("rotation");
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
},
- [selectedWithBounds, canvasRef],
+ [selectedWithBounds, viewport],
+ );
+
+ const handleEdgePointerDown = useCallback(
+ ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
+ if (!selectedWithBounds) return;
+ event.stopPropagation();
+
+ 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 baseWidth = bounds.width / resolvedTransform.scaleX;
+ const baseHeight = bounds.height / resolvedTransform.scaleY;
+ const rotationRad = (bounds.rotation * Math.PI) / 180;
+
+ const propertyPath =
+ edge === "right" || edge === "left"
+ ? "transform.scaleX"
+ : "transform.scaleY";
+ const shouldClearScaleAnimation =
+ !!element.animations?.channels[propertyPath];
+ const animationsWithoutScale = shouldClearScaleAnimation
+ ? setChannel({
+ animations: element.animations,
+ propertyPath,
+ channel: undefined,
+ })
+ : element.animations;
+
+ edgeScaleStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: resolvedTransform,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ baseWidth,
+ baseHeight,
+ edge,
+ rotationRad,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ };
+ setActiveHandle(edge);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [selectedWithBounds],
);
const handlePointerMove = useCallback(
({ event }: { event: React.PointerEvent }) => {
- if (!canvasRef.current) return;
- if (!scaleStateRef.current && !rotationStateRef.current) return;
+ if (
+ !scaleStateRef.current &&
+ !rotationStateRef.current &&
+ !edgeScaleStateRef.current
+ )
+ return;
- const position = screenToCanvas({
+ const position = viewport.screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
- canvas: canvasRef.current,
});
+ if (!position) return;
if (
scaleStateRef.current &&
@@ -266,58 +391,157 @@ export function useTransformHandles({
animationsWithoutScale,
} = scaleStateRef.current;
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 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,
- initialTransform.scale * scaleFactor,
- );
- const canvasSize = editor.project.getActive().settings.canvasSize;
- const snapThreshold = screenPixelsToLogicalThreshold({
- canvas: canvasRef.current,
+ // Use actual element dimensions (base * current scale) so snap
+ // computes the correct edges when scaleX ≠ scaleY
+ const effectiveWidth = baseWidth * initialTransform.scaleX;
+ const effectiveHeight = baseHeight * initialTransform.scaleY;
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
- const shouldSnap = !isShiftHeldRef.current;
- const { snappedScale, activeLines } = shouldSnap
- ? snapScale({
- proposedScale,
+ const { snappedScale: snappedFactor, activeLines } =
+ isShiftHeldRef.current
+ ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
+ : snapScale({
+ proposedScale: scaleFactor,
+ position: initialTransform.position,
+ baseWidth: effectiveWidth,
+ baseHeight: effectiveHeight,
+ rotation: initialTransform.rotate,
+ canvasSize,
+ snapThreshold,
+ });
+
+ onSnapLinesChange?.(activeLines);
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ scaleX: clampScaleNonZero(
+ initialTransform.scaleX * snappedFactor,
+ ),
+ scaleY: clampScaleNonZero(
+ initialTransform.scaleY * snappedFactor,
+ ),
+ },
+ ...(shouldClearScaleAnimation && {
+ animations: animationsWithoutScale,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ if (
+ edgeScaleStateRef.current &&
+ (activeHandle === "right" ||
+ activeHandle === "left" ||
+ activeHandle === "bottom")
+ ) {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialBoundsCx,
+ initialBoundsCy,
+ baseWidth,
+ baseHeight,
+ edge,
+ rotationRad,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ } = edgeScaleStateRef.current;
+
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const xProjection =
+ deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
+ const yProjection =
+ -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
+ const projection =
+ edge === "right"
+ ? xProjection
+ : edge === "left"
+ ? -xProjection
+ : yProjection;
+
+ const baseAxisHalf =
+ edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
+ const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
+
+ const proposedScaleX =
+ edge === "right" || edge === "left"
+ ? proposedScale
+ : initialTransform.scaleX;
+ const proposedScaleY =
+ edge === "bottom" ? proposedScale : initialTransform.scaleY;
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { x: xSnap, y: ySnap } = isShiftHeldRef.current
+ ? {
+ x: {
+ snappedScale: proposedScaleX,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ y: {
+ snappedScale: proposedScaleY,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ }
+ : snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY,
position: initialTransform.position,
baseWidth,
baseHeight,
+ rotation: initialTransform.rotate,
canvasSize,
snapThreshold,
- })
- : { snappedScale: proposedScale, activeLines: [] as SnapLine[] };
-
- const isSameLines = areSnapLinesEqual({
- previousLines: snapLinesRef.current,
- nextLines: activeLines,
- });
-
- if (!isSameLines) {
- snapLinesRef.current = activeLines;
- setSnapLines(activeLines);
- }
+ preferredEdges: getPreferredEdge({ edge }),
+ });
- const updates: {
- transform: Transform;
- animations?: ElementAnimations;
- } = {
- transform: { ...initialTransform, scale: snappedScale },
- };
- if (shouldClearScaleAnimation) {
- updates.animations = animationsWithoutScale;
- }
+ const relevantSnap =
+ edge === "right" || edge === "left" ? xSnap : ySnap;
+ onSnapLinesChange?.(relevantSnap.activeLines);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
- updates,
+ updates: {
+ transform: {
+ ...initialTransform,
+ scaleX:
+ edge === "right" || edge === "left"
+ ? xSnap.snappedScale
+ : initialTransform.scaleX,
+ scaleY:
+ edge === "bottom"
+ ? ySnap.snappedScale
+ : initialTransform.scaleY,
+ },
+ ...(shouldClearScaleAnimation && {
+ animations: animationsWithoutScale,
+ }),
+ },
},
],
});
@@ -334,17 +558,16 @@ export function useTransformHandles({
initialBoundsCy,
} = rotationStateRef.current;
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentAngle = (Math.atan2(deltaY, deltaX) * 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;
const newRotate = initialTransform.rotate + deltaAngle;
- const shouldSnapRotation = !isShiftHeldRef.current;
- const { snappedRotation } = shouldSnapRotation
- ? snapRotation({ proposedRotation: newRotate })
- : { snappedRotation: newRotate };
+ const { snappedRotation } = isShiftHeldRef.current
+ ? { snappedRotation: newRotate }
+ : snapRotation({ proposedRotation: newRotate });
editor.timeline.previewElements({
updates: [
@@ -359,32 +582,36 @@ export function useTransformHandles({
});
}
},
- [activeHandle, canvasRef, editor, isShiftHeldRef],
+ [
+ activeHandle,
+ canvasSize,
+ editor,
+ isShiftHeldRef,
+ onSnapLinesChange,
+ viewport,
+ ],
);
- const handlePointerUp = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (scaleStateRef.current || rotationStateRef.current) {
+ const handlePointerUp = useCallback(() => {
+ if (
+ scaleStateRef.current ||
+ rotationStateRef.current ||
+ edgeScaleStateRef.current
+ ) {
editor.timeline.commitPreview();
- scaleStateRef.current = null;
- rotationStateRef.current = null;
- setActiveHandle(null);
- snapLinesRef.current = [];
- setSnapLines([]);
+ clearActiveHandleState();
}
- (event.currentTarget as HTMLElement).releasePointerCapture(
- event.pointerId,
- );
+ releaseCapturedPointer();
},
- [editor],
+ [clearActiveHandleState, editor, releaseCapturedPointer],
);
return {
selectedWithBounds,
hasVisualSelection,
activeHandle,
- snapLines,
handleCornerPointerDown,
+ handleEdgePointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,