refactor: fresh properties panel

This commit is contained in:
Maze Winther
2025-07-09 21:03:56 +02:00
parent dd35c91f39
commit c02f276303
6 changed files with 280 additions and 255 deletions

View File

@ -2,13 +2,10 @@
import { useTimelineStore } from "@/stores/timeline-store";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
import {
useMediaStore,
type MediaItem,
getMediaAspectRatio,
} from "@/stores/media-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store";
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
import {
@ -269,54 +266,25 @@ export function PreviewPanel() {
function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
const { isPlaying, toggle, currentTime } = usePlaybackStore();
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
const { getTotalDuration } = useTimelineStore();
const {
canvasSize,
currentPreset,
isOriginal,
getOriginalAspectRatio,
getDisplayName,
canvasPresets,
setCanvasSize,
setCanvasSizeFromAspectRatio,
} = useEditorStore();
const { mediaItems } = useMediaStore();
const { tracks, getTotalDuration } = useTimelineStore();
// Find the current preset based on canvas size
const currentPreset = canvasPresets.find(
(preset) =>
preset.width === canvasSize.width && preset.height === canvasSize.height
);
} = useAspectRatio();
const handlePresetSelect = (preset: { width: number; height: number }) => {
setCanvasSize({ width: preset.width, height: preset.height });
};
// Get the first video/image media item to determine original aspect ratio
const getOriginalAspectRatio = () => {
// Find first video or image in timeline
for (const track of tracks) {
for (const element of track.elements) {
if (element.type === "media") {
const mediaItem = mediaItems.find(
(item) => item.id === element.mediaId
);
if (
mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image")
) {
return getMediaAspectRatio(mediaItem);
}
}
}
}
return 16 / 9; // Default aspect ratio
};
const handleOriginalSelect = () => {
const aspectRatio = getOriginalAspectRatio();
setCanvasSizeFromAspectRatio(aspectRatio);
setCanvasSizeToOriginal(aspectRatio);
};
// Check if current size is "Original" (not matching any preset)
const isOriginal = !currentPreset;
return (
<div
data-toolbar
@ -353,7 +321,7 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
className="!bg-background text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light"
disabled={!hasAnyElements}
>
{currentPreset?.name || "Ratio"}
{getDisplayName()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">

View File

@ -1,220 +1,37 @@
"use client";
import { Input } from "../ui/input";
import { useProjectStore } from "@/stores/project-store";
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import { Label } from "../ui/label";
import { Slider } from "../ui/slider";
import { ScrollArea } from "../ui/scroll-area";
import { Separator } from "../ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { useState } from "react";
import { SpeedControl } from "./speed-control";
import type { BackgroundType } from "@/types/editor";
export function PropertiesPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
const [backgroundColor, setBackgroundColor] = useState("#000000");
// Get the first video element for preview (simplified)
const firstVideoElement = tracks
.flatMap((track) => track.elements)
.find((element) => {
if (element.type !== "media") return false;
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
return mediaItem?.type === "video";
});
const firstVideoItem = firstVideoElement && firstVideoElement.type === "media"
? mediaItems.find((item) => item.id === firstVideoElement.mediaId)
: null;
const firstImageElement = tracks
.flatMap((track) => track.elements)
.find((element) => {
if (element.type !== "media") return false;
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
return mediaItem?.type === "image";
});
const firstImageItem = firstImageElement && firstImageElement.type === "media"
? mediaItems.find((item) => item.id === firstImageElement.mediaId)
: null;
const { activeProject } = useProjectStore();
const { getDisplayName, canvasSize } = useAspectRatio();
return (
<ScrollArea className="h-full">
<div className="space-y-6 p-5">
{/* Image Treatment - only show if an image is selected */}
{firstImageItem && (
<>
<div className="space-y-4">
<h3 className="text-sm font-medium">Image Treatment</h3>
<div className="space-y-4">
{/* Preview */}
<div className="space-y-2">
<Label>Preview</Label>
<div className="w-full aspect-video max-w-48">
<ImageTimelineTreatment
src={firstImageItem.url!}
alt={firstImageItem.name}
targetAspectRatio={16 / 9}
className="rounded-sm border"
backgroundType={backgroundType}
backgroundColor={backgroundColor}
/>
</div>
</div>
{/* Background Type */}
<div className="space-y-2">
<Label htmlFor="bg-type">Background Type</Label>
<Select
value={backgroundType}
onValueChange={(value: BackgroundType) =>
setBackgroundType(value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select background type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="blur">Blur</SelectItem>
<SelectItem value="mirror">Mirror</SelectItem>
<SelectItem value="color">Solid Color</SelectItem>
</SelectContent>
</Select>
</div>
{/* Background Color - only show for color type */}
{backgroundType === "color" && (
<div className="space-y-2">
<Label htmlFor="bg-color">Background Color</Label>
<div className="flex gap-2">
<Input
id="bg-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-16 h-10 p-1"
/>
<Input
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
placeholder="#000000"
className="flex-1"
/>
</div>
</div>
)}
</div>
</div>
<Separator />
</>
)}
{/* Video Controls - only show if a video is selected */}
{firstVideoItem && (
<>
<SpeedControl />
<Separator />
</>
)}
{/* Transform */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Transform</h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="x">X Position</Label>
<Input id="x" type="number" defaultValue="0" />
</div>
<div className="space-y-1">
<Label htmlFor="y">Y Position</Label>
<Input id="y" type="number" defaultValue="0" />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="rotation">Rotation</Label>
<Slider
id="rotation"
max={360}
step={1}
defaultValue={[0]}
className="mt-2"
/>
</div>
</div>
</div>
<Separator />
{/* Effects */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Effects</h3>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="opacity">Opacity</Label>
<Slider
id="opacity"
max={100}
step={1}
defaultValue={[100]}
className="mt-2"
/>
</div>
<div className="space-y-1">
<Label htmlFor="blur">Blur</Label>
<Slider
id="blur"
max={20}
step={0.5}
defaultValue={[0]}
className="mt-2"
/>
</div>
</div>
</div>
<Separator />
{/* Timing */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Timing</h3>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="duration">Duration (seconds)</Label>
<Input
id="duration"
type="number"
min="0"
step="0.1"
defaultValue="5"
/>
</div>
<div className="space-y-1">
<Label htmlFor="delay">Delay (seconds)</Label>
<Input
id="delay"
type="number"
min="0"
step="0.1"
defaultValue="0"
/>
</div>
</div>
<div className="space-y-4 p-5">
{/* Media Properties */}
<div className="flex flex-col gap-3">
<PropertyItem label="Name:" value={activeProject?.name || ""} />
<PropertyItem label="Aspect ratio:" value={getDisplayName()} />
<PropertyItem
label="Resolution:"
value={`${canvasSize.width} × ${canvasSize.height}`}
/>
<PropertyItem label="Frame rate:" value="30.00fps" />
</div>
</div>
</ScrollArea>
);
}
function PropertyItem({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground">{label}</Label>
<span className="text-xs text-right">{value}</span>
</div>
);
}

View File

@ -12,6 +12,7 @@ import {
ChevronLeft,
Type,
Copy,
RefreshCw,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
@ -61,6 +62,7 @@ export function TimelineElement({
splitAndKeepRight,
separateAudio,
addElementToTrack,
replaceElementMedia,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@ -213,6 +215,37 @@ export function TimelineElement({
removeElementFromTrack(track.id, element.id);
};
const handleReplaceClip = () => {
if (element.type !== "media") {
toast.error("Replace is only available for media clips");
return;
}
// Create a file input to select replacement media
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*,audio/*,image/*";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const success = await replaceElementMedia(track.id, element.id, file);
if (success) {
toast.success("Clip replaced successfully");
} else {
toast.error("Failed to replace clip");
}
} catch (error) {
toast.error("Failed to replace clip");
console.log(
JSON.stringify({ error: "Failed to replace clip", details: error })
);
}
};
input.click();
};
const renderElementContent = () => {
if (element.type === "text") {
return (
@ -350,6 +383,12 @@ export function TimelineElement({
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
{element.type === "media" && (
<ContextMenuItem onClick={handleReplaceClip}>
<RefreshCw className="h-4 w-4 mr-2" />
Replace clip
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={handleElementDeleteContext}

View File

@ -0,0 +1,92 @@
import { useEditorStore } from "@/stores/editor-store";
import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
export function useAspectRatio() {
const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
const { mediaItems } = useMediaStore();
const { tracks } = useTimelineStore();
// Find the current preset based on canvas size
const currentPreset = canvasPresets.find(
(preset) =>
preset.width === canvasSize.width && preset.height === canvasSize.height
);
// Get the original aspect ratio from the first video/image in timeline
const getOriginalAspectRatio = (): number => {
// Find first video or image in timeline
for (const track of tracks) {
for (const element of track.elements) {
if (element.type === "media") {
const mediaItem = mediaItems.find(
(item) => item.id === element.mediaId
);
if (
mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image")
) {
return getMediaAspectRatio(mediaItem);
}
}
}
}
return 16 / 9; // Default aspect ratio
};
// Get current aspect ratio
const getCurrentAspectRatio = (): number => {
return canvasSize.width / canvasSize.height;
};
// Format aspect ratio as a readable string
const formatAspectRatio = (aspectRatio: number): string => {
// Check if it matches a common aspect ratio
const ratios = [
{ ratio: 16 / 9, label: "16:9" },
{ ratio: 9 / 16, label: "9:16" },
{ ratio: 1, label: "1:1" },
{ ratio: 4 / 3, label: "4:3" },
{ ratio: 3 / 4, label: "3:4" },
{ ratio: 21 / 9, label: "21:9" },
];
for (const { ratio, label } of ratios) {
if (Math.abs(aspectRatio - ratio) < 0.01) {
return label;
}
}
// If not a common ratio, format as decimal
return aspectRatio.toFixed(2);
};
// Check if current mode is "Original"
const isOriginal = canvasMode === "original";
// Get display name for current aspect ratio
const getDisplayName = (): string => {
// If explicitly set to original mode, always show "Original"
if (canvasMode === "original") {
return "Original";
}
if (currentPreset) {
return currentPreset.name;
}
return formatAspectRatio(getCurrentAspectRatio());
};
return {
currentPreset,
canvasMode,
isOriginal,
getCurrentAspectRatio,
getOriginalAspectRatio,
formatAspectRatio,
getDisplayName,
canvasSize,
canvasPresets,
};
}

View File

@ -1,6 +1,8 @@
import { create } from "zustand";
import { CanvasSize, CanvasPreset } from "@/types/editor";
type CanvasMode = "preset" | "original" | "custom";
interface EditorState {
// Loading states
isInitializing: boolean;
@ -8,6 +10,7 @@ interface EditorState {
// Canvas/Project settings
canvasSize: CanvasSize;
canvasMode: CanvasMode;
canvasPresets: CanvasPreset[];
// Actions
@ -15,6 +18,7 @@ interface EditorState {
setPanelsReady: (ready: boolean) => void;
initializeApp: () => Promise<void>;
setCanvasSize: (size: CanvasSize) => void;
setCanvasSizeToOriginal: (aspectRatio: number) => void;
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
}
@ -65,6 +69,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
isInitializing: true,
isPanelsReady: false,
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
canvasMode: "preset" as CanvasMode,
canvasPresets: DEFAULT_CANVAS_PRESETS,
// Actions
@ -85,15 +90,16 @@ export const useEditorStore = create<EditorState>((set, get) => ({
},
setCanvasSize: (size) => {
set({ canvasSize: size });
set({ canvasSize: size, canvasMode: "preset" });
},
setCanvasSizeToOriginal: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
set({ canvasSize: newCanvasSize, canvasMode: "original" });
},
setCanvasSizeFromAspectRatio: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
console.log(
`Setting canvas size based on aspect ratio ${aspectRatio}:`,
newCanvasSize
);
set({ canvasSize: newCanvasSize });
set({ canvasSize: newCanvasSize, canvasMode: "custom" });
},
}));

View File

@ -117,6 +117,13 @@ interface TimelineStore {
) => void;
separateAudio: (trackId: string, elementId: string) => string | null;
// Replace media for an element
replaceElementMedia: (
trackId: string,
elementId: string,
newFile: File
) => Promise<boolean>;
// Computed values
getTotalDuration: () => number;
@ -677,6 +684,102 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return audioElementId;
},
// Replace media for an element
replaceElementMedia: async (trackId, elementId, newFile) => {
const { _tracks } = get();
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId);
if (!element || element.type !== "media") return false;
try {
const mediaStore = useMediaStore.getState();
const projectStore = useProjectStore.getState();
if (!projectStore.activeProject) return false;
// Import required media processing functions
const {
getFileType,
getImageDimensions,
generateVideoThumbnail,
getMediaDuration,
} = await import("./media-store");
const fileType = getFileType(newFile);
if (!fileType) return false;
// Process the new media file
let mediaData: any = {
name: newFile.name,
type: fileType,
file: newFile,
url: URL.createObjectURL(newFile),
};
// Get media-specific metadata
if (fileType === "image") {
const { width, height } = await getImageDimensions(newFile);
mediaData.width = width;
mediaData.height = height;
} else if (fileType === "video") {
const [duration, { thumbnailUrl, width, height }] = await Promise.all(
[getMediaDuration(newFile), generateVideoThumbnail(newFile)]
);
mediaData.duration = duration;
mediaData.thumbnailUrl = thumbnailUrl;
mediaData.width = width;
mediaData.height = height;
} else if (fileType === "audio") {
mediaData.duration = await getMediaDuration(newFile);
}
// Add new media item to store
await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData);
// Find the newly created media item
const newMediaItem = mediaStore.mediaItems.find(
(item) => item.file === newFile
);
if (!newMediaItem) return false;
get().pushHistory();
// Update the timeline element to reference the new media
updateTracksAndSave(
_tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((c) =>
c.id === elementId
? {
...c,
mediaId: newMediaItem.id,
name: newMediaItem.name,
// Update duration if the new media has a different duration
duration: newMediaItem.duration || c.duration,
}
: c
),
}
: track
)
);
return true;
} catch (error) {
console.log(
JSON.stringify({
error: "Failed to replace element media",
details: error,
})
);
return false;
}
},
getTotalDuration: () => {
const { _tracks } = get();
if (_tracks.length === 0) return 0;