timeline ux improvements
This commit is contained in:
@@ -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%)",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user