mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
removed useless files, to avoid having more files than needed
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import {
|
||||
forwardRef,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
@@ -32,7 +33,6 @@ import { useTimelineAudioPeaks } from "./hooks/useTimelineAudioPeaks";
|
||||
import { calculateTimelineScale } from "./core/time";
|
||||
import { useTimelineEditorRuntime } from "./hooks/useTimelineEditorRuntime";
|
||||
import { useTimelineRange } from "./hooks/useTimelineRange";
|
||||
import TimelineEditorShell from "./components/editor/TimelineEditorShell";
|
||||
import TimelineCanvas from "./components/viewport/TimelineCanvas";
|
||||
import TimelineToolbar from "./components/toolbar/TimelineToolbar";
|
||||
|
||||
@@ -300,11 +300,25 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
void handleAddAudio();
|
||||
}, [handleAddAudio]);
|
||||
|
||||
if (!videoDuration || videoDuration === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-editor-surface gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-foreground/5 flex items-center justify-center">
|
||||
<Plus className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">No Video Loaded</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Drag and drop a video to start editing
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineEditorShell
|
||||
videoDuration={videoDuration}
|
||||
hideToolbar={hideToolbar}
|
||||
toolbar={
|
||||
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden">
|
||||
{hideToolbar ? null : (
|
||||
<TimelineToolbar
|
||||
aspectRatio={aspectRatio}
|
||||
isCropped={isCropped}
|
||||
@@ -324,9 +338,8 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
onSplitClip={handleSplitClip}
|
||||
cropLabel={t("sections.crop", "Crop")}
|
||||
/>
|
||||
}
|
||||
timelineViewport={
|
||||
<div
|
||||
)}
|
||||
<div
|
||||
ref={timelineContainerRef}
|
||||
className="flex-1 min-h-0 overflow-auto bg-editor-bg relative"
|
||||
tabIndex={0}
|
||||
@@ -387,8 +400,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface TimelineEditorShellProps {
|
||||
videoDuration: number;
|
||||
hideToolbar: boolean;
|
||||
toolbar: ReactNode;
|
||||
timelineViewport: ReactNode;
|
||||
}
|
||||
|
||||
export default function TimelineEditorShell({
|
||||
videoDuration,
|
||||
hideToolbar,
|
||||
toolbar,
|
||||
timelineViewport,
|
||||
}: TimelineEditorShellProps) {
|
||||
if (!videoDuration || videoDuration === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-editor-surface gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-foreground/5 flex items-center justify-center">
|
||||
<Plus className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">No Video Loaded</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Drag and drop a video to start editing
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden">
|
||||
{hideToolbar ? null : toolbar}
|
||||
{timelineViewport}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,34 @@
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { memo, useCallback, useMemo, useRef, useState, type MouseEvent, type MouseEventHandler } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getTimelineContentMinHeightPx,
|
||||
getTimelineRowsMinHeightPx,
|
||||
getTimelineViewportStretchFactor,
|
||||
TIMELINE_AXIS_HEIGHT_PX,
|
||||
} from "../../timelineLayout";
|
||||
import AudioWaveform from "../waveform/AudioWaveform";
|
||||
import glassStyles from "../../ItemGlass.module.css";
|
||||
import Item from "../../Item";
|
||||
import Row from "../../Row";
|
||||
import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../../core/constants";
|
||||
import type { AudioPeaksData, TimelineRenderItem } from "../../core/timelineTypes";
|
||||
import { isAnnotationTrackRowId, isAudioTrackRowId } from "../../core/rows";
|
||||
import {
|
||||
getAnnotationTrackIndex,
|
||||
getAnnotationTrackRowId,
|
||||
getAudioTrackIndex,
|
||||
getAudioTrackRowId,
|
||||
isAnnotationTrackRowId,
|
||||
isAudioTrackRowId,
|
||||
} from "../../core/rows";
|
||||
import TimelineAxis from "../axis/TimelineAxis";
|
||||
import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay";
|
||||
import PlaybackCursor from "../playhead/PlaybackCursor";
|
||||
import { TimelineCanvasRows } from "./TimelineCanvasRows";
|
||||
import { useTimelineCanvasHover } from "./useTimelineCanvasHover";
|
||||
|
||||
const HINT_CLIP = "Press C to split clip";
|
||||
const HINT_ANNOTATION = "Press A to add annotation";
|
||||
const HINT_AUDIO = "Click music icon to add audio";
|
||||
|
||||
interface TimelineCanvasProps {
|
||||
items: TimelineRenderItem[];
|
||||
@@ -35,6 +51,359 @@ interface TimelineCanvasProps {
|
||||
audioPeaks?: AudioPeaksData | null;
|
||||
}
|
||||
|
||||
interface TimelineHoverParams {
|
||||
direction: string;
|
||||
sidebarWidth: number;
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
videoDurationMs: number;
|
||||
onAddZoomAtMs?: (startMs: number) => void;
|
||||
canPlaceZoomAtMs?: (startMs: number) => boolean;
|
||||
valueToPixels: (value: number) => number;
|
||||
}
|
||||
|
||||
function useTimelineHover({
|
||||
direction,
|
||||
sidebarWidth,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
videoDurationMs,
|
||||
onAddZoomAtMs,
|
||||
canPlaceZoomAtMs,
|
||||
valueToPixels,
|
||||
}: TimelineHoverParams) {
|
||||
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 visibleDurationMs = Math.max(1, rangeEnd - rangeStart);
|
||||
|
||||
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 = rangeStart + ratio * visibleDurationMs;
|
||||
setTimelineHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
|
||||
},
|
||||
[direction, rangeStart, sidebarWidth, videoDurationMs, visibleDurationMs],
|
||||
);
|
||||
|
||||
const handleTimelineMouseEnter = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
setIsTimelineHovered(true);
|
||||
updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
|
||||
},
|
||||
[updateTimelineHoverTime],
|
||||
);
|
||||
|
||||
const handleTimelineMouseMove = useCallback(
|
||||
(event: 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 = rangeStart + ratio * visibleDurationMs;
|
||||
setZoomRowHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
|
||||
},
|
||||
[direction, rangeStart, videoDurationMs, visibleDurationMs],
|
||||
);
|
||||
|
||||
const handleZoomRowMouseEnter = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
setIsZoomRowHovered(true);
|
||||
updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
|
||||
},
|
||||
[updateZoomRowHoverTime],
|
||||
);
|
||||
|
||||
const handleZoomRowMouseMove = useCallback(
|
||||
(event: 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: 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],
|
||||
);
|
||||
|
||||
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 - rangeStart));
|
||||
const ghostEndOffsetPx = ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - rangeStart));
|
||||
const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx);
|
||||
const timelineGhostOffsetPx =
|
||||
timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - rangeStart));
|
||||
const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null;
|
||||
const canShowGhostZoom =
|
||||
isZoomRowHovered &&
|
||||
ghostStartMs !== null &&
|
||||
(onAddZoomAtMs ? (canPlaceZoomAtMs?.(ghostStartMs) ?? true) : false);
|
||||
|
||||
return {
|
||||
canShowGhostPlayhead,
|
||||
timelineGhostOffsetPx,
|
||||
handleTimelineMouseEnter,
|
||||
handleTimelineMouseMove,
|
||||
handleTimelineMouseLeave,
|
||||
canShowGhostZoom,
|
||||
ghostStartMs,
|
||||
ghostStartOffsetPx,
|
||||
ghostWidthPx,
|
||||
handleZoomRowMouseEnter,
|
||||
handleZoomRowMouseMove,
|
||||
handleZoomRowMouseLeave,
|
||||
handleZoomRowClick,
|
||||
};
|
||||
}
|
||||
|
||||
interface TimelineCanvasRowsProps {
|
||||
items: TimelineRenderItem[];
|
||||
videoDurationMs: number;
|
||||
selectAllBlocksActive: boolean;
|
||||
selectedZoomId: string | null;
|
||||
selectedClipId?: string | null;
|
||||
selectedAnnotationId?: string | null;
|
||||
selectedAudioId?: string | null;
|
||||
onSelectZoom?: (id: string | null) => void;
|
||||
onSelectClip?: (id: string | null) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onSelectAudio?: (id: string | null) => void;
|
||||
audioPeaks?: AudioPeaksData | null;
|
||||
direction: string;
|
||||
canShowGhostZoom: boolean;
|
||||
ghostStartMs: number | null;
|
||||
ghostStartOffsetPx: number;
|
||||
ghostWidthPx: number;
|
||||
onZoomRowMouseEnter: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowMouseMove: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowMouseLeave: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowClick: MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
items,
|
||||
videoDurationMs,
|
||||
selectAllBlocksActive,
|
||||
selectedZoomId,
|
||||
selectedClipId,
|
||||
selectedAnnotationId,
|
||||
selectedAudioId,
|
||||
onSelectZoom,
|
||||
onSelectClip,
|
||||
onSelectAnnotation,
|
||||
onSelectAudio,
|
||||
audioPeaks,
|
||||
direction,
|
||||
canShowGhostZoom,
|
||||
ghostStartMs,
|
||||
ghostStartOffsetPx,
|
||||
ghostWidthPx,
|
||||
onZoomRowMouseEnter,
|
||||
onZoomRowMouseMove,
|
||||
onZoomRowMouseLeave,
|
||||
onZoomRowClick,
|
||||
}: TimelineCanvasRowsProps) {
|
||||
const { clipItems, zoomItems, annotationRows, audioRows } = useMemo(() => {
|
||||
const nextClipItems: TimelineRenderItem[] = [];
|
||||
const nextZoomItems: TimelineRenderItem[] = [];
|
||||
const annotationBuckets = new Map<number, TimelineRenderItem[]>();
|
||||
const audioBuckets = new Map<number, TimelineRenderItem[]>();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.rowId === CLIP_ROW_ID) {
|
||||
nextClipItems.push(item);
|
||||
continue;
|
||||
}
|
||||
if (item.rowId === ZOOM_ROW_ID) {
|
||||
nextZoomItems.push(item);
|
||||
continue;
|
||||
}
|
||||
if (isAnnotationTrackRowId(item.rowId)) {
|
||||
const trackIndex = getAnnotationTrackIndex(item.rowId);
|
||||
const bucket = annotationBuckets.get(trackIndex);
|
||||
if (bucket) bucket.push(item);
|
||||
else annotationBuckets.set(trackIndex, [item]);
|
||||
continue;
|
||||
}
|
||||
if (isAudioTrackRowId(item.rowId)) {
|
||||
const trackIndex = getAudioTrackIndex(item.rowId);
|
||||
const bucket = audioBuckets.get(trackIndex);
|
||||
if (bucket) bucket.push(item);
|
||||
else audioBuckets.set(trackIndex, [item]);
|
||||
}
|
||||
}
|
||||
|
||||
const annotationRowsSorted = Array.from(annotationBuckets.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([trackIndex, rowItems]) => ({
|
||||
rowId: getAnnotationTrackRowId(trackIndex),
|
||||
items: rowItems,
|
||||
}));
|
||||
const audioRowsSorted = Array.from(audioBuckets.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([trackIndex, rowItems]) => ({
|
||||
rowId: getAudioTrackRowId(trackIndex),
|
||||
items: rowItems,
|
||||
}));
|
||||
|
||||
return {
|
||||
clipItems: nextClipItems,
|
||||
zoomItems: nextZoomItems,
|
||||
annotationRows: annotationRowsSorted,
|
||||
audioRows: audioRowsSorted,
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row id={CLIP_ROW_ID} isEmpty={clipItems.length === 0} hint={HINT_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}
|
||||
onSelectId={onSelectClip}
|
||||
variant="clip"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
id={ZOOM_ROW_ID}
|
||||
isEmpty={zoomItems.length === 0}
|
||||
onMouseEnter={onZoomRowMouseEnter}
|
||||
onMouseMove={onZoomRowMouseMove}
|
||||
onMouseLeave={onZoomRowMouseLeave}
|
||||
onClick={onZoomRowClick}
|
||||
>
|
||||
{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}
|
||||
onSelectId={onSelectZoom}
|
||||
zoomDepth={item.zoomDepth}
|
||||
zoomMode={item.zoomMode}
|
||||
variant="zoom"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{annotationRows.map(({ rowId, items: rowItems }, index) => (
|
||||
<Row key={rowId} id={rowId} isEmpty={rowItems.length === 0} hint={index === 0 ? HINT_ANNOTATION : undefined}>
|
||||
{rowItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedAnnotationId}
|
||||
onSelectId={onSelectAnnotation}
|
||||
variant="annotation"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
|
||||
{audioRows.map(({ rowId, items: rowItems }, index) => (
|
||||
<Row key={rowId} id={rowId} isEmpty={rowItems.length === 0} hint={index === 0 ? HINT_AUDIO : undefined}>
|
||||
{rowItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedAudioId}
|
||||
onSelectId={onSelectAudio}
|
||||
variant="audio"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default function TimelineCanvas({
|
||||
items,
|
||||
videoDurationMs,
|
||||
@@ -71,11 +440,14 @@ export default function TimelineCanvas({
|
||||
(e: MouseEvent<HTMLDivElement>) => {
|
||||
if (!onSeek || videoDurationMs <= 0) return;
|
||||
|
||||
onSelectZoom?.(null);
|
||||
onSelectClip?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectAudio?.(null);
|
||||
onClearBlockSelection?.();
|
||||
if (onClearBlockSelection) {
|
||||
onClearBlockSelection();
|
||||
} else {
|
||||
onSelectZoom?.(null);
|
||||
onSelectClip?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectAudio?.(null);
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
@@ -125,7 +497,7 @@ export default function TimelineCanvas({
|
||||
handleZoomRowMouseMove,
|
||||
handleZoomRowMouseLeave,
|
||||
handleZoomRowClick,
|
||||
} = useTimelineCanvasHover({
|
||||
} = useTimelineHover({
|
||||
direction,
|
||||
sidebarWidth,
|
||||
rangeStart: range.start,
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { memo, useMemo, type MouseEventHandler } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import AudioWaveform from "../waveform/AudioWaveform";
|
||||
import glassStyles from "../../ItemGlass.module.css";
|
||||
import Item from "../../Item";
|
||||
import Row from "../../Row";
|
||||
import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../../core/constants";
|
||||
import type { AudioPeaksData, TimelineRenderItem } from "../../core/timelineTypes";
|
||||
import {
|
||||
getAnnotationTrackIndex,
|
||||
getAnnotationTrackRowId,
|
||||
getAudioTrackIndex,
|
||||
getAudioTrackRowId,
|
||||
isAnnotationTrackRowId,
|
||||
isAudioTrackRowId,
|
||||
} from "../../core/rows";
|
||||
import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay";
|
||||
|
||||
const HINT_CLIP = "Press C to split clip";
|
||||
const HINT_ANNOTATION = "Press A to add annotation";
|
||||
const HINT_AUDIO = "Click music icon to add audio";
|
||||
|
||||
interface TimelineCanvasRowsProps {
|
||||
items: TimelineRenderItem[];
|
||||
videoDurationMs: number;
|
||||
selectAllBlocksActive: boolean;
|
||||
selectedZoomId: string | null;
|
||||
selectedClipId?: string | null;
|
||||
selectedAnnotationId?: string | null;
|
||||
selectedAudioId?: string | null;
|
||||
onSelectZoom?: (id: string | null) => void;
|
||||
onSelectClip?: (id: string | null) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onSelectAudio?: (id: string | null) => void;
|
||||
audioPeaks?: AudioPeaksData | null;
|
||||
direction: string;
|
||||
canShowGhostZoom: boolean;
|
||||
ghostStartMs: number | null;
|
||||
ghostStartOffsetPx: number;
|
||||
ghostWidthPx: number;
|
||||
onZoomRowMouseEnter: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowMouseMove: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowMouseLeave: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowClick: MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function TimelineCanvasRowsComponent({
|
||||
items,
|
||||
videoDurationMs,
|
||||
selectAllBlocksActive,
|
||||
selectedZoomId,
|
||||
selectedClipId,
|
||||
selectedAnnotationId,
|
||||
selectedAudioId,
|
||||
onSelectZoom,
|
||||
onSelectClip,
|
||||
onSelectAnnotation,
|
||||
onSelectAudio,
|
||||
audioPeaks,
|
||||
direction,
|
||||
canShowGhostZoom,
|
||||
ghostStartMs,
|
||||
ghostStartOffsetPx,
|
||||
ghostWidthPx,
|
||||
onZoomRowMouseEnter,
|
||||
onZoomRowMouseMove,
|
||||
onZoomRowMouseLeave,
|
||||
onZoomRowClick,
|
||||
}: TimelineCanvasRowsProps) {
|
||||
const { clipItems, zoomItems, annotationRows, audioRows } = useMemo(() => {
|
||||
const nextClipItems: TimelineRenderItem[] = [];
|
||||
const nextZoomItems: TimelineRenderItem[] = [];
|
||||
const annotationBuckets = new Map<number, TimelineRenderItem[]>();
|
||||
const audioBuckets = new Map<number, TimelineRenderItem[]>();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.rowId === CLIP_ROW_ID) {
|
||||
nextClipItems.push(item);
|
||||
continue;
|
||||
}
|
||||
if (item.rowId === ZOOM_ROW_ID) {
|
||||
nextZoomItems.push(item);
|
||||
continue;
|
||||
}
|
||||
if (isAnnotationTrackRowId(item.rowId)) {
|
||||
const trackIndex = getAnnotationTrackIndex(item.rowId);
|
||||
const bucket = annotationBuckets.get(trackIndex);
|
||||
if (bucket) bucket.push(item);
|
||||
else annotationBuckets.set(trackIndex, [item]);
|
||||
continue;
|
||||
}
|
||||
if (isAudioTrackRowId(item.rowId)) {
|
||||
const trackIndex = getAudioTrackIndex(item.rowId);
|
||||
const bucket = audioBuckets.get(trackIndex);
|
||||
if (bucket) bucket.push(item);
|
||||
else audioBuckets.set(trackIndex, [item]);
|
||||
}
|
||||
}
|
||||
|
||||
const annotationRowsSorted = Array.from(annotationBuckets.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([trackIndex, rowItems]) => ({
|
||||
rowId: getAnnotationTrackRowId(trackIndex),
|
||||
items: rowItems,
|
||||
}));
|
||||
const audioRowsSorted = Array.from(audioBuckets.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([trackIndex, rowItems]) => ({
|
||||
rowId: getAudioTrackRowId(trackIndex),
|
||||
items: rowItems,
|
||||
}));
|
||||
|
||||
return {
|
||||
clipItems: nextClipItems,
|
||||
zoomItems: nextZoomItems,
|
||||
annotationRows: annotationRowsSorted,
|
||||
audioRows: audioRowsSorted,
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row id={CLIP_ROW_ID} isEmpty={clipItems.length === 0} hint={HINT_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}
|
||||
onSelectId={onSelectClip}
|
||||
variant="clip"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
id={ZOOM_ROW_ID}
|
||||
isEmpty={zoomItems.length === 0}
|
||||
onMouseEnter={onZoomRowMouseEnter}
|
||||
onMouseMove={onZoomRowMouseMove}
|
||||
onMouseLeave={onZoomRowMouseLeave}
|
||||
onClick={onZoomRowClick}
|
||||
>
|
||||
{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}
|
||||
onSelectId={onSelectZoom}
|
||||
zoomDepth={item.zoomDepth}
|
||||
zoomMode={item.zoomMode}
|
||||
variant="zoom"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{annotationRows.map(({ rowId, items: rowItems }, index) => {
|
||||
return (
|
||||
<Row
|
||||
key={rowId}
|
||||
id={rowId}
|
||||
isEmpty={rowItems.length === 0}
|
||||
hint={index === 0 ? HINT_ANNOTATION : undefined}
|
||||
>
|
||||
{rowItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedAnnotationId}
|
||||
onSelectId={onSelectAnnotation}
|
||||
variant="annotation"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
|
||||
{audioRows.map(({ rowId, items: rowItems }, index) => {
|
||||
return (
|
||||
<Row
|
||||
key={rowId}
|
||||
id={rowId}
|
||||
isEmpty={rowItems.length === 0}
|
||||
hint={index === 0 ? HINT_AUDIO : undefined}
|
||||
>
|
||||
{rowItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={selectAllBlocksActive || item.id === selectedAudioId}
|
||||
onSelectId={onSelectAudio}
|
||||
variant="audio"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const TimelineCanvasRows = memo(TimelineCanvasRowsComponent);
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useCallback, useState, type MouseEvent } from "react";
|
||||
|
||||
interface UseTimelineCanvasHoverParams {
|
||||
direction: string;
|
||||
sidebarWidth: number;
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
videoDurationMs: number;
|
||||
onAddZoomAtMs?: (startMs: number) => void;
|
||||
canPlaceZoomAtMs?: (startMs: number) => boolean;
|
||||
valueToPixels: (value: number) => number;
|
||||
}
|
||||
|
||||
export function useTimelineCanvasHover({
|
||||
direction,
|
||||
sidebarWidth,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
videoDurationMs,
|
||||
onAddZoomAtMs,
|
||||
canPlaceZoomAtMs,
|
||||
valueToPixels,
|
||||
}: UseTimelineCanvasHoverParams) {
|
||||
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 visibleDurationMs = Math.max(1, rangeEnd - rangeStart);
|
||||
|
||||
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 = rangeStart + ratio * visibleDurationMs;
|
||||
setTimelineHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
|
||||
},
|
||||
[direction, rangeStart, sidebarWidth, videoDurationMs, visibleDurationMs],
|
||||
);
|
||||
|
||||
const handleTimelineMouseEnter = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
setIsTimelineHovered(true);
|
||||
updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
|
||||
},
|
||||
[updateTimelineHoverTime],
|
||||
);
|
||||
|
||||
const handleTimelineMouseMove = useCallback(
|
||||
(event: 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 = rangeStart + ratio * visibleDurationMs;
|
||||
setZoomRowHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
|
||||
},
|
||||
[direction, rangeStart, videoDurationMs, visibleDurationMs],
|
||||
);
|
||||
|
||||
const handleZoomRowMouseEnter = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
setIsZoomRowHovered(true);
|
||||
updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
|
||||
},
|
||||
[updateZoomRowHoverTime],
|
||||
);
|
||||
const handleZoomRowMouseMove = useCallback(
|
||||
(event: 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: 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],
|
||||
);
|
||||
|
||||
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 - rangeStart));
|
||||
const ghostEndOffsetPx = ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - rangeStart));
|
||||
const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx);
|
||||
const timelineGhostOffsetPx =
|
||||
timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - rangeStart));
|
||||
const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null;
|
||||
const canShowGhostZoom =
|
||||
isZoomRowHovered &&
|
||||
ghostStartMs !== null &&
|
||||
(onAddZoomAtMs ? (canPlaceZoomAtMs?.(ghostStartMs) ?? true) : false);
|
||||
|
||||
return {
|
||||
canShowGhostPlayhead,
|
||||
timelineGhostOffsetPx,
|
||||
handleTimelineMouseEnter,
|
||||
handleTimelineMouseMove,
|
||||
handleTimelineMouseLeave,
|
||||
canShowGhostZoom,
|
||||
ghostStartMs,
|
||||
ghostStartOffsetPx,
|
||||
ghostWidthPx,
|
||||
handleZoomRowMouseEnter,
|
||||
handleZoomRowMouseMove,
|
||||
handleZoomRowMouseLeave,
|
||||
handleZoomRowClick,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface UseTimelineAnnotationsActionsParams {
|
||||
videoDuration: number;
|
||||
totalMs: number;
|
||||
currentTimeMs: number;
|
||||
defaultRegionDurationMs: number;
|
||||
onAnnotationAdded?: (span: { start: number; end: number }, trackIndex?: number) => void;
|
||||
}
|
||||
|
||||
export function useTimelineAnnotationsActions({
|
||||
videoDuration,
|
||||
totalMs,
|
||||
currentTimeMs,
|
||||
defaultRegionDurationMs,
|
||||
onAnnotationAdded,
|
||||
}: UseTimelineAnnotationsActionsParams) {
|
||||
const handleAddAnnotation = useCallback(
|
||||
(trackIndex = 0) => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
const endPos = Math.min(startPos + defaultDuration, totalMs);
|
||||
onAnnotationAdded({ start: startPos, end: endPos }, trackIndex);
|
||||
},
|
||||
[videoDuration, totalMs, currentTimeMs, defaultRegionDurationMs, onAnnotationAdded],
|
||||
);
|
||||
|
||||
return { handleAddAnnotation };
|
||||
}
|
||||
@@ -28,6 +28,8 @@ interface UseTimelineDndBindingsParams {
|
||||
onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
|
||||
}
|
||||
|
||||
type TimelineItemKind = "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | null;
|
||||
|
||||
export function useTimelineDndBindings({
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
@@ -42,16 +44,39 @@ export function useTimelineDndBindings({
|
||||
onSpeedSpanChange,
|
||||
onAudioSpanChange,
|
||||
}: UseTimelineDndBindingsParams) {
|
||||
const resolveItemKind = useCallback(
|
||||
(id: string): TimelineItemKind => {
|
||||
if (zoomRegions.some((r) => r.id === id)) return "zoom";
|
||||
if (trimRegions.some((r) => r.id === id)) return "trim";
|
||||
if (clipRegions.some((r) => r.id === id)) return "clip";
|
||||
if (annotationRegions.some((r) => r.id === id)) return "annotation";
|
||||
if (speedRegions.some((r) => r.id === id)) return "speed";
|
||||
if (audioRegions.some((r) => r.id === id)) return "audio";
|
||||
return null;
|
||||
},
|
||||
[zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions],
|
||||
);
|
||||
|
||||
const resolveTrackIndex = useCallback(
|
||||
(kind: "annotation" | "audio", id: string, rowId?: string): number => {
|
||||
if (kind === "annotation") {
|
||||
return rowId && isAnnotationTrackRowId(rowId)
|
||||
? getAnnotationTrackIndex(rowId)
|
||||
: (annotationRegions.find((region) => region.id === id)?.trackIndex ?? 0);
|
||||
}
|
||||
return rowId && isAudioTrackRowId(rowId)
|
||||
? getAudioTrackIndex(rowId)
|
||||
: (audioRegions.find((region) => region.id === id)?.trackIndex ?? 0);
|
||||
},
|
||||
[annotationRegions, audioRegions],
|
||||
);
|
||||
|
||||
const hasOverlap = useCallback(
|
||||
(newSpan: Span, excludeId?: string, rowId?: string): boolean => {
|
||||
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
|
||||
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
|
||||
const isClipItem = clipRegions.some((r) => r.id === excludeId);
|
||||
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
|
||||
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
|
||||
const isAudioItem = audioRegions.some((r) => r.id === excludeId);
|
||||
if (!excludeId) return false;
|
||||
const itemKind = resolveItemKind(excludeId);
|
||||
|
||||
if (isAnnotationItem) return false;
|
||||
if (itemKind === "annotation") return false;
|
||||
|
||||
const checkOverlap = (
|
||||
regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[],
|
||||
@@ -61,17 +86,13 @@ export function useTimelineDndBindings({
|
||||
return spansOverlap(newSpan, { start: region.startMs, end: region.endMs });
|
||||
});
|
||||
|
||||
if (isZoomItem) return checkOverlap(zoomRegions);
|
||||
if (isTrimItem) return checkOverlap(trimRegions);
|
||||
if (isClipItem) return checkOverlap(clipRegions);
|
||||
if (isSpeedItem) return checkOverlap(speedRegions);
|
||||
if (itemKind === "zoom") return checkOverlap(zoomRegions);
|
||||
if (itemKind === "trim") return checkOverlap(trimRegions);
|
||||
if (itemKind === "clip") return checkOverlap(clipRegions);
|
||||
if (itemKind === "speed") return checkOverlap(speedRegions);
|
||||
|
||||
if (isAudioItem) {
|
||||
const activeAudioRegion = audioRegions.find((region) => region.id === excludeId);
|
||||
const activeTrackIndex =
|
||||
rowId && isAudioTrackRowId(rowId)
|
||||
? getAudioTrackIndex(rowId)
|
||||
: (activeAudioRegion?.trackIndex ?? 0);
|
||||
if (itemKind === "audio") {
|
||||
const activeTrackIndex = resolveTrackIndex("audio", excludeId, rowId);
|
||||
return checkOverlap(
|
||||
audioRegions.filter((region) => (region.trackIndex ?? 0) === activeTrackIndex),
|
||||
);
|
||||
@@ -79,7 +100,15 @@ export function useTimelineDndBindings({
|
||||
|
||||
return false;
|
||||
},
|
||||
[zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions],
|
||||
[
|
||||
resolveItemKind,
|
||||
resolveTrackIndex,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
clipRegions,
|
||||
audioRegions,
|
||||
speedRegions,
|
||||
],
|
||||
);
|
||||
|
||||
const timelineItems = useMemo<TimelineRenderItem[]>(
|
||||
@@ -110,35 +139,26 @@ export function useTimelineDndBindings({
|
||||
|
||||
const handleItemSpanChange = useCallback(
|
||||
(id: string, span: Span, rowId?: string) => {
|
||||
if (zoomRegions.some((r) => r.id === id)) {
|
||||
const itemKind = resolveItemKind(id);
|
||||
if (itemKind === "zoom") {
|
||||
onZoomSpanChange(id, span);
|
||||
} else if (trimRegions.some((r) => r.id === id)) {
|
||||
} else if (itemKind === "trim") {
|
||||
onTrimSpanChange?.(id, span);
|
||||
} else if (clipRegions.some((r) => r.id === id)) {
|
||||
} else if (itemKind === "clip") {
|
||||
onClipSpanChange?.(id, span);
|
||||
} else if (annotationRegions.some((r) => r.id === id)) {
|
||||
const nextTrackIndex =
|
||||
rowId && isAnnotationTrackRowId(rowId)
|
||||
? getAnnotationTrackIndex(rowId)
|
||||
: (annotationRegions.find((region) => region.id === id)?.trackIndex ?? 0);
|
||||
} else if (itemKind === "annotation") {
|
||||
const nextTrackIndex = resolveTrackIndex("annotation", id, rowId);
|
||||
onAnnotationSpanChange?.(id, span, nextTrackIndex);
|
||||
} else if (speedRegions.some((r) => r.id === id)) {
|
||||
} else if (itemKind === "speed") {
|
||||
onSpeedSpanChange?.(id, span);
|
||||
} else if (audioRegions.some((r) => r.id === id)) {
|
||||
const nextTrackIndex =
|
||||
rowId && isAudioTrackRowId(rowId)
|
||||
? getAudioTrackIndex(rowId)
|
||||
: (audioRegions.find((region) => region.id === id)?.trackIndex ?? 0);
|
||||
} else if (itemKind === "audio") {
|
||||
const nextTrackIndex = resolveTrackIndex("audio", id, rowId);
|
||||
onAudioSpanChange?.(id, span, nextTrackIndex);
|
||||
}
|
||||
},
|
||||
[
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
clipRegions,
|
||||
annotationRegions,
|
||||
speedRegions,
|
||||
audioRegions,
|
||||
resolveItemKind,
|
||||
resolveTrackIndex,
|
||||
onZoomSpanChange,
|
||||
onTrimSpanChange,
|
||||
onClipSpanChange,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useCallback, useImperativeHandle } from "react";
|
||||
import type { ForwardedRef, RefObject } from "react";
|
||||
import type { TimelineShortcutBindings } from "../core/timelineTypes";
|
||||
import { useTimelineDndBindings } from "./useTimelineDndBindings";
|
||||
import { useTimelineAnnotationsActions } from "./actions/useTimelineAnnotationsActions";
|
||||
import { useTimelineAudioActions } from "./actions/useTimelineAudioActions";
|
||||
import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts";
|
||||
import { useTimelineNormalization } from "./useTimelineNormalization";
|
||||
@@ -204,13 +203,23 @@ export function useTimelineEditorRuntime({
|
||||
onAudioAdded,
|
||||
});
|
||||
|
||||
const { handleAddAnnotation } = useTimelineAnnotationsActions({
|
||||
videoDuration,
|
||||
totalMs,
|
||||
currentTimeMs,
|
||||
defaultRegionDurationMs,
|
||||
onAnnotationAdded,
|
||||
});
|
||||
const handleAddAnnotation = useCallback(
|
||||
(trackIndex = 0) => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
const endPos = Math.min(startPos + defaultDuration, totalMs);
|
||||
onAnnotationAdded({ start: startPos, end: endPos }, trackIndex);
|
||||
},
|
||||
[videoDuration, totalMs, currentTimeMs, defaultRegionDurationMs, onAnnotationAdded],
|
||||
);
|
||||
|
||||
useTimelineKeyboardShortcuts({
|
||||
isMac,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import type { TimelineShortcutBindings } from "../core/timelineTypes";
|
||||
import { resolveDeleteSelectionTarget } from "./utils/timelineSelectionUtils";
|
||||
|
||||
interface UseTimelineKeyboardShortcutsParams {
|
||||
isMac: boolean;
|
||||
@@ -89,18 +90,28 @@ export function useTimelineKeyboardShortcuts({
|
||||
e.key === "Backspace" ||
|
||||
matchesShortcut(e, keyShortcuts.deleteSelected, isMac)
|
||||
) {
|
||||
if (selectAllBlocksActive) {
|
||||
const target = resolveDeleteSelectionTarget({
|
||||
selectAllBlocksActive,
|
||||
selectedKeyframeId,
|
||||
selectedZoomId,
|
||||
selectedClipId,
|
||||
selectedAnnotationId,
|
||||
selectedAudioId,
|
||||
});
|
||||
if (target !== "none") {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (target === "all") {
|
||||
deleteAllBlocks();
|
||||
} else if (selectedKeyframeId) {
|
||||
} else if (target === "keyframe") {
|
||||
deleteSelectedKeyframe();
|
||||
} else if (selectedZoomId) {
|
||||
} else if (target === "zoom") {
|
||||
deleteSelectedZoom();
|
||||
} else if (selectedClipId) {
|
||||
} else if (target === "clip") {
|
||||
deleteSelectedClip();
|
||||
} else if (selectedAnnotationId) {
|
||||
} else if (target === "annotation") {
|
||||
deleteSelectedAnnotation();
|
||||
} else if (selectedAudioId) {
|
||||
} else if (target === "audio") {
|
||||
deleteSelectedAudio();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user