mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: add ultracite (#452)
* Init ultracite * Update scripts and biome.jsonc * Update biome.jsonc * Update biome.jsonc * Update biome.jsonc * Run format
This commit is contained in:
@@ -3,58 +3,58 @@ import { useRouter } from "next/navigation";
|
||||
import { signIn } from "@opencut/auth/client";
|
||||
|
||||
export function useLogin() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setError(null);
|
||||
setIsEmailLoading(true);
|
||||
const handleLogin = useCallback(async () => {
|
||||
setError(null);
|
||||
setIsEmailLoading(true);
|
||||
|
||||
const { error } = await signIn.email({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
const { error } = await signIn.email({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
setIsEmailLoading(false);
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
setIsEmailLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/projects");
|
||||
}, [router, email, password]);
|
||||
router.push("/projects");
|
||||
}, [router, email, password]);
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
const handleGoogleLogin = async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/projects",
|
||||
});
|
||||
} catch (error) {
|
||||
setError("Failed to sign in with Google. Please try again.");
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/projects",
|
||||
});
|
||||
} catch (error) {
|
||||
setError("Failed to sign in with Google. Please try again.");
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
|
||||
return {
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
isAnyLoading,
|
||||
handleLogin,
|
||||
handleGoogleLogin,
|
||||
};
|
||||
return {
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
isAnyLoading,
|
||||
handleLogin,
|
||||
handleGoogleLogin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,63 +3,63 @@ import { useRouter } from "next/navigation";
|
||||
import { signUp, signIn } from "@opencut/auth/client";
|
||||
|
||||
export function useSignUp() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
|
||||
const handleSignUp = useCallback(async () => {
|
||||
setError(null);
|
||||
setIsEmailLoading(true);
|
||||
const handleSignUp = useCallback(async () => {
|
||||
setError(null);
|
||||
setIsEmailLoading(true);
|
||||
|
||||
const { error } = await signUp.email({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
const { error } = await signUp.email({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
setIsEmailLoading(false);
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
setIsEmailLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/login");
|
||||
}, [name, email, password, router]);
|
||||
router.push("/login");
|
||||
}, [name, email, password, router]);
|
||||
|
||||
const handleGoogleSignUp = useCallback(async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
const handleGoogleSignUp = useCallback(async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
|
||||
router.push("/editor");
|
||||
} catch (error) {
|
||||
setError("Failed to sign up with Google. Please try again.");
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
router.push("/editor");
|
||||
} catch (error) {
|
||||
setError("Failed to sign up with Google. Please try again.");
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
|
||||
return {
|
||||
name,
|
||||
setName,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
isAnyLoading,
|
||||
handleSignUp,
|
||||
handleGoogleSignUp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name,
|
||||
setName,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
isAnyLoading,
|
||||
handleSignUp,
|
||||
handleGoogleSignUp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,92 +1,92 @@
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
export function useAspectRatio() {
|
||||
const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { tracks } = useTimelineStore();
|
||||
|
||||
// Find the current preset based on canvas size
|
||||
const currentPreset = canvasPresets.find(
|
||||
(preset) =>
|
||||
preset.width === canvasSize.width && preset.height === canvasSize.height
|
||||
);
|
||||
|
||||
// Get the original aspect ratio from the first video/image in timeline
|
||||
const getOriginalAspectRatio = (): number => {
|
||||
// Find first video or image in timeline
|
||||
for (const track of tracks) {
|
||||
for (const element of track.elements) {
|
||||
if (element.type === "media") {
|
||||
const mediaItem = mediaItems.find(
|
||||
(item) => item.id === element.mediaId
|
||||
);
|
||||
if (
|
||||
mediaItem &&
|
||||
(mediaItem.type === "video" || mediaItem.type === "image")
|
||||
) {
|
||||
return getMediaAspectRatio(mediaItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 16 / 9; // Default aspect ratio
|
||||
};
|
||||
|
||||
// Get current aspect ratio
|
||||
const getCurrentAspectRatio = (): number => {
|
||||
return canvasSize.width / canvasSize.height;
|
||||
};
|
||||
|
||||
// Format aspect ratio as a readable string
|
||||
const formatAspectRatio = (aspectRatio: number): string => {
|
||||
// Check if it matches a common aspect ratio
|
||||
const ratios = [
|
||||
{ ratio: 16 / 9, label: "16:9" },
|
||||
{ ratio: 9 / 16, label: "9:16" },
|
||||
{ ratio: 1, label: "1:1" },
|
||||
{ ratio: 4 / 3, label: "4:3" },
|
||||
{ ratio: 3 / 4, label: "3:4" },
|
||||
{ ratio: 21 / 9, label: "21:9" },
|
||||
];
|
||||
|
||||
for (const { ratio, label } of ratios) {
|
||||
if (Math.abs(aspectRatio - ratio) < 0.01) {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
// If not a common ratio, format as decimal
|
||||
return aspectRatio.toFixed(2);
|
||||
};
|
||||
|
||||
// Check if current mode is "Original"
|
||||
const isOriginal = canvasMode === "original";
|
||||
|
||||
// Get display name for current aspect ratio
|
||||
const getDisplayName = (): string => {
|
||||
// If explicitly set to original mode, always show "Original"
|
||||
if (canvasMode === "original") {
|
||||
return "Original";
|
||||
}
|
||||
|
||||
if (currentPreset) {
|
||||
return currentPreset.name;
|
||||
}
|
||||
|
||||
return formatAspectRatio(getCurrentAspectRatio());
|
||||
};
|
||||
|
||||
return {
|
||||
currentPreset,
|
||||
canvasMode,
|
||||
isOriginal,
|
||||
getCurrentAspectRatio,
|
||||
getOriginalAspectRatio,
|
||||
formatAspectRatio,
|
||||
getDisplayName,
|
||||
canvasSize,
|
||||
canvasPresets,
|
||||
};
|
||||
}
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
export function useAspectRatio() {
|
||||
const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { tracks } = useTimelineStore();
|
||||
|
||||
// Find the current preset based on canvas size
|
||||
const currentPreset = canvasPresets.find(
|
||||
(preset) =>
|
||||
preset.width === canvasSize.width && preset.height === canvasSize.height
|
||||
);
|
||||
|
||||
// Get the original aspect ratio from the first video/image in timeline
|
||||
const getOriginalAspectRatio = (): number => {
|
||||
// Find first video or image in timeline
|
||||
for (const track of tracks) {
|
||||
for (const element of track.elements) {
|
||||
if (element.type === "media") {
|
||||
const mediaItem = mediaItems.find(
|
||||
(item) => item.id === element.mediaId
|
||||
);
|
||||
if (
|
||||
mediaItem &&
|
||||
(mediaItem.type === "video" || mediaItem.type === "image")
|
||||
) {
|
||||
return getMediaAspectRatio(mediaItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 16 / 9; // Default aspect ratio
|
||||
};
|
||||
|
||||
// Get current aspect ratio
|
||||
const getCurrentAspectRatio = (): number => {
|
||||
return canvasSize.width / canvasSize.height;
|
||||
};
|
||||
|
||||
// Format aspect ratio as a readable string
|
||||
const formatAspectRatio = (aspectRatio: number): string => {
|
||||
// Check if it matches a common aspect ratio
|
||||
const ratios = [
|
||||
{ ratio: 16 / 9, label: "16:9" },
|
||||
{ ratio: 9 / 16, label: "9:16" },
|
||||
{ ratio: 1, label: "1:1" },
|
||||
{ ratio: 4 / 3, label: "4:3" },
|
||||
{ ratio: 3 / 4, label: "3:4" },
|
||||
{ ratio: 21 / 9, label: "21:9" },
|
||||
];
|
||||
|
||||
for (const { ratio, label } of ratios) {
|
||||
if (Math.abs(aspectRatio - ratio) < 0.01) {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
// If not a common ratio, format as decimal
|
||||
return aspectRatio.toFixed(2);
|
||||
};
|
||||
|
||||
// Check if current mode is "Original"
|
||||
const isOriginal = canvasMode === "original";
|
||||
|
||||
// Get display name for current aspect ratio
|
||||
const getDisplayName = (): string => {
|
||||
// If explicitly set to original mode, always show "Original"
|
||||
if (canvasMode === "original") {
|
||||
return "Original";
|
||||
}
|
||||
|
||||
if (currentPreset) {
|
||||
return currentPreset.name;
|
||||
}
|
||||
|
||||
return formatAspectRatio(getCurrentAspectRatio());
|
||||
};
|
||||
|
||||
return {
|
||||
currentPreset,
|
||||
canvasMode,
|
||||
isOriginal,
|
||||
getCurrentAspectRatio,
|
||||
getOriginalAspectRatio,
|
||||
formatAspectRatio,
|
||||
getDisplayName,
|
||||
canvasSize,
|
||||
canvasPresets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
interface UseDragDropOptions {
|
||||
onDrop?: (files: FileList) => void;
|
||||
}
|
||||
|
||||
// Helper function to check if drag contains files from external sources (not internal app drags)
|
||||
const containsFiles = (dataTransfer: DataTransfer): boolean => {
|
||||
// Check if this is an internal app drag (media item)
|
||||
if (dataTransfer.types.includes("application/x-media-item")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only show overlay for external file drags
|
||||
return dataTransfer.types.includes("Files");
|
||||
};
|
||||
|
||||
export function useDragDrop(options: UseDragDropOptions = {}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle external file drags, not internal app element drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current += 1;
|
||||
if (!isDragOver) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle file drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle file drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
// Only handle file drops
|
||||
if (
|
||||
options.onDrop &&
|
||||
e.dataTransfer.files &&
|
||||
containsFiles(e.dataTransfer)
|
||||
) {
|
||||
options.onDrop(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const dragProps = {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
};
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dragProps,
|
||||
};
|
||||
}
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
interface UseDragDropOptions {
|
||||
onDrop?: (files: FileList) => void;
|
||||
}
|
||||
|
||||
// Helper function to check if drag contains files from external sources (not internal app drags)
|
||||
const containsFiles = (dataTransfer: DataTransfer): boolean => {
|
||||
// Check if this is an internal app drag (media item)
|
||||
if (dataTransfer.types.includes("application/x-media-item")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only show overlay for external file drags
|
||||
return dataTransfer.types.includes("Files");
|
||||
};
|
||||
|
||||
export function useDragDrop(options: UseDragDropOptions = {}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle external file drags, not internal app element drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current += 1;
|
||||
if (!isDragOver) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle file drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle file drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
// Only handle file drops
|
||||
if (
|
||||
options.onDrop &&
|
||||
e.dataTransfer.files &&
|
||||
containsFiles(e.dataTransfer)
|
||||
) {
|
||||
options.onDrop(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const dragProps = {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
};
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dragProps,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function useKeybindingsListener() {
|
||||
ev.preventDefault();
|
||||
|
||||
// Handle actions with default arguments
|
||||
let actionArgs: any = undefined;
|
||||
let actionArgs: any;
|
||||
|
||||
if (boundAction === "seek-forward") {
|
||||
actionArgs = { seconds: 1 };
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile
|
||||
return !!isMobile;
|
||||
}
|
||||
|
||||
@@ -1,199 +1,199 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface UseSelectionBoxProps {
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
playheadRef?: React.RefObject<HTMLElement>;
|
||||
onSelectionComplete: (
|
||||
elements: { trackId: string; elementId: string }[]
|
||||
) => void;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectionBoxState {
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function useSelectionBox({
|
||||
containerRef,
|
||||
playheadRef,
|
||||
onSelectionComplete,
|
||||
isEnabled = true,
|
||||
}: UseSelectionBoxProps) {
|
||||
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
|
||||
null
|
||||
);
|
||||
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
|
||||
|
||||
// Mouse down handler to start selection
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
// Only start selection on empty space clicks
|
||||
if ((e.target as HTMLElement).closest(".timeline-element")) {
|
||||
return;
|
||||
}
|
||||
if (playheadRef?.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
|
||||
return;
|
||||
}
|
||||
// Don't start selection when clicking in the ruler area - this interferes with playhead dragging
|
||||
if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionBox({
|
||||
startPos: { x: e.clientX, y: e.clientY },
|
||||
currentPos: { x: e.clientX, y: e.clientY },
|
||||
isActive: false, // Will become active when mouse moves
|
||||
});
|
||||
},
|
||||
[isEnabled, playheadRef]
|
||||
);
|
||||
|
||||
// Function to select elements within the selection box
|
||||
const selectElementsInBox = useCallback(
|
||||
(startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculate selection rectangle in container coordinates
|
||||
const startX = startPos.x - containerRect.left;
|
||||
const startY = startPos.y - containerRect.top;
|
||||
const endX = endPos.x - containerRect.left;
|
||||
const endY = endPos.y - containerRect.top;
|
||||
|
||||
const selectionRect = {
|
||||
left: Math.min(startX, endX),
|
||||
top: Math.min(startY, endY),
|
||||
right: Math.max(startX, endX),
|
||||
bottom: Math.max(startY, endY),
|
||||
};
|
||||
|
||||
// Find all timeline elements within the selection rectangle
|
||||
const timelineElements = container.querySelectorAll(".timeline-element");
|
||||
|
||||
const selectedElements: { trackId: string; elementId: string }[] = [];
|
||||
|
||||
timelineElements.forEach((element) => {
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
// Use absolute coordinates for more accurate intersection detection
|
||||
const elementAbsolute = {
|
||||
left: elementRect.left,
|
||||
top: elementRect.top,
|
||||
right: elementRect.right,
|
||||
bottom: elementRect.bottom,
|
||||
};
|
||||
|
||||
const selectionAbsolute = {
|
||||
left: startPos.x,
|
||||
top: startPos.y,
|
||||
right: endPos.x,
|
||||
bottom: endPos.y,
|
||||
};
|
||||
|
||||
// Normalize selection rectangle (handle dragging in any direction)
|
||||
const normalizedSelection = {
|
||||
left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
|
||||
top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
|
||||
right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
|
||||
bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
|
||||
};
|
||||
|
||||
const elementId = element.getAttribute("data-element-id");
|
||||
const trackId = element.getAttribute("data-track-id");
|
||||
|
||||
// Check if element intersects with selection rectangle (any overlap)
|
||||
// Using absolute coordinates for more precise detection
|
||||
const intersects = !(
|
||||
elementAbsolute.right < normalizedSelection.left ||
|
||||
elementAbsolute.left > normalizedSelection.right ||
|
||||
elementAbsolute.bottom < normalizedSelection.top ||
|
||||
elementAbsolute.top > normalizedSelection.bottom
|
||||
);
|
||||
|
||||
if (intersects && elementId && trackId) {
|
||||
selectedElements.push({ trackId, elementId });
|
||||
}
|
||||
});
|
||||
|
||||
// Always call the callback - with elements or empty array to clear selection
|
||||
console.log(
|
||||
JSON.stringify({ selectElementsInBox: selectedElements.length })
|
||||
);
|
||||
onSelectionComplete(selectedElements);
|
||||
},
|
||||
[containerRef, onSelectionComplete]
|
||||
);
|
||||
|
||||
// Effect to track selection box movement
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
|
||||
const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
|
||||
|
||||
// Start selection if mouse moved more than 5px
|
||||
const shouldActivate = deltaX > 5 || deltaY > 5;
|
||||
|
||||
const newSelectionBox = {
|
||||
...selectionBox,
|
||||
currentPos: { x: e.clientX, y: e.clientY },
|
||||
isActive: shouldActivate || selectionBox.isActive,
|
||||
};
|
||||
|
||||
setSelectionBox(newSelectionBox);
|
||||
|
||||
// Real-time visual feedback: update selection as we drag
|
||||
if (newSelectionBox.isActive) {
|
||||
selectElementsInBox(
|
||||
newSelectionBox.startPos,
|
||||
newSelectionBox.currentPos
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
console.log(
|
||||
JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
|
||||
);
|
||||
|
||||
// If we had an active selection, mark that we just finished selecting
|
||||
if (selectionBox?.isActive) {
|
||||
console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
|
||||
setJustFinishedSelecting(true);
|
||||
// Clear the flag after a short delay to allow click events to check it
|
||||
setTimeout(() => {
|
||||
console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
|
||||
setJustFinishedSelecting(false);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Don't call selectElementsInBox again - real-time selection already handled it
|
||||
// Just clean up the selection box visual
|
||||
setSelectionBox(null);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [selectionBox, selectElementsInBox]);
|
||||
|
||||
return {
|
||||
selectionBox,
|
||||
handleMouseDown,
|
||||
isSelecting: selectionBox?.isActive || false,
|
||||
justFinishedSelecting,
|
||||
};
|
||||
}
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface UseSelectionBoxProps {
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
playheadRef?: React.RefObject<HTMLElement>;
|
||||
onSelectionComplete: (
|
||||
elements: { trackId: string; elementId: string }[]
|
||||
) => void;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectionBoxState {
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function useSelectionBox({
|
||||
containerRef,
|
||||
playheadRef,
|
||||
onSelectionComplete,
|
||||
isEnabled = true,
|
||||
}: UseSelectionBoxProps) {
|
||||
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
|
||||
null
|
||||
);
|
||||
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
|
||||
|
||||
// Mouse down handler to start selection
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
// Only start selection on empty space clicks
|
||||
if ((e.target as HTMLElement).closest(".timeline-element")) {
|
||||
return;
|
||||
}
|
||||
if (playheadRef?.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
|
||||
return;
|
||||
}
|
||||
// Don't start selection when clicking in the ruler area - this interferes with playhead dragging
|
||||
if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionBox({
|
||||
startPos: { x: e.clientX, y: e.clientY },
|
||||
currentPos: { x: e.clientX, y: e.clientY },
|
||||
isActive: false, // Will become active when mouse moves
|
||||
});
|
||||
},
|
||||
[isEnabled, playheadRef]
|
||||
);
|
||||
|
||||
// Function to select elements within the selection box
|
||||
const selectElementsInBox = useCallback(
|
||||
(startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculate selection rectangle in container coordinates
|
||||
const startX = startPos.x - containerRect.left;
|
||||
const startY = startPos.y - containerRect.top;
|
||||
const endX = endPos.x - containerRect.left;
|
||||
const endY = endPos.y - containerRect.top;
|
||||
|
||||
const selectionRect = {
|
||||
left: Math.min(startX, endX),
|
||||
top: Math.min(startY, endY),
|
||||
right: Math.max(startX, endX),
|
||||
bottom: Math.max(startY, endY),
|
||||
};
|
||||
|
||||
// Find all timeline elements within the selection rectangle
|
||||
const timelineElements = container.querySelectorAll(".timeline-element");
|
||||
|
||||
const selectedElements: { trackId: string; elementId: string }[] = [];
|
||||
|
||||
timelineElements.forEach((element) => {
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
// Use absolute coordinates for more accurate intersection detection
|
||||
const elementAbsolute = {
|
||||
left: elementRect.left,
|
||||
top: elementRect.top,
|
||||
right: elementRect.right,
|
||||
bottom: elementRect.bottom,
|
||||
};
|
||||
|
||||
const selectionAbsolute = {
|
||||
left: startPos.x,
|
||||
top: startPos.y,
|
||||
right: endPos.x,
|
||||
bottom: endPos.y,
|
||||
};
|
||||
|
||||
// Normalize selection rectangle (handle dragging in any direction)
|
||||
const normalizedSelection = {
|
||||
left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
|
||||
top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
|
||||
right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
|
||||
bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
|
||||
};
|
||||
|
||||
const elementId = element.getAttribute("data-element-id");
|
||||
const trackId = element.getAttribute("data-track-id");
|
||||
|
||||
// Check if element intersects with selection rectangle (any overlap)
|
||||
// Using absolute coordinates for more precise detection
|
||||
const intersects = !(
|
||||
elementAbsolute.right < normalizedSelection.left ||
|
||||
elementAbsolute.left > normalizedSelection.right ||
|
||||
elementAbsolute.bottom < normalizedSelection.top ||
|
||||
elementAbsolute.top > normalizedSelection.bottom
|
||||
);
|
||||
|
||||
if (intersects && elementId && trackId) {
|
||||
selectedElements.push({ trackId, elementId });
|
||||
}
|
||||
});
|
||||
|
||||
// Always call the callback - with elements or empty array to clear selection
|
||||
console.log(
|
||||
JSON.stringify({ selectElementsInBox: selectedElements.length })
|
||||
);
|
||||
onSelectionComplete(selectedElements);
|
||||
},
|
||||
[containerRef, onSelectionComplete]
|
||||
);
|
||||
|
||||
// Effect to track selection box movement
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
|
||||
const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
|
||||
|
||||
// Start selection if mouse moved more than 5px
|
||||
const shouldActivate = deltaX > 5 || deltaY > 5;
|
||||
|
||||
const newSelectionBox = {
|
||||
...selectionBox,
|
||||
currentPos: { x: e.clientX, y: e.clientY },
|
||||
isActive: shouldActivate || selectionBox.isActive,
|
||||
};
|
||||
|
||||
setSelectionBox(newSelectionBox);
|
||||
|
||||
// Real-time visual feedback: update selection as we drag
|
||||
if (newSelectionBox.isActive) {
|
||||
selectElementsInBox(
|
||||
newSelectionBox.startPos,
|
||||
newSelectionBox.currentPos
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
console.log(
|
||||
JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
|
||||
);
|
||||
|
||||
// If we had an active selection, mark that we just finished selecting
|
||||
if (selectionBox?.isActive) {
|
||||
console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
|
||||
setJustFinishedSelecting(true);
|
||||
// Clear the flag after a short delay to allow click events to check it
|
||||
setTimeout(() => {
|
||||
console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
|
||||
setJustFinishedSelecting(false);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Don't call selectElementsInBox again - real-time selection already handled it
|
||||
// Just clean up the selection box visual
|
||||
setSelectionBox(null);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [selectionBox, selectElementsInBox]);
|
||||
|
||||
return {
|
||||
selectionBox,
|
||||
handleMouseDown,
|
||||
isSelecting: selectionBox?.isActive || false,
|
||||
justFinishedSelecting,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,157 +1,157 @@
|
||||
import { snapTimeToFrame } from "@/constants/timeline-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
seek: (time: number) => void;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: UseTimelinePlayheadProps) {
|
||||
// Playhead scrubbing state
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
|
||||
// Ruler drag detection state
|
||||
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
|
||||
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
|
||||
|
||||
const playheadPosition =
|
||||
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
|
||||
|
||||
// --- Playhead Scrubbing Handlers ---
|
||||
const handlePlayheadMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ruler drag from triggering
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
},
|
||||
[duration, zoomLevel]
|
||||
);
|
||||
|
||||
// Ruler mouse down handler
|
||||
const handleRulerMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only handle left mouse button
|
||||
if (e.button !== 0) return;
|
||||
|
||||
// Don't interfere if clicking on the playhead itself
|
||||
if (playheadRef?.current?.contains(e.target as Node)) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDraggingRuler(true);
|
||||
setHasDraggedRuler(false);
|
||||
|
||||
// Start scrubbing immediately
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
},
|
||||
[duration, zoomLevel]
|
||||
);
|
||||
|
||||
const handleScrub = useCallback(
|
||||
(e: MouseEvent | React.MouseEvent) => {
|
||||
const ruler = rulerRef.current;
|
||||
if (!ruler) return;
|
||||
const rect = ruler.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
|
||||
// Use frame snapping for playhead scrubbing
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const time = snapTimeToFrame(rawTime, projectFps);
|
||||
setScrubTime(time);
|
||||
seek(time); // update video preview in real time
|
||||
},
|
||||
[duration, zoomLevel, seek, rulerRef]
|
||||
);
|
||||
|
||||
// Mouse move/up event handlers
|
||||
useEffect(() => {
|
||||
if (!isScrubbing) return;
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
handleScrub(e);
|
||||
// Mark that we've dragged if ruler drag is active
|
||||
if (isDraggingRuler) {
|
||||
setHasDraggedRuler(true);
|
||||
}
|
||||
};
|
||||
const onMouseUp = (e: MouseEvent) => {
|
||||
setIsScrubbing(false);
|
||||
if (scrubTime !== null) seek(scrubTime); // finalize seek
|
||||
setScrubTime(null);
|
||||
|
||||
// Handle ruler click vs drag
|
||||
if (isDraggingRuler) {
|
||||
setIsDraggingRuler(false);
|
||||
// If we didn't drag, treat it as a click-to-seek
|
||||
if (!hasDraggedRuler) {
|
||||
handleScrub(e);
|
||||
}
|
||||
setHasDraggedRuler(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [
|
||||
isScrubbing,
|
||||
scrubTime,
|
||||
seek,
|
||||
handleScrub,
|
||||
isDraggingRuler,
|
||||
hasDraggedRuler,
|
||||
]);
|
||||
|
||||
// --- Playhead auto-scroll effect ---
|
||||
useEffect(() => {
|
||||
const rulerViewport = rulerScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
const tracksViewport = tracksScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMin = 0;
|
||||
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
|
||||
// Center the playhead if it's not visible (100px buffer)
|
||||
const desiredScroll = Math.max(
|
||||
scrollMin,
|
||||
Math.min(scrollMax, playheadPx - viewportWidth / 2)
|
||||
);
|
||||
if (
|
||||
playheadPx < rulerViewport.scrollLeft + 100 ||
|
||||
playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
|
||||
) {
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||
}
|
||||
}, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
|
||||
|
||||
return {
|
||||
playheadPosition,
|
||||
handlePlayheadMouseDown,
|
||||
handleRulerMouseDown,
|
||||
isDraggingRuler,
|
||||
};
|
||||
}
|
||||
import { snapTimeToFrame } from "@/constants/timeline-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
seek: (time: number) => void;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: UseTimelinePlayheadProps) {
|
||||
// Playhead scrubbing state
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
|
||||
// Ruler drag detection state
|
||||
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
|
||||
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
|
||||
|
||||
const playheadPosition =
|
||||
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
|
||||
|
||||
// --- Playhead Scrubbing Handlers ---
|
||||
const handlePlayheadMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ruler drag from triggering
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
},
|
||||
[duration, zoomLevel]
|
||||
);
|
||||
|
||||
// Ruler mouse down handler
|
||||
const handleRulerMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only handle left mouse button
|
||||
if (e.button !== 0) return;
|
||||
|
||||
// Don't interfere if clicking on the playhead itself
|
||||
if (playheadRef?.current?.contains(e.target as Node)) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDraggingRuler(true);
|
||||
setHasDraggedRuler(false);
|
||||
|
||||
// Start scrubbing immediately
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
},
|
||||
[duration, zoomLevel]
|
||||
);
|
||||
|
||||
const handleScrub = useCallback(
|
||||
(e: MouseEvent | React.MouseEvent) => {
|
||||
const ruler = rulerRef.current;
|
||||
if (!ruler) return;
|
||||
const rect = ruler.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
|
||||
// Use frame snapping for playhead scrubbing
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const time = snapTimeToFrame(rawTime, projectFps);
|
||||
setScrubTime(time);
|
||||
seek(time); // update video preview in real time
|
||||
},
|
||||
[duration, zoomLevel, seek, rulerRef]
|
||||
);
|
||||
|
||||
// Mouse move/up event handlers
|
||||
useEffect(() => {
|
||||
if (!isScrubbing) return;
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
handleScrub(e);
|
||||
// Mark that we've dragged if ruler drag is active
|
||||
if (isDraggingRuler) {
|
||||
setHasDraggedRuler(true);
|
||||
}
|
||||
};
|
||||
const onMouseUp = (e: MouseEvent) => {
|
||||
setIsScrubbing(false);
|
||||
if (scrubTime !== null) seek(scrubTime); // finalize seek
|
||||
setScrubTime(null);
|
||||
|
||||
// Handle ruler click vs drag
|
||||
if (isDraggingRuler) {
|
||||
setIsDraggingRuler(false);
|
||||
// If we didn't drag, treat it as a click-to-seek
|
||||
if (!hasDraggedRuler) {
|
||||
handleScrub(e);
|
||||
}
|
||||
setHasDraggedRuler(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [
|
||||
isScrubbing,
|
||||
scrubTime,
|
||||
seek,
|
||||
handleScrub,
|
||||
isDraggingRuler,
|
||||
hasDraggedRuler,
|
||||
]);
|
||||
|
||||
// --- Playhead auto-scroll effect ---
|
||||
useEffect(() => {
|
||||
const rulerViewport = rulerScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
const tracksViewport = tracksScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMin = 0;
|
||||
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
|
||||
// Center the playhead if it's not visible (100px buffer)
|
||||
const desiredScroll = Math.max(
|
||||
scrollMin,
|
||||
Math.min(scrollMax, playheadPx - viewportWidth / 2)
|
||||
);
|
||||
if (
|
||||
playheadPx < rulerViewport.scrollLeft + 100 ||
|
||||
playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
|
||||
) {
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||
}
|
||||
}, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
|
||||
|
||||
return {
|
||||
playheadPosition,
|
||||
handlePlayheadMouseDown,
|
||||
handleRulerMouseDown,
|
||||
isDraggingRuler,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
import { useState, useCallback, useEffect, RefObject } from "react";
|
||||
|
||||
interface UseTimelineZoomProps {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
isInTimeline?: boolean;
|
||||
}
|
||||
|
||||
interface UseTimelineZoomReturn {
|
||||
zoomLevel: number;
|
||||
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
}
|
||||
|
||||
export function useTimelineZoom({
|
||||
containerRef,
|
||||
isInTimeline = false,
|
||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.15 : 0.15;
|
||||
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
|
||||
}
|
||||
// For horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||
// let the event bubble up to allow ScrollArea to handle it
|
||||
else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
// Don't prevent default - let ScrollArea handle horizontal scrolling
|
||||
return;
|
||||
}
|
||||
// Otherwise, allow normal scrolling
|
||||
}, []);
|
||||
|
||||
// Prevent browser zooming in/out when in timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = (e: WheelEvent) => {
|
||||
if (
|
||||
isInTimeline &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
containerRef.current?.contains(e.target as Node)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", preventZoom, { passive: false });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom);
|
||||
};
|
||||
}, [isInTimeline, containerRef]);
|
||||
|
||||
return {
|
||||
zoomLevel,
|
||||
setZoomLevel,
|
||||
handleWheel,
|
||||
};
|
||||
}
|
||||
import { useState, useCallback, useEffect, RefObject } from "react";
|
||||
|
||||
interface UseTimelineZoomProps {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
isInTimeline?: boolean;
|
||||
}
|
||||
|
||||
interface UseTimelineZoomReturn {
|
||||
zoomLevel: number;
|
||||
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
}
|
||||
|
||||
export function useTimelineZoom({
|
||||
containerRef,
|
||||
isInTimeline = false,
|
||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.15 : 0.15;
|
||||
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
|
||||
}
|
||||
// For horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||
// let the event bubble up to allow ScrollArea to handle it
|
||||
else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
// Don't prevent default - let ScrollArea handle horizontal scrolling
|
||||
return;
|
||||
}
|
||||
// Otherwise, allow normal scrolling
|
||||
}, []);
|
||||
|
||||
// Prevent browser zooming in/out when in timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = (e: WheelEvent) => {
|
||||
if (
|
||||
isInTimeline &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
containerRef.current?.contains(e.target as Node)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", preventZoom, { passive: false });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom);
|
||||
};
|
||||
}, [isInTimeline, containerRef]);
|
||||
|
||||
return {
|
||||
zoomLevel,
|
||||
setZoomLevel,
|
||||
handleWheel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as React from "react";
|
||||
import type { ToastActionElement, ToastProps } from "../components/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
const TOAST_REMOVE_DELAY = 1_000_000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
@@ -64,7 +64,7 @@ const addToRemoveQueue = (toastId: string) => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
@@ -162,7 +162,7 @@ function toast({ ...props }: Toast) {
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user