Skip to content

refactor: decompose Timeline.tsx into focused sub-components#1049

Merged
ChuxiJ merged 1 commit intomainfrom
refactor/issue-1027-timeline
Mar 27, 2026
Merged

refactor: decompose Timeline.tsx into focused sub-components#1049
ChuxiJ merged 1 commit intomainfrom
refactor/issue-1027-timeline

Conversation

@ChuxiJ
Copy link
Copy Markdown

@ChuxiJ ChuxiJ commented Mar 27, 2026

Summary

  • Decomposes Timeline.tsx from 1442 lines down to 604 lines by extracting 4 focused modules
  • TimelineWindowOverlay.tsx (107 lines) -- window overlay UI component
  • useTimelineScroll.ts (285 lines) -- zoom requests, auto-scroll, wheel zoom, viewport measurement
  • useTimelineDragSelection.ts (306 lines) -- selection/context drag, window move, window mode switch
  • EmptyTrackRows.tsx (236 lines) -- ArrangementEmptyTrackHeaderRow and EmptyTrackRow components

Test plan

  • npx tsc --noEmit -- 0 type errors
  • npm test -- --run -- all 3008 tests pass (319 files, identical to baseline)
  • npm run build -- succeeds with no errors
  • Visual verification: start dev server and confirm timeline renders correctly

Closes #1027

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings March 27, 2026 13:01
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +258 to +282
// --- 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]);
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// --- 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],
);

Copilot uses AI. Check for mistakes.
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';
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
import { useTransportStore } from '../../store/transportStore';

Copilot uses AI. Check for mistakes.
if (target.closest?.('[data-testid="arrangement-markers"]')) return;

const isCtx = e.altKey;
const isSel = !isCtx;
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const isSel = !isCtx;

Copilot uses AI. Check for mistakes.
);
}

export function Timeline() {
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@ChuxiJ ChuxiJ merged commit f99481d into main Mar 27, 2026
8 checks passed
ChuxiJ pushed a commit that referenced this pull request Mar 27, 2026
…erged

- Marked #1100 and #1101 as already implemented (closed)
- Merged 4 refactoring PRs: #1046, #1049, #1050, #1052
- Updated analysis to reflect current state

https://claude.ai/code/session_01KYEjWp985CYRCcSm9J46ps
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: Decompose Timeline.tsx (1442 lines)

2 participants