mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -17,23 +16,21 @@ import { useRouter } from "next/navigation";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
import { ExportButton } from "./export-button";
|
||||
import { ThemeToggle } from "../theme-toggle";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
ArrowLeft02Icon,
|
||||
Edit03Icon,
|
||||
Delete02Icon,
|
||||
CommandIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { ArrowLeft02Icon, CommandIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export function EditorHeader() {
|
||||
return (
|
||||
<header className="bg-background flex h-[3.2rem] items-center justify-between px-3 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<header className="bg-background flex h-[3.4rem] items-center justify-between px-3 pt-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<ProjectDropdown />
|
||||
<EditableProjectName />
|
||||
</div>
|
||||
<nav className="flex items-center gap-2">
|
||||
<ExportButton />
|
||||
@@ -111,14 +108,14 @@ function ProjectDropdown() {
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex h-auto items-center justify-center px-2.5 py-1.5"
|
||||
>
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
<span className="mr-2 text-[0.85rem]">
|
||||
{activeProject?.metadata.name}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="p-1 rounded-sm size-8">
|
||||
<Image
|
||||
src={DEFAULT_LOGO_URL}
|
||||
alt="Project thumbnail"
|
||||
width={32}
|
||||
height={32}
|
||||
className="invert dark:invert-0 size-5"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-100 w-52">
|
||||
@@ -130,21 +127,7 @@ function ProjectDropdown() {
|
||||
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
|
||||
Exit project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setOpenDialog("rename")}
|
||||
>
|
||||
<HugeiconsIcon icon={Edit03Icon} className="size-4" />
|
||||
Rename project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setOpenDialog("delete")}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
||||
Delete project
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
@@ -185,3 +168,79 @@ function ProjectDropdown() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableProjectName() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const originalNameRef = useRef("");
|
||||
|
||||
const projectName = activeProject?.metadata.name || "";
|
||||
|
||||
const startEditing = () => {
|
||||
if (isEditing) return;
|
||||
originalNameRef.current = projectName;
|
||||
setIsEditing(true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.select();
|
||||
});
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!inputRef.current || !activeProject) return;
|
||||
const newName = inputRef.current.value.trim();
|
||||
setIsEditing(false);
|
||||
|
||||
if (!newName) {
|
||||
inputRef.current.value = originalNameRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
if (newName !== originalNameRef.current) {
|
||||
try {
|
||||
await editor.project.renameProject({
|
||||
id: activeProject.metadata.id,
|
||||
name: newName,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Failed to rename project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
inputRef.current?.blur();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = originalNameRef.current;
|
||||
}
|
||||
setIsEditing(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
defaultValue={projectName}
|
||||
readOnly={!isEditing}
|
||||
onClick={startEditing}
|
||||
onBlur={saveEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ fieldSizing: "content" }}
|
||||
className={cn(
|
||||
"text-[0.9rem] h-8 px-2 py-1 rounded-sm bg-transparent outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
||||
isEditing && "ring-1 ring-ring cursor-text hover:bg-transparent",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
|
||||
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
|
||||
import { Check, Copy, Download, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
EXPORT_FORMAT_VALUES,
|
||||
EXPORT_QUALITY_VALUES,
|
||||
@@ -44,9 +44,7 @@ export function ExportButton() {
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
|
||||
hasProject
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50",
|
||||
hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={hasProject ? handleExport : undefined}
|
||||
disabled={!hasProject}
|
||||
@@ -141,20 +139,12 @@ function ExportPopover({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isExporting) {
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelRequestedRef.current = true;
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3">
|
||||
<PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0">
|
||||
{exportResult && !exportResult.success ? (
|
||||
<ExportError
|
||||
error={exportResult.error || "Unknown error occurred"}
|
||||
@@ -162,23 +152,20 @@ function ExportPopover({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<h3 className="font-medium text-sm">
|
||||
{isExporting ? "Exporting project" : "Export project"}
|
||||
</h3>
|
||||
<Button variant="text" size="icon" onClick={handleClose}>
|
||||
<X className="text-foreground/85 !size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{!isExporting && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col">
|
||||
<PropertyGroup
|
||||
title="Format"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
hasBorderTop={false}
|
||||
>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
@@ -203,11 +190,7 @@ function ExportPopover({
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup
|
||||
title="Quality"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<PropertyGroup title="Quality" defaultExpanded={false}>
|
||||
<RadioGroup
|
||||
value={quality}
|
||||
onValueChange={(value) => {
|
||||
@@ -237,11 +220,7 @@ function ExportPopover({
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup
|
||||
title="Audio"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<PropertyGroup title="Audio" defaultExpanded={false}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="include-audio"
|
||||
@@ -257,15 +236,17 @@ function ExportPopover({
|
||||
</PropertyGroup>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleExport} className="w-full gap-2">
|
||||
<Download className="size-4" />
|
||||
Export
|
||||
</Button>
|
||||
<div className="p-3 pt-0">
|
||||
<Button onClick={handleExport} className="w-full gap-2">
|
||||
<Download className="size-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isExporting && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 p-3">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between text-center">
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
|
||||
@@ -110,7 +110,7 @@ export function DraggableItem({
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-panel-accent relative overflow-hidden",
|
||||
"bg-accent relative overflow-hidden",
|
||||
isRounded && "rounded-sm",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
@@ -215,7 +215,7 @@ function PlusButton({
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background hover:bg-panel text-foreground absolute right-2 bottom-2 size-5",
|
||||
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -43,7 +43,7 @@ export function AssetsPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-panel flex h-full">
|
||||
<div className="panel bg-background flex h-full rounded-sm border overflow-hidden">
|
||||
<TabBar />
|
||||
<Separator orientation="vertical" />
|
||||
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
TAB_KEYS,
|
||||
@@ -15,9 +16,9 @@ import {
|
||||
|
||||
export function TabBar() {
|
||||
const { activeTab, setActiveTab } = useAssetsPanelStore();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const [showBottomFade, setShowBottomFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const checkScrollPosition = useCallback(() => {
|
||||
const element = scrollRef.current;
|
||||
@@ -48,32 +49,24 @@ export function TabBar() {
|
||||
<div className="relative flex">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-hidden relative flex size-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4"
|
||||
className="scrollbar-hidden relative flex size-full p-2 flex-col items-center justify-start gap-1.5 overflow-y-auto"
|
||||
>
|
||||
{TAB_KEYS.map((tabKey) => {
|
||||
const tab = tabs[tabKey];
|
||||
return (
|
||||
<Tooltip key={tabKey} delayDuration={10}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant={activeTab === tabKey ? "secondary" : "text"}
|
||||
aria-label={tab.label}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75",
|
||||
activeTab === tabKey
|
||||
? "text-primary !opacity-100"
|
||||
: "text-muted-foreground",
|
||||
"flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
|
||||
activeTab !== tabKey && "border border-transparent text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setActiveTab(tabKey)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setActiveTab(tabKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<tab.icon className=" " />
|
||||
</button>
|
||||
<tab.icon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
@@ -108,8 +101,8 @@ function FadeOverlay({
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-0 left-0 h-6",
|
||||
direction === "top" && show
|
||||
? "from-panel top-0 bg-gradient-to-b to-transparent"
|
||||
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
|
||||
? "from-background top-0 bg-gradient-to-b to-transparent"
|
||||
: "from-background bottom-0 bg-gradient-to-t to-transparent",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import {
|
||||
Select,
|
||||
@@ -21,6 +20,7 @@ import { transcriptionService } from "@/services/transcription/service";
|
||||
import { decodeAudioToFloat32 } from "@/lib/media/audio";
|
||||
import { buildCaptionChunks } from "@/lib/transcription/caption";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function Captions() {
|
||||
const [selectedLanguage, setSelectedLanguage] =
|
||||
@@ -112,12 +112,13 @@ export function Captions() {
|
||||
ref={containerRef}
|
||||
className="flex h-full flex-col justify-between"
|
||||
>
|
||||
<PropertyGroup title="Language">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>Language</Label>
|
||||
<Select
|
||||
value={selectedLanguage}
|
||||
onValueChange={(value) => handleLanguageChange({ value })}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent h-8 w-full text-xs">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -129,7 +130,7 @@ export function Captions() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && (
|
||||
|
||||
@@ -202,7 +202,7 @@ export function MediaView() {
|
||||
className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="bg-panel py-2 px-4 flex items-center justify-between border-b">
|
||||
<div className="bg-background h-12 px-4 pr-2 flex items-center justify-between border-b">
|
||||
<span className="text-muted-foreground text-sm">Assets</span>
|
||||
<div className="flex items-center gap-0">
|
||||
<TooltipProvider>
|
||||
@@ -320,10 +320,10 @@ export function MediaView() {
|
||||
onClick={openFilePicker}
|
||||
disabled={isProcessing}
|
||||
size="sm"
|
||||
className="items-center justify-center gap-1.5 ml-1.5"
|
||||
className="items-center justify-center gap-1.5 ml-1.5 hover:bg-accent px-3"
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} />
|
||||
Upload
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { colors } from "@/data/colors/solid";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { TProject } from "@/types/project";
|
||||
import { dimensionToAspectRatio } from "@/utils/geometry";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
@@ -30,8 +29,7 @@ import {
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
} from "@/components/editor/panels/properties/property-item";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { DropperIcon } from "@hugeicons/core-free-icons";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
|
||||
export function SettingsView() {
|
||||
return <ProjectSettingsTabs />;
|
||||
@@ -56,24 +54,9 @@ function ProjectSettingsTabs() {
|
||||
label: "Background",
|
||||
content: (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="flex-1 p-5">
|
||||
<div className="flex-1">
|
||||
<BackgroundView />
|
||||
</div>
|
||||
{/* <div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg">
|
||||
<Separator />
|
||||
<Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none">
|
||||
Custom background
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div> */}
|
||||
|
||||
{/* another ui */}
|
||||
{/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
|
||||
<Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
|
||||
<span className="text-sm">Custom</span>
|
||||
<PlusIcon className="" />
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -83,15 +66,6 @@ function ProjectSettingsTabs() {
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) {
|
||||
const { canvasSize } = activeProject.settings;
|
||||
|
||||
return {
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
function ProjectInfoView() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
@@ -117,7 +91,7 @@ function ProjectInfoView() {
|
||||
return -1;
|
||||
};
|
||||
|
||||
const currentCanvasSize = getCurrentCanvasSize({ activeProject });
|
||||
const currentCanvasSize = activeProject.settings.canvasSize;
|
||||
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
|
||||
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
|
||||
const presetIndex = findPresetIndexByAspectRatio({
|
||||
@@ -162,7 +136,7 @@ function ProjectInfoView() {
|
||||
value={selectedPresetValue}
|
||||
onValueChange={(value) => handleAspectRatioChange({ value })}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an aspect ratio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -190,7 +164,7 @@ function ProjectInfoView() {
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -219,7 +193,7 @@ const BlurPreview = memo(
|
||||
}) => (
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border",
|
||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
@@ -265,7 +239,7 @@ const BackgroundPreviews = memo(
|
||||
<button
|
||||
key={`${index}-${bg}`}
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border",
|
||||
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
||||
isColorBackground &&
|
||||
bg === currentBackgroundColor &&
|
||||
"border-primary border-2",
|
||||
@@ -347,48 +321,35 @@ function BackgroundView() {
|
||||
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
||||
);
|
||||
|
||||
const backgroundSections = [
|
||||
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
|
||||
{ title: "Pattern craft", backgrounds: patternCraftGradients },
|
||||
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<PropertyGroup title="Blur" defaultExpanded={false}>
|
||||
<div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div>
|
||||
<div className="flex h-full flex-col">
|
||||
<PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
|
||||
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Colors" defaultExpanded={false}>
|
||||
<div className="grid w-full grid-cols-4 gap-2">
|
||||
<div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border">
|
||||
<HugeiconsIcon icon={DropperIcon} className="size-4" />
|
||||
{backgroundSections.map((section) => (
|
||||
<PropertyGroup
|
||||
key={section.title}
|
||||
title={section.title}
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BackgroundPreviews
|
||||
backgrounds={section.backgrounds}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
useBackgroundColor={section.useBackgroundColor}
|
||||
/>
|
||||
</div>
|
||||
<BackgroundPreviews
|
||||
backgrounds={colors}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
useBackgroundColor={true}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Pattern craft" defaultExpanded={false}>
|
||||
<div className="grid w-full grid-cols-4 gap-2">
|
||||
<BackgroundPreviews
|
||||
backgrounds={patternCraftGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Syntax UI" defaultExpanded={false}>
|
||||
<div className="grid w-full grid-cols-4 gap-2">
|
||||
<BackgroundPreviews
|
||||
backgrounds={syntaxUIGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
</PropertyGroup>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ function SoundEffectsView() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Search sound effects"
|
||||
className="bg-panel-accent w-full"
|
||||
className="bg-accent w-full"
|
||||
containerClassName="w-full"
|
||||
value={searchQuery}
|
||||
onChange={({ currentTarget }) =>
|
||||
@@ -406,7 +406,7 @@ function SavedSoundsView() {
|
||||
|
||||
if (savedSounds.length === 0) {
|
||||
return (
|
||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<HugeiconsIcon
|
||||
icon={FavouriteIcon}
|
||||
className="text-muted-foreground size-10"
|
||||
|
||||
@@ -149,7 +149,7 @@ function CollectionGrid({
|
||||
|
||||
function EmptyView({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<HugeiconsIcon
|
||||
icon={HappyIcon}
|
||||
className="text-muted-foreground size-10"
|
||||
|
||||
@@ -27,7 +27,7 @@ export function TextView() {
|
||||
<DraggableItem
|
||||
name="Default text"
|
||||
preview={
|
||||
<div className="bg-panel-accent flex size-full items-center justify-center rounded">
|
||||
<div className="bg-accent flex size-full items-center justify-center rounded">
|
||||
<span className="text-xs select-none">Default text</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ function ViewContent({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="flex-1 scrollbar-hidden">
|
||||
<div className={cn("p-5", className)}>{children}</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
@@ -52,7 +52,7 @@ export function PanelBaseView({
|
||||
onValueChange={onValueChange}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<div className="bg-panel sticky top-0 z-10">
|
||||
<div className="bg-background sticky top-0 z-10">
|
||||
<div className="px-3 pt-3 pb-0">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
|
||||
@@ -4,11 +4,23 @@ import { useCallback, useMemo, useRef } from "react";
|
||||
import useDeepCompareEffect from "use-deep-compare-effect";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useRafLoop } from "@/hooks/use-raf-loop";
|
||||
import { useContainerSize } from "@/hooks/use-container-size";
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { getLastFrameTime } from "@/lib/time";
|
||||
import { formatTimeCode, getLastFrameTime } from "@/lib/time";
|
||||
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
|
||||
import { EditableTimecode } from "@/components/editable-timecode";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FullScreenIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
function usePreviewSize() {
|
||||
const editor = useEditor();
|
||||
@@ -47,32 +59,134 @@ function RenderTreeController() {
|
||||
}
|
||||
|
||||
export function PreviewPanel() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
|
||||
|
||||
return (
|
||||
<div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"panel bg-background relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm border",
|
||||
isFullscreen && "bg-background",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
|
||||
<PreviewCanvas />
|
||||
<RenderTreeController />
|
||||
</div>
|
||||
<PreviewToolbar
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewToolbar({
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
}: {
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const totalDuration = editor.timeline.getTotalDuration();
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
|
||||
<div className="flex items-center mt-1">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={totalDuration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={fps}
|
||||
onTimeChange={({ time }) => editor.playback.seek({ time })}
|
||||
className="text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{formatTimeCode({
|
||||
timeInSeconds: totalDuration,
|
||||
format: "HH:MM:SS:FF",
|
||||
fps,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => invokeAction("toggle-play")}
|
||||
>
|
||||
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
|
||||
</Button>
|
||||
|
||||
<div className="justify-self-end">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||
>
|
||||
<HugeiconsIcon icon={FullScreenIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastFrameRef = useRef(-1);
|
||||
const lastSceneRef = useRef<RootNode | null>(null);
|
||||
const renderingRef = useRef(false);
|
||||
const { width, height } = usePreviewSize();
|
||||
const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
|
||||
const containerSize = useContainerSize({ containerRef });
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const renderer = useMemo(() => {
|
||||
return new CanvasRenderer({
|
||||
width,
|
||||
height,
|
||||
width: nativeWidth,
|
||||
height: nativeHeight,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
}, [width, height, activeProject.settings.fps]);
|
||||
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
|
||||
|
||||
const displaySize = useMemo(() => {
|
||||
if (
|
||||
!nativeWidth ||
|
||||
!nativeHeight ||
|
||||
containerSize.width === 0 ||
|
||||
containerSize.height === 0
|
||||
) {
|
||||
return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 };
|
||||
}
|
||||
|
||||
const paddingBuffer = 4;
|
||||
const availableWidth = containerSize.width - paddingBuffer;
|
||||
const availableHeight = containerSize.height - paddingBuffer;
|
||||
|
||||
const aspectRatio = nativeWidth / nativeHeight;
|
||||
const containerAspect = availableWidth / availableHeight;
|
||||
|
||||
const displayWidth =
|
||||
containerAspect > aspectRatio
|
||||
? availableHeight * aspectRatio
|
||||
: availableWidth;
|
||||
const displayHeight =
|
||||
containerAspect > aspectRatio
|
||||
? availableHeight
|
||||
: availableWidth / aspectRatio;
|
||||
|
||||
return { width: displayWidth, height: displayHeight };
|
||||
}, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]);
|
||||
|
||||
const renderTree = editor.renderer.getRenderTree();
|
||||
|
||||
@@ -109,13 +223,18 @@ function PreviewCanvas() {
|
||||
useRafLoop(render);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex h-full w-full items-center justify-center"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
className="block max-h-full max-w-full border"
|
||||
width={nativeWidth}
|
||||
height={nativeHeight}
|
||||
className="block border"
|
||||
style={{
|
||||
width: displaySize.width,
|
||||
height: displaySize.height,
|
||||
background:
|
||||
activeProject.settings.background.type === "blur"
|
||||
? "transparent"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Settings05Icon } from "@hugeicons/core-free-icons";
|
||||
|
||||
export function EmptyView() {
|
||||
return (
|
||||
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<HugeiconsIcon
|
||||
icon={Settings05Icon}
|
||||
className="text-muted-foreground/75 size-10"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium ">It's empty here</p>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Click an element on the timeline to edit its properties
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AudioProperties } from "./audio-properties";
|
||||
import { VideoProperties } from "./video-properties";
|
||||
import { TextProperties } from "./text-properties";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Settings05Icon } from "@hugeicons/core-free-icons";
|
||||
import { EmptyView } from "./empty-view";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
|
||||
@@ -18,9 +17,9 @@ export function PropertiesPanel() {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
|
||||
{selectedElements.length > 0 ? (
|
||||
<ScrollArea className="bg-panel h-full rounded-sm">
|
||||
<ScrollArea className="h-full">
|
||||
{elementsWithTracks.map(({ track, element }) => {
|
||||
if (element.type === "text") {
|
||||
return (
|
||||
@@ -45,24 +44,6 @@ export function PropertiesPanel() {
|
||||
) : (
|
||||
<EmptyView />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyView() {
|
||||
return (
|
||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<HugeiconsIcon
|
||||
icon={Settings05Icon}
|
||||
className="text-muted-foreground/75 size-10"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium">It's empty here</p>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Click an element on the timeline to edit its properties
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
|
||||
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
interface PropertyItemProps {
|
||||
direction?: "row" | "column";
|
||||
@@ -57,35 +57,76 @@ interface PropertyGroupProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
collapsible?: boolean;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
hasBorderTop?: boolean;
|
||||
hasBorderBottom?: boolean;
|
||||
}
|
||||
|
||||
export function PropertyGroup({
|
||||
title,
|
||||
children,
|
||||
defaultExpanded = true,
|
||||
collapsible = true,
|
||||
className,
|
||||
titleClassName,
|
||||
hasBorderTop = true,
|
||||
hasBorderBottom = true,
|
||||
}: PropertyGroupProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<PropertyItem direction="column" className={cn("gap-3", className)}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<PropertyItemLabel className={cn(titleClassName)}>
|
||||
{title}
|
||||
</PropertyItemLabel>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDownIcon}
|
||||
className={cn("size-3", !isExpanded && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && <PropertyItemValue>{children}</PropertyItemValue>}
|
||||
</PropertyItem>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
hasBorderTop && "border-t",
|
||||
hasBorderBottom && "last:border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between p-3.5 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<PropertyGroupTitle isExpanded={isExpanded}>
|
||||
{title}
|
||||
</PropertyGroupTitle>
|
||||
<HugeiconsIcon
|
||||
icon={isExpanded ? MinusSignIcon : PlusSignIcon}
|
||||
className={cn(
|
||||
"size-3",
|
||||
isExpanded ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<PropertyGroupTitle isExpanded>{title}</PropertyGroupTitle>
|
||||
</div>
|
||||
)}
|
||||
{(collapsible ? isExpanded : true) && (
|
||||
<div className="p-3 pt-0">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyGroupTitle({
|
||||
children,
|
||||
isExpanded = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isExpanded?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isExpanded ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+8
-8
@@ -15,12 +15,12 @@ import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "../../ui/context-menu";
|
||||
} from "../../../ui/context-menu";
|
||||
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import { TimelinePlayhead } from "./timeline-playhead";
|
||||
import { SelectionBox } from "../selection-box";
|
||||
import { SelectionBox } from "../../selection-box";
|
||||
import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
|
||||
import { SnapIndicator } from "./snap-indicator";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
@@ -201,7 +201,7 @@ export function Timeline() {
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
"bg-panel relative flex h-full flex-col overflow-hidden rounded-sm"
|
||||
"panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border"
|
||||
}
|
||||
{...dragProps}
|
||||
aria-label="Timeline"
|
||||
@@ -226,17 +226,17 @@ export function Timeline() {
|
||||
isVisible={showSnapIndicator}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="bg-panel flex w-28 shrink-0 flex-col border-r">
|
||||
<div className="bg-panel flex h-4 items-center justify-between px-3">
|
||||
<div className="bg-background flex w-28 shrink-0 flex-col border-r">
|
||||
<div className="bg-background flex h-4 items-center justify-between px-3">
|
||||
<span className="opacity-0">.</span>
|
||||
</div>
|
||||
<div className="bg-panel flex h-4 items-center justify-between px-3">
|
||||
<div className="bg-background flex h-4 items-center justify-between px-3">
|
||||
<span className="opacity-0">.</span>
|
||||
</div>
|
||||
{tracks.length > 0 && (
|
||||
<div
|
||||
ref={trackLabelsRef}
|
||||
className="bg-panel flex-1 overflow-y-auto"
|
||||
className="bg-background flex-1 overflow-y-auto"
|
||||
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }}
|
||||
>
|
||||
<ScrollArea className="size-full" ref={trackLabelsScrollRef}>
|
||||
@@ -351,7 +351,7 @@ export function Timeline() {
|
||||
>
|
||||
<div
|
||||
ref={timelineHeaderRef}
|
||||
className="bg-panel sticky top-0 z-30 flex flex-col"
|
||||
className="bg-background sticky top-0 z-30 flex flex-col"
|
||||
>
|
||||
<TimelineRuler
|
||||
zoomLevel={zoomLevel}
|
||||
+17
-5
@@ -19,7 +19,7 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../../ui/context-menu";
|
||||
} from "../../../ui/context-menu";
|
||||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
TimelineTrack,
|
||||
@@ -166,7 +166,10 @@ export function TimelineElement({
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-200 w-64">
|
||||
<ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}>
|
||||
<ActionMenuItem
|
||||
action="split"
|
||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||
>
|
||||
Split
|
||||
</ActionMenuItem>
|
||||
<CopyMenuItem />
|
||||
@@ -185,7 +188,10 @@ export function TimelineElement({
|
||||
/>
|
||||
)}
|
||||
{selectedElements.length === 1 && (
|
||||
<ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
|
||||
<ActionMenuItem
|
||||
action="duplicate-selected"
|
||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||
>
|
||||
Duplicate
|
||||
</ActionMenuItem>
|
||||
)}
|
||||
@@ -446,7 +452,10 @@ function ElementContent({
|
||||
|
||||
function CopyMenuItem() {
|
||||
return (
|
||||
<ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
|
||||
<ActionMenuItem
|
||||
action="copy-selected"
|
||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||
>
|
||||
Copy
|
||||
</ActionMenuItem>
|
||||
);
|
||||
@@ -502,7 +511,10 @@ function VisibilityMenuItem({
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}>
|
||||
<ActionMenuItem
|
||||
action="toggle-elements-visibility-selected"
|
||||
icon={getIcon()}
|
||||
>
|
||||
{isHidden ? "Show" : "Hide"}
|
||||
</ActionMenuItem>
|
||||
);
|
||||
+21
-3
@@ -1,4 +1,4 @@
|
||||
import type { JSX } from "react";
|
||||
import { type JSX, useLayoutEffect, useRef } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
@@ -44,7 +44,21 @@ export function TimelineRuler({
|
||||
scrollRef: tracksScrollRef,
|
||||
});
|
||||
|
||||
const bufferPx = 200;
|
||||
/**
|
||||
* widens the virtualization buffer during zoom transitions.
|
||||
* useScrollPosition lags one frame behind the scroll adjustment
|
||||
* that useLayoutEffect applies after a zoom change.
|
||||
*/
|
||||
const prevZoomRef = useRef(zoomLevel);
|
||||
const isZoomTransition = zoomLevel !== prevZoomRef.current;
|
||||
const bufferPx = isZoomTransition
|
||||
? Math.max(200, (scrollLeft + viewportWidth) * 0.15)
|
||||
: 200;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
prevZoomRef.current = zoomLevel;
|
||||
}, [zoomLevel]);
|
||||
|
||||
const visibleStartTime = Math.max(
|
||||
0,
|
||||
(scrollLeft - bufferPx) / pixelsPerSecond,
|
||||
@@ -62,7 +76,11 @@ export function TimelineRuler({
|
||||
);
|
||||
|
||||
const timelineTicks: Array<JSX.Element> = [];
|
||||
for (let tickIndex = startTickIndex; tickIndex <= endTickIndex; tickIndex += 1) {
|
||||
for (
|
||||
let tickIndex = startTickIndex;
|
||||
tickIndex <= endTickIndex;
|
||||
tickIndex += 1
|
||||
) {
|
||||
const time = tickIndex * tickIntervalSeconds;
|
||||
if (time > effectiveDuration) break;
|
||||
|
||||
+15
-84
@@ -6,7 +6,7 @@ import {
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkipBack, SplitSquareHorizontal } from "lucide-react";
|
||||
import { SplitSquareHorizontal } from "lucide-react";
|
||||
import {
|
||||
SplitButton,
|
||||
SplitButtonLeft,
|
||||
@@ -14,11 +14,9 @@ import {
|
||||
SplitButtonSeparator,
|
||||
} from "@/components/ui/split-button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||
import { EditableTimecode } from "@/components/editable-timecode";
|
||||
import { ScenesView } from "../scenes-view";
|
||||
import { ScenesView } from "../../scenes-view";
|
||||
import { type TAction, invokeAction } from "@/lib/actions";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
@@ -32,8 +30,6 @@ import {
|
||||
Link04Icon,
|
||||
SearchAddIcon,
|
||||
SearchMinusIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
Copy01Icon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
@@ -82,7 +78,6 @@ export function TimelineToolbar({
|
||||
function ToolbarLeftSection() {
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
|
||||
|
||||
const handleAction = ({
|
||||
@@ -99,32 +94,6 @@ function ToolbarLeftSection() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<ToolbarButton
|
||||
icon={
|
||||
isPlaying ? (
|
||||
<HugeiconsIcon icon={PauseIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={PlayIcon} />
|
||||
)
|
||||
}
|
||||
tooltip={isPlaying ? "Pause" : "Play"}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-play", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<SkipBack />}
|
||||
tooltip="Go to start"
|
||||
onClick={({ event }) => handleAction({ action: "goto-start", event })}
|
||||
/>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<TimeDisplay />
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||
tooltip="Split element"
|
||||
@@ -179,12 +148,8 @@ function ToolbarLeftSection() {
|
||||
|
||||
<Tooltip>
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={Bookmark02Icon}
|
||||
className={currentBookmarked ? "fill-primary text-primary" : ""}
|
||||
/>
|
||||
}
|
||||
icon={<HugeiconsIcon icon={Bookmark02Icon} />}
|
||||
isActive={currentBookmarked}
|
||||
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-bookmark", event })
|
||||
@@ -196,34 +161,6 @@ function ToolbarLeftSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function TimeDisplay() {
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const totalDuration = editor.timeline.getTotalDuration();
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-center px-2">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={totalDuration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={fps}
|
||||
onTimeChange={({ time }) => editor.playback.seek({ time })}
|
||||
className="text-center"
|
||||
/>
|
||||
<div className="text-muted-foreground px-2 font-mono text-xs">/</div>
|
||||
<div className="text-muted-foreground text-center font-mono text-xs">
|
||||
{formatTimeCode({
|
||||
timeInSeconds: totalDuration,
|
||||
format: "HH:MM:SS:FF",
|
||||
fps,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneSelector() {
|
||||
const editor = useEditor();
|
||||
const currentScene = editor.scenes.getActiveScene();
|
||||
@@ -265,26 +202,15 @@ function ToolbarRightSection({
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={MagnetIcon}
|
||||
className={cn(snappingEnabled ? "text-primary" : "")}
|
||||
/>
|
||||
}
|
||||
icon={<HugeiconsIcon icon={MagnetIcon} />}
|
||||
isActive={snappingEnabled}
|
||||
tooltip="Auto snapping"
|
||||
onClick={() => toggleSnapping()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={Link04Icon}
|
||||
className={cn(
|
||||
rippleEditingEnabled ? "text-primary" : "",
|
||||
"scale-110",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
|
||||
isActive={rippleEditingEnabled}
|
||||
tooltip="Ripple editing"
|
||||
onClick={() => toggleRippleEditing()}
|
||||
/>
|
||||
@@ -329,21 +255,26 @@ function ToolbarButton({
|
||||
tooltip,
|
||||
onClick,
|
||||
disabled,
|
||||
isActive,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
variant={isActive ? "secondary" : "text"}
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) => onClick({ event })}
|
||||
className={disabled ? "cursor-not-allowed opacity-50" : ""}
|
||||
className={cn(
|
||||
"rounded-sm",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
+1
-6
@@ -9,7 +9,6 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import type { ElementDragState } from "@/types/timeline";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface TimelineTrackContentProps {
|
||||
track: TimelineTrack;
|
||||
@@ -63,13 +62,9 @@ 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 (
|
||||
<button
|
||||
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
|
||||
className="size-full"
|
||||
onClick={(event) => {
|
||||
if (shouldIgnoreClick?.()) return;
|
||||
clearElementSelection();
|
||||
Reference in New Issue
Block a user