mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
add: timeline editor components seperated, TimelineEditor is 600 lines now
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { useMemo, type CSSProperties } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { calculateAxisScale, formatTimeLabel } from "../../core/time";
|
||||
|
||||
interface TimelineAxisProps {
|
||||
videoDurationMs: number;
|
||||
currentTimeMs: number;
|
||||
}
|
||||
|
||||
export default function TimelineAxis({ videoDurationMs, currentTimeMs }: TimelineAxisProps) {
|
||||
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
|
||||
const { intervalMs } = useMemo(
|
||||
() => calculateAxisScale(range.end - range.start),
|
||||
[range.end, range.start],
|
||||
);
|
||||
|
||||
const markers = useMemo(() => {
|
||||
if (intervalMs <= 0) {
|
||||
return { markers: [], minorTicks: [] as number[] };
|
||||
}
|
||||
|
||||
const maxTime = videoDurationMs > 0 ? videoDurationMs : range.end;
|
||||
const visibleStart = Math.max(0, Math.min(range.start, maxTime));
|
||||
const visibleEnd = Math.min(range.end, maxTime);
|
||||
const markerTimes = new Set<number>();
|
||||
const firstMarker = Math.ceil(visibleStart / intervalMs) * intervalMs;
|
||||
|
||||
for (let time = firstMarker; time <= maxTime; time += intervalMs) {
|
||||
if (time >= visibleStart && time <= visibleEnd) markerTimes.add(Math.round(time));
|
||||
}
|
||||
|
||||
if (visibleStart <= maxTime) markerTimes.add(Math.round(visibleStart));
|
||||
if (videoDurationMs > 0) markerTimes.add(Math.round(videoDurationMs));
|
||||
|
||||
const sorted = Array.from(markerTimes)
|
||||
.filter((time) => time <= maxTime)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const minorTicks: number[] = [];
|
||||
const minorInterval = intervalMs / 5;
|
||||
for (let time = firstMarker; time <= maxTime; time += minorInterval) {
|
||||
if (time >= visibleStart && time <= visibleEnd) {
|
||||
const isMajor = Math.abs(time % intervalMs) < 1;
|
||||
if (!isMajor) minorTicks.push(time);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
markers: sorted.map((time) => ({ time, label: formatTimeLabel(time, intervalMs) })),
|
||||
minorTicks,
|
||||
};
|
||||
}, [intervalMs, range.end, range.start, videoDurationMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-8 bg-editor-bg border-b border-foreground/10 relative overflow-hidden select-none"
|
||||
style={{ [sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px` }}
|
||||
>
|
||||
{markers.minorTicks.map((time) => {
|
||||
const offset = valueToPixels(time - range.start);
|
||||
return (
|
||||
<div
|
||||
key={`minor-${time}`}
|
||||
className="absolute bottom-1 h-1 w-[1px] bg-foreground/5"
|
||||
style={{ [sideProperty]: `${offset}px` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{markers.markers.map((marker) => {
|
||||
const offset = valueToPixels(marker.time - range.start);
|
||||
const markerStyle: CSSProperties = {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
[sideProperty]: `${offset}px`,
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={marker.time} style={markerStyle}>
|
||||
<div className="flex flex-col items-center pb-1">
|
||||
<div className="mb-1.5 h-[5px] w-[5px] rounded-full bg-foreground/30" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium tabular-nums tracking-tight",
|
||||
marker.time === currentTimeMs ? "text-[#2563EB]" : "text-foreground/40",
|
||||
)}
|
||||
>
|
||||
{marker.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { useMemo } from "react";
|
||||
import { calculateAxisScale } from "../../core/time";
|
||||
|
||||
interface ClipMarkerOverlayProps {
|
||||
videoDurationMs: number;
|
||||
}
|
||||
|
||||
export default function ClipMarkerOverlay({ videoDurationMs }: ClipMarkerOverlayProps) {
|
||||
const { direction, range, valueToPixels } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
|
||||
const { intervalMs } = useMemo(
|
||||
() => calculateAxisScale(range.end - range.start),
|
||||
[range.end, range.start],
|
||||
);
|
||||
|
||||
const markers = useMemo(() => {
|
||||
if (intervalMs <= 0) return [] as { time: number; offset: number }[];
|
||||
const maxTime = videoDurationMs > 0 ? videoDurationMs : range.end;
|
||||
const visibleStart = Math.max(0, range.start);
|
||||
const visibleEnd = Math.min(range.end, maxTime);
|
||||
const firstMarker = Math.ceil(visibleStart / intervalMs) * intervalMs;
|
||||
const result: { time: number; offset: number }[] = [];
|
||||
for (let time = firstMarker; time <= maxTime; time += intervalMs) {
|
||||
if (time > visibleStart && time < visibleEnd) {
|
||||
result.push({
|
||||
time: Math.round(time),
|
||||
offset: valueToPixels(Math.round(time) - range.start),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [intervalMs, range.start, range.end, videoDurationMs, valueToPixels]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-[1]">
|
||||
{markers.map(({ time, offset }) => (
|
||||
<div
|
||||
key={time}
|
||||
className="absolute w-px"
|
||||
style={{
|
||||
top: "7.5%",
|
||||
bottom: "7.5%",
|
||||
[sideProperty]: `${offset}px`,
|
||||
background:
|
||||
"linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.32) 35%, rgba(255,255,255,0.32) 65%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatPlayheadTime } from "../../core/time";
|
||||
|
||||
interface PlaybackCursorProps {
|
||||
currentTimeMs: number;
|
||||
videoDurationMs: number;
|
||||
onSeek?: (time: number) => void;
|
||||
timelineRef: RefObject<HTMLDivElement>;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
}
|
||||
|
||||
export default function PlaybackCursor({
|
||||
currentTimeMs,
|
||||
videoDurationMs,
|
||||
onSeek,
|
||||
timelineRef,
|
||||
keyframes = [],
|
||||
}: PlaybackCursorProps) {
|
||||
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!timelineRef.current || !onSeek) return;
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
let absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
|
||||
const snapThresholdMs = 150;
|
||||
const nearbyKeyframe = keyframes.find(
|
||||
(kf) =>
|
||||
Math.abs(kf.time - absoluteMs) <= snapThresholdMs &&
|
||||
kf.time >= range.start &&
|
||||
kf.time <= range.end,
|
||||
);
|
||||
if (nearbyKeyframe) absoluteMs = nearbyKeyframe.time;
|
||||
|
||||
onSeek(absoluteMs / 1000);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "ew-resize";
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
}, [
|
||||
isDragging,
|
||||
onSeek,
|
||||
timelineRef,
|
||||
sidebarWidth,
|
||||
range.start,
|
||||
range.end,
|
||||
videoDurationMs,
|
||||
pixelsToValue,
|
||||
keyframes,
|
||||
]);
|
||||
|
||||
if (videoDurationMs <= 0 || currentTimeMs < 0) return null;
|
||||
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
|
||||
if (clampedTime < range.start || clampedTime > range.end) return null;
|
||||
|
||||
const offset = valueToPixels(clampedTime - range.start);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-50 group/cursor"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-[#2563EB] shadow-[0_0_10px_rgba(37,99,235,0.5)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_15px_rgba(37,99,235,0.7)] transition-shadow"
|
||||
style={{ [sideProperty]: `${offset}px` }}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform"
|
||||
style={{ width: "16px", height: "16px" }}
|
||||
>
|
||||
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#2563EB] rotate-45 rounded-sm shadow-lg border border-foreground/20" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-foreground/10 shadow-lg pointer-events-none",
|
||||
isDragging ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className="leading-5">{formatPlayheadTime(clampedTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Check,
|
||||
CaretDown as ChevronDown,
|
||||
Crop,
|
||||
ChatText as MessageSquare,
|
||||
MusicNote as Music,
|
||||
Scissors,
|
||||
MagicWand as WandSparkles,
|
||||
MagnifyingGlassPlus as ZoomIn,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
ASPECT_RATIOS,
|
||||
type AspectRatio,
|
||||
getAspectRatioLabel,
|
||||
isCustomAspectRatio,
|
||||
} from "@/utils/aspectRatioUtils";
|
||||
|
||||
interface TimelineToolbarProps {
|
||||
aspectRatio: AspectRatio;
|
||||
isCropped: boolean;
|
||||
scrollLabels: { pan: string; zoom: string };
|
||||
customAspectWidth: string;
|
||||
customAspectHeight: string;
|
||||
onCustomAspectWidthChange: (value: string) => void;
|
||||
onCustomAspectHeightChange: (value: string) => void;
|
||||
onCustomAspectRatioKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
|
||||
onApplyCustomAspectRatio: () => void;
|
||||
onAspectRatioChange?: (aspectRatio: AspectRatio) => void;
|
||||
onOpenCropEditor?: () => void;
|
||||
onAddZoom: () => void;
|
||||
onSuggestZooms: () => void;
|
||||
onAddAnnotation: () => void;
|
||||
onAddAudio: () => void;
|
||||
onSplitClip: () => void;
|
||||
cropLabel: string;
|
||||
}
|
||||
|
||||
export default function TimelineToolbar({
|
||||
aspectRatio,
|
||||
isCropped,
|
||||
scrollLabels,
|
||||
customAspectWidth,
|
||||
customAspectHeight,
|
||||
onCustomAspectWidthChange,
|
||||
onCustomAspectHeightChange,
|
||||
onCustomAspectRatioKeyDown,
|
||||
onApplyCustomAspectRatio,
|
||||
onAspectRatioChange,
|
||||
onOpenCropEditor,
|
||||
onAddZoom,
|
||||
onSuggestZooms,
|
||||
onAddAnnotation,
|
||||
onAddAudio,
|
||||
onSplitClip,
|
||||
cropLabel,
|
||||
}: TimelineToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-foreground/10 bg-editor-panel">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button onClick={onAddZoom} variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-[#2563EB] hover:bg-[#2563EB]/10 transition-all" title="Add Zoom (Z)">
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={onSuggestZooms} variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-[#2563EB] hover:bg-[#2563EB]/10 transition-all" title="Suggest Zooms from Cursor">
|
||||
<WandSparkles className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={onAddAnnotation} variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all" title="Add Annotation (A)">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={onAddAudio} variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-[#a855f7] hover:bg-[#a855f7]/10 transition-all" title="Add Audio">
|
||||
<Music className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={onSplitClip} variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all" title="Split Clip (C)">
|
||||
<Scissors className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all gap-1">
|
||||
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-editor-surface-alt border-foreground/10">
|
||||
{ASPECT_RATIOS.map((ratio) => (
|
||||
<DropdownMenuItem key={ratio} onClick={() => onAspectRatioChange?.(ratio)} className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer flex items-center justify-between gap-3">
|
||||
<span>{getAspectRatioLabel(ratio)}</span>
|
||||
{aspectRatio === ratio && <Check className="w-3 h-3 text-[#2563EB]" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<div className="mx-1 my-1 h-px bg-foreground/10" />
|
||||
<div className="px-2 py-1.5 flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-sm">Custom</span>
|
||||
<input type="text" inputMode="numeric" value={customAspectWidth} onChange={(event) => onCustomAspectWidthChange(event.target.value.replace(/\D/g, ""))} onKeyDown={onCustomAspectRatioKeyDown} className="w-12 h-7 rounded border border-foreground/10 bg-foreground/5 px-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#2563EB]" aria-label="Custom aspect width" />
|
||||
<span className="text-muted-foreground/70">:</span>
|
||||
<input type="text" inputMode="numeric" value={customAspectHeight} onChange={(event) => onCustomAspectHeightChange(event.target.value.replace(/\D/g, ""))} onKeyDown={onCustomAspectRatioKeyDown} className="w-12 h-7 rounded border border-foreground/10 bg-foreground/5 px-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#2563EB]" aria-label="Custom aspect height" />
|
||||
<Button variant="ghost" size="sm" onClick={onApplyCustomAspectRatio} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-foreground/10">Set</Button>
|
||||
{isCustomAspectRatio(aspectRatio) && <Check className="w-3 h-3 text-[#2563EB] ml-auto" />}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="w-[1px] h-4 bg-foreground/10" />
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenCropEditor?.()} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all gap-1.5">
|
||||
<Crop className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">{cropLabel}</span>
|
||||
{isCropped ? <span className="h-1.5 w-1.5 rounded-full bg-[#2563EB]" /> : null}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-4 text-[10px] text-muted-foreground/70 font-medium">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-sans">Side Scroll</kbd>
|
||||
<span>Pan</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-sans">{scrollLabels.pan}</kbd>
|
||||
<span>Pan</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-sans">{scrollLabels.zoom}</kbd>
|
||||
<span>Zoom</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import glassStyles from "../../ItemGlass.module.css";
|
||||
import Item from "../../Item";
|
||||
import Row from "../../Row";
|
||||
import {
|
||||
getTimelineContentMinHeightPx,
|
||||
getTimelineRowsMinHeightPx,
|
||||
getTimelineViewportStretchFactor,
|
||||
TIMELINE_AXIS_HEIGHT_PX,
|
||||
} from "../../timelineLayout";
|
||||
import type { AudioPeaksData } from "../../useAudioPeaks";
|
||||
import AudioWaveform from "../../AudioWaveform";
|
||||
import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../../core/constants";
|
||||
import {
|
||||
getAnnotationTrackIndex,
|
||||
getAnnotationTrackRowId,
|
||||
getAudioTrackIndex,
|
||||
getAudioTrackRowId,
|
||||
isAnnotationTrackRowId,
|
||||
isAudioTrackRowId,
|
||||
} from "../../core/rows";
|
||||
import type { TimelineRenderItem } from "../../model/timelineModel";
|
||||
import TimelineAxis from "../axis/TimelineAxis";
|
||||
import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay";
|
||||
import PlaybackCursor from "../playhead/PlaybackCursor";
|
||||
|
||||
interface TimelineCanvasProps {
|
||||
items: TimelineRenderItem[];
|
||||
videoDurationMs: number;
|
||||
currentTimeMs: number;
|
||||
onSeek?: (time: number) => void;
|
||||
canPlaceZoomAtMs?: (startMs: number) => boolean;
|
||||
onSelectZoom?: (id: string | null) => void;
|
||||
onSelectTrim?: (id: string | null) => void;
|
||||
onSelectClip?: (id: string | null) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
onSelectAudio?: (id: string | null) => void;
|
||||
onAddZoomAtMs?: (startMs: number) => void;
|
||||
selectedZoomId: string | null;
|
||||
selectedTrimId?: string | null;
|
||||
selectedClipId?: string | null;
|
||||
selectedAnnotationId?: string | null;
|
||||
selectedSpeedId?: string | null;
|
||||
selectedAudioId?: string | null;
|
||||
selectAllBlocksActive?: boolean;
|
||||
onClearBlockSelection?: () => void;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
audioPeaks?: AudioPeaksData | null;
|
||||
}
|
||||
|
||||
export default function TimelineCanvas({
|
||||
items,
|
||||
videoDurationMs,
|
||||
currentTimeMs,
|
||||
onSeek,
|
||||
onAddZoomAtMs,
|
||||
canPlaceZoomAtMs,
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectClip,
|
||||
onSelectAnnotation,
|
||||
onSelectSpeed,
|
||||
onSelectAudio,
|
||||
selectedZoomId,
|
||||
selectedTrimId: _selectedTrimId,
|
||||
selectedClipId,
|
||||
selectedAnnotationId,
|
||||
selectedSpeedId: _selectedSpeedId,
|
||||
selectedAudioId,
|
||||
selectAllBlocksActive = false,
|
||||
onClearBlockSelection,
|
||||
keyframes = [],
|
||||
audioPeaks,
|
||||
}: TimelineCanvasProps) {
|
||||
const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } =
|
||||
useTimelineContext();
|
||||
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isTimelineHovered, setIsTimelineHovered] = useState(false);
|
||||
const [timelineHoverMs, setTimelineHoverMs] = useState<number | null>(null);
|
||||
const [isZoomRowHovered, setIsZoomRowHovered] = useState(false);
|
||||
const [zoomRowHoverMs, setZoomRowHoverMs] = useState<number | null>(null);
|
||||
|
||||
const setRefs = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setTimelineRef(node);
|
||||
localTimelineRef.current = node;
|
||||
},
|
||||
[setTimelineRef],
|
||||
);
|
||||
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!onSeek || videoDurationMs <= 0) return;
|
||||
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectClip?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
onSelectAudio?.(null);
|
||||
onClearBlockSelection?.();
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
if (clickX < 0) return;
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
onSeek(absoluteMs / 1000);
|
||||
},
|
||||
[
|
||||
onSeek,
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectClip,
|
||||
onSelectAnnotation,
|
||||
onSelectSpeed,
|
||||
onSelectAudio,
|
||||
onClearBlockSelection,
|
||||
videoDurationMs,
|
||||
sidebarWidth,
|
||||
range.start,
|
||||
pixelsToValue,
|
||||
],
|
||||
);
|
||||
|
||||
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
||||
const clipItems = items.filter((item) => item.rowId === CLIP_ROW_ID);
|
||||
const annotationItems = items.filter((item) => isAnnotationTrackRowId(item.rowId));
|
||||
const audioItems = items.filter((item) => isAudioTrackRowId(item.rowId));
|
||||
const audioRowIds = useMemo(
|
||||
() =>
|
||||
Array.from(new Set(audioItems.map((item) => getAudioTrackRowId(getAudioTrackIndex(item.rowId))))).sort(
|
||||
(left, right) => getAudioTrackIndex(left) - getAudioTrackIndex(right),
|
||||
),
|
||||
[audioItems],
|
||||
);
|
||||
const annotationRowIds = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(annotationItems.map((item) => getAnnotationTrackRowId(getAnnotationTrackIndex(item.rowId)))),
|
||||
).sort((left, right) => getAnnotationTrackIndex(left) - getAnnotationTrackIndex(right)),
|
||||
[annotationItems],
|
||||
);
|
||||
|
||||
const timelineRowCount = 2 + annotationRowIds.length + audioRowIds.length;
|
||||
const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount);
|
||||
const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount);
|
||||
const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount);
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
const visibleDurationMs = Math.max(1, range.end - range.start);
|
||||
|
||||
const ghostStartMs = zoomRowHoverMs === null ? null : Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs));
|
||||
const ghostDurationMs = Math.min(1000, videoDurationMs);
|
||||
const ghostEndMs =
|
||||
ghostStartMs === null
|
||||
? null
|
||||
: Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs));
|
||||
const ghostStartOffsetPx = ghostStartMs === null ? 0 : valueToPixels(Math.max(0, ghostStartMs - range.start));
|
||||
const ghostEndOffsetPx = ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - range.start));
|
||||
const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx);
|
||||
const timelineGhostOffsetPx = timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - range.start));
|
||||
const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null;
|
||||
const canShowGhostZoom =
|
||||
isZoomRowHovered && ghostStartMs !== null && (onAddZoomAtMs ? (canPlaceZoomAtMs?.(ghostStartMs) ?? true) : false);
|
||||
|
||||
const updateTimelineHoverTime = useCallback(
|
||||
(clientX: number, rect: DOMRect) => {
|
||||
const contentWidth = Math.max(1, rect.width - sidebarWidth);
|
||||
const contentX =
|
||||
direction === "rtl" ? rect.right - sidebarWidth - clientX : clientX - rect.left - sidebarWidth;
|
||||
const clampedX = Math.max(0, Math.min(contentX, contentWidth));
|
||||
const ratio = clampedX / contentWidth;
|
||||
const nextMs = range.start + ratio * visibleDurationMs;
|
||||
setTimelineHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
|
||||
},
|
||||
[direction, range.start, sidebarWidth, videoDurationMs, visibleDurationMs],
|
||||
);
|
||||
|
||||
const handleTimelineMouseEnter = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsTimelineHovered(true);
|
||||
updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
|
||||
},
|
||||
[updateTimelineHoverTime],
|
||||
);
|
||||
|
||||
const handleTimelineMouseMove = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isTimelineHovered) setIsTimelineHovered(true);
|
||||
updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
|
||||
},
|
||||
[isTimelineHovered, updateTimelineHoverTime],
|
||||
);
|
||||
|
||||
const handleTimelineMouseLeave = useCallback(() => {
|
||||
setIsTimelineHovered(false);
|
||||
setTimelineHoverMs(null);
|
||||
setIsZoomRowHovered(false);
|
||||
setZoomRowHoverMs(null);
|
||||
}, []);
|
||||
|
||||
const updateZoomRowHoverTime = useCallback(
|
||||
(clientX: number, rect: DOMRect) => {
|
||||
if (rect.width <= 0) return;
|
||||
const position =
|
||||
direction === "rtl"
|
||||
? Math.max(0, Math.min(rect.right - clientX, rect.width))
|
||||
: Math.max(0, Math.min(clientX - rect.left, rect.width));
|
||||
const ratio = position / rect.width;
|
||||
const nextMs = range.start + ratio * visibleDurationMs;
|
||||
setZoomRowHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
|
||||
},
|
||||
[direction, range.start, videoDurationMs, visibleDurationMs],
|
||||
);
|
||||
|
||||
const handleZoomRowMouseEnter = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsZoomRowHovered(true);
|
||||
updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
|
||||
},
|
||||
[updateZoomRowHoverTime],
|
||||
);
|
||||
const handleZoomRowMouseMove = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isZoomRowHovered) setIsZoomRowHovered(true);
|
||||
updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
|
||||
},
|
||||
[isZoomRowHovered, updateZoomRowHoverTime],
|
||||
);
|
||||
const handleZoomRowMouseLeave = useCallback(() => {
|
||||
setIsZoomRowHovered(false);
|
||||
setZoomRowHoverMs(null);
|
||||
}, []);
|
||||
const handleZoomRowClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!onAddZoomAtMs || zoomRowHoverMs === null) return;
|
||||
const startMs = Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs));
|
||||
if (canPlaceZoomAtMs && !canPlaceZoomAtMs(startMs)) return;
|
||||
onAddZoomAtMs(startMs);
|
||||
},
|
||||
[canPlaceZoomAtMs, onAddZoomAtMs, videoDurationMs, zoomRowHoverMs],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
style={{
|
||||
...style,
|
||||
height: `max(100%, ${timelineContentMinHeightPx}px, calc(${TIMELINE_AXIS_HEIGHT_PX}px + (100% - ${TIMELINE_AXIS_HEIGHT_PX}px) * ${timelineViewportStretchFactor}))`,
|
||||
}}
|
||||
className="select-none bg-editor-bg relative cursor-pointer group flex flex-col"
|
||||
onClick={handleTimelineClick}
|
||||
onMouseEnter={handleTimelineMouseEnter}
|
||||
onMouseMove={handleTimelineMouseMove}
|
||||
onMouseLeave={handleTimelineMouseLeave}
|
||||
>
|
||||
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||
<PlaybackCursor
|
||||
currentTimeMs={currentTimeMs}
|
||||
videoDurationMs={videoDurationMs}
|
||||
onSeek={onSeek}
|
||||
timelineRef={localTimelineRef}
|
||||
keyframes={keyframes}
|
||||
/>
|
||||
{canShowGhostPlayhead && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-[45] pointer-events-none"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0 bottom-0 w-px bg-foreground/35" style={{ [sideProperty]: `${timelineGhostOffsetPx}px` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex flex-1 min-h-0 flex-col" style={{ minHeight: timelineRowsMinHeightPx }}>
|
||||
<Row id={CLIP_ROW_ID} isEmpty={clipItems.length === 0} hint="Press C to split clip">
|
||||
{audioPeaks && <AudioWaveform peaks={audioPeaks} />}
|
||||
<ClipMarkerOverlay videoDurationMs={videoDurationMs} />
|
||||
{clipItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedClipId}
|
||||
onSelect={() => onSelectClip?.(item.id)}
|
||||
variant="clip"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
id={ZOOM_ROW_ID}
|
||||
isEmpty={zoomItems.length === 0}
|
||||
onMouseEnter={handleZoomRowMouseEnter}
|
||||
onMouseMove={handleZoomRowMouseMove}
|
||||
onMouseLeave={handleZoomRowMouseLeave}
|
||||
onClick={handleZoomRowClick}
|
||||
>
|
||||
{canShowGhostZoom && ghostStartMs !== null && (
|
||||
<div className="absolute inset-0 z-[3] pointer-events-none">
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 h-[85%] min-h-[22px]"
|
||||
style={
|
||||
direction === "rtl"
|
||||
? { right: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` }
|
||||
: { left: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` }
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
glassStyles.glassPurple,
|
||||
"w-full h-full overflow-hidden flex items-center justify-center cursor-default relative opacity-80",
|
||||
)}
|
||||
>
|
||||
<div className={cn(glassStyles.zoomEndCap, glassStyles.left)} />
|
||||
<div className={cn(glassStyles.zoomEndCap, glassStyles.right)} />
|
||||
<div className="relative z-10 inline-flex h-4 w-4 items-center justify-center rounded-full border border-white/45 bg-white/15 text-white">
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{zoomItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedZoomId}
|
||||
onSelect={() => onSelectZoom?.(item.id)}
|
||||
zoomDepth={item.zoomDepth}
|
||||
zoomMode={item.zoomMode}
|
||||
variant="zoom"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{annotationRowIds.map((rowId, index) => {
|
||||
const rowItems = annotationItems.filter(
|
||||
(item) => getAnnotationTrackRowId(getAnnotationTrackIndex(item.rowId)) === rowId,
|
||||
);
|
||||
return (
|
||||
<Row key={rowId} id={rowId} isEmpty={rowItems.length === 0} hint={index === 0 ? "Press A to add annotation" : undefined}>
|
||||
{rowItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedAnnotationId}
|
||||
onSelect={() => onSelectAnnotation?.(item.id)}
|
||||
variant="annotation"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
|
||||
{audioRowIds.map((rowId, index) => {
|
||||
const rowItems = audioItems.filter(
|
||||
(item) => getAudioTrackRowId(getAudioTrackIndex(item.rowId)) === rowId,
|
||||
);
|
||||
return (
|
||||
<Row key={rowId} id={rowId} isEmpty={rowItems.length === 0} hint={index === 0 ? "Click music icon to add audio" : undefined}>
|
||||
{rowItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedAudioId}
|
||||
onSelect={() => onSelectAudio?.(item.id)}
|
||||
variant="audio"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user