more timeline ux qol improvements:
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useItem } from "dnd-timeline";
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -25,6 +26,16 @@ const ZOOM_LABELS: Record<number, string> = {
|
||||
6: "5×",
|
||||
};
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
const totalSeconds = ms / 1000;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes > 0) {
|
||||
return `${minutes}:${seconds.toFixed(1).padStart(4, '0')}`;
|
||||
}
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function Item({
|
||||
id,
|
||||
span,
|
||||
@@ -43,19 +54,24 @@ export default function Item({
|
||||
|
||||
const isZoom = variant === 'zoom';
|
||||
const isTrim = variant === 'trim';
|
||||
|
||||
const glassClass = isZoom
|
||||
? glassStyles.glassGreen
|
||||
: isTrim
|
||||
? glassStyles.glassRed
|
||||
|
||||
const glassClass = isZoom
|
||||
? glassStyles.glassGreen
|
||||
: isTrim
|
||||
? glassStyles.glassRed
|
||||
: glassStyles.glassYellow;
|
||||
|
||||
const endCapColor = isZoom
|
||||
? '#21916A'
|
||||
: isTrim
|
||||
? '#ef4444'
|
||||
|
||||
const endCapColor = isZoom
|
||||
? '#21916A'
|
||||
: isTrim
|
||||
? '#ef4444'
|
||||
: '#B4A046';
|
||||
|
||||
const timeLabel = useMemo(
|
||||
() => `${formatMs(span.start)} – ${formatMs(span.end)}`,
|
||||
[span.start, span.end],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -89,29 +105,38 @@ export default function Item({
|
||||
title="Resize right"
|
||||
/>
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex items-center gap-1.5 text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none">
|
||||
{isZoom ? (
|
||||
<>
|
||||
<ZoomIn className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-semibold tracking-tight">
|
||||
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
</span>
|
||||
</>
|
||||
) : isTrim ? (
|
||||
<>
|
||||
<Scissors className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-semibold tracking-tight">
|
||||
Trim
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-semibold tracking-tight">
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isZoom ? (
|
||||
<>
|
||||
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
</span>
|
||||
</>
|
||||
) : isTrim ? (
|
||||
<>
|
||||
<Scissors className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
Trim
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-[9px] tabular-nums tracking-tight whitespace-nowrap transition-opacity ${
|
||||
isSelected ? 'opacity-60' : 'opacity-0 group-hover:opacity-40'
|
||||
}`}
|
||||
>
|
||||
{timeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,36 @@ import type { RowDefinition } from "dnd-timeline";
|
||||
|
||||
interface RowProps extends RowDefinition {
|
||||
children: React.ReactNode;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
isEmpty?: boolean;
|
||||
labelColor?: string;
|
||||
}
|
||||
|
||||
export default function Row({ id, children }: RowProps) {
|
||||
export default function Row({ id, children, label, hint, isEmpty, labelColor = '#666' }: RowProps) {
|
||||
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-[#18181b] bg-[#18181b]"
|
||||
className="border-b border-[#18181b] bg-[#18181b] relative"
|
||||
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
|
||||
>
|
||||
{label && (
|
||||
<div
|
||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none"
|
||||
style={{ color: labelColor, writingMode: 'horizontal-tb' }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && hint && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
|
||||
<span className="text-[11px] text-white/15 font-medium">{hint}</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={setNodeRef} style={rowStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,14 @@ function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
||||
return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatPlayheadTime(ms: number): string {
|
||||
const s = ms / 1000;
|
||||
const min = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
if (min > 0) return `${min}:${sec.toFixed(1).padStart(4, '0')}`;
|
||||
return `${sec.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function PlaybackCursor({
|
||||
currentTimeMs,
|
||||
videoDurationMs,
|
||||
@@ -252,6 +260,11 @@ function PlaybackCursor({
|
||||
>
|
||||
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
|
||||
</div>
|
||||
{isDragging && (
|
||||
<div className="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-white/10 shadow-lg pointer-events-none">
|
||||
{formatPlayheadTime(clampedTime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -451,7 +464,7 @@ function Timeline({
|
||||
keyframes={keyframes}
|
||||
/>
|
||||
|
||||
<Row id={ZOOM_ROW_ID}>
|
||||
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint="Press Z to add zoom">
|
||||
{zoomItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
@@ -468,7 +481,7 @@ function Timeline({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={TRIM_ROW_ID}>
|
||||
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint="Press T to add trim">
|
||||
{trimItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
@@ -484,7 +497,7 @@ function Timeline({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={ANNOTATION_ROW_ID}>
|
||||
<Row id={ANNOTATION_ROW_ID} isEmpty={annotationItems.length === 0} hint="Press A to add annotation">
|
||||
{annotationItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
@@ -1003,6 +1016,7 @@ export default function TimelineEditor({
|
||||
selectedTrimId={selectedTrimId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
keyframes={keyframes}
|
||||
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { TimelineContext } from "dnd-timeline";
|
||||
import type { DragEndEvent, Range, ResizeEndEvent, Span } from "dnd-timeline";
|
||||
import type {
|
||||
DragEndEvent,
|
||||
DragMoveEvent,
|
||||
DragStartEvent,
|
||||
Range,
|
||||
ResizeEndEvent,
|
||||
ResizeMoveEvent,
|
||||
Span,
|
||||
} from "dnd-timeline";
|
||||
|
||||
interface TimelineWrapperProps {
|
||||
children: ReactNode;
|
||||
@@ -167,6 +175,88 @@ export default function TimelineWrapper({
|
||||
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange]
|
||||
);
|
||||
|
||||
// Drag/resize tooltip (direct DOM updates, no re-renders)
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const formatTooltipMs = (ms: number) => {
|
||||
const s = ms / 1000;
|
||||
const min = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return min > 0
|
||||
? `${min}:${sec.toFixed(1).padStart(4, '0')}`
|
||||
: `${sec.toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const showTooltip = useCallback(
|
||||
(span: { start: number; end: number } | null, screenX?: number) => {
|
||||
const el = tooltipRef.current;
|
||||
if (!el) return;
|
||||
if (!span) {
|
||||
el.style.opacity = '0';
|
||||
return;
|
||||
}
|
||||
el.textContent = `${formatTooltipMs(span.start)} – ${formatTooltipMs(span.end)}`;
|
||||
el.style.opacity = '1';
|
||||
if (screenX !== undefined) {
|
||||
const parent = el.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(screenX - rect.left, rect.width - 100));
|
||||
el.style.left = `${x}px`;
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const span = event.active.data.current.getSpanFromDragEvent?.(event);
|
||||
if (span) showTooltip(span);
|
||||
},
|
||||
[showTooltip],
|
||||
);
|
||||
|
||||
const onDragMove = useCallback(
|
||||
(event: DragMoveEvent) => {
|
||||
const span = event.active.data.current.getSpanFromDragEvent?.(event);
|
||||
const screenX = event.activatorEvent && 'clientX' in event.activatorEvent
|
||||
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
|
||||
: undefined;
|
||||
if (span) showTooltip(span, screenX);
|
||||
},
|
||||
[showTooltip],
|
||||
);
|
||||
|
||||
const onResizeMove = useCallback(
|
||||
(event: ResizeMoveEvent) => {
|
||||
const span = event.active.data.current.getSpanFromResizeEvent?.(event);
|
||||
const screenX = event.activatorEvent && 'clientX' in event.activatorEvent
|
||||
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
|
||||
: undefined;
|
||||
if (span) showTooltip(span, screenX);
|
||||
},
|
||||
[showTooltip],
|
||||
);
|
||||
|
||||
const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]);
|
||||
|
||||
const onResizeEndWithTooltip = useCallback(
|
||||
(event: ResizeEndEvent) => {
|
||||
hideTooltip();
|
||||
onResizeEnd(event);
|
||||
},
|
||||
[hideTooltip, onResizeEnd],
|
||||
);
|
||||
|
||||
const onDragEndWithTooltip = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
hideTooltip();
|
||||
onDragEnd(event);
|
||||
},
|
||||
[hideTooltip, onDragEnd],
|
||||
);
|
||||
|
||||
const handleRangeChange = useCallback(
|
||||
(updater: (previous: Range) => Range) => {
|
||||
onRangeChange((prev) => {
|
||||
@@ -197,11 +287,22 @@ export default function TimelineWrapper({
|
||||
<TimelineContext
|
||||
range={range}
|
||||
onRangeChanged={handleRangeChange}
|
||||
onResizeEnd={onResizeEnd}
|
||||
onDragEnd={onDragEnd}
|
||||
onResizeEnd={onResizeEndWithTooltip}
|
||||
onResizeMove={onResizeMove}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEndWithTooltip}
|
||||
autoScroll={{ enabled: false }}
|
||||
>
|
||||
{children}
|
||||
<div className="relative">
|
||||
{children}
|
||||
{/* Floating tooltip shown during drag/resize */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="absolute top-1 pointer-events-none z-[60] px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg"
|
||||
style={{ opacity: 0, transition: 'opacity 0.1s' }}
|
||||
/>
|
||||
</div>
|
||||
</TimelineContext>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user