shipping this slop

This commit is contained in:
Maze Winther
2026-01-20 22:14:49 +01:00
parent 7117f17ece
commit 6e4069b42f
97 changed files with 3357 additions and 1858 deletions
@@ -3,8 +3,7 @@ import { PropertyGroup } from "../../properties-panel/property-item";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import { LanguageSelect } from "@/components/language-select";
import { useState, useRef, useEffect } from "react";
import { extractTimelineAudio } from "@/lib/mediabunny-utils";
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { LANGUAGES } from "@/constants/captions-constants";
@@ -136,8 +135,8 @@ export function Captions() {
});
shortCaptions.forEach((caption, index) => {
editor.timeline.addElementToTrack({
trackId: captionTrackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId: captionTrackId },
element: {
...DEFAULT_TEXT_ELEMENT,
name: `Caption ${index + 1}`,
@@ -11,16 +11,16 @@ import {
Music,
Video,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { processMediaAssets } from "@/lib/media-processing-utils";
import { processMediaAssets } from "@/lib/media/processing";
import { cn } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { canElementGoOnTrack } from "@/lib/timeline/track-utils";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import type { MediaAsset } from "@/types/assets";
import type { CreateTimelineElement, TrackType } from "@/types/timeline";
import type { CreateTimelineElement } from "@/types/timeline";
import { Button } from "@/components/ui/button";
import { MediaDragOverlay } from "@/components/editor/assets-panel/drag-overlay";
import {
@@ -42,7 +42,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { usePanelStore } from "@/stores/panel-store";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
export function MediaView() {
@@ -50,8 +49,7 @@ export function MediaView() {
const mediaFiles = editor.media.getAssets();
const activeProject = editor.project.getActive();
const { mediaViewMode, setMediaViewMode } = usePanelStore();
const { highlightMediaId, clearHighlight } = useAssetsPanelStore();
const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } = useAssetsPanelStore();
const { highlightedId, registerElement } = useRevealItem(
highlightMediaId,
clearHighlight,
@@ -129,39 +127,9 @@ export function MediaView() {
startTime: number;
}): boolean => {
const element = createElementFromMedia({ asset, startTime });
const trackType = getTrackTypeForMedia({ mediaType: asset.type });
const tracks = editor.timeline.getTracks();
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const existingTrack = tracks.find((track) => {
if (
!canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
})
) {
return false;
}
return !wouldElementOverlap({
elements: track.elements,
startTime,
endTime: startTime + duration,
});
});
if (existingTrack) {
editor.timeline.addElementToTrack({
trackId: existingTrack.id,
element,
});
return true;
}
const newTrackId = editor.timeline.addTrack({ type: trackType });
editor.timeline.addElementToTrack({
trackId: newTrackId,
editor.timeline.insertElement({
element,
placement: { mode: "auto" },
});
return true;
};
@@ -531,12 +499,58 @@ function ListView({
);
}
const formatDuration = ({ duration }: { duration: number }) => {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
function MediaDurationBadge({ duration }: { duration?: number }) {
if (!duration) return null;
return (
<div className="absolute bottom-1 right-1 rounded bg-black/70 px-1 text-xs text-white">
{formatDuration({ duration })}
</div>
);
}
function MediaDurationLabel({ duration }: { duration?: number }) {
if (!duration) return null;
return (
<span className="text-xs opacity-70">{formatDuration({ duration })}</span>
);
}
function MediaTypePlaceholder({
icon: Icon,
label,
duration,
variant,
}: {
icon: LucideIcon;
label: string;
duration?: number;
variant: "muted" | "bordered";
}) {
const iconClassName = cn("size-6", variant === "bordered" && "mb-1");
return (
<div
className={cn(
"text-muted-foreground flex size-full flex-col items-center justify-center rounded",
variant === "muted" ? "bg-muted/30" : "border",
)}
>
<Icon className={iconClassName} aria-hidden="true" />
<span className="text-xs">{label}</span>
<MediaDurationLabel duration={duration} />
</div>
);
}
function MediaPreview({ item }: { item: MediaAsset }) {
const formatDuration = (duration: number) => {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
if (item.type === "image") {
return (
@@ -564,47 +578,38 @@ function MediaPreview({ item }: { item: MediaAsset }) {
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
<Video className="size-6 text-white drop-shadow-md" />
</div>
{item.duration && (
<div className="absolute bottom-1 right-1 rounded bg-black/70 px-1 text-xs text-white">
{formatDuration(item.duration)}
</div>
)}
<MediaDurationBadge duration={item.duration} />
</div>
);
}
return (
<div className="bg-muted/30 text-muted-foreground flex size-full flex-col items-center justify-center rounded">
<Video className="mb-1 size-6" />
<span className="text-xs">Video</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
<MediaTypePlaceholder
icon={Video}
label="Video"
duration={item.duration}
variant="muted"
/>
);
}
if (item.type === "audio") {
return (
<div className="bg-linear-to-br text-muted-foreground flex size-full flex-col items-center justify-center rounded border border-green-500/20 from-green-500/20 to-emerald-500/20">
<Music className="mb-1 size-6" />
<span className="text-xs">Audio</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
<MediaTypePlaceholder
icon={Music}
label="Audio"
duration={item.duration}
variant="bordered"
/>
);
}
return (
<div className="bg-muted/30 text-muted-foreground flex size-full flex-col items-center justify-center rounded">
<Image className="size-6" />
<span className="mt-1 text-xs">Unknown</span>
</div>
<MediaTypePlaceholder
icon={Image}
label="Unknown"
variant="muted"
/>
);
}
@@ -631,22 +636,6 @@ function SortMenuItem({
);
}
function getTrackTypeForMedia({
mediaType,
}: {
mediaType: MediaAsset["type"];
}): TrackType {
switch (mediaType) {
case "video":
case "image":
return "video";
case "audio":
return "audio";
default:
return "video";
}
}
function createElementFromMedia({
asset,
startTime,
@@ -697,7 +686,6 @@ function createElementFromMedia({
trimEnd: 0,
volume: 1,
muted: false,
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
};
default:
throw new Error(`Unsupported media type: ${asset.type}`);
@@ -124,14 +124,23 @@ function ProjectInfoView() {
const currentCanvasSize = getCurrentCanvasSize({ activeProject });
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
const presetIndex = findPresetIndexByAspectRatio({
presets: canvasPresets,
targetAspectRatio: currentAspectRatio,
});
const selectedPresetIndex =
presetIndex !== -1 ? presetIndex.toString() : undefined;
const originalPresetValue = "original";
const selectedPresetValue =
presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
const handleAspectRatioChange = ({ value }: { value: string }) => {
if (value === originalPresetValue) {
const canvasSize = originalCanvasSize ?? currentCanvasSize;
editor.project.updateSettings({
settings: { canvasSize },
});
return;
}
const index = parseInt(value, 10);
const preset = canvasPresets[index];
if (preset) {
@@ -157,13 +166,14 @@ function ProjectInfoView() {
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
<PropertyItemValue>
<Select
value={selectedPresetIndex}
value={selectedPresetValue}
onValueChange={(value) => handleAspectRatioChange({ value })}
>
<SelectTrigger className="bg-panel-accent">
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
<SelectItem value={originalPresetValue}>Original</SelectItem>
{canvasPresets.map((preset, index) => {
const label = dimensionToAspectRatio({
width: preset.width,
@@ -262,18 +272,18 @@ const BackgroundPreviews = memo(
className={cn(
"border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border",
isColorBackground &&
bg === currentBackgroundColor &&
"border-primary border-2",
bg === currentBackgroundColor &&
"border-primary border-2",
)}
style={
useBackgroundColor
? { backgroundColor: bg }
: {
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
}
onClick={() => handleColorSelect({ bg })}
/>
@@ -53,25 +53,25 @@ export function StickersView() {
{
value: "all",
label: "All",
icon: <Grid3X3 className="h-3 w-3" />,
icon: <Grid3X3 className="size-3" />,
content: <StickersContentView category="all" />,
},
{
value: "general",
label: "Icons",
icon: <Sparkles className="h-3 w-3" />,
icon: <Sparkles className="size-3" />,
content: <StickersContentView category="general" />,
},
{
value: "brands",
label: "Brands",
icon: <Hash className="h-3 w-3" />,
icon: <Hash className="size-3" />,
content: <StickersContentView category="brands" />,
},
{
value: "emoji",
label: "Emoji",
icon: <Smile className="h-3 w-3" />,
icon: <Smile className="size-3" />,
content: <StickersContentView category="emoji" />,
},
]}
@@ -328,7 +328,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
{recentStickers.length > 0 && viewMode === "browse" && (
<div className="h-full">
<div className="mb-2 flex items-center gap-2">
<Clock className="text-muted-foreground h-4 w-4" />
<Clock className="text-muted-foreground size-4" />
<span className="text-sm font-medium">Recent</span>
<TooltipProvider>
<Tooltip>
@@ -337,7 +337,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
onClick={clearRecentStickers}
className="hover:bg-accent ml-auto flex h-5 w-5 items-center justify-center rounded p-0"
>
<X className="text-muted-foreground h-3 w-3" />
<X className="text-muted-foreground size-3" />
</button>
</TooltipTrigger>
<TooltipContent>
@@ -359,7 +359,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="h-full">
{isLoadingCollection ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : showCollectionItems ? (
<StickerGrid
@@ -379,7 +379,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="h-full">
{isSearching ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : searchResults?.icons.length ? (
<>
@@ -426,7 +426,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="h-full space-y-4">
{isLoadingCollections ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : (
<>
@@ -482,7 +482,7 @@ function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
<p className="font-medium">{title}</p>
<p className="text-muted-foreground text-xs">{subtitle}</p>
</div>
<ArrowRight className="h-4 w-4" />
<ArrowRight className="size-4" />
</Button>
);
}
@@ -583,7 +583,7 @@ function StickerItem({
/>
{isAdding && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
<Loader2 className="h-6 w-6 animate-spin text-white" />
<Loader2 className="size-6 animate-spin text-white" />
</div>
)}
</div>
@@ -15,13 +15,11 @@ export function TextView() {
raw: DEFAULT_TEXT_ELEMENT,
startTime: currentTime,
});
const textTrack = activeScene.tracks.find((t) => t.type === "text");
if (textTrack) {
editor.timeline.addElementToTrack({
trackId: textTrack.id,
element,
});
}
editor.timeline.insertElement({
element,
placement: { mode: "auto" },
});
};
return (
@@ -22,7 +22,6 @@ import { RenameProjectDialog } from "../rename-project-dialog";
import { DeleteProjectDialog } from "../delete-project-dialog";
import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
import { PanelPresetSelector } from "./panel-preset-selector";
import { ExportButton } from "./export-button";
import { ThemeToggle } from "../theme-toggle";
import { SOCIAL_LINKS } from "@/constants/site-constants";
@@ -36,7 +35,6 @@ export function EditorHeader() {
<ProjectDropdown />
</div>
<nav className="flex items-center gap-2">
<PanelPresetSelector />
<KeyboardShortcutsHelp />
<ExportButton />
<ThemeToggle />
@@ -51,7 +49,7 @@ function ProjectDropdown() {
const [isExiting, setIsExiting] = useState(false);
const router = useRouter();
const editor = useEditor();
const activeProject = editor.project.getActiveOrNull();
const activeProject = editor.project.getActive();
const handleExit = async () => {
if (isExiting) return;
@@ -9,11 +9,7 @@ import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Progress } from "../ui/progress";
import { Checkbox } from "../ui/checkbox";
import { cn } from "@/lib/utils";
import {
exportProject,
getExportMimeType,
getExportFileExtension,
} from "@/lib/export-utils";
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
import { PropertyGroup } from "./properties-panel/property-item";
@@ -91,13 +87,15 @@ function ExportPopover({
setProgress(0);
setExportResult(null);
const result = await exportProject({
format,
quality,
fps: activeProject.settings.fps,
includeAudio,
onProgress: ({ progress }) => setProgress(progress),
onCancel: () => false, // TODO: add cancel functionality
const result = await editor.project.export({
options: {
format,
quality,
fps: activeProject.settings.fps,
includeAudio,
onProgress: ({ progress }) => setProgress(progress),
onCancel: () => false, // TODO: add cancel functionality
},
});
setIsExporting(false);
@@ -255,7 +253,7 @@ function ExportPopover({
<Button
variant="outline"
className="w-full rounded-md"
onClick={() => {}}
onClick={() => { }}
>
Cancel
</Button>
@@ -1,91 +0,0 @@
"use client";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
const PRESET_LABELS: Record<PanelPreset, string> = {
default: "Default",
media: "Media",
inspector: "Inspector",
"vertical-preview": "Vertical Preview",
};
const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = {
default: "Media, preview, and inspector on top row, timeline on bottom",
media: "Full height media on left, preview and inspector on top row",
inspector: "Full height inspector on right, media and preview on top row",
"vertical-preview": "Full height preview on right for vertical videos",
};
export function PanelPresetSelector() {
const { activePreset, setActivePreset, resetPreset } = usePanelStore();
const handlePresetChange = (preset: PanelPreset) => {
setActivePreset(preset);
};
const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => {
event.stopPropagation();
resetPreset(preset);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="sm"
className="h-8 px-2 flex items-center gap-1 text-xs"
title="Panel Presets"
>
<LayoutPanelTop className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">
Panel Presets
</div>
<DropdownMenuSeparator />
{(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
<DropdownMenuItem
key={preset}
onClick={() => handlePresetChange(preset)}
className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{PRESET_LABELS[preset]}
</span>
{activePreset === preset && (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-tight">
{PRESET_DESCRIPTIONS[preset]}
</p>
</div>
<Button
variant="secondary"
size="icon"
className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
onClick={(e) => handleResetPreset(preset, e)}
title={`Reset ${PRESET_LABELS[preset]} preset`}
>
<RotateCcw className="h-3 w-3" />
</Button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -18,7 +18,7 @@ import {
PropertyItemValue,
} from "./property-item";
import { ColorPicker } from "@/components/ui/color-picker";
import { cn, capitalizeFirstLetter, clamp } from "@/lib/utils";
import { cn, capitalizeFirstLetter, clamp, uppercase } from "@/lib/utils";
import { Grid2x2 } from "lucide-react";
import {
Tooltip,
@@ -296,7 +296,7 @@ export function TextProperties({
<PropertyItemLabel>Color</PropertyItemLabel>
<PropertyItemValue>
<ColorPicker
value={capitalizeFirstLetter({ string:
value={uppercase({ string:
(element.color || "FFFFFF").replace("#", "")
})}
onChange={(color) => {
@@ -27,11 +27,10 @@ export function TimelineBookmarksRow({
return (
<div
className="relative mt-0.5 h-4 flex-1 overflow-hidden"
className="relative h-4 flex-1 overflow-hidden"
onWheel={handleWheel}
onClick={handleTimelineContentClick}
onMouseDown={handleRulerTrackingMouseDown}
data-bookmarks-area
>
<ScrollArea className="scrollbar-hidden w-full" ref={bookmarksScrollRef}>
<div
@@ -29,6 +29,7 @@ import {
canTracktHaveAudio,
canTrackBeHidden,
isMainTrack,
getTimelineZoomMin,
} from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar";
import { useScrollSync } from "@/hooks/timeline/use-scroll-sync";
@@ -43,7 +44,7 @@ import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
export function Timeline() {
const tracksContainerHeight = { min: 200, max: 800 };
const tracksContainerHeight = { min: 0, max: 800 };
const { snappingEnabled } = useTimelineStore();
const { clearElementSelection, setElementSelection } = useElementSelection();
const editor = useEditor();
@@ -64,6 +65,7 @@ export function Timeline() {
// state
const [isInTimeline, setIsInTimeline] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
);
@@ -71,10 +73,26 @@ export function Timeline() {
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
setCurrentSnapPoint(snapPoint);
}, []);
const handleResizeStateChange = useCallback(
({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
setIsResizing(nextIsResizing);
if (!nextIsResizing) {
setCurrentSnapPoint(null);
}
},
[],
);
const timelineDuration = timeline.getTotalDuration() || 0;
const minZoomLevel = getTimelineZoomMin({
duration: timelineDuration,
containerWidth: timelineRef.current?.clientWidth,
});
const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
containerRef: timelineRef,
isInTimeline,
minZoom: minZoomLevel,
});
const {
@@ -88,6 +106,7 @@ export function Timeline() {
timelineRef,
tracksContainerRef,
tracksScrollRef,
snappingEnabled,
onSnapPointChange: handleSnapPointChange,
});
@@ -118,7 +137,6 @@ export function Timeline() {
zoomLevel,
});
const timelineDuration = timeline.getTotalDuration() || 0;
const paddedDuration =
timelineDuration + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS;
const dynamicTimelineWidth = Math.max(
@@ -127,7 +145,9 @@ export function Timeline() {
);
const showSnapIndicator =
dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
snappingEnabled &&
currentSnapPoint !== null &&
(dragState.isDragging || isResizing);
const {
handleTracksMouseDown,
@@ -164,6 +184,7 @@ export function Timeline() {
>
<TimelineToolbar
zoomLevel={zoomLevel}
minZoom={minZoomLevel}
setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
/>
@@ -197,7 +218,6 @@ export function Timeline() {
<div className="bg-panel flex h-4 w-28 shrink-0 items-center justify-between border-r px-3">
<span className="opacity-0">.</span>
</div>
<TimelineRuler
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
@@ -210,7 +230,7 @@ export function Timeline() {
/>
</div>
<div className="flex">
<div className="bg-panel flex h-6 w-28 shrink-0 items-center justify-between border-r px-3">
<div className="bg-panel flex h-4 w-28 shrink-0 items-center justify-between border-r px-3">
<span className="opacity-0">.</span>
</div>
<TimelineBookmarksRow
@@ -230,7 +250,7 @@ export function Timeline() {
<div
ref={trackLabelsRef}
className="z-100 bg-panel w-28 shrink-0 overflow-y-auto border-r"
data-track-labels
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP }}
>
<ScrollArea className="h-full w-full" ref={trackLabelsScrollRef}>
<div className="flex flex-col gap-1">
@@ -244,9 +264,9 @@ export function Timeline() {
>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
{/* Debug main track */}
{isMainTrack(track) && (
{/* {isMainTrack(track) && (
<div className="size-2 rounded-full bg-red-500" />
)}
)} */}
{canTracktHaveAudio(track) && (
<TrackToggleIcon
@@ -289,6 +309,7 @@ export function Timeline() {
<div
className="relative flex-1 overflow-hidden"
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP }}
onWheel={(e) => {
if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
return;
@@ -319,7 +340,23 @@ export function Timeline() {
isVisible={dragState.isDragging}
/>
<ScrollArea className="h-full w-full" ref={tracksScrollRef}>
<ScrollArea
className="h-full w-full"
ref={tracksScrollRef}
onMouseDown={(event) => {
const isDirectTarget = event.target === event.currentTarget;
if (!isDirectTarget) return;
event.stopPropagation();
handleTracksMouseDown(event);
handleSelectionMouseDown(event);
}}
onClick={(event) => {
const isDirectTarget = event.target === event.currentTarget;
if (!isDirectTarget) return;
event.stopPropagation();
handleTracksClick(event);
}}
>
<div
className="relative flex-1"
style={{
@@ -357,6 +394,8 @@ export function Timeline() {
rulerScrollRef={rulerScrollRef}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
onSnapPointChange={handleSnapPointChange}
onResizeStateChange={handleResizeStateChange}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
/>
@@ -12,11 +12,11 @@ import {
VolumeX,
ArrowUpDown,
} from "lucide-react";
import { useEffect, useRef } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import AudioWaveform from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
getTrackClasses,
@@ -47,6 +47,8 @@ interface TimelineElementProps {
track: TimelineTrack;
zoomLevel: number;
isSelected: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
onElementMouseDown: (
e: React.MouseEvent,
element: TimelineElementType,
@@ -60,12 +62,13 @@ export function TimelineElement({
track,
zoomLevel,
isSelected,
onSnapPointChange,
onResizeStateChange,
onElementMouseDown,
onElementClick,
dragState,
}: TimelineElementProps) {
const editor = useEditor();
const lastDragStateRef = useRef(false);
const { selectedElements } = useElementSelection();
const { requestRevealMedia } = useAssetsPanelStore();
@@ -88,6 +91,8 @@ export function TimelineElement({
element,
track,
zoomLevel,
onSnapPointChange,
onResizeStateChange,
});
const isCurrentElementSelected = selectedElements.some(
@@ -144,8 +149,6 @@ export function TimelineElement({
? `translate3d(0, ${dragOffsetY}px, 0)`
: undefined,
}}
data-element-id={element.id}
data-track-id={track.id}
>
<ElementInner
element={element}
@@ -307,28 +310,46 @@ function ElementInner({
{isSelected && (
<>
<div
className="bg-primary absolute bottom-0 left-0 top-0 z-50 flex w-[0.6rem] cursor-w-resize items-center justify-center"
onMouseDown={(e) =>
handleResizeStart({ e, elementId: element.id, side: "left" })
}
>
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
</div>
<div
className="bg-primary absolute bottom-0 right-0 top-0 z-50 flex w-[0.6rem] cursor-e-resize items-center justify-center"
onMouseDown={(e) =>
handleResizeStart({ e, elementId: element.id, side: "right" })
}
>
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
</div>
<ResizeHandle
side="left"
elementId={element.id}
handleResizeStart={handleResizeStart}
/>
<ResizeHandle
side="right"
elementId={element.id}
handleResizeStart={handleResizeStart}
/>
</>
)}
</div>
);
}
function ResizeHandle({
side,
elementId,
handleResizeStart,
}: {
side: "left" | "right";
elementId: string;
handleResizeStart: (params: {
e: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => void;
}) {
const isLeft = side === "left";
return (
<div
className={`bg-primary absolute bottom-0 top-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
>
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
</div>
);
}
function ElementContent({
element,
track,
@@ -362,13 +383,12 @@ function ElementContent({
}
if (element.type === "audio") {
const audioBuffer =
element.sourceType === "library" ? element.buffer : undefined;
const audioBuffer = element.sourceType === "library" ? element.buffer : undefined;
const audioUrl =
element.sourceType === "upload"
? mediaAssets.find((asset) => asset.id === element.mediaId)?.url
: undefined;
element.sourceType === "library"
? element.sourceUrl
: mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
if (audioBuffer || audioUrl) {
return (
@@ -99,7 +99,7 @@ export function TimelinePlayhead({
<div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
<div
className={`shadow-xs absolute left-1/2 top-1 h-3 w-3 -translate-x-1/2 transform rounded-full border-2 ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
className={`shadow-xs absolute left-1/2 top-1 size-3 -translate-x-1/2 transform rounded-full border-2 ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
/>
</div>
);
@@ -26,14 +26,12 @@ export function TimelineRuler({
handleRulerMouseDown,
}: TimelineRulerProps) {
const editor = useEditor();
const activeScene = editor.scenes.getActiveScene();
const duration = editor.timeline.getTotalDuration();
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDuration = Math.max(duration, visibleDuration);
const project = editor.project.getActiveOrNull();
const project = editor.project.getActive();
const fps = project?.settings.fps ?? DEFAULT_FPS;
const interval = getOptimalTimeInterval({ zoomLevel, fps });
const markerCount = Math.ceil(effectiveDuration / interval) + 1;
@@ -53,7 +51,6 @@ export function TimelineRuler({
onWheel={handleWheel}
onClick={handleTimelineContentClick}
onMouseDown={handleRulerTrackingMouseDown}
data-ruler-area
>
<ScrollArea className="scrollbar-hidden w-full" ref={rulerScrollRef}>
<div
@@ -41,9 +41,11 @@ import { useTimelineStore } from "@/stores/timeline-store";
export function TimelineToolbar({
zoomLevel,
minZoom,
setZoomLevel,
}: {
zoomLevel: number;
minZoom: number;
setZoomLevel: ({ zoom }: { zoom: number }) => void;
}) {
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
@@ -54,7 +56,7 @@ export function TimelineToolbar({
zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP,
)
: Math.max(
TIMELINE_CONSTANTS.ZOOM_MIN,
minZoom,
zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP,
);
setZoomLevel({ zoom: newZoomLevel });
@@ -68,6 +70,7 @@ export function TimelineToolbar({
<ToolbarRightSection
zoomLevel={zoomLevel}
minZoom={minZoom}
onZoomChange={(zoom) => setZoomLevel({ zoom })}
onZoom={handleZoom}
/>
@@ -229,7 +232,6 @@ function SceneSelector() {
<SplitButtonSeparator />
<ScenesView>
<SplitButtonRight
disabled={scenesCount === 1}
onClick={() => { }}
type="button"
>
@@ -243,10 +245,12 @@ function SceneSelector() {
function ToolbarRightSection({
zoomLevel,
minZoom,
onZoomChange,
onZoom,
}: {
zoomLevel: number;
minZoom: number;
onZoomChange: (zoom: number) => void;
onZoom: (options: { direction: "in" | "out" }) => void;
}) {
@@ -283,7 +287,7 @@ function ToolbarRightSection({
className="w-24"
value={[zoomLevel]}
onValueChange={(values) => onZoomChange(values[0])}
min={TIMELINE_CONSTANTS.ZOOM_MIN}
min={minZoom}
max={TIMELINE_CONSTANTS.ZOOM_MAX}
step={TIMELINE_CONSTANTS.ZOOM_STEP}
/>
@@ -4,10 +4,12 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-select
import { TimelineElement } from "./timeline-element";
import { TimelineTrack } from "@/types/timeline";
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { ElementDragState } from "@/types/timeline";
import { useEditor } from "@/hooks/use-editor";
import { cn } from "@/lib/utils";
interface TimelineTrackContentProps {
track: TimelineTrack;
@@ -16,6 +18,8 @@ interface TimelineTrackContentProps {
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
lastMouseXRef: React.RefObject<number>;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
onElementMouseDown: (params: {
event: React.MouseEvent;
element: TimelineElementType;
@@ -35,6 +39,8 @@ export function TimelineTrackContent({
rulerScrollRef,
tracksScrollRef,
lastMouseXRef,
onSnapPointChange,
onResizeStateChange,
onElementMouseDown,
onElementClick,
}: TimelineTrackContentProps) {
@@ -51,9 +57,13 @@ export function TimelineTrackContent({
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
});
const hasSelectedElements = track.elements.some((element) =>
isElementSelected({ trackId: track.id, elementId: element.id })
);
return (
<div
className="size-full"
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
onClick={clearElementSelection}
>
<div className="relative h-full min-w-full">
@@ -74,6 +84,8 @@ export function TimelineTrackContent({
track={track}
zoomLevel={zoomLevel}
isSelected={isSelected}
onSnapPointChange={onSnapPointChange}
onResizeStateChange={onResizeStateChange}
onElementMouseDown={(event, element) =>
onElementMouseDown({ event, element, track })
}
@@ -129,7 +129,7 @@ export function KeyboardShortcutsHelp() {
<h3 className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
{category}
</h3>
<div className="space-y-0.5">
<div className="space-y-1">
{shortcuts
.filter((shortcut) => shortcut.category === category)
.map((shortcut) => (
@@ -147,12 +147,13 @@ export function KeyboardShortcutsHelp() {
))}
</div>
</div>
<DialogFooter className="flex-shrink-0 p-6 pt-4">
<DialogFooter className="p-4 pt-0">
<Button size="sm" variant="destructive" onClick={resetToDefaults}>
Reset to Default
Reset to default
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -185,10 +186,10 @@ function ShortcutItem({
)}
<span className="text-sm">{shortcut.description}</span>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
{displayKeys.map((key: string, index: number) => (
<div key={key} className="flex items-center gap-1">
<div className="flex items-center">
<div key={key} className="flex items-center gap-2">
<div className="flex items-center gap-1">
{key.split("+").map((keyPart: string, partIndex: number) => {
const keyId = `${shortcut.id}-${index}-${partIndex}`;
return (
@@ -231,9 +232,6 @@ function EditableShortcutKey({
<Button
variant="outline"
size="sm"
className={`font-sans px-2 min-w-6 min-h-6 leading-none mr-1 hover:bg-opacity-80 ${
isRecording ? "border-primary bg-primary/10" : "border bg-accent/50"
}`}
onClick={handleClick}
title={
isRecording ? "Press any key combination..." : "Click to edit shortcut"
+1 -1
View File
@@ -2,7 +2,7 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
import { storageService } from "@/lib/storage/storage-service";
import { storageService } from "@/services/storage/storage-service";
import { toast } from "sonner";
interface StorageContextType {
+1 -1
View File
@@ -18,7 +18,7 @@ const buttonVariants = cva(
"primary-gradient":
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
destructive:
"bg-destructive/0 border border-destructive/25 text-destructive shadow-xs hover:bg-destructive hover:text-destructive-foreground",
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/80",
outline:
"border border-input bg-transparent shadow-xs transition-colors hover:bg-accent",
secondary:
+1 -1
View File
@@ -64,7 +64,7 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
"flex flex-col space-y-2 text-left",
className
)}
{...props}
@@ -12,7 +12,7 @@ import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useEditor } from "@/hooks/use-editor";
import { setDragData } from "@/lib/drag-data";
import { clearDragData, setDragData } from "@/lib/drag-data";
import type { TimelineDragData } from "@/types/drag";
export interface DraggableItemProps {
@@ -90,6 +90,7 @@ export function DraggableItem({
const handleDragEnd = () => {
setIsDragging(false);
clearDragData();
};
return (
@@ -55,7 +55,7 @@ const NavigationMenuTrigger = React.forwardRef<
>
{children}{" "}
<ChevronDown
className="relative top-px ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
className="relative top-px ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>