mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: major editor overhaul (assets, properties, timeline, fonts) (#709)
* feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env
This commit is contained in:
@@ -7,10 +7,15 @@ import {
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
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 { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type {
|
||||
@@ -21,8 +26,6 @@ import type {
|
||||
} from "@/types/timeline";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
|
||||
const DRAG_THRESHOLD_PX = 5;
|
||||
|
||||
interface UseElementInteractionProps {
|
||||
zoomLevel: number;
|
||||
timelineRef: RefObject<HTMLDivElement | null>;
|
||||
@@ -54,24 +57,6 @@ interface PendingDragState {
|
||||
clickOffsetTime: number;
|
||||
}
|
||||
|
||||
function getMouseTimeFromClientX({
|
||||
clientX,
|
||||
containerRect,
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
}: {
|
||||
clientX: number;
|
||||
containerRect: DOMRect;
|
||||
zoomLevel: number;
|
||||
scrollLeft: number;
|
||||
}): number {
|
||||
const mouseX = clientX - containerRect.left + scrollLeft;
|
||||
return Math.max(
|
||||
0,
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
);
|
||||
}
|
||||
|
||||
function getClickOffsetTime({
|
||||
clientX,
|
||||
elementRect,
|
||||
@@ -175,6 +160,7 @@ export function useElementInteraction({
|
||||
onSnapPointChange,
|
||||
}: UseElementInteractionProps) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const { snapElementEdge } = useTimelineSnapping();
|
||||
const {
|
||||
@@ -230,7 +216,8 @@ export function useElementInteraction({
|
||||
frameSnappedTime: number;
|
||||
movingElement: TimelineElement | null | undefined;
|
||||
}) => {
|
||||
if (!snappingEnabled || !movingElement) {
|
||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||
if (!shouldSnap || !movingElement) {
|
||||
return { snappedTime: frameSnappedTime, snapPoint: null };
|
||||
}
|
||||
|
||||
@@ -268,7 +255,14 @@ export function useElementInteraction({
|
||||
snapPoint: snapResult.snapPoint,
|
||||
};
|
||||
},
|
||||
[snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel],
|
||||
[
|
||||
snappingEnabled,
|
||||
editor.playback,
|
||||
snapElementEdge,
|
||||
tracks,
|
||||
zoomLevel,
|
||||
isShiftHeldRef,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import {
|
||||
useTimelineSnapping,
|
||||
type SnapPoint,
|
||||
@@ -33,8 +35,9 @@ export function useTimelineElementResize({
|
||||
onSnapPointChange,
|
||||
onResizeStateChange,
|
||||
}: UseTimelineElementResizeProps) {
|
||||
const editor = EditorCore.getInstance();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
||||
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
|
||||
|
||||
@@ -49,21 +52,21 @@ export function useTimelineElementResize({
|
||||
const currentDurationRef = useRef(element.duration);
|
||||
|
||||
const handleResizeStart = ({
|
||||
e,
|
||||
event,
|
||||
elementId,
|
||||
side,
|
||||
}: {
|
||||
e: React.MouseEvent;
|
||||
event: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
setResizing({
|
||||
elementId,
|
||||
side,
|
||||
startX: e.clientX,
|
||||
startX: event.clientX,
|
||||
initialTrimStart: element.trimStart,
|
||||
initialTrimEnd: element.trimEnd,
|
||||
initialStartTime: element.startTime,
|
||||
@@ -89,104 +92,69 @@ export function useTimelineElementResize({
|
||||
return false;
|
||||
}, [element.type]);
|
||||
|
||||
const updateTrimFromMouseMove = useCallback(({ clientX }: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
const updateTrimFromMouseMove = useCallback(
|
||||
({ clientX }: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
|
||||
const deltaX = clientX - resizing.startX;
|
||||
let deltaTime = deltaX / (50 * zoomLevel);
|
||||
let resizeSnapPoint: SnapPoint | null = null;
|
||||
const deltaX = clientX - resizing.startX;
|
||||
let deltaTime =
|
||||
deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
let resizeSnapPoint: SnapPoint | null = null;
|
||||
|
||||
const projectFps = activeProject.settings.fps;
|
||||
const minDurationSeconds = 1 / projectFps;
|
||||
const canSnap = snappingEnabled;
|
||||
if (canSnap) {
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const snapPoints = findSnapPoints({
|
||||
tracks,
|
||||
playheadTime,
|
||||
excludeElementId: element.id,
|
||||
});
|
||||
if (resizing.side === "left") {
|
||||
const targetStartTime = resizing.initialStartTime + deltaTime;
|
||||
const snapResult = snapToNearestPoint({
|
||||
targetTime: targetStartTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
const projectFps = activeProject.settings.fps;
|
||||
const minDurationSeconds = 1 / projectFps;
|
||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||
if (shouldSnap) {
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const snapPoints = findSnapPoints({
|
||||
tracks,
|
||||
playheadTime,
|
||||
excludeElementId: element.id,
|
||||
});
|
||||
resizeSnapPoint = snapResult.snapPoint;
|
||||
if (snapResult.snapPoint) {
|
||||
deltaTime = snapResult.snappedTime - resizing.initialStartTime;
|
||||
}
|
||||
} else {
|
||||
const baseEndTime =
|
||||
resizing.initialStartTime + resizing.initialDuration;
|
||||
const targetEndTime = baseEndTime + deltaTime;
|
||||
const snapResult = snapToNearestPoint({
|
||||
targetTime: targetEndTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
resizeSnapPoint = snapResult.snapPoint;
|
||||
if (snapResult.snapPoint) {
|
||||
deltaTime = snapResult.snappedTime - baseEndTime;
|
||||
if (resizing.side === "left") {
|
||||
const targetStartTime = resizing.initialStartTime + deltaTime;
|
||||
const snapResult = snapToNearestPoint({
|
||||
targetTime: targetStartTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
resizeSnapPoint = snapResult.snapPoint;
|
||||
if (snapResult.snapPoint) {
|
||||
deltaTime = snapResult.snappedTime - resizing.initialStartTime;
|
||||
}
|
||||
} else {
|
||||
const baseEndTime =
|
||||
resizing.initialStartTime + resizing.initialDuration;
|
||||
const targetEndTime = baseEndTime + deltaTime;
|
||||
const snapResult = snapToNearestPoint({
|
||||
targetTime: targetEndTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
resizeSnapPoint = snapResult.snapPoint;
|
||||
if (snapResult.snapPoint) {
|
||||
deltaTime = snapResult.snappedTime - baseEndTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onSnapPointChange?.(resizeSnapPoint);
|
||||
onSnapPointChange?.(resizeSnapPoint);
|
||||
|
||||
if (resizing.side === "left") {
|
||||
const sourceDuration =
|
||||
resizing.initialTrimStart +
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const maxAllowed =
|
||||
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
if (resizing.side === "left") {
|
||||
const sourceDuration =
|
||||
resizing.initialTrimStart +
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const maxAllowed =
|
||||
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
|
||||
if (calculated >= 0 && calculated <= maxAllowed) {
|
||||
const newTrimStart = snapTimeToFrame({
|
||||
time: Math.min(maxAllowed, calculated),
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimStartRef.current = newTrimStart;
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
} else if (calculated < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionAmount = Math.abs(calculated);
|
||||
const maxExtension = resizing.initialStartTime;
|
||||
const actualExtension = Math.min(extensionAmount, maxExtension);
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime - actualExtension,
|
||||
if (calculated >= 0 && calculated <= maxAllowed) {
|
||||
const newTrimStart = snapTimeToFrame({
|
||||
time: Math.min(maxAllowed, calculated),
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimStartRef.current = 0;
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
} else {
|
||||
const trimDelta = 0 - resizing.initialTrimStart;
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
@@ -196,68 +164,119 @@ export function useTimelineElementResize({
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimStartRef.current = 0;
|
||||
currentTrimStartRef.current = newTrimStart;
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const sourceDuration =
|
||||
resizing.initialTrimStart +
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
|
||||
} else if (calculated < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionAmount = Math.abs(calculated);
|
||||
const maxExtension = resizing.initialStartTime;
|
||||
const actualExtension = Math.min(extensionAmount, maxExtension);
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime - actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
if (newTrimEnd < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionNeeded = Math.abs(newTrimEnd);
|
||||
const baseDuration =
|
||||
resizing.initialDuration + resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: baseDuration + extensionNeeded,
|
||||
fps: projectFps,
|
||||
});
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimStartRef.current = 0;
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
} else {
|
||||
const trimDelta = 0 - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
currentDurationRef.current = newDuration;
|
||||
currentTrimEndRef.current = 0;
|
||||
} else {
|
||||
const extensionToLimit = resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + extensionToLimit,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
currentDurationRef.current = newDuration;
|
||||
currentTrimEndRef.current = 0;
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimStartRef.current = 0;
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const maxTrimEnd =
|
||||
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
|
||||
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
|
||||
const finalTrimEnd = snapTimeToFrame({
|
||||
time: clampedTrimEnd,
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
const sourceDuration =
|
||||
resizing.initialTrimStart +
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
|
||||
|
||||
setCurrentTrimEnd(finalTrimEnd);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimEndRef.current = finalTrimEnd;
|
||||
currentDurationRef.current = newDuration;
|
||||
if (newTrimEnd < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionNeeded = Math.abs(newTrimEnd);
|
||||
const baseDuration =
|
||||
resizing.initialDuration + resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: baseDuration + extensionNeeded,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
currentDurationRef.current = newDuration;
|
||||
currentTrimEndRef.current = 0;
|
||||
} else {
|
||||
const extensionToLimit = resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + extensionToLimit,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
currentDurationRef.current = newDuration;
|
||||
currentTrimEndRef.current = 0;
|
||||
}
|
||||
} else {
|
||||
const maxTrimEnd =
|
||||
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
|
||||
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
|
||||
const finalTrimEnd = snapTimeToFrame({
|
||||
time: clampedTrimEnd,
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimEnd(finalTrimEnd);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimEndRef.current = finalTrimEnd;
|
||||
currentDurationRef.current = newDuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [resizing, zoomLevel, activeProject.settings.fps, snappingEnabled, editor, findSnapPoints, snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration]);
|
||||
},
|
||||
[
|
||||
resizing,
|
||||
zoomLevel,
|
||||
activeProject.settings.fps,
|
||||
snappingEnabled,
|
||||
editor,
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
element.id,
|
||||
onSnapPointChange,
|
||||
canExtendElementDuration,
|
||||
isShiftHeldRef,
|
||||
],
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
if (!resizing) return;
|
||||
@@ -297,7 +316,14 @@ export function useTimelineElementResize({
|
||||
setResizing(null);
|
||||
onResizeStateChange?.({ isResizing: false });
|
||||
onSnapPointChange?.(null);
|
||||
}, [resizing, editor.timeline, element.id, track.id, onResizeStateChange, onSnapPointChange]);
|
||||
}, [
|
||||
resizing,
|
||||
editor.timeline,
|
||||
element.id,
|
||||
track.id,
|
||||
onResizeStateChange,
|
||||
onSnapPointChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
@@ -21,7 +21,9 @@ export function useElementSelection() {
|
||||
|
||||
const selectElement = useCallback(
|
||||
({ trackId, elementId }: ElementRef) => {
|
||||
editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] });
|
||||
editor.selection.setSelectedElements({
|
||||
elements: [{ trackId, elementId }],
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type { Bookmark } from "@/types/timeline";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
|
||||
export interface BookmarkDragState {
|
||||
isDragging: boolean;
|
||||
bookmarkTime: number | null;
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
interface PendingBookmarkDrag {
|
||||
bookmarkTime: number;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
}
|
||||
|
||||
interface UseBookmarkDragProps {
|
||||
zoomLevel: number;
|
||||
scrollRef: RefObject<HTMLElement | null>;
|
||||
snappingEnabled: boolean;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
export function useBookmarkDrag({
|
||||
zoomLevel,
|
||||
scrollRef,
|
||||
snappingEnabled,
|
||||
onSnapPointChange,
|
||||
}: UseBookmarkDragProps) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
const bookmarks = activeScene?.bookmarks ?? [];
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
|
||||
|
||||
const [dragState, setDragState] = useState<BookmarkDragState>({
|
||||
isDragging: false,
|
||||
bookmarkTime: null,
|
||||
currentTime: 0,
|
||||
});
|
||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
|
||||
const lastMouseXRef = useRef(0);
|
||||
|
||||
const startDrag = useCallback(
|
||||
({
|
||||
bookmarkTime,
|
||||
initialCurrentTime,
|
||||
}: {
|
||||
bookmarkTime: number;
|
||||
initialCurrentTime: number;
|
||||
}) => {
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
bookmarkTime,
|
||||
currentTime: initialCurrentTime,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragState({
|
||||
isDragging: false,
|
||||
bookmarkTime: null,
|
||||
currentTime: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getSnapResult = useCallback(
|
||||
({
|
||||
rawTime,
|
||||
excludeBookmarkTime,
|
||||
}: {
|
||||
rawTime: number;
|
||||
excludeBookmarkTime: number;
|
||||
}): { snappedTime: number; snapPoint: SnapPoint | null } => {
|
||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||
if (!shouldSnap) {
|
||||
return { snappedTime: rawTime, snapPoint: null };
|
||||
}
|
||||
|
||||
const snapPoints = findSnapPoints({
|
||||
tracks,
|
||||
playheadTime,
|
||||
bookmarks,
|
||||
excludeBookmarkTime,
|
||||
});
|
||||
const result = snapToNearestPoint({
|
||||
targetTime: rawTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
return {
|
||||
snappedTime: result.snappedTime,
|
||||
snapPoint: result.snapPoint,
|
||||
};
|
||||
},
|
||||
[
|
||||
snappingEnabled,
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
tracks,
|
||||
playheadTime,
|
||||
bookmarks,
|
||||
zoomLevel,
|
||||
isShiftHeldRef,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging && !isPendingDrag) return;
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
lastMouseXRef.current = event.clientX;
|
||||
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
if (isPendingDrag && pendingDragRef.current) {
|
||||
const { startMouseX, startMouseY, bookmarkTime } =
|
||||
pendingDragRef.current;
|
||||
const deltaX = Math.abs(event.clientX - startMouseX);
|
||||
const deltaY = Math.abs(event.clientY - startMouseY);
|
||||
|
||||
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX: event.clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const frameSnappedTime = snapTimeToFrame({
|
||||
time: Math.max(0, Math.min(mouseTime, duration)),
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const { snappedTime: initialTime } = getSnapResult({
|
||||
rawTime: frameSnappedTime,
|
||||
excludeBookmarkTime: bookmarkTime,
|
||||
});
|
||||
|
||||
startDrag({
|
||||
bookmarkTime,
|
||||
initialCurrentTime: initialTime,
|
||||
});
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragState.isDragging || dragState.bookmarkTime === null) return;
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX: event.clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
|
||||
const frameSnappedTime = snapTimeToFrame({
|
||||
time: clampedTime,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const snapResult = getSnapResult({
|
||||
rawTime: frameSnappedTime,
|
||||
excludeBookmarkTime: dragState.bookmarkTime,
|
||||
});
|
||||
|
||||
setDragState((previousDragState) => ({
|
||||
...previousDragState,
|
||||
currentTime: snapResult.snappedTime,
|
||||
}));
|
||||
onSnapPointChange?.(snapResult.snapPoint);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
return () => document.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.bookmarkTime,
|
||||
zoomLevel,
|
||||
duration,
|
||||
editor.project,
|
||||
scrollRef,
|
||||
isPendingDrag,
|
||||
startDrag,
|
||||
getSnapResult,
|
||||
onSnapPointChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (dragState.bookmarkTime === null) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(dragState.currentTime, duration),
|
||||
);
|
||||
|
||||
editor.scenes.moveBookmark({
|
||||
fromTime: dragState.bookmarkTime,
|
||||
toTime: clampedTime,
|
||||
});
|
||||
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.bookmarkTime,
|
||||
dragState.currentTime,
|
||||
duration,
|
||||
endDrag,
|
||||
onSnapPointChange,
|
||||
editor.scenes,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPendingDrag) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [isPendingDrag, onSnapPointChange]);
|
||||
|
||||
const handleBookmarkMouseDown = useCallback(
|
||||
({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
pendingDragRef.current = {
|
||||
bookmarkTime: bookmark.time,
|
||||
startMouseX: event.clientX,
|
||||
startMouseY: event.clientY,
|
||||
};
|
||||
setIsPendingDrag(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
dragState,
|
||||
handleBookmarkMouseDown,
|
||||
lastMouseXRef,
|
||||
};
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import { snapTimeToFrame } from "@/lib/time";
|
||||
import {
|
||||
buildTextElement,
|
||||
buildStickerElement,
|
||||
buildUploadAudioElement,
|
||||
buildVideoElement,
|
||||
buildImageElement,
|
||||
buildElementFromMedia,
|
||||
} 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";
|
||||
@@ -231,7 +233,8 @@ export function useTimelineDragDrop({
|
||||
}
|
||||
|
||||
const element = buildStickerElement({
|
||||
iconName: dragData.iconName,
|
||||
stickerId: dragData.stickerId,
|
||||
name: dragData.name,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
@@ -265,38 +268,18 @@ export function useTimelineDragDrop({
|
||||
|
||||
const duration =
|
||||
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: mediaAsset.id,
|
||||
mediaType: mediaAsset.type,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
if (dragData.mediaType === "audio") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildUploadAudioElement({
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
}),
|
||||
});
|
||||
} else if (dragData.mediaType === "video") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildVideoElement({
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildImageElement({
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
}),
|
||||
});
|
||||
}
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, mediaAssets, tracks],
|
||||
);
|
||||
@@ -314,80 +297,67 @@ export function useTimelineDragDrop({
|
||||
if (!activeProject) return;
|
||||
|
||||
const processedAssets = await processMediaAssets({ files });
|
||||
const projectId = activeProject.metadata.id;
|
||||
|
||||
for (const asset of processedAssets) {
|
||||
await editor.media.addMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
asset,
|
||||
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,
|
||||
});
|
||||
|
||||
const added = editor.media
|
||||
.getAssets()
|
||||
.find((m) => m.name === asset.name && m.url === asset.url);
|
||||
const trackType: TrackType = asset.type === "audio" ? "audio" : "video";
|
||||
const addMediaCmd = new AddMediaAssetCommand(projectId, asset);
|
||||
const assetId = addMediaCmd.getAssetId();
|
||||
|
||||
if (added) {
|
||||
const duration =
|
||||
added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const currentTracks = editor.timeline.getTracks();
|
||||
const dropTarget = computeDropTarget({
|
||||
elementType: added.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 =
|
||||
added.type === "audio" ? "audio" : "video";
|
||||
const trackId = dropTarget.isNewTrack
|
||||
? editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: dropTarget.trackIndex,
|
||||
})
|
||||
: currentTracks[dropTarget.trackIndex]?.id;
|
||||
|
||||
if (!trackId) return;
|
||||
|
||||
if (added.type === "audio") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildUploadAudioElement({
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
|
||||
}),
|
||||
});
|
||||
} else if (added.type === "video") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildVideoElement({
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildImageElement({
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
}),
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
if (!trackId) return;
|
||||
|
||||
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 insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId },
|
||||
});
|
||||
commands.push(insertCmd);
|
||||
|
||||
const batchCmd = new BatchCommand(commands);
|
||||
editor.command.execute({ command: batchCmd });
|
||||
}
|
||||
},
|
||||
[activeProject, editor.media, editor.timeline, currentTime, zoomLevel],
|
||||
[activeProject, editor.command, editor.timeline, currentTime, zoomLevel],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { useState, 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 {
|
||||
useTimelineSnapping,
|
||||
type SnapPoint,
|
||||
} from "@/hooks/timeline/use-timeline-snapping";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
@@ -25,6 +30,11 @@ export function useTimelinePlayhead({
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const isScrubbing = editor.playback.getIsScrubbing();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const { snapToNearestPoint } = useTimelineSnapping({
|
||||
enableElementSnapping: false,
|
||||
enablePlayheadSnapping: false,
|
||||
});
|
||||
|
||||
const seek = useCallback(
|
||||
({ time }: { time: number }) => editor.playback.seek({ time }),
|
||||
@@ -62,19 +72,45 @@ export function useTimelinePlayhead({
|
||||
clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
|
||||
const framesPerSecond = activeProject.settings.fps;
|
||||
const time = getSnappedSeekTime({
|
||||
const frameTime = getSnappedSeekTime({
|
||||
rawTime,
|
||||
duration,
|
||||
fps: framesPerSecond,
|
||||
});
|
||||
|
||||
const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
|
||||
const bookmarkSnapPoints: SnapPoint[] = bookmarks.map((bookmark) => ({
|
||||
time: bookmark.time,
|
||||
type: "bookmark",
|
||||
}));
|
||||
const shouldSnapToBookmark =
|
||||
!isShiftHeldRef.current && bookmarkSnapPoints.length > 0;
|
||||
const snapResult = shouldSnapToBookmark
|
||||
? snapToNearestPoint({
|
||||
targetTime: frameTime,
|
||||
snapPoints: bookmarkSnapPoints,
|
||||
zoomLevel,
|
||||
})
|
||||
: null;
|
||||
const time = snapResult?.snapPoint ? snapResult.snappedTime : frameTime;
|
||||
|
||||
setScrubTime(time);
|
||||
seek({ time });
|
||||
|
||||
lastMouseXRef.current = event.clientX;
|
||||
},
|
||||
[duration, zoomLevel, seek, rulerRef, activeProject.settings.fps],
|
||||
[
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
activeProject.settings.fps,
|
||||
isShiftHeldRef,
|
||||
editor.scenes,
|
||||
snapToNearestPoint,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePlayheadMouseDown = useCallback(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback } from "react";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { Bookmark, TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
||||
|
||||
export interface SnapPoint {
|
||||
time: number;
|
||||
type: "element-start" | "element-end" | "playhead";
|
||||
type: "element-start" | "element-end" | "playhead" | "bookmark";
|
||||
elementId?: string;
|
||||
trackId?: string;
|
||||
}
|
||||
@@ -19,22 +20,28 @@ export interface UseTimelineSnappingOptions {
|
||||
snapThreshold?: number;
|
||||
enableElementSnapping?: boolean;
|
||||
enablePlayheadSnapping?: boolean;
|
||||
enableBookmarkSnapping?: boolean;
|
||||
}
|
||||
|
||||
export function useTimelineSnapping({
|
||||
snapThreshold = 10,
|
||||
enableElementSnapping = true,
|
||||
enablePlayheadSnapping = true,
|
||||
enableBookmarkSnapping = true,
|
||||
}: UseTimelineSnappingOptions = {}) {
|
||||
const findSnapPoints = useCallback(
|
||||
({
|
||||
tracks,
|
||||
playheadTime,
|
||||
excludeElementId,
|
||||
bookmarks = [],
|
||||
excludeBookmarkTime,
|
||||
}: {
|
||||
tracks: Array<TimelineTrack>;
|
||||
playheadTime: number;
|
||||
excludeElementId?: string;
|
||||
bookmarks?: Array<Bookmark>;
|
||||
excludeBookmarkTime?: number;
|
||||
}): SnapPoint[] => {
|
||||
const snapPoints: SnapPoint[] = [];
|
||||
|
||||
@@ -71,9 +78,25 @@ export function useTimelineSnapping({
|
||||
});
|
||||
}
|
||||
|
||||
if (enableBookmarkSnapping) {
|
||||
for (const bookmark of bookmarks) {
|
||||
if (
|
||||
excludeBookmarkTime != null &&
|
||||
Math.abs(bookmark.time - excludeBookmarkTime) <
|
||||
BOOKMARK_TIME_EPSILON
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
snapPoints.push({
|
||||
time: bookmark.time,
|
||||
type: "bookmark",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return snapPoints;
|
||||
},
|
||||
[enableElementSnapping, enablePlayheadSnapping],
|
||||
[enableElementSnapping, enablePlayheadSnapping, enableBookmarkSnapping],
|
||||
);
|
||||
|
||||
const snapToNearestPoint = useCallback(
|
||||
@@ -118,6 +141,7 @@ export function useTimelineSnapping({
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
snapToStart = true,
|
||||
bookmarks = [],
|
||||
}: {
|
||||
targetTime: number;
|
||||
elementDuration: number;
|
||||
@@ -126,11 +150,13 @@ export function useTimelineSnapping({
|
||||
zoomLevel: number;
|
||||
excludeElementId?: string;
|
||||
snapToStart?: boolean;
|
||||
bookmarks?: Array<Bookmark>;
|
||||
}): SnapResult => {
|
||||
const snapPoints = findSnapPoints({
|
||||
tracks,
|
||||
playheadTime,
|
||||
excludeElementId,
|
||||
bookmarks,
|
||||
});
|
||||
|
||||
const effectiveTargetTime = snapToStart
|
||||
|
||||
@@ -57,6 +57,8 @@ export function useTimelineZoom({
|
||||
const previousZoomRef = useRef(zoomLevel);
|
||||
const hasRestoredScrollRef = useRef(false);
|
||||
const preZoomScrollLeftRef = useRef(0);
|
||||
const prePlayheadAnchorScrollLeftRef = useRef(0);
|
||||
const isInPlayheadAnchorModeRef = useRef(false);
|
||||
|
||||
const setZoomLevel = useCallback(
|
||||
(updater: number | ((prev: number) => number)) => {
|
||||
@@ -89,8 +91,6 @@ export function useTimelineZoom({
|
||||
);
|
||||
return nextZoom;
|
||||
});
|
||||
// for horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||
// let the event bubble up to allow ScrollArea to handle it
|
||||
return;
|
||||
}
|
||||
},
|
||||
@@ -143,6 +143,36 @@ export function useTimelineZoom({
|
||||
const currentScrollLeft = preZoomScrollLeftRef.current;
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
|
||||
const previousSliderPercent = zoomToSlider({
|
||||
zoomLevel: previousZoom,
|
||||
minZoom,
|
||||
});
|
||||
const isCrossingThresholdUp =
|
||||
previousSliderPercent <
|
||||
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
const isCrossingThresholdDown =
|
||||
previousSliderPercent >=
|
||||
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent < TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
|
||||
const syncScroll = (scrollLeft: number) => {
|
||||
scrollElement.scrollLeft = scrollLeft;
|
||||
if (rulerScrollRef.current) {
|
||||
rulerScrollRef.current.scrollLeft = scrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
const clampScrollLeft = (scrollLeft: number) => {
|
||||
const maxScrollLeft =
|
||||
scrollElement.scrollWidth - scrollElement.clientWidth;
|
||||
return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
|
||||
};
|
||||
|
||||
if (isCrossingThresholdUp) {
|
||||
prePlayheadAnchorScrollLeftRef.current = currentScrollLeft;
|
||||
isInPlayheadAnchorModeRef.current = true;
|
||||
}
|
||||
|
||||
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
||||
const playheadPixelsBefore =
|
||||
@@ -153,17 +183,10 @@ export function useTimelineZoom({
|
||||
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
|
||||
const newScrollLeft = playheadPixelsAfter - viewportOffset;
|
||||
|
||||
const maxScrollLeft =
|
||||
scrollElement.scrollWidth - scrollElement.clientWidth;
|
||||
const clampedScrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(maxScrollLeft, newScrollLeft),
|
||||
);
|
||||
|
||||
scrollElement.scrollLeft = clampedScrollLeft;
|
||||
if (rulerScrollRef.current) {
|
||||
rulerScrollRef.current.scrollLeft = clampedScrollLeft;
|
||||
}
|
||||
syncScroll(clampScrollLeft(newScrollLeft));
|
||||
} else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) {
|
||||
syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current));
|
||||
isInPlayheadAnchorModeRef.current = false;
|
||||
}
|
||||
|
||||
previousZoomRef.current = zoomLevel;
|
||||
@@ -217,7 +240,6 @@ export function useTimelineZoom({
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (scrollElement.scrollWidth > 0) {
|
||||
restoreScroll();
|
||||
hasRestoredScrollRef.current = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";
|
||||
|
||||
const DATA_ATTR = "data-focus-locked";
|
||||
|
||||
function buildFocusLockCSS({
|
||||
cursor,
|
||||
allowSelector,
|
||||
}: {
|
||||
cursor: FocusLockCursor;
|
||||
allowSelector?: string;
|
||||
}) {
|
||||
const rules = [
|
||||
`*, *::before, *::after { pointer-events: none !important; cursor: ${cursor} !important; }`,
|
||||
`[${DATA_ATTR}], [${DATA_ATTR}] * { pointer-events: auto !important; cursor: auto !important; }`,
|
||||
];
|
||||
|
||||
if (allowSelector) {
|
||||
rules.push(
|
||||
`${allowSelector} { pointer-events: auto !important; cursor: auto !important; }`,
|
||||
);
|
||||
}
|
||||
|
||||
return rules.join("\n");
|
||||
}
|
||||
|
||||
export function useFocusLock<T extends HTMLElement = HTMLElement>({
|
||||
isActive,
|
||||
onDismiss,
|
||||
cursor = "default",
|
||||
allowSelector,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
onDismiss: () => void;
|
||||
cursor?: FocusLockCursor;
|
||||
allowSelector?: string;
|
||||
}) {
|
||||
const containerRef = useRef<T>(null);
|
||||
const onDismissRef = useRef(onDismiss);
|
||||
onDismissRef.current = onDismiss;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.setAttribute(DATA_ATTR, "");
|
||||
|
||||
const focusLockStyle = document.createElement("style");
|
||||
focusLockStyle.textContent = buildFocusLockCSS({ cursor, allowSelector });
|
||||
document.head.appendChild(focusLockStyle);
|
||||
|
||||
const handleOutsidePointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
if (container.contains(event.target as Node)) return;
|
||||
|
||||
const target = event.target as Element | null;
|
||||
const isAllowedTarget = allowSelector && target?.closest(allowSelector);
|
||||
if (isAllowedTarget) return;
|
||||
|
||||
onDismissRef.current();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handleOutsidePointerDown, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
"pointerdown",
|
||||
handleOutsidePointerDown,
|
||||
true,
|
||||
);
|
||||
container.removeAttribute(DATA_ATTR);
|
||||
focusLockStyle.remove();
|
||||
};
|
||||
}, [isActive, cursor, allowSelector]);
|
||||
|
||||
return { containerRef };
|
||||
}
|
||||
@@ -1,35 +1,35 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
|
||||
interface UseInfiniteScrollOptions {
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
threshold?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useInfiniteScroll({
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
isLoading,
|
||||
threshold = 200,
|
||||
enabled = true,
|
||||
}: UseInfiniteScrollOptions) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!enabled) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
|
||||
|
||||
if (nearBottom && hasMore && !isLoading) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
[onLoadMore, hasMore, isLoading, threshold, enabled],
|
||||
);
|
||||
|
||||
return { scrollAreaRef, handleScroll };
|
||||
}
|
||||
import { useRef, useCallback } from "react";
|
||||
|
||||
interface UseInfiniteScrollOptions {
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
threshold?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useInfiniteScroll({
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
isLoading,
|
||||
threshold = 200,
|
||||
enabled = true,
|
||||
}: UseInfiniteScrollOptions) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!enabled) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
|
||||
|
||||
if (nearBottom && hasMore && !isLoading) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
[onLoadMore, hasMore, isLoading, threshold, enabled],
|
||||
);
|
||||
|
||||
return { scrollAreaRef, handleScroll };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { processMediaAssets } from "@/lib/media/processing";
|
||||
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";
|
||||
|
||||
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
|
||||
|
||||
function isMediaMimeType({ type }: { type: string }): boolean {
|
||||
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
|
||||
}
|
||||
|
||||
function extractMediaFilesFromClipboard({
|
||||
clipboardData,
|
||||
}: {
|
||||
clipboardData: DataTransfer | null;
|
||||
}): File[] {
|
||||
if (!clipboardData?.items) return [];
|
||||
|
||||
const files: File[] = [];
|
||||
for (const item of clipboardData.items) {
|
||||
if (item.kind !== "file") continue;
|
||||
if (!isMediaMimeType({ type: item.type })) continue;
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function usePasteMedia() {
|
||||
const editor = useEditor();
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement && isTypableDOMElement({ element: activeElement })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = extractMediaFilesFromClipboard({
|
||||
clipboardData: event.clipboardData,
|
||||
});
|
||||
if (files.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
try {
|
||||
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";
|
||||
|
||||
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 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to paste media:", error);
|
||||
toast.error("Failed to paste media");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [editor]);
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Transform, TimelineTrack } from "@/types/timeline";
|
||||
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 { screenToCanvas } from "@/lib/preview/preview-coords";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import { snapPosition, type SnapLine } from "@/lib/preview/preview-snap";
|
||||
|
||||
const MIN_DRAG_DISTANCE = 0.5;
|
||||
|
||||
interface DragState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
tracksSnapshot: TimelineTrack[];
|
||||
bounds: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
elements: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
@@ -20,59 +30,164 @@ export function usePreviewInteraction({
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
|
||||
const [editingText, setEditingText] = useState<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
element: TextElement;
|
||||
originalOpacity: number;
|
||||
} | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const wasPlayingRef = useRef(editor.playback.getIsPlaying());
|
||||
const editingTextRef = useRef(editingText);
|
||||
editingTextRef.current = editingText;
|
||||
|
||||
const selectedElements = useSyncExternalStore(
|
||||
(listener) => editor.selection.subscribe(listener),
|
||||
() => editor.selection.getSelectedElements(),
|
||||
);
|
||||
const commitTextEdit = useCallback(() => {
|
||||
const current = editingTextRef.current;
|
||||
if (!current) return;
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId: current.trackId,
|
||||
elementId: current.elementId,
|
||||
updates: { opacity: current.originalOpacity },
|
||||
},
|
||||
],
|
||||
});
|
||||
editor.timeline.commitPreview();
|
||||
setEditingText(null);
|
||||
}, [editor.timeline]);
|
||||
|
||||
const getCanvasCoordinates = useCallback(
|
||||
({ clientX, clientY }: { clientX: number; clientY: number }) => {
|
||||
if (!canvasRef.current) return { x: 0, y: 0 };
|
||||
const cancelTextEdit = useCallback(() => {
|
||||
editor.timeline.discardPreview();
|
||||
setEditingText(null);
|
||||
}, [editor.timeline]);
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const logicalWidth = canvasRef.current.width;
|
||||
const logicalHeight = canvasRef.current.height;
|
||||
const scaleX = logicalWidth / rect.width;
|
||||
const scaleY = logicalHeight / rect.height;
|
||||
useEffect(() => {
|
||||
const unsubscribe = editor.playback.subscribe(() => {
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
if (isPlaying && !wasPlayingRef.current && editingTextRef.current) {
|
||||
commitTextEdit();
|
||||
}
|
||||
wasPlayingRef.current = isPlaying;
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [editor.playback, commitTextEdit]);
|
||||
|
||||
const canvasX = (clientX - rect.left) * scaleX;
|
||||
const canvasY = (clientY - rect.top) * scaleY;
|
||||
const handleDoubleClick = useCallback(
|
||||
({ clientX, clientY }: React.MouseEvent) => {
|
||||
if (!canvasRef.current || editingText) return;
|
||||
|
||||
return { x: canvasX, y: canvasY };
|
||||
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({
|
||||
clientX,
|
||||
clientY,
|
||||
canvas: canvasRef.current,
|
||||
});
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
const hit = hitTest({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds,
|
||||
});
|
||||
|
||||
if (!hit || hit.element.type !== "text") return;
|
||||
|
||||
const textElement = hit.element as TextElement;
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId: hit.trackId,
|
||||
elementId: hit.elementId,
|
||||
updates: { opacity: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
setEditingText({
|
||||
trackId: hit.trackId,
|
||||
elementId: hit.elementId,
|
||||
element: textElement,
|
||||
originalOpacity: textElement.opacity,
|
||||
});
|
||||
},
|
||||
[canvasRef],
|
||||
[canvasRef, editor, editingText],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (selectedElements.length === 0) return;
|
||||
({
|
||||
clientX,
|
||||
clientY,
|
||||
currentTarget,
|
||||
pointerId,
|
||||
button,
|
||||
}: React.PointerEvent) => {
|
||||
if (!canvasRef.current) return;
|
||||
if (editingText) return;
|
||||
if (button !== 0) return;
|
||||
|
||||
const elementsWithTracks = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
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({
|
||||
clientX,
|
||||
clientY,
|
||||
canvas: canvasRef.current,
|
||||
});
|
||||
|
||||
const draggableElements = elementsWithTracks.filter(
|
||||
({ element }) =>
|
||||
element.type === "video" ||
|
||||
element.type === "image" ||
|
||||
element.type === "text" ||
|
||||
element.type === "sticker",
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
const hit = hitTest({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const startPos = getCanvasCoordinates({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
|
||||
dragStateRef.current = {
|
||||
startX: startPos.x,
|
||||
startY: startPos.y,
|
||||
tracksSnapshot: editor.timeline.getTracks(),
|
||||
bounds: {
|
||||
width: hit.bounds.width,
|
||||
height: hit.bounds.height,
|
||||
},
|
||||
elements: draggableElements.map(({ track, element }) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
@@ -81,107 +196,118 @@ export function usePreviewInteraction({
|
||||
};
|
||||
|
||||
setIsDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
currentTarget.setPointerCapture(pointerId);
|
||||
},
|
||||
[selectedElements, editor, getCanvasCoordinates],
|
||||
[editor, canvasRef, editingText],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (!dragStateRef.current || !isDragging) return;
|
||||
({ clientX, clientY }: React.PointerEvent) => {
|
||||
if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
|
||||
|
||||
const currentPos = getCanvasCoordinates({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
const currentPos = screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
canvas: canvasRef.current,
|
||||
});
|
||||
|
||||
const deltaX = currentPos.x - dragStateRef.current.startX;
|
||||
const deltaY = currentPos.y - dragStateRef.current.startY;
|
||||
|
||||
for (const { trackId, elementId, initialTransform } of dragStateRef
|
||||
.current.elements) {
|
||||
const newPosition = {
|
||||
x: initialTransform.position.x + deltaX,
|
||||
y: initialTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isDragging, getCanvasCoordinates, editor],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (!dragStateRef.current || !isDragging) return;
|
||||
|
||||
const currentPos = getCanvasCoordinates({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
|
||||
const deltaX = currentPos.x - dragStateRef.current.startX;
|
||||
const deltaY = currentPos.y - dragStateRef.current.startY;
|
||||
|
||||
const hasMovement = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5;
|
||||
|
||||
|
||||
const hasMovement =
|
||||
Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
|
||||
Math.abs(deltaY) > MIN_DRAG_DISTANCE;
|
||||
if (!hasMovement) {
|
||||
dragStateRef.current = null;
|
||||
setIsDragging(false);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
setSnapLines([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// revert to pre-drag state so the command captures the correct undo snapshot
|
||||
editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot);
|
||||
const firstElement = dragStateRef.current.elements[0];
|
||||
const proposedPosition = {
|
||||
x: firstElement.initialTransform.position.x + deltaX,
|
||||
y: firstElement.initialTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
const shouldSnap = !isShiftHeldRef.current;
|
||||
const { snappedPosition, activeLines } = shouldSnap
|
||||
? snapPosition({
|
||||
proposedPosition,
|
||||
canvasSize,
|
||||
elementSize: dragStateRef.current.bounds,
|
||||
})
|
||||
: {
|
||||
snappedPosition: proposedPosition,
|
||||
activeLines: [] as SnapLine[],
|
||||
};
|
||||
|
||||
setSnapLines(activeLines);
|
||||
|
||||
const deltaSnappedX =
|
||||
snappedPosition.x - firstElement.initialTransform.position.x;
|
||||
const deltaSnappedY =
|
||||
snappedPosition.y - firstElement.initialTransform.position.y;
|
||||
|
||||
const updates = dragStateRef.current.elements.map(
|
||||
({ trackId, elementId, initialTransform }) => {
|
||||
const newPosition = {
|
||||
x: initialTransform.position.x + deltaX,
|
||||
y: initialTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
return {
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: newPosition,
|
||||
({ trackId, elementId, initialTransform }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: {
|
||||
x: initialTransform.position.x + deltaSnappedX,
|
||||
y: initialTransform.position.y + deltaSnappedY,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
editor.timeline.updateElements({ updates });
|
||||
editor.timeline.previewElements({ updates });
|
||||
},
|
||||
[isDragging, canvasRef, editor, isShiftHeldRef],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => {
|
||||
if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
|
||||
|
||||
const currentPos = screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
canvas: canvasRef.current,
|
||||
});
|
||||
|
||||
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) {
|
||||
editor.timeline.discardPreview();
|
||||
} else {
|
||||
editor.timeline.commitPreview();
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
setIsDragging(false);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
setSnapLines([]);
|
||||
currentTarget.releasePointerCapture(pointerId);
|
||||
},
|
||||
[isDragging, getCanvasCoordinates, editor],
|
||||
[isDragging, canvasRef, editor],
|
||||
);
|
||||
|
||||
return {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerUp,
|
||||
onDoubleClick: handleDoubleClick,
|
||||
snapLines,
|
||||
editingText,
|
||||
commitTextEdit,
|
||||
cancelTextEdit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
|
||||
export function useShiftKey(): RefObject<boolean> {
|
||||
const isShiftHeldRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = ({ key }: KeyboardEvent) => {
|
||||
if (key === "Shift") {
|
||||
isShiftHeldRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = ({ key }: KeyboardEvent) => {
|
||||
if (key === "Shift") {
|
||||
isShiftHeldRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isShiftHeldRef.current = false;
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isShiftHeldRef;
|
||||
}
|
||||
@@ -1,154 +1,154 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSoundsStore } from "@/stores/sounds-store";
|
||||
|
||||
export function useSoundSearch({
|
||||
query,
|
||||
commercialOnly,
|
||||
}: {
|
||||
query: string;
|
||||
commercialOnly: boolean;
|
||||
}) {
|
||||
const {
|
||||
searchResults,
|
||||
isSearching,
|
||||
searchError,
|
||||
lastSearchQuery,
|
||||
currentPage,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
setSearchResults,
|
||||
setSearching,
|
||||
setSearchError,
|
||||
setLastSearchQuery,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
setLoadingMore,
|
||||
appendSearchResults,
|
||||
appendTopSounds,
|
||||
resetPagination,
|
||||
} = useSoundsStore();
|
||||
|
||||
const loadMore = async () => {
|
||||
if (isLoadingMore || !hasNextPage) return;
|
||||
|
||||
try {
|
||||
setLoadingMore({ loading: true });
|
||||
const nextPage = currentPage + 1;
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
page: nextPage.toString(),
|
||||
type: "effects",
|
||||
});
|
||||
|
||||
if (query.trim()) {
|
||||
searchParams.set("q", query);
|
||||
}
|
||||
|
||||
searchParams.set("commercial_only", commercialOnly.toString());
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (query.trim()) {
|
||||
appendSearchResults(data.results);
|
||||
} else {
|
||||
appendTopSounds(data.results);
|
||||
}
|
||||
|
||||
setCurrentPage({ page: nextPage });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount(data.count);
|
||||
} else {
|
||||
setSearchError({ error: `Load more failed: ${response.status}` });
|
||||
}
|
||||
} catch (err) {
|
||||
setSearchError({
|
||||
error: err instanceof Error ? err.message : "Load more failed",
|
||||
});
|
||||
} finally {
|
||||
setLoadingMore({ loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults({ results: [] });
|
||||
setSearchError({ error: null });
|
||||
setLastSearchQuery({ query: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (query === lastSearchQuery && searchResults.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ignore = false;
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
setSearching({ searching: true });
|
||||
setSearchError({ error: null });
|
||||
resetPagination();
|
||||
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
|
||||
);
|
||||
|
||||
if (!ignore) {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSearchResults({ results: data.results });
|
||||
setLastSearchQuery({ query: query });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount({ count: data.count });
|
||||
setCurrentPage({ page: 1 });
|
||||
} else {
|
||||
setSearchError({ error: `Search failed: ${response.status}` });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!ignore) {
|
||||
setSearchError({
|
||||
error: err instanceof Error ? err.message : "Search failed",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setSearching({ searching: false });
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
ignore = true;
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
lastSearchQuery,
|
||||
searchResults.length,
|
||||
setSearchResults,
|
||||
setSearching,
|
||||
setSearchError,
|
||||
setLastSearchQuery,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
resetPagination,
|
||||
]);
|
||||
|
||||
return {
|
||||
results: searchResults,
|
||||
isLoading: isSearching,
|
||||
error: searchError,
|
||||
loadMore,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
import { useEffect } from "react";
|
||||
import { useSoundsStore } from "@/stores/sounds-store";
|
||||
|
||||
export function useSoundSearch({
|
||||
query,
|
||||
commercialOnly,
|
||||
}: {
|
||||
query: string;
|
||||
commercialOnly: boolean;
|
||||
}) {
|
||||
const {
|
||||
searchResults,
|
||||
isSearching,
|
||||
searchError,
|
||||
lastSearchQuery,
|
||||
currentPage,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
setSearchResults,
|
||||
setSearching,
|
||||
setSearchError,
|
||||
setLastSearchQuery,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
setLoadingMore,
|
||||
appendSearchResults,
|
||||
appendTopSounds,
|
||||
resetPagination,
|
||||
} = useSoundsStore();
|
||||
|
||||
const loadMore = async () => {
|
||||
if (isLoadingMore || !hasNextPage) return;
|
||||
|
||||
try {
|
||||
setLoadingMore({ loading: true });
|
||||
const nextPage = currentPage + 1;
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
page: nextPage.toString(),
|
||||
type: "effects",
|
||||
});
|
||||
|
||||
if (query.trim()) {
|
||||
searchParams.set("q", query);
|
||||
}
|
||||
|
||||
searchParams.set("commercial_only", commercialOnly.toString());
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (query.trim()) {
|
||||
appendSearchResults(data.results);
|
||||
} else {
|
||||
appendTopSounds(data.results);
|
||||
}
|
||||
|
||||
setCurrentPage({ page: nextPage });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount(data.count);
|
||||
} else {
|
||||
setSearchError({ error: `Load more failed: ${response.status}` });
|
||||
}
|
||||
} catch (err) {
|
||||
setSearchError({
|
||||
error: err instanceof Error ? err.message : "Load more failed",
|
||||
});
|
||||
} finally {
|
||||
setLoadingMore({ loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults({ results: [] });
|
||||
setSearchError({ error: null });
|
||||
setLastSearchQuery({ query: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (query === lastSearchQuery && searchResults.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ignore = false;
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
setSearching({ searching: true });
|
||||
setSearchError({ error: null });
|
||||
resetPagination();
|
||||
|
||||
const response = await fetch(
|
||||
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
|
||||
);
|
||||
|
||||
if (!ignore) {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSearchResults({ results: data.results });
|
||||
setLastSearchQuery({ query: query });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount({ count: data.count });
|
||||
setCurrentPage({ page: 1 });
|
||||
} else {
|
||||
setSearchError({ error: `Search failed: ${response.status}` });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!ignore) {
|
||||
setSearchError({
|
||||
error: err instanceof Error ? err.message : "Search failed",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setSearching({ searching: false });
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
ignore = true;
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
lastSearchQuery,
|
||||
searchResults.length,
|
||||
setSearchResults,
|
||||
setSearching,
|
||||
setSearchError,
|
||||
setLastSearchQuery,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
resetPagination,
|
||||
]);
|
||||
|
||||
return {
|
||||
results: searchResults,
|
||||
isLoading: isSearching,
|
||||
error: searchError,
|
||||
loadMore,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/lib/preview/element-bounds";
|
||||
import { screenToCanvas } from "@/lib/preview/preview-coords";
|
||||
import {
|
||||
MIN_SCALE,
|
||||
snapRotation,
|
||||
snapScale,
|
||||
type SnapLine,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import type { Transform } from "@/types/timeline";
|
||||
|
||||
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
type HandleType = Corner | "rotation";
|
||||
|
||||
interface ScaleState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
initialDistance: number;
|
||||
initialBoundsCx: number;
|
||||
initialBoundsCy: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
}
|
||||
|
||||
interface RotationState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
initialAngle: number;
|
||||
initialBoundsCx: number;
|
||||
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;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getCornerDistance({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
};
|
||||
corner: Corner;
|
||||
}): number {
|
||||
const halfW = bounds.width / 2;
|
||||
const halfH = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||
|
||||
const rotatedX = localX * cos - localY * sin;
|
||||
const rotatedY = localX * sin + localY * cos;
|
||||
return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
|
||||
}
|
||||
|
||||
export function useTransformHandles({
|
||||
canvasRef,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
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 tracks = editor.timeline.getTracks();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
const selectedWithBounds: ElementWithBounds | null =
|
||||
selectedElements.length === 1
|
||||
? (elementsWithBounds.find(
|
||||
(entry) =>
|
||||
entry.trackId === selectedElements[0].trackId &&
|
||||
entry.elementId === selectedElements[0].elementId,
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
const hasVisualSelection =
|
||||
selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
|
||||
|
||||
const handleCornerPointerDown = useCallback(
|
||||
({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
|
||||
if (!selectedWithBounds) return;
|
||||
event.stopPropagation();
|
||||
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const initialDistance = getCornerDistance({ bounds, corner });
|
||||
const baseWidth = bounds.width / element.transform.scale;
|
||||
const baseHeight = bounds.height / element.transform.scale;
|
||||
|
||||
scaleStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: element.transform,
|
||||
initialDistance,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
};
|
||||
setActiveHandle(corner);
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
},
|
||||
[selectedWithBounds],
|
||||
);
|
||||
|
||||
const handleRotationPointerDown = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
if (!selectedWithBounds || !canvasRef.current) return;
|
||||
event.stopPropagation();
|
||||
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const position = screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
canvas: canvasRef.current,
|
||||
});
|
||||
const dx = position.x - bounds.cx;
|
||||
const dy = position.y - bounds.cy;
|
||||
const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
|
||||
rotationStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: element.transform,
|
||||
initialAngle,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
};
|
||||
setActiveHandle("rotation");
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
},
|
||||
[selectedWithBounds, canvasRef],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
if (!canvasRef.current) return;
|
||||
if (!scaleStateRef.current && !rotationStateRef.current) return;
|
||||
|
||||
const position = screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
canvas: canvasRef.current,
|
||||
});
|
||||
|
||||
if (
|
||||
scaleStateRef.current &&
|
||||
activeHandle &&
|
||||
activeHandle !== "rotation"
|
||||
) {
|
||||
const {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform,
|
||||
initialDistance,
|
||||
initialBoundsCx,
|
||||
initialBoundsCy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
} = scaleStateRef.current;
|
||||
|
||||
const dx = position.x - initialBoundsCx;
|
||||
const dy = position.y - initialBoundsCy;
|
||||
const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const scaleFactor = currentDistance / initialDistance;
|
||||
const proposedScale = Math.max(
|
||||
MIN_SCALE,
|
||||
initialTransform.scale * scaleFactor,
|
||||
);
|
||||
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
const shouldSnap = !isShiftHeldRef.current;
|
||||
const { snappedScale, activeLines } = shouldSnap
|
||||
? snapScale({
|
||||
proposedScale,
|
||||
position: initialTransform.position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
canvasSize,
|
||||
})
|
||||
: { snappedScale: proposedScale, activeLines: [] as SnapLine[] };
|
||||
|
||||
const isSameLines = areSnapLinesEqual({
|
||||
previousLines: snapLinesRef.current,
|
||||
nextLines: activeLines,
|
||||
});
|
||||
|
||||
if (!isSameLines) {
|
||||
snapLinesRef.current = activeLines;
|
||||
setSnapLines(activeLines);
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: { ...initialTransform, scale: snappedScale },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (rotationStateRef.current && activeHandle === "rotation") {
|
||||
const {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform,
|
||||
initialAngle,
|
||||
initialBoundsCx,
|
||||
initialBoundsCy,
|
||||
} = rotationStateRef.current;
|
||||
|
||||
const dx = position.x - initialBoundsCx;
|
||||
const dy = position.y - initialBoundsCy;
|
||||
const currentAngle = (Math.atan2(dy, dx) * 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 };
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: { ...initialTransform, rotate: snappedRotation },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
[activeHandle, canvasRef, editor, isShiftHeldRef],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
if (scaleStateRef.current || rotationStateRef.current) {
|
||||
editor.timeline.commitPreview();
|
||||
scaleStateRef.current = null;
|
||||
rotationStateRef.current = null;
|
||||
setActiveHandle(null);
|
||||
snapLinesRef.current = [];
|
||||
setSnapLines([]);
|
||||
}
|
||||
(event.currentTarget as HTMLElement).releasePointerCapture(
|
||||
event.pointerId,
|
||||
);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedWithBounds,
|
||||
hasVisualSelection,
|
||||
activeHandle,
|
||||
snapLines,
|
||||
handleCornerPointerDown,
|
||||
handleRotationPointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user