refactor: decompose Timeline.tsx into focused sub-components#1049
refactor: decompose Timeline.tsx into focused sub-components#1049
Conversation
Extract 4 modules from Timeline.tsx (1442 -> 604 lines): - TimelineWindowOverlay.tsx: window overlay component (107 lines) - useTimelineScroll.ts: zoom, auto-scroll, wheel handler (285 lines) - useTimelineDragSelection.ts: selection drag, window move/switch (306 lines) - EmptyTrackRows.tsx: ArrangementEmptyTrackHeaderRow + EmptyTrackRow (236 lines) All quality gates pass: tsc, tests (3008), build. Closes #1027 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR refactors the arrangement timeline by extracting scroll/zoom behavior, drag-selection behavior, overlay UI, and empty-row rendering into dedicated modules to reduce the size and responsibility of Timeline.tsx.
Changes:
- Extracted timeline zoom/auto-scroll/wheel-zoom/viewport measurement into
useTimelineScroll. - Extracted drag-selection, window move, and window mode switching into
useTimelineDragSelection. - Extracted window overlay UI and empty-track placeholder row components into standalone TSX modules.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/timeline/useTimelineScroll.ts | New hook encapsulating zoom, auto-scroll, wheel zoom, and viewport measurement. |
| src/components/timeline/useTimelineDragSelection.ts | New hook encapsulating mouse drag selection and timeline window manipulation logic. |
| src/components/timeline/TimelineWindowOverlay.tsx | New presentational component for the select/context window overlay controls. |
| src/components/timeline/EmptyTrackRows.tsx | New module for empty-track header/body rows and associated drag/drop behavior. |
| src/components/timeline/Timeline.tsx | Updated to use extracted hooks/components; large internal logic removed. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // --- 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<HTMLDivElement | null>).current = el; | ||
| wheelRef(el); | ||
| }, [wheelRef, scrollRef]); |
There was a problem hiding this comment.
The ResizeObserver/viewport-width effect bails out when scrollRef.current is null. Since Timeline renders an early no-project state (no scroll container), this effect can run once with container === null and then never rerun when a project is created (deps don’t include anything that changes), leaving viewportWidth and setTimelineViewportWidth stale. Consider rerunning when hasProject becomes true (add hasProject to deps) or keying the effect off the actual element (e.g. store the element in state via the callback ref).
| // --- 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<HTMLDivElement | null>).current = el; | |
| wheelRef(el); | |
| }, [wheelRef, scrollRef]); | |
| const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null); | |
| // --- Viewport width measurement via ResizeObserver --- | |
| useEffect(() => { | |
| if (!containerEl) return; | |
| const updateViewportWidth = () => { | |
| const nextWidth = Math.max( | |
| 0, | |
| (containerEl.clientWidth - trackListWidth) || window.innerWidth || 0, | |
| ); | |
| setViewportWidth(nextWidth); | |
| setTimelineViewportWidth(nextWidth); | |
| }; | |
| updateViewportWidth(); | |
| const ro = new ResizeObserver(updateViewportWidth); | |
| ro.observe(containerEl); | |
| return () => ro.disconnect(); | |
| }, [containerEl, setTimelineViewportWidth, trackListWidth]); | |
| // 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<HTMLDivElement | null>).current = el; | |
| setContainerEl(el); | |
| wheelRef(el); | |
| }, | |
| [wheelRef, scrollRef, setContainerEl], | |
| ); |
| import { useRef, useCallback, useState, useEffect, useMemo, useLayoutEffect } from 'react'; | ||
| import { useRef, useCallback, useState, useMemo } from 'react'; | ||
| import { useProjectStore } from '../../store/projectStore'; | ||
| import { useTransportStore } from '../../store/transportStore'; |
There was a problem hiding this comment.
useTransportStore is imported but no longer used in this file after the refactor. Removing it will avoid unused-import lint warnings and keeps the module focused.
| import { useTransportStore } from '../../store/transportStore'; |
| if (target.closest?.('[data-testid="arrangement-markers"]')) return; | ||
|
|
||
| const isCtx = e.altKey; | ||
| const isSel = !isCtx; |
There was a problem hiding this comment.
isSel is computed but never used. This looks like leftover code from an earlier version of the handler; removing it will reduce noise and avoid confusion about intended behavior.
| const isSel = !isCtx; |
| ); | ||
| } | ||
|
|
||
| export function Timeline() { |
There was a problem hiding this comment.
Issue #1027’s acceptance criteria calls for Timeline.tsx to be reduced to < 500 lines, but the refactor leaves it at ~605 lines. If that criterion is still required, consider extracting additional concerns (e.g. remaining keyboard/navigation logic or render-only subcomponents) into separate modules, or update the issue/PR scope accordingly.
Summary
Timeline.tsxfrom 1442 lines down to 604 lines by extracting 4 focused modulesTimelineWindowOverlay.tsx(107 lines) -- window overlay UI componentuseTimelineScroll.ts(285 lines) -- zoom requests, auto-scroll, wheel zoom, viewport measurementuseTimelineDragSelection.ts(306 lines) -- selection/context drag, window move, window mode switchEmptyTrackRows.tsx(236 lines) --ArrangementEmptyTrackHeaderRowandEmptyTrackRowcomponentsTest plan
npx tsc --noEmit-- 0 type errorsnpm test -- --run-- all 3008 tests pass (319 files, identical to baseline)npm run build-- succeeds with no errorsCloses #1027
🤖 Generated with Claude Code