mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
Timeline UI - Highlight clips visible at the playhead in the primary color; others share one neutral color - Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels - Per-track eye toggle and a per-element hide button in the design panel - Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom - Sticky gutter so track controls stay visible while scrolling WYSIWYG visibility (data-hidden) - Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview - HTML stays the source of truth; hide state persists and round-trips on reload Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
212 lines
8.0 KiB
TypeScript
212 lines
8.0 KiB
TypeScript
import { formatTime } from "../lib/time";
|
|
import type { ZoomMode } from "../store/playerStore";
|
|
|
|
/* ── Layout constants ──────────────────────────────────────────────── */
|
|
export const GUTTER = 32;
|
|
export const TRACK_H = 48;
|
|
export const RULER_H = 24;
|
|
export const CLIP_Y = 3;
|
|
export const CLIP_HANDLE_W = 18;
|
|
const TIMELINE_SCROLL_BUFFER = 20;
|
|
|
|
/* ── Tick generation ──────────────────────────────────────────────── */
|
|
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
|
|
const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
|
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
|
const targetMajorPx = 88;
|
|
return (
|
|
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
|
|
);
|
|
}
|
|
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
|
const target = duration / 6;
|
|
return durationIntervals.find((interval) => interval >= target) ?? 60;
|
|
}
|
|
|
|
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
|
|
if (
|
|
Number.isFinite(pixelsPerSecond) &&
|
|
(pixelsPerSecond ?? 0) > 0 &&
|
|
(majorInterval / 2) * (pixelsPerSecond ?? 0) < 12
|
|
) {
|
|
return 0;
|
|
}
|
|
return majorInterval / 2;
|
|
}
|
|
|
|
export function generateTicks(
|
|
duration: number,
|
|
pixelsPerSecond?: number,
|
|
): { major: number[]; minor: number[] } {
|
|
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
|
|
return { major: [], minor: [] };
|
|
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
|
|
const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
|
|
const major: number[] = [];
|
|
const minor: number[] = [];
|
|
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
|
for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
|
|
const rounded = Math.round(t * 100) / 100;
|
|
major.push(rounded);
|
|
if (minorInterval > 0 && major.length + minor.length < maxTicks) {
|
|
const midpoint = Math.round((t + minorInterval) * 100) / 100;
|
|
if (midpoint <= duration + 0.001) minor.push(midpoint);
|
|
}
|
|
}
|
|
return { major, minor };
|
|
}
|
|
|
|
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
|
if (!Number.isFinite(time)) return "0:00";
|
|
const safeTime = Math.max(0, time);
|
|
if (majorInterval < 0.1) {
|
|
const totalHundredths = Math.round(safeTime * 100);
|
|
const wholeSeconds = Math.floor(totalHundredths / 100);
|
|
const hundredth = totalHundredths % 100;
|
|
return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
|
|
}
|
|
if (majorInterval < 1) {
|
|
const totalTenths = Math.round(safeTime * 10);
|
|
const wholeSeconds = Math.floor(totalTenths / 10);
|
|
const tenth = totalTenths % 10;
|
|
return `${formatTime(wholeSeconds)}.${tenth}`;
|
|
}
|
|
if (duration >= 3600 || safeTime >= 3600) {
|
|
const totalSeconds = Math.floor(safeTime);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
}
|
|
return formatTime(safeTime);
|
|
}
|
|
|
|
/* ── Scroll / zoom helpers ────────────────────────────────────────── */
|
|
export function shouldAutoScrollTimeline(
|
|
zoomMode: ZoomMode,
|
|
scrollWidth: number,
|
|
clientWidth: number,
|
|
): boolean {
|
|
if (zoomMode === "fit") return false;
|
|
if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false;
|
|
return scrollWidth - clientWidth > 1;
|
|
}
|
|
|
|
export function getTimelineScrollLeftForZoomTransition(
|
|
previousZoomMode: ZoomMode | null,
|
|
nextZoomMode: ZoomMode,
|
|
currentScrollLeft: number,
|
|
): number {
|
|
if (nextZoomMode === "fit") return 0;
|
|
return currentScrollLeft;
|
|
}
|
|
|
|
export function getTimelineScrollLeftForZoomAnchor(input: {
|
|
pointerX: number;
|
|
currentScrollLeft: number;
|
|
gutter: number;
|
|
currentPixelsPerSecond: number;
|
|
nextPixelsPerSecond: number;
|
|
duration: number;
|
|
}): number {
|
|
const currentPps = Math.max(0, input.currentPixelsPerSecond);
|
|
const nextPps = Math.max(0, input.nextPixelsPerSecond);
|
|
if (
|
|
!Number.isFinite(input.pointerX) ||
|
|
!Number.isFinite(input.currentScrollLeft) ||
|
|
!Number.isFinite(input.duration) ||
|
|
input.duration <= 0 ||
|
|
currentPps <= 0 ||
|
|
nextPps <= 0
|
|
) {
|
|
return Math.max(0, input.currentScrollLeft);
|
|
}
|
|
const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
|
|
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
|
|
return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
|
|
}
|
|
|
|
/* ── Playhead / canvas ────────────────────────────────────────────── */
|
|
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
|
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
|
|
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
|
|
}
|
|
|
|
export function getTimelineCanvasHeight(trackCount: number): number {
|
|
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
|
}
|
|
|
|
/* ── UI helpers ───────────────────────────────────────────────────── */
|
|
export function shouldShowTimelineShortcutHint(
|
|
scrollHeight: number,
|
|
clientHeight: number,
|
|
): boolean {
|
|
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
|
|
return scrollHeight - clientHeight <= 1;
|
|
}
|
|
|
|
export function shouldHandleTimelineDeleteKey(input: {
|
|
key: string;
|
|
metaKey?: boolean;
|
|
ctrlKey?: boolean;
|
|
altKey?: boolean;
|
|
target?: EventTarget | null;
|
|
}): boolean {
|
|
if (input.key !== "Delete" && input.key !== "Backspace") return false;
|
|
if (input.metaKey || input.ctrlKey || input.altKey) return false;
|
|
const target =
|
|
input.target && typeof input.target === "object"
|
|
? (input.target as {
|
|
tagName?: string;
|
|
isContentEditable?: boolean;
|
|
closest?: (selector: string) => Element | null;
|
|
})
|
|
: null;
|
|
if (target) {
|
|
const tag = target.tagName?.toLowerCase() ?? "";
|
|
if (target.isContentEditable) return false;
|
|
if (["input", "textarea", "select"].includes(tag)) return false;
|
|
if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* ── Asset drop ───────────────────────────────────────────────────── */
|
|
export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
|
|
if (trackOrder.length === 0) return 0;
|
|
if (rowIndex == null || rowIndex < 0) return trackOrder[0];
|
|
if (rowIndex >= trackOrder.length) {
|
|
return Math.max(...trackOrder) + 1;
|
|
}
|
|
return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0;
|
|
}
|
|
|
|
export function resolveTimelineAssetDrop(
|
|
input: {
|
|
rectLeft: number;
|
|
rectTop: number;
|
|
scrollLeft: number;
|
|
scrollTop: number;
|
|
pixelsPerSecond: number;
|
|
duration: number;
|
|
trackHeight: number;
|
|
trackOrder: number[];
|
|
},
|
|
clientX: number,
|
|
clientY: number,
|
|
): { start: number; track: number } {
|
|
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
|
|
const y = clientY - input.rectTop + input.scrollTop - RULER_H;
|
|
const start = Math.max(
|
|
0,
|
|
Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
|
|
);
|
|
const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1));
|
|
return {
|
|
start,
|
|
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
|
};
|
|
}
|