- {/* Grid and playhead span full height (tracks + empty space below) */}
- {/* Committed context window overlay — Apple Teal (#5AC8FA) */}
+ {/* Committed context window overlay */}
{ctxLeft !== null && ctxWidth !== null && ctxVRange && (
)}
- {/* Committed select window overlay — Neutral White */}
+ {/* Committed select window overlay */}
{selLeft !== null && selWidth !== null && selVRange && (
- {/* Live context drag overlay — Apple Teal (#5AC8FA) */}
+ {/* Live context drag overlay */}
{ctxDrag && (
)}
- {/* Live select drag overlay — Neutral White */}
+ {/* Live select drag overlay */}
{selDrag && (
- {/* MultiTrackGenerateModal is now accessed via GENR button or toolbar —
- no longer auto-opens on selectWindow creation (#577) */}
-
- {/* Region context menu — right-click on select window → same as canvas context menu with AI Tools */}
+ {/* Region context menu */}
{regionCtxMenu && selectWindow && (
}
- {/* Canvas context menu — right-click on empty timeline area */}
+ {/* Canvas context menu */}
{canvasCtxMenu && (
);
}
-
-/** Empty placeholder rows below tracks — infinite grid like ACE Studio */
-const PLACEHOLDER_ROW_HEIGHT = DEFAULT_ARRANGEMENT_ROW_HEIGHT;
-
-function ArrangementEmptyTrackHeaderRow({
- slotIndex,
- isCollapsed,
- isDropDisabled,
- isDragOver,
- onDragOver,
- onDrop,
-}: {
- slotIndex: number;
- isCollapsed: boolean;
- isDropDisabled: boolean;
- isDragOver: boolean;
- onDragOver: (e: React.DragEvent, slotIndex: number) => void;
- onDrop: (e: React.DragEvent, slotIndex: number) => void;
-}) {
- const setShowInstrumentPicker = useUIStore((s) => s.setShowInstrumentPicker);
- const selectTrack = useUIStore((s) => s.selectTrack);
- const selectedTrackIds = useUIStore((s) => s.selectedTrackIds);
- const virtualId = getArrangementEmptyTrackId(slotIndex);
- const isSelected = selectedTrackIds.has(virtualId);
-
- return (
-
{
- selectTrack(virtualId, false);
- setShowInstrumentPicker(true);
- }}
- onDragOver={isDropDisabled ? undefined : (e) => onDragOver(e, slotIndex)}
- onDrop={isDropDisabled ? undefined : (e) => onDrop(e, slotIndex)}
- aria-label={`Empty track slot ${slotIndex + 1}`}
- data-drop-disabled={isDropDisabled ? 'true' : 'false'}
- data-testid={`empty-header-row-${slotIndex}`}
- >
- {isSelected && (
-
- )}
-
+
-
- );
-}
-
-function EmptyTrackRow({ slotIndex }: { slotIndex: number }) {
- const selectedTrackIds = useUIStore((s) => s.selectedTrackIds);
- const pixelsPerSecond = useUIStore((s) => s.pixelsPerSecond);
- const setTrackLaneRect = useUIStore((s) => s.setTrackLaneRect);
- const removeTrackLaneRect = useUIStore((s) => s.removeTrackLaneRect);
- const hasProject = useProjectStore((s) => Boolean(s.project));
- const bpm = useProjectStore((s) => s.project?.bpm ?? 120);
- const timeSignature = useProjectStore((s) => s.project?.timeSignature ?? 4);
- const timeSignatureDenominator = useProjectStore((s) => s.project?.timeSignatureDenominator ?? 4);
- const tempoMap = useProjectStore((s) => s.project?.tempoMap ?? EMPTY_TEMPO_MAP);
- const addTrack = useProjectStore((s) => s.addTrack);
- const virtualId = getArrangementEmptyTrackId(slotIndex);
- const isSelected = selectedTrackIds.has(virtualId);
- const {
- importAudioToTrack,
- importLoopToTrack,
- importAudioFileAsNewQuickSampler,
- importAssetAsNewTrack,
- } = useAudioImport();
-
- const laneRef = useRef
(null);
- const [dropGhost, setDropGhost] = useState<{ left: number; width: number; name: string } | null>(null);
- const [isDragOver, setIsDragOver] = useState(false);
- const dragCounterRef = useRef(0);
-
- const defaultClipDuration = hasProject
- ? getBarDuration(bpm, timeSignature, timeSignatureDenominator) * 4
- : 8;
-
- useLayoutEffect(() => {
- const el = laneRef.current;
- if (!el) return;
-
- const update = () => {
- const parentEl = el.offsetParent as HTMLElement | null;
- const parentOffset = parentEl ? parentEl.offsetTop : 0;
- setTrackLaneRect(virtualId, {
- top: el.offsetTop + parentOffset,
- height: el.offsetHeight,
- });
- };
-
- update();
- const ro = new ResizeObserver(update);
- ro.observe(el);
-
- return () => {
- ro.disconnect();
- removeTrackLaneRect(virtualId);
- };
- }, [removeTrackLaneRect, setTrackLaneRect, virtualId]);
-
- const onDragEnter = useCallback((e: React.DragEvent) => {
- const types = e.dataTransfer.types;
- if (types.includes('Files') || types.includes('application/x-loop-id') || types.includes('application/x-asset-id')) {
- e.preventDefault();
- dragCounterRef.current++;
- setIsDragOver(true);
- }
- }, []);
-
- const onDragOver = useCallback((e: React.DragEvent) => {
- const types = e.dataTransfer.types;
- if (types.includes('Files') || types.includes('application/x-loop-id') || types.includes('application/x-asset-id')) {
- e.preventDefault();
- e.stopPropagation();
- e.dataTransfer.dropEffect = 'copy';
-
- if (hasProject) {
- const payload = getDragPayload();
- const laneX = clientXToLaneX(e.clientX);
- const rawTime = laneX / pixelsPerSecond;
- const snappedTime = Math.max(0, snapToGrid(rawTime, bpm, 1, tempoMap));
- const ghostDuration = payload?.duration ?? defaultClipDuration;
- const ghostName = payload?.name ?? (types.includes('Files') ? 'Audio file' : 'Audio');
- setDropGhost({
- left: snappedTime * pixelsPerSecond,
- width: ghostDuration * pixelsPerSecond,
- name: ghostName,
- });
- }
- }
- }, [hasProject, pixelsPerSecond, defaultClipDuration, bpm, tempoMap]);
-
- const onDragLeave = useCallback(() => {
- dragCounterRef.current--;
- if (dragCounterRef.current <= 0) {
- dragCounterRef.current = 0;
- setIsDragOver(false);
- setDropGhost(null);
- }
- }, []);
-
- const onDrop = useCallback(async (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- dragCounterRef.current = 0;
- setIsDragOver(false);
- setDropGhost(null);
- clearDragPayload();
- if (!hasProject) return;
-
- const laneX = clientXToLaneX(e.clientX);
- const rawTime = laneX / pixelsPerSecond;
- const startTime = Math.max(0, snapToGrid(rawTime, bpm, 1, tempoMap));
-
- const loopId = e.dataTransfer.getData('application/x-loop-id');
- if (loopId) {
- const newTrack = addTrack('custom', 'sample', { order: slotIndex + 1 });
- await importLoopToTrack(loopId, newTrack.id, startTime);
- return;
- }
-
- const assetId = e.dataTransfer.getData('application/x-asset-id');
- if (assetId) {
- await importAssetAsNewTrack(assetId, startTime, { order: slotIndex + 1 });
- return;
- }
-
- const wantsQuickSampler = e.altKey;
- const files = e.dataTransfer.files;
- if (files.length > 0) {
- for (const file of Array.from(files)) {
- if (file.type.startsWith('audio/') || /\.(wav|mp3|ogg|flac|aac|m4a|webm)$/i.test(file.name)) {
- if (wantsQuickSampler) {
- await importAudioFileAsNewQuickSampler(file);
- } else {
- const newTrack = addTrack('custom', 'sample', { order: slotIndex + 1 });
- useProjectStore.getState().updateTrack(newTrack.id, {
- displayName: file.name.replace(/\.[^.]+$/, ''),
- });
- await importAudioToTrack(file, newTrack.id, startTime);
- }
- }
- }
- }
- }, [hasProject, pixelsPerSecond, addTrack, importAudioToTrack, importLoopToTrack, importAssetAsNewTrack, importAudioFileAsNewQuickSampler, bpm, tempoMap]);
-
- return (
-
- {isSelected && (
-
- )}
- {dropGhost && (
-
- {dropGhost.name}
-
- )}
-
- );
-}
diff --git a/src/components/timeline/TimelineWindowOverlay.tsx b/src/components/timeline/TimelineWindowOverlay.tsx
new file mode 100644
index 00000000..fb4428d4
--- /dev/null
+++ b/src/components/timeline/TimelineWindowOverlay.tsx
@@ -0,0 +1,107 @@
+import type React from 'react';
+
+const WINDOW_CONTROL_BAR_HEIGHT = 24;
+
+export interface TimelineWindowOverlayProps {
+ kind: 'select' | 'context';
+ left: number;
+ width: number;
+ top: number;
+ height: number;
+ label: string;
+ switchLabel: string;
+ switchAriaLabel: string;
+ accentTextColor: string;
+ fillColor: string;
+ borderColor: string;
+ edgeColor: string;
+ align: 'left' | 'right';
+ onMoveStart: (e: React.MouseEvent) => void;
+ onSwitch: () => void;
+ onContextMenu?: (e: React.MouseEvent) => void;
+}
+
+export function TimelineWindowOverlay({
+ kind,
+ left,
+ width,
+ top,
+ height,
+ label,
+ switchLabel,
+ switchAriaLabel,
+ accentTextColor,
+ fillColor,
+ borderColor,
+ edgeColor,
+ align,
+ onMoveStart,
+ onSwitch,
+ onContextMenu,
+}: TimelineWindowOverlayProps) {
+ const justifyClass = align === 'left' ? 'justify-start' : 'justify-end';
+
+ return (
+
+
+
{
+ if (!onContextMenu) return;
+ e.preventDefault();
+ e.stopPropagation();
+ onContextMenu(e);
+ }}
+ style={{
+ minHeight: WINDOW_CONTROL_BAR_HEIGHT,
+ color: accentTextColor,
+ background: 'rgba(18, 19, 24, 0.82)',
+ borderColor,
+ }}
+ >
+ {label}
+
+
+
+
+ );
+}
diff --git a/src/components/timeline/useTimelineDragSelection.ts b/src/components/timeline/useTimelineDragSelection.ts
new file mode 100644
index 00000000..5b6f905b
--- /dev/null
+++ b/src/components/timeline/useTimelineDragSelection.ts
@@ -0,0 +1,306 @@
+import { useCallback, useState } from 'react';
+import { useProjectStore } from '../../store/projectStore';
+import { useTransportStore } from '../../store/transportStore';
+import { useUIStore } from '../../store/uiStore';
+import type { Track } from '../../types/project';
+import { snapToGrid } from '../../utils/time';
+import { convertTimelineWindowMode, moveTimelineWindow, type TimelineWindowRange } from './timelineWindowUtils';
+
+const DRAG_THRESHOLD_PX = 4;
+const EMPTY_TRACKS: Track[] = [];
+
+interface DragRect { left: number; width: number; top: number; height: number }
+
+function getIntersectedTrackIds(container: HTMLElement, minY: number, maxY: number): string[] {
+ const lanes = container.querySelectorAll('[data-timeline-lane][data-track-id]');
+ const cRect = container.getBoundingClientRect();
+ const ids: string[] = [];
+ for (const lane of lanes) {
+ const r = lane.getBoundingClientRect();
+ const laneTop = r.top - cRect.top + container.scrollTop;
+ const laneBot = laneTop + r.height;
+ if (laneBot > minY && laneTop < maxY) {
+ ids.push(lane.dataset.trackId!);
+ }
+ }
+ return ids;
+}
+
+function getTrackRowIndex(container: HTMLElement, trackId: string): number | null {
+ const lanes = Array.from(container.querySelectorAll('[data-timeline-lane][data-track-id]'));
+ const rowIndex = lanes.findIndex((lane) => lane.dataset.trackId === trackId);
+ return rowIndex === -1 ? null : rowIndex;
+}
+
+function getTrackVerticalRange(
+ container: HTMLElement, trackIds: string[],
+): { top: number; height: number } | null {
+ if (trackIds.length === 0) return null;
+ const cRect = container.getBoundingClientRect();
+ let minTop = Infinity;
+ let maxBot = -Infinity;
+ const idSet = new Set(trackIds);
+ const lanes = container.querySelectorAll('[data-timeline-lane][data-track-id]');
+ for (const lane of lanes) {
+ if (!idSet.has(lane.dataset.trackId!)) continue;
+ const r = lane.getBoundingClientRect();
+ const laneTop = r.top - cRect.top + container.scrollTop;
+ const laneBot = laneTop + r.height;
+ if (laneTop < minTop) minTop = laneTop;
+ if (laneBot > maxBot) maxBot = laneBot;
+ }
+ if (minTop === Infinity) return null;
+ return { top: minTop, height: maxBot - minTop };
+}
+
+// Re-export for use by Timeline.tsx render logic
+export { getTrackVerticalRange };
+
+/**
+ * Encapsulates timeline mouse-drag selection (select window / context window),
+ * window move, and window switch logic.
+ */
+export function useTimelineDragSelection(
+ scrollRef: React.RefObject,
+ trackAreaRef: React.RefObject,
+) {
+ const tracks = useProjectStore((s) => s.project?.tracks ?? EMPTY_TRACKS);
+ const bpm = useProjectStore((s) => s.project?.bpm ?? 120);
+ const hasProject = useProjectStore((s) => Boolean(s.project));
+ const totalDuration = useProjectStore((s) => s.project?.totalDuration ?? 0);
+ const seek = useTransportStore((s) => s.seek);
+ const pixelsPerSecond = useUIStore((s) => s.pixelsPerSecond);
+ const setTimelineFocused = useUIStore((s) => s.setTimelineFocused);
+ const trackListWidth = useUIStore((s) => s.trackListWidth);
+ const contextWindow = useUIStore((s) => s.contextWindow);
+ const setContextWindow = useUIStore((s) => s.setContextWindow);
+ const selectWindow = useUIStore((s) => s.selectWindow);
+ const setSelectWindow = useUIStore((s) => s.setSelectWindow);
+ const selectClips = useUIStore((s) => s.selectClips);
+ const deselectAllTracks = useUIStore((s) => s.deselectAllTracks);
+ const selectTrack = useUIStore((s) => s.selectTrack);
+
+ const [ctxDrag, setCtxDrag] = useState(null);
+ const [selDrag, setSelDrag] = useState(null);
+
+ const handleMouseDownCapture = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.button !== 0) return;
+
+ const target = e.target as HTMLElement;
+ if (target.closest?.('[data-window-overlay-control="true"]')) return;
+ if (target.closest?.('[data-clip-block]')) return;
+ if (target.closest?.('[data-track-column-region="true"]')) return;
+ if (target.closest?.('.fixed')) return;
+ if (target.closest?.('[data-sequencer-grid]')) return;
+ if (target.closest?.('[data-timeline-scrubber="true"]')) return;
+ if (target.closest?.('[data-testid="arrangement-markers"]')) return;
+
+ const isCtx = e.altKey;
+ const isSel = !isCtx;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ const container = scrollRef.current;
+ const trackArea = trackAreaRef.current;
+ if (!container || !trackArea) return;
+
+ const scrollLeft = container.scrollLeft;
+ const cRect = container.getBoundingClientRect();
+ const timelineRectLeft = cRect.left + trackListWidth;
+ const startClientX = e.clientX;
+ const startClientY = e.clientY;
+ const startViewX = startClientX - timelineRectLeft;
+ const startViewY = startClientY - cRect.top + container.scrollTop;
+ const primaryTrackId = getIntersectedTrackIds(container, startViewY, startViewY + 1)[0];
+
+ let hasDragged = false;
+ const setDrag = isCtx ? setCtxDrag : setSelDrag;
+
+ const onMouseMove = (ev: MouseEvent) => {
+ const dx = ev.clientX - startClientX;
+ if (!hasDragged && Math.abs(dx) < DRAG_THRESHOLD_PX) return;
+ hasDragged = true;
+
+ const curViewX = ev.clientX - timelineRectLeft;
+ const curViewY = ev.clientY - cRect.top + container.scrollTop;
+
+ const left = Math.min(startViewX, curViewX) + scrollLeft;
+ const width = Math.abs(curViewX - startViewX);
+
+ const minY = Math.min(startViewY, curViewY);
+ const maxY = Math.max(startViewY, curViewY);
+
+ const vRange = getTrackVerticalRange(
+ container, getIntersectedTrackIds(container, minY, maxY),
+ );
+ const trackAreaTop = trackArea.getBoundingClientRect().top - cRect.top + container.scrollTop;
+ const top = vRange ? vRange.top - trackAreaTop : minY - trackAreaTop;
+ const height = vRange ? vRange.height : maxY - minY;
+ setDrag({ left, width, top, height });
+ };
+
+ const onMouseUp = (ev: MouseEvent) => {
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseUp);
+
+ if (!hasDragged) {
+ setDrag(null);
+ // Click without drag -> seek playhead + select the clicked track row
+ const time = (startViewX + scrollLeft) / pixelsPerSecond;
+ seek(time);
+ setTimelineFocused(true);
+ // Find and select the track row at the click Y position
+ const clickedIds = getIntersectedTrackIds(container, startViewY, startViewY + 1);
+ if (clickedIds.length > 0) {
+ selectTrack(clickedIds[0], ev.metaKey || ev.ctrlKey);
+ } else {
+ deselectAllTracks();
+ }
+ return;
+ }
+
+ const endViewX = ev.clientX - timelineRectLeft;
+ const endViewY = ev.clientY - cRect.top + container.scrollTop;
+
+ const leftPx = Math.min(startViewX, endViewX) + scrollLeft;
+ const rightPx = Math.max(startViewX, endViewX) + scrollLeft;
+ const minY = Math.min(startViewY, endViewY);
+ const maxY = Math.max(startViewY, endViewY);
+
+ const rawStart = leftPx / pixelsPerSecond;
+ const rawEnd = rightPx / pixelsPerSecond;
+ const startTime = Math.max(0, snapToGrid(rawStart, bpm, 1));
+ const endTime = snapToGrid(rawEnd, bpm, 1);
+ const trackIds = getIntersectedTrackIds(container, minY, maxY);
+
+ if (endTime > startTime && trackIds.length > 0) {
+ if (isCtx) {
+ setContextWindow({ startTime, endTime, trackIds });
+ } else {
+ const nextSelectWindow: TimelineWindowRange = {
+ startTime,
+ endTime,
+ trackIds,
+ };
+ if (primaryTrackId !== undefined) {
+ nextSelectWindow.primaryTrackId = primaryTrackId;
+ const targetRowIndex = getTrackRowIndex(container, primaryTrackId);
+ if (targetRowIndex !== null) {
+ nextSelectWindow.targetRowIndex = targetRowIndex;
+ }
+ }
+ setSelectWindow(nextSelectWindow);
+ seek(startTime);
+
+ // Auto-select all clips overlapping the select window
+ const overlappingClipIds: string[] = [];
+ const trackIdSet = new Set(trackIds);
+ for (const track of tracks) {
+ if (!trackIdSet.has(track.id)) continue;
+ for (const clip of track.clips) {
+ const clipEnd = clip.startTime + clip.duration;
+ if (clipEnd > startTime && clip.startTime < endTime) {
+ overlappingClipIds.push(clip.id);
+ }
+ }
+ }
+ if (overlappingClipIds.length > 0) {
+ selectClips(overlappingClipIds);
+ }
+ }
+ }
+ setDrag(null);
+ };
+
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseUp);
+ },
+ [bpm, pixelsPerSecond, setContextWindow, setSelectWindow, deselectAllTracks, selectTrack, selectClips, seek, setTimelineFocused, trackListWidth, tracks, scrollRef, trackAreaRef],
+ );
+
+ const startWindowMove = useCallback(
+ (
+ kind: 'select' | 'context',
+ windowRange: TimelineWindowRange,
+ e: React.MouseEvent,
+ ) => {
+ if (e.button !== 0) return;
+
+ const container = scrollRef.current;
+ if (!container || !hasProject) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ const rect = container.getBoundingClientRect();
+ const timelineRectLeft = rect.left + trackListWidth;
+ const setWindow = kind === 'context' ? setContextWindow : setSelectWindow;
+ const pointerTimeAtStart = (e.clientX - timelineRectLeft + container.scrollLeft) / pixelsPerSecond;
+ const pointerOffsetTime = pointerTimeAtStart - windowRange.startTime;
+
+ // Track vertical state for cross-track movement
+ const startClientY = e.clientY;
+ const initialVRange = getTrackVerticalRange(container, windowRange.trackIds);
+ const initialWindowHeight = initialVRange ? initialVRange.height : 0;
+
+ let currentWindow = windowRange;
+
+ const applyMove = (clientX: number, clientY: number) => {
+ const pointerTime = (clientX - timelineRectLeft + container.scrollLeft) / pixelsPerSecond;
+ const desiredStartTime = snapToGrid(pointerTime - pointerOffsetTime, bpm, 1);
+
+ // Calculate vertical delta and find new track set
+ const deltaY = clientY - startClientY;
+ if (initialVRange) {
+ const newTop = initialVRange.top + deltaY;
+ const newBottom = newTop + initialWindowHeight;
+ const newTrackIds = getIntersectedTrackIds(container, newTop, newBottom);
+ if (newTrackIds.length > 0) {
+ currentWindow = {
+ ...currentWindow,
+ trackIds: newTrackIds,
+ primaryTrackId: newTrackIds[0],
+ };
+ }
+ }
+
+ const moved = moveTimelineWindow(currentWindow, desiredStartTime, totalDuration);
+ currentWindow = moved;
+ setWindow(moved);
+ };
+
+ const onMouseMove = (ev: MouseEvent) => {
+ applyMove(ev.clientX, ev.clientY);
+ };
+
+ const onMouseUp = (ev: MouseEvent) => {
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseUp);
+ applyMove(ev.clientX, ev.clientY);
+ };
+
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseUp);
+ },
+ [bpm, hasProject, pixelsPerSecond, setContextWindow, setSelectWindow, totalDuration, trackListWidth, scrollRef],
+ );
+
+ const switchTimelineWindow = useCallback(
+ (kind: 'select' | 'context') => {
+ const nextWindows = convertTimelineWindowMode(kind, { selectWindow, contextWindow });
+ setSelectWindow(nextWindows.selectWindow);
+ setContextWindow(nextWindows.contextWindow);
+ },
+ [contextWindow, selectWindow, setContextWindow, setSelectWindow],
+ );
+
+ return {
+ ctxDrag,
+ selDrag,
+ handleMouseDownCapture,
+ startWindowMove,
+ switchTimelineWindow,
+ };
+}
diff --git a/src/components/timeline/useTimelineScroll.ts b/src/components/timeline/useTimelineScroll.ts
new file mode 100644
index 00000000..4f15c12a
--- /dev/null
+++ b/src/components/timeline/useTimelineScroll.ts
@@ -0,0 +1,285 @@
+import { useRef, useCallback, useState, useEffect } from 'react';
+import { useProjectStore } from '../../store/projectStore';
+import { useTransportStore } from '../../store/transportStore';
+import { useUIStore } from '../../store/uiStore';
+import { toastInfo } from '../../hooks/useToast';
+import {
+ clampTimelineScrollLeft,
+ clampTimelinePixelsPerSecond,
+ DEFAULT_TIMELINE_PIXELS_PER_SECOND,
+ getNextTimelineZoomLevel,
+ getTimelineContentWidth,
+ getTimelineFitViewport,
+ getTimelineZoomAnchor,
+ getZoomedTimelineViewport,
+} from '../../utils/timelineZoom';
+import { useNonPassiveWheel } from '../../hooks/useNonPassiveWheel';
+
+/**
+ * Encapsulates timeline zoom, auto-scroll, resize-observer, and wheel handling.
+ *
+ * Returns:
+ * - `mergedScrollRef` — callback ref to attach to the scroll container
+ * - `viewportWidth` — the measured timeline viewport width (excluding track list)
+ * - `totalWidth` — the computed content width
+ */
+export function useTimelineScroll(scrollRef: React.RefObject) {
+ const hasProject = useProjectStore((s) => Boolean(s.project));
+ const totalDuration = useProjectStore((s) => s.project?.totalDuration ?? 0);
+ const tracks = useProjectStore((s) => s.project?.tracks ?? []);
+ const pixelsPerSecond = useUIStore((s) => s.pixelsPerSecond);
+ const setPixelsPerSecond = useUIStore((s) => s.setPixelsPerSecond);
+ const setTimelineViewportWidth = useUIStore((s) => s.setTimelineViewportWidth);
+ const trackListWidth = useUIStore((s) => s.trackListWidth);
+ const selectWindow = useUIStore((s) => s.selectWindow);
+ const selectedClipIds = useUIStore((s) => s.selectedClipIds);
+ const timelineZoomRequest = useUIStore((s) => s.timelineZoomRequest);
+ const autoScrollEnabled = useUIStore((s) => s.autoScrollEnabled);
+ const setScrollX = useUIStore((s) => s.setScrollX);
+ const currentTime = useTransportStore((s) => s.currentTime);
+ const playStartTime = useTransportStore((s) => s.playStartTime);
+ const isPlaying = useTransportStore((s) => s.isPlaying);
+
+ const [viewportWidth, setViewportWidth] = useState(0);
+ const zoomAnimationFrameRef = useRef(null);
+ const zoomTargetRef = useRef(pixelsPerSecond);
+ const zoomAnchorRef = useRef<{ time: number; viewportX: number } | null>(null);
+ const zoomFrameTimeRef = useRef(null);
+ const handledTimelineZoomRequestIdRef = useRef(null);
+
+ const totalWidth = hasProject
+ ? getTimelineContentWidth(totalDuration, pixelsPerSecond, viewportWidth)
+ : 0;
+
+ // --- Zoom request effect (toolbar zoom buttons, keyboard shortcuts) ---
+ useEffect(() => {
+ if (!hasProject || !timelineZoomRequest || !scrollRef.current) return;
+ if (handledTimelineZoomRequestIdRef.current === timelineZoomRequest.id) return;
+
+ const container = scrollRef.current;
+ const nextViewportWidth = Math.max(1, (container.clientWidth - trackListWidth) || window.innerWidth || 1);
+ const projectRange = { startTime: 0, endTime: totalDuration };
+ handledTimelineZoomRequestIdRef.current = timelineZoomRequest.id;
+
+ let targetRange = projectRange;
+ let usedFallback = false;
+
+ if (timelineZoomRequest.mode === 'selection') {
+ const selectedClips = tracks
+ .flatMap((track) => track.clips)
+ .filter((clip) => selectedClipIds.has(clip.id));
+
+ if (selectedClips.length > 0) {
+ targetRange = {
+ startTime: Math.min(...selectedClips.map((clip) => clip.startTime)),
+ endTime: Math.max(...selectedClips.map((clip) => clip.startTime + clip.duration)),
+ };
+ } else if (selectWindow) {
+ targetRange = {
+ startTime: selectWindow.startTime,
+ endTime: selectWindow.endTime,
+ };
+ } else {
+ usedFallback = true;
+ }
+ }
+
+ if (timelineZoomRequest.mode === 'stepIn'
+ || timelineZoomRequest.mode === 'stepOut'
+ || timelineZoomRequest.mode === 'reset') {
+ const nextPixelsPerSecond = timelineZoomRequest.mode === 'reset'
+ ? DEFAULT_TIMELINE_PIXELS_PER_SECOND
+ : getNextTimelineZoomLevel(
+ pixelsPerSecond,
+ timelineZoomRequest.mode === 'stepIn' ? 'in' : 'out',
+ );
+
+ if (nextPixelsPerSecond === pixelsPerSecond) return;
+
+ const playheadAnchorTime = isPlaying ? currentTime : playStartTime;
+ const anchor = getTimelineZoomAnchor({
+ pixelsPerSecond,
+ scrollLeft: container.scrollLeft,
+ viewportWidth: nextViewportWidth,
+ playheadTime: playheadAnchorTime,
+ });
+ const nextViewport = getZoomedTimelineViewport({
+ pixelsPerSecond,
+ scrollLeft: container.scrollLeft,
+ viewportWidth: nextViewportWidth,
+ totalDuration,
+ }, nextPixelsPerSecond, anchor);
+
+ setPixelsPerSecond(nextViewport.pixelsPerSecond);
+ setScrollX(nextViewport.scrollLeft);
+ container.scrollLeft = nextViewport.scrollLeft;
+ return;
+ }
+
+ const nextViewport = getTimelineFitViewport(targetRange, nextViewportWidth, totalDuration, {
+ paddingPx: timelineZoomRequest.mode === 'project' ? 0 : 40,
+ });
+ setPixelsPerSecond(nextViewport.pixelsPerSecond);
+ setScrollX(nextViewport.scrollLeft);
+ container.scrollLeft = nextViewport.scrollLeft;
+
+ if (usedFallback) {
+ toastInfo('Nothing is selected, so the timeline zoomed to the full project.');
+ }
+ }, [
+ currentTime,
+ hasProject,
+ isPlaying,
+ pixelsPerSecond,
+ playStartTime,
+ selectWindow,
+ selectedClipIds,
+ setPixelsPerSecond,
+ setScrollX,
+ totalDuration,
+ trackListWidth,
+ tracks,
+ timelineZoomRequest,
+ scrollRef,
+ ]);
+
+ // --- Auto-scroll during playback ---
+ useEffect(() => {
+ const container = scrollRef.current;
+ if (!container || !hasProject || !isPlaying || !autoScrollEnabled) return;
+
+ const timelineViewportWidth = Math.max(1, (container.clientWidth - trackListWidth) || window.innerWidth || 1);
+ const fixedPlayheadViewportX = Math.min(
+ Math.max(120, timelineViewportWidth * 0.35),
+ Math.max(120, timelineViewportWidth - 96),
+ );
+ const nextScrollLeft = clampTimelineScrollLeft(
+ currentTime * pixelsPerSecond - fixedPlayheadViewportX,
+ totalDuration,
+ pixelsPerSecond,
+ timelineViewportWidth,
+ );
+
+ if (Math.abs(container.scrollLeft - nextScrollLeft) < 1) return;
+ container.scrollLeft = nextScrollLeft;
+ setScrollX(nextScrollLeft);
+ }, [autoScrollEnabled, currentTime, hasProject, isPlaying, pixelsPerSecond, setScrollX, totalDuration, trackListWidth, scrollRef]);
+
+ // --- Wheel zoom (trackpad pinch / Cmd+scroll) ---
+ const handleWheel = useCallback(
+ (e: WheelEvent) => {
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ const container = scrollRef.current;
+ if (!container) return;
+ const rect = container.getBoundingClientRect();
+ const target = e.target as HTMLElement | null;
+ const isTrackColumnTarget = !!target?.closest?.('[data-track-column-region="true"]');
+ const timelineViewportWidth = Math.max(1, (container.clientWidth - trackListWidth) || window.innerWidth || 1);
+ const cursorOffsetX = isTrackColumnTarget
+ ? Math.min(120, timelineViewportWidth - 1)
+ : Math.max(0, Math.min(timelineViewportWidth - 1, e.clientX - rect.left - trackListWidth));
+ const playheadAnchorTime = isPlaying ? currentTime : playStartTime;
+ const anchor = getTimelineZoomAnchor({
+ pixelsPerSecond,
+ scrollLeft: container.scrollLeft,
+ viewportWidth: timelineViewportWidth,
+ pointerViewportX: cursorOffsetX,
+ playheadTime: playheadAnchorTime,
+ });
+
+ const normalizedDelta = e.deltaMode === WheelEvent.DOM_DELTA_LINE
+ ? e.deltaY * 18
+ : e.deltaMode === WheelEvent.DOM_DELTA_PAGE
+ ? e.deltaY * (container.clientHeight || window.innerHeight || 1)
+ : e.deltaY;
+ const sensitivity = e.ctrlKey && !e.metaKey ? 0.0065 : 0.0042;
+ const zoomFactor = Math.exp(-normalizedDelta * sensitivity);
+ const currentBase = zoomAnimationFrameRef.current === null
+ ? pixelsPerSecond
+ : zoomTargetRef.current;
+ zoomTargetRef.current = clampTimelinePixelsPerSecond(currentBase * zoomFactor);
+ zoomAnchorRef.current = anchor;
+
+ if (zoomAnimationFrameRef.current !== null) {
+ return;
+ }
+
+ const animateZoom = (timestamp: number) => {
+ const liveContainer = scrollRef.current;
+ const liveAnchor = zoomAnchorRef.current;
+ if (!liveContainer || !liveAnchor) {
+ zoomAnimationFrameRef.current = null;
+ zoomFrameTimeRef.current = null;
+ return;
+ }
+
+ const dt = zoomFrameTimeRef.current === null ? 16 : Math.max(8, timestamp - zoomFrameTimeRef.current);
+ zoomFrameTimeRef.current = timestamp;
+ const currentPixels = useUIStore.getState().pixelsPerSecond;
+ const alpha = 1 - Math.exp(-dt / 42);
+ const nextPixelsPerSecond = Math.abs(zoomTargetRef.current - currentPixels) < 0.02
+ ? zoomTargetRef.current
+ : currentPixels + (zoomTargetRef.current - currentPixels) * alpha;
+
+ const nextViewport = getZoomedTimelineViewport({
+ pixelsPerSecond: currentPixels,
+ scrollLeft: liveContainer.scrollLeft,
+ viewportWidth: Math.max(1, (liveContainer.clientWidth - trackListWidth) || window.innerWidth || 1),
+ totalDuration,
+ }, nextPixelsPerSecond, liveAnchor);
+
+ setPixelsPerSecond(nextViewport.pixelsPerSecond);
+ setScrollX(nextViewport.scrollLeft);
+ liveContainer.scrollLeft = nextViewport.scrollLeft;
+
+ if (Math.abs(zoomTargetRef.current - nextPixelsPerSecond) < 0.02) {
+ zoomAnimationFrameRef.current = null;
+ zoomFrameTimeRef.current = null;
+ return;
+ }
+
+ zoomAnimationFrameRef.current = window.requestAnimationFrame(animateZoom);
+ };
+
+ zoomAnimationFrameRef.current = window.requestAnimationFrame(animateZoom);
+ }
+ },
+ [currentTime, isPlaying, pixelsPerSecond, playStartTime, setPixelsPerSecond, setScrollX, totalDuration, trackListWidth, scrollRef],
+ );
+
+ // Cancel zoom animation on unmount
+ useEffect(() => () => {
+ if (zoomAnimationFrameRef.current !== null) {
+ window.cancelAnimationFrame(zoomAnimationFrameRef.current);
+ }
+ }, []);
+
+ // --- Viewport width measurement via ResizeObserver ---
+ useEffect(() => {
+ const container = scrollRef.current;
+ if (!container) return;
+
+ const updateViewportWidth = () => {
+ const nextWidth = Math.max(0, (container.clientWidth - trackListWidth) || window.innerWidth || 0);
+ setViewportWidth(nextWidth);
+ setTimelineViewportWidth(nextWidth);
+ };
+ updateViewportWidth();
+
+ const ro = new ResizeObserver(updateViewportWidth);
+ ro.observe(container);
+ return () => ro.disconnect();
+ }, [setTimelineViewportWidth, trackListWidth, scrollRef]);
+
+ // Non-passive wheel listener so preventDefault() works for trackpad pinch-zoom
+ const wheelRef = useNonPassiveWheel(handleWheel);
+
+ // Merge scrollRef (used throughout) with wheelRef (callback ref from hook)
+ const mergedScrollRef = useCallback((el: HTMLDivElement | null) => {
+ (scrollRef as React.MutableRefObject).current = el;
+ wheelRef(el);
+ }, [wheelRef, scrollRef]);
+
+ return { mergedScrollRef, viewportWidth, totalWidth };
+}