- Add controlsWidth offset to tooltip left position - Tooltip now appears correctly at mouse cursor position 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
266 lines
8.6 KiB
TypeScript
266 lines
8.6 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { cn } from '@/lib/utils/cn';
|
|
import {
|
|
timeToPixel,
|
|
pixelToTime,
|
|
calculateTickInterval,
|
|
formatTimeLabel,
|
|
getVisibleTimeRange,
|
|
} from '@/lib/utils/timeline';
|
|
|
|
export interface TimeScaleProps {
|
|
duration: number;
|
|
zoom: number;
|
|
currentTime: number;
|
|
onSeek?: (time: number) => void;
|
|
className?: string;
|
|
height?: number;
|
|
controlsWidth?: number;
|
|
scrollRef?: React.MutableRefObject<HTMLDivElement | null>;
|
|
onScroll?: () => void;
|
|
}
|
|
|
|
export function TimeScale({
|
|
duration,
|
|
zoom,
|
|
currentTime,
|
|
onSeek,
|
|
className,
|
|
height = 40,
|
|
controlsWidth = 240,
|
|
scrollRef: externalScrollRef,
|
|
onScroll,
|
|
}: TimeScaleProps) {
|
|
const localScrollRef = React.useRef<HTMLDivElement>(null);
|
|
const scrollRef = externalScrollRef || localScrollRef;
|
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
|
const [viewportWidth, setViewportWidth] = React.useState(800);
|
|
const [scrollLeft, setScrollLeft] = React.useState(0);
|
|
const [hoverTime, setHoverTime] = React.useState<number | null>(null);
|
|
|
|
// Calculate total timeline width (match waveform calculation)
|
|
// Uses 5 pixels per second as base scale, multiplied by zoom
|
|
// Always ensure minimum width is at least viewport width for full coverage
|
|
const PIXELS_PER_SECOND_BASE = 5;
|
|
const totalWidth = React.useMemo(() => {
|
|
if (zoom >= 1) {
|
|
const calculatedWidth = duration * zoom * PIXELS_PER_SECOND_BASE;
|
|
// Ensure it's at least viewport width so timeline always fills
|
|
return Math.max(calculatedWidth, viewportWidth);
|
|
}
|
|
return viewportWidth;
|
|
}, [duration, zoom, viewportWidth]);
|
|
|
|
// Update viewport width on resize
|
|
React.useEffect(() => {
|
|
const scroller = scrollRef.current;
|
|
if (!scroller) return;
|
|
|
|
const updateWidth = () => {
|
|
setViewportWidth(scroller.clientWidth);
|
|
};
|
|
|
|
updateWidth();
|
|
|
|
const resizeObserver = new ResizeObserver(updateWidth);
|
|
resizeObserver.observe(scroller);
|
|
|
|
return () => resizeObserver.disconnect();
|
|
}, [scrollRef]);
|
|
|
|
// Handle scroll - update scrollLeft and trigger onScroll callback
|
|
const handleScroll = React.useCallback(() => {
|
|
if (scrollRef.current) {
|
|
setScrollLeft(scrollRef.current.scrollLeft);
|
|
}
|
|
if (onScroll) {
|
|
onScroll();
|
|
}
|
|
}, [onScroll, scrollRef]);
|
|
|
|
// Draw time scale - redraws on scroll and zoom
|
|
React.useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas || duration === 0) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// Set canvas size to viewport width
|
|
const dpr = window.devicePixelRatio || 1;
|
|
canvas.width = viewportWidth * dpr;
|
|
canvas.height = height * dpr;
|
|
canvas.style.width = `${viewportWidth}px`;
|
|
canvas.style.height = `${height}px`;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
// Clear canvas
|
|
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-background') || '#ffffff';
|
|
ctx.fillRect(0, 0, viewportWidth, height);
|
|
|
|
// Calculate visible time range
|
|
const visibleRange = getVisibleTimeRange(scrollLeft, viewportWidth, duration, zoom);
|
|
const visibleDuration = visibleRange.end - visibleRange.start;
|
|
|
|
// Calculate tick intervals based on visible duration
|
|
const { major, minor } = calculateTickInterval(visibleDuration);
|
|
|
|
// Calculate which ticks to draw (only visible ones)
|
|
const startTick = Math.floor(visibleRange.start / minor) * minor;
|
|
const endTick = Math.ceil(visibleRange.end / minor) * minor;
|
|
|
|
// Set up text style for labels
|
|
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'top';
|
|
|
|
// Draw ticks and labels
|
|
for (let time = startTick; time <= endTick; time += minor) {
|
|
if (time < 0 || time > duration) continue;
|
|
|
|
// Calculate x position using the actual totalWidth (not timeToPixel which recalculates)
|
|
const x = (time / duration) * totalWidth - scrollLeft;
|
|
if (x < 0 || x > viewportWidth) continue;
|
|
|
|
const isMajor = Math.abs(time % major) < 0.001;
|
|
|
|
if (isMajor) {
|
|
// Major ticks - tall and prominent
|
|
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue('--color-foreground') || '#000000';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, height - 20);
|
|
ctx.lineTo(x, height);
|
|
ctx.stroke();
|
|
|
|
// Major tick label
|
|
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-foreground') || '#000000';
|
|
const label = formatTimeLabel(time, visibleDuration < 10);
|
|
ctx.fillText(label, x, 6);
|
|
} else {
|
|
// Minor ticks - shorter and lighter
|
|
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue('--color-muted-foreground') || '#9ca3af';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, height - 10);
|
|
ctx.lineTo(x, height);
|
|
ctx.stroke();
|
|
|
|
// Minor tick label (smaller and lighter)
|
|
if (x > 20 && x < viewportWidth - 20) {
|
|
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-muted-foreground') || '#9ca3af';
|
|
ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
const label = formatTimeLabel(time, visibleDuration < 10);
|
|
ctx.fillText(label, x, 8);
|
|
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw playhead indicator
|
|
const playheadX = (currentTime / duration) * totalWidth - scrollLeft;
|
|
if (playheadX >= 0 && playheadX <= viewportWidth) {
|
|
ctx.strokeStyle = '#ef4444';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(playheadX, 0);
|
|
ctx.lineTo(playheadX, height);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Draw hover indicator
|
|
if (hoverTime !== null) {
|
|
const hoverX = (hoverTime / duration) * totalWidth - scrollLeft;
|
|
if (hoverX >= 0 && hoverX <= viewportWidth) {
|
|
ctx.strokeStyle = 'rgba(59, 130, 246, 0.5)';
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([3, 3]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(hoverX, 0);
|
|
ctx.lineTo(hoverX, height);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
}
|
|
}, [duration, zoom, currentTime, viewportWidth, scrollLeft, height, hoverTime, totalWidth]);
|
|
|
|
// Handle click to seek
|
|
const handleClick = React.useCallback(
|
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
if (!onSeek) return;
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const pixelPos = x + scrollLeft;
|
|
const time = (pixelPos / totalWidth) * duration;
|
|
|
|
onSeek(Math.max(0, Math.min(duration, time)));
|
|
},
|
|
[onSeek, duration, totalWidth, scrollLeft]
|
|
);
|
|
|
|
// Handle mouse move for hover
|
|
const handleMouseMove = React.useCallback(
|
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const pixelPos = x + scrollLeft;
|
|
const time = (pixelPos / totalWidth) * duration;
|
|
|
|
setHoverTime(Math.max(0, Math.min(duration, time)));
|
|
},
|
|
[duration, totalWidth, scrollLeft]
|
|
);
|
|
|
|
const handleMouseLeave = React.useCallback(() => {
|
|
setHoverTime(null);
|
|
}, []);
|
|
|
|
return (
|
|
<div className={cn('relative bg-background', className)} style={{ paddingLeft: '240px', paddingRight: '240px' }}>
|
|
<div
|
|
ref={scrollRef}
|
|
className="w-full bg-background overflow-x-auto overflow-y-hidden custom-scrollbar"
|
|
style={{
|
|
height: `${height}px`,
|
|
}}
|
|
onScroll={handleScroll}
|
|
>
|
|
{/* Spacer to create scrollable width */}
|
|
<div style={{ width: `${totalWidth}px`, height: `${height}px`, position: 'relative' }}>
|
|
<canvas
|
|
ref={canvasRef}
|
|
onClick={handleClick}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
className="cursor-pointer"
|
|
style={{
|
|
position: 'sticky',
|
|
left: 0,
|
|
width: `${viewportWidth}px`,
|
|
height: `${height}px`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hover tooltip */}
|
|
{hoverTime !== null && (
|
|
<div
|
|
className="absolute top-full mt-1 px-2 py-1 bg-popover border border-border rounded shadow-lg text-xs font-mono pointer-events-none z-10"
|
|
style={{
|
|
left: `${Math.min(
|
|
viewportWidth - 60 + controlsWidth,
|
|
Math.max(controlsWidth, (hoverTime / duration) * totalWidth - scrollLeft - 30 + controlsWidth)
|
|
)}px`,
|
|
}}
|
|
>
|
|
{formatTimeLabel(hoverTime, true)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|