summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/hooks')
-rw-r--r--apps/web/src/hooks/auth/useLogin.ts92
-rw-r--r--apps/web/src/hooks/auth/useSignUp.ts102
-rw-r--r--apps/web/src/hooks/use-aspect-ratio.ts184
-rw-r--r--apps/web/src/hooks/use-drag-drop.ts170
-rw-r--r--apps/web/src/hooks/use-editor-actions.ts254
-rw-r--r--apps/web/src/hooks/use-keybinding-conflicts.ts80
-rw-r--r--apps/web/src/hooks/use-keybindings.ts68
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts-help.ts206
-rw-r--r--apps/web/src/hooks/use-mobile.tsx28
-rw-r--r--apps/web/src/hooks/use-playback-controls.ts158
-rw-r--r--apps/web/src/hooks/use-selection-box.ts398
-rw-r--r--apps/web/src/hooks/use-timeline-element-resize.ts330
-rw-r--r--apps/web/src/hooks/use-timeline-playhead.ts314
-rw-r--r--apps/web/src/hooks/use-timeline-snapping.ts292
-rw-r--r--apps/web/src/hooks/use-timeline-zoom.ts108
-rw-r--r--apps/web/src/hooks/use-toast.ts256
16 files changed, 1521 insertions, 1519 deletions
diff --git a/apps/web/src/hooks/auth/useLogin.ts b/apps/web/src/hooks/auth/useLogin.ts
index 12eddcbf..a3942506 100644
--- a/apps/web/src/hooks/auth/useLogin.ts
+++ b/apps/web/src/hooks/auth/useLogin.ts
@@ -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,
+ };
}
diff --git a/apps/web/src/hooks/auth/useSignUp.ts b/apps/web/src/hooks/auth/useSignUp.ts
index 4cd2850a..fae7c967 100644
--- a/apps/web/src/hooks/auth/useSignUp.ts
+++ b/apps/web/src/hooks/auth/useSignUp.ts
@@ -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,
- };
-} \ No newline at end of file
+ return {
+ name,
+ setName,
+ email,
+ setEmail,
+ password,
+ setPassword,
+ error,
+ isEmailLoading,
+ isGoogleLoading,
+ isAnyLoading,
+ handleSignUp,
+ handleGoogleSignUp,
+ };
+}
diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts
index 40de8454..b297f6b5 100644
--- a/apps/web/src/hooks/use-aspect-ratio.ts
+++ b/apps/web/src/hooks/use-aspect-ratio.ts
@@ -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,
+ };
+}
diff --git a/apps/web/src/hooks/use-drag-drop.ts b/apps/web/src/hooks/use-drag-drop.ts
index eaab89d8..768014ec 100644
--- a/apps/web/src/hooks/use-drag-drop.ts
+++ b/apps/web/src/hooks/use-drag-drop.ts
@@ -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,
+ };
+}
diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts
index e0d9b9eb..3a47dcee 100644
--- a/apps/web/src/hooks/use-editor-actions.ts
+++ b/apps/web/src/hooks/use-editor-actions.ts
@@ -8,155 +8,155 @@ import { useProjectStore } from "@/stores/project-store";
import { toast } from "sonner";
export function useEditorActions() {
- const {
- tracks,
- selectedElements,
- clearSelectedElements,
- setSelectedElements,
- removeElementFromTrack,
- splitElement,
- addElementToTrack,
- snappingEnabled,
- toggleSnapping,
- undo,
- redo,
- } = useTimelineStore();
+ const {
+ tracks,
+ selectedElements,
+ clearSelectedElements,
+ setSelectedElements,
+ removeElementFromTrack,
+ splitElement,
+ addElementToTrack,
+ snappingEnabled,
+ toggleSnapping,
+ undo,
+ redo,
+ } = useTimelineStore();
- const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
- const { activeProject } = useProjectStore();
+ const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
+ const { activeProject } = useProjectStore();
- // Playback actions
- useActionHandler("toggle-play", () => {
- toggle();
- });
+ // Playback actions
+ useActionHandler("toggle-play", () => {
+ toggle();
+ });
- useActionHandler("stop-playback", () => {
- if (isPlaying) {
- toggle();
- }
- seek(0);
- });
+ useActionHandler("stop-playback", () => {
+ if (isPlaying) {
+ toggle();
+ }
+ seek(0);
+ });
- useActionHandler("seek-forward", (args) => {
- const seconds = args?.seconds ?? 1;
- seek(Math.min(duration, currentTime + seconds));
- });
+ useActionHandler("seek-forward", (args) => {
+ const seconds = args?.seconds ?? 1;
+ seek(Math.min(duration, currentTime + seconds));
+ });
- useActionHandler("seek-backward", (args) => {
- const seconds = args?.seconds ?? 1;
- seek(Math.max(0, currentTime - seconds));
- });
+ useActionHandler("seek-backward", (args) => {
+ const seconds = args?.seconds ?? 1;
+ seek(Math.max(0, currentTime - seconds));
+ });
- useActionHandler("frame-step-forward", () => {
- const projectFps = activeProject?.fps || 30;
- seek(Math.min(duration, currentTime + 1 / projectFps));
- });
+ useActionHandler("frame-step-forward", () => {
+ const projectFps = activeProject?.fps || 30;
+ seek(Math.min(duration, currentTime + 1 / projectFps));
+ });
- useActionHandler("frame-step-backward", () => {
- const projectFps = activeProject?.fps || 30;
- seek(Math.max(0, currentTime - 1 / projectFps));
- });
+ useActionHandler("frame-step-backward", () => {
+ const projectFps = activeProject?.fps || 30;
+ seek(Math.max(0, currentTime - 1 / projectFps));
+ });
- useActionHandler("jump-forward", (args) => {
- const seconds = args?.seconds ?? 5;
- seek(Math.min(duration, currentTime + seconds));
- });
+ useActionHandler("jump-forward", (args) => {
+ const seconds = args?.seconds ?? 5;
+ seek(Math.min(duration, currentTime + seconds));
+ });
- useActionHandler("jump-backward", (args) => {
- const seconds = args?.seconds ?? 5;
- seek(Math.max(0, currentTime - seconds));
- });
+ useActionHandler("jump-backward", (args) => {
+ const seconds = args?.seconds ?? 5;
+ seek(Math.max(0, currentTime - seconds));
+ });
- useActionHandler("goto-start", () => {
- seek(0);
- });
+ useActionHandler("goto-start", () => {
+ seek(0);
+ });
- useActionHandler("goto-end", () => {
- seek(duration);
- });
+ useActionHandler("goto-end", () => {
+ seek(duration);
+ });
- // Timeline editing actions
- useActionHandler("split-element", () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element to split");
- return;
- }
+ // Timeline editing actions
+ useActionHandler("split-element", () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element to split");
+ return;
+ }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t: any) => t.id === trackId);
- const element = track?.elements.find((el: any) => el.id === elementId);
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t: any) => t.id === trackId);
+ const element = track?.elements.find((el: any) => el.id === elementId);
- if (element) {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ if (element) {
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- splitElement(trackId, elementId, currentTime);
- } else {
- toast.error("Playhead must be within selected element");
- }
- }
- });
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ splitElement(trackId, elementId, currentTime);
+ } else {
+ toast.error("Playhead must be within selected element");
+ }
+ }
+ });
- useActionHandler("delete-selected", () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- selectedElements.forEach(
- ({ trackId, elementId }: { trackId: string; elementId: string }) => {
- removeElementFromTrack(trackId, elementId);
- }
- );
- clearSelectedElements();
- });
+ useActionHandler("delete-selected", () => {
+ if (selectedElements.length === 0) {
+ toast.error("No elements selected");
+ return;
+ }
+ selectedElements.forEach(
+ ({ trackId, elementId }: { trackId: string; elementId: string }) => {
+ removeElementFromTrack(trackId, elementId);
+ },
+ );
+ clearSelectedElements();
+ });
- useActionHandler("select-all", () => {
- const allElements = tracks.flatMap((track: any) =>
- track.elements.map((element: any) => ({
- trackId: track.id,
- elementId: element.id,
- }))
- );
- setSelectedElements(allElements);
- });
+ useActionHandler("select-all", () => {
+ const allElements = tracks.flatMap((track: any) =>
+ track.elements.map((element: any) => ({
+ trackId: track.id,
+ elementId: element.id,
+ })),
+ );
+ setSelectedElements(allElements);
+ });
- useActionHandler("duplicate-selected", () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element to duplicate");
- return;
- }
+ useActionHandler("duplicate-selected", () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element to duplicate");
+ return;
+ }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t: any) => t.id === trackId);
- const element = track?.elements.find((el: any) => el.id === elementId);
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t: any) => t.id === trackId);
+ const element = track?.elements.find((el: any) => el.id === elementId);
- if (element) {
- const newStartTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1;
- const { id, ...elementWithoutId } = element;
+ if (element) {
+ const newStartTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1;
+ const { id, ...elementWithoutId } = element;
- addElementToTrack(trackId, {
- ...elementWithoutId,
- startTime: newStartTime,
- });
- }
- });
+ addElementToTrack(trackId, {
+ ...elementWithoutId,
+ startTime: newStartTime,
+ });
+ }
+ });
- useActionHandler("toggle-snapping", () => {
- toggleSnapping();
- });
+ useActionHandler("toggle-snapping", () => {
+ toggleSnapping();
+ });
- // History actions
- useActionHandler("undo", () => {
- undo();
- });
+ // History actions
+ useActionHandler("undo", () => {
+ undo();
+ });
- useActionHandler("redo", () => {
- redo();
- });
+ useActionHandler("redo", () => {
+ redo();
+ });
}
diff --git a/apps/web/src/hooks/use-keybinding-conflicts.ts b/apps/web/src/hooks/use-keybinding-conflicts.ts
index cc5dd340..0720982e 100644
--- a/apps/web/src/hooks/use-keybinding-conflicts.ts
+++ b/apps/web/src/hooks/use-keybinding-conflicts.ts
@@ -5,55 +5,55 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
import { ActionWithOptionalArgs } from "@/constants/actions";
export interface KeybindingConflictInfo {
- key: string;
- actions: ActionWithOptionalArgs[];
- isConflict: boolean;
+ key: string;
+ actions: ActionWithOptionalArgs[];
+ isConflict: boolean;
}
export const useKeybindingConflicts = () => {
- const { keybindings } = useKeybindingsStore();
+ const { keybindings } = useKeybindingsStore();
- const conflicts = useMemo(() => {
- const keyToActions: Record<string, ActionWithOptionalArgs[]> = {};
- const conflictList: KeybindingConflictInfo[] = [];
+ const conflicts = useMemo(() => {
+ const keyToActions: Record<string, ActionWithOptionalArgs[]> = {};
+ const conflictList: KeybindingConflictInfo[] = [];
- // Group actions by key
- Object.entries(keybindings).forEach(([key, action]) => {
- if (!keyToActions[key]) {
- keyToActions[key] = [];
- }
- keyToActions[key].push(action);
- });
+ // Group actions by key
+ Object.entries(keybindings).forEach(([key, action]) => {
+ if (!keyToActions[key]) {
+ keyToActions[key] = [];
+ }
+ keyToActions[key].push(action);
+ });
- // Find conflicts
- Object.entries(keyToActions).forEach(([key, actions]) => {
- const uniqueActions = [...new Set(actions)];
- conflictList.push({
- key,
- actions: uniqueActions,
- isConflict: uniqueActions.length > 1,
- });
- });
+ // Find conflicts
+ Object.entries(keyToActions).forEach(([key, actions]) => {
+ const uniqueActions = [...new Set(actions)];
+ conflictList.push({
+ key,
+ actions: uniqueActions,
+ isConflict: uniqueActions.length > 1,
+ });
+ });
- return conflictList.filter((item) => item.isConflict);
- }, [keybindings]);
+ return conflictList.filter((item) => item.isConflict);
+ }, [keybindings]);
- const hasConflicts = conflicts.length > 0;
+ const hasConflicts = conflicts.length > 0;
- const getConflictsForKey = (key: string): KeybindingConflictInfo | null => {
- return conflicts.find((conflict) => conflict.key === key) || null;
- };
+ const getConflictsForKey = (key: string): KeybindingConflictInfo | null => {
+ return conflicts.find((conflict) => conflict.key === key) || null;
+ };
- const getConflictsForAction = (
- action: ActionWithOptionalArgs
- ): KeybindingConflictInfo[] => {
- return conflicts.filter((conflict) => conflict.actions.includes(action));
- };
+ const getConflictsForAction = (
+ action: ActionWithOptionalArgs,
+ ): KeybindingConflictInfo[] => {
+ return conflicts.filter((conflict) => conflict.actions.includes(action));
+ };
- return {
- conflicts,
- hasConflicts,
- getConflictsForKey,
- getConflictsForAction,
- };
+ return {
+ conflicts,
+ hasConflicts,
+ getConflictsForKey,
+ getConflictsForAction,
+ };
};
diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts
index 7887f3f1..1c001820 100644
--- a/apps/web/src/hooks/use-keybindings.ts
+++ b/apps/web/src/hooks/use-keybindings.ts
@@ -8,56 +8,56 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
* the appropriate actions based on keybindings
*/
export function useKeybindingsListener() {
- const { keybindings, getKeybindingString, keybindingsEnabled } =
- useKeybindingsStore();
+ const { keybindings, getKeybindingString, keybindingsEnabled } =
+ useKeybindingsStore();
- useEffect(() => {
- const handleKeyDown = (ev: KeyboardEvent) => {
- // Do not check keybinds if the mode is disabled
- if (!keybindingsEnabled) return;
+ useEffect(() => {
+ const handleKeyDown = (ev: KeyboardEvent) => {
+ // Do not check keybinds if the mode is disabled
+ if (!keybindingsEnabled) return;
- const binding = getKeybindingString(ev);
- if (!binding) return;
+ const binding = getKeybindingString(ev);
+ if (!binding) return;
- const boundAction = keybindings[binding];
- if (!boundAction) return;
+ const boundAction = keybindings[binding];
+ if (!boundAction) return;
- ev.preventDefault();
+ ev.preventDefault();
- // Handle actions with default arguments
- let actionArgs: any = undefined;
+ // Handle actions with default arguments
+ let actionArgs: any = undefined;
- if (boundAction === "seek-forward") {
- actionArgs = { seconds: 1 };
- } else if (boundAction === "seek-backward") {
- actionArgs = { seconds: 1 };
- } else if (boundAction === "jump-forward") {
- actionArgs = { seconds: 5 };
- } else if (boundAction === "jump-backward") {
- actionArgs = { seconds: 5 };
- }
+ if (boundAction === "seek-forward") {
+ actionArgs = { seconds: 1 };
+ } else if (boundAction === "seek-backward") {
+ actionArgs = { seconds: 1 };
+ } else if (boundAction === "jump-forward") {
+ actionArgs = { seconds: 5 };
+ } else if (boundAction === "jump-backward") {
+ actionArgs = { seconds: 5 };
+ }
- invokeAction(boundAction, actionArgs, "keypress");
- };
+ invokeAction(boundAction, actionArgs, "keypress");
+ };
- document.addEventListener("keydown", handleKeyDown);
+ document.addEventListener("keydown", handleKeyDown);
- return () => {
- document.removeEventListener("keydown", handleKeyDown);
- };
- }, [keybindings, getKeybindingString, keybindingsEnabled]);
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [keybindings, getKeybindingString, keybindingsEnabled]);
}
/**
* This composable allows for the UI component to be disabled if the component in question is mounted
*/
export function useKeybindingDisabler() {
- const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
+ const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
- return {
- disableKeybindings,
- enableKeybindings,
- };
+ return {
+ disableKeybindings,
+ enableKeybindings,
+ };
}
// Export the bindings for backward compatibility
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
index c7de8466..dd52f00b 100644
--- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
+++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
@@ -5,125 +5,125 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
import { Action } from "@/constants/actions";
export interface KeyboardShortcut {
- id: string;
- keys: string[];
- description: string;
- category: string;
- action: Action;
- icon?: React.ReactNode;
+ id: string;
+ keys: string[];
+ description: string;
+ category: string;
+ action: Action;
+ icon?: React.ReactNode;
}
// Map actions to their descriptions and categories
const actionDescriptions: Record<
- Action,
- { description: string; category: string }
+ Action,
+ { description: string; category: string }
> = {
- "toggle-play": { description: "Play/Pause", category: "Playback" },
- "stop-playback": { description: "Stop playback", category: "Playback" },
- "seek-forward": {
- description: "Seek forward 1 second",
- category: "Playback",
- },
- "seek-backward": {
- description: "Seek backward 1 second",
- category: "Playback",
- },
- "frame-step-forward": {
- description: "Frame step forward",
- category: "Navigation",
- },
- "frame-step-backward": {
- description: "Frame step backward",
- category: "Navigation",
- },
- "jump-forward": {
- description: "Jump forward 5 seconds",
- category: "Navigation",
- },
- "jump-backward": {
- description: "Jump backward 5 seconds",
- category: "Navigation",
- },
- "goto-start": { description: "Go to timeline start", category: "Navigation" },
- "goto-end": { description: "Go to timeline end", category: "Navigation" },
- "split-element": {
- description: "Split element at playhead",
- category: "Editing",
- },
- "delete-selected": {
- description: "Delete selected elements",
- category: "Editing",
- },
- "select-all": { description: "Select all elements", category: "Selection" },
- "duplicate-selected": {
- description: "Duplicate selected element",
- category: "Selection",
- },
- "toggle-snapping": { description: "Toggle snapping", category: "Editing" },
- undo: { description: "Undo", category: "History" },
- redo: { description: "Redo", category: "History" },
+ "toggle-play": { description: "Play/Pause", category: "Playback" },
+ "stop-playback": { description: "Stop playback", category: "Playback" },
+ "seek-forward": {
+ description: "Seek forward 1 second",
+ category: "Playback",
+ },
+ "seek-backward": {
+ description: "Seek backward 1 second",
+ category: "Playback",
+ },
+ "frame-step-forward": {
+ description: "Frame step forward",
+ category: "Navigation",
+ },
+ "frame-step-backward": {
+ description: "Frame step backward",
+ category: "Navigation",
+ },
+ "jump-forward": {
+ description: "Jump forward 5 seconds",
+ category: "Navigation",
+ },
+ "jump-backward": {
+ description: "Jump backward 5 seconds",
+ category: "Navigation",
+ },
+ "goto-start": { description: "Go to timeline start", category: "Navigation" },
+ "goto-end": { description: "Go to timeline end", category: "Navigation" },
+ "split-element": {
+ description: "Split element at playhead",
+ category: "Editing",
+ },
+ "delete-selected": {
+ description: "Delete selected elements",
+ category: "Editing",
+ },
+ "select-all": { description: "Select all elements", category: "Selection" },
+ "duplicate-selected": {
+ description: "Duplicate selected element",
+ category: "Selection",
+ },
+ "toggle-snapping": { description: "Toggle snapping", category: "Editing" },
+ undo: { description: "Undo", category: "History" },
+ redo: { description: "Redo", category: "History" },
};
// Convert key binding format to display format
const formatKey = (key: string): string => {
- return key
- .replace("ctrl", "Cmd")
- .replace("alt", "Alt")
- .replace("shift", "Shift")
- .replace("left", "ArrowLeft")
- .replace("right", "ArrowRight")
- .replace("up", "ArrowUp")
- .replace("down", "ArrowDown")
- .replace("space", "Space")
- .replace("home", "Home")
- .replace("end", "End")
- .replace("delete", "Delete")
- .replace("backspace", "Backspace")
- .replace("-", "+");
+ return key
+ .replace("ctrl", "Cmd")
+ .replace("alt", "Alt")
+ .replace("shift", "Shift")
+ .replace("left", "ArrowLeft")
+ .replace("right", "ArrowRight")
+ .replace("up", "ArrowUp")
+ .replace("down", "ArrowDown")
+ .replace("space", "Space")
+ .replace("home", "Home")
+ .replace("end", "End")
+ .replace("delete", "Delete")
+ .replace("backspace", "Backspace")
+ .replace("-", "+");
};
export const useKeyboardShortcutsHelp = () => {
- const { keybindings } = useKeybindingsStore();
+ const { keybindings } = useKeybindingsStore();
- const shortcuts = useMemo(() => {
- const result: KeyboardShortcut[] = [];
+ const shortcuts = useMemo(() => {
+ const result: KeyboardShortcut[] = [];
- // Group keybindings by action
- const actionToKeys: Record<Action, string[]> = {} as any;
+ // Group keybindings by action
+ const actionToKeys: Record<Action, string[]> = {} as any;
- Object.entries(keybindings).forEach(([key, action]) => {
- if (action) {
- if (!actionToKeys[action]) {
- actionToKeys[action] = [];
- }
- actionToKeys[action].push(formatKey(key));
- }
- });
+ Object.entries(keybindings).forEach(([key, action]) => {
+ if (action) {
+ if (!actionToKeys[action]) {
+ actionToKeys[action] = [];
+ }
+ actionToKeys[action].push(formatKey(key));
+ }
+ });
- // Convert to shortcuts format
- Object.entries(actionToKeys).forEach(([action, keys]) => {
- const actionInfo = actionDescriptions[action as Action];
- if (actionInfo) {
- result.push({
- id: action,
- keys,
- description: actionInfo.description,
- category: actionInfo.category,
- action: action as Action,
- });
- }
- });
+ // Convert to shortcuts format
+ Object.entries(actionToKeys).forEach(([action, keys]) => {
+ const actionInfo = actionDescriptions[action as Action];
+ if (actionInfo) {
+ result.push({
+ id: action,
+ keys,
+ description: actionInfo.description,
+ category: actionInfo.category,
+ action: action as Action,
+ });
+ }
+ });
- // Sort shortcuts by category first, then by description to ensure consistent ordering
- return result.sort((a, b) => {
- if (a.category !== b.category) {
- return a.category.localeCompare(b.category);
- }
- return a.description.localeCompare(b.description);
- });
- }, [keybindings]);
+ // Sort shortcuts by category first, then by description to ensure consistent ordering
+ return result.sort((a, b) => {
+ if (a.category !== b.category) {
+ return a.category.localeCompare(b.category);
+ }
+ return a.description.localeCompare(b.description);
+ });
+ }, [keybindings]);
- return {
- shortcuts,
- };
+ return {
+ shortcuts,
+ };
};
diff --git a/apps/web/src/hooks/use-mobile.tsx b/apps/web/src/hooks/use-mobile.tsx
index 2b0fe1df..0a892310 100644
--- a/apps/web/src/hooks/use-mobile.tsx
+++ b/apps/web/src/hooks/use-mobile.tsx
@@ -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 onChange = () => {
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
- }
- mql.addEventListener("change", onChange)
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
- return () => mql.removeEventListener("change", onChange)
- }, [])
+ React.useEffect(() => {
+ 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);
+ }, []);
- return !!isMobile
+ return !!isMobile;
}
diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts
index ecbd1232..39b5c841 100644
--- a/apps/web/src/hooks/use-playback-controls.ts
+++ b/apps/web/src/hooks/use-playback-controls.ts
@@ -4,106 +4,106 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { toast } from "sonner";
export const usePlaybackControls = () => {
- const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
+ const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
- const {
- selectedElements,
- tracks,
- splitElement,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- } = useTimelineStore();
+ const {
+ selectedElements,
+ tracks,
+ splitElement,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ separateAudio,
+ } = useTimelineStore();
- const handleSplitSelectedElement = useCallback(() => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element to split");
- return;
- }
+ const handleSplitSelectedElement = useCallback(() => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element to split");
+ return;
+ }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
- if (!element) return;
+ if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within selected element");
+ return;
+ }
- splitElement(trackId, elementId, currentTime);
- }, [selectedElements, tracks, currentTime, splitElement]);
+ splitElement(trackId, elementId, currentTime);
+ }, [selectedElements, tracks, currentTime, splitElement]);
- const handleSplitAndKeepLeftCallback = useCallback(() => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
+ const handleSplitAndKeepLeftCallback = useCallback(() => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
+ return;
+ }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
- if (!element) return;
+ if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within selected element");
+ return;
+ }
- splitAndKeepLeft(trackId, elementId, currentTime);
- }, [selectedElements, tracks, currentTime, splitAndKeepLeft]);
+ splitAndKeepLeft(trackId, elementId, currentTime);
+ }, [selectedElements, tracks, currentTime, splitAndKeepLeft]);
- const handleSplitAndKeepRightCallback = useCallback(() => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
+ const handleSplitAndKeepRightCallback = useCallback(() => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
+ return;
+ }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
- if (!element) return;
+ if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within selected element");
+ return;
+ }
- splitAndKeepRight(trackId, elementId, currentTime);
- }, [selectedElements, tracks, currentTime, splitAndKeepRight]);
+ splitAndKeepRight(trackId, elementId, currentTime);
+ }, [selectedElements, tracks, currentTime, splitAndKeepRight]);
- const handleSeparateAudioCallback = useCallback(() => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one media element to separate audio");
- return;
- }
+ const handleSeparateAudioCallback = useCallback(() => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one media element to separate audio");
+ return;
+ }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
- if (!track || track.type !== "media") {
- toast.error("Select a media element to separate audio");
- return;
- }
+ if (!track || track.type !== "media") {
+ toast.error("Select a media element to separate audio");
+ return;
+ }
- separateAudio(trackId, elementId);
- }, [selectedElements, tracks, separateAudio]);
+ separateAudio(trackId, elementId);
+ }, [selectedElements, tracks, separateAudio]);
};
diff --git a/apps/web/src/hooks/use-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts
index c40617c1..0abf8707 100644
--- a/apps/web/src/hooks/use-selection-box.ts
+++ b/apps/web/src/hooks/use-selection-box.ts
@@ -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,
+ };
+}
diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts
index f16ca8e2..9b664af4 100644
--- a/apps/web/src/hooks/use-timeline-element-resize.ts
+++ b/apps/web/src/hooks/use-timeline-element-resize.ts
@@ -4,197 +4,197 @@ import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
interface UseTimelineElementResizeProps {
- element: TimelineElement;
- track: TimelineTrack;
- zoomLevel: number;
- onUpdateTrim: (
- trackId: string,
- elementId: string,
- trimStart: number,
- trimEnd: number
- ) => void;
- onUpdateDuration: (
- trackId: string,
- elementId: string,
- duration: number
- ) => void;
+ element: TimelineElement;
+ track: TimelineTrack;
+ zoomLevel: number;
+ onUpdateTrim: (
+ trackId: string,
+ elementId: string,
+ trimStart: number,
+ trimEnd: number,
+ ) => void;
+ onUpdateDuration: (
+ trackId: string,
+ elementId: string,
+ duration: number,
+ ) => void;
}
export function useTimelineElementResize({
- element,
- track,
- zoomLevel,
- onUpdateTrim,
- onUpdateDuration,
+ element,
+ track,
+ zoomLevel,
+ onUpdateTrim,
+ onUpdateDuration,
}: UseTimelineElementResizeProps) {
- const [resizing, setResizing] = useState<ResizeState | null>(null);
- const { mediaItems } = useMediaStore();
- const { updateElementStartTime } = useTimelineStore();
+ const [resizing, setResizing] = useState<ResizeState | null>(null);
+ const { mediaItems } = useMediaStore();
+ const { updateElementStartTime } = useTimelineStore();
- // Set up document-level mouse listeners during resize (like proper drag behavior)
- useEffect(() => {
- if (!resizing) return;
+ // Set up document-level mouse listeners during resize (like proper drag behavior)
+ useEffect(() => {
+ if (!resizing) return;
- const handleDocumentMouseMove = (e: MouseEvent) => {
- updateTrimFromMouseMove({ clientX: e.clientX });
- };
+ const handleDocumentMouseMove = (e: MouseEvent) => {
+ updateTrimFromMouseMove({ clientX: e.clientX });
+ };
- const handleDocumentMouseUp = () => {
- handleResizeEnd();
- };
+ const handleDocumentMouseUp = () => {
+ handleResizeEnd();
+ };
- // Add document-level listeners for proper drag behavior
- document.addEventListener("mousemove", handleDocumentMouseMove);
- document.addEventListener("mouseup", handleDocumentMouseUp);
+ // Add document-level listeners for proper drag behavior
+ document.addEventListener("mousemove", handleDocumentMouseMove);
+ document.addEventListener("mouseup", handleDocumentMouseUp);
- return () => {
- document.removeEventListener("mousemove", handleDocumentMouseMove);
- document.removeEventListener("mouseup", handleDocumentMouseUp);
- };
- }, [resizing]); // Re-run when resizing state changes
+ return () => {
+ document.removeEventListener("mousemove", handleDocumentMouseMove);
+ document.removeEventListener("mouseup", handleDocumentMouseUp);
+ };
+ }, [resizing]); // Re-run when resizing state changes
- const handleResizeStart = (
- e: React.MouseEvent,
- elementId: string,
- side: "left" | "right"
- ) => {
- e.stopPropagation();
- e.preventDefault();
+ const handleResizeStart = (
+ e: React.MouseEvent,
+ elementId: string,
+ side: "left" | "right",
+ ) => {
+ e.stopPropagation();
+ e.preventDefault();
- setResizing({
- elementId,
- side,
- startX: e.clientX,
- initialTrimStart: element.trimStart,
- initialTrimEnd: element.trimEnd,
- });
- };
+ setResizing({
+ elementId,
+ side,
+ startX: e.clientX,
+ initialTrimStart: element.trimStart,
+ initialTrimEnd: element.trimEnd,
+ });
+ };
- const canExtendElementDuration = () => {
- // Text elements can always be extended
- if (element.type === "text") {
- return true;
- }
+ const canExtendElementDuration = () => {
+ // Text elements can always be extended
+ if (element.type === "text") {
+ return true;
+ }
- // Media elements - check the media type
- if (element.type === "media") {
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- if (!mediaItem) return false;
+ // Media elements - check the media type
+ if (element.type === "media") {
+ const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
+ if (!mediaItem) return false;
- // Images can be extended (static content)
- if (mediaItem.type === "image") {
- return true;
- }
+ // Images can be extended (static content)
+ if (mediaItem.type === "image") {
+ return true;
+ }
- // Videos and audio cannot be extended beyond their natural duration
- // (no additional content exists)
- return false;
- }
+ // Videos and audio cannot be extended beyond their natural duration
+ // (no additional content exists)
+ return false;
+ }
- return false;
- };
+ return false;
+ };
- const updateTrimFromMouseMove = (e: { clientX: number }) => {
- if (!resizing) return;
+ const updateTrimFromMouseMove = (e: { clientX: number }) => {
+ if (!resizing) return;
- const deltaX = e.clientX - resizing.startX;
- // Reasonable sensitivity for resize operations - similar to timeline scale
- const deltaTime = deltaX / (50 * zoomLevel);
+ const deltaX = e.clientX - resizing.startX;
+ // Reasonable sensitivity for resize operations - similar to timeline scale
+ const deltaTime = deltaX / (50 * zoomLevel);
- if (resizing.side === "left") {
- // Left resize - different behavior for media vs text/image elements
- const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
- const calculated = resizing.initialTrimStart + deltaTime;
+ if (resizing.side === "left") {
+ // Left resize - different behavior for media vs text/image elements
+ const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
+ const calculated = resizing.initialTrimStart + deltaTime;
- if (calculated >= 0) {
- // Normal trimming within available content
- const newTrimStart = Math.min(maxAllowed, calculated);
- const trimDelta = newTrimStart - resizing.initialTrimStart;
- const newStartTime = element.startTime + trimDelta;
+ if (calculated >= 0) {
+ // Normal trimming within available content
+ const newTrimStart = Math.min(maxAllowed, calculated);
+ const trimDelta = newTrimStart - resizing.initialTrimStart;
+ const newStartTime = element.startTime + trimDelta;
- onUpdateTrim(
- track.id,
- element.id,
- newTrimStart,
- resizing.initialTrimEnd
- );
- updateElementStartTime(track.id, element.id, newStartTime);
- } else {
- // Trying to extend beyond trimStart = 0
- if (canExtendElementDuration()) {
- // Text/Image: extend element to the left by moving startTime and increasing duration
- const extensionAmount = Math.abs(calculated);
- const newStartTime = element.startTime - extensionAmount;
- const newDuration = element.duration + extensionAmount;
+ onUpdateTrim(
+ track.id,
+ element.id,
+ newTrimStart,
+ resizing.initialTrimEnd,
+ );
+ updateElementStartTime(track.id, element.id, newStartTime);
+ } else {
+ // Trying to extend beyond trimStart = 0
+ if (canExtendElementDuration()) {
+ // Text/Image: extend element to the left by moving startTime and increasing duration
+ const extensionAmount = Math.abs(calculated);
+ const newStartTime = element.startTime - extensionAmount;
+ const newDuration = element.duration + extensionAmount;
- // Keep trimStart at 0 and extend the element
- onUpdateTrim(track.id, element.id, 0, resizing.initialTrimEnd);
- onUpdateDuration(track.id, element.id, newDuration);
- updateElementStartTime(track.id, element.id, newStartTime);
- } else {
- // Video/Audio: can't extend beyond original content - limit to trimStart = 0
- const newTrimStart = 0;
- const trimDelta = newTrimStart - resizing.initialTrimStart;
- const newStartTime = element.startTime + trimDelta;
+ // Keep trimStart at 0 and extend the element
+ onUpdateTrim(track.id, element.id, 0, resizing.initialTrimEnd);
+ onUpdateDuration(track.id, element.id, newDuration);
+ updateElementStartTime(track.id, element.id, newStartTime);
+ } else {
+ // Video/Audio: can't extend beyond original content - limit to trimStart = 0
+ const newTrimStart = 0;
+ const trimDelta = newTrimStart - resizing.initialTrimStart;
+ const newStartTime = element.startTime + trimDelta;
- onUpdateTrim(
- track.id,
- element.id,
- newTrimStart,
- resizing.initialTrimEnd
- );
- updateElementStartTime(track.id, element.id, newStartTime);
- }
- }
- } else {
- // Right resize - can extend duration for supported element types
- const calculated = resizing.initialTrimEnd - deltaTime;
+ onUpdateTrim(
+ track.id,
+ element.id,
+ newTrimStart,
+ resizing.initialTrimEnd,
+ );
+ updateElementStartTime(track.id, element.id, newStartTime);
+ }
+ }
+ } else {
+ // Right resize - can extend duration for supported element types
+ const calculated = resizing.initialTrimEnd - deltaTime;
- if (calculated < 0) {
- // We're trying to extend beyond original duration
- if (canExtendElementDuration()) {
- // Extend the duration instead of reducing trimEnd further
- const extensionNeeded = Math.abs(calculated);
- const newDuration = element.duration + extensionNeeded;
- const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
+ if (calculated < 0) {
+ // We're trying to extend beyond original duration
+ if (canExtendElementDuration()) {
+ // Extend the duration instead of reducing trimEnd further
+ const extensionNeeded = Math.abs(calculated);
+ const newDuration = element.duration + extensionNeeded;
+ const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
- // Update duration first, then trim
- onUpdateDuration(track.id, element.id, newDuration);
- onUpdateTrim(
- track.id,
- element.id,
- resizing.initialTrimStart,
- newTrimEnd
- );
- } else {
- // Can't extend - just set trimEnd to 0 (maximum possible extension)
- onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0);
- }
- } else {
- // Normal trimming within original duration
- const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible
- const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated));
+ // Update duration first, then trim
+ onUpdateDuration(track.id, element.id, newDuration);
+ onUpdateTrim(
+ track.id,
+ element.id,
+ resizing.initialTrimStart,
+ newTrimEnd,
+ );
+ } else {
+ // Can't extend - just set trimEnd to 0 (maximum possible extension)
+ onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0);
+ }
+ } else {
+ // Normal trimming within original duration
+ const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible
+ const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated));
- onUpdateTrim(
- track.id,
- element.id,
- resizing.initialTrimStart,
- newTrimEnd
- );
- }
- }
- };
+ onUpdateTrim(
+ track.id,
+ element.id,
+ resizing.initialTrimStart,
+ newTrimEnd,
+ );
+ }
+ }
+ };
- const handleResizeEnd = () => {
- setResizing(null);
- };
+ const handleResizeEnd = () => {
+ setResizing(null);
+ };
- return {
- resizing,
- isResizing: resizing !== null,
- handleResizeStart,
- // Return empty handlers since we use document listeners now
- handleResizeMove: () => {}, // Not used anymore
- handleResizeEnd: () => {}, // Not used anymore
- };
+ return {
+ resizing,
+ isResizing: resizing !== null,
+ handleResizeStart,
+ // Return empty handlers since we use document listeners now
+ handleResizeMove: () => {}, // Not used anymore
+ handleResizeEnd: () => {}, // Not used anymore
+ };
}
diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts
index 9a82c23f..cd2fc76e 100644
--- a/apps/web/src/hooks/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/use-timeline-playhead.ts
@@ -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,
+ };
+}
diff --git a/apps/web/src/hooks/use-timeline-snapping.ts b/apps/web/src/hooks/use-timeline-snapping.ts
index f7f96392..e51f6a22 100644
--- a/apps/web/src/hooks/use-timeline-snapping.ts
+++ b/apps/web/src/hooks/use-timeline-snapping.ts
@@ -3,176 +3,176 @@ import { TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
export interface SnapPoint {
- time: number;
- type: "element-start" | "element-end" | "playhead";
- elementId?: string;
- trackId?: string;
+ time: number;
+ type: "element-start" | "element-end" | "playhead";
+ elementId?: string;
+ trackId?: string;
}
export interface SnapResult {
- snappedTime: number;
- snapPoint: SnapPoint | null;
- snapDistance: number;
+ snappedTime: number;
+ snapPoint: SnapPoint | null;
+ snapDistance: number;
}
export interface UseTimelineSnappingOptions {
- snapThreshold?: number; // Distance in pixels to trigger snapping
- enableElementSnapping?: boolean;
- enablePlayheadSnapping?: boolean;
+ snapThreshold?: number; // Distance in pixels to trigger snapping
+ enableElementSnapping?: boolean;
+ enablePlayheadSnapping?: boolean;
}
export function useTimelineSnapping({
- snapThreshold = 10,
- enableElementSnapping = true,
- enablePlayheadSnapping = true,
+ snapThreshold = 10,
+ enableElementSnapping = true,
+ enablePlayheadSnapping = true,
}: UseTimelineSnappingOptions = {}) {
- const findSnapPoints = useCallback(
- (
- tracks: TimelineTrack[],
- currentTime: number,
- playheadTime: number,
- zoomLevel: number,
- excludeElementId?: string
- ): SnapPoint[] => {
- const snapPoints: SnapPoint[] = [];
+ const findSnapPoints = useCallback(
+ (
+ tracks: TimelineTrack[],
+ currentTime: number,
+ playheadTime: number,
+ zoomLevel: number,
+ excludeElementId?: string,
+ ): SnapPoint[] => {
+ const snapPoints: SnapPoint[] = [];
- // Add element snap points
- if (enableElementSnapping) {
- tracks.forEach((track) => {
- track.elements.forEach((element) => {
- // Skip the element being dragged
- if (element.id === excludeElementId) return;
+ // Add element snap points
+ if (enableElementSnapping) {
+ tracks.forEach((track) => {
+ track.elements.forEach((element) => {
+ // Skip the element being dragged
+ if (element.id === excludeElementId) return;
- const elementStart = element.startTime;
- const elementEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const elementStart = element.startTime;
+ const elementEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- snapPoints.push(
- {
- time: elementStart,
- type: "element-start",
- elementId: element.id,
- trackId: track.id,
- },
- {
- time: elementEnd,
- type: "element-end",
- elementId: element.id,
- trackId: track.id,
- }
- );
- });
- });
- }
+ snapPoints.push(
+ {
+ time: elementStart,
+ type: "element-start",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ {
+ time: elementEnd,
+ type: "element-end",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ );
+ });
+ });
+ }
- // Add playhead snap point
- if (enablePlayheadSnapping) {
- snapPoints.push({
- time: playheadTime,
- type: "playhead",
- });
- }
+ // Add playhead snap point
+ if (enablePlayheadSnapping) {
+ snapPoints.push({
+ time: playheadTime,
+ type: "playhead",
+ });
+ }
- return snapPoints;
- },
- [enableElementSnapping, enablePlayheadSnapping]
- );
+ return snapPoints;
+ },
+ [enableElementSnapping, enablePlayheadSnapping],
+ );
- const snapToNearestPoint = useCallback(
- (
- targetTime: number,
- snapPoints: SnapPoint[],
- zoomLevel: number
- ): SnapResult => {
- const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const thresholdInSeconds = snapThreshold / pixelsPerSecond;
+ const snapToNearestPoint = useCallback(
+ (
+ targetTime: number,
+ snapPoints: SnapPoint[],
+ zoomLevel: number,
+ ): SnapResult => {
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const thresholdInSeconds = snapThreshold / pixelsPerSecond;
- let closestSnapPoint: SnapPoint | null = null;
- let closestDistance = Infinity;
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestDistance = Infinity;
- snapPoints.forEach((snapPoint) => {
- const distance = Math.abs(targetTime - snapPoint.time);
- if (distance < thresholdInSeconds && distance < closestDistance) {
- closestDistance = distance;
- closestSnapPoint = snapPoint;
- }
- });
+ snapPoints.forEach((snapPoint) => {
+ const distance = Math.abs(targetTime - snapPoint.time);
+ if (distance < thresholdInSeconds && distance < closestDistance) {
+ closestDistance = distance;
+ closestSnapPoint = snapPoint;
+ }
+ });
- return {
- snappedTime: closestSnapPoint
- ? (closestSnapPoint as SnapPoint).time
- : targetTime,
- snapPoint: closestSnapPoint,
- snapDistance: closestDistance,
- };
- },
- [snapThreshold]
- );
+ return {
+ snappedTime: closestSnapPoint
+ ? (closestSnapPoint as SnapPoint).time
+ : targetTime,
+ snapPoint: closestSnapPoint,
+ snapDistance: closestDistance,
+ };
+ },
+ [snapThreshold],
+ );
- const snapElementPosition = useCallback(
- (
- targetTime: number,
- tracks: TimelineTrack[],
- playheadTime: number,
- zoomLevel: number,
- excludeElementId?: string
- ): SnapResult => {
- const snapPoints = findSnapPoints(
- tracks,
- targetTime,
- playheadTime,
- zoomLevel,
- excludeElementId
- );
+ const snapElementPosition = useCallback(
+ (
+ targetTime: number,
+ tracks: TimelineTrack[],
+ playheadTime: number,
+ zoomLevel: number,
+ excludeElementId?: string,
+ ): SnapResult => {
+ const snapPoints = findSnapPoints(
+ tracks,
+ targetTime,
+ playheadTime,
+ zoomLevel,
+ excludeElementId,
+ );
- return snapToNearestPoint(targetTime, snapPoints, zoomLevel);
- },
- [findSnapPoints, snapToNearestPoint]
- );
+ return snapToNearestPoint(targetTime, snapPoints, zoomLevel);
+ },
+ [findSnapPoints, snapToNearestPoint],
+ );
- const snapElementEdge = useCallback(
- (
- targetTime: number,
- elementDuration: number,
- tracks: TimelineTrack[],
- playheadTime: number,
- zoomLevel: number,
- excludeElementId?: string,
- snapToStart = true // true for start edge, false for end edge
- ): SnapResult => {
- const snapPoints = findSnapPoints(
- tracks,
- targetTime,
- playheadTime,
- zoomLevel,
- excludeElementId
- );
+ const snapElementEdge = useCallback(
+ (
+ targetTime: number,
+ elementDuration: number,
+ tracks: TimelineTrack[],
+ playheadTime: number,
+ zoomLevel: number,
+ excludeElementId?: string,
+ snapToStart = true, // true for start edge, false for end edge
+ ): SnapResult => {
+ const snapPoints = findSnapPoints(
+ tracks,
+ targetTime,
+ playheadTime,
+ zoomLevel,
+ excludeElementId,
+ );
- // For end edge snapping, we need to account for element duration
- const effectiveTargetTime = snapToStart
- ? targetTime
- : targetTime + elementDuration;
- const snapResult = snapToNearestPoint(
- effectiveTargetTime,
- snapPoints,
- zoomLevel
- );
+ // For end edge snapping, we need to account for element duration
+ const effectiveTargetTime = snapToStart
+ ? targetTime
+ : targetTime + elementDuration;
+ const snapResult = snapToNearestPoint(
+ effectiveTargetTime,
+ snapPoints,
+ zoomLevel,
+ );
- // Adjust the snapped time back for end edge
- if (!snapToStart && snapResult.snapPoint) {
- snapResult.snappedTime = snapResult.snappedTime - elementDuration;
- }
+ // Adjust the snapped time back for end edge
+ if (!snapToStart && snapResult.snapPoint) {
+ snapResult.snappedTime = snapResult.snappedTime - elementDuration;
+ }
- return snapResult;
- },
- [findSnapPoints, snapToNearestPoint]
- );
+ return snapResult;
+ },
+ [findSnapPoints, snapToNearestPoint],
+ );
- return {
- snapElementPosition,
- snapElementEdge,
- findSnapPoints,
- snapToNearestPoint,
- };
+ return {
+ snapElementPosition,
+ snapElementEdge,
+ findSnapPoints,
+ snapToNearestPoint,
+ };
}
diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts
index ce32c9f0..00511290 100644
--- a/apps/web/src/hooks/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/use-timeline-zoom.ts
@@ -1,54 +1,54 @@
-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)));
- }
- // 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)));
+ }
+ // 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,
+ };
+}
diff --git a/apps/web/src/hooks/use-toast.ts b/apps/web/src/hooks/use-toast.ts
index c682cd1f..78cc46e8 100644
--- a/apps/web/src/hooks/use-toast.ts
+++ b/apps/web/src/hooks/use-toast.ts
@@ -9,121 +9,121 @@ const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
- id: string;
- title?: React.ReactNode;
- description?: React.ReactNode;
- action?: ToastActionElement;
+ id: string;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ action?: ToastActionElement;
};
const actionTypes = {
- ADD_TOAST: "ADD_TOAST",
- UPDATE_TOAST: "UPDATE_TOAST",
- DISMISS_TOAST: "DISMISS_TOAST",
- REMOVE_TOAST: "REMOVE_TOAST",
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
- count = (count + 1) % Number.MAX_SAFE_INTEGER;
- return count.toString();
+ count = (count + 1) % Number.MAX_SAFE_INTEGER;
+ return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
- | {
- type: ActionType["ADD_TOAST"];
- toast: ToasterToast;
- }
- | {
- type: ActionType["UPDATE_TOAST"];
- toast: Partial<ToasterToast>;
- }
- | {
- type: ActionType["DISMISS_TOAST"];
- toastId?: ToasterToast["id"];
- }
- | {
- type: ActionType["REMOVE_TOAST"];
- toastId?: ToasterToast["id"];
- };
+ | {
+ type: ActionType["ADD_TOAST"];
+ toast: ToasterToast;
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"];
+ toast: Partial<ToasterToast>;
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"];
+ toastId?: ToasterToast["id"];
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"];
+ toastId?: ToasterToast["id"];
+ };
interface State {
- toasts: ToasterToast[];
+ toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
- if (toastTimeouts.has(toastId)) {
- return;
- }
+ if (toastTimeouts.has(toastId)) {
+ return;
+ }
- const timeout = setTimeout(() => {
- toastTimeouts.delete(toastId);
- dispatch({
- type: "REMOVE_TOAST",
- toastId: toastId,
- });
- }, TOAST_REMOVE_DELAY);
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId);
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId,
+ });
+ }, TOAST_REMOVE_DELAY);
- toastTimeouts.set(toastId, timeout);
+ toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
- switch (action.type) {
- case "ADD_TOAST":
- return {
- ...state,
- toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
- };
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ };
- case "UPDATE_TOAST":
- return {
- ...state,
- toasts: state.toasts.map((t) =>
- t.id === action.toast.id ? { ...t, ...action.toast } : t
- ),
- };
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t,
+ ),
+ };
- case "DISMISS_TOAST": {
- const { toastId } = action;
+ case "DISMISS_TOAST": {
+ const { toastId } = action;
- // ! Side effects ! - This could be extracted into a dismissToast() action,
- // but I'll keep it here for simplicity
- if (toastId) {
- addToRemoveQueue(toastId);
- } else {
- state.toasts.forEach((toast) => {
- addToRemoveQueue(toast.id);
- });
- }
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId);
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id);
+ });
+ }
- return {
- ...state,
- toasts: state.toasts.map((t) =>
- t.id === toastId || toastId === undefined
- ? {
- ...t,
- open: false,
- }
- : t
- ),
- };
- }
- case "REMOVE_TOAST":
- if (action.toastId === undefined) {
- return {
- ...state,
- toasts: [],
- };
- }
- return {
- ...state,
- toasts: state.toasts.filter((t) => t.id !== action.toastId),
- };
- }
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t,
+ ),
+ };
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ };
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ };
+ }
};
const listeners: Array<(state: State) => void> = [];
@@ -131,61 +131,61 @@ const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
- memoryState = reducer(memoryState, action);
- listeners.forEach((listener) => {
- listener(memoryState);
- });
+ memoryState = reducer(memoryState, action);
+ listeners.forEach((listener) => {
+ listener(memoryState);
+ });
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
- const id = genId();
+ const id = genId();
- const update = (props: ToasterToast) =>
- dispatch({
- type: "UPDATE_TOAST",
- toast: { ...props, id },
- });
- const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id },
+ });
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
- dispatch({
- type: "ADD_TOAST",
- toast: {
- ...props,
- id,
- open: true,
- onOpenChange: (open) => {
- if (!open) dismiss();
- },
- },
- });
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss();
+ },
+ },
+ });
- return {
- id: id,
- dismiss,
- update,
- };
+ return {
+ id: id,
+ dismiss,
+ update,
+ };
}
function useToast() {
- const [state, setState] = React.useState<State>(memoryState);
+ const [state, setState] = React.useState<State>(memoryState);
- React.useEffect(() => {
- listeners.push(setState);
- return () => {
- const index = listeners.indexOf(setState);
- if (index > -1) {
- listeners.splice(index, 1);
- }
- };
- }, [state]);
+ React.useEffect(() => {
+ listeners.push(setState);
+ return () => {
+ const index = listeners.indexOf(setState);
+ if (index > -1) {
+ listeners.splice(index, 1);
+ }
+ };
+ }, [state]);
- return {
- ...state,
- toast,
- dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
- };
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
+ };
}
export { useToast, toast };