refactor: centralize track colors and timeline constants

This commit is contained in:
Maze Winther
2025-07-07 23:21:06 +02:00
parent d36df2fb62
commit 9d2fd50fbc
4 changed files with 97 additions and 45 deletions

View File

@ -19,6 +19,10 @@ import AudioWaveform from "./audio-waveform";
import { toast } from "sonner"; import { toast } from "sonner";
import { TimelineElementProps, TrackType } from "@/types/timeline"; import { TimelineElementProps, TrackType } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import {
getTrackElementClasses,
TIMELINE_CONSTANTS,
} from "@/lib/timeline-constants";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -67,7 +71,10 @@ export function TimelineElement({
const effectiveDuration = const effectiveDuration =
element.duration - element.trimStart - element.trimEnd; element.duration - element.trimStart - element.trimEnd;
const elementWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); const elementWidth = Math.max(
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
);
// Use real-time position during drag, otherwise use stored position // Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.elementId === element.id; const isBeingDragged = dragState.elementId === element.id;
@ -77,19 +84,6 @@ export function TimelineElement({
: element.startTime; : element.startTime;
const elementLeft = elementStartTime * 50 * zoomLevel; const elementLeft = elementStartTime * 50 * zoomLevel;
const getTrackColor = (type: TrackType) => {
switch (type) {
case "media":
return "bg-blue-500/20 border-blue-500/30";
case "text":
return "bg-purple-500/20 border-purple-500/30";
case "audio":
return "bg-green-500/20 border-green-500/30";
default:
return "bg-gray-500/20 border-gray-500/30";
}
};
const handleDeleteElement = () => { const handleDeleteElement = () => {
removeElementFromTrack(track.id, element.id); removeElementFromTrack(track.id, element.id);
setElementMenuOpen(false); setElementMenuOpen(false);
@ -271,7 +265,7 @@ export function TimelineElement({
onMouseLeave={resizing ? handleResizeEnd : undefined} onMouseLeave={resizing ? handleResizeEnd : undefined}
> >
<div <div
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackColor( className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
track.type track.type
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
isBeingDragged ? "z-50" : "z-10" isBeingDragged ? "z-50" : "z-10"

View File

@ -25,6 +25,7 @@ import type {
TimelineElement as TimelineElementType, TimelineElement as TimelineElementType,
DragData, DragData,
} from "@/types/timeline"; } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/lib/timeline-constants";
export function TimelineTrackContent({ export function TimelineTrackContent({
track, track,
@ -82,7 +83,10 @@ export function TimelineTrackContent({
const timelineRect = timelineRef.current.getBoundingClientRect(); const timelineRect = timelineRef.current.getBoundingClientRect();
const mouseX = e.clientX - timelineRect.left; const mouseX = e.clientX - timelineRect.left;
const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); const mouseTime = Math.max(
0,
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
);
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const snappedTime = Math.round(adjustedTime * 10) / 10; const snappedTime = Math.round(adjustedTime * 10) / 10;
@ -212,7 +216,8 @@ export function TimelineTrackContent({
const elementElement = e.currentTarget as HTMLElement; const elementElement = e.currentTarget as HTMLElement;
const elementRect = elementElement.getBoundingClientRect(); const elementRect = elementElement.getBoundingClientRect();
const clickOffsetX = e.clientX - elementRect.left; const clickOffsetX = e.clientX - elementRect.left;
const clickOffsetTime = clickOffsetX / (50 * zoomLevel); const clickOffsetTime =
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
startDragAction( startDragAction(
element.id, element.id,
@ -278,7 +283,7 @@ export function TimelineTrackContent({
if (trackContainer) { if (trackContainer) {
const rect = trackContainer.getBoundingClientRect(); const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left); const mouseX = Math.max(0, e.clientX - rect.left);
dropTime = mouseX / (50 * zoomLevel); dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
} }
// Check for potential overlaps and show appropriate feedback // Check for potential overlaps and show appropriate feedback
@ -453,11 +458,11 @@ export function TimelineTrackContent({
const rect = trackContainer.getBoundingClientRect(); const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left); const mouseX = Math.max(0, e.clientX - rect.left);
const mouseY = e.clientY - rect.top; // Get Y position relative to this track const mouseY = e.clientY - rect.top; // Get Y position relative to this track
const newStartTime = mouseX / (50 * zoomLevel); const newStartTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
const snappedTime = Math.round(newStartTime * 10) / 10; const snappedTime = Math.round(newStartTime * 10) / 10;
// Calculate drop position relative to tracks // Calculate drop position relative to tracks
const TRACK_HEIGHT = 60;
const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
// Determine drop zone within the track (top 20px, middle 20px, bottom 20px) // Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
@ -619,7 +624,7 @@ export function TimelineTrackContent({
type: "text", type: "text",
name: dragData.name || "Text", name: dragData.name || "Text",
content: dragData.content || "Default Text", content: dragData.content || "Default Text",
duration: 5, duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: snappedTime, startTime: snappedTime,
trimStart: 0, trimStart: 0,
trimEnd: 0, trimEnd: 0,

View File

@ -44,6 +44,10 @@ import {
} from "../ui/select"; } from "../ui/select";
import { TimelineTrackContent } from "./timeline-track"; import { TimelineTrackContent } from "./timeline-track";
import type { DragData } from "@/types/timeline"; import type { DragData } from "@/types/timeline";
import {
getTrackLabelColor,
TIMELINE_CONSTANTS,
} from "@/lib/timeline-constants";
export function Timeline() { export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their elements. // Timeline shows all tracks (video, audio, effects) and their elements.
@ -105,8 +109,8 @@ export function Timeline() {
// Dynamic timeline width calculation based on playhead position and duration // Dynamic timeline width calculation based on playhead position and duration
const dynamicTimelineWidth = Math.max( const dynamicTimelineWidth = Math.max(
(duration || 0) * 50 * zoomLevel, // Base width from duration (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration
(currentTime + 30) * 50 * zoomLevel, // Width to show current time + 30 seconds buffer (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer
timelineRef.current?.clientWidth || 1000 // Minimum width timelineRef.current?.clientWidth || 1000 // Minimum width
); );
@ -236,10 +240,11 @@ export function Timeline() {
let newSelection: { trackId: string; elementId: string }[] = []; let newSelection: { trackId: string; elementId: string }[] = [];
tracks.forEach((track, trackIdx) => { tracks.forEach((track, trackIdx) => {
track.elements.forEach((element) => { track.elements.forEach((element) => {
const clipLeft = element.startTime * 50 * zoomLevel; const clipLeft =
const clipTop = trackIdx * 60; element.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const clipBottom = clipTop + 60; const clipTop = trackIdx * TIMELINE_CONSTANTS.TRACK_HEIGHT;
const clipRight = clipLeft + 60; const clipBottom = clipTop + TIMELINE_CONSTANTS.TRACK_HEIGHT;
const clipRight = clipLeft + TIMELINE_CONSTANTS.TRACK_HEIGHT;
if ( if (
bx1 < clipRight && bx1 < clipRight &&
bx2 > clipLeft && bx2 > clipLeft &&
@ -334,7 +339,7 @@ export function Timeline() {
type: "text", type: "text",
name: dragData.name || "Text", name: dragData.name || "Text",
content: dragData.content || "Default Text", content: dragData.content || "Default Text",
duration: 5, duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0, startTime: 0,
trimStart: 0, trimStart: 0,
trimEnd: 0, trimEnd: 0,
@ -721,7 +726,8 @@ export function Timeline() {
"[data-radix-scroll-area-viewport]" "[data-radix-scroll-area-viewport]"
) as HTMLElement; ) as HTMLElement;
if (!rulerViewport || !tracksViewport) return; if (!rulerViewport || !tracksViewport) return;
const playheadPx = playheadPosition * 50 * zoomLevel; const playheadPx =
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const viewportWidth = rulerViewport.clientWidth; const viewportWidth = rulerViewport.clientWidth;
const scrollMin = 0; const scrollMin = 0;
const scrollMax = rulerViewport.scrollWidth - viewportWidth; const scrollMax = rulerViewport.scrollWidth - viewportWidth;
@ -792,7 +798,7 @@ export function Timeline() {
type: "media", type: "media",
mediaId: "test", mediaId: "test",
name: "Test Clip", name: "Test Clip",
duration: 5, duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0, startTime: 0,
trimStart: 0, trimStart: 0,
trimEnd: 0, trimEnd: 0,
@ -927,7 +933,8 @@ export function Timeline() {
{(() => { {(() => {
// Calculate appropriate time interval based on zoom level // Calculate appropriate time interval based on zoom level
const getTimeInterval = (zoom: number) => { const getTimeInterval = (zoom: number) => {
const pixelsPerSecond = 50 * zoom; const pixelsPerSecond =
TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom;
if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in 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 >= 100) return 0.5; // Every 0.5s when zoomed in
if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
@ -955,7 +962,9 @@ export function Timeline() {
? "border-l border-muted-foreground/40" ? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20" : "border-l border-muted-foreground/20"
}`} }`}
style={{ left: `${time * 50 * zoomLevel}px` }} style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
> >
<span <span
className={`absolute top-1 left-1 text-xs ${ className={`absolute top-1 left-1 text-xs ${
@ -991,7 +1000,9 @@ export function Timeline() {
{/* Playhead in ruler (scrubbable) */} {/* Playhead in ruler (scrubbable) */}
<div <div
className="playhead absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col-resize" className="playhead absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col-resize"
style={{ left: `${playheadPosition * 50 * zoomLevel}px` }} style={{
left: `${playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
onMouseDown={handlePlayheadMouseDown} onMouseDown={handlePlayheadMouseDown}
> >
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" /> <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
@ -1014,13 +1025,7 @@ export function Timeline() {
> >
<div className="flex items-center flex-1 min-w-0"> <div className="flex items-center flex-1 min-w-0">
<div <div
className={`w-3 h-3 rounded-full flex-shrink-0 ${ className={`w-3 h-3 rounded-full flex-shrink-0 ${getTrackLabelColor(track.type)}`}
track.type === "media"
? "bg-blue-500"
: track.type === "audio"
? "bg-green-500"
: "bg-purple-500"
}`}
/> />
<span className="ml-2 text-sm font-medium truncate"> <span className="ml-2 text-sm font-medium truncate">
{track.name} {track.name}
@ -1043,7 +1048,7 @@ export function Timeline() {
<div <div
className="relative flex-1" className="relative flex-1"
style={{ style={{
height: `${Math.max(200, Math.min(800, tracks.length * 60))}px`, height: `${Math.max(200, Math.min(800, tracks.length * TIMELINE_CONSTANTS.TRACK_HEIGHT))}px`,
width: `${dynamicTimelineWidth}px`, width: `${dynamicTimelineWidth}px`,
}} }}
onClick={handleTimelineClick} onClick={handleTimelineClick}
@ -1059,8 +1064,8 @@ export function Timeline() {
<div <div
className="absolute left-0 right-0 border-b border-muted/30" className="absolute left-0 right-0 border-b border-muted/30"
style={{ style={{
top: `${index * 60}px`, top: `${index * TIMELINE_CONSTANTS.TRACK_HEIGHT}px`,
height: "60px", height: `${TIMELINE_CONSTANTS.TRACK_HEIGHT}px`,
}} }}
onClick={(e) => { onClick={(e) => {
// If clicking empty area (not on a element), deselect all elements // If clicking empty area (not on a element), deselect all elements
@ -1092,8 +1097,8 @@ export function Timeline() {
<div <div
className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col" className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col"
style={{ style={{
left: `${playheadPosition * 50 * zoomLevel}px`, left: `${playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
height: `${tracks.length * 60}px`, height: `${tracks.length * TIMELINE_CONSTANTS.TRACK_HEIGHT}px`,
}} }}
onMouseDown={handlePlayheadMouseDown} onMouseDown={handlePlayheadMouseDown}
/> />

View File

@ -0,0 +1,48 @@
import type { TrackType } from "@/types/timeline";
// Track color definitions
export const TRACK_COLORS = {
media: {
solid: "bg-blue-500",
background: "bg-blue-500/20",
border: "border-blue-500/30",
},
text: {
solid: "bg-purple-500",
background: "bg-purple-500/20",
border: "border-purple-500/30",
},
audio: {
solid: "bg-green-500",
background: "bg-green-500/20",
border: "border-green-500/30",
},
default: {
solid: "bg-gray-500",
background: "bg-gray-500/20",
border: "border-gray-500/30",
},
} as const;
// Utility functions
export function getTrackColors(type: TrackType) {
return TRACK_COLORS[type] || TRACK_COLORS.default;
}
export function getTrackElementClasses(type: TrackType) {
const colors = getTrackColors(type);
return `${colors.background} ${colors.border}`;
}
export function getTrackLabelColor(type: TrackType) {
return getTrackColors(type).solid;
}
// Other timeline constants
export const TIMELINE_CONSTANTS = {
ELEMENT_MIN_WIDTH: 80,
PIXELS_PER_SECOND: 50,
TRACK_HEIGHT: 60,
DEFAULT_TEXT_DURATION: 5,
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
} as const;