feat: implement TimeScale component with proper zoom calculation
- Add TimeScale component with canvas-based rendering - Use 5 pixels per second base scale (duration * zoom * 5) - Implement viewport-based rendering for performance - Add scroll synchronization with waveforms - Add 240px padding for alignment with track controls and master area - Apply custom scrollbar styling - Update all waveform width calculations to match timeline 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ const ImportTrackDialog = React.lazy(() => import('@/components/tracks/ImportTra
|
||||
const KeyboardShortcutsDialog = React.lazy(() => import('@/components/dialogs/KeyboardShortcutsDialog').then(m => ({ default: m.KeyboardShortcutsDialog })));
|
||||
const MarkerTimeline = React.lazy(() => import('@/components/markers/MarkerTimeline').then(m => ({ default: m.MarkerTimeline })));
|
||||
const MarkerDialog = React.lazy(() => import('@/components/markers/MarkerDialog').then(m => ({ default: m.MarkerDialog })));
|
||||
const TimeScale = React.lazy(() => import('@/components/timeline/TimeScale').then(m => ({ default: m.TimeScale })));
|
||||
|
||||
// Lazy load analysis components (shown conditionally based on analyzerView)
|
||||
const FrequencyAnalyzer = React.lazy(() => import('@/components/analysis/FrequencyAnalyzer').then(m => ({ default: m.FrequencyAnalyzer })));
|
||||
@@ -197,6 +198,16 @@ export function AudioEditor() {
|
||||
// Track last recorded values to detect changes
|
||||
const lastRecordedValuesRef = React.useRef<Map<string, { value: number; time: number }>>(new Map());
|
||||
|
||||
// Time scale scroll synchronization
|
||||
const timeScaleScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const timeScaleScrollHandlerRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
const handleTimeScaleScroll = React.useCallback(() => {
|
||||
if (timeScaleScrollHandlerRef.current) {
|
||||
timeScaleScrollHandlerRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Automation recording callback
|
||||
const handleAutomationRecording = React.useCallback((
|
||||
trackId: string,
|
||||
@@ -2056,23 +2067,22 @@ export function AudioEditor() {
|
||||
</header>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Main canvas area */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||
{/* Multi-Track View */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Marker Timeline */}
|
||||
<React.Suspense fallback={null}>
|
||||
<MarkerTimeline
|
||||
markers={markers}
|
||||
duration={duration}
|
||||
currentTime={currentTime}
|
||||
onMarkerEdit={handleEditMarker}
|
||||
onMarkerDelete={handleDeleteMarker}
|
||||
onSeek={seek}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Time Scale - Full width */}
|
||||
<React.Suspense fallback={null}>
|
||||
<TimeScale
|
||||
duration={duration}
|
||||
zoom={zoom}
|
||||
currentTime={currentTime}
|
||||
onSeek={seek}
|
||||
scrollRef={timeScaleScrollRef}
|
||||
onScroll={handleTimeScaleScroll}
|
||||
/>
|
||||
</React.Suspense>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Main canvas area */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||
<TrackList
|
||||
tracks={tracks}
|
||||
zoom={zoom}
|
||||
@@ -2087,18 +2097,20 @@ export function AudioEditor() {
|
||||
onSeek={seek}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onToggleRecordEnable={handleToggleRecordEnable}
|
||||
timeScaleScrollRef={timeScaleScrollRef}
|
||||
onTimeScaleScroll={handleTimeScaleScroll}
|
||||
timeScaleScrollHandlerRef={timeScaleScrollHandlerRef}
|
||||
recordingTrackId={recordingTrackId}
|
||||
recordingLevel={recordingState.inputLevel}
|
||||
trackLevels={trackLevels}
|
||||
onParameterTouched={setParameterTouched}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
|
||||
{/* Right Sidebar - Master Controls & Analyzers - Hidden on mobile */}
|
||||
<aside className="hidden lg:flex flex-shrink-0 border-l border-border bg-card flex-col pt-5 px-4 pb-4 gap-4 w-60">
|
||||
{/* Master Controls */}
|
||||
{/* Right Sidebar - Master Controls & Analyzers - Hidden on mobile */}
|
||||
<aside className="hidden lg:flex flex-shrink-0 border-l border-border bg-card flex-col pt-5 px-4 pb-4 gap-4 w-60">
|
||||
{/* Master Controls */}
|
||||
<div className="flex items-center justify-center">
|
||||
<MasterControls
|
||||
volume={masterVolume}
|
||||
@@ -2215,7 +2227,8 @@ export function AudioEditor() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar - Stacked on mobile (Master then Transport), Side-by-side on desktop */}
|
||||
|
||||
259
components/timeline/TimeScale.tsx
Normal file
259
components/timeline/TimeScale.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
'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
|
||||
const PIXELS_PER_SECOND_BASE = 5;
|
||||
const totalWidth = React.useMemo(() => {
|
||||
return duration * zoom * PIXELS_PER_SECOND_BASE;
|
||||
}, [duration, zoom]);
|
||||
|
||||
// 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,
|
||||
Math.max(0, (hoverTime / duration) * totalWidth - scrollLeft - 30)
|
||||
)}px`,
|
||||
}}
|
||||
>
|
||||
{formatTimeLabel(hoverTime, true)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -773,8 +773,8 @@ export function Track({
|
||||
className="relative h-full"
|
||||
style={{
|
||||
minWidth:
|
||||
track.audioBuffer && zoom > 1
|
||||
? `${duration * zoom * 100}px`
|
||||
track.audioBuffer && zoom >= 1
|
||||
? `${duration * zoom * 5}px`
|
||||
: "100%",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface TrackListProps {
|
||||
trackLevels?: Record<string, number>;
|
||||
onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void;
|
||||
isPlaying?: boolean;
|
||||
timeScaleScrollRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
onTimeScaleScroll?: () => void;
|
||||
timeScaleScrollHandlerRef?: React.MutableRefObject<(() => void) | null>;
|
||||
}
|
||||
|
||||
export function TrackList({
|
||||
@@ -57,6 +60,9 @@ export function TrackList({
|
||||
trackLevels = {},
|
||||
onParameterTouched,
|
||||
isPlaying = false,
|
||||
timeScaleScrollRef: externalTimeScaleScrollRef,
|
||||
onTimeScaleScroll,
|
||||
timeScaleScrollHandlerRef,
|
||||
}: TrackListProps) {
|
||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||
const [effectBrowserTrackId, setEffectBrowserTrackId] = React.useState<string | null>(null);
|
||||
@@ -66,6 +72,8 @@ export function TrackList({
|
||||
// Refs for horizontal scroll synchronization (per track)
|
||||
const waveformHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const automationHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const localTimeScaleScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const timeScaleScrollRef = externalTimeScaleScrollRef || localTimeScaleScrollRef;
|
||||
const [syncingScroll, setSyncingScroll] = React.useState(false);
|
||||
|
||||
// Synchronize vertical scroll between controls and waveforms
|
||||
@@ -100,6 +108,11 @@ export function TrackList({
|
||||
el.scrollLeft = scrollLeft;
|
||||
});
|
||||
|
||||
// Sync time scale
|
||||
if (timeScaleScrollRef.current) {
|
||||
timeScaleScrollRef.current.scrollLeft = scrollLeft;
|
||||
}
|
||||
|
||||
setSyncingScroll(false);
|
||||
}, [syncingScroll]);
|
||||
|
||||
@@ -127,9 +140,50 @@ export function TrackList({
|
||||
}
|
||||
});
|
||||
|
||||
// Sync time scale
|
||||
if (timeScaleScrollRef.current) {
|
||||
timeScaleScrollRef.current.scrollLeft = scrollLeft;
|
||||
}
|
||||
|
||||
setSyncingScroll(false);
|
||||
}, [syncingScroll]);
|
||||
|
||||
const handleTimeScaleScrollInternal = React.useCallback(() => {
|
||||
if (syncingScroll) return;
|
||||
setSyncingScroll(true);
|
||||
|
||||
if (!timeScaleScrollRef.current) {
|
||||
setSyncingScroll(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollLeft = timeScaleScrollRef.current.scrollLeft;
|
||||
|
||||
// Sync all waveforms
|
||||
waveformHScrollRefs.current.forEach((el) => {
|
||||
el.scrollLeft = scrollLeft;
|
||||
});
|
||||
|
||||
// Sync all automation lanes
|
||||
automationHScrollRefs.current.forEach((el) => {
|
||||
el.scrollLeft = scrollLeft;
|
||||
});
|
||||
|
||||
setSyncingScroll(false);
|
||||
|
||||
// Also call the external callback if provided
|
||||
if (onTimeScaleScroll) {
|
||||
onTimeScaleScroll();
|
||||
}
|
||||
}, [syncingScroll, onTimeScaleScroll]);
|
||||
|
||||
// Expose the scroll handler via ref so AudioEditor can call it
|
||||
React.useEffect(() => {
|
||||
if (timeScaleScrollHandlerRef) {
|
||||
timeScaleScrollHandlerRef.current = handleTimeScaleScrollInternal;
|
||||
}
|
||||
}, [handleTimeScaleScrollInternal, timeScaleScrollHandlerRef]);
|
||||
|
||||
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
|
||||
if (onImportTrack) {
|
||||
onImportTrack(buffer, name);
|
||||
@@ -490,7 +544,7 @@ export function TrackList({
|
||||
<div className="overflow-x-auto custom-scrollbar">
|
||||
<div
|
||||
style={{
|
||||
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
|
||||
minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%',
|
||||
}}
|
||||
>
|
||||
{track.automation.lanes
|
||||
@@ -799,7 +853,7 @@ export function TrackList({
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
|
||||
minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%',
|
||||
}}
|
||||
>
|
||||
<Track
|
||||
@@ -1072,7 +1126,7 @@ export function TrackList({
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
|
||||
minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%',
|
||||
}}
|
||||
>
|
||||
{track.automation.lanes
|
||||
|
||||
Reference in New Issue
Block a user