summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx2
-rw-r--r--apps/web/src/app/layout.tsx2
-rw-r--r--apps/web/src/components/editor-provider.tsx25
-rw-r--r--apps/web/src/components/editor/properties-panel/index.tsx6
-rw-r--r--apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx24
-rw-r--r--apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx78
-rw-r--r--apps/web/src/components/editor/timeline-element.tsx88
-rw-r--r--apps/web/src/components/editor/timeline-playhead.tsx32
-rw-r--r--apps/web/src/components/editor/timeline-track.tsx2281
-rw-r--r--apps/web/src/components/editor/timeline.tsx327
-rw-r--r--apps/web/src/components/keyboard-shortcuts-help.tsx166
-rw-r--r--apps/web/src/constants/actions.ts293
-rw-r--r--apps/web/src/constants/timeline-constants.ts5
-rw-r--r--apps/web/src/hooks/use-disable-browser-zoom.ts63
-rw-r--r--apps/web/src/hooks/use-editor-actions.ts162
-rw-r--r--apps/web/src/hooks/use-keybinding-conflicts.ts59
-rw-r--r--apps/web/src/hooks/use-keybindings.ts64
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts-help.ts129
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts.ts338
-rw-r--r--apps/web/src/hooks/use-timeline-element-resize.ts50
-rw-r--r--apps/web/src/hooks/use-timeline-playhead.ts202
-rw-r--r--apps/web/src/hooks/use-timeline-zoom.ts103
-rw-r--r--apps/web/src/lib/timeline.ts108
-rw-r--r--apps/web/src/lib/utils.ts19
-rw-r--r--apps/web/src/stores/keybindings-store.ts241
-rw-r--r--apps/web/src/stores/timeline-store.ts338
-rw-r--r--apps/web/src/types/keybinding.ts35
-rw-r--r--apps/web/src/types/timeline.ts20
28 files changed, 2974 insertions, 2286 deletions
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 4be4d9cc..aa5b7315 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -16,7 +16,6 @@ import { usePanelStore } from "@/stores/panel-store";
import { useProjectStore } from "@/stores/project-store";
import { EditorProvider } from "@/components/editor-provider";
import { usePlaybackControls } from "@/hooks/use-playback-controls";
-import { useDisableBrowserZoom } from "@/hooks/use-disable-browser-zoom";
import { Onboarding } from "@/components/onboarding";
export default function Editor() {
@@ -40,7 +39,6 @@ export default function Editor() {
const handledProjectIds = useRef<Set<string>>(new Set());
const [isOnboardingOpen, setIsOnboardingOpen] = useState(true);
- useDisableBrowserZoom();
usePlaybackControls();
useEffect(() => {
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 1cbc3f2e..5c1d7520 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -8,6 +8,7 @@ import { StorageProvider } from "../components/storage-provider";
import { baseMetaData } from "./metadata";
import { defaultFont } from "../lib/font-config";
import { BotIdClient } from "botid/client";
+import { env } from "@/env";
export const metadata = baseMetaData;
@@ -39,6 +40,7 @@ export default function RootLayout({
strategy="afterInteractive"
async
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
+ data-disabled={env.NODE_ENV === "development"}
data-track-attributes={false}
data-track-errors={true}
data-track-outgoing-links={false}
diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/editor-provider.tsx
index d970f86d..25d040db 100644
--- a/apps/web/src/components/editor-provider.tsx
+++ b/apps/web/src/components/editor-provider.tsx
@@ -3,7 +3,11 @@
import { useEffect } from "react";
import { Loader2 } from "lucide-react";
import { useEditorStore } from "@/stores/editor-store";
-import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
+import {
+ useKeybindingsListener,
+ useKeybindingDisabler,
+} from "@/hooks/use-keybindings";
+import { useEditorActions } from "@/hooks/use-editor-actions";
interface EditorProviderProps {
children: React.ReactNode;
@@ -11,11 +15,22 @@ interface EditorProviderProps {
export function EditorProvider({ children }: EditorProviderProps) {
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
+ const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
- useKeyboardShortcuts({
- enabled: !isInitializing && isPanelsReady,
- context: "editor",
- });
+ // Set up action handlers
+ useEditorActions();
+
+ // Set up keybinding listener
+ useKeybindingsListener();
+
+ // Disable keybindings when initializing
+ useEffect(() => {
+ if (isInitializing || !isPanelsReady) {
+ disableKeybindings();
+ } else {
+ enableKeybindings();
+ }
+ }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
useEffect(() => {
initializeApp();
diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx
index dc0dd4b2..9e28850f 100644
--- a/apps/web/src/components/editor/properties-panel/index.tsx
+++ b/apps/web/src/components/editor/properties-panel/index.tsx
@@ -83,11 +83,7 @@ export function PropertiesPanel() {
);
if (mediaItem?.type === "audio") {
- return (
- <div key={elementId}>
- <AudioProperties element={element} />
- </div>
- );
+ return <AudioProperties element={element} />;
}
return (
diff --git a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx
deleted file mode 100644
index c0efc19e..00000000
--- a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { TimelineCanvasRulerWrapperProps } from "@/types/timeline";
-import React, { forwardRef } from "react";
-
-/**
- * Wrapper div around the canvas-based timeline ruler.
- * Captures mouse events for scrubbing and ensures pointer events are properly enabled.
- */
-const TimelineCanvasRulerWrapper = forwardRef<
- HTMLDivElement,
- TimelineCanvasRulerWrapperProps
->(({ children, onMouseDown, className = "" }, ref) => {
- return (
- <div
- ref={ref}
- className={`relative overflow-hidden h-5 w-full select-none cursor-pointer ${className}`}
- onMouseDown={onMouseDown}
- data-ruler-wrapper
- >
- {children}
- </div>
- );
-});
-
-export default TimelineCanvasRulerWrapper;
diff --git a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx
deleted file mode 100644
index c40cdbda..00000000
--- a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { TimelineCanvasRulerProps } from "@/types/timeline";
-import React, { useRef, useEffect } from "react";
-
-/**
- * TimelineCanvasRuler renders the timeline ticks and labels using Canvas.
- * Should be wrapped by TimelineCanvasRulerWrapper for interaction handling.
- */
-export default function TimelineCanvasRuler({
- zoomLevel,
- duration,
- width,
- height = 20,
-}: TimelineCanvasRulerProps) {
- const canvasRef = useRef<HTMLCanvasElement>(null);
-
- useEffect(() => {
- const ctx = canvasRef.current?.getContext("2d");
- if (!ctx) return;
-
- ctx.clearRect(0, 0, width, height);
-
- const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
-
- const getTimeInterval = () => {
- if (pixelsPerSecond >= 200) return 0.1;
- if (pixelsPerSecond >= 100) return 0.5;
- if (pixelsPerSecond >= 50) return 1;
- if (pixelsPerSecond >= 25) return 2;
- if (pixelsPerSecond >= 12) return 5;
- if (pixelsPerSecond >= 6) return 10;
- return 30;
- };
-
- const mainInterval = getTimeInterval();
- const tickPerMain = 5;
- const subInterval = mainInterval / tickPerMain;
-
- const totalTicks = Math.ceil(duration / subInterval) + 1;
-
- for (let i = 0; i < totalTicks; i++) {
- const time = i * subInterval;
- const x = time * pixelsPerSecond;
-
- const isMain = i % tickPerMain === 0;
-
- ctx.beginPath();
- ctx.moveTo(x, 0);
- ctx.lineTo(x, isMain ? height * 0.3 : height * 0.1);
- ctx.strokeStyle = isMain ? "#999" : "#ccc";
- ctx.lineWidth = isMain ? 1 : 0.5;
- ctx.stroke();
-
- // Label
- if (isMain) {
- ctx.fillStyle = "#666";
- ctx.font = "10px sans-serif";
- const mins = Math.floor(time / 60);
- const secs = Math.floor(time % 60);
- let label = "";
- let xTranslate = 10;
- if (mainInterval < 1) {
- label = `${time.toFixed(1)}s`;
- } else if (mins > 0) {
- label = `${mins}:${secs.toString().padStart(2, "0")}`;
- } else {
- label = `${secs}s`;
- xTranslate = 5;
- }
- ctx.fillText(label, x - xTranslate, height - 4);
- }
- }
- }, [zoomLevel, duration, width, height]);
-
- return (
- <canvas ref={canvasRef} width={width} height={height} className="block" />
- );
-}
diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx
index bb414a9c..eb9f1c01 100644
--- a/apps/web/src/components/editor/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline-element.tsx
@@ -98,93 +98,9 @@ export function TimelineElement({
isBeingDragged && dragState.isDragging
? dragState.currentTime
: element.startTime;
- const elementLeft = elementStartTime * 50 * zoomLevel;
-
- const handleDeleteElement = () => {
- removeElementFromTrack(track.id, element.id);
- setElementMenuOpen(false);
- };
-
- const handleSplitElement = () => {
- 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 element to split");
- return;
- }
-
- const secondElementId = splitElement(track.id, element.id, currentTime);
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- setElementMenuOpen(false);
- };
-
- const handleSplitAndKeepLeft = () => {
- 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 element");
- return;
- }
-
- splitAndKeepLeft(track.id, element.id, currentTime);
- setElementMenuOpen(false);
- };
- const handleSplitAndKeepRight = () => {
- 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 element");
- return;
- }
-
- splitAndKeepRight(track.id, element.id, currentTime);
- setElementMenuOpen(false);
- };
-
- const handleSeparateAudio = () => {
- if (element.type !== "media") {
- toast.error("Audio separation only available for media elements");
- return;
- }
-
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- if (!mediaItem || mediaItem.type !== "video") {
- toast.error("Audio separation only available for video elements");
- return;
- }
-
- const audioElementId = separateAudio(track.id, element.id);
- if (!audioElementId) {
- toast.error("Failed to separate audio");
- }
- setElementMenuOpen(false);
- };
-
- const canSplitAtPlayhead = () => {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- return currentTime > effectiveStart && currentTime < effectiveEnd;
- };
-
- const canSeparateAudio = () => {
- if (element.type !== "media") return false;
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- return mediaItem?.type === "video" && track.type === "media";
- };
+ // Element should always be positioned at startTime - trimStart only affects content, not position
+ const elementLeft = elementStartTime * 50 * zoomLevel;
const handleElementSplitContext = () => {
const effectiveStart = element.startTime;
diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx
index 390abc3d..7a357067 100644
--- a/apps/web/src/components/editor/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/timeline-playhead.tsx
@@ -2,7 +2,10 @@
import { useRef } from "react";
import { TimelineTrack } from "@/types/timeline";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ TIMELINE_CONSTANTS,
+ getTotalTracksHeight,
+} from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
interface TimelinePlayheadProps {
@@ -13,6 +16,7 @@ interface TimelinePlayheadProps {
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
@@ -27,6 +31,7 @@ export function TimelinePlayhead({
seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
trackLabelsRef,
timelineRef,
playheadRef: externalPlayheadRef,
@@ -34,13 +39,15 @@ export function TimelinePlayhead({
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handleRulerMouseDown } = useTimelinePlayhead({
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
});
// Use timeline container height minus a few pixels for breathing room
@@ -59,14 +66,14 @@ export function TimelinePlayhead({
return (
<div
ref={playheadRef}
- className="absolute pointer-events-auto z-[50]"
+ className="absolute pointer-events-auto z-[150]"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
}}
- onMouseDown={handleRulerMouseDown}
+ onMouseDown={handlePlayheadMouseDown}
>
{/* The playhead line spanning full height */}
<div
@@ -89,24 +96,21 @@ export function useTimelinePlayheadRuler({
seek,
rulerRef,
rulerScrollRef,
-}: Omit<
- TimelinePlayheadProps,
- | "tracks"
- | "trackLabelsRef"
- | "timelineRef"
- | "tracksScrollRef"
- | "playheadRef"
->) {
- const { handleRulerMouseDown } = useTimelinePlayhead({
+ tracksScrollRef,
+ playheadRef,
+}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
+ const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
});
- return { handleRulerMouseDown };
+ return { handleRulerMouseDown, isDraggingRuler };
}
export { TimelinePlayhead as default };
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx
index 58ca411e..82ca0b98 100644
--- a/apps/web/src/components/editor/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline-track.tsx
@@ -1,1130 +1,1151 @@
-"use client";
-
-import { useRef, useState, useEffect } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { toast } from "sonner";
-import { TimelineElement } from "./timeline-element";
-import {
- TimelineTrack,
- sortTracksByOrder,
- ensureMainTrack,
- getMainTrack,
- canElementGoOnTrack,
-} from "@/types/timeline";
-import { usePlaybackStore } from "@/stores/playback-store";
-import type {
- TimelineElement as TimelineElementType,
- DragData,
-} from "@/types/timeline";
-import {
- snapTimeToFrame,
- TIMELINE_CONSTANTS,
-} from "@/constants/timeline-constants";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
-
-export function TimelineTrackContent({
- track,
- zoomLevel,
- onSnapPointChange,
-}: {
- track: TimelineTrack;
- zoomLevel: number;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}) {
- const { mediaItems } = useMediaStore();
- const {
- tracks,
- addTrack,
- moveElementToTrack,
- updateElementStartTime,
- updateElementStartTimeWithRipple,
- addElementToTrack,
- selectedElements,
- selectElement,
- dragState,
- startDrag: startDragAction,
- updateDragTime,
- endDrag: endDragAction,
- clearSelectedElements,
- insertTrackAt,
- snappingEnabled,
- rippleEditingEnabled,
- } = useTimelineStore();
-
- const { currentTime } = usePlaybackStore();
-
- // Initialize snapping hook
- const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
- snapThreshold: 10,
- enableElementSnapping: snappingEnabled,
- enablePlayheadSnapping: snappingEnabled,
- });
-
- // Helper function for drop snapping that tries both edges
- const getDropSnappedTime = (
- dropTime: number,
- elementDuration: number,
- excludeElementId?: string
- ) => {
- if (!snappingEnabled) {
- // Use frame snapping if project has FPS, otherwise use decimal snapping
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- return snapTimeToFrame(dropTime, projectFps);
- }
-
- // Try snapping both start and end edges for drops
- const startSnapResult = snapElementEdge(
- dropTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- excludeElementId,
- true // snap to start edge
- );
-
- const endSnapResult = snapElementEdge(
- dropTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- excludeElementId,
- false // snap to end edge
- );
-
- // Choose the snap result with the smaller distance (closer snap)
- let bestSnapResult = startSnapResult;
- if (
- endSnapResult.snapPoint &&
- (!startSnapResult.snapPoint ||
- endSnapResult.snapDistance < startSnapResult.snapDistance)
- ) {
- bestSnapResult = endSnapResult;
- }
-
- return bestSnapResult.snappedTime;
- };
-
- const timelineRef = useRef<HTMLDivElement>(null);
- const [isDropping, setIsDropping] = useState(false);
- const [dropPosition, setDropPosition] = useState<number | null>(null);
- const [wouldOverlap, setWouldOverlap] = useState(false);
- const dragCounterRef = useRef(0);
- const [mouseDownLocation, setMouseDownLocation] = useState<{
- x: number;
- y: number;
- } | null>(null);
-
- // Set up mouse event listeners for drag
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!timelineRef.current) return;
-
- // On first mouse move during drag, ensure the element is selected
- if (dragState.elementId && dragState.trackId) {
- const isSelected = selectedElements.some(
- (c) =>
- c.trackId === dragState.trackId &&
- c.elementId === dragState.elementId
- );
-
- if (!isSelected) {
- // Select this element (replacing other selections) since we're dragging it
- selectElement(dragState.trackId, dragState.elementId, false);
- }
- }
-
- const timelineRect = timelineRef.current.getBoundingClientRect();
- const mouseX = e.clientX - timelineRect.left;
- const mouseTime = Math.max(
- 0,
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
- );
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
-
- // Apply snapping if enabled
- let finalTime = adjustedTime;
- let snapPoint = null;
- if (snappingEnabled) {
- // Find the element being dragged to get its duration
- let elementDuration = 5; // fallback duration
- if (dragState.elementId && dragState.trackId) {
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const element = sourceTrack?.elements.find(
- (e) => e.id === dragState.elementId
- );
- if (element) {
- elementDuration =
- element.duration - element.trimStart - element.trimEnd;
- }
- }
-
- // Try snapping both start and end edges
- const startSnapResult = snapElementEdge(
- adjustedTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- dragState.elementId || undefined,
- true // snap to start edge
- );
-
- const endSnapResult = snapElementEdge(
- adjustedTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- dragState.elementId || undefined,
- false // snap to end edge
- );
-
- // Choose the snap result with the smaller distance (closer snap)
- let bestSnapResult = startSnapResult;
- if (
- endSnapResult.snapPoint &&
- (!startSnapResult.snapPoint ||
- endSnapResult.snapDistance < startSnapResult.snapDistance)
- ) {
- bestSnapResult = endSnapResult;
- }
-
- finalTime = bestSnapResult.snappedTime;
- snapPoint = bestSnapResult.snapPoint;
-
- // Notify parent component about snap point change
- onSnapPointChange?.(snapPoint);
- } else {
- // Use frame snapping if project has FPS, otherwise use decimal snapping
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- finalTime = snapTimeToFrame(adjustedTime, projectFps);
-
- // Clear snap point when not snapping
- onSnapPointChange?.(null);
- }
-
- updateDragTime(finalTime);
- };
-
- const handleMouseUp = (e: MouseEvent) => {
- if (!dragState.elementId || !dragState.trackId) return;
-
- // If this track initiated the drag, we should handle the mouse up regardless of where it occurs
- const isTrackThatStartedDrag = dragState.trackId === track.id;
-
- const timelineRect = timelineRef.current?.getBoundingClientRect();
- if (!timelineRect) {
- if (isTrackThatStartedDrag) {
- updateElementStartTime(
- track.id,
- dragState.elementId,
- dragState.currentTime
- );
- endDragAction();
- // Clear snap point when drag ends
- onSnapPointChange?.(null);
- }
- return;
- }
-
- const isMouseOverThisTrack =
- e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
-
- if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
-
- const finalTime = dragState.currentTime;
-
- if (isMouseOverThisTrack) {
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const targetTrack = tracks.find((t) => t.id === track.id);
- const hasOverlap = targetTrack?.elements.some((existingElement) => {
- if (
- dragState.trackId === track.id &&
- existingElement.id === dragState.elementId
- ) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (dragState.trackId === track.id) {
- // Use ripple editing if enabled, otherwise regular update
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- finalTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId,
- finalTime
- );
- }
- } else {
- moveElementToTrack(
- dragState.trackId,
- track.id,
- dragState.elementId
- );
- requestAnimationFrame(() => {
- // Use ripple editing if enabled, otherwise regular update
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId!,
- finalTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId!,
- finalTime
- );
- }
- });
- }
- }
- }
- } else if (isTrackThatStartedDrag) {
- // Mouse is not over this track, but this track started the drag
- // This means user released over ruler/outside - update position within same track
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const hasOverlap = track.elements.some((existingElement) => {
- if (existingElement.id === dragState.elementId) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- // Use ripple editing if enabled, otherwise regular update
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- finalTime
- );
- } else {
- updateElementStartTime(track.id, dragState.elementId, finalTime);
- }
- }
- }
- }
-
- if (isTrackThatStartedDrag) {
- endDragAction();
- // Clear snap point when drag ends
- onSnapPointChange?.(null);
- }
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.elementId,
- dragState.trackId,
- dragState.currentTime,
- zoomLevel,
- tracks,
- track.id,
- updateDragTime,
- updateElementStartTime,
- moveElementToTrack,
- endDragAction,
- selectedElements,
- selectElement,
- onSnapPointChange,
- ]);
-
- const handleElementMouseDown = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- setMouseDownLocation({ x: e.clientX, y: e.clientY });
-
- // Detect right-click (button 2) and handle selection without starting drag
- const isRightClick = e.button === 2;
- const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
-
- if (isRightClick) {
- // Handle right-click selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- // If element is not selected, select it (keep other selections if multi-select)
- if (!isSelected) {
- selectElement(track.id, element.id, isMultiSelect);
- }
- // If element is already selected, keep it selected
-
- // Don't start drag action for right-clicks
- return;
- }
-
- // Handle multi-selection for left-click with modifiers
- if (isMultiSelect) {
- selectElement(track.id, element.id, true);
- }
-
- // Calculate the offset from the left edge of the element to where the user clicked
- const elementElement = e.currentTarget as HTMLElement;
- const elementRect = elementElement.getBoundingClientRect();
- const clickOffsetX = e.clientX - elementRect.left;
- const clickOffsetTime =
- clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
-
- startDragAction(
- element.id,
- track.id,
- e.clientX,
- element.startTime,
- clickOffsetTime
- );
- };
-
- const handleElementClick = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- e.stopPropagation();
-
- // Check if mouse moved significantly
- if (mouseDownLocation) {
- const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
- const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
- // If it moved more than a few pixels, consider it a drag and not a click.
- if (deltaX > 5 || deltaY > 5) {
- setMouseDownLocation(null); // Reset for next interaction
- return;
- }
- }
-
- // Skip selection logic for multi-selection (handled in mousedown)
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- return;
- }
-
- // Handle single selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- if (!isSelected) {
- // If element is not selected, select it (replacing other selections)
- selectElement(track.id, element.id, false);
- }
- // If element is already selected, keep it selected (do nothing)
- };
-
- const handleTrackDragOver = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Handle both timeline elements and media items
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- // Calculate drop position for overlap checking
- const trackContainer = e.currentTarget.querySelector(
- ".track-elements-container"
- ) as HTMLElement;
- let dropTime = 0;
- if (trackContainer) {
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
- }
-
- // Check for potential overlaps and show appropriate feedback
- let wouldOverlap = false;
-
- if (hasMediaItem) {
- try {
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (mediaItemData) {
- const dragData: DragData = JSON.parse(mediaItemData);
-
- if (dragData.type === "text") {
- // Text elements have default duration of 5 seconds
- const newElementDuration = 5;
- const snappedTime = getDropSnappedTime(
- dropTime,
- newElementDuration
- );
- const newElementEnd = snappedTime + newElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return snappedTime < existingEnd && newElementEnd > existingStart;
- });
- } else {
- // Media elements
- const mediaItem = mediaItems.find(
- (item) => item.id === dragData.id
- );
- if (mediaItem) {
- const newElementDuration = mediaItem.duration || 5;
- const snappedTime = getDropSnappedTime(
- dropTime,
- newElementDuration
- );
- const newElementEnd = snappedTime + newElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return (
- snappedTime < existingEnd && newElementEnd > existingStart
- );
- });
- }
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- } else if (hasTimelineElement) {
- try {
- const timelineElementData = e.dataTransfer.getData(
- "application/x-timeline-element"
- );
- if (timelineElementData) {
- const { elementId, trackId: fromTrackId } =
- JSON.parse(timelineElementData);
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingElement = sourceTrack?.elements.find(
- (c: any) => c.id === elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const snappedTime = getDropSnappedTime(
- dropTime,
- movingElementDuration,
- elementId
- );
- const movingElementEnd = snappedTime + movingElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- if (fromTrackId === track.id && existingElement.id === elementId)
- return false;
-
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return (
- snappedTime < existingEnd && movingElementEnd > existingStart
- );
- });
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- }
-
- if (wouldOverlap) {
- e.dataTransfer.dropEffect = "none";
- setWouldOverlap(true);
- // Use default duration for position indicator
- setDropPosition(getDropSnappedTime(dropTime, 5));
- return;
- }
-
- e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
- setWouldOverlap(false);
- // Use default duration for position indicator
- setDropPosition(getDropSnappedTime(dropTime, 5));
- };
-
- const handleTrackDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- dragCounterRef.current++;
- setIsDropping(true);
- };
-
- const handleTrackDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- dragCounterRef.current--;
-
- if (dragCounterRef.current === 0) {
- setIsDropping(false);
- setWouldOverlap(false);
- setDropPosition(null);
- }
- };
-
- const handleTrackDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- // Debug logging
- console.log(
- JSON.stringify({
- message: "Drop event started in timeline track",
- dataTransferTypes: Array.from(e.dataTransfer.types),
- trackId: track.id,
- trackType: track.type,
- })
- );
-
- // Reset all drag states
- dragCounterRef.current = 0;
- setIsDropping(false);
- setWouldOverlap(false);
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- const trackContainer = e.currentTarget.querySelector(
- ".track-elements-container"
- ) as HTMLElement;
- if (!trackContainer) return;
-
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- const mouseY = e.clientY - rect.top; // Get Y position relative to this track
- const newStartTime =
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- const snappedTime = snapTimeToFrame(newStartTime, projectFps);
-
- // Calculate drop position relative to tracks
- const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
-
- // Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
- let dropPosition: "above" | "on" | "below";
- if (mouseY < 20) {
- dropPosition = "above";
- } else if (mouseY > 40) {
- dropPosition = "below";
- } else {
- dropPosition = "on";
- }
-
- try {
- if (hasTimelineElement) {
- // Handle timeline element movement
- const timelineElementData = e.dataTransfer.getData(
- "application/x-timeline-element"
- );
- if (!timelineElementData) return;
-
- const {
- elementId,
- trackId: fromTrackId,
- clickOffsetTime = 0,
- } = JSON.parse(timelineElementData);
-
- // Find the element being moved
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingElement = sourceTrack?.elements.find(
- (c: TimelineElementType) => c.id === elementId
- );
-
- if (!movingElement) {
- toast.error("Element not found");
- return;
- }
-
- // Check for overlaps with existing elements (excluding the moving element itself)
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
-
- // Adjust position based on where user clicked on the element
- const adjustedStartTime = newStartTime - clickOffsetTime;
- const snappedStartTime = getDropSnappedTime(
- adjustedStartTime,
- movingElementDuration,
- elementId
- );
- const finalStartTime = Math.max(0, snappedStartTime);
- const movingElementEnd = finalStartTime + movingElementDuration;
-
- const hasOverlap = track.elements.some((existingElement) => {
- // Skip the element being moved if it's on the same track
- if (fromTrackId === track.id && existingElement.id === elementId)
- return false;
-
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- finalStartTime < existingEnd && movingElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot move element here - it would overlap with existing elements"
- );
- return;
- }
-
- if (fromTrackId === track.id) {
- // Moving within same track
- updateElementStartTime(track.id, elementId, finalStartTime);
- } else {
- // Moving to different track
- moveElementToTrack(fromTrackId, track.id, elementId);
- requestAnimationFrame(() => {
- updateElementStartTime(track.id, elementId, finalStartTime);
- });
- }
- } else if (hasMediaItem) {
- // Handle media item drop
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (!mediaItemData) return;
-
- const dragData: DragData = JSON.parse(mediaItemData);
-
- if (dragData.type === "text") {
- let targetTrackId = track.id;
- let targetTrack = track;
-
- // Handle position-aware track creation for text
- if (track.type !== "text" || dropPosition !== "on") {
- // Text tracks should go above the main track
- const mainTrack = getMainTrack(tracks);
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // dropPosition === "on" but track is not text type
- // Insert above main track if main track exists, otherwise at top
- if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex;
- } else {
- insertIndex = 0; // Top of timeline
- }
- }
-
- targetTrackId = insertTrackAt("text", insertIndex);
- // Get the updated tracks array after creating the new track
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
-
- // Check for overlaps with existing elements in target track
- const newElementDuration = 5; // Default text duration
- const textSnappedTime = getDropSnappedTime(
- newStartTime,
- newElementDuration
- );
- const newElementEnd = textSnappedTime + newElementDuration;
-
- const hasOverlap = targetTrack.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- textSnappedTime < existingEnd && newElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return;
- }
-
- addElementToTrack(targetTrackId, {
- type: "text",
- name: dragData.name || "Text",
- content: dragData.content || "Default Text",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: textSnappedTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: 48,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- x: 0,
- y: 0,
- rotation: 0,
- opacity: 1,
- });
- } else {
- // Handle media items
- const mediaItem = mediaItems.find((item) => item.id === dragData.id);
-
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- let targetTrackId = track.id;
-
- // Check if track type is compatible
- const isVideoOrImage =
- dragData.type === "video" || dragData.type === "image";
- const isAudio = dragData.type === "audio";
- const isCompatible = isVideoOrImage
- ? canElementGoOnTrack("media", track.type)
- : isAudio
- ? canElementGoOnTrack("media", track.type)
- : false;
-
- let targetTrack = tracks.find((t) => t.id === targetTrackId);
-
- // Handle position-aware track creation for media elements
- if (!isCompatible || dropPosition !== "on") {
- if (isVideoOrImage) {
- // For video/image, check if we need a main track or additional media track
- const mainTrack = getMainTrack(tracks);
-
- if (!mainTrack) {
- // No main track exists, create it
- targetTrackId = addTrack("media");
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- } else if (
- mainTrack.elements.length === 0 &&
- dropPosition === "on"
- ) {
- // Main track exists and is empty, use it
- targetTrackId = mainTrack.id;
- targetTrack = mainTrack;
- } else {
- // Create new media track
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // Insert above main track
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex;
- }
-
- targetTrackId = insertTrackAt("media", insertIndex);
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- } else if (isAudio) {
- // Audio tracks go at the bottom
- const mainTrack = getMainTrack(tracks);
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // Insert after main track (bottom area)
- if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex + 1;
- } else {
- insertIndex = tracks.length; // Bottom of timeline
- }
- }
-
- targetTrackId = insertTrackAt("audio", insertIndex);
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- }
-
- if (!targetTrack) return;
-
- // Check for overlaps with existing elements in target track
- const newElementDuration = mediaItem.duration || 5;
- const mediaSnappedTime = getDropSnappedTime(
- newStartTime,
- newElementDuration
- );
- const newElementEnd = mediaSnappedTime + newElementDuration;
-
- const hasOverlap = targetTrack.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- mediaSnappedTime < existingEnd && newElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return;
- }
-
- addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: mediaSnappedTime,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- }
- } catch (error) {
- console.error("Error handling drop:", error);
- toast.error("Failed to add media to track");
- }
- };
-
- return (
- <div
- className="w-full h-full hover:bg-muted/20"
- onClick={(e) => {
- // If clicking empty area (not on an element), deselect all elements
- if (!(e.target as HTMLElement).closest(".timeline-element")) {
- clearSelectedElements();
- }
- }}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- >
- <div
- ref={timelineRef}
- className="h-full relative track-elements-container min-w-full"
- >
- {track.elements.length === 0 ? (
- <div
- className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
- isDropping
- ? wouldOverlap
- ? "border-red-500 bg-red-500/10 text-red-600"
- : "border-blue-500 bg-blue-500/10 text-blue-600"
- : "border-muted/30"
- }`}
- >
- {isDropping
- ? wouldOverlap
- ? "Cannot drop - would overlap"
- : "Drop element here"
- : ""}
- </div>
- ) : (
- <>
- {track.elements.map((element) => {
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- const handleElementSplit = () => {
- const { currentTime } = usePlaybackStore();
- const { splitElement } = useTimelineStore();
- const splitTime = currentTime;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (splitTime > effectiveStart && splitTime < effectiveEnd) {
- const secondElementId = splitElement(
- track.id,
- element.id,
- splitTime
- );
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- } else {
- toast.error("Playhead must be within element to split");
- }
- };
-
- const handleElementDuplicate = () => {
- const { addElementToTrack } = useTimelineStore.getState();
- const { id, ...elementWithoutId } = element;
- addElementToTrack(track.id, {
- ...elementWithoutId,
- name: element.name + " (copy)",
- startTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1,
- });
- };
-
- const handleElementDelete = () => {
- const { removeElementFromTrack } = useTimelineStore.getState();
- removeElementFromTrack(track.id, element.id);
- };
-
- return (
- <TimelineElement
- key={element.id}
- element={element}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onElementMouseDown={handleElementMouseDown}
- onElementClick={handleElementClick}
- />
- );
- })}
- </>
- )}
- </div>
- </div>
- );
-}
+"use client";
+
+import { useRef, useState, useEffect } from "react";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { toast } from "sonner";
+import { TimelineElement } from "./timeline-element";
+import {
+ TimelineTrack,
+ sortTracksByOrder,
+ ensureMainTrack,
+ getMainTrack,
+ canElementGoOnTrack,
+} from "@/types/timeline";
+import { usePlaybackStore } from "@/stores/playback-store";
+import type {
+ TimelineElement as TimelineElementType,
+ DragData,
+} from "@/types/timeline";
+import {
+ snapTimeToFrame,
+ TIMELINE_CONSTANTS,
+} from "@/constants/timeline-constants";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
+
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+ onSnapPointChange,
+}: {
+ track: TimelineTrack;
+ zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}) {
+ const { mediaItems } = useMediaStore();
+ const {
+ tracks,
+ addTrack,
+ moveElementToTrack,
+ updateElementStartTime,
+ updateElementStartTimeWithRipple,
+ addElementToTrack,
+ selectedElements,
+ selectElement,
+ dragState,
+ startDrag: startDragAction,
+ updateDragTime,
+ endDrag: endDragAction,
+ clearSelectedElements,
+ insertTrackAt,
+ snappingEnabled,
+ rippleEditingEnabled,
+ } = useTimelineStore();
+
+ const { currentTime } = usePlaybackStore();
+
+ // Initialize snapping hook
+ const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
+ snapThreshold: 10,
+ enableElementSnapping: snappingEnabled,
+ enablePlayheadSnapping: snappingEnabled,
+ });
+
+ // Helper function for drop snapping that tries both edges
+ const getDropSnappedTime = (
+ dropTime: number,
+ elementDuration: number,
+ excludeElementId?: string
+ ) => {
+ if (!snappingEnabled) {
+ // Use frame snapping if project has FPS, otherwise use decimal snapping
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ return snapTimeToFrame(dropTime, projectFps);
+ }
+
+ // Try snapping both start and end edges for drops
+ const startSnapResult = snapElementEdge(
+ dropTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ excludeElementId,
+ true // snap to start edge
+ );
+
+ const endSnapResult = snapElementEdge(
+ dropTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ excludeElementId,
+ false // snap to end edge
+ );
+
+ // Choose the snap result with the smaller distance (closer snap)
+ let bestSnapResult = startSnapResult;
+ if (
+ endSnapResult.snapPoint &&
+ (!startSnapResult.snapPoint ||
+ endSnapResult.snapDistance < startSnapResult.snapDistance)
+ ) {
+ bestSnapResult = endSnapResult;
+ }
+
+ return bestSnapResult.snappedTime;
+ };
+
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const [isDropping, setIsDropping] = useState(false);
+ const [dropPosition, setDropPosition] = useState<number | null>(null);
+ const [wouldOverlap, setWouldOverlap] = useState(false);
+ const dragCounterRef = useRef(0);
+ const [mouseDownLocation, setMouseDownLocation] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
+ // Set up mouse event listeners for drag
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!timelineRef.current) return;
+
+ // On first mouse move during drag, ensure the element is selected
+ if (dragState.elementId && dragState.trackId) {
+ const isSelected = selectedElements.some(
+ (c) =>
+ c.trackId === dragState.trackId &&
+ c.elementId === dragState.elementId
+ );
+
+ if (!isSelected) {
+ // Select this element (replacing other selections) since we're dragging it
+ selectElement(dragState.trackId, dragState.elementId, false);
+ }
+ }
+
+ const timelineRect = timelineRef.current.getBoundingClientRect();
+ const mouseX = e.clientX - timelineRect.left;
+ const mouseTime = Math.max(
+ 0,
+ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
+ );
+ const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
+
+ // Apply snapping if enabled
+ let finalTime = adjustedTime;
+ let snapPoint = null;
+ if (snappingEnabled) {
+ // Find the element being dragged to get its duration
+ let elementDuration = 5; // fallback duration
+ if (dragState.elementId && dragState.trackId) {
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const element = sourceTrack?.elements.find(
+ (e) => e.id === dragState.elementId
+ );
+ if (element) {
+ elementDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ }
+ }
+
+ // Try snapping both start and end edges
+ const startSnapResult = snapElementEdge(
+ adjustedTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ dragState.elementId || undefined,
+ true // snap to start edge
+ );
+
+ const endSnapResult = snapElementEdge(
+ adjustedTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ dragState.elementId || undefined,
+ false // snap to end edge
+ );
+
+ // Choose the snap result with the smaller distance (closer snap)
+ let bestSnapResult = startSnapResult;
+ if (
+ endSnapResult.snapPoint &&
+ (!startSnapResult.snapPoint ||
+ endSnapResult.snapDistance < startSnapResult.snapDistance)
+ ) {
+ bestSnapResult = endSnapResult;
+ }
+
+ finalTime = bestSnapResult.snappedTime;
+ snapPoint = bestSnapResult.snapPoint;
+
+ // Notify parent component about snap point change
+ onSnapPointChange?.(snapPoint);
+ } else {
+ // Use frame snapping if project has FPS, otherwise use decimal snapping
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ finalTime = snapTimeToFrame(adjustedTime, projectFps);
+
+ // Clear snap point when not snapping
+ onSnapPointChange?.(null);
+ }
+
+ updateDragTime(finalTime);
+ };
+
+ const handleMouseUp = (e: MouseEvent) => {
+ if (!dragState.elementId || !dragState.trackId) return;
+
+ // If this track initiated the drag, we should handle the mouse up regardless of where it occurs
+ const isTrackThatStartedDrag = dragState.trackId === track.id;
+
+ const timelineRect = timelineRef.current?.getBoundingClientRect();
+ if (!timelineRect) {
+ if (isTrackThatStartedDrag) {
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ dragState.elementId,
+ dragState.currentTime
+ );
+ } else {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId,
+ dragState.currentTime
+ );
+ }
+ endDragAction();
+ // Clear snap point when drag ends
+ onSnapPointChange?.(null);
+ }
+ return;
+ }
+
+ const isMouseOverThisTrack =
+ e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
+
+ if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
+
+ const finalTime = dragState.currentTime;
+
+ if (isMouseOverThisTrack) {
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const movingElement = sourceTrack?.elements.find(
+ (c) => c.id === dragState.elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const movingElementEnd = finalTime + movingElementDuration;
+
+ const targetTrack = tracks.find((t) => t.id === track.id);
+ const hasOverlap = targetTrack?.elements.some((existingElement) => {
+ if (
+ dragState.trackId === track.id &&
+ existingElement.id === dragState.elementId
+ ) {
+ return false;
+ }
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return finalTime < existingEnd && movingElementEnd > existingStart;
+ });
+
+ if (!hasOverlap) {
+ if (dragState.trackId === track.id) {
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ dragState.elementId,
+ finalTime
+ );
+ } else {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId,
+ finalTime
+ );
+ }
+ } else {
+ moveElementToTrack(
+ dragState.trackId,
+ track.id,
+ dragState.elementId
+ );
+ requestAnimationFrame(() => {
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ dragState.elementId!,
+ finalTime
+ );
+ } else {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId!,
+ finalTime
+ );
+ }
+ });
+ }
+ }
+ }
+ } else if (isTrackThatStartedDrag) {
+ // Mouse is not over this track, but this track started the drag
+ // This means user released over ruler/outside - update position within same track
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const movingElement = sourceTrack?.elements.find(
+ (c) => c.id === dragState.elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const movingElementEnd = finalTime + movingElementDuration;
+
+ const hasOverlap = track.elements.some((existingElement) => {
+ if (existingElement.id === dragState.elementId) {
+ return false;
+ }
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return finalTime < existingEnd && movingElementEnd > existingStart;
+ });
+
+ if (!hasOverlap) {
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ dragState.elementId,
+ finalTime
+ );
+ } else {
+ updateElementStartTime(track.id, dragState.elementId, finalTime);
+ }
+ }
+ }
+ }
+
+ if (isTrackThatStartedDrag) {
+ endDragAction();
+ // Clear snap point when drag ends
+ onSnapPointChange?.(null);
+ }
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [
+ dragState.isDragging,
+ dragState.clickOffsetTime,
+ dragState.elementId,
+ dragState.trackId,
+ dragState.currentTime,
+ zoomLevel,
+ tracks,
+ track.id,
+ updateDragTime,
+ updateElementStartTime,
+ moveElementToTrack,
+ endDragAction,
+ selectedElements,
+ selectElement,
+ onSnapPointChange,
+ ]);
+
+ const handleElementMouseDown = (
+ e: React.MouseEvent,
+ element: TimelineElementType
+ ) => {
+ setMouseDownLocation({ x: e.clientX, y: e.clientY });
+
+ // Detect right-click (button 2) and handle selection without starting drag
+ const isRightClick = e.button === 2;
+ const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
+
+ if (isRightClick) {
+ // Handle right-click selection
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ // If element is not selected, select it (keep other selections if multi-select)
+ if (!isSelected) {
+ selectElement(track.id, element.id, isMultiSelect);
+ }
+ // If element is already selected, keep it selected
+
+ // Don't start drag action for right-clicks
+ return;
+ }
+
+ // Handle multi-selection for left-click with modifiers
+ if (isMultiSelect) {
+ selectElement(track.id, element.id, true);
+ }
+
+ // Calculate the offset from the left edge of the element to where the user clicked
+ const elementElement = e.currentTarget as HTMLElement;
+ const elementRect = elementElement.getBoundingClientRect();
+ const clickOffsetX = e.clientX - elementRect.left;
+ const clickOffsetTime =
+ clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+
+ startDragAction(
+ element.id,
+ track.id,
+ e.clientX,
+ element.startTime,
+ clickOffsetTime
+ );
+ };
+
+ const handleElementClick = (
+ e: React.MouseEvent,
+ element: TimelineElementType
+ ) => {
+ e.stopPropagation();
+
+ // Check if mouse moved significantly
+ if (mouseDownLocation) {
+ const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
+ const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
+ // If it moved more than a few pixels, consider it a drag and not a click.
+ if (deltaX > 5 || deltaY > 5) {
+ setMouseDownLocation(null); // Reset for next interaction
+ return;
+ }
+ }
+
+ // Skip selection logic for multi-selection (handled in mousedown)
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ return;
+ }
+
+ // Handle single selection
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ if (!isSelected) {
+ // If element is not selected, select it (replacing other selections)
+ selectElement(track.id, element.id, false);
+ }
+ // If element is already selected, keep it selected (do nothing)
+ };
+
+ const handleTrackDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Handle both timeline elements and media items
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ // Calculate drop position for overlap checking
+ const trackContainer = e.currentTarget.querySelector(
+ ".track-elements-container"
+ ) as HTMLElement;
+ let dropTime = 0;
+ if (trackContainer) {
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+ }
+
+ // Check for potential overlaps and show appropriate feedback
+ let wouldOverlap = false;
+
+ if (hasMediaItem) {
+ try {
+ const mediaItemData = e.dataTransfer.getData(
+ "application/x-media-item"
+ );
+ if (mediaItemData) {
+ const dragData: DragData = JSON.parse(mediaItemData);
+
+ if (dragData.type === "text") {
+ // Text elements have default duration of 5 seconds
+ const newElementDuration = 5;
+ const snappedTime = getDropSnappedTime(
+ dropTime,
+ newElementDuration
+ );
+ const newElementEnd = snappedTime + newElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return snappedTime < existingEnd && newElementEnd > existingStart;
+ });
+ } else {
+ // Media elements
+ const mediaItem = mediaItems.find(
+ (item) => item.id === dragData.id
+ );
+ if (mediaItem) {
+ const newElementDuration = mediaItem.duration || 5;
+ const snappedTime = getDropSnappedTime(
+ dropTime,
+ newElementDuration
+ );
+ const newElementEnd = snappedTime + newElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return (
+ snappedTime < existingEnd && newElementEnd > existingStart
+ );
+ });
+ }
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ } else if (hasTimelineElement) {
+ try {
+ const timelineElementData = e.dataTransfer.getData(
+ "application/x-timeline-element"
+ );
+ if (timelineElementData) {
+ const { elementId, trackId: fromTrackId } =
+ JSON.parse(timelineElementData);
+ const sourceTrack = tracks.find(
+ (t: TimelineTrack) => t.id === fromTrackId
+ );
+ const movingElement = sourceTrack?.elements.find(
+ (c: any) => c.id === elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const snappedTime = getDropSnappedTime(
+ dropTime,
+ movingElementDuration,
+ elementId
+ );
+ const movingElementEnd = snappedTime + movingElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ if (fromTrackId === track.id && existingElement.id === elementId)
+ return false;
+
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return (
+ snappedTime < existingEnd && movingElementEnd > existingStart
+ );
+ });
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ }
+
+ if (wouldOverlap) {
+ e.dataTransfer.dropEffect = "none";
+ setWouldOverlap(true);
+ // Use default duration for position indicator
+ setDropPosition(getDropSnappedTime(dropTime, 5));
+ return;
+ }
+
+ e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
+ setWouldOverlap(false);
+ // Use default duration for position indicator
+ setDropPosition(getDropSnappedTime(dropTime, 5));
+ };
+
+ const handleTrackDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ dragCounterRef.current++;
+ setIsDropping(true);
+ };
+
+ const handleTrackDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ dragCounterRef.current--;
+
+ if (dragCounterRef.current === 0) {
+ setIsDropping(false);
+ setWouldOverlap(false);
+ setDropPosition(null);
+ }
+ };
+
+ const handleTrackDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Debug logging
+ console.log(
+ JSON.stringify({
+ message: "Drop event started in timeline track",
+ dataTransferTypes: Array.from(e.dataTransfer.types),
+ trackId: track.id,
+ trackType: track.type,
+ })
+ );
+
+ // Reset all drag states
+ dragCounterRef.current = 0;
+ setIsDropping(false);
+ setWouldOverlap(false);
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ const trackContainer = e.currentTarget.querySelector(
+ ".track-elements-container"
+ ) as HTMLElement;
+ if (!trackContainer) return;
+
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ const mouseY = e.clientY - rect.top; // Get Y position relative to this track
+ const newStartTime =
+ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const snappedTime = snapTimeToFrame(newStartTime, projectFps);
+
+ // Calculate drop position relative to tracks
+ const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
+
+ // Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
+ let dropPosition: "above" | "on" | "below";
+ if (mouseY < 20) {
+ dropPosition = "above";
+ } else if (mouseY > 40) {
+ dropPosition = "below";
+ } else {
+ dropPosition = "on";
+ }
+
+ try {
+ if (hasTimelineElement) {
+ // Handle timeline element movement
+ const timelineElementData = e.dataTransfer.getData(
+ "application/x-timeline-element"
+ );
+ if (!timelineElementData) return;
+
+ const {
+ elementId,
+ trackId: fromTrackId,
+ clickOffsetTime = 0,
+ } = JSON.parse(timelineElementData);
+
+ // Find the element being moved
+ const sourceTrack = tracks.find(
+ (t: TimelineTrack) => t.id === fromTrackId
+ );
+ const movingElement = sourceTrack?.elements.find(
+ (c: TimelineElementType) => c.id === elementId
+ );
+
+ if (!movingElement) {
+ toast.error("Element not found");
+ return;
+ }
+
+ // Check for overlaps with existing elements (excluding the moving element itself)
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+
+ // Adjust position based on where user clicked on the element
+ const adjustedStartTime = newStartTime - clickOffsetTime;
+ const snappedStartTime = getDropSnappedTime(
+ adjustedStartTime,
+ movingElementDuration,
+ elementId
+ );
+ const finalStartTime = Math.max(0, snappedStartTime);
+ const movingElementEnd = finalStartTime + movingElementDuration;
+
+ const hasOverlap = track.elements.some((existingElement) => {
+ // Skip the element being moved if it's on the same track
+ if (fromTrackId === track.id && existingElement.id === elementId)
+ return false;
+
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return (
+ finalStartTime < existingEnd && movingElementEnd > existingStart
+ );
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot move element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ if (fromTrackId === track.id) {
+ // Moving within same track
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ elementId,
+ finalStartTime
+ );
+ } else {
+ updateElementStartTime(track.id, elementId, finalStartTime);
+ }
+ } else {
+ // Moving to different track
+ moveElementToTrack(fromTrackId, track.id, elementId);
+ requestAnimationFrame(() => {
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ elementId,
+ finalStartTime
+ );
+ } else {
+ updateElementStartTime(track.id, elementId, finalStartTime);
+ }
+ });
+ }
+ } else if (hasMediaItem) {
+ // Handle media item drop
+ const mediaItemData = e.dataTransfer.getData(
+ "application/x-media-item"
+ );
+ if (!mediaItemData) return;
+
+ const dragData: DragData = JSON.parse(mediaItemData);
+
+ if (dragData.type === "text") {
+ let targetTrackId = track.id;
+ let targetTrack = track;
+
+ // Handle position-aware track creation for text
+ if (track.type !== "text" || dropPosition !== "on") {
+ // Text tracks should go above the main track
+ const mainTrack = getMainTrack(tracks);
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // dropPosition === "on" but track is not text type
+ // Insert above main track if main track exists, otherwise at top
+ if (mainTrack) {
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
+ insertIndex = mainTrackIndex;
+ } else {
+ insertIndex = 0; // Top of timeline
+ }
+ }
+
+ targetTrackId = insertTrackAt("text", insertIndex);
+ // Get the updated tracks array after creating the new track
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+
+ // Check for overlaps with existing elements in target track
+ const newElementDuration = 5; // Default text duration
+ const textSnappedTime = getDropSnappedTime(
+ newStartTime,
+ newElementDuration
+ );
+ const newElementEnd = textSnappedTime + newElementDuration;
+
+ const hasOverlap = targetTrack.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return (
+ textSnappedTime < existingEnd && newElementEnd > existingStart
+ );
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ addElementToTrack(targetTrackId, {
+ type: "text",
+ name: dragData.name || "Text",
+ content: dragData.content || "Default Text",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: textSnappedTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: 48,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ x: 0,
+ y: 0,
+ rotation: 0,
+ opacity: 1,
+ });
+ } else {
+ // Handle media items
+ const mediaItem = mediaItems.find((item) => item.id === dragData.id);
+
+ if (!mediaItem) {
+ toast.error("Media item not found");
+ return;
+ }
+
+ let targetTrackId = track.id;
+
+ // Check if track type is compatible
+ const isVideoOrImage =
+ dragData.type === "video" || dragData.type === "image";
+ const isAudio = dragData.type === "audio";
+ const isCompatible = isVideoOrImage
+ ? canElementGoOnTrack("media", track.type)
+ : isAudio
+ ? canElementGoOnTrack("media", track.type)
+ : false;
+
+ let targetTrack = tracks.find((t) => t.id === targetTrackId);
+
+ // Handle position-aware track creation for media elements
+ if (!isCompatible || dropPosition !== "on") {
+ if (isVideoOrImage) {
+ // For video/image, check if we need a main track or additional media track
+ const mainTrack = getMainTrack(tracks);
+
+ if (!mainTrack) {
+ // No main track exists, create it
+ targetTrackId = addTrack("media");
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ } else if (
+ mainTrack.elements.length === 0 &&
+ dropPosition === "on"
+ ) {
+ // Main track exists and is empty, use it
+ targetTrackId = mainTrack.id;
+ targetTrack = mainTrack;
+ } else {
+ // Create new media track
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // Insert above main track
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
+ insertIndex = mainTrackIndex;
+ }
+
+ targetTrackId = insertTrackAt("media", insertIndex);
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+ } else if (isAudio) {
+ // Audio tracks go at the bottom
+ const mainTrack = getMainTrack(tracks);
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // Insert after main track (bottom area)
+ if (mainTrack) {
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
+ insertIndex = mainTrackIndex + 1;
+ } else {
+ insertIndex = tracks.length; // Bottom of timeline
+ }
+ }
+
+ targetTrackId = insertTrackAt("audio", insertIndex);
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+ }
+
+ if (!targetTrack) return;
+
+ // Check for overlaps with existing elements in target track
+ const newElementDuration = mediaItem.duration || 5;
+ const mediaSnappedTime = getDropSnappedTime(
+ newStartTime,
+ newElementDuration
+ );
+ const newElementEnd = mediaSnappedTime + newElementDuration;
+
+ const hasOverlap = targetTrack.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return (
+ mediaSnappedTime < existingEnd && newElementEnd > existingStart
+ );
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: mediaItem.id,
+ name: mediaItem.name,
+ duration: mediaItem.duration || 5,
+ startTime: mediaSnappedTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }
+ }
+ } catch (error) {
+ console.error("Error handling drop:", error);
+ toast.error("Failed to add media to track");
+ }
+ };
+
+ return (
+ <div
+ className="w-full h-full hover:bg-muted/20"
+ onClick={(e) => {
+ // If clicking empty area (not on an element), deselect all elements
+ if (!(e.target as HTMLElement).closest(".timeline-element")) {
+ clearSelectedElements();
+ }
+ }}
+ onDragOver={handleTrackDragOver}
+ onDragEnter={handleTrackDragEnter}
+ onDragLeave={handleTrackDragLeave}
+ onDrop={handleTrackDrop}
+ >
+ <div
+ ref={timelineRef}
+ className="h-full relative track-elements-container min-w-full"
+ >
+ {track.elements.length === 0 ? (
+ <div
+ className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
+ isDropping
+ ? wouldOverlap
+ ? "border-red-500 bg-red-500/10 text-red-600"
+ : "border-blue-500 bg-blue-500/10 text-blue-600"
+ : "border-muted/30"
+ }`}
+ >
+ {isDropping
+ ? wouldOverlap
+ ? "Cannot drop - would overlap"
+ : "Drop element here"
+ : ""}
+ </div>
+ ) : (
+ <>
+ {track.elements.map((element) => {
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ const handleElementSplit = () => {
+ const { currentTime } = usePlaybackStore();
+ const { splitElement } = useTimelineStore();
+ const splitTime = currentTime;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (splitTime > effectiveStart && splitTime < effectiveEnd) {
+ const secondElementId = splitElement(
+ track.id,
+ element.id,
+ splitTime
+ );
+ if (!secondElementId) {
+ toast.error("Failed to split element");
+ }
+ } else {
+ toast.error("Playhead must be within element to split");
+ }
+ };
+
+ const handleElementDuplicate = () => {
+ const { addElementToTrack } = useTimelineStore.getState();
+ const { id, ...elementWithoutId } = element;
+ addElementToTrack(track.id, {
+ ...elementWithoutId,
+ name: element.name + " (copy)",
+ startTime:
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1,
+ });
+ };
+
+ const handleElementDelete = () => {
+ const { removeElementFromTrack } = useTimelineStore.getState();
+ removeElementFromTrack(track.id, element.id);
+ };
+
+ return (
+ <TimelineElement
+ key={element.id}
+ element={element}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onElementMouseDown={handleElementMouseDown}
+ onElementClick={handleElementClick}
+ />
+ );
+ })}
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 8eb00875..9e68fd4d 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -16,8 +16,6 @@ import {
Music,
TypeIcon,
Lock,
- ZoomIn,
- ZoomOut,
LockOpen,
Link,
} from "lucide-react";
@@ -58,9 +56,6 @@ import {
TIMELINE_CONSTANTS,
snapTimeToFrame,
} from "@/constants/timeline-constants";
-import { Slider } from "../ui/slider";
-import TimelineCanvasRuler from "./timeline-canvas/timeline-canvas-ruler";
-import TimelineCanvasRulerWrapper from "./timeline-canvas/timeline-canvas-ruler-wrapper";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their elements.
@@ -82,8 +77,6 @@ export function Timeline() {
splitAndKeepRight,
toggleTrackMute,
separateAudio,
- undo,
- redo,
snappingEnabled,
toggleSnapping,
rippleEditingEnabled,
@@ -102,14 +95,19 @@ export function Timeline() {
const rulerRef = useRef<HTMLDivElement>(null);
const [isInTimeline, setIsInTimeline] = useState(false);
+ // Track mouse down/up for distinguishing clicks from drag/resize ends
+ const mouseTrackingRef = useRef({
+ isMouseDown: false,
+ downX: 0,
+ downY: 0,
+ downTime: 0,
+ });
+
// Timeline zoom functionality
- const {
- zoomLevel,
- zoomStep,
- handleChangeZoomLevel,
- handleChangeZoomStep,
- handleWheel,
- } = useTimelineZoom();
+ const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
+ containerRef: timelineRef,
+ isInTimeline,
+ });
// Old marquee selection removed - using new SelectionBox component instead
@@ -122,6 +120,7 @@ export function Timeline() {
// Scroll synchronization and auto-scroll to playhead
const rulerScrollRef = useRef<HTMLDivElement>(null);
+ const tracksScrollRef = useRef<HTMLDivElement>(null);
const trackLabelsRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
@@ -138,6 +137,8 @@ export function Timeline() {
seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
});
// Selection box functionality
@@ -168,18 +169,66 @@ export function Timeline() {
setCurrentSnapPoint(snapPoint);
}, []);
+ // Track mouse down to distinguish real clicks from drag/resize ends
+ const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
+ // Only track mouse down on timeline background areas (not elements)
+ const target = e.target as HTMLElement;
+ const isTimelineBackground =
+ !target.closest(".timeline-element") &&
+ !playheadRef.current?.contains(target) &&
+ !target.closest("[data-track-labels]");
+
+ if (isTimelineBackground) {
+ mouseTrackingRef.current = {
+ isMouseDown: true,
+ downX: e.clientX,
+ downY: e.clientY,
+ downTime: e.timeStamp,
+ };
+ }
+ }, []);
+
// Timeline content click to seek handler
const handleTimelineContentClick = useCallback(
(e: React.MouseEvent) => {
- console.log(
- JSON.stringify({
- timelineClick: {
- isSelecting,
- justFinishedSelecting,
- willReturn: isSelecting || justFinishedSelecting,
- },
- })
- );
+ const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
+
+ // Reset mouse tracking
+ mouseTrackingRef.current = {
+ isMouseDown: false,
+ downX: 0,
+ downY: 0,
+ downTime: 0,
+ };
+
+ // Only process as click if we tracked a mouse down on timeline background
+ if (!isMouseDown) {
+ console.log(
+ JSON.stringify({
+ ignoredClickWithoutMouseDown: true,
+ timeStamp: e.timeStamp,
+ })
+ );
+ return;
+ }
+
+ // Check if mouse moved significantly (indicates drag, not click)
+ const deltaX = Math.abs(e.clientX - downX);
+ const deltaY = Math.abs(e.clientY - downY);
+ const deltaTime = e.timeStamp - downTime;
+
+ if (deltaX > 5 || deltaY > 5 || deltaTime > 500) {
+ console.log(
+ JSON.stringify({
+ ignoredDragNotClick: true,
+ deltaX,
+ deltaY,
+ deltaTime,
+ timeStamp: e.timeStamp,
+ })
+ );
+ return;
+ }
// Don't seek if this was a selection box operation
if (isSelecting || justFinishedSelecting) {
@@ -207,14 +256,14 @@ export function Timeline() {
clearSelectedElements();
// Determine if we're clicking in ruler or tracks area
- const isTracksAreaClick = (e.target as HTMLElement).closest(
- "[data-tracks-area]"
+ const isRulerClick = (e.target as HTMLElement).closest(
+ "[data-ruler-area]"
);
let mouseX: number;
let scrollLeft = 0;
- if (isTracksAreaClick) {
+ if (isRulerClick) {
// Calculate based on ruler position
const rulerContent = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
@@ -224,7 +273,14 @@ export function Timeline() {
mouseX = e.clientX - rect.left;
scrollLeft = rulerContent.scrollLeft;
} else {
- return;
+ // Calculate based on tracks content position
+ const tracksContent = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ if (!tracksContent) return;
+ const rect = tracksContent.getBoundingClientRect();
+ mouseX = e.clientX - rect.left;
+ scrollLeft = tracksContent.scrollLeft;
}
const rawTime = Math.max(
@@ -247,6 +303,7 @@ export function Timeline() {
zoomLevel,
seek,
rulerScrollRef,
+ tracksScrollRef,
clearSelectedElements,
isSelecting,
justFinishedSelecting,
@@ -514,11 +571,14 @@ export function Timeline() {
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
+ const tracksViewport = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
- if (!rulerViewport) return;
+ if (!rulerViewport || !tracksViewport) return;
// Horizontal scroll synchronization between ruler and tracks
const handleRulerScroll = () => {
@@ -526,6 +586,7 @@ export function Timeline() {
if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
lastRulerSync.current = now;
isUpdatingRef.current = true;
+ tracksViewport.scrollLeft = rulerViewport.scrollLeft;
isUpdatingRef.current = false;
};
const handleTracksScroll = () => {
@@ -533,10 +594,12 @@ export function Timeline() {
if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
lastTracksSync.current = now;
isUpdatingRef.current = true;
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft;
isUpdatingRef.current = false;
};
rulerViewport.addEventListener("scroll", handleRulerScroll);
+ tracksViewport.addEventListener("scroll", handleTracksScroll);
// Vertical scroll synchronization between track labels and tracks content
if (trackLabelsViewport) {
@@ -546,6 +609,7 @@ export function Timeline() {
return;
lastVerticalSync.current = now;
isUpdatingRef.current = true;
+ tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
isUpdatingRef.current = false;
};
const handleTracksVerticalScroll = () => {
@@ -554,22 +618,30 @@ export function Timeline() {
return;
lastVerticalSync.current = now;
isUpdatingRef.current = true;
+ trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
isUpdatingRef.current = false;
};
trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
+ tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
return () => {
rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
trackLabelsViewport.removeEventListener(
"scroll",
handleTrackLabelsScroll
);
+ tracksViewport.removeEventListener(
+ "scroll",
+ handleTracksVerticalScroll
+ );
};
}
return () => {
rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
};
}, []);
@@ -729,39 +801,6 @@ export function Timeline() {
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- className="ml-auto"
- variant="text"
- size="icon"
- onClick={() => handleChangeZoomLevel(zoomLevel - 0.15)}
- >
- <ZoomOut className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Zoom Out</TooltipContent>
- </Tooltip>
- <Slider
- className="max-w-24"
- max={TIMELINE_CONSTANTS.MAX_ZOOM_STEP}
- min={0}
- step={1}
- value={[zoomStep]}
- onValueChange={(value) => handleChangeZoomStep(value[0])}
- />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={() => handleChangeZoomLevel(zoomLevel + 0.15)}
- >
- <ZoomIn className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Zoom In</TooltipContent>
- </Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-1">
@@ -770,9 +809,9 @@ export function Timeline() {
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={toggleSnapping}>
{snappingEnabled ? (
- <Lock className="h-4 w-4" />
- ) : (
<LockOpen className="h-4 w-4 text-primary" />
+ ) : (
+ <Lock className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
@@ -813,6 +852,7 @@ export function Timeline() {
seek={seek}
rulerRef={rulerRef}
rulerScrollRef={rulerScrollRef}
+ tracksScrollRef={tracksScrollRef}
trackLabelsRef={trackLabelsRef}
timelineRef={timelineRef}
playheadRef={playheadRef}
@@ -830,38 +870,114 @@ export function Timeline() {
/>
{/* Timeline Header with Ruler */}
<div className="flex bg-panel sticky top-0 z-10">
- <div className="w-48 flex-shrink-0 bg-muted/30 border-r h-5 px-3" />
+ {/* Track Labels Header */}
+ <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
+ {/* Empty space */}
+ <span className="text-sm font-medium text-muted-foreground opacity-0">
+ .
+ </span>
+ </div>
- <div className="flex-1 overflow-hidden h-5">
+ {/* Timeline Ruler */}
+ <div
+ className="flex-1 relative overflow-hidden h-4"
+ onWheel={handleWheel}
+ onMouseDown={handleSelectionMouseDown}
+ onClick={handleTimelineContentClick}
+ data-ruler-area
+ >
<ScrollArea className="w-full" ref={rulerScrollRef}>
- <TimelineCanvasRulerWrapper
+ <div
ref={rulerRef}
+ className="relative h-4 select-none cursor-default"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
onMouseDown={handleRulerMouseDown}
>
- <TimelineCanvasRuler
- zoomLevel={zoomLevel}
- duration={duration}
- width={dynamicTimelineWidth}
- />
- </TimelineCanvasRulerWrapper>
+ {/* Time markers */}
+ {(() => {
+ // Calculate appropriate time interval based on zoom level
+ const getTimeInterval = (zoom: number) => {
+ const pixelsPerSecond =
+ TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom;
+ if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in
+ if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in
+ if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
+ if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out
+ if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out
+ if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out
+ return 30; // Every 30s when extremely zoomed out
+ };
+
+ const interval = getTimeInterval(zoomLevel);
+ const markerCount = Math.ceil(duration / interval) + 1;
+
+ return Array.from({ length: markerCount }, (_, i) => {
+ const time = i * interval;
+ if (time > duration) return null;
+
+ const isMainMarker =
+ time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
+
+ return (
+ <div
+ key={i}
+ className={`absolute top-0 bottom-0 ${
+ isMainMarker
+ ? "border-l border-muted-foreground/40"
+ : "border-l border-muted-foreground/20"
+ }`}
+ style={{
+ left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ }}
+ >
+ <span
+ className={`absolute top-1 left-1 text-[0.6rem] ${
+ isMainMarker
+ ? "text-muted-foreground font-medium"
+ : "text-muted-foreground/70"
+ }`}
+ >
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (minutes > 0) {
+ return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (interval >= 1) {
+ return `${Math.floor(secs)}s`;
+ } else {
+ return `${secs.toFixed(1)}s`;
+ }
+ };
+ return formatTime(time);
+ })()}
+ </span>
+ </div>
+ );
+ }).filter(Boolean);
+ })()}
+ </div>
</ScrollArea>
</div>
</div>
{/* Tracks Area */}
- <ScrollArea className="w-full h-full">
- <div
- className="flex-1 flex overflow-hidden overflow-y-auto"
- data-tracks-area
- >
- {/* Track Labels */}
- {tracks.length > 0 && (
- <div
- ref={trackLabelsRef}
- className="w-48 flex-shrink-0 border-r bg-panel-accent "
- data-track-labels
- >
- <div className="flex flex-col gap-1" ref={trackLabelsScrollRef}>
+ <div className="flex-1 flex overflow-hidden">
+ {/* Track Labels */}
+ {tracks.length > 0 && (
+ <div
+ ref={trackLabelsRef}
+ className="w-48 flex-shrink-0 border-r border-black overflow-y-auto"
+ data-track-labels
+ >
+ <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
+ <div className="flex flex-col gap-1">
{tracks.map((track) => (
<div
key={track.id}
@@ -879,23 +995,28 @@ export function Timeline() {
</div>
))}
</div>
- </div>
- )}
+ </ScrollArea>
+ </div>
+ )}
- {/* Timeline Tracks Content */}
- <div
- className="flex-1 relative overflow-hidden"
- onWheel={handleWheel}
- onMouseDown={handleSelectionMouseDown}
- onClick={handleTimelineContentClick}
- ref={tracksContainerRef}
- >
- <SelectionBox
- startPos={selectionBox?.startPos || null}
- currentPos={selectionBox?.currentPos || null}
- containerRef={tracksContainerRef}
- isActive={selectionBox?.isActive || false}
- />
+ {/* Timeline Tracks Content */}
+ <div
+ className="flex-1 relative overflow-hidden"
+ onWheel={handleWheel}
+ onMouseDown={(e) => {
+ handleTimelineMouseDown(e);
+ handleSelectionMouseDown(e);
+ }}
+ onClick={handleTimelineContentClick}
+ ref={tracksContainerRef}
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
+ <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
<div
className="relative flex-1"
style={{
@@ -949,9 +1070,9 @@ export function Timeline() {
</>
)}
</div>
- </div>
+ </ScrollArea>
</div>
- </ScrollArea>
+ </div>
</div>
</div>
);
diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx
index a48b3c21..91cfb01a 100644
--- a/apps/web/src/components/keyboard-shortcuts-help.tsx
+++ b/apps/web/src/components/keyboard-shortcuts-help.tsx
@@ -1,8 +1,7 @@
"use client";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
-import { Keyboard } from "lucide-react";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { Button } from "./ui/button";
import {
Dialog,
@@ -13,6 +12,13 @@ import {
DialogTrigger,
} from "./ui/dialog";
import { getPlatformSpecialKey } from "@/lib/utils";
+import { Keyboard } from "lucide-react";
+import {
+ useKeyboardShortcutsHelp,
+ KeyboardShortcut,
+} from "@/hooks/use-keyboard-shortcuts-help";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { toast } from "sonner";
const modifier: {
[key: string]: string;
@@ -31,7 +37,15 @@ function getKeyWithModifier(key: string) {
return modifier[key] || key;
}
-const ShortcutItem = ({ shortcut }: { shortcut: any }) => {
+const ShortcutItem = ({
+ shortcut,
+ recordingKey,
+ onStartRecording
+}: {
+ shortcut: KeyboardShortcut;
+ recordingKey: string | null;
+ onStartRecording: (keyId: string, shortcut: KeyboardShortcut) => void;
+}) => {
// Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
const displayKeys = shortcut.keys.filter((key: string) => {
if (
@@ -55,11 +69,21 @@ const ShortcutItem = ({ shortcut }: { shortcut: any }) => {
{displayKeys.map((key: string, index: number) => (
<div key={index} className="flex items-center gap-1">
<div className="flex items-center">
- {key.split("+").map((keyPart: string, partIndex: number) => (
- <ShortcutKey key={partIndex}>
- {getKeyWithModifier(keyPart)}
- </ShortcutKey>
- ))}
+ {key.split("+").map((keyPart: string, partIndex: number) => {
+ const keyId = `${shortcut.id}-${index}-${partIndex}`;
+ return (
+ <EditableShortcutKey
+ key={partIndex}
+ keyId={keyId}
+ originalKey={key}
+ shortcut={shortcut}
+ isRecording={recordingKey === keyId}
+ onStartRecording={() => onStartRecording(keyId, shortcut)}
+ >
+ {getKeyWithModifier(keyPart)}
+ </EditableShortcutKey>
+ );
+ })}
</div>
{index < displayKeys.length - 1 && (
<span className="text-xs text-muted-foreground">or</span>
@@ -71,14 +95,111 @@ const ShortcutItem = ({ shortcut }: { shortcut: any }) => {
);
};
+const EditableShortcutKey = ({
+ children,
+ keyId,
+ originalKey,
+ shortcut,
+ isRecording,
+ onStartRecording
+}: {
+ children: React.ReactNode;
+ keyId: string;
+ originalKey: string;
+ shortcut: KeyboardShortcut;
+ isRecording: boolean;
+ onStartRecording: () => void;
+}) => {
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onStartRecording();
+ };
+
+ return (
+ <kbd
+ className={`inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1 cursor-pointer hover:bg-opacity-80 ${
+ isRecording
+ ? "border-primary bg-primary/10"
+ : "border-white/10 bg-black/20"
+ }`}
+ onClick={handleClick}
+ title={isRecording ? "Press any key combination..." : "Click to edit shortcut"}
+ >
+ {children}
+ </kbd>
+ );
+};
+
export const KeyboardShortcutsHelp = () => {
const [open, setOpen] = useState(false);
+ const [recordingKey, setRecordingKey] = useState<string | null>(null);
+ const [recordingShortcut, setRecordingShortcut] = useState<KeyboardShortcut | null>(null);
+
+ const {
+ updateKeybinding,
+ removeKeybinding,
+ getKeybindingString,
+ validateKeybinding,
+ getKeybindingsForAction,
+ } = useKeybindingsStore();
- // Get shortcuts from centralized hook (disabled so it doesn't add event listeners)
- const { shortcuts } = useKeyboardShortcuts({ enabled: false });
+ // Get shortcuts from centralized hook
+ const { shortcuts } = useKeyboardShortcutsHelp();
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
+ useEffect(() => {
+ if (!recordingKey || !recordingShortcut) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const keyString = getKeybindingString(e);
+ if (keyString) {
+ // Auto-save the new keybinding
+ const conflict = validateKeybinding(keyString, recordingShortcut.action);
+ if (conflict) {
+ toast.error(
+ `Key "${keyString}" is already bound to "${conflict.existingAction}"`
+ );
+ setRecordingKey(null);
+ setRecordingShortcut(null);
+ return;
+ }
+
+ // Remove old keybindings for this action
+ const oldKeys = getKeybindingsForAction(recordingShortcut.action);
+ oldKeys.forEach((key) => removeKeybinding(key));
+
+ // Add new keybinding
+ updateKeybinding(keyString, recordingShortcut.action);
+
+ setRecordingKey(null);
+ setRecordingShortcut(null);
+ }
+ };
+
+ const handleClickOutside = (e: MouseEvent) => {
+ setRecordingKey(null);
+ setRecordingShortcut(null);
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ document.addEventListener("click", handleClickOutside);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ document.removeEventListener("click", handleClickOutside);
+ };
+ }, [recordingKey, recordingShortcut, getKeybindingString, updateKeybinding, removeKeybinding, validateKeybinding, getKeybindingsForAction]);
+
+ const handleStartRecording = (keyId: string, shortcut: KeyboardShortcut) => {
+ setRecordingKey(keyId);
+ setRecordingShortcut(shortcut);
+ };
+
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -87,7 +208,7 @@ export const KeyboardShortcutsHelp = () => {
Shortcuts
</Button>
</DialogTrigger>
- <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-hidden flex">
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5" />
@@ -95,7 +216,7 @@ export const KeyboardShortcutsHelp = () => {
</DialogTitle>
<DialogDescription>
Speed up your video editing workflow with these keyboard shortcuts.
- Most shortcuts work when the timeline is focused.
+ Click any shortcut key to edit it.
</DialogDescription>
</DialogHeader>
@@ -109,7 +230,12 @@ export const KeyboardShortcutsHelp = () => {
{shortcuts
.filter((shortcut) => shortcut.category === category)
.map((shortcut, index) => (
- <ShortcutItem key={index} shortcut={shortcut} />
+ <ShortcutItem
+ key={index}
+ shortcut={shortcut}
+ recordingKey={recordingKey}
+ onStartRecording={handleStartRecording}
+ />
))}
</div>
</div>
@@ -119,17 +245,3 @@ export const KeyboardShortcutsHelp = () => {
</Dialog>
);
};
-
-function ShortcutKey({ children }: { children: React.ReactNode }) {
- return (
- <kbd
- className="inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1"
- style={{
- backgroundColor: "rgba(0, 0, 0, 0.2)",
- borderColor: "rgba(255, 255, 255, 0.1)"
- }}
- >
- {children}
- </kbd>
- );
-};
diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts
new file mode 100644
index 00000000..5f1883f1
--- /dev/null
+++ b/apps/web/src/constants/actions.ts
@@ -0,0 +1,293 @@
+/* An `action` is a unique verb that is associated with certain thing that can be done on OpenCut.
+ * For example, toggling playback or seeking.
+ */
+
+import {
+ useEffect,
+ useRef,
+ useState,
+ useCallback,
+ MutableRefObject,
+} from "react";
+
+// Simple event emitter for action changes
+class ActionEmitter {
+ private listeners: Array<(actions: Action[]) => void> = [];
+
+ subscribe(listener: (actions: Action[]) => void) {
+ this.listeners.push(listener);
+ return () => {
+ this.listeners = this.listeners.filter((l) => l !== listener);
+ };
+ }
+
+ emit(actions: Action[]) {
+ this.listeners.forEach((listener) => listener(actions));
+ }
+}
+
+const actionEmitter = new ActionEmitter();
+
+export type Action =
+ | "toggle-play" // Toggle play/pause state
+ | "stop-playback" // Stop playback
+ | "seek-forward" // Seek forward in playback
+ | "seek-backward" // Seek backward in playback
+ | "frame-step-forward" // Step forward by one frame
+ | "frame-step-backward" // Step backward by one frame
+ | "jump-forward" // Jump forward by 5 seconds
+ | "jump-backward" // Jump backward by 5 seconds
+ | "goto-start" // Go to timeline start
+ | "goto-end" // Go to timeline end
+ | "split-element" // Split element at current time
+ | "delete-selected" // Delete selected elements
+ | "select-all" // Select all elements
+ | "duplicate-selected" // Duplicate selected element
+ | "toggle-snapping" // Toggle snapping
+ | "undo" // Undo last action
+ | "redo"; // Redo last undone action
+
+/**
+ * Defines the arguments, if present for a given type that is required to be passed on
+ * invocation and will be passed to action handlers.
+ *
+ * This type is supposed to be an object with the key being one of the actions mentioned above.
+ * The value to the key can be anything.
+ * If an action has no argument, you do not need to add it to this type.
+ *
+ * NOTE: We can't enforce type checks to make sure the key is Action, you
+ * will know if you got something wrong if there is a type error in this file
+ */
+type ActionArgsMap = {
+ "seek-forward": { seconds: number } | undefined; // Args needed for seeking forward (default: 1)
+ "seek-backward": { seconds: number } | undefined; // Args needed for seeking backward (default: 1)
+ "jump-forward": { seconds: number } | undefined; // Args needed for jumping forward (default: 5)
+ "jump-backward": { seconds: number } | undefined; // Args needed for jumping backward (default: 5)
+};
+
+type KeysWithValueUndefined<T> = {
+ [K in keyof T]: undefined extends T[K] ? K : never;
+}[keyof T];
+
+/**
+ * Actions which require arguments for their invocation
+ */
+export type ActionWithArgs = keyof ActionArgsMap;
+
+/**
+ * Actions which optionally takes in arguments for their invocation
+ */
+
+export type ActionWithOptionalArgs =
+ | ActionWithNoArgs
+ | KeysWithValueUndefined<ActionArgsMap>;
+
+/**
+ * Actions which do not require arguments for their invocation
+ */
+export type ActionWithNoArgs = Exclude<Action, ActionWithArgs>;
+
+/**
+ * Resolves the argument type for a given Action
+ */
+type ArgOfHoppAction<A extends Action> = A extends ActionWithArgs
+ ? ActionArgsMap[A]
+ : undefined;
+
+/**
+ * Resolves the action function for a given Action, used by action handler function defs
+ */
+type ActionFunc<A extends Action> = A extends ActionWithArgs
+ ? (arg: ArgOfHoppAction<A>, trigger?: InvocationTriggers) => void
+ : (_?: undefined, trigger?: InvocationTriggers) => void;
+
+type BoundActionList = {
+ [A in Action]?: Array<ActionFunc<A>>;
+};
+
+const boundActions: BoundActionList = {};
+
+let currentActiveActions: Action[] = [];
+
+function updateActiveActions() {
+ const newActions = Object.keys(boundActions) as Action[];
+ currentActiveActions = newActions;
+ actionEmitter.emit(newActions);
+}
+
+export function bindAction<A extends Action>(
+ action: A,
+ handler: ActionFunc<A>
+) {
+ if (boundActions[action]) {
+ boundActions[action]?.push(handler);
+ } else {
+ // 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
+ boundActions[action] = [handler] as any;
+ }
+
+ updateActiveActions();
+}
+
+export type InvocationTriggers = "keypress" | "mouseclick";
+
+type InvokeActionFunc = {
+ (
+ action: ActionWithOptionalArgs,
+ args?: undefined,
+ trigger?: InvocationTriggers
+ ): void;
+ <A extends ActionWithArgs>(action: A, args: ActionArgsMap[A]): void;
+};
+
+/**
+ * Invokes an action, triggering action handlers if any registered.
+ * The second and third arguments are optional
+ * @param action The action to fire
+ * @param args The argument passed to the action handler. Optional if action has no args required
+ * @param trigger Optionally supply the trigger that invoked the action (keypress/mouseclick)
+ */
+export const invokeAction: InvokeActionFunc = <A extends Action>(
+ action: A,
+ args?: ArgOfHoppAction<A>,
+ trigger?: InvocationTriggers
+) => {
+ boundActions[action]?.forEach((handler) => (handler as any)(args, trigger));
+};
+
+export function unbindAction<A extends Action>(
+ action: A,
+ handler: ActionFunc<A>
+) {
+ // 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
+ boundActions[action] = boundActions[action]?.filter(
+ (x) => x !== handler
+ ) as any;
+
+ if (boundActions[action]?.length === 0) {
+ delete boundActions[action];
+ }
+
+ updateActiveActions();
+}
+
+/**
+ * Returns whether a given action is bound at a given time
+ *
+ * @param action The action to check
+ */
+export function isActionBound(action: Action): boolean {
+ return !!boundActions[action];
+}
+
+/**
+ * A React hook that defines a component can handle a given
+ * Action. The handler will be bound when the component is mounted
+ * and unbound when the component is unmounted.
+ * @param action The action to be bound
+ * @param handler The function to be called when the action is invoked
+ * @param isActive A ref that indicates whether the action is active
+ */
+export function useActionHandler<A extends Action>(
+ action: A,
+ handler: ActionFunc<A>,
+ isActive: MutableRefObject<boolean> | boolean | undefined = undefined
+) {
+ const handlerRef = useRef(handler);
+ const [isBound, setIsBound] = useState(false);
+
+ // Update handler ref when handler changes
+ useEffect(() => {
+ handlerRef.current = handler;
+ }, [handler]);
+
+ // Create a stable handler wrapper
+ const stableHandler = useCallback(
+ (args: any, trigger?: InvocationTriggers) => {
+ (handlerRef.current as any)(args, trigger);
+ },
+ []
+ ) as ActionFunc<A>;
+
+ useEffect(() => {
+ const shouldBind =
+ isActive === undefined ||
+ (typeof isActive === "boolean" ? isActive : isActive.current);
+
+ if (shouldBind && !isBound) {
+ bindAction(action, stableHandler);
+ setIsBound(true);
+ } else if (!shouldBind && isBound) {
+ unbindAction(action, stableHandler);
+ setIsBound(false);
+ }
+
+ return () => {
+ if (isBound) {
+ unbindAction(action, stableHandler);
+ setIsBound(false);
+ }
+ };
+ }, [action, stableHandler, isActive, isBound]);
+
+ // Handle ref-based isActive changes
+ useEffect(() => {
+ if (isActive && typeof isActive === "object" && "current" in isActive) {
+ // Poll for ref changes
+ const interval = setInterval(() => {
+ const shouldBind = isActive.current;
+ if (shouldBind !== isBound) {
+ if (shouldBind) {
+ bindAction(action, stableHandler);
+ } else {
+ unbindAction(action, stableHandler);
+ }
+ setIsBound(shouldBind);
+ }
+ }, 100);
+ return () => clearInterval(interval);
+ }
+ }, [action, stableHandler, isActive, isBound]);
+}
+
+/**
+ * A React hook that returns the current list of active actions
+ * and re-renders when the list changes
+ */
+export function useActiveActions(): Action[] {
+ const [activeActions, setActiveActions] = useState<Action[]>([]);
+
+ useEffect(() => {
+ // Set initial value
+ setActiveActions(currentActiveActions);
+
+ // Subscribe to changes
+ const unsubscribe = actionEmitter.subscribe(setActiveActions);
+ return unsubscribe;
+ }, []);
+
+ return activeActions;
+}
+
+/**
+ * A React hook that returns whether a specific action is currently bound
+ * and re-renders when the binding state changes
+ */
+export function useIsActionBound(action: Action): boolean {
+ const [isBound, setIsBound] = useState(() => isActionBound(action));
+
+ useEffect(() => {
+ const updateBoundState = () => {
+ setIsBound(isActionBound(action));
+ };
+
+ // Set initial value
+ updateBoundState();
+
+ // Subscribe to changes
+ const unsubscribe = actionEmitter.subscribe(updateBoundState);
+ return unsubscribe;
+ }, [action]);
+
+ return isBound;
+}
diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts
index 05e44e2f..8c76a2e8 100644
--- a/apps/web/src/constants/timeline-constants.ts
+++ b/apps/web/src/constants/timeline-constants.ts
@@ -76,11 +76,6 @@ export const TIMELINE_CONSTANTS = {
DEFAULT_TEXT_DURATION: 5,
DEFAULT_IMAGE_DURATION: 5,
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
- ZOOM_LEVEL_MIN: 0.1,
- ZOOM_LEVEL_MAX: 10,
- ZOOM_STEP_BASE: 0.15,
- MAX_ZOOM_STEP: 66,
- ZOOM_STEP: 1,
} as const;
// FPS presets for project settings
diff --git a/apps/web/src/hooks/use-disable-browser-zoom.ts b/apps/web/src/hooks/use-disable-browser-zoom.ts
deleted file mode 100644
index 9467cb71..00000000
--- a/apps/web/src/hooks/use-disable-browser-zoom.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useEffect } from "react";
-
-/**
- * useDisableBrowserZoom
- *
- * This React hook prevents users from zooming in/out the browser window using keyboard shortcuts,
- * mouse wheel, or gesture events (such as pinch-to-zoom on trackpads or touch devices).
- *
- * Typical use case: apps with fixed-size UIs, custom editors, or when zooming would break layout.
- *
- * NOTE: Disabling browser zoom may negatively affect accessibility and user experience.
- * Use with caution, especially for public-facing applications.
- */
-export const useDisableBrowserZoom = () => {
- useEffect(() => {
- /**
- * Prevents browser zoom when user holds Ctrl (or Cmd on Mac) and scrolls the mouse wheel.
- */
- const handleWheel = (e: WheelEvent) => {
- if (e.ctrlKey) {
- e.preventDefault();
- }
- };
-
- /**
- * Prevents browser zoom via keyboard shortcuts:
- * Ctrl/Cmd + '+', '-', '=', or '0' (reset zoom)
- * Some keyboards emit '=' for '+' (without Shift).
- */
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.ctrlKey) {
- const key = e.key;
- if (key === "+" || key === "-" || key === "0" || key === "=") {
- e.preventDefault();
- }
- }
- };
-
- /**
- * Prevents pinch-zoom gestures on supported browsers (mainly Safari/macOS/iOS).
- * gesturestart, gesturechange, and gestureend are non-standard and not supported everywhere.
- */
- const handleGesture = (e: Event) => {
- e.preventDefault();
- };
-
- // Attach event listeners (passive: false is required for preventDefault to work)
- window.addEventListener("wheel", handleWheel, { passive: false });
- window.addEventListener("keydown", handleKeyDown, { passive: false });
- window.addEventListener("gesturestart", handleGesture, { passive: false });
- window.addEventListener("gesturechange", handleGesture, { passive: false });
- window.addEventListener("gestureend", handleGesture, { passive: false });
-
- // Cleanup listeners on unmount to avoid memory leaks
- return () => {
- window.removeEventListener("wheel", handleWheel);
- window.removeEventListener("keydown", handleKeyDown);
- window.removeEventListener("gesturestart", handleGesture);
- window.removeEventListener("gesturechange", handleGesture);
- window.removeEventListener("gestureend", handleGesture);
- };
- }, []);
-};
diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts
new file mode 100644
index 00000000..e0d9b9eb
--- /dev/null
+++ b/apps/web/src/hooks/use-editor-actions.ts
@@ -0,0 +1,162 @@
+"use client";
+
+import { useEffect } from "react";
+import { useActionHandler } from "@/constants/actions";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+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 { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
+ const { activeProject } = useProjectStore();
+
+ // Playback actions
+ useActionHandler("toggle-play", () => {
+ toggle();
+ });
+
+ 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-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-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-backward", (args) => {
+ const seconds = args?.seconds ?? 5;
+ seek(Math.max(0, currentTime - seconds));
+ });
+
+ useActionHandler("goto-start", () => {
+ seek(0);
+ });
+
+ useActionHandler("goto-end", () => {
+ seek(duration);
+ });
+
+ // 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);
+
+ 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");
+ }
+ }
+ });
+
+ 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("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);
+
+ if (element) {
+ const newStartTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1;
+ const { id, ...elementWithoutId } = element;
+
+ addElementToTrack(trackId, {
+ ...elementWithoutId,
+ startTime: newStartTime,
+ });
+ }
+ });
+
+ useActionHandler("toggle-snapping", () => {
+ toggleSnapping();
+ });
+
+ // History actions
+ useActionHandler("undo", () => {
+ undo();
+ });
+
+ useActionHandler("redo", () => {
+ redo();
+ });
+}
diff --git a/apps/web/src/hooks/use-keybinding-conflicts.ts b/apps/web/src/hooks/use-keybinding-conflicts.ts
new file mode 100644
index 00000000..cc5dd340
--- /dev/null
+++ b/apps/web/src/hooks/use-keybinding-conflicts.ts
@@ -0,0 +1,59 @@
+"use client";
+
+import { useMemo } from "react";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { ActionWithOptionalArgs } from "@/constants/actions";
+
+export interface KeybindingConflictInfo {
+ key: string;
+ actions: ActionWithOptionalArgs[];
+ isConflict: boolean;
+}
+
+export const useKeybindingConflicts = () => {
+ const { keybindings } = useKeybindingsStore();
+
+ 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);
+ });
+
+ // 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]);
+
+ const hasConflicts = conflicts.length > 0;
+
+ 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));
+ };
+
+ return {
+ conflicts,
+ hasConflicts,
+ getConflictsForKey,
+ getConflictsForAction,
+ };
+};
diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts
new file mode 100644
index 00000000..7887f3f1
--- /dev/null
+++ b/apps/web/src/hooks/use-keybindings.ts
@@ -0,0 +1,64 @@
+import { useEffect } from "react";
+import { invokeAction } from "../constants/actions";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+
+/**
+ * A composable that hooks to the caller component's
+ * lifecycle and hooks to the keyboard events to fire
+ * the appropriate actions based on keybindings
+ */
+export function useKeybindingsListener() {
+ const { keybindings, getKeybindingString, keybindingsEnabled } =
+ useKeybindingsStore();
+
+ 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 boundAction = keybindings[binding];
+ if (!boundAction) return;
+
+ ev.preventDefault();
+
+ // 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 };
+ }
+
+ invokeAction(boundAction, actionArgs, "keypress");
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+
+ 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();
+
+ return {
+ disableKeybindings,
+ enableKeybindings,
+ };
+}
+
+// Export the bindings for backward compatibility
+export const bindings = {};
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
new file mode 100644
index 00000000..c7de8466
--- /dev/null
+++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
@@ -0,0 +1,129 @@
+"use client";
+
+import { useMemo } from "react";
+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;
+}
+
+// Map actions to their descriptions and categories
+const actionDescriptions: Record<
+ 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" },
+};
+
+// 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("-", "+");
+};
+
+export const useKeyboardShortcutsHelp = () => {
+ const { keybindings } = useKeybindingsStore();
+
+ const shortcuts = useMemo(() => {
+ const result: KeyboardShortcut[] = [];
+
+ // 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));
+ }
+ });
+
+ // 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]);
+
+ return {
+ shortcuts,
+ };
+};
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts
deleted file mode 100644
index feb8f409..00000000
--- a/apps/web/src/hooks/use-keyboard-shortcuts.ts
+++ /dev/null
@@ -1,338 +0,0 @@
-"use client";
-
-import { useEffect, useCallback } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useProjectStore } from "@/stores/project-store";
-import { toast } from "sonner";
-
-export interface KeyboardShortcut {
- id: string;
- keys: string[];
- description: string;
- category: string;
- action: () => void;
- enabled?: boolean;
- requiresSelection?: boolean;
- icon?: React.ReactNode;
-}
-
-interface UseKeyboardShortcutsOptions {
- enabled?: boolean;
- context?: "global" | "timeline" | "editor";
-}
-
-export const useKeyboardShortcuts = (
- options: UseKeyboardShortcutsOptions = {}
-) => {
- const { enabled = true, context = "editor" } = options;
-
- const {
- tracks,
- selectedElements,
- clearSelectedElements,
- setSelectedElements,
- removeElementFromTrack,
- splitElement,
- addElementToTrack,
- snappingEnabled,
- toggleSnapping,
- undo,
- redo,
- } = useTimelineStore();
-
- const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
-
- const { activeProject } = useProjectStore();
-
- // Check if user is typing in an input field
- const isInputFocused = useCallback(() => {
- const activeElement = document.activeElement as HTMLElement;
- return (
- activeElement &&
- (activeElement.tagName === "INPUT" ||
- activeElement.tagName === "TEXTAREA" ||
- activeElement.contentEditable === "true")
- );
- }, []);
-
- // Define all shortcuts in one place
- const shortcuts: KeyboardShortcut[] = [
- // Playback Controls
- {
- id: "play-pause",
- keys: ["Space"],
- description: "Play/Pause",
- category: "Playback",
- action: () => {
- toggle();
- },
- },
- {
- id: "rewind",
- keys: ["J"],
- description: "Rewind 1 second",
- category: "Playback",
- action: () => {
- seek(Math.max(0, currentTime - 1));
- },
- },
- {
- id: "play-pause-alt",
- keys: ["K"],
- description: "Play/Pause (alternative)",
- category: "Playback",
- action: () => {
- toggle();
- },
- },
- {
- id: "fast-forward",
- keys: ["L"],
- description: "Fast forward 1 second",
- category: "Playback",
- action: () => {
- seek(Math.min(duration, currentTime + 1));
- },
- },
-
- // Navigation
- {
- id: "frame-backward",
- keys: ["ArrowLeft"],
- description: "Frame step backward",
- category: "Navigation",
- action: () => {
- const projectFps = activeProject?.fps || 30;
- seek(Math.max(0, currentTime - 1 / projectFps));
- },
- },
- {
- id: "frame-forward",
- keys: ["ArrowRight"],
- description: "Frame step forward",
- category: "Navigation",
- action: () => {
- const projectFps = activeProject?.fps || 30;
- seek(Math.min(duration, currentTime + 1 / projectFps));
- },
- },
- {
- id: "jump-backward",
- keys: ["Shift+ArrowLeft"],
- description: "Jump back 5 seconds",
- category: "Navigation",
- action: () => {
- seek(Math.max(0, currentTime - 5));
- },
- },
- {
- id: "jump-forward",
- keys: ["Shift+ArrowRight"],
- description: "Jump forward 5 seconds",
- category: "Navigation",
- action: () => {
- seek(Math.min(duration, currentTime + 5));
- },
- },
- {
- id: "goto-start",
- keys: ["Home"],
- description: "Go to timeline start",
- category: "Navigation",
- action: () => {
- seek(0);
- },
- },
- {
- id: "goto-end",
- keys: ["End"],
- description: "Go to timeline end",
- category: "Navigation",
- action: () => {
- seek(duration);
- },
- },
-
- // Editing
- {
- id: "split-element",
- keys: ["S"],
- description: "Split element at playhead",
- category: "Editing",
- requiresSelection: true,
- action: () => {
- 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);
-
- 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");
- }
- }
- },
- },
- {
- id: "delete-elements",
- keys: ["Delete", "Backspace"],
- description: "Delete selected elements",
- category: "Editing",
- requiresSelection: true,
- action: () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- selectedElements.forEach(
- ({ trackId, elementId }: { trackId: string; elementId: string }) => {
- removeElementFromTrack(trackId, elementId);
- }
- );
- clearSelectedElements();
- },
- },
- {
- id: "toggle-snapping",
- keys: ["N"],
- description: "Toggle snapping",
- category: "Editing",
- action: () => {
- toggleSnapping();
- },
- },
-
- // Selection & Organization
- {
- id: "select-all",
- keys: ["Cmd+A", "Ctrl+A"],
- description: "Select all elements",
- category: "Selection",
- action: () => {
- const allElements = tracks.flatMap((track: any) =>
- track.elements.map((element: any) => ({
- trackId: track.id,
- elementId: element.id,
- }))
- );
- setSelectedElements(allElements);
- },
- },
- {
- id: "duplicate-element",
- keys: ["Cmd+D", "Ctrl+D"],
- description: "Duplicate selected element",
- category: "Selection",
- requiresSelection: true,
- action: () => {
- 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);
-
- if (element) {
- const newStartTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1;
- const { id, ...elementWithoutId } = element;
-
- addElementToTrack(trackId, {
- ...elementWithoutId,
- startTime: newStartTime,
- });
- }
- },
- },
-
- // History
- {
- id: "undo",
- keys: ["Cmd+Z", "Ctrl+Z"],
- description: "Undo",
- category: "History",
- action: () => {
- undo();
- },
- },
- {
- id: "redo",
- keys: ["Cmd+Shift+Z", "Ctrl+Shift+Z", "Cmd+Y", "Ctrl+Y"],
- description: "Redo",
- category: "History",
- action: () => {
- redo();
- },
- },
- ];
-
- // Parse keyboard event to match against shortcuts
- const parseKeyboardEvent = useCallback((e: KeyboardEvent): string => {
- const parts: string[] = [];
-
- if (e.metaKey || e.ctrlKey) parts.push(e.metaKey ? "Cmd" : "Ctrl");
- if (e.shiftKey) parts.push("Shift");
- if (e.altKey) parts.push("Alt");
-
- parts.push(e.key);
-
- return parts.join("+").toLowerCase();
- }, []);
-
- // Handle keyboard events
- const handleKeyDown = useCallback(
- (e: KeyboardEvent) => {
- if (!enabled || isInputFocused()) return;
-
- const keyCombo = parseKeyboardEvent(e);
- const shortcut = shortcuts.find((s) =>
- s.keys.some(
- (key) =>
- key.toLowerCase() === keyCombo ||
- key.toLowerCase() === e.key.toLowerCase()
- )
- );
-
- if (shortcut) {
- // Check if shortcut requires selection
- if (shortcut.requiresSelection && selectedElements.length === 0) {
- return;
- }
-
- e.preventDefault();
- shortcut.action();
- }
- },
- [enabled, shortcuts, selectedElements, parseKeyboardEvent, isInputFocused]
- );
-
- // Set up event listener
- useEffect(() => {
- if (!enabled) return;
-
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [enabled, handleKeyDown]);
-
- // Return shortcuts for help component
- return {
- shortcuts: shortcuts.filter((s) => s.enabled !== false),
- enabled,
- };
-};
diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts
index 0e409977..f16ca8e2 100644
--- a/apps/web/src/hooks/use-timeline-element-resize.ts
+++ b/apps/web/src/hooks/use-timeline-element-resize.ts
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
import { useMediaStore } from "@/stores/media-store";
+import { useTimelineStore } from "@/stores/timeline-store";
interface UseTimelineElementResizeProps {
element: TimelineElement;
@@ -28,6 +29,7 @@ export function useTimelineElementResize({
}: UseTimelineElementResizeProps) {
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(() => {
@@ -100,12 +102,50 @@ export function useTimelineElementResize({
const deltaTime = deltaX / (50 * zoomLevel);
if (resizing.side === "left") {
- // Left resize - only trim within original duration
+ // Left resize - different behavior for media vs text/image elements
const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
const calculated = resizing.initialTrimStart + deltaTime;
- const newTrimStart = Math.max(0, Math.min(maxAllowed, calculated));
- onUpdateTrim(track.id, element.id, newTrimStart, resizing.initialTrimEnd);
+ 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;
+
+ // 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;
@@ -145,10 +185,6 @@ export function useTimelineElementResize({
}
};
- const handleResizeMove = (e: React.MouseEvent) => {
- updateTrimFromMouseMove(e);
- };
-
const handleResizeEnd = () => {
setResizing(null);
};
diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts
index 9eb06485..9a82c23f 100644
--- a/apps/web/src/hooks/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/use-timeline-playhead.ts
@@ -1,6 +1,6 @@
import { snapTimeToFrame } from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
-import { useRef, useState, useCallback } from "react";
+import { useState, useEffect, useCallback } from "react";
interface UseTimelinePlayheadProps {
currentTime: number;
@@ -9,15 +9,10 @@ interface UseTimelinePlayheadProps {
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ playheadRef?: React.RefObject<HTMLDivElement>;
}
-/**
- * useTimelinePlayhead
- *
- * Custom hook to manage playhead (scrubbing) logic for a timeline editor.
- * Handles mouse interaction for timeline ruler, calculates time from mouse,
- * and enables smooth scrubbing without unnecessary re-renders.
- */
export function useTimelinePlayhead({
currentTime,
duration,
@@ -25,101 +20,138 @@ export function useTimelinePlayhead({
seek,
rulerRef,
rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
}: UseTimelinePlayheadProps) {
- // Get current project info (especially FPS) from global store
- const { activeProject } = useProjectStore();
+ // Playhead scrubbing state
+ const [isScrubbing, setIsScrubbing] = useState(false);
+ const [scrubTime, setScrubTime] = useState<number | null>(null);
- // Ref to track if currently scrubbing
- const isScrubbingRef = useRef(false);
+ // Ruler drag detection state
+ const [isDraggingRuler, setIsDraggingRuler] = useState(false);
+ const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
- // Ref to hold the playhead time while scrubbing (does not trigger re-render)
- const scrubTimeRef = useRef<number | null>(null);
-
- // State to force re-render when scrubbing ends
- const [_, forceRerender] = useState(0);
-
- /**
- * Determines the actual playhead position:
- * - If scrubbing, use the value in scrubTimeRef
- * - Otherwise, use the currentTime (controlled by external state)
- */
const playheadPosition =
- isScrubbingRef.current && scrubTimeRef.current !== null
- ? scrubTimeRef.current
- : currentTime;
-
- /**
- * Calculate timeline time (in seconds) based on mouse X position.
- * - Gets bounding rect of the ruler
- * - Adjusts for scroll position if ruler is scrollable
- * - Converts X pixel offset to seconds using current zoom level
- * - Snaps to nearest frame using FPS
- */
- const getTimeFromMouse = useCallback(
- (e: MouseEvent | React.MouseEvent) => {
- const ruler = rulerRef.current;
- const scrollArea = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
-
- if (!ruler || !scrollArea) return 0;
+ isScrubbing && scrubTime !== null ? scrubTime : currentTime;
- const rect = ruler.getBoundingClientRect();
- const scrollLeft = scrollArea.scrollLeft;
- const x = e.clientX - rect.left + scrollLeft;
-
- // Calculate how many pixels represent one second, depending on zoom
- const pixelsPerSecond = 50 * zoomLevel;
- const rawTime = Math.max(0, Math.min(duration, x / pixelsPerSecond));
- const time = snapTimeToFrame(rawTime, activeProject?.fps || 30);
- return time;
+ // --- Playhead Scrubbing Handlers ---
+ const handlePlayheadMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation(); // Prevent ruler drag from triggering
+ setIsScrubbing(true);
+ handleScrub(e);
},
- [rulerRef, rulerScrollRef, duration, zoomLevel, activeProject?.fps]
+ [duration, zoomLevel]
);
- /**
- * Handle mouse down event on the ruler:
- * - Starts scrubbing and updates playhead immediately
- * - Registers mousemove/mouseup events to allow scrubbing
- * - Updates time as mouse moves and seeks to new time
- * - Cleans up listeners and triggers re-render when scrubbing ends
- */
+ // Ruler mouse down handler
const handleRulerMouseDown = useCallback(
(e: React.MouseEvent) => {
+ // Only handle left mouse button
if (e.button !== 0) return;
- e.preventDefault();
- const time = getTimeFromMouse(e);
- isScrubbingRef.current = true;
- scrubTimeRef.current = time;
- seek(time);
+ // Don't interfere if clicking on the playhead itself
+ if (playheadRef?.current?.contains(e.target as Node)) return;
- // Mouse move handler: update playhead and seek to new time
- const onMouseMove = (e: MouseEvent) => {
- const t = getTimeFromMouse(e);
- if (t !== scrubTimeRef.current) {
- scrubTimeRef.current = t;
- seek(t);
- }
- };
+ e.preventDefault();
+ setIsDraggingRuler(true);
+ setHasDraggedRuler(false);
- // Mouse up handler: stop scrubbing, cleanup, and force re-render
- const onMouseUp = () => {
- isScrubbingRef.current = false;
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- forceRerender((v) => v + 1);
- };
+ // Start scrubbing immediately
+ setIsScrubbing(true);
+ handleScrub(e);
+ },
+ [duration, zoomLevel]
+ );
- // Attach listeners to window for drag outside the ruler area
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
+ 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
},
- [getTimeFromMouse, seek]
+ [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, // Current playhead position (real-time while scrubbing)
- handleRulerMouseDown, // Attach to the ruler's onMouseDown
+ playheadPosition,
+ handlePlayheadMouseDown,
+ handleRulerMouseDown,
+ isDraggingRuler,
};
}
diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts
index c8780f9a..ce32c9f0 100644
--- a/apps/web/src/hooks/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/use-timeline-zoom.ts
@@ -1,5 +1,4 @@
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { useState, useCallback, RefObject, useMemo } from "react";
+import { useState, useCallback, useEffect, RefObject } from "react";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement>;
@@ -8,86 +7,48 @@ interface UseTimelineZoomProps {
interface UseTimelineZoomReturn {
zoomLevel: number;
- zoomStep: number;
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
- handleChangeZoomStep: (zoomStep: number) => void;
- handleChangeZoomLevel: (zoomStep: number) => void;
handleWheel: (e: React.WheelEvent) => void;
}
-/**
- * useTimelineZoom
- *
- * Custom hook to manage zoom logic for a timeline component.
- * Handles zoom state, step calculation, level changes, and mouse wheel zooming (with ctrl/meta).
- */
-export function useTimelineZoom(): UseTimelineZoomReturn {
- // Current zoom level (1 = default)
+export function useTimelineZoom({
+ containerRef,
+ isInTimeline = false,
+}: UseTimelineZoomProps): UseTimelineZoomReturn {
const [zoomLevel, setZoomLevel] = useState(1);
- /**
- * Calculate the current zoom step based on the zoom level and a base step constant.
- * Ensures minimum step is 1 (prevents zero or negative step).
- */
- const zoomStep = useMemo(
- () =>
- Math.max(1, Math.round(zoomLevel / TIMELINE_CONSTANTS.ZOOM_STEP_BASE)),
- [zoomLevel]
- );
-
- /**
- * Update the zoom level using a given step.
- * The zoom level is clamped to a minimum value defined in constants.
- */
- const handleChangeZoomStep = useCallback((newStep: number) => {
- setZoomLevel(
- Math.max(
- TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN,
- newStep * TIMELINE_CONSTANTS.ZOOM_STEP_BASE
- )
- );
- }, []);
-
- /**
- * Update the zoom level directly.
- * The new value is clamped between the defined minimum and maximum levels.
- */
- const handleChangeZoomLevel = useCallback((newLevel: number) => {
- setZoomLevel(
- Math.max(
- TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN,
- Math.min(TIMELINE_CONSTANTS.ZOOM_LEVEL_MAX, newLevel)
- )
- );
- }, []);
-
- /**
- * Handle zooming using mouse wheel + ctrl/meta key (like browser zoom).
- * Prevents default browser behavior and updates zoom level accordingly.
- */
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
- ? -TIMELINE_CONSTANTS.ZOOM_STEP_BASE
- : TIMELINE_CONSTANTS.ZOOM_STEP_BASE;
- setZoomLevel((prev) =>
- Math.max(
- TIMELINE_CONSTANTS.ZOOM_LEVEL_MIN,
- Math.min(TIMELINE_CONSTANTS.ZOOM_LEVEL_MAX, prev + delta)
- )
- );
+ 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
}, []);
- // Return all handlers and state values for use in timeline components
+ // 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, // Current zoom level
- zoomStep, // Current zoom step (calculated from zoomLevel)
- setZoomLevel, // Directly set the zoom level (accepts value or updater function)
- handleChangeZoomStep, // Change zoom by step value
- handleChangeZoomLevel, // Change zoom by level value
- handleWheel, // Handler for mouse wheel zooming
+ zoomLevel,
+ setZoomLevel,
+ handleWheel,
};
}
diff --git a/apps/web/src/lib/timeline.ts b/apps/web/src/lib/timeline.ts
index 85fcabe6..531defac 100644
--- a/apps/web/src/lib/timeline.ts
+++ b/apps/web/src/lib/timeline.ts
@@ -1,54 +1,54 @@
-import { TimelineElement } from "@/types/timeline";
-
-// Helper function to check for element overlaps and prevent invalid timeline states
-export const checkElementOverlaps = (elements: TimelineElement[]): boolean => {
- // Sort elements by start time
- const sortedElements = [...elements].sort(
- (a, b) => a.startTime - b.startTime
- );
-
- for (let i = 0; i < sortedElements.length - 1; i++) {
- const current = sortedElements[i];
- const next = sortedElements[i + 1];
-
- const currentEnd =
- current.startTime +
- (current.duration - current.trimStart - current.trimEnd);
-
- // Check if current element overlaps with next element
- if (currentEnd > next.startTime) return true; // Overlap detected
- }
-
- return false; // No overlaps
-};
-
-// Helper function to resolve overlaps by adjusting element positions
-export const resolveElementOverlaps = (
- elements: TimelineElement[]
-): TimelineElement[] => {
- // Sort elements by start time
- const sortedElements = [...elements].sort(
- (a, b) => a.startTime - b.startTime
- );
- const resolvedElements: TimelineElement[] = [];
-
- for (let i = 0; i < sortedElements.length; i++) {
- const current = { ...sortedElements[i] };
-
- if (resolvedElements.length > 0) {
- const previous = resolvedElements[resolvedElements.length - 1];
- const previousEnd =
- previous.startTime +
- (previous.duration - previous.trimStart - previous.trimEnd);
-
- // If current element would overlap with previous, push it after previous ends
- if (current.startTime < previousEnd) {
- current.startTime = previousEnd;
- }
- }
-
- resolvedElements.push(current);
- }
-
- return resolvedElements;
-};
+import { TimelineElement } from "@/types/timeline";
+
+// Helper function to check for element overlaps and prevent invalid timeline states
+export const checkElementOverlaps = (elements: TimelineElement[]): boolean => {
+ // Sort elements by start time
+ const sortedElements = [...elements].sort(
+ (a, b) => a.startTime - b.startTime
+ );
+
+ for (let i = 0; i < sortedElements.length - 1; i++) {
+ const current = sortedElements[i];
+ const next = sortedElements[i + 1];
+
+ const currentEnd =
+ current.startTime +
+ (current.duration - current.trimStart - current.trimEnd);
+
+ // Check if current element overlaps with next element
+ if (currentEnd > next.startTime) return true; // Overlap detected
+ }
+
+ return false; // No overlaps
+};
+
+// Helper function to resolve overlaps by adjusting element positions
+export const resolveElementOverlaps = (
+ elements: TimelineElement[]
+): TimelineElement[] => {
+ // Sort elements by start time
+ const sortedElements = [...elements].sort(
+ (a, b) => a.startTime - b.startTime
+ );
+ const resolvedElements: TimelineElement[] = [];
+
+ for (let i = 0; i < sortedElements.length; i++) {
+ const current = { ...sortedElements[i] };
+
+ if (resolvedElements.length > 0) {
+ const previous = resolvedElements[resolvedElements.length - 1];
+ const previousEnd =
+ previous.startTime +
+ (previous.duration - previous.trimStart - previous.trimEnd);
+
+ // If current element would overlap with previous, push it after previous ends
+ if (current.startTime < previousEnd) {
+ current.startTime = previousEnd;
+ }
+ }
+
+ resolvedElements.push(current);
+ }
+
+ return resolvedElements;
+};
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
index fd47cc13..8d2ee03a 100644
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -44,6 +44,25 @@ export function generateUUID(): string {
);
}
+export function isDOMElement(el: any): el is HTMLElement {
+ return !!el && (el instanceof Element || el instanceof HTMLElement);
+}
+
+export function isTypableElement(el: HTMLElement): boolean {
+ // If content editable, then it is editable
+ if (el.isContentEditable) return true;
+
+ // If element is an input and the input is enabled, then it is typable
+ if (el.tagName === "INPUT") {
+ return !(el as HTMLInputElement).disabled;
+ }
+ // If element is a textarea and the input is enabled, then it is typable
+ if (el.tagName === "TEXTAREA") {
+ return !(el as HTMLTextAreaElement).disabled;
+ }
+
+ return false;
+}
export function isAppleDevice() {
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
}
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts
new file mode 100644
index 00000000..60c71afd
--- /dev/null
+++ b/apps/web/src/stores/keybindings-store.ts
@@ -0,0 +1,241 @@
+"use client";
+
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import { ActionWithOptionalArgs } from "@/constants/actions";
+import { isAppleDevice, isDOMElement, isTypableElement } from "@/lib/utils";
+import { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
+
+// Default keybindings configuration
+export const defaultKeybindings: KeybindingConfig = {
+ space: "toggle-play",
+ j: "seek-backward",
+ k: "toggle-play",
+ l: "seek-forward",
+ left: "frame-step-backward",
+ right: "frame-step-forward",
+ "shift+left": "jump-backward",
+ "shift+right": "jump-forward",
+ home: "goto-start",
+ end: "goto-end",
+ s: "split-element",
+ n: "toggle-snapping",
+ "ctrl+a": "select-all",
+ "ctrl+d": "duplicate-selected",
+ "ctrl+z": "undo",
+ "ctrl+shift+z": "redo",
+ "ctrl+y": "redo",
+ delete: "delete-selected",
+ backspace: "delete-selected",
+};
+
+export interface KeybindingConflict {
+ key: ShortcutKey;
+ existingAction: ActionWithOptionalArgs;
+ newAction: ActionWithOptionalArgs;
+}
+
+interface KeybindingsState {
+ keybindings: KeybindingConfig;
+ isCustomized: boolean;
+ keybindingsEnabled: boolean;
+
+ // Actions
+ updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void;
+ removeKeybinding: (key: ShortcutKey) => void;
+ resetToDefaults: () => void;
+ importKeybindings: (config: KeybindingConfig) => void;
+ exportKeybindings: () => KeybindingConfig;
+ enableKeybindings: () => void;
+ disableKeybindings: () => void;
+
+ // Validation
+ validateKeybinding: (
+ key: ShortcutKey,
+ action: ActionWithOptionalArgs
+ ) => KeybindingConflict | null;
+ getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[];
+
+ // Utility
+ getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
+}
+
+export const useKeybindingsStore = create<KeybindingsState>()(
+ persist(
+ (set, get) => ({
+ keybindings: { ...defaultKeybindings },
+ isCustomized: false,
+ keybindingsEnabled: true,
+
+ updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => {
+ set((state) => {
+ const newKeybindings = { ...state.keybindings };
+ newKeybindings[key] = action;
+
+ return {
+ keybindings: newKeybindings,
+ isCustomized: true,
+ };
+ });
+ },
+
+ removeKeybinding: (key: ShortcutKey) => {
+ set((state) => {
+ const newKeybindings = { ...state.keybindings };
+ delete newKeybindings[key];
+
+ return {
+ keybindings: newKeybindings,
+ isCustomized: true,
+ };
+ });
+ },
+
+ resetToDefaults: () => {
+ set({
+ keybindings: { ...defaultKeybindings },
+ isCustomized: false,
+ });
+ },
+
+ enableKeybindings: () => {
+ set({ keybindingsEnabled: true });
+ },
+
+ disableKeybindings: () => {
+ set({ keybindingsEnabled: false });
+ },
+
+ importKeybindings: (config: KeybindingConfig) => {
+ // Validate all keys and actions
+ for (const [key, action] of Object.entries(config)) {
+ // Validate the key format
+ if (typeof key !== "string" || key.length === 0) {
+ throw new Error(`Invalid key format: ${key}`);
+ }
+ }
+ set({
+ keybindings: { ...config },
+ isCustomized: true,
+ });
+ },
+
+ exportKeybindings: () => {
+ return get().keybindings;
+ },
+
+ validateKeybinding: (
+ key: ShortcutKey,
+ action: ActionWithOptionalArgs
+ ) => {
+ const { keybindings } = get();
+ const existingAction = keybindings[key];
+
+ if (existingAction && existingAction !== action) {
+ return {
+ key,
+ existingAction,
+ newAction: action,
+ };
+ }
+
+ return null;
+ },
+
+ getKeybindingsForAction: (action: ActionWithOptionalArgs) => {
+ const { keybindings } = get();
+ return Object.keys(keybindings).filter(
+ (key) => keybindings[key as ShortcutKey] === action
+ ) as ShortcutKey[];
+ },
+
+ getKeybindingString: (ev: KeyboardEvent) => {
+ return generateKeybindingString(ev) as ShortcutKey | null;
+ },
+ }),
+ {
+ name: "opencut-keybindings",
+ version: 1,
+ }
+ )
+);
+
+// Utility functions
+function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
+ const target = ev.target;
+
+ // We may or may not have a modifier key
+ const modifierKey = getActiveModifier(ev);
+
+ // We will always have a non-modifier key
+ const key = getPressedKey(ev);
+ if (!key) return null;
+
+ // All key combos backed by modifiers are valid shortcuts (whether currently typing or not)
+ if (modifierKey) {
+ // If the modifier is shift and the target is an input, we ignore
+ if (
+ modifierKey === "shift" &&
+ isDOMElement(target) &&
+ isTypableElement(target)
+ ) {
+ return null;
+ }
+
+ return `${modifierKey}+${key}` as ShortcutKey;
+ }
+
+ // no modifier key here then we do not do anything while on input
+ if (isDOMElement(target) && isTypableElement(target)) return null;
+
+ // single key while not input
+ return `${key}` as ShortcutKey;
+}
+
+function getPressedKey(ev: KeyboardEvent): string | null {
+ // Sometimes the property code is not available on the KeyboardEvent object
+ const key = (ev.key ?? "").toLowerCase();
+
+ // Check arrow keys
+ if (key.startsWith("arrow")) {
+ return key.slice(5);
+ }
+
+ // Check for special keys
+ if (key === "tab") return "tab";
+ if (key === " " || key === "space") return "space";
+ if (key === "home") return "home";
+ if (key === "end") return "end";
+ if (key === "delete") return "delete";
+ if (key === "backspace") return "backspace";
+
+ // Check letter keys
+ const isLetter = key.length === 1 && key >= "a" && key <= "z";
+ if (isLetter) return key;
+
+ // Check if number keys
+ const isDigit = key.length === 1 && key >= "0" && key <= "9";
+ if (isDigit) return key;
+
+ // Check if slash, period or enter
+ if (key === "/" || key === "." || key === "enter") return key;
+
+ // If no other cases match, this is not a valid key
+ return null;
+}
+
+function getActiveModifier(ev: KeyboardEvent): string | null {
+ const modifierKeys = {
+ ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
+ alt: ev.altKey,
+ shift: ev.shiftKey,
+ };
+
+ // active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift
+ // modiferKeys object's keys are sorted to match the above order
+ const activeModifier = Object.keys(modifierKeys)
+ .filter((key) => modifierKeys[key as keyof typeof modifierKeys])
+ .join("+");
+
+ return activeModifier === "" ? null : activeModifier;
+}
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 2e645415..1fcfc60e 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -52,12 +52,12 @@ interface TimelineStore {
// Snapping settings
snappingEnabled: boolean;
+
// Snapping actions
toggleSnapping: () => void;
// Ripple editing mode
rippleEditingEnabled: boolean;
- // Controls whether ripple editing affects all tracks or just the current track
toggleRippleEditing: () => void;
// Multi-selection
@@ -572,6 +572,78 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}
},
+ removeElementFromTrackWithRipple: (trackId, elementId) => {
+ const { _tracks, rippleEditingEnabled } = get();
+
+ if (!rippleEditingEnabled) {
+ // If ripple editing is disabled, use regular removal
+ get().removeElementFromTrack(trackId, elementId);
+ return;
+ }
+
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+
+ if (!element || !track) return;
+
+ get().pushHistory();
+
+ const elementStartTime = element.startTime;
+ const elementDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ const elementEndTime = elementStartTime + elementDuration;
+
+ // Remove the element and shift all elements that come after it
+ const updatedTracks = _tracks
+ .map((currentTrack) => {
+ // Only apply ripple effects to the same track unless multi-track ripple is enabled
+ const shouldApplyRipple = currentTrack.id === trackId;
+
+ const updatedElements = currentTrack.elements
+ .filter((currentElement) => {
+ // Remove the target element
+ if (
+ currentElement.id === elementId &&
+ currentTrack.id === trackId
+ ) {
+ return false;
+ }
+ return true;
+ })
+ .map((currentElement) => {
+ // Only apply ripple effects if we should process this track
+ if (!shouldApplyRipple) {
+ return currentElement;
+ }
+
+ // Shift elements that start after the removed element
+ if (currentElement.startTime >= elementEndTime) {
+ return {
+ ...currentElement,
+ startTime: Math.max(
+ 0,
+ currentElement.startTime - elementDuration
+ ),
+ };
+ }
+ return currentElement;
+ });
+
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ // Resolve overlaps by adjusting element positions
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...currentTrack, elements: resolvedElements };
+ }
+
+ return { ...currentTrack, elements: updatedElements };
+ })
+ .filter((track) => track.elements.length > 0 || track.isMain);
+
+ updateTracksAndSave(updatedTracks);
+ },
+
moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
get().pushHistory();
@@ -665,6 +737,95 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
+ updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
+ const { _tracks, rippleEditingEnabled } = get();
+
+ if (!rippleEditingEnabled) {
+ // If ripple editing is disabled, use regular update
+ get().updateElementStartTime(trackId, elementId, newStartTime);
+ return;
+ }
+
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+
+ if (!element || !track) return;
+
+ get().pushHistory();
+
+ const oldStartTime = element.startTime;
+ const oldEndTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ const newEndTime =
+ newStartTime + (element.duration - element.trimStart - element.trimEnd);
+ const timeDelta = newStartTime - oldStartTime;
+
+ // Update tracks based on multi-track ripple setting
+ const updatedTracks = _tracks.map((currentTrack) => {
+ // Only apply ripple effects to the same track unless multi-track ripple is enabled
+ const shouldApplyRipple = currentTrack.id === trackId;
+
+ const updatedElements = currentTrack.elements.map((currentElement) => {
+ if (currentElement.id === elementId && currentTrack.id === trackId) {
+ // Update the moved element
+ return { ...currentElement, startTime: newStartTime };
+ }
+
+ // Only apply ripple effects if we should process this track
+ if (!shouldApplyRipple) {
+ return currentElement;
+ }
+
+ // For ripple editing, we need to move elements that come after the moved element
+ const currentElementStart = currentElement.startTime;
+ const currentElementEnd =
+ currentElement.startTime +
+ (currentElement.duration -
+ currentElement.trimStart -
+ currentElement.trimEnd);
+
+ // If moving element to the right (positive delta)
+ if (timeDelta > 0) {
+ // Move elements that start after the original position of the moved element
+ if (currentElementStart >= oldEndTime) {
+ return {
+ ...currentElement,
+ startTime: currentElementStart + timeDelta,
+ };
+ }
+ }
+ // If moving element to the left (negative delta)
+ else if (timeDelta < 0) {
+ // Move elements that start after the new position of the moved element
+ if (
+ currentElementStart >= newEndTime &&
+ currentElementStart >= oldStartTime
+ ) {
+ return {
+ ...currentElement,
+ startTime: Math.max(0, currentElementStart + timeDelta),
+ };
+ }
+ }
+
+ return currentElement;
+ });
+
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ // Resolve overlaps by adjusting element positions
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...currentTrack, elements: resolvedElements };
+ }
+
+ return { ...currentTrack, elements: updatedElements };
+ });
+
+ updateTracksAndSave(updatedTracks);
+ },
+
toggleTrackMute: (trackId) => {
get().pushHistory();
updateTracksAndSave(
@@ -1103,6 +1264,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
},
+ // Ripple editing functions
+ toggleRippleEditing: () => {
+ set((state) => ({
+ rippleEditingEnabled: !state.rippleEditingEnabled,
+ }));
+ },
+
checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
const track = get()._tracks.find((t) => t.id === trackId);
if (!track) return false;
@@ -1246,173 +1414,5 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
});
return true;
},
-
- // Ripple editing functions
- toggleRippleEditing: () => {
- set((state) => ({
- rippleEditingEnabled: !state.rippleEditingEnabled,
- }));
- },
-
- updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
- const { _tracks, rippleEditingEnabled } = get();
-
- if (!rippleEditingEnabled) {
- // If ripple editing is disabled, use regular update
- get().updateElementStartTime(trackId, elementId, newStartTime);
- return;
- }
-
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
-
- if (!element || !track) return;
-
- get().pushHistory();
-
- const oldStartTime = element.startTime;
- const oldEndTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- const newEndTime =
- newStartTime + (element.duration - element.trimStart - element.trimEnd);
- const timeDelta = newStartTime - oldStartTime;
-
- // Update tracks based on multi-track ripple setting
- const updatedTracks = _tracks.map((currentTrack) => {
- // Only apply ripple effects to the same track unless multi-track ripple is enabled
- const shouldApplyRipple = currentTrack.id === trackId;
-
- const updatedElements = currentTrack.elements.map((currentElement) => {
- if (currentElement.id === elementId && currentTrack.id === trackId) {
- // Update the moved element
- return { ...currentElement, startTime: newStartTime };
- }
-
- // Only apply ripple effects if we should process this track
- if (!shouldApplyRipple) {
- return currentElement;
- }
-
- // For ripple editing, we need to move elements that come after the moved element
- const currentElementStart = currentElement.startTime;
- const currentElementEnd =
- currentElement.startTime +
- (currentElement.duration -
- currentElement.trimStart -
- currentElement.trimEnd);
-
- // If moving element to the right (positive delta)
- if (timeDelta > 0) {
- // Move elements that start after the original position of the moved element
- if (currentElementStart >= oldEndTime) {
- return {
- ...currentElement,
- startTime: currentElementStart + timeDelta,
- };
- }
- }
- // If moving element to the left (negative delta)
- else if (timeDelta < 0) {
- // Move elements that start after the new position of the moved element
- if (
- currentElementStart >= newEndTime &&
- currentElementStart >= oldStartTime
- ) {
- return {
- ...currentElement,
- startTime: Math.max(0, currentElementStart + timeDelta),
- };
- }
- }
-
- return currentElement;
- });
-
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- // Resolve overlaps by adjusting element positions
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...currentTrack, elements: resolvedElements };
- }
-
- return { ...currentTrack, elements: updatedElements };
- });
-
- updateTracksAndSave(updatedTracks);
- },
-
- removeElementFromTrackWithRipple: (trackId, elementId) => {
- const { _tracks, rippleEditingEnabled } = get();
-
- if (!rippleEditingEnabled) {
- // If ripple editing is disabled, use regular removal
- get().removeElementFromTrack(trackId, elementId);
- return;
- }
-
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
-
- if (!element || !track) return;
-
- get().pushHistory();
-
- const elementStartTime = element.startTime;
- const elementDuration =
- element.duration - element.trimStart - element.trimEnd;
- const elementEndTime = elementStartTime + elementDuration;
-
- // Remove the element and shift all elements that come after it
- const updatedTracks = _tracks
- .map((currentTrack) => {
- // Only apply ripple effects to the same track unless multi-track ripple is enabled
- const shouldApplyRipple = currentTrack.id === trackId;
-
- const updatedElements = currentTrack.elements
- .filter((currentElement) => {
- // Remove the target element
- if (
- currentElement.id === elementId &&
- currentTrack.id === trackId
- ) {
- return false;
- }
- return true;
- })
- .map((currentElement) => {
- // Only apply ripple effects if we should process this track
- if (!shouldApplyRipple) {
- return currentElement;
- }
-
- // Shift elements that start after the removed element
- if (currentElement.startTime >= elementEndTime) {
- return {
- ...currentElement,
- startTime: Math.max(
- 0,
- currentElement.startTime - elementDuration
- ),
- };
- }
- return currentElement;
- });
-
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- // Resolve overlaps by adjusting element positions
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...currentTrack, elements: resolvedElements };
- }
-
- return { ...currentTrack, elements: updatedElements };
- })
- .filter((track) => track.elements.length > 0 || track.isMain);
-
- updateTracksAndSave(updatedTracks);
- },
};
});
diff --git a/apps/web/src/types/keybinding.ts b/apps/web/src/types/keybinding.ts
new file mode 100644
index 00000000..d97c88da
--- /dev/null
+++ b/apps/web/src/types/keybinding.ts
@@ -0,0 +1,35 @@
+import { ActionWithOptionalArgs } from "@/constants/actions";
+
+/**
+ * Alt is also regarded as macOS OPTION (⌥) key
+ * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
+ */
+export type ModifierKeys =
+ | "ctrl"
+ | "alt"
+ | "shift"
+ | "ctrl+shift"
+ | "alt+shift"
+ | "ctrl+alt"
+ | "ctrl+alt+shift";
+
+/* eslint-disable prettier/prettier */
+// prettier-ignore
+export type Key =
+ | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j"
+ | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t"
+ | "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3"
+ | "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left"
+ | "right" | "/" | "?" | "." | "enter" | "tab" | "space"
+ | "escape" | "esc" | "backspace" | "delete" | "home" | "end"
+/* eslint-enable */
+
+export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
+// Singular keybindings (these will be disabled when an input-ish area has been focused)
+export type SingleCharacterShortcutKey = `${Key}`;
+
+export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
+
+export type KeybindingConfig = {
+ [key in ShortcutKey]?: ActionWithOptionalArgs;
+};
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index 146205c4..17dbb5d1 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,6 +1,5 @@
import { MediaType } from "@/stores/media-store";
import { generateUUID } from "@/lib/utils";
-import { ReactNode } from "react";
export type TrackType = "media" | "text" | "audio";
@@ -156,22 +155,3 @@ export function validateElementTrackCompatibility(
return { isValid: true };
}
-
-export interface TimelineTick {
- left: number;
- label?: string;
- isMajor?: boolean;
-}
-
-export interface TimelineCanvasRulerWrapperProps {
- children: ReactNode;
- onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
- className?: string;
-}
-
-export interface TimelineCanvasRulerProps {
- zoomLevel: number;
- duration: number;
- width: number;
- height?: number;
-}