mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 07:45:34 +00:00
fix: findings that were high, removed monolithic timeline canvas
This commit is contained in:
@@ -1,10 +1,5 @@
|
||||
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 { useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
getTimelineContentMinHeightPx,
|
||||
getTimelineRowsMinHeightPx,
|
||||
@@ -12,20 +7,11 @@ import {
|
||||
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";
|
||||
import { TimelineCanvasRows } from "./TimelineCanvasRows";
|
||||
import { useTimelineCanvasHover } from "./useTimelineCanvasHover";
|
||||
|
||||
interface TimelineCanvasProps {
|
||||
items: TimelineRenderItem[];
|
||||
@@ -79,10 +65,6 @@ export default function TimelineCanvas({
|
||||
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) => {
|
||||
@@ -127,124 +109,40 @@ export default function TimelineCanvas({
|
||||
],
|
||||
);
|
||||
|
||||
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],
|
||||
() => new Set(items.map((item) => item.rowId)).size,
|
||||
[items],
|
||||
);
|
||||
|
||||
const timelineRowCount = 2 + annotationRowIds.length + audioRowIds.length;
|
||||
const timelineRowCount = Math.max(2, audioRowIds);
|
||||
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],
|
||||
);
|
||||
const {
|
||||
canShowGhostPlayhead,
|
||||
timelineGhostOffsetPx,
|
||||
handleTimelineMouseEnter,
|
||||
handleTimelineMouseMove,
|
||||
handleTimelineMouseLeave,
|
||||
canShowGhostZoom,
|
||||
ghostStartMs,
|
||||
ghostStartOffsetPx,
|
||||
ghostWidthPx,
|
||||
handleZoomRowMouseEnter,
|
||||
handleZoomRowMouseMove,
|
||||
handleZoomRowMouseLeave,
|
||||
handleZoomRowClick,
|
||||
} = useTimelineCanvasHover({
|
||||
direction,
|
||||
sidebarWidth,
|
||||
rangeStart: range.start,
|
||||
rangeEnd: range.end,
|
||||
videoDurationMs,
|
||||
onAddZoomAtMs,
|
||||
canPlaceZoomAtMs,
|
||||
valueToPixels,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -279,119 +177,29 @@ export default function TimelineCanvas({
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
<TimelineCanvasRows
|
||||
items={items}
|
||||
videoDurationMs={videoDurationMs}
|
||||
selectAllBlocksActive={selectAllBlocksActive}
|
||||
selectedZoomId={selectedZoomId}
|
||||
selectedClipId={selectedClipId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
selectedAudioId={selectedAudioId}
|
||||
onSelectZoom={onSelectZoom}
|
||||
onSelectClip={onSelectClip}
|
||||
onSelectAnnotation={onSelectAnnotation}
|
||||
onSelectAudio={onSelectAudio}
|
||||
audioPeaks={audioPeaks}
|
||||
direction={direction}
|
||||
canShowGhostZoom={canShowGhostZoom}
|
||||
ghostStartMs={ghostStartMs}
|
||||
ghostStartOffsetPx={ghostStartOffsetPx}
|
||||
ghostWidthPx={ghostWidthPx}
|
||||
onZoomRowMouseEnter={handleZoomRowMouseEnter}
|
||||
onZoomRowMouseMove={handleZoomRowMouseMove}
|
||||
onZoomRowMouseLeave={handleZoomRowMouseLeave}
|
||||
onZoomRowClick={handleZoomRowClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { useMemo, type MouseEventHandler } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import AudioWaveform from "../../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 {
|
||||
getAnnotationTrackIndex,
|
||||
getAnnotationTrackRowId,
|
||||
getAudioTrackIndex,
|
||||
getAudioTrackRowId,
|
||||
isAnnotationTrackRowId,
|
||||
isAudioTrackRowId,
|
||||
} from "../../core/rows";
|
||||
import type { TimelineRenderItem } from "../../model/timelineModel";
|
||||
import type { AudioPeaksData } from "../../useAudioPeaks";
|
||||
import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay";
|
||||
|
||||
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: "ltr" | "rtl";
|
||||
canShowGhostZoom: boolean;
|
||||
ghostStartMs: number | null;
|
||||
ghostStartOffsetPx: number;
|
||||
ghostWidthPx: number;
|
||||
onZoomRowMouseEnter: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowMouseMove: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowMouseLeave: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowClick: MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export 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 zoomItems = useMemo(() => items.filter((item) => item.rowId === ZOOM_ROW_ID), [items]);
|
||||
const clipItems = useMemo(() => items.filter((item) => item.rowId === CLIP_ROW_ID), [items]);
|
||||
const annotationItems = useMemo(
|
||||
() => items.filter((item) => isAnnotationTrackRowId(item.rowId)),
|
||||
[items],
|
||||
);
|
||||
const audioItems = useMemo(() => items.filter((item) => isAudioTrackRowId(item.rowId)), [items]);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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={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}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useCallback, useState, type MouseEvent } from "react";
|
||||
|
||||
interface UseTimelineCanvasHoverParams {
|
||||
direction: "ltr" | "rtl";
|
||||
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,15 +1,17 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { matchesShortcut, type ShortcutBinding } from "@/lib/shortcuts";
|
||||
|
||||
interface TimelineShortcutBindings {
|
||||
addKeyframe: ShortcutBinding;
|
||||
addZoom: ShortcutBinding;
|
||||
splitClip: ShortcutBinding;
|
||||
addAnnotation: ShortcutBinding;
|
||||
deleteSelected: ShortcutBinding;
|
||||
}
|
||||
|
||||
interface UseTimelineKeyboardShortcutsParams {
|
||||
isMac: boolean;
|
||||
keyShortcuts: {
|
||||
addKeyframe: unknown;
|
||||
addZoom: unknown;
|
||||
splitClip: unknown;
|
||||
addAnnotation: unknown;
|
||||
deleteSelected: unknown;
|
||||
};
|
||||
keyShortcuts: TimelineShortcutBindings;
|
||||
isTimelineFocusedRef: RefObject<boolean>;
|
||||
hasAnyTimelineBlocks: boolean;
|
||||
annotationCount: number;
|
||||
@@ -76,10 +78,10 @@ export function useTimelineKeyboardShortcuts({
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(e, keyShortcuts.addKeyframe as never, isMac)) addKeyframe();
|
||||
if (matchesShortcut(e, keyShortcuts.addZoom as never, isMac)) handleAddZoom();
|
||||
if (matchesShortcut(e, keyShortcuts.splitClip as never, isMac)) handleSplitClip();
|
||||
if (matchesShortcut(e, keyShortcuts.addAnnotation as never, isMac)) {
|
||||
if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) addKeyframe();
|
||||
if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) handleAddZoom();
|
||||
if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) handleSplitClip();
|
||||
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
|
||||
@@ -92,7 +94,7 @@ export function useTimelineKeyboardShortcuts({
|
||||
if (
|
||||
e.key === "Delete" ||
|
||||
e.key === "Backspace" ||
|
||||
matchesShortcut(e, keyShortcuts.deleteSelected as never, isMac)
|
||||
matchesShortcut(e, keyShortcuts.deleteSelected, isMac)
|
||||
) {
|
||||
if (selectAllBlocksActive) {
|
||||
e.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user