Merge pull request #120 from IdrisGit/feat-add-support-for-16-10-aspect-ratio
feat: add support for 16:10 aspect ratio
This commit is contained in:
@@ -17,7 +17,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
|
||||
import { type AspectRatio, getAspectRatioLabel, ASPECT_RATIOS } from "@/utils/aspectRatioUtils";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { TutorialHelp } from "../TutorialHelp";
|
||||
|
||||
@@ -155,13 +155,13 @@ function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
||||
return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function PlaybackCursor({
|
||||
currentTimeMs,
|
||||
function PlaybackCursor({
|
||||
currentTimeMs,
|
||||
videoDurationMs,
|
||||
onSeek,
|
||||
timelineRef,
|
||||
}: {
|
||||
currentTimeMs: number;
|
||||
}: {
|
||||
currentTimeMs: number;
|
||||
videoDurationMs: number;
|
||||
onSeek?: (time: number) => void;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -175,14 +175,14 @@ function PlaybackCursor({
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!timelineRef.current || !onSeek) return;
|
||||
|
||||
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
|
||||
|
||||
// Allow dragging outside to 0 or max, but clamp the value
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
|
||||
|
||||
onSeek(absoluteMs / 1000);
|
||||
};
|
||||
|
||||
@@ -207,7 +207,7 @@ function PlaybackCursor({
|
||||
}
|
||||
|
||||
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
|
||||
|
||||
|
||||
if (clampedTime < range.start || clampedTime > range.end) {
|
||||
return null;
|
||||
}
|
||||
@@ -276,7 +276,7 @@ function TimelineAxis({
|
||||
if (visibleStart <= maxTime) {
|
||||
markerTimes.add(Math.round(visibleStart));
|
||||
}
|
||||
|
||||
|
||||
if (videoDurationMs > 0) {
|
||||
markerTimes.add(Math.round(videoDurationMs));
|
||||
}
|
||||
@@ -288,7 +288,7 @@ function TimelineAxis({
|
||||
// Generate minor ticks (4 ticks between major intervals)
|
||||
const minorTicks = [];
|
||||
const minorInterval = intervalMs / 5;
|
||||
|
||||
|
||||
for (let time = firstMarker; time <= maxTime; time += minorInterval) {
|
||||
if (time >= visibleStart && time <= visibleEnd) {
|
||||
// Skip if it's close to a major marker
|
||||
@@ -299,12 +299,12 @@ function TimelineAxis({
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
return {
|
||||
markers: sorted.map((time) => ({
|
||||
time,
|
||||
label: formatTimeLabel(time, intervalMs),
|
||||
})),
|
||||
minorTicks
|
||||
})),
|
||||
minorTicks
|
||||
};
|
||||
}, [intervalMs, range.end, range.start, videoDurationMs]);
|
||||
|
||||
@@ -395,7 +395,7 @@ function Timeline({
|
||||
|
||||
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!onSeek || videoDurationMs <= 0) return;
|
||||
|
||||
|
||||
// Only clear selection if clicking on empty space (not on items)
|
||||
// This is handled by event propagation - items stop propagation
|
||||
onSelectZoom?.(null);
|
||||
@@ -404,13 +404,13 @@ function Timeline({
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
|
||||
|
||||
if (clickX < 0) return;
|
||||
|
||||
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
const timeInSeconds = absoluteMs / 1000;
|
||||
|
||||
|
||||
onSeek(timeInSeconds);
|
||||
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
|
||||
|
||||
@@ -427,13 +427,13 @@ function Timeline({
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
||||
<TimelineAxis intervalMs={intervalMs} videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||
<PlaybackCursor
|
||||
currentTimeMs={currentTimeMs}
|
||||
videoDurationMs={videoDurationMs}
|
||||
<PlaybackCursor
|
||||
currentTimeMs={currentTimeMs}
|
||||
videoDurationMs={videoDurationMs}
|
||||
onSeek={onSeek}
|
||||
timelineRef={localTimelineRef}
|
||||
/>
|
||||
|
||||
|
||||
<Row id={ZOOM_ROW_ID}>
|
||||
{zoomItems.map((item) => (
|
||||
<Item
|
||||
@@ -711,7 +711,7 @@ export default function TimelineEditor({
|
||||
// Multiple annotations can exist at the same timestamp
|
||||
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
const endPos = Math.min(startPos + defaultDuration, totalMs);
|
||||
|
||||
|
||||
onAnnotationAdded({ start: startPos, end: endPos });
|
||||
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded]);
|
||||
|
||||
@@ -733,29 +733,29 @@ export default function TimelineEditor({
|
||||
if (e.key === 'a' || e.key === 'A') {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
|
||||
|
||||
// Tab: Cycle through overlapping annotations at current time
|
||||
if (e.key === 'Tab' && annotationRegions.length > 0) {
|
||||
const currentTimeMs = Math.round(currentTime * 1000);
|
||||
const overlapping = annotationRegions
|
||||
.filter(a => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs)
|
||||
.sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index
|
||||
|
||||
|
||||
if (overlapping.length > 0) {
|
||||
e.preventDefault();
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedAnnotationId || !overlapping.some(a => a.id === selectedAnnotationId)) {
|
||||
onSelectAnnotation?.(overlapping[0].id);
|
||||
} else {
|
||||
// Cycle to next annotation
|
||||
const currentIndex = overlapping.findIndex(a => a.id === selectedAnnotationId);
|
||||
const nextIndex = e.shiftKey
|
||||
const nextIndex = e.shiftKey
|
||||
? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab = backward
|
||||
: (currentIndex + 1) % overlapping.length; // Tab = forward
|
||||
onSelectAnnotation?.(overlapping[nextIndex].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) {
|
||||
if (selectedKeyframeId) {
|
||||
deleteSelectedKeyframe();
|
||||
@@ -803,7 +803,7 @@ export default function TimelineEditor({
|
||||
|
||||
const annotations: TimelineRenderItem[] = annotationRegions.map((region) => {
|
||||
let label: string;
|
||||
|
||||
|
||||
if (region.type === 'text') {
|
||||
// Show text preview
|
||||
const preview = region.content.trim() || 'Empty text';
|
||||
@@ -813,7 +813,7 @@ export default function TimelineEditor({
|
||||
} else {
|
||||
label = 'Annotation';
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
rowId: ANNOTATION_ROW_ID,
|
||||
@@ -896,7 +896,7 @@ export default function TimelineEditor({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-white/10">
|
||||
{(['16:9', '9:16', '1:1', '4:3', '4:5'] as AspectRatio[]).map((ratio) => (
|
||||
{ASPECT_RATIOS.map((ratio) => (
|
||||
<DropdownMenuItem
|
||||
key={ratio}
|
||||
onClick={() => onAspectRatioChange(ratio)}
|
||||
@@ -918,7 +918,7 @@ export default function TimelineEditor({
|
||||
<span>Pan</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.zoom}</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.zoom}</kbd>
|
||||
<span>Zoom</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export type AspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '4:5';
|
||||
export const ASPECT_RATIOS = ['16:9', '9:16', '1:1', '4:3', '4:5', '16:10', '10:16'] as const;
|
||||
|
||||
export type AspectRatio = typeof ASPECT_RATIOS[number];
|
||||
|
||||
/**
|
||||
* Returns the numeric value of an aspect ratio.
|
||||
* Uses exhaustive type checking to ensure all AspectRatio cases are handled.
|
||||
* If TypeScript errors here, a new ratio was added to the type but not handled.
|
||||
*/
|
||||
export function getAspectRatioValue(aspectRatio: AspectRatio): number {
|
||||
switch (aspectRatio) {
|
||||
case '16:9': return 16 / 9;
|
||||
@@ -7,6 +14,13 @@ export function getAspectRatioValue(aspectRatio: AspectRatio): number {
|
||||
case '1:1': return 1;
|
||||
case '4:3': return 4 / 3;
|
||||
case '4:5': return 4 / 5;
|
||||
case '16:10': return 16 / 10;
|
||||
case '10:16': return 10 / 16;
|
||||
default: {
|
||||
// Ensures all cases are handled - TypeScript errors if missing
|
||||
const _exhaustiveCheck: never = aspectRatio;
|
||||
return _exhaustiveCheck;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +42,4 @@ export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
|
||||
|
||||
export function formatAspectRatioForCSS(aspectRatio: AspectRatio): string {
|
||||
return aspectRatio.replace(':', '/');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user