From 019b3f7222b1a3b11e71ba1e1a9cc747718d33cc Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sat, 11 Apr 2026 12:45:31 +1000 Subject: [PATCH 1/2] feat: add custom track sizing --- .../components/track/track-component.ts | 98 +++++++++- .../timeline/components/track/track-list.ts | 26 ++- .../interaction/interaction-calculations.ts | 16 +- .../interaction/interaction-controller.ts | 48 +++-- .../interaction/interaction-feedback.ts | 4 +- src/components/timeline/timeline.ts | 5 +- src/components/timeline/timeline.types.ts | 4 + src/styles/timeline/timeline.css | 21 ++- tests/interaction-calculations.test.ts | 25 ++- tests/interaction-controller.test.ts | 173 +++++++++++++++--- 10 files changed, 337 insertions(+), 83 deletions(-) diff --git a/src/components/timeline/components/track/track-component.ts b/src/components/timeline/components/track/track-component.ts index 565a1acb..d4a96928 100644 --- a/src/components/timeline/components/track/track-component.ts +++ b/src/components/timeline/components/track/track-component.ts @@ -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 { @@ -20,6 +20,8 @@ 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; + /** Callback when a track's height changes via resize (no args — parent re-reads heights) */ + onHeightChange?: () => void; } /** Renders a single track with its clips */ @@ -27,19 +29,91 @@ export class TrackComponent { public readonly element: HTMLElement; private readonly clipComponents = new Map(); 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 { @@ -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; } diff --git a/src/components/timeline/components/track/track-list.ts b/src/components/timeline/components/track/track-list.ts index e7d2d21f..7ac2635b 100644 --- a/src/components/timeline/components/track/track-list.ts +++ b/src/components/timeline/components/track/track-list.ts @@ -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"; @@ -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; + /** Callback when any track's height changes via resize */ + onHeightChange?: () => void; } /** Container for all track components with virtualization support */ @@ -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); @@ -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]; } @@ -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; @@ -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; @@ -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; } diff --git a/src/components/timeline/interaction/interaction-calculations.ts b/src/components/timeline/interaction/interaction-calculations.ts index ed6bfeda..de92bf26 100644 --- a/src/components/timeline/interaction/interaction-calculations.ts +++ b/src/components/timeline/interaction/interaction-calculations.ts @@ -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 ───────────────────────────────────────────────────────────────── @@ -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; @@ -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) { diff --git a/src/components/timeline/interaction/interaction-controller.ts b/src/components/timeline/interaction/interaction-controller.ts index 6291d0c1..3d6a6044 100644 --- a/src/components/timeline/interaction/interaction-controller.ts +++ b/src/components/timeline/interaction/interaction-controller.ts @@ -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 { @@ -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; @@ -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 @@ -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); @@ -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) { @@ -269,8 +265,8 @@ 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); @@ -278,7 +274,7 @@ export class InteractionController implements TimelineInteractionRegistration { // 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); } @@ -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, @@ -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 @@ -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); } } @@ -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 ========== diff --git a/src/components/timeline/interaction/interaction-feedback.ts b/src/components/timeline/interaction/interaction-feedback.ts index 57b9a08b..3e24f233 100644 --- a/src/components/timeline/interaction/interaction-feedback.ts +++ b/src/components/timeline/interaction/interaction-feedback.ts @@ -1,7 +1,6 @@ import { type Seconds, sec } from "@core/timing/types"; import type { ClipState } from "../timeline.types"; -import { getTrackHeight } from "../timeline.types"; import { formatDragTime, secondsToPixels, timeToViewX } from "./interaction-calculations"; @@ -189,13 +188,12 @@ export function clearLumaFeedback(elements: FeedbackElements, draggingClipElemen // ─── Ghost Creation ──────────────────────────────────────────────────────── -export function createDragGhost(clipLength: Seconds, clipAssetType: string, trackAssetType: string, pixelsPerSecond: number): HTMLElement { +export function createDragGhost(clipLength: Seconds, clipAssetType: string, trackHeight: number, pixelsPerSecond: number): HTMLElement { const ghost = document.createElement("div"); ghost.className = "ss-drag-ghost ss-clip"; ghost.dataset["assetType"] = clipAssetType; const width = secondsToPixels(clipLength, pixelsPerSecond); - const trackHeight = getTrackHeight(trackAssetType); ghost.style.width = `${width}px`; ghost.style.height = `${trackHeight - 8}px`; diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts index d42a3667..187aaa95 100644 --- a/src/components/timeline/timeline.ts +++ b/src/components/timeline/timeline.ts @@ -419,7 +419,8 @@ export class Timeline { isLumaVisibleForEditing: (contentTrackIndex, contentClipIndex) => this.stateManager.isLumaVisibleForEditing(contentTrackIndex, contentClipIndex), findContentForLuma: (lumaTrack, lumaClip) => this.stateManager.findContentForLuma(lumaTrack, lumaClip), - aiAssetNumbers: this.computeAiAssetNumbers() + aiAssetNumbers: this.computeAiAssetNumbers(), + onHeightChange: () => this.requestRender() }); // Set up scroll sync (also sync playhead) @@ -455,7 +456,7 @@ export class Timeline { this.rulerTracksWrapper.appendChild(this.feedbackLayer); // Initialize interaction controller - this.interactionController = new InteractionController(this.edit, this.stateManager, this.trackList.element, this.feedbackLayer, { + this.interactionController = new InteractionController(this.edit, this.stateManager, this.trackList, this.trackList.element, this.feedbackLayer, { snapThreshold: DEFAULT_SNAP_THRESHOLD, onRequestRender: () => this.requestRender() }); diff --git a/src/components/timeline/timeline.types.ts b/src/components/timeline/timeline.types.ts index 1c76e851..4dda2e18 100644 --- a/src/components/timeline/timeline.types.ts +++ b/src/components/timeline/timeline.types.ts @@ -100,6 +100,10 @@ export const TRACK_HEIGHTS: Record = { default: 48 }; +/** Track height constraints for per-track resize */ +export const MIN_TRACK_HEIGHT = 20; +export const MAX_TRACK_HEIGHT = 200; + /** Get track height for an asset type */ export function getTrackHeight(assetType: string): number { return TRACK_HEIGHTS[assetType] ?? TRACK_HEIGHTS["default"]; diff --git a/src/styles/timeline/timeline.css b/src/styles/timeline/timeline.css index e1564858..d7e33e95 100644 --- a/src/styles/timeline/timeline.css +++ b/src/styles/timeline/timeline.css @@ -203,10 +203,29 @@ .ss-track { position: relative; border-bottom: 1px solid #f3f4f6; - transition: background 0.15s ease; + transition: background 0.15s ease, height 0.2s ease; background: #ffffff; } +.ss-track.ss-track--resizing { + transition: none; +} + +.ss-track-resize-handle { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 6px; + cursor: ns-resize; + z-index: 2; +} + +.ss-track-resize-handle:hover, +.ss-track-resize-handle:active { + background: rgba(59, 130, 246, 0.4); +} + /* Track backgrounds by asset type - subtle tints on white */ .ss-track[data-asset-type="video"] { background: rgba(232, 222, 248, 0.3); diff --git a/tests/interaction-calculations.test.ts b/tests/interaction-calculations.test.ts index 6879941b..f726a005 100644 --- a/tests/interaction-calculations.test.ts +++ b/tests/interaction-calculations.test.ts @@ -28,6 +28,12 @@ import { determineDropAction } from "../src/components/timeline/interaction/interaction-calculations"; import type { ClipState, TrackState } from "../src/components/timeline/timeline.types"; +import { getTrackHeight } from "../src/components/timeline/timeline.types"; + +/** Helper to convert tracks to their default heights array (for use in tests) */ +function heightsOf(tracks: readonly TrackState[]): number[] { + return tracks.map(t => getTrackHeight(t.primaryAssetType)); +} // ─── Test Fixtures ───────────────────────────────────────────────────────── @@ -120,12 +126,12 @@ describe("formatDragTime", () => { describe("Track Y Position Calculations", () => { describe("buildTrackYPositions", () => { it("returns sentinel-only array for no tracks", () => { - expect(buildTrackYPositions([])).toEqual([0]); + expect(buildTrackYPositions([], [])).toEqual([0]); }); it("calculates positions for image tracks (72px height)", () => { const tracks: TrackState[] = [createMockTrack([createMockClip(0, 1)], "image"), createMockTrack([createMockClip(0, 1, "image", 1)], "image")]; - expect(buildTrackYPositions(tracks)).toEqual([0, 72, 144]); + expect(buildTrackYPositions(tracks, heightsOf(tracks))).toEqual([0, 72, 144]); }); it("calculates positions for audio tracks (48px height)", () => { @@ -133,7 +139,7 @@ describe("Track Y Position Calculations", () => { createMockTrack([createMockClip(0, 1, "audio")], "audio"), createMockTrack([createMockClip(0, 1, "audio", 1)], "audio") ]; - expect(buildTrackYPositions(tracks)).toEqual([0, 48, 96]); + expect(buildTrackYPositions(tracks, heightsOf(tracks))).toEqual([0, 48, 96]); }); it("handles mixed track types", () => { @@ -141,7 +147,7 @@ describe("Track Y Position Calculations", () => { createMockTrack([createMockClip(0, 1)], "image"), // 72px createMockTrack([createMockClip(0, 1, "audio", 1)], "audio") // 48px ]; - const positions = buildTrackYPositions(tracks); + const positions = buildTrackYPositions(tracks, heightsOf(tracks)); expect(positions).toEqual([0, 72, 120]); // Sentinel at 72 + 48 = 120 }); }); @@ -174,29 +180,30 @@ describe("getDragTargetAtY", () => { createMockTrack([createMockClip(0, 1)], "image"), // 0-72 createMockTrack([createMockClip(0, 1, "image", 1)], "image") // 72-144 ]; + const heights = heightsOf(tracks); it("returns insert at 0 for top edge", () => { - const result = getDragTargetAtY(3, tracks); + const result = getDragTargetAtY(3, tracks, heights); expect(result).toEqual({ type: "insert", insertionIndex: 0 }); }); it("returns track 0 for middle of first track", () => { - const result = getDragTargetAtY(36, tracks); + const result = getDragTargetAtY(36, tracks, heights); expect(result).toEqual({ type: "track", trackIndex: 0 }); }); it("returns insert between tracks at boundary", () => { - const result = getDragTargetAtY(72, tracks); + const result = getDragTargetAtY(72, tracks, heights); expect(result).toEqual({ type: "insert", insertionIndex: 1 }); }); it("returns track 1 for middle of second track", () => { - const result = getDragTargetAtY(108, tracks); + const result = getDragTargetAtY(108, tracks, heights); expect(result).toEqual({ type: "track", trackIndex: 1 }); }); it("returns insert after last track at bottom", () => { - const result = getDragTargetAtY(150, tracks); + const result = getDragTargetAtY(150, tracks, heights); expect(result).toEqual({ type: "insert", insertionIndex: 2 }); }); }); diff --git a/tests/interaction-controller.test.ts b/tests/interaction-controller.test.ts index 23554a70..3d7b9880 100644 --- a/tests/interaction-controller.test.ts +++ b/tests/interaction-controller.test.ts @@ -256,6 +256,12 @@ describe("InteractionController", () => { let mockStateManager: ReturnType; let mockDOM: ReturnType; + // Mock TrackListComponent with getEffectiveHeights + const mockTrackList = { + element: null as unknown as HTMLElement, // replaced in beforeEach + getEffectiveHeights: () => [72, 72] // default mock: two 72px tracks + }; + beforeEach(() => { // Create fresh mocks mockEdit = createMockEdit(); @@ -266,6 +272,7 @@ describe("InteractionController", () => { ]) ] as MockTrack[]); mockDOM = createMockDOM(); + mockTrackList.element = mockDOM.tracksContainer; // Add clip elements to DOM mockDOM.addClipElement(0, 0, { left: 0, top: 0, width: 200, height: 40 }); @@ -280,7 +287,13 @@ describe("InteractionController", () => { describe("state machine", () => { it("starts in idle state", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); // isDragging/isResizing should both return false @@ -289,7 +302,13 @@ describe("InteractionController", () => { }); it("transitions to pending state on pointerdown on clip", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -307,9 +326,16 @@ describe("InteractionController", () => { }); it("transitions from pending to dragging after threshold", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer, { - dragThreshold: 3 - }); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer, + { + dragThreshold: 3 + } + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -334,7 +360,13 @@ describe("InteractionController", () => { }); it("returns to idle state on pointerup without movement (click)", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -360,7 +392,13 @@ describe("InteractionController", () => { }); it("clears selection when clicking on empty space", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); // Click on container (not on a clip) @@ -379,7 +417,13 @@ describe("InteractionController", () => { describe("resize state", () => { it("transitions to resizing state on resize handle click", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -397,7 +441,13 @@ describe("InteractionController", () => { }); it("returns to idle after resize completion", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -428,7 +478,13 @@ describe("InteractionController", () => { describe("visual state queries", () => { it("isDragging returns false for non-dragged clips", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -458,7 +514,13 @@ describe("InteractionController", () => { }); it("isResizing returns false for non-resized clips", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -487,7 +549,13 @@ describe("InteractionController", () => { // Setup with single clip to avoid collision mockStateManager.setTestTracks([createMockTrack(0, [{ start: 0, length: 2 }])] as MockTrack[]); - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -525,7 +593,13 @@ describe("InteractionController", () => { }); it("does not execute command when clip returns to original position", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -568,7 +642,13 @@ describe("InteractionController", () => { describe("resize completion", () => { it("executes ResizeClipCommand when resizing right edge", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -601,7 +681,13 @@ describe("InteractionController", () => { }); it("executes both MoveClipCommand and ResizeClipCommand when resizing left edge", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -634,7 +720,13 @@ describe("InteractionController", () => { }); it("enforces minimum clip length during resize", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -671,7 +763,13 @@ describe("InteractionController", () => { describe("dispose", () => { it("removes event listeners on dispose", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); // Spy on removeEventListener @@ -686,7 +784,13 @@ describe("InteractionController", () => { }); it("removes feedback elements on dispose", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); // Trigger some feedback elements to be created by starting a drag @@ -731,7 +835,13 @@ describe("InteractionController", () => { (mockStateManager as { getAttachedLumaPlayer: jest.Mock }).getAttachedLumaPlayer = jest.fn(() => mockLumaPlayer); mockEdit.findClipIndices = jest.fn().mockReturnValue({ trackIndex: 0, clipIndex: 1 }); - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -778,7 +888,13 @@ describe("InteractionController", () => { // No luma attachment mockStateManager.getAttachedLumaPlayer = jest.fn(() => null); - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -816,7 +932,13 @@ describe("InteractionController", () => { (mockStateManager as { getAttachedLumaPlayer: jest.Mock }).getAttachedLumaPlayer = jest.fn(() => mockLumaPlayer); mockEdit.findClipIndices = jest.fn().mockReturnValue({ trackIndex: 0, clipIndex: 1 }); - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; @@ -860,6 +982,7 @@ describe("InteractionController", () => { controller = new InteractionController( mockEdit as never, mockStateManager as never, + mockTrackList as never, mockDOM.tracksContainer, mockDOM.feedbackLayer, { dragThreshold: 20 } // High threshold @@ -898,7 +1021,13 @@ describe("InteractionController", () => { }); it("uses default configuration when none provided", () => { - controller = new InteractionController(mockEdit as never, mockStateManager as never, mockDOM.tracksContainer, mockDOM.feedbackLayer); + controller = new InteractionController( + mockEdit as never, + mockStateManager as never, + mockTrackList as never, + mockDOM.tracksContainer, + mockDOM.feedbackLayer + ); controller.mount(); const clipElement = mockDOM.tracksContainer.querySelector(".ss-clip") as HTMLElement; From ee8525343842d2a84386464c79a35d5928beca7d Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sat, 11 Apr 2026 12:57:04 +1000 Subject: [PATCH 2/2] fix: use auto background-size for thumbnail tiles to fill clip height --- src/components/timeline/media-thumbnail-renderer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/timeline/media-thumbnail-renderer.ts b/src/components/timeline/media-thumbnail-renderer.ts index d0253d5b..3a8487d7 100644 --- a/src/components/timeline/media-thumbnail-renderer.ts +++ b/src/components/timeline/media-thumbnail-renderer.ts @@ -194,15 +194,15 @@ export class MediaThumbnailRenderer implements ClipRenderer { }); } - private applyThumbnail(el: HTMLElement, url: string, thumbnailWidth: number): void { + private applyThumbnail(el: HTMLElement, url: string, _thumbnailWidth: number): void { el.classList.add("ss-clip--thumbnails"); this.setLoadingState(el, false); - // Single thumbnail with CSS repeat-x tiles across clip width + // Thumbnail tiles across clip width, scaled to fill clip height. // eslint-disable-next-line no-param-reassign -- Intentional DOM styling el.style.backgroundImage = `url("${url}")`; // eslint-disable-next-line no-param-reassign -- Intentional DOM styling - el.style.backgroundSize = `${thumbnailWidth}px 100%`; + el.style.backgroundSize = "auto 100%"; // eslint-disable-next-line no-param-reassign -- Intentional DOM styling el.style.backgroundRepeat = "repeat-x"; // eslint-disable-next-line no-param-reassign -- Intentional DOM styling