mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
shipping this slop
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user