Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 93 additions & 5 deletions src/components/timeline/components/track/track-component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TrackState, ClipState, ClipRenderer } from "../../timeline.types";
import { getTrackHeight } from "../../timeline.types";
import { MAX_TRACK_HEIGHT, MIN_TRACK_HEIGHT, getTrackHeight } from "../../timeline.types";
import { ClipComponent } from "../clip/clip-component";

export interface TrackComponentOptions {
Expand All @@ -20,26 +20,100 @@ export interface TrackComponentOptions {
findContentForLuma?: (lumaTrack: number, lumaClip: number) => { trackIndex: number; clipIndex: number } | null;
/** Pre-computed AI asset numbers (map of clip ID to number) */
aiAssetNumbers: Map<string, number>;
/** Callback when a track's height changes via resize (no args — parent re-reads heights) */
onHeightChange?: () => void;
}

/** Renders a single track with its clips */
export class TrackComponent {
public readonly element: HTMLElement;
private readonly clipComponents = new Map<string, ClipComponent>();
private readonly options: TrackComponentOptions;
private readonly resizeHandle: HTMLElement;
private trackIndex: number;

// Current state for draw
private currentTrack: TrackState | null = null;
private currentPixelsPerSecond = 50;
private currentHeight = 0;
private needsUpdate = true;

// Per-track resize state (component-local)
private customHeight: number | null = null;
private isResizing = false;

constructor(trackIndex: number, options: TrackComponentOptions) {
this.element = document.createElement("div");
this.element.className = "ss-track";
this.trackIndex = trackIndex;
this.options = options;
this.element.dataset["trackIndex"] = String(trackIndex);

this.resizeHandle = document.createElement("div");
this.resizeHandle.className = "ss-track-resize-handle";
this.element.appendChild(this.resizeHandle);
this.setupResizeDrag();
}

private setupResizeDrag(): void {
let startY = 0;
let startHeight = 0;

const onPointerDown = (e: PointerEvent): void => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation(); // Prevent InteractionController from clearing clip selection

startY = e.clientY;
startHeight = this.element.offsetHeight;
this.isResizing = true;

this.element.classList.add("ss-track--resizing");
this.resizeHandle.setPointerCapture(e.pointerId);
};

const onPointerMove = (e: PointerEvent): void => {
if (!this.isResizing) return;

const delta = e.clientY - startY;
const newHeight = Math.max(MIN_TRACK_HEIGHT, Math.min(MAX_TRACK_HEIGHT, startHeight + delta));

this.customHeight = newHeight;
this.currentHeight = newHeight;
this.element.style.height = `${newHeight}px`;
this.options.onHeightChange?.();
};

const onPointerUp = (e: PointerEvent): void => {
if (!this.isResizing) return;

this.isResizing = false;
this.element.classList.remove("ss-track--resizing");
this.resizeHandle.releasePointerCapture(e.pointerId);
this.options.onHeightChange?.();
};

const onDblClick = (e: MouseEvent): void => {
e.preventDefault();
e.stopPropagation();

this.customHeight = null;
const height = getTrackHeight(this.currentTrack?.primaryAssetType ?? "default");
this.currentHeight = height;
this.element.style.height = `${height}px`;
this.options.onHeightChange?.();
};

this.resizeHandle.addEventListener("pointerdown", onPointerDown);
this.resizeHandle.addEventListener("pointermove", onPointerMove);
this.resizeHandle.addEventListener("pointerup", onPointerUp);
this.resizeHandle.addEventListener("pointercancel", onPointerUp);
this.resizeHandle.addEventListener("dblclick", onDblClick);
}

/** Get the effective height (custom override or default for asset type) */
public getEffectiveHeight(): number {
return this.customHeight ?? getTrackHeight(this.currentTrack?.primaryAssetType ?? "default");
}

public draw(): void {
Expand Down Expand Up @@ -148,16 +222,30 @@ export class TrackComponent {
return; // Nothing changed, skip update
}

// Only update height if asset type changed (not every frame)
const prevAssetType = this.currentTrack?.primaryAssetType;

// Reset custom height when component is reassigned to a different track (pool reuse)
if (track.index !== this.currentTrack?.index) {
this.customHeight = null;
}

this.currentTrack = track;
this.currentPixelsPerSecond = pixelsPerSecond;

// Set height only when asset type changes
// Skip height update during resize (prevents render loop fighting drag),
// unless the asset type genuinely changed
if (!this.isResizing || track.primaryAssetType !== prevAssetType) {
if (this.isResizing && track.primaryAssetType !== prevAssetType) {
this.customHeight = null;
}
const height = this.customHeight ?? getTrackHeight(track.primaryAssetType);
if (height !== this.currentHeight) {
this.element.style.height = `${height}px`;
this.currentHeight = height;
}
}

if (track.primaryAssetType !== prevAssetType) {
const height = getTrackHeight(track.primaryAssetType);
this.element.style.height = `${height}px`;
this.element.dataset["assetType"] = track.primaryAssetType;
}

Expand Down
26 changes: 18 additions & 8 deletions src/components/timeline/components/track/track-list.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TrackState, ClipState, ClipRenderer } from "../../timeline.types";
import { TIMELINE_PADDING, getTrackHeight } from "../../timeline.types";
import { TIMELINE_PADDING } from "../../timeline.types";

import { TrackComponent } from "./track-component";

Expand All @@ -21,6 +21,8 @@ export interface TrackListOptions {
findContentForLuma?: (lumaTrack: number, lumaClip: number) => { trackIndex: number; clipIndex: number } | null;
/** Pre-computed AI asset numbers (map of clip ID to number) */
aiAssetNumbers: Map<string, number>;
/** Callback when any track's height changes via resize */
onHeightChange?: () => void;
}

/** Container for all track components with virtualization support */
Expand Down Expand Up @@ -86,7 +88,8 @@ export class TrackListComponent {
onMaskClick: this.options.onMaskClick,
isLumaVisibleForEditing: this.options.isLumaVisibleForEditing,
findContentForLuma: this.options.findContentForLuma,
aiAssetNumbers: this.options.aiAssetNumbers
aiAssetNumbers: this.options.aiAssetNumbers,
onHeightChange: this.options.onHeightChange
});
this.trackComponents.push(trackComponent);
this.contentElement.appendChild(trackComponent.element);
Expand Down Expand Up @@ -124,6 +127,16 @@ export class TrackListComponent {
this.needsUpdate = true;
}

/** Get effective heights for all active tracks (component-local custom heights resolved) */
public getEffectiveHeights(): number[] {
const count = this.currentTracks.length;
const heights: number[] = [];
for (let i = 0; i < count; i += 1) {
heights.push(this.trackComponents[i].getEffectiveHeight());
}
return heights;
}

public getTrackComponent(trackIndex: number): TrackComponent | undefined {
return this.trackComponents[trackIndex];
}
Expand All @@ -136,8 +149,7 @@ export class TrackListComponent {
let currentY = 0;
let trackIndex = -1;
for (let i = 0; i < this.trackComponents.length; i += 1) {
const track = this.trackComponents[i].getCurrentTrack();
const height = getTrackHeight(track?.primaryAssetType ?? "default");
const height = this.trackComponents[i].getEffectiveHeight();

if (relativeY >= currentY && relativeY < currentY + height) {
trackIndex = i;
Expand All @@ -163,8 +175,7 @@ export class TrackListComponent {

let currentY = 0;
for (let i = 0; i < this.trackComponents.length; i += 1) {
const track = this.trackComponents[i].getCurrentTrack();
const height = getTrackHeight(track?.primaryAssetType ?? "default");
const height = this.trackComponents[i].getEffectiveHeight();

if (relativeY >= currentY && relativeY < currentY + height) {
return i;
Expand All @@ -178,8 +189,7 @@ export class TrackListComponent {
public getTrackYPosition(trackIndex: number): number {
let y = 0;
for (let i = 0; i < trackIndex && i < this.trackComponents.length; i += 1) {
const track = this.trackComponents[i].getCurrentTrack();
y += getTrackHeight(track?.primaryAssetType ?? "default");
y += this.trackComponents[i].getEffectiveHeight();
}
return y;
}
Expand Down
16 changes: 9 additions & 7 deletions src/components/timeline/interaction/interaction-calculations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type Seconds, sec } from "@core/timing/types";

import type { ClipState, TrackState } from "../timeline.types";
import { TIMELINE_PADDING, getTrackHeight } from "../timeline.types";
import { TIMELINE_PADDING } from "../timeline.types";

// ─── Types ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -96,12 +96,13 @@ export function formatDragTime(seconds: Seconds): string {

// ─── Track Y Position Calculations ─────────────────────────────────────────

export function buildTrackYPositions(tracks: readonly TrackState[]): number[] {
export function buildTrackYPositions(tracks: readonly TrackState[], heights: number[]): number[] {
const count = Math.min(tracks.length, heights.length);
const positions: number[] = [];
let y = 0;
for (const track of tracks) {
for (let i = 0; i < count; i += 1) {
positions.push(y);
y += getTrackHeight(track.primaryAssetType);
y += heights[i];
}
positions.push(y); // sentinel: total height for "insert after last track"
return positions;
Expand All @@ -115,15 +116,16 @@ export function getTrackYPosition(trackIndex: number, trackYPositions: readonly

const INSERT_ZONE_SIZE = 12; // pixels at track edges for insert detection

export function getDragTargetAtY(y: number, tracks: readonly TrackState[]): DragTarget {
export function getDragTargetAtY(y: number, tracks: readonly TrackState[], heights: number[]): DragTarget {
// Top edge - insert above first track
if (y < INSERT_ZONE_SIZE / 2) {
return { type: "insert", insertionIndex: 0 };
}

const count = Math.min(tracks.length, heights.length);
let currentY = 0;
for (let i = 0; i < tracks.length; i += 1) {
const height = getTrackHeight(tracks[i].primaryAssetType);
for (let i = 0; i < count; i += 1) {
const height = heights[i];

// Top edge insert zone (between this track and previous)
if (i > 0 && y >= currentY - INSERT_ZONE_SIZE / 2 && y < currentY + INSERT_ZONE_SIZE / 2) {
Expand Down
48 changes: 22 additions & 26 deletions src/components/timeline/interaction/interaction-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { type Seconds, sec } from "@core/timing/types";
import type { ClipState } from "@timeline/timeline.types";
import { getTrackHeight } from "@timeline/timeline.types";

import type { TrackListComponent } from "../components/track/track-list";
import { TimelineStateManager } from "../timeline-state";

import {
Expand Down Expand Up @@ -103,8 +104,6 @@ export class InteractionController implements TimelineInteractionRegistration {
// DOM feedback elements (stateless management)
private feedbackElements: FeedbackElements;

private trackYCache: number[] | null = null;

// Bound handlers for cleanup
private readonly handlePointerDown: (e: PointerEvent) => void;
private readonly handlePointerMove: (e: PointerEvent) => void;
Expand All @@ -113,6 +112,7 @@ export class InteractionController implements TimelineInteractionRegistration {
constructor(
private readonly edit: Edit,
private readonly stateManager: TimelineStateManager,
private readonly trackList: TrackListComponent,
private readonly tracksContainer: HTMLElement,
feedbackLayer: HTMLElement,
config?: Partial<TimelineInteractionConfig>
Expand Down Expand Up @@ -187,8 +187,6 @@ export class InteractionController implements TimelineInteractionRegistration {
) as HTMLElement | null;
if (!clipElement) return;

this.trackYCache = null;

this.state = createResizingState(clipRef, clipElement, edge, clip.config.start, clip.config.length);

this.buildSnapPointsForClip(clipRef);
Expand Down Expand Up @@ -222,8 +220,6 @@ export class InteractionController implements TimelineInteractionRegistration {
}

private transitionToDragging(e: PointerEvent, state: PendingState): void {
this.trackYCache = null;

const { clipRef } = state;
const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex);
if (!clip) {
Expand Down Expand Up @@ -269,16 +265,16 @@ export class InteractionController implements TimelineInteractionRegistration {
const pps = this.stateManager.getViewport().pixelsPerSecond;
const track = this.stateManager.getTracks()[clipRef.trackIndex];
const clipAssetType = clip.config.asset?.type || "unknown";
const trackAssetType = track?.primaryAssetType ?? clipAssetType;
const ghost = createDragGhost(clip.config.length, clipAssetType, trackAssetType, pps);
const sourceTrackHeight = this.getHeights()[clipRef.trackIndex] ?? getTrackHeight(track?.primaryAssetType ?? clipAssetType);
const ghost = createDragGhost(clip.config.length, clipAssetType, sourceTrackHeight, pps);
this.feedbackElements.container.appendChild(ghost);

this.state = createDraggingState(state, clipElement, ghost, dragOffsetX, dragOffsetY, originalStyles, clip.config.length, e.altKey);

// Position ghost at current clip position initially
const tracksOffset = getTracksOffsetInFeedbackLayer(this.feedbackElements.container, this.tracksContainer);
ghost.style.left = `${timeToViewX(sec(clip.config.start), pps)}px`;
ghost.style.top = `${this.getTrackYPositionCached(clipRef.trackIndex) + 4 + tracksOffset}px`;
ghost.style.top = `${this.getTrackYPosition(clipRef.trackIndex) + 4 + tracksOffset}px`;

this.buildSnapPointsForClip(clipRef);
}
Expand Down Expand Up @@ -386,8 +382,9 @@ export class InteractionController implements TimelineInteractionRegistration {
const targetTrack = tracks[state.dragTarget.trackIndex];
if (!targetTrack) return sec(targetClip.config.start);

const targetTrackY = this.getTrackYPositionCached(state.dragTarget.trackIndex);
const targetTrackHeight = getTrackHeight(targetTrack.primaryAssetType);
const targetTrackY = this.getTrackYPosition(state.dragTarget.trackIndex);
const heights = this.getHeights();
const targetTrackHeight = heights[state.dragTarget.trackIndex] ?? getTrackHeight(targetTrack.primaryAssetType);

const lumaResult = updateLumaTargetHighlight(
this.tracksContainer,
Expand Down Expand Up @@ -417,8 +414,9 @@ export class InteractionController implements TimelineInteractionRegistration {
const targetTrack = tracks[state.dragTarget.trackIndex];
if (!targetTrack) return;

const targetTrackY = this.getTrackYPositionCached(state.dragTarget.trackIndex) + 4;
const targetHeight = getTrackHeight(targetTrack.primaryAssetType) - 8;
const targetTrackY = this.getTrackYPosition(state.dragTarget.trackIndex) + 4;
const ghostHeights = this.getHeights();
const targetHeight = (ghostHeights[state.dragTarget.trackIndex] ?? getTrackHeight(targetTrack.primaryAssetType)) - 8;

ghost.style.left = `${timeToViewX(clipTime, feedbackConfig.pixelsPerSecond)}px`; // eslint-disable-line no-param-reassign -- DOM manipulation
ghost.style.top = `${targetTrackY + feedbackConfig.tracksOffset}px`; // eslint-disable-line no-param-reassign -- DOM manipulation
Expand All @@ -433,7 +431,7 @@ export class InteractionController implements TimelineInteractionRegistration {
hideDropZone(this.feedbackElements.dropZone);
} else {
ghost.style.display = "none"; // eslint-disable-line no-param-reassign -- DOM manipulation
const dropZoneY = this.getTrackYPositionCached(state.dragTarget.insertionIndex);
const dropZoneY = this.getTrackYPosition(state.dragTarget.insertionIndex);
this.feedbackElements.dropZone = showDropZone(this.feedbackElements, dropZoneY, feedbackConfig.tracksOffset);
}
}
Expand Down Expand Up @@ -831,23 +829,21 @@ export class InteractionController implements TimelineInteractionRegistration {
});
}

/** Get effective track heights from components (pull-based, always current) */
private getHeights(): number[] {
return this.trackList.getEffectiveHeights();
}

/** Get drag target at Y position (delegates to pure function) */
private getDragTargetAtYPosition(y: number): DragTarget {
const tracks = this.stateManager.getTracks();
return getDragTargetAtY(y, tracks);
return getDragTargetAtY(y, tracks, this.getHeights());
}

private ensureTrackYCache(): number[] {
if (!this.trackYCache) {
const tracks = this.stateManager.getTracks();
this.trackYCache = buildTrackYPositions(tracks);
}
return this.trackYCache;
}

private getTrackYPositionCached(trackIndex: number): number {
const cache = this.ensureTrackYCache();
return getTrackYPosition(trackIndex, cache);
private getTrackYPosition(trackIndex: number): number {
const tracks = this.stateManager.getTracks();
const positions = buildTrackYPositions(tracks, this.getHeights());
return getTrackYPosition(trackIndex, positions);
}

// ========== Visual State Queries ==========
Expand Down
Loading
Loading