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
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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`;
Expand Down
6 changes: 4 additions & 2 deletions src/components/timeline/components/ruler/ruler-component.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 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 { getTrackHeight } from "../../timeline.types";
import { TIMELINE_PADDING, getTrackHeight } from "../../timeline.types";

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

Expand Down Expand Up @@ -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);
}
Expand Down
12 changes: 11 additions & 1 deletion 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 { getTrackHeight } from "../timeline.types";
import { TIMELINE_PADDING, getTrackHeight } from "../timeline.types";

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

Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 9 additions & 7 deletions src/components/timeline/interaction/interaction-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import {
findNearestSnapPoint,
getDragTargetAtY,
getTrackYPosition,
resolveClipCollision
resolveClipCollision,
timeToViewX,
viewXToTime
} from "./interaction-calculations";
import {
type FeedbackConfig,
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions src/components/timeline/interaction/interaction-feedback.ts
Original file line number Diff line number Diff line change
@@ -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 ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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`;
Expand Down
7 changes: 5 additions & 2 deletions src/components/timeline/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 });
}
});
Expand Down
3 changes: 3 additions & 0 deletions src/components/timeline/timeline.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
video: 72,
Expand Down
4 changes: 4 additions & 0 deletions src/styles/timeline/timeline.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 */
Expand Down
Loading