timeline ux improvements

This commit is contained in:
Siddharth
2025-11-20 13:47:46 -07:00
parent c6dbf1fa67
commit 7a0b756cea
4 changed files with 110 additions and 30 deletions
@@ -26,7 +26,6 @@ const GRADIENTS = [
"linear-gradient(109.6deg, #F635A6, #36D860)",
"linear-gradient(90deg, #FF0101, #4DFF01)",
"linear-gradient(315deg, #EC0101, #5044A9)",
// New Gradients
"linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)",
"linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)",
"linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)",
+24 -4
View File
@@ -1,6 +1,7 @@
import { useItem } from "dnd-timeline";
import type { Span } from "dnd-timeline";
import { cn } from "@/lib/utils";
import { ZoomIn } from "lucide-react";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
@@ -10,9 +11,19 @@ interface ItemProps {
children: React.ReactNode;
isSelected?: boolean;
onSelect?: () => void;
zoomDepth: number;
}
export default function Item({ id, span, rowId, isSelected: _isSelected = false, onSelect }: ItemProps) {
// Map zoom depth to multiplier labels
const ZOOM_LABELS: Record<number, string> = {
1: "1.25×",
2: "1.5×",
3: "1.8×",
4: "2.2×",
5: "3.5×",
};
export default function Item({ id, span, rowId, isSelected = false, onSelect, zoomDepth }: ItemProps) {
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
@@ -26,13 +37,14 @@ export default function Item({ id, span, rowId, isSelected: _isSelected = false,
{...listeners}
{...attributes}
onPointerDownCapture={() => onSelect?.()}
className={cn(glassStyles.itemDark)}
className="group"
>
<div style={itemContentStyle}>
<div
className={cn(
"w-full overflow-hidden flex items-center justify-center transition-all duration-150 cursor-grab active:cursor-grabbing group relative",
glassStyles.glassPurple
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
glassStyles.glassGreen,
isSelected && glassStyles.selected
)}
style={{ height: 48 }}
onClick={(event) => {
@@ -42,6 +54,14 @@ export default function Item({ id, span, rowId, isSelected: _isSelected = false,
>
<div className={cn(glassStyles.zoomEndCap, glassStyles.left)} />
<div className={cn(glassStyles.zoomEndCap, glassStyles.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">
<ZoomIn className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</div>
</div>
</div>
</div>
@@ -1,33 +1,55 @@
.glassPurple {
.glassGreen {
position: relative;
border-radius: 12px;
border-radius: 8px;
-corner-smoothing: antialiased;
background: #34B27B;
border: none;
box-shadow: 0 2px 12px 0 rgba(52,178,123,0.14) inset, 0 2px 8px 0 rgba(0,0,0,0.10), 0 1px 6px 0 rgba(52,178,123,0.08);
margin: 0 4px;
backdrop-filter: blur(2px) saturate(120%);
-webkit-backdrop-filter: blur(2px) saturate(120%);
background: rgba(52, 178, 123, 0.15);
border: 1px solid rgba(52, 178, 123, 0.3);
box-shadow: 0 2px 12px 0 rgba(52, 178, 123, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glassGreen:hover {
background: rgba(52, 178, 123, 0.25);
border-color: rgba(52, 178, 123, 0.5);
box-shadow: 0 4px 20px 0 rgba(52, 178, 123, 0.2) inset;
}
.glassGreen.selected {
background: rgba(52, 178, 123, 0.35);
border-color: #34B27B;
box-shadow: 0 0 0 1px #34B27B, 0 4px 20px 0 rgba(52, 178, 123, 0.3) inset;
z-index: 10;
}
.zoomEndCap {
position: absolute;
top: 0;
background: #315d4a;
width: 18px;
height: 100%;
bottom: 0;
background: #34B27B;
width: 4px;
pointer-events: none;
z-index: 2;
transition: background 0.2s, box-shadow 0.2s;
opacity: 0;
transition: opacity 0.2s, width 0.2s;
}
.glassGreen:hover .zoomEndCap,
.glassGreen.selected .zoomEndCap {
opacity: 1;
}
.zoomEndCap.left {
left: -9px;
left: 0;
cursor: ew-resize;
border-top-left-radius: 7px;
border-bottom-left-radius: 7px;
}
.zoomEndCap.right {
right: -9px;
transform: scaleX(-1);
right: 0;
cursor: ew-resize;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
}
@@ -39,6 +39,7 @@ interface TimelineRenderItem {
rowId: string;
span: Span;
label: string;
zoomDepth: number;
}
const SCALE_CANDIDATES = [
@@ -186,7 +187,7 @@ function TimelineAxis({
const markers = useMemo(() => {
if (intervalMs <= 0) {
return [] as { time: number; label: string }[];
return { markers: [], minorTicks: [] };
}
const maxTime = videoDurationMs > 0 ? videoDurationMs : range.end;
@@ -214,10 +215,27 @@ function TimelineAxis({
.filter(time => time <= maxTime)
.sort((a, b) => a - b);
return sorted.map((time) => ({
time,
label: formatTimeLabel(time, intervalMs),
}));
// Generate minor ticks (4 ticks between major intervals)
const minorTicks = [];
const minorInterval = intervalMs / 5;
for (let time = firstMarker; time <= maxTime; time += minorInterval) {
if (time >= visibleStart && time <= visibleEnd) {
// Skip if it's close to a major marker
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 (
@@ -227,7 +245,20 @@ function TimelineAxis({
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
}}
>
{markers.map((marker) => {
{/* Minor Ticks */}
{markers.minorTicks.map((time) => {
const offset = valueToPixels(time - range.start);
return (
<div
key={`minor-${time}`}
className="absolute bottom-0 h-1 w-[1px] bg-white/5"
style={{ [sideProperty]: `${offset}px` }}
/>
);
})}
{/* Major Markers */}
{markers.markers.map((marker) => {
const offset = valueToPixels(marker.time - range.start);
const markerStyle: React.CSSProperties = {
position: "absolute",
@@ -242,7 +273,7 @@ function TimelineAxis({
return (
<div key={marker.time} style={markerStyle}>
<div className="flex flex-col items-center pb-1">
<div className="h-1.5 w-[1px] bg-white/20 mb-1" />
<div className="h-2 w-[1px] bg-white/20 mb-1" />
<span
className={cn(
"text-[10px] font-medium tabular-nums tracking-tight",
@@ -313,6 +344,7 @@ function Timeline({
span={item.span}
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
>
{item.label}
</Item>
@@ -429,13 +461,20 @@ export default function TimelineEditor({
rowId: ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Zoom ${index + 1}`,
zoomDepth: region.depth,
}));
}, [zoomRegions]);
if (!videoDuration || videoDuration === 0) {
return (
<div className="flex-1 flex items-center justify-center rounded-lg bg-[#09090b]">
<span className="text-slate-500 text-sm">Load a video to see timeline</span>
<div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-[#09090b] gap-3">
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center">
<Plus className="w-6 h-6 text-slate-600" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-slate-300">No Video Loaded</p>
<p className="text-xs text-slate-500 mt-1">Drag and drop a video to start editing</p>
</div>
</div>
);
}