diff --git a/src/components/timeline/components/playhead/playhead-component.ts b/src/components/timeline/components/playhead/playhead-component.ts index 36fa1c41..9acf2f15 100644 --- a/src/components/timeline/components/playhead/playhead-component.ts +++ b/src/components/timeline/components/playhead/playhead-component.ts @@ -1,5 +1,7 @@ import { type Seconds, sec } from "@core/timing/types"; +import { timeToViewX, viewXToTime } from "../../interaction/interaction-calculations"; + export interface PlayheadOptions { onSeek: (time: Seconds) => void; getScrollX?: () => number; @@ -55,7 +57,7 @@ export class PlayheadComponent { // Get current scroll from callback or stored value const scrollX = this.options.getScrollX?.() ?? this.currentScrollX; const x = e.clientX - this.containerRect.left + scrollX; - const time = sec(Math.max(0, x / this.pixelsPerSecond)); + const time = viewXToTime(x, this.pixelsPerSecond); // Update position immediately for smooth feedback this.setPosition(time); @@ -82,7 +84,7 @@ export class PlayheadComponent { if (!this.needsUpdate) return; this.needsUpdate = false; - const x = this.currentTime * this.pixelsPerSecond; + const x = timeToViewX(this.currentTime, this.pixelsPerSecond); this.element.style.setProperty("--playhead-time", String(this.currentTime)); this.element.style.left = `${x}px`; diff --git a/src/components/timeline/components/ruler/ruler-component.ts b/src/components/timeline/components/ruler/ruler-component.ts index 972b4cac..c8c00bfe 100644 --- a/src/components/timeline/components/ruler/ruler-component.ts +++ b/src/components/timeline/components/ruler/ruler-component.ts @@ -1,5 +1,7 @@ import { type Seconds, sec } from "@core/timing/types"; +import { viewXToTime } from "../../interaction/interaction-calculations"; + interface RulerOptions { onSeek?: (time: Seconds) => void; onWheel?: (e: WheelEvent) => void; @@ -41,9 +43,9 @@ export class RulerComponent { const rect = this.element.getBoundingClientRect(); const x = e.clientX - rect.left + this.scrollX; - const time = Math.max(0, x / this.currentPixelsPerSecond); + const time = viewXToTime(x, this.currentPixelsPerSecond); - this.options.onSeek(sec(time)); + this.options.onSeek(time); } private buildElement(): HTMLElement { diff --git a/src/components/timeline/components/track/track-list.ts b/src/components/timeline/components/track/track-list.ts index f9c4a1da..e7d2d21f 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 { getTrackHeight } from "../../timeline.types"; +import { TIMELINE_PADDING, getTrackHeight } from "../../timeline.types"; import { TrackComponent } from "./track-component"; @@ -151,7 +151,7 @@ export class TrackListComponent { } const scrollX = this.element.scrollLeft; - const relativeX = x + scrollX; + const relativeX = x + scrollX - TIMELINE_PADDING; return this.trackComponents[trackIndex].getClipAtPosition(relativeX, pixelsPerSecond); } diff --git a/src/components/timeline/interaction/interaction-calculations.ts b/src/components/timeline/interaction/interaction-calculations.ts index a0783049..ed6bfeda 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 { getTrackHeight } from "../timeline.types"; +import { TIMELINE_PADDING, getTrackHeight } from "../timeline.types"; // ─── Types ───────────────────────────────────────────────────────────────── @@ -75,6 +75,16 @@ export function secondsToPixels(seconds: Seconds, pixelsPerSecond: number): numb return seconds * pixelsPerSecond; } +/** Convert a time value to a visual x-position (includes timeline padding offset) */ +export function timeToViewX(seconds: Seconds, pixelsPerSecond: number): number { + return seconds * pixelsPerSecond + TIMELINE_PADDING; +} + +/** Convert a visual x-position back to a time value (removes timeline padding offset) */ +export function viewXToTime(viewX: number, pixelsPerSecond: number): Seconds { + return sec(Math.max(0, (viewX - TIMELINE_PADDING) / pixelsPerSecond)); +} + // ─── Time Formatting ─────────────────────────────────────────────────────── export function formatDragTime(seconds: Seconds): string { diff --git a/src/components/timeline/interaction/interaction-controller.ts b/src/components/timeline/interaction/interaction-controller.ts index e2e3ef09..6291d0c1 100644 --- a/src/components/timeline/interaction/interaction-controller.ts +++ b/src/components/timeline/interaction/interaction-controller.ts @@ -25,7 +25,9 @@ import { findNearestSnapPoint, getDragTargetAtY, getTrackYPosition, - resolveClipCollision + resolveClipCollision, + timeToViewX, + viewXToTime } from "./interaction-calculations"; import { type FeedbackConfig, @@ -275,7 +277,7 @@ export class InteractionController implements TimelineInteractionRegistration { // Position ghost at current clip position initially const tracksOffset = getTracksOffsetInFeedbackLayer(this.feedbackElements.container, this.tracksContainer); - ghost.style.left = `${clip.config.start * pps}px`; + ghost.style.left = `${timeToViewX(sec(clip.config.start), pps)}px`; ghost.style.top = `${this.getTrackYPositionCached(clipRef.trackIndex) + 4 + tracksOffset}px`; this.buildSnapPointsForClip(clipRef); @@ -297,7 +299,7 @@ export class InteractionController implements TimelineInteractionRegistration { const mouseX = e.clientX - rect.left + scrollX; const mouseY = e.clientY - rect.top + this.tracksContainer.scrollTop; const clipX = mouseX - state.dragOffsetX; - let clipTime: Seconds = sec(Math.max(0, clipX / pps)); + let clipTime: Seconds = viewXToTime(clipX, pps); // 4. Determine drag target and apply snapping const dragTarget = this.getDragTargetAtYPosition(mouseY); @@ -418,14 +420,14 @@ export class InteractionController implements TimelineInteractionRegistration { const targetTrackY = this.getTrackYPositionCached(state.dragTarget.trackIndex) + 4; const targetHeight = getTrackHeight(targetTrack.primaryAssetType) - 8; - ghost.style.left = `${clipTime * feedbackConfig.pixelsPerSecond}px`; // eslint-disable-line no-param-reassign -- DOM manipulation + 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 ghost.style.height = `${targetHeight}px`; // eslint-disable-line no-param-reassign -- DOM manipulation this.feedbackElements.dragTimeTooltip = showDragTimeTooltip( this.feedbackElements, clipTime, - clipTime * feedbackConfig.pixelsPerSecond, + timeToViewX(clipTime, feedbackConfig.pixelsPerSecond), targetTrackY + feedbackConfig.tracksOffset ); hideDropZone(this.feedbackElements.dropZone); @@ -446,7 +448,7 @@ export class InteractionController implements TimelineInteractionRegistration { const feedbackConfig = { pixelsPerSecond: pps, scrollLeft: scrollX, tracksOffset }; const x = e.clientX - rect.left + scrollX; - let time: Seconds = sec(Math.max(0, x / pps)); + let time: Seconds = viewXToTime(x, pps); // Apply snapping const snappedTime = this.applySnap(time); @@ -702,7 +704,7 @@ export class InteractionController implements TimelineInteractionRegistration { const pps = this.stateManager.getViewport().pixelsPerSecond; const x = e.clientX - rect.left + scrollX; - let time: Seconds = sec(Math.max(0, x / pps)); + let time: Seconds = viewXToTime(x, pps); // Apply snapping const snappedTime = this.applySnap(time); diff --git a/src/components/timeline/interaction/interaction-feedback.ts b/src/components/timeline/interaction/interaction-feedback.ts index 15e94e61..57b9a08b 100644 --- a/src/components/timeline/interaction/interaction-feedback.ts +++ b/src/components/timeline/interaction/interaction-feedback.ts @@ -1,9 +1,9 @@ -import type { Seconds } from "@core/timing/types"; +import { type Seconds, sec } from "@core/timing/types"; import type { ClipState } from "../timeline.types"; import { getTrackHeight } from "../timeline.types"; -import { formatDragTime, secondsToPixels } from "./interaction-calculations"; +import { formatDragTime, secondsToPixels, timeToViewX } from "./interaction-calculations"; // ─── Types ───────────────────────────────────────────────────────────────── @@ -59,7 +59,7 @@ export function getOrCreateElement(container: HTMLElement, existing: HTMLElement export function showSnapLine(elements: FeedbackElements, time: Seconds, config: FeedbackConfig): HTMLElement { const snapLine = getOrCreateElement(elements.container, elements.snapLine, "ss-snap-line"); - const x = secondsToPixels(time, config.pixelsPerSecond) - config.scrollLeft; + const x = timeToViewX(time, config.pixelsPerSecond) - config.scrollLeft; snapLine.style.left = `${x}px`; snapLine.style.display = "block"; return snapLine; @@ -111,7 +111,7 @@ export function showLumaConnectionLine( pixelsPerSecond: number ): HTMLElement { const line = getOrCreateElement(elements.container, elements.lumaConnectionLine, "ss-luma-connection-line"); - const clipX = secondsToPixels(targetClip.config.start, pixelsPerSecond); + const clipX = timeToViewX(sec(targetClip.config.start), pixelsPerSecond); line.style.left = `${clipX}px`; line.style.top = `${trackYPosition + tracksOffset}px`; line.style.height = `${trackHeight}px`; diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts index a5376ebf..d42a3667 100644 --- a/src/components/timeline/timeline.ts +++ b/src/components/timeline/timeline.ts @@ -5,7 +5,7 @@ import { computeAiAssetNumber, type ResolvedClipWithId } from "@core/shared/ai-a import { inferAssetTypeFromUrl } from "@core/shared/asset-utils"; import { type Seconds, sec } from "@core/timing/types"; import { injectShotstackStyles } from "@styles/inject"; -import { DEFAULT_PIXELS_PER_SECOND, type ClipRenderer, type ClipInfo } from "@timeline/timeline.types"; +import { DEFAULT_PIXELS_PER_SECOND, TIMELINE_PADDING, type ClipRenderer, type ClipInfo } from "@timeline/timeline.types"; import { PlayheadComponent } from "./components/playhead/playhead-component"; import { RulerComponent } from "./components/ruler/ruler-component"; @@ -131,6 +131,9 @@ export class Timeline { // Inject styles injectShotstackStyles(); + // Set timeline padding CSS variable from single source of truth + this.element.style.setProperty("--ss-timeline-padding", `${TIMELINE_PADDING}px`); + // Mount to container first so we can measure this.container.appendChild(this.element); @@ -341,7 +344,7 @@ export class Timeline { this.resizeHandle = createTimelineResizeHandle({ container: this.container, onResize: () => this.resize(), - onResizeEnd: (height) => { + onResizeEnd: height => { this.edit.getInternalEvents().emit(EditEvent.TimelineResized, { height }); } }); diff --git a/src/components/timeline/timeline.types.ts b/src/components/timeline/timeline.types.ts index ab1f5706..1c76e851 100644 --- a/src/components/timeline/timeline.types.ts +++ b/src/components/timeline/timeline.types.ts @@ -82,6 +82,9 @@ export interface InteractionQuery { /** Default timeline settings */ export const DEFAULT_PIXELS_PER_SECOND = 50; +/** Horizontal padding (px) at the start and end of the timeline content area */ +export const TIMELINE_PADDING = 16; + /** Track heights by asset type */ export const TRACK_HEIGHTS: Record = { video: 72, diff --git a/src/styles/timeline/timeline.css b/src/styles/timeline/timeline.css index 01784538..e1564858 100644 --- a/src/styles/timeline/timeline.css +++ b/src/styles/timeline/timeline.css @@ -149,6 +149,8 @@ .ss-ruler-content { position: relative; height: 100%; + margin-left: var(--ss-timeline-padding); + margin-right: var(--ss-timeline-padding); } .ss-ruler-marker { @@ -193,6 +195,8 @@ .ss-tracks-content { position: relative; min-height: 100%; + margin-left: var(--ss-timeline-padding); + margin-right: var(--ss-timeline-padding); } /* Track - height set dynamically via inline style */