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:
Hayden Bleasel
2025-07-25 00:41:34 +03:00
committed by GitHub
parent 7b840e9b4e
commit 5931ddb77a
113 changed files with 8033 additions and 7070 deletions
+46 -46
View File
@@ -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,
};
}
+51 -51
View File
@@ -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,
};
}
+92 -92
View File
@@ -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,
};
}
+85 -85
View File
@@ -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,
};
}
+1 -1
View File
@@ -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 };
+13 -11
View File
@@ -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;
}
+199 -199
View File
@@ -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,
};
}
+157 -157
View File
@@ -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,
};
}
+60 -60
View File
@@ -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,
};
}
+3 -3
View File
@@ -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,
};