Merge branch 'opencut' into uat
# Conflicts: # apps/web/public/landing-page-dark.png # apps/web/src/app/blog/[slug]/page.tsx # apps/web/src/app/blog/page.tsx
This commit is contained in:
commit
c5447b7628
|
|
@ -91,6 +91,16 @@ Review every point below carefully to ensure files follow consistent code style
|
|||
|
||||
- [ ] Code is scannable — use variables and helper functions to make intent clear at a glance
|
||||
- [ ] Complex logic is extracted into well-named variables or helpers
|
||||
- [ ] No redundant single/plural function variants — if a function can operate on multiple items, it should accept an array and handle both cases. Don't create `doThing()` + `doThings()`.
|
||||
|
||||
```tsx
|
||||
// ❌ wrong — redundant variants
|
||||
function updateElement({ element }: { element: Element }) { ... }
|
||||
function updateElements({ elements }: { elements: Element[] }) { ... }
|
||||
|
||||
// ✅ correct — one function, accepts array
|
||||
function updateElements({ elements }: { elements: Element[] }) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
|
|
@ -0,0 +1,133 @@
|
|||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import Prose from "@/components/ui/prose";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
|
||||
import type { Author, Post } from "@/types/blog";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
const slug = (await params).slug;
|
||||
|
||||
const data = await getSinglePost({ slug });
|
||||
|
||||
if (!data || !data.post) return {};
|
||||
|
||||
return {
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
twitter: {
|
||||
title: `${data.post.title}`,
|
||||
description: `${data.post.description}`,
|
||||
card: "summary_large_image",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
publishedTime: new Date(data.post.publishedAt).toISOString(),
|
||||
authors: data.post.authors.map((author: Author) => author.name),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const data = await getPosts();
|
||||
if (!data || !data.posts.length) return [];
|
||||
|
||||
return data.posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
const slug = (await params).slug;
|
||||
const data = await getSinglePost({ slug });
|
||||
if (!data || !data.post) return notFound();
|
||||
|
||||
const html = await processHtmlContent({ html: data.post.content });
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<PostHeader post={data.post} />
|
||||
<Separator />
|
||||
<PostContent html={html} />
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function PostHeader({ post }: { post: Post }) {
|
||||
const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-8">
|
||||
<PostMeta date={formattedDate} publishedAt={post.publishedAt} />
|
||||
<PostTitle title={post.title} />
|
||||
{post.coverImage && <PostCoverImage post={post} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostCoverImage({ post }: { post: Post }) {
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg w-full mt-4">
|
||||
<Image
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
loading="eager"
|
||||
fill
|
||||
className="rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<time dateTime={publishedAt.toString()}>{date}</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">{title}</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function PostContent({ html }: { html: string }) {
|
||||
return (
|
||||
<section className="">
|
||||
<Prose html={html} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { Post } from "@/types/blog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
openGraph: {
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default async function BlogPage() {
|
||||
const data = await getPosts();
|
||||
if (!data || !data.posts) return <div>No posts yet</div>;
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
title="Blog"
|
||||
description="Read the latest news and updates about OpenCut, the free and open-source video editor."
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{data.posts.map((post) => (
|
||||
<div key={post.id} className="flex flex-col">
|
||||
<BlogPostItem post={post} />
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogPostItem({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link href={`/blog/${post.slug}`}>
|
||||
<div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold">{post.title}</h2>
|
||||
<p className="text-muted-foreground">{post.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from "@/components/ui/resizable";
|
||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||
import { Timeline } from "@/components/editor/timeline";
|
||||
import { Timeline } from "@/components/editor/panels/timeline";
|
||||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
|
|
@ -63,7 +63,7 @@ function EditorLayout() {
|
|||
defaultSize={panels.tools}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0 rounded-sm"
|
||||
className="min-w-0"
|
||||
>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
|
|
@ -84,7 +84,7 @@ function EditorLayout() {
|
|||
defaultSize={panels.properties}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0 rounded-sm"
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
|
|
|
|||
|
|
@ -8,88 +8,115 @@
|
|||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(0 0% 11%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: hsl(203, 100%, 50%);
|
||||
--primary-hover: hsl(203, 100%, 45%);
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--secondary: hsl(216 13% 94%);
|
||||
--secondary-foreground: hsl(0 0% 2%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(216, 13%, 88%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 88%);
|
||||
--input: hsl(0 0% 85.1%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--panel-background: hsl(216 13% 94%);
|
||||
--panel-accent: hsl(216, 8%, 88%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(0 0% 11%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-hover: hsl(0, 0%, 96%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: #009dff;
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--secondary: hsl(204, 100%, 97%);
|
||||
--secondary-border: hsl(204, 100%, 94%);
|
||||
--secondary-foreground: hsl(200, 98%, 39%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(0, 0%, 96%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 91%);
|
||||
--input: hsl(0 0% 85.1%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
.panel {
|
||||
--background: hsl(216 13% 98%);
|
||||
--foreground: hsl(0 0% 13%);
|
||||
--card: hsl(0, 0%, 98%);
|
||||
--card-foreground: hsl(0 0% 13%);
|
||||
--primary-foreground: hsl(0, 0%, 98%);
|
||||
--secondary: hsl(204, 100%, 95%);
|
||||
--secondary-border: hsl(204, 100%, 92%);
|
||||
--secondary-foreground: hsl(200, 98%, 37%);
|
||||
--muted: hsl(0 0% 83.1%);
|
||||
--muted-foreground: hsl(0 0% 48%);
|
||||
--accent: hsl(0, 0%, 93%);
|
||||
--accent-foreground: hsl(0 0% 5%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 98%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 98%);
|
||||
--border: hsl(0 0% 89%);
|
||||
--input: hsl(0 0% 83.1%);
|
||||
--ring: hsl(0, 0%, 53%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(0 0% 4%);
|
||||
--foreground: hsl(0 0% 89%);
|
||||
--card: hsl(0 0% 4%);
|
||||
--card-foreground: hsl(0 0% 89%);
|
||||
--popover: hsl(0 0% 14.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(203, 100%, 50%);
|
||||
--primary-hover: hsl(203, 100%, 45%);
|
||||
--primary-foreground: hsl(0 0% 9%);
|
||||
--secondary: hsl(0 0% 14.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(0 0% 14.9%);
|
||||
--muted-foreground: hsl(0 0% 63.9%);
|
||||
--accent: hsl(0, 0%, 28%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 83%, 55%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0 0% 100%);
|
||||
--border: hsl(0 0% 17%);
|
||||
--input: hsl(0 0% 14.9%);
|
||||
--ring: hsl(0 0% 83.1%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 3.9%);
|
||||
--sidebar-foreground: hsl(0 0% 98%);
|
||||
--sidebar-primary: hsl(0 0% 98%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 9%);
|
||||
--sidebar-accent: hsl(0 0% 14.9%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 98%);
|
||||
--sidebar-border: hsl(0 0% 14.9%);
|
||||
--sidebar-ring: hsl(0 0% 83.1%);
|
||||
--panel-background: hsl(0 0% 11%);
|
||||
--panel-accent: hsl(0 0% 15%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--background: hsl(0, 0%, 7%);
|
||||
--foreground: hsl(0 0% 87%);
|
||||
--card: hsl(0, 0%, 7%);
|
||||
--card-foreground: hsl(0 0% 87%);
|
||||
--popover: hsl(0, 0%, 16%);
|
||||
--popover-hover: hsl(0, 0%, 22%);
|
||||
--popover-foreground: hsl(0 0% 95%);
|
||||
--secondary: hsl(204, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 15%);
|
||||
--secondary-foreground: hsl(200, 98%, 61%);
|
||||
--muted: hsl(0 0% 20%);
|
||||
--accent: hsl(0, 0%, 14%);
|
||||
--accent-foreground: hsl(0 0% 95%);
|
||||
--constructive: hsl(141, 71%, 52%);
|
||||
--border: hsl(0 0% 16%);
|
||||
--input: hsl(0 0% 20%);
|
||||
--ring: hsl(0, 0%, 50%);
|
||||
--sidebar-background: hsl(0 0% 8%);
|
||||
--sidebar-foreground: hsl(0 0% 95%);
|
||||
--sidebar-primary: hsl(0 0% 95%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 15%);
|
||||
--sidebar-accent: hsl(0 0% 20%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 95%);
|
||||
--sidebar-border: hsl(0 0% 20%);
|
||||
--sidebar-ring: hsl(0 0% 83.1%);
|
||||
--sidebar: hsl(0 0% 6%);
|
||||
}
|
||||
|
||||
.dark .panel {
|
||||
--background: hsl(0 0% 10%);
|
||||
--foreground: hsl(0 0% 85%);
|
||||
--card: hsl(0, 0%, 10%);
|
||||
--card-foreground: hsl(0 0% 85%);
|
||||
--secondary: hsl(204, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 17%);
|
||||
--secondary-foreground: hsl(200, 98%, 63%);
|
||||
--muted: hsl(0 0% 22%);
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent-foreground: hsl(0 0% 93%);
|
||||
--constructive: hsl(141, 71%, 52%);
|
||||
--border: hsl(0 0% 18%);
|
||||
--input: hsl(0 0% 22%);
|
||||
--ring: hsl(0, 0%, 52%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/*
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
|
@ -97,165 +124,166 @@
|
|||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-primary/35 selection:text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xl: 1.20rem;
|
||||
/* Font sizes */
|
||||
--text-xl: 1.2rem;
|
||||
--text-base: 0.92rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.85rem;
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: 0.82rem;
|
||||
--radius-md: 0.65rem;
|
||||
--radius-sm: 0.35rem;
|
||||
/* Border radius */
|
||||
--radius-lg: 0.82rem;
|
||||
--radius-md: 0.65rem;
|
||||
--radius-sm: 0.35rem;
|
||||
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-hover: var(--popover-hover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary-hover: var(--primary-hover);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-border: var(--secondary-border);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Panel */
|
||||
--color-panel: var(--panel-background);
|
||||
--color-panel-accent: var(--panel-accent);
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-x-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-y-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-thin {
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,11 @@ const formatProjectDuration = ({
|
|||
return formatTimeCode({ timeInSeconds: duration, format });
|
||||
};
|
||||
|
||||
const VIEW_MODE_OPTIONS = [
|
||||
{ mode: "grid" as const, icon: GridViewIcon, label: "Grid view" },
|
||||
{ mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" },
|
||||
];
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
|
||||
const editor = useEditor();
|
||||
|
|
@ -190,34 +195,23 @@ function ProjectsHeader() {
|
|||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="hidden md:flex rounded-md border p-1 h-10">
|
||||
<button
|
||||
type="button"
|
||||
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`}
|
||||
onClick={() => setViewMode({ viewMode: "grid" })}
|
||||
onKeyDown={(event) =>
|
||||
event.key === "Enter" && setViewMode({ viewMode: "grid" })
|
||||
}
|
||||
aria-label="Grid view"
|
||||
aria-pressed={isHydrated && viewMode === "grid"}
|
||||
>
|
||||
<HugeiconsIcon icon={GridViewIcon} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`}
|
||||
onClick={() => setViewMode({ viewMode: "list" })}
|
||||
onKeyDown={(event) =>
|
||||
event.key === "Enter" && setViewMode({ viewMode: "list" })
|
||||
}
|
||||
aria-label="List view"
|
||||
aria-pressed={isHydrated && viewMode === "list"}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={LeftToRightListDashIcon}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
<div className="hidden md:flex items-center rounded-md border p-1 px-1.5 h-10">
|
||||
{VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-sm hover:bg-background",
|
||||
isHydrated && viewMode === mode && "!bg-accent",
|
||||
)}
|
||||
onClick={() => setViewMode({ viewMode: mode })}
|
||||
aria-label={label}
|
||||
aria-pressed={isHydrated && viewMode === mode}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} className="size-4" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -336,28 +330,21 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
|
|||
<div className="h-4 w-px bg-border/50 block md:hidden" />
|
||||
|
||||
<div className="flex md:hidden items-center gap-4">
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => setViewMode({ viewMode: "grid" })}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={GridViewIcon}
|
||||
className={cn(
|
||||
viewMode === "grid" ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => setViewMode({ viewMode: "list" })}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={LeftToRightListDashIcon}
|
||||
className={cn(
|
||||
viewMode === "list" ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
{VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant="text"
|
||||
onClick={() => setViewMode({ viewMode: mode })}
|
||||
aria-label={label}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={icon}
|
||||
className={cn(
|
||||
viewMode === mode ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedProjectCount > 0 ? <ProjectActions /> : null}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export const useProjectsStore = create<ProjectsState>()(
|
|||
persist(
|
||||
(set) => ({
|
||||
searchQuery: "",
|
||||
sortKey: "createdAt",
|
||||
sortKey: "updatedAt",
|
||||
sortOrder: "desc",
|
||||
viewMode: "grid",
|
||||
selectedProjectIds: [],
|
||||
|
|
@ -86,7 +86,9 @@ export const useProjectsStore = create<ProjectsState>()(
|
|||
projectId,
|
||||
isSelected,
|
||||
}),
|
||||
lastSelectedProjectId: isSelected ? projectId : state.lastSelectedProjectId,
|
||||
lastSelectedProjectId: isSelected
|
||||
? projectId
|
||||
: state.lastSelectedProjectId,
|
||||
})),
|
||||
selectProjectRange: ({ projectId, allProjectIds }) =>
|
||||
set((state) => {
|
||||
|
|
@ -120,7 +122,11 @@ export const useProjectsStore = create<ProjectsState>()(
|
|||
}),
|
||||
{
|
||||
name: "projects-view-mode",
|
||||
partialize: (state) => ({ viewMode: state.viewMode }),
|
||||
partialize: (state) => ({
|
||||
viewMode: state.viewMode,
|
||||
sortKey: state.sortKey,
|
||||
sortOrder: state.sortOrder,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
state?.setIsHydrated({ isHydrated: true });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -122,8 +122,8 @@ export function EditableTimecode({
|
|||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"border-none bg-transparent font-mono text-xs outline-none",
|
||||
"focus:bg-background focus:border-primary focus:rounded focus:border focus:px-1",
|
||||
"-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
|
||||
"focus:bg-background focus:border-primary focus:rounded",
|
||||
"text-primary tabular-nums",
|
||||
hasError && "text-destructive focus:border-destructive",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -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,26 +16,24 @@ 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() {
|
||||
const editor = useEditor();
|
||||
const cloudSync = editor.project.getCloudSyncState();
|
||||
|
||||
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">
|
||||
<CloudSyncBadge
|
||||
|
|
@ -159,14 +156,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">
|
||||
|
|
@ -178,21 +175,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"
|
||||
|
|
@ -233,3 +216,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,10 +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();
|
||||
|
|
@ -46,37 +59,139 @@ 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 ref = useRef<HTMLCanvasElement>(null);
|
||||
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();
|
||||
|
||||
const render = useCallback(() => {
|
||||
if (ref.current && renderTree && !renderingRef.current) {
|
||||
if (canvasRef.current && renderTree && !renderingRef.current) {
|
||||
const time = editor.playback.getCurrentTime();
|
||||
const lastFrameTime = getLastFrameTime({
|
||||
duration: renderTree.duration,
|
||||
|
|
@ -96,7 +211,7 @@ function PreviewCanvas() {
|
|||
.renderToCanvas({
|
||||
node: renderTree,
|
||||
time: renderTime,
|
||||
targetCanvas: ref.current,
|
||||
targetCanvas: canvasRef.current,
|
||||
})
|
||||
.then(() => {
|
||||
renderingRef.current = false;
|
||||
|
|
@ -108,17 +223,25 @@ function PreviewCanvas() {
|
|||
useRafLoop(render);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={ref}
|
||||
width={width}
|
||||
height={height}
|
||||
className="block max-h-full max-w-full border"
|
||||
style={{
|
||||
background:
|
||||
activeProject.settings.background.type === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.settings.background.color,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex h-full w-full items-center justify-center"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={nativeWidth}
|
||||
height={nativeHeight}
|
||||
className="block border"
|
||||
style={{
|
||||
width: displaySize.width,
|
||||
height: displaySize.height,
|
||||
background:
|
||||
activeProject.settings.background.type === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.settings.background.color,
|
||||
}}
|
||||
/>
|
||||
<PreviewInteractionOverlay canvasRef={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export function PreviewInteractionOverlay({
|
||||
canvasRef,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
}) {
|
||||
const { onPointerDown, onPointerMove, onPointerUp } =
|
||||
usePreviewInteraction({ canvasRef });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("absolute inset-0 pointer-events-auto")}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,31 +5,20 @@ import type { TextElement } from "@/types/timeline";
|
|||
import { Slider } from "@/components/ui/slider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useRef } from "react";
|
||||
import { useReducer, useRef } from "react";
|
||||
import { PanelBaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import {
|
||||
TEXT_PROPERTIES_TABS,
|
||||
isTextPropertiesTab,
|
||||
useTextPropertiesStore,
|
||||
} from "@/stores/text-properties-store";
|
||||
import {
|
||||
PropertyGroup,
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
} from "./property-item";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { capitalizeFirstLetter, uppercase } from "@/utils/string";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { LayoutGridIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_COLOR } from "@/constants/project-constants";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants";
|
||||
|
||||
export function TextProperties({
|
||||
element,
|
||||
|
|
@ -39,372 +28,623 @@ export function TextProperties({
|
|||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { activeTab, setActiveTab } = useTextPropertiesStore();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fontSizeInput, setFontSizeInput] = useState(
|
||||
element.fontSize.toString(),
|
||||
);
|
||||
const [opacityInput, setOpacityInput] = useState(
|
||||
Math.round(element.opacity * 100).toString(),
|
||||
);
|
||||
const [, forceRender] = useReducer((x: number) => x + 1, 0);
|
||||
const isEditingFontSize = useRef(false);
|
||||
const isEditingOpacity = useRef(false);
|
||||
const isEditingContent = useRef(false);
|
||||
const fontSizeDraft = useRef("");
|
||||
const opacityDraft = useRef("");
|
||||
const contentDraft = useRef("");
|
||||
|
||||
const fontSizeDisplay = isEditingFontSize.current
|
||||
? fontSizeDraft.current
|
||||
: element.fontSize.toString();
|
||||
const opacityDisplay = isEditingOpacity.current
|
||||
? opacityDraft.current
|
||||
: Math.round(element.opacity * 100).toString();
|
||||
const contentDisplay = isEditingContent.current
|
||||
? contentDraft.current
|
||||
: element.content;
|
||||
|
||||
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
||||
const initialFontSizeRef = useRef<number | null>(null);
|
||||
const initialOpacityRef = useRef<number | null>(null);
|
||||
const initialContentRef = useRef<string | null>(null);
|
||||
const initialColorRef = useRef<string | null>(null);
|
||||
const initialBgColorRef = useRef<string | null>(null);
|
||||
|
||||
const handleFontSizeChange = ({ value }: { value: string }) => {
|
||||
setFontSizeInput(value);
|
||||
fontSizeDraft.current = value;
|
||||
forceRender();
|
||||
|
||||
if (value.trim() !== "") {
|
||||
if (initialFontSizeRef.current === null) {
|
||||
initialFontSizeRef.current = element.fontSize;
|
||||
}
|
||||
const parsed = parseInt(value, 10);
|
||||
const fontSize = Number.isNaN(parsed)
|
||||
? element.fontSize
|
||||
: clamp({ value: parsed, min: 8, max: 300 });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize },
|
||||
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
|
||||
editor.timeline.updateElements({
|
||||
updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
|
||||
pushHistory: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFontSizeBlur = () => {
|
||||
const parsed = parseInt(fontSizeInput, 10);
|
||||
const fontSize = Number.isNaN(parsed)
|
||||
? element.fontSize
|
||||
: clamp({ value: parsed, min: 8, max: 300 });
|
||||
setFontSizeInput(fontSize.toString());
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize },
|
||||
});
|
||||
if (initialFontSizeRef.current !== null) {
|
||||
const parsed = parseInt(fontSizeDraft.current, 10);
|
||||
const fontSize = Number.isNaN(parsed)
|
||||
? element.fontSize
|
||||
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: initialFontSizeRef.current },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialFontSizeRef.current = null;
|
||||
}
|
||||
isEditingFontSize.current = false;
|
||||
fontSizeDraft.current = "";
|
||||
forceRender();
|
||||
};
|
||||
|
||||
const handleOpacityChange = ({ value }: { value: string }) => {
|
||||
setOpacityInput(value);
|
||||
opacityDraft.current = value;
|
||||
forceRender();
|
||||
|
||||
if (value.trim() !== "") {
|
||||
if (initialOpacityRef.current === null) {
|
||||
initialOpacityRef.current = element.opacity;
|
||||
}
|
||||
const parsed = parseInt(value, 10);
|
||||
const opacityPercent = Number.isNaN(parsed)
|
||||
? Math.round(element.opacity * 100)
|
||||
: clamp({ value: parsed, min: 0, max: 100 });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpacityBlur = () => {
|
||||
const parsed = parseInt(opacityInput, 10);
|
||||
const opacityPercent = Number.isNaN(parsed)
|
||||
? Math.round(element.opacity * 100)
|
||||
: clamp({ value: parsed, min: 0, max: 100 });
|
||||
setOpacityInput(opacityPercent.toString());
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
});
|
||||
if (initialOpacityRef.current !== null) {
|
||||
const parsed = parseInt(opacityDraft.current, 10);
|
||||
const opacityPercent = Number.isNaN(parsed)
|
||||
? Math.round(element.opacity * 100)
|
||||
: clamp({ value: parsed, min: 0, max: 100 });
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: initialOpacityRef.current },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialOpacityRef.current = null;
|
||||
}
|
||||
isEditingOpacity.current = false;
|
||||
opacityDraft.current = "";
|
||||
forceRender();
|
||||
};
|
||||
|
||||
const handleColorChange = ({ color }: { color: string }) => {
|
||||
if (color !== "transparent") {
|
||||
lastSelectedColor.current = color;
|
||||
}
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: color },
|
||||
});
|
||||
if (initialBgColorRef.current === null) {
|
||||
initialBgColorRef.current = element.backgroundColor;
|
||||
}
|
||||
if (initialBgColorRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: color },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: color },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransparentToggle = ({
|
||||
isTransparent,
|
||||
}: {
|
||||
isTransparent: boolean;
|
||||
}) => {
|
||||
const newColor = isTransparent ? "transparent" : lastSelectedColor.current;
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: newColor },
|
||||
});
|
||||
const handleColorChangeEnd = ({ color }: { color: string }) => {
|
||||
if (initialBgColorRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: initialBgColorRef.current },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: `#${color}` },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialBgColorRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelBaseView
|
||||
defaultTab="transform"
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
if (isTextPropertiesTab(v)) setActiveTab(v);
|
||||
}}
|
||||
ref={containerRef}
|
||||
tabs={TEXT_PROPERTIES_TABS.map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
content:
|
||||
t.value === "transform" ? (
|
||||
<div className="space-y-6"></div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
defaultValue={element.content}
|
||||
className="bg-panel-accent min-h-20"
|
||||
onChange={(e) =>
|
||||
editor.timeline.updateTextElement({
|
||||
<div className="flex h-full flex-col" ref={containerRef}>
|
||||
<PanelBaseView className="p-0">
|
||||
<PropertyGroup title="Content" hasBorderTop={false} collapsible={false}>
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
value={contentDisplay}
|
||||
className="bg-accent min-h-20"
|
||||
onFocus={() => {
|
||||
isEditingContent.current = true;
|
||||
contentDraft.current = element.content;
|
||||
initialContentRef.current = element.content;
|
||||
forceRender();
|
||||
}}
|
||||
onChange={(event) => {
|
||||
contentDraft.current = event.target.value;
|
||||
forceRender();
|
||||
if (initialContentRef.current === null) {
|
||||
initialContentRef.current = element.content;
|
||||
}
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { content: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Font</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value: FontFamily) =>
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontFamily: value },
|
||||
updates: { content: event.target.value },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (initialContentRef.current !== null) {
|
||||
const finalContent = contentDraft.current;
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { content: initialContentRef.current },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { content: finalContent },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialContentRef.current = null;
|
||||
}
|
||||
isEditingContent.current = false;
|
||||
contentDraft.current = "";
|
||||
forceRender();
|
||||
}}
|
||||
/>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup title="Typography" collapsible={false}>
|
||||
<div className="space-y-6">
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Font</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value: FontFamily) =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontFamily: value },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Style</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={
|
||||
element.fontWeight === "bold" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontWeight:
|
||||
element.fontWeight === "bold"
|
||||
? "normal"
|
||||
: "bold",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Style</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={
|
||||
element.fontWeight === "bold" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontWeight:
|
||||
element.fontWeight === "bold" ? "normal" : "bold",
|
||||
className="h-8 px-3 font-bold"
|
||||
>
|
||||
B
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.fontStyle === "italic" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontStyle:
|
||||
element.fontStyle === "italic"
|
||||
? "normal"
|
||||
: "italic",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 font-bold"
|
||||
>
|
||||
B
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.fontStyle === "italic" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontStyle:
|
||||
element.fontStyle === "italic"
|
||||
? "normal"
|
||||
: "italic",
|
||||
],
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 italic"
|
||||
>
|
||||
I
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "underline"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "underline"
|
||||
? "none"
|
||||
: "underline",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 italic"
|
||||
>
|
||||
I
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "underline"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "underline"
|
||||
? "none"
|
||||
: "underline",
|
||||
],
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 underline"
|
||||
>
|
||||
U
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "line-through"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "line-through"
|
||||
? "none"
|
||||
: "line-through",
|
||||
},
|
||||
},
|
||||
})
|
||||
],
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 line-through"
|
||||
>
|
||||
S
|
||||
</Button>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Font size</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.fontSize]}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
if (initialFontSizeRef.current === null) {
|
||||
initialFontSizeRef.current = element.fontSize;
|
||||
}
|
||||
className="h-8 px-3 underline"
|
||||
>
|
||||
U
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "line-through"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "line-through"
|
||||
? "none"
|
||||
: "line-through",
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: value },
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 line-through"
|
||||
>
|
||||
S
|
||||
</Button>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Font size</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.fontSize]}
|
||||
min={8}
|
||||
max={300}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: value },
|
||||
});
|
||||
setFontSizeInput(value.toString());
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={fontSizeInput}
|
||||
min={8}
|
||||
max={300}
|
||||
onChange={(e) =>
|
||||
handleFontSizeChange({ value: e.target.value })
|
||||
}
|
||||
onBlur={handleFontSizeBlur}
|
||||
className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Color</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<ColorPicker
|
||||
value={uppercase({
|
||||
string: (element.color || "FFFFFF").replace("#", ""),
|
||||
})}
|
||||
onChange={(color) => {
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
}}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Opacity</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.opacity * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: value / 100 },
|
||||
onValueCommit={([value]) => {
|
||||
if (initialFontSizeRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontSize: initialFontSizeRef.current,
|
||||
},
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
setOpacityInput(value.toString());
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={opacityInput}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) =>
|
||||
handleOpacityChange({ value: e.target.value })
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: value },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialFontSizeRef.current = null;
|
||||
}
|
||||
onBlur={handleOpacityBlur}
|
||||
className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Background</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorPicker
|
||||
value={capitalizeFirstLetter({
|
||||
string:
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: element.backgroundColor.replace("#", ""),
|
||||
})}
|
||||
onChange={(color) =>
|
||||
handleColorChange({ color: `#${color}` })
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={fontSizeDisplay}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
onFocus={() => {
|
||||
isEditingFontSize.current = true;
|
||||
fontSizeDraft.current = element.fontSize.toString();
|
||||
forceRender();
|
||||
}}
|
||||
onChange={(e) =>
|
||||
handleFontSizeChange({ value: e.target.value })
|
||||
}
|
||||
onBlur={handleFontSizeBlur}
|
||||
className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup title="Appearance" collapsible={false}>
|
||||
<div className="space-y-6">
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Color</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<ColorPicker
|
||||
value={uppercase({
|
||||
string: (element.color || "FFFFFF").replace("#", ""),
|
||||
})}
|
||||
onChange={(color) => {
|
||||
if (initialColorRef.current === null) {
|
||||
initialColorRef.current = element.color || "#FFFFFF";
|
||||
}
|
||||
if (initialColorRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChangeEnd={(color) => {
|
||||
if (initialColorRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: initialColorRef.current },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialColorRef.current = null;
|
||||
}
|
||||
}}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Opacity</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.opacity * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
if (initialOpacityRef.current === null) {
|
||||
initialOpacityRef.current = element.opacity;
|
||||
}
|
||||
containerRef={containerRef}
|
||||
className={
|
||||
element.backgroundColor === "transparent"
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: value / 100 },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
}}
|
||||
onValueCommit={([value]) => {
|
||||
if (initialOpacityRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: initialOpacityRef.current },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: value / 100 },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialOpacityRef.current = null;
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleTransparentToggle({
|
||||
isTransparent:
|
||||
element.backgroundColor !== "transparent",
|
||||
})
|
||||
}
|
||||
className="bg-panel-accent size-9 overflow-hidden rounded-full p-0"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={LayoutGridIcon}
|
||||
className={cn(
|
||||
"text-foreground",
|
||||
element.backgroundColor === "transparent" &&
|
||||
"text-primary",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Transparent background</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={opacityDisplay}
|
||||
min={0}
|
||||
max={100}
|
||||
onFocus={() => {
|
||||
isEditingOpacity.current = true;
|
||||
opacityDraft.current = Math.round(
|
||||
element.opacity * 100,
|
||||
).toString();
|
||||
forceRender();
|
||||
}}
|
||||
onChange={(e) =>
|
||||
handleOpacityChange({ value: e.target.value })
|
||||
}
|
||||
onBlur={handleOpacityBlur}
|
||||
className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Background</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<ColorPicker
|
||||
value={
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: element.backgroundColor.replace("#", "")
|
||||
}
|
||||
onChange={(color) =>
|
||||
handleColorChange({ color: `#${color}` })
|
||||
}
|
||||
onChangeEnd={(color) => handleColorChangeEnd({ color })}
|
||||
containerRef={containerRef}
|
||||
className={
|
||||
element.backgroundColor === "transparent"
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
</PanelBaseView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,12 +351,13 @@ 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}
|
||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||
rulerRef={rulerRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
handleWheel={handleWheel}
|
||||
handleTimelineContentClick={handleRulerClick}
|
||||
handleRulerTrackingMouseDown={handleRulerMouseDown}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
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";
|
||||
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
|
||||
import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
|
||||
import { TimelineTick } from "./timeline-tick";
|
||||
|
||||
interface TimelineRulerProps {
|
||||
zoomLevel: number;
|
||||
dynamicTimelineWidth: number;
|
||||
rulerRef: React.Ref<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLElement | null>;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
||||
|
|
@ -19,6 +21,7 @@ export function TimelineRuler({
|
|||
zoomLevel,
|
||||
dynamicTimelineWidth,
|
||||
rulerRef,
|
||||
tracksScrollRef,
|
||||
handleWheel,
|
||||
handleTimelineContentClick,
|
||||
handleRulerTrackingMouseDown,
|
||||
|
|
@ -37,8 +40,47 @@ export function TimelineRuler({
|
|||
});
|
||||
const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
|
||||
|
||||
const { scrollLeft, viewportWidth } = useScrollPosition({
|
||||
scrollRef: tracksScrollRef,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
const visibleEndTime =
|
||||
(scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
|
||||
|
||||
const startTickIndex = Math.max(
|
||||
0,
|
||||
Math.floor(visibleStartTime / tickIntervalSeconds),
|
||||
);
|
||||
const endTickIndex = Math.min(
|
||||
tickCount - 1,
|
||||
Math.ceil(visibleEndTime / tickIntervalSeconds),
|
||||
);
|
||||
|
||||
const timelineTicks: Array<JSX.Element> = [];
|
||||
for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) {
|
||||
for (
|
||||
let tickIndex = startTickIndex;
|
||||
tickIndex <= endTickIndex;
|
||||
tickIndex += 1
|
||||
) {
|
||||
const time = tickIndex * tickIntervalSeconds;
|
||||
if (time > effectiveDuration) break;
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
|
|
@ -22,8 +22,8 @@ export function ThemeToggle({
|
|||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
className={cn("h-7", className)}
|
||||
variant="ghost"
|
||||
className={cn("size-8", className)}
|
||||
onClick={(e) => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
onToggle?.(e);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const buttonVariants = cva(
|
|||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary-hover",
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
background:
|
||||
"bg-background text-foreground hover:bg-background/90",
|
||||
foreground:
|
||||
|
|
@ -22,13 +22,14 @@ const buttonVariants = cva(
|
|||
outline:
|
||||
"border border-border bg-transparent hover:bg-accent/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-foreground/15 border border-input",
|
||||
"bg-secondary text-secondary-foreground border border-secondary-border",
|
||||
text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
|
||||
ghost: "bg-transparent hover:bg-accent",
|
||||
link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
|
||||
},
|
||||
size: {
|
||||
default: "h-9.5 px-4 py-2",
|
||||
sm: "h-8 px-3 text-xs",
|
||||
sm: "h-8 p-1 px-2 text-xs rounded-sm",
|
||||
lg: "h-10 p-5 px-6",
|
||||
icon: "size-7",
|
||||
text: "p-0",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Input } from "./input";
|
|||
interface ColorPickerProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onChangeEnd?: (value: string) => void;
|
||||
className?: string;
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
|
@ -86,7 +87,7 @@ const hsvToHex = (h: number, s: number, v: number) => {
|
|||
};
|
||||
|
||||
const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
({ className, value = "FFFFFF", onChange, containerRef, ...props }, ref) => {
|
||||
({ className, value = "FFFFFF", onChange, onChangeEnd, containerRef, ...props }, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>(
|
||||
null,
|
||||
|
|
@ -102,6 +103,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
const saturationRef = useRef<HTMLButtonElement>(null);
|
||||
const hueRef = useRef<HTMLButtonElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const latestDragColorRef = useRef<string | null>(null);
|
||||
|
||||
const [h, s, v] = hexToHsv(value);
|
||||
const displayHue = s > 0 ? h : internalHue;
|
||||
|
|
@ -144,6 +146,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
const newS = x;
|
||||
const newV = 1 - y;
|
||||
const newHex = hsvToHex(displayHue, newS, newV);
|
||||
latestDragColorRef.current = newHex;
|
||||
onChange?.(newHex);
|
||||
}
|
||||
|
||||
|
|
@ -157,12 +160,17 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
setInternalHue(newH);
|
||||
if (s > 0) {
|
||||
const newHex = hsvToHex(newH, s, v);
|
||||
latestDragColorRef.current = newHex;
|
||||
onChange?.(newHex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (latestDragColorRef.current !== null && onChangeEnd) {
|
||||
onChangeEnd(latestDragColorRef.current);
|
||||
latestDragColorRef.current = null;
|
||||
}
|
||||
setIsDragging(null);
|
||||
};
|
||||
|
||||
|
|
@ -187,6 +195,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
const newS = x;
|
||||
const newV = 1 - y;
|
||||
const newHex = hsvToHex(displayHue, newS, newV);
|
||||
latestDragColorRef.current = newHex;
|
||||
onChange?.(newHex);
|
||||
};
|
||||
|
||||
|
|
@ -201,6 +210,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
setInternalHue(newH);
|
||||
if (s > 0) {
|
||||
const newHex = hsvToHex(newH, s, v);
|
||||
latestDragColorRef.current = newHex;
|
||||
onChange?.(newHex);
|
||||
}
|
||||
};
|
||||
|
|
@ -235,14 +245,14 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-panel-accent flex h-9 items-center gap-2 rounded-full px-[0.45rem]",
|
||||
"bg-accent flex h-8 items-center gap-2 rounded-md px-[0.45rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className="size-6 cursor-pointer rounded-full hover:ring-2 hover:ring-white/20"
|
||||
className="size-4.5 cursor-pointer border rounded-sm hover:ring-2 hover:ring-white/20"
|
||||
style={{ backgroundColor: `#${value}` }}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -259,7 +269,8 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
/>
|
||||
<div className="flex flex-1 items-center">
|
||||
<Input
|
||||
className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0"
|
||||
className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0 uppercase"
|
||||
size="sm"
|
||||
containerClassName="w-full"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ const CommandItem = React.forwardRef<
|
|||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"data-[selected=true]:bg-popover-hover data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
|||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const dropdownMenuItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface FontPickerProps {
|
||||
defaultValue?: FontFamily;
|
||||
|
|
@ -21,16 +22,15 @@ export function FontPicker({
|
|||
return (
|
||||
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
|
||||
<SelectTrigger
|
||||
className={`bg-panel-accent h-9 w-full text-base ${className || ""}`}
|
||||
className={cn("w-full", className)}
|
||||
>
|
||||
<SelectValue placeholder="Select a font" className="text-sm" />
|
||||
<SelectValue placeholder="Select a font" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((font) => (
|
||||
<SelectItem
|
||||
key={font.value}
|
||||
value={font.value}
|
||||
className="text-sn"
|
||||
style={{ fontFamily: font.value }}
|
||||
>
|
||||
{font.label}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function InputWithBack({
|
|||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-panel-accent !size-9 rounded-full"
|
||||
className="bg-accent !size-9 rounded-full"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
|
|
@ -77,7 +77,7 @@ export function InputWithBack({
|
|||
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className="bg-panel-accent w-full pl-9"
|
||||
className="bg-accent w-full pl-9"
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||
import { cn } from "@/utils/ui";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm text-muted-foreground font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
"text-xs text-muted-foreground font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import * as React from "react";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ function Prose({ children, html, className }: ProseProps) {
|
|||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"prose prose-h2:font-semibold prose-h1:text-xl prose-a:text-blue-600 prose-p:text-justify dark:prose-invert mx-auto max-w-none",
|
||||
"prose prose-h2:font-semibold prose-h1:text-xl prose-a:text-blue-600 prose-p:first:mt-0 dark:prose-invert mx-auto max-w-none px-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const SelectGroup = SelectPrimitive.Group;
|
|||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const selectItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
@ -35,7 +35,7 @@ const SelectTrigger = React.forwardRef<
|
|||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none",
|
||||
className,
|
||||
)}
|
||||
|
|
@ -159,7 +159,7 @@ const SelectSeparator = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-border/60 mx-1 my-2 h-px", className)}
|
||||
className={cn("bg-border mx-1 my-2 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const Slider = React.forwardRef<
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="bg-primary/20 relative h-1.5 w-full grow overflow-hidden rounded-full">
|
||||
<SliderPrimitive.Track className="bg-accent relative h-1.5 w-full grow overflow-hidden rounded-full">
|
||||
<SliderPrimitive.Range className="bg-primary absolute h-full" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" />
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-input bg-panel-accent inline-flex h-7 overflow-hidden rounded-lg border",
|
||||
"border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -39,7 +39,7 @@ const SplitButtonSide = forwardRef<
|
|||
ref={ref}
|
||||
variant="text"
|
||||
className={cn(
|
||||
"bg-panel-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
|
||||
"bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
|
||||
onClick
|
||||
? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
|
||||
: "cursor-default select-text",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
|||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-panel-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
|
||||
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import type { TextElement } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "./timeline-constants";
|
||||
|
||||
export const MIN_FONT_SIZE = 5;
|
||||
export const MAX_FONT_SIZE = 300;
|
||||
|
||||
/**
|
||||
* higher value: smaller font size
|
||||
* lower value: larger font size
|
||||
*/
|
||||
export const FONT_SIZE_SCALE_REFERENCE = 90;
|
||||
|
||||
export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
|
||||
type: "text",
|
||||
name: "Text",
|
||||
content: "Default Text",
|
||||
fontSize: 48,
|
||||
content: "Default text",
|
||||
fontSize: 15,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
backgroundColor: "transparent",
|
||||
|
|
|
|||
|
|
@ -86,6 +86,11 @@ export class AudioManager {
|
|||
const detail = (event as CustomEvent<{ time: number }>).detail;
|
||||
if (!detail) return;
|
||||
|
||||
if (this.editor.playback.getIsScrubbing()) {
|
||||
this.stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.editor.playback.getIsPlaying()) {
|
||||
void this.startPlayback({ time: detail.time });
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export class PlaybackManager {
|
|||
private volume = 1;
|
||||
private muted = false;
|
||||
private previousVolume = 1;
|
||||
private isScrubbing = false;
|
||||
private listeners = new Set<() => void>();
|
||||
private playbackTimer: number | null = null;
|
||||
private lastUpdate = 0;
|
||||
|
|
@ -101,6 +102,15 @@ export class PlaybackManager {
|
|||
return this.muted;
|
||||
}
|
||||
|
||||
setScrubbing({ isScrubbing }: { isScrubbing: boolean }): void {
|
||||
this.isScrubbing = isScrubbing;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getIsScrubbing(): boolean {
|
||||
return this.isScrubbing;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { EditorCore } from "@/core";
|
|||
import type {
|
||||
TrackType,
|
||||
TimelineTrack,
|
||||
TextElement,
|
||||
TimelineElement,
|
||||
ClipboardItem,
|
||||
} from "@/types/timeline";
|
||||
|
|
@ -19,12 +18,13 @@ import {
|
|||
DuplicateElementsCommand,
|
||||
ToggleElementsVisibilityCommand,
|
||||
ToggleElementsMutedCommand,
|
||||
UpdateTextElementCommand,
|
||||
UpdateElementCommand,
|
||||
SplitElementsCommand,
|
||||
PasteCommand,
|
||||
UpdateElementStartTimeCommand,
|
||||
MoveElementCommand,
|
||||
} from "@/lib/commands/timeline";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
||||
|
||||
export class TimelineManager {
|
||||
|
|
@ -199,32 +199,27 @@ export class TimelineManager {
|
|||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
updateTextElement({
|
||||
trackId,
|
||||
elementId,
|
||||
updateElements({
|
||||
updates,
|
||||
pushHistory = true,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<
|
||||
Pick<
|
||||
TextElement,
|
||||
| "content"
|
||||
| "fontSize"
|
||||
| "fontFamily"
|
||||
| "color"
|
||||
| "backgroundColor"
|
||||
| "textAlign"
|
||||
| "fontWeight"
|
||||
| "fontStyle"
|
||||
| "textDecoration"
|
||||
| "transform"
|
||||
| "opacity"
|
||||
>
|
||||
>;
|
||||
updates: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<Record<string, unknown>>;
|
||||
}>;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
const command = new UpdateTextElementCommand(trackId, elementId, updates);
|
||||
this.editor.command.execute({ command });
|
||||
const commands = updates.map(
|
||||
({ trackId, elementId, updates: elementUpdates }) =>
|
||||
new UpdateElementCommand(trackId, elementId, elementUpdates),
|
||||
);
|
||||
const command = commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
||||
if (pushHistory) {
|
||||
this.editor.command.execute({ command });
|
||||
} else {
|
||||
command.execute();
|
||||
}
|
||||
}
|
||||
|
||||
duplicateElements({
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ export function useEditorActions() {
|
|||
editor.timeline.deleteElements({
|
||||
elements: selectedElements,
|
||||
});
|
||||
editor.selection.clearSelection();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,10 +60,7 @@ export function useEdgeAutoScroll({
|
|||
mouseXRelative > viewportWidth - edgeThreshold &&
|
||||
rulerViewport.scrollLeft < scrollMax
|
||||
) {
|
||||
const edgeDistance = Math.max(
|
||||
0,
|
||||
viewportWidth - edgeThreshold - mouseXRelative,
|
||||
);
|
||||
const edgeDistance = Math.max(0, viewportWidth - mouseXRelative);
|
||||
const intensity = 1 - edgeDistance / edgeThreshold;
|
||||
scrollSpeed = maxScrollSpeed * intensity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
interface UseScrollPositionReturn {
|
||||
scrollLeft: number;
|
||||
viewportWidth: number;
|
||||
}
|
||||
|
||||
export function useScrollPosition({
|
||||
scrollRef,
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLElement | null>;
|
||||
}): UseScrollPositionReturn {
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
}
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
setScrollLeft(scrollElement.scrollLeft);
|
||||
setViewportWidth(scrollElement.clientWidth);
|
||||
rafIdRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updatePosition();
|
||||
});
|
||||
|
||||
updatePosition();
|
||||
|
||||
scrollElement.addEventListener("scroll", updatePosition, { passive: true });
|
||||
resizeObserver.observe(scrollElement);
|
||||
|
||||
return () => {
|
||||
scrollElement.removeEventListener("scroll", updatePosition);
|
||||
resizeObserver.disconnect();
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
}
|
||||
};
|
||||
}, [scrollRef]);
|
||||
|
||||
return { scrollLeft, viewportWidth };
|
||||
}
|
||||
|
|
@ -24,13 +24,13 @@ export function useTimelinePlayhead({
|
|||
const currentTime = editor.playback.getCurrentTime();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const isScrubbing = editor.playback.getIsScrubbing();
|
||||
|
||||
const seek = useCallback(
|
||||
({ time }: { time: number }) => editor.playback.seek({ time }),
|
||||
[editor.playback],
|
||||
);
|
||||
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
|
||||
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
|
||||
|
|
@ -81,10 +81,10 @@ export function useTimelinePlayhead({
|
|||
({ event }: { event: React.MouseEvent }) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsScrubbing(true);
|
||||
editor.playback.setScrubbing({ isScrubbing: true });
|
||||
handleScrub({ event });
|
||||
},
|
||||
[handleScrub],
|
||||
[handleScrub, editor.playback],
|
||||
);
|
||||
|
||||
const handleRulerMouseDown = useCallback(
|
||||
|
|
@ -97,10 +97,10 @@ export function useTimelinePlayhead({
|
|||
setIsDraggingRuler(true);
|
||||
setHasDraggedRuler(false);
|
||||
|
||||
setIsScrubbing(true);
|
||||
editor.playback.setScrubbing({ isScrubbing: true });
|
||||
handleScrub({ event });
|
||||
},
|
||||
[handleScrub, playheadRef],
|
||||
[handleScrub, playheadRef, editor.playback],
|
||||
);
|
||||
|
||||
const handlePlayheadMouseDownEvent = useCallback(
|
||||
|
|
@ -132,7 +132,7 @@ export function useTimelinePlayhead({
|
|||
};
|
||||
|
||||
const handleMouseUp = ({ event }: { event: MouseEvent }) => {
|
||||
setIsScrubbing(false);
|
||||
editor.playback.setScrubbing({ isScrubbing: false });
|
||||
if (scrubTime !== null) {
|
||||
seek({ time: scrubTime });
|
||||
editor.project.setTimelineViewState({
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function useTimelineZoom({
|
|||
null,
|
||||
);
|
||||
|
||||
const [zoomLevel, setZoomLevel] = useState(() => {
|
||||
const [zoomLevel, setZoomLevelRaw] = useState(() => {
|
||||
if (initialZoom !== undefined) {
|
||||
hasInitializedRef.current = true;
|
||||
return Math.max(
|
||||
|
|
@ -56,6 +56,18 @@ export function useTimelineZoom({
|
|||
});
|
||||
const previousZoomRef = useRef(zoomLevel);
|
||||
const hasRestoredScrollRef = useRef(false);
|
||||
const preZoomScrollLeftRef = useRef(0);
|
||||
|
||||
const setZoomLevel = useCallback(
|
||||
(updater: number | ((prev: number) => number)) => {
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (scrollElement) {
|
||||
preZoomScrollLeftRef.current = scrollElement.scrollLeft;
|
||||
}
|
||||
setZoomLevelRaw(updater);
|
||||
},
|
||||
[tracksScrollRef],
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(event: ReactWheelEvent) => {
|
||||
|
|
@ -82,7 +94,7 @@ export function useTimelineZoom({
|
|||
return;
|
||||
}
|
||||
},
|
||||
[minZoom],
|
||||
[minZoom, setZoomLevel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -99,7 +111,7 @@ export function useTimelineZoom({
|
|||
}
|
||||
return prev;
|
||||
});
|
||||
}, [minZoom, initialZoom]);
|
||||
}, [minZoom, initialZoom, setZoomLevel]);
|
||||
|
||||
const wrappedSetZoomLevel = useCallback(
|
||||
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
|
||||
|
|
@ -115,7 +127,7 @@ export function useTimelineZoom({
|
|||
return clampedZoom;
|
||||
});
|
||||
},
|
||||
[minZoom],
|
||||
[minZoom, setZoomLevel],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
|
@ -128,7 +140,7 @@ export function useTimelineZoom({
|
|||
return;
|
||||
}
|
||||
|
||||
const currentScrollLeft = scrollElement.scrollLeft;
|
||||
const currentScrollLeft = preZoomScrollLeftRef.current;
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useContainerSize({
|
||||
containerRef,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
setSize({ width, height });
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export function useFullscreen({
|
||||
containerRef,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
setIsFullscreen(document.fullscreenElement !== null);
|
||||
};
|
||||
document.addEventListener("fullscreenchange", handleChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
containerRef.current.requestFullscreen();
|
||||
}
|
||||
}, [containerRef]);
|
||||
|
||||
return { isFullscreen, toggleFullscreen };
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Transform, TimelineTrack } from "@/types/timeline";
|
||||
|
||||
interface DragState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
tracksSnapshot: TimelineTrack[];
|
||||
elements: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function usePreviewInteraction({
|
||||
canvasRef,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
|
||||
const selectedElements = useSyncExternalStore(
|
||||
(listener) => editor.selection.subscribe(listener),
|
||||
() => editor.selection.getSelectedElements(),
|
||||
);
|
||||
|
||||
const getCanvasCoordinates = useCallback(
|
||||
({ clientX, clientY }: { clientX: number; clientY: number }) => {
|
||||
if (!canvasRef.current) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const logicalWidth = canvasRef.current.width;
|
||||
const logicalHeight = canvasRef.current.height;
|
||||
const scaleX = logicalWidth / rect.width;
|
||||
const scaleY = logicalHeight / rect.height;
|
||||
|
||||
const canvasX = (clientX - rect.left) * scaleX;
|
||||
const canvasY = (clientY - rect.top) * scaleY;
|
||||
|
||||
return { x: canvasX, y: canvasY };
|
||||
},
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (selectedElements.length === 0) return;
|
||||
|
||||
const elementsWithTracks = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
});
|
||||
|
||||
const draggableElements = elementsWithTracks.filter(
|
||||
({ element }) =>
|
||||
element.type === "video" ||
|
||||
element.type === "image" ||
|
||||
element.type === "text" ||
|
||||
element.type === "sticker",
|
||||
);
|
||||
|
||||
if (draggableElements.length === 0) return;
|
||||
|
||||
const startPos = getCanvasCoordinates({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
|
||||
dragStateRef.current = {
|
||||
startX: startPos.x,
|
||||
startY: startPos.y,
|
||||
tracksSnapshot: editor.timeline.getTracks(),
|
||||
elements: draggableElements.map(({ track, element }) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
initialTransform: (element as { transform: Transform }).transform,
|
||||
})),
|
||||
};
|
||||
|
||||
setIsDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
},
|
||||
[selectedElements, editor, getCanvasCoordinates],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (!dragStateRef.current || !isDragging) return;
|
||||
|
||||
const currentPos = getCanvasCoordinates({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
|
||||
const deltaX = currentPos.x - dragStateRef.current.startX;
|
||||
const deltaY = currentPos.y - dragStateRef.current.startY;
|
||||
|
||||
for (const { trackId, elementId, initialTransform } of dragStateRef
|
||||
.current.elements) {
|
||||
const newPosition = {
|
||||
x: initialTransform.position.x + deltaX,
|
||||
y: initialTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isDragging, getCanvasCoordinates, editor],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (!dragStateRef.current || !isDragging) return;
|
||||
|
||||
const currentPos = getCanvasCoordinates({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
|
||||
const deltaX = currentPos.x - dragStateRef.current.startX;
|
||||
const deltaY = currentPos.y - dragStateRef.current.startY;
|
||||
|
||||
const hasMovement = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5;
|
||||
|
||||
|
||||
if (!hasMovement) {
|
||||
dragStateRef.current = null;
|
||||
setIsDragging(false);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// revert to pre-drag state so the command captures the correct undo snapshot
|
||||
editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot);
|
||||
|
||||
const updates = dragStateRef.current.elements.map(
|
||||
({ trackId, elementId, initialTransform }) => {
|
||||
const newPosition = {
|
||||
x: initialTransform.position.x + deltaX,
|
||||
y: initialTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
return {
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
editor.timeline.updateElements({ updates });
|
||||
|
||||
dragStateRef.current = null;
|
||||
setIsDragging(false);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
},
|
||||
[isDragging, getCanvasCoordinates, editor],
|
||||
);
|
||||
|
||||
return {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerUp,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Command } from "./base-command";
|
||||
|
||||
export class BatchCommand extends Command {
|
||||
constructor(private commands: Command[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
for (const command of this.commands) {
|
||||
command.execute();
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
for (const command of [...this.commands].reverse()) {
|
||||
command.undo();
|
||||
}
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
for (const command of this.commands) {
|
||||
command.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { Command } from "./base-command";
|
||||
export { BatchCommand } from "./batch-command";
|
||||
|
||||
export * from "./timeline";
|
||||
export * from "./media";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { wouldElementOverlap } from "@/lib/timeline/element-utils";
|
|||
import {
|
||||
buildEmptyTrack,
|
||||
getHighestInsertIndexForTrack,
|
||||
isMainTrack,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
|
|
@ -65,11 +67,32 @@ export class PasteCommand extends Command {
|
|||
|
||||
if (resolvedTargetIndex >= 0) {
|
||||
const targetTrack = updatedTracks[resolvedTargetIndex];
|
||||
let adjustedElements = elementsToAdd;
|
||||
|
||||
if (isMainTrack(targetTrack)) {
|
||||
const earliestElement = elementsToAdd.reduce((earliest, element) =>
|
||||
element.startTime < earliest.startTime ? element : earliest,
|
||||
);
|
||||
const adjustedEarliestStartTime = enforceMainTrackStart({
|
||||
tracks: updatedTracks,
|
||||
targetTrackId: targetTrack.id,
|
||||
requestedStartTime: earliestElement.startTime,
|
||||
});
|
||||
const delta = adjustedEarliestStartTime - earliestElement.startTime;
|
||||
|
||||
if (delta !== 0) {
|
||||
adjustedElements = elementsToAdd.map((element) => ({
|
||||
...element,
|
||||
startTime: Math.max(0, element.startTime + delta),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
updatedTracks[resolvedTargetIndex] = {
|
||||
...targetTrack,
|
||||
elements: [...targetTrack.elements, ...elementsToAdd],
|
||||
elements: [...targetTrack.elements, ...adjustedElements],
|
||||
} as TimelineTrack;
|
||||
for (const element of elementsToAdd) {
|
||||
for (const element of adjustedElements) {
|
||||
this.pastedElements.push({
|
||||
trackId: targetTrack.id,
|
||||
elementId: element.id,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export { UpdateElementTrimCommand } from "./update-element-trim";
|
|||
export { UpdateElementDurationCommand } from "./update-element-duration";
|
||||
export { UpdateElementStartTimeCommand } from "./update-element-start-time";
|
||||
export { SplitElementsCommand } from "./split-elements";
|
||||
export { UpdateTextElementCommand } from "./update-text-element";
|
||||
export { UpdateElementCommand } from "./update-element";
|
||||
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
|
||||
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
|
||||
export { MoveElementCommand } from "./move-elements";
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
canElementGoOnTrack,
|
||||
getDefaultInsertIndexForTrack,
|
||||
validateElementTrackCompatibility,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
|
@ -204,9 +205,18 @@ export class InsertElementCommand extends Command {
|
|||
return null;
|
||||
}
|
||||
|
||||
const adjustedElement = this.adjustElementForMainTrack({
|
||||
tracks,
|
||||
targetTrackId: targetTrack.id,
|
||||
element,
|
||||
});
|
||||
|
||||
const updatedTracks = tracks.map((track) =>
|
||||
track.id === targetTrack.id
|
||||
? { ...track, elements: [...track.elements, element] }
|
||||
? {
|
||||
...track,
|
||||
elements: [...track.elements, adjustedElement],
|
||||
}
|
||||
: track,
|
||||
) as TimelineTrack[];
|
||||
|
||||
|
|
@ -248,9 +258,18 @@ export class InsertElementCommand extends Command {
|
|||
});
|
||||
|
||||
if (existingTrack) {
|
||||
const adjustedElement = this.adjustElementForMainTrack({
|
||||
tracks,
|
||||
targetTrackId: existingTrack.id,
|
||||
element,
|
||||
});
|
||||
|
||||
const updatedTracks = tracks.map((track) =>
|
||||
track.id === existingTrack.id
|
||||
? { ...track, elements: [...track.elements, element] }
|
||||
? {
|
||||
...track,
|
||||
elements: [...track.elements, adjustedElement],
|
||||
}
|
||||
: track,
|
||||
) as TimelineTrack[];
|
||||
|
||||
|
|
@ -298,6 +317,23 @@ export class InsertElementCommand extends Command {
|
|||
});
|
||||
}
|
||||
|
||||
private adjustElementForMainTrack({
|
||||
tracks,
|
||||
targetTrackId,
|
||||
element,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
targetTrackId: string;
|
||||
element: TimelineElement;
|
||||
}): TimelineElement {
|
||||
const adjustedStartTime = enforceMainTrackStart({
|
||||
tracks,
|
||||
targetTrackId,
|
||||
requestedStartTime: element.startTime,
|
||||
});
|
||||
return { ...element, startTime: adjustedStartTime };
|
||||
}
|
||||
|
||||
private getTrackTypeForElement({
|
||||
element,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
buildEmptyTrack,
|
||||
isMainTrack,
|
||||
validateElementTrackCompatibility,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
|
|
@ -66,9 +67,16 @@ export class MoveElementCommand extends Command {
|
|||
return;
|
||||
}
|
||||
|
||||
const adjustedStartTime = enforceMainTrackStart({
|
||||
tracks: tracksToUpdate,
|
||||
targetTrackId: this.targetTrackId,
|
||||
requestedStartTime: this.newStartTime,
|
||||
excludeElementId: this.elementId,
|
||||
});
|
||||
|
||||
const movedElement: TimelineElement = {
|
||||
...element,
|
||||
startTime: this.newStartTime,
|
||||
startTime: adjustedStartTime,
|
||||
};
|
||||
|
||||
const isSameTrack = this.sourceTrackId === this.targetTrackId;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
||||
|
||||
export class UpdateElementStartTimeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
|
@ -16,7 +17,8 @@ export class UpdateElementStartTimeCommand extends Command {
|
|||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
const currentTracks = this.savedState;
|
||||
const updatedTracks = currentTracks.map((track) => {
|
||||
const hasElementsToUpdate = this.elements.some(
|
||||
(el) => el.trackId === track.id,
|
||||
);
|
||||
|
|
@ -29,9 +31,19 @@ export class UpdateElementStartTimeCommand extends Command {
|
|||
const shouldUpdate = this.elements.some(
|
||||
(el) => el.elementId === element.id && el.trackId === track.id,
|
||||
);
|
||||
return shouldUpdate
|
||||
? { ...element, startTime: Math.max(0, this.startTime) }
|
||||
: element;
|
||||
if (!shouldUpdate) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const baseStartTime = Math.max(0, this.startTime);
|
||||
const adjustedStartTime = enforceMainTrackStart({
|
||||
tracks: currentTracks,
|
||||
targetTrackId: track.id,
|
||||
requestedStartTime: baseStartTime,
|
||||
excludeElementId: element.id,
|
||||
});
|
||||
|
||||
return { ...element, startTime: adjustedStartTime };
|
||||
});
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,27 +1,14 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TextElement, TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class UpdateTextElementCommand extends Command {
|
||||
export class UpdateElementCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(
|
||||
private trackId: string,
|
||||
private elementId: string,
|
||||
private updates: Partial<
|
||||
Pick<
|
||||
TextElement,
|
||||
| "content"
|
||||
| "fontSize"
|
||||
| "fontFamily"
|
||||
| "color"
|
||||
| "backgroundColor"
|
||||
| "textAlign"
|
||||
| "fontWeight"
|
||||
| "fontStyle"
|
||||
| "textDecoration"
|
||||
>
|
||||
>,
|
||||
private updates: Partial<Record<string, unknown>>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
|
@ -33,9 +20,7 @@ export class UpdateTextElementCommand extends Command {
|
|||
const updatedTracks = this.savedState.map((t) => {
|
||||
if (t.id !== this.trackId) return t;
|
||||
const newElements = t.elements.map((el) =>
|
||||
el.id === this.elementId && el.type === "text"
|
||||
? { ...el, ...this.updates }
|
||||
: el,
|
||||
el.id === this.elementId ? { ...el, ...this.updates } : el,
|
||||
);
|
||||
return { ...t, elements: newElements } as typeof t;
|
||||
});
|
||||
|
|
@ -2,6 +2,9 @@ import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
|
|||
|
||||
export const users = pgTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
|
||||
// todo: implement fully anonymous sign-in for privacy
|
||||
// we don't have any auth flows currently so this is fine for now
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ export async function processMediaAssets({
|
|||
width = videoInfo.width;
|
||||
height = videoInfo.height;
|
||||
fps = Number.isFinite(videoInfo.fps)
|
||||
? Math.round(videoInfo.fps * 1000) / 1000
|
||||
? Math.round(videoInfo.fps)
|
||||
: undefined;
|
||||
|
||||
thumbnailUrl = await generateThumbnail({
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { TimelineTrack, ElementType } from "@/types/timeline";
|
|||
import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
|
||||
import { wouldElementOverlap } from "./element-utils";
|
||||
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
|
||||
import { isMainTrack } from "./track-utils";
|
||||
import { isMainTrack, enforceMainTrackStart } from "./track-utils";
|
||||
|
||||
function getTrackAtY({
|
||||
mouseY,
|
||||
|
|
@ -185,11 +185,21 @@ export function computeDropTarget({
|
|||
});
|
||||
|
||||
if (isTrackCompatible && !hasOverlap) {
|
||||
const targetTrack = tracks[trackIndex];
|
||||
// safe: snap to 0 only happens when element becomes the new earliest,
|
||||
// meaning the space before the current earliest is empty
|
||||
const adjustedXPosition = enforceMainTrackStart({
|
||||
tracks,
|
||||
targetTrackId: targetTrack.id,
|
||||
requestedStartTime: xPosition,
|
||||
excludeElementId,
|
||||
});
|
||||
|
||||
return {
|
||||
trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
xPosition: adjustedXPosition,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ const TICK_FRAME_INTERVALS = [1, 2, 3, 5, 10, 15] as const;
|
|||
/**
|
||||
* second intervals for when we're zoomed out past frame-level detail.
|
||||
*/
|
||||
const SECOND_MULTIPLIERS = [1, 2, 3, 5, 10, 15, 30, 60] as const;
|
||||
const SECOND_MULTIPLIERS = [
|
||||
1, 2, 3, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* minimum pixel spacing between labels to keep them readable
|
||||
|
|
@ -233,11 +235,20 @@ function getFrameWithinSecond({
|
|||
}
|
||||
|
||||
/**
|
||||
* formats a timestamp as MM:SS.
|
||||
* formats a timestamp as MM:SS or H:MM:SS when >= 1 hour.
|
||||
*/
|
||||
function formatTimestamp({ timeInSeconds }: { timeInSeconds: number }): string {
|
||||
const totalSeconds = Math.round(timeInSeconds);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
|
||||
const mm = minutes.toString().padStart(2, "0");
|
||||
const ss = seconds.toString().padStart(2, "0");
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
return `${mm}:${ss}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
AudioTrack,
|
||||
StickerTrack,
|
||||
TextTrack,
|
||||
TimelineElement,
|
||||
} from "@/types/timeline";
|
||||
import {
|
||||
TRACK_COLORS,
|
||||
|
|
@ -242,3 +243,62 @@ export function validateElementTrackCompatibility({
|
|||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function getEarliestMainTrackElement({
|
||||
tracks,
|
||||
excludeElementId,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
excludeElementId?: string;
|
||||
}): TimelineElement | null {
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
if (!mainTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elements = mainTrack.elements.filter(
|
||||
(element) => !excludeElementId || element.id !== excludeElementId,
|
||||
);
|
||||
|
||||
if (elements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return elements.reduce((earliest, element) =>
|
||||
element.startTime < earliest.startTime ? element : earliest,
|
||||
);
|
||||
}
|
||||
|
||||
export function enforceMainTrackStart({
|
||||
tracks,
|
||||
targetTrackId,
|
||||
requestedStartTime,
|
||||
excludeElementId,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
targetTrackId: string;
|
||||
requestedStartTime: number;
|
||||
excludeElementId?: string;
|
||||
}): number {
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
if (!mainTrack || mainTrack.id !== targetTrackId) {
|
||||
return requestedStartTime;
|
||||
}
|
||||
|
||||
const earliestElement = getEarliestMainTrackElement({
|
||||
tracks,
|
||||
excludeElementId,
|
||||
});
|
||||
|
||||
if (!earliestElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// main track must always start at time 0; if this element would
|
||||
// become the earliest, pin it to the start
|
||||
if (requestedStartTime <= earliestElement.startTime) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return requestedStartTime;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,11 @@
|
|||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { BaseNode } from "./base-node";
|
||||
import { VisualNode, type VisualNodeParams } from "./visual-node";
|
||||
|
||||
const IMAGE_EPSILON = 1 / 1000;
|
||||
|
||||
export interface ImageNodeParams {
|
||||
export interface ImageNodeParams extends VisualNodeParams {
|
||||
url: string;
|
||||
duration: number;
|
||||
timeOffset: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export class ImageNode extends BaseNode<ImageNodeParams> {
|
||||
export class ImageNode extends VisualNode<ImageNodeParams> {
|
||||
private image?: HTMLImageElement;
|
||||
private readyPromise: Promise<void>;
|
||||
|
||||
|
|
@ -36,18 +25,6 @@ export class ImageNode extends BaseNode<ImageNodeParams> {
|
|||
});
|
||||
}
|
||||
|
||||
private getImageTime(time: number) {
|
||||
return time - this.params.timeOffset + this.params.trimStart;
|
||||
}
|
||||
|
||||
private isInRange(time: number) {
|
||||
const imageTime = this.getImageTime(time);
|
||||
return (
|
||||
imageTime >= this.params.trimStart - IMAGE_EPSILON &&
|
||||
imageTime < this.params.trimStart + this.params.duration
|
||||
);
|
||||
}
|
||||
|
||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||
await super.render({ renderer, time });
|
||||
|
||||
|
|
@ -61,40 +38,14 @@ export class ImageNode extends BaseNode<ImageNodeParams> {
|
|||
return;
|
||||
}
|
||||
|
||||
renderer.context.save();
|
||||
const mediaW = this.image.naturalWidth || renderer.width;
|
||||
const mediaH = this.image.naturalHeight || renderer.height;
|
||||
|
||||
if (this.params.opacity !== undefined) {
|
||||
renderer.context.globalAlpha = this.params.opacity;
|
||||
}
|
||||
|
||||
if (
|
||||
this.params.x !== undefined &&
|
||||
this.params.y !== undefined &&
|
||||
this.params.width !== undefined &&
|
||||
this.params.height !== undefined
|
||||
) {
|
||||
renderer.context.drawImage(
|
||||
this.image,
|
||||
this.params.x,
|
||||
this.params.y,
|
||||
this.params.width,
|
||||
this.params.height,
|
||||
);
|
||||
} else {
|
||||
const mediaW = this.image.naturalWidth || renderer.width;
|
||||
const mediaH = this.image.naturalHeight || renderer.height;
|
||||
const containScale = Math.min(
|
||||
renderer.width / mediaW,
|
||||
renderer.height / mediaH,
|
||||
);
|
||||
const drawW = mediaW * containScale;
|
||||
const drawH = mediaH * containScale;
|
||||
const drawX = (renderer.width - drawW) / 2;
|
||||
const drawY = (renderer.height - drawH) / 2;
|
||||
|
||||
renderer.context.drawImage(this.image, drawX, drawY, drawW, drawH);
|
||||
}
|
||||
|
||||
renderer.context.restore();
|
||||
this.renderVisual({
|
||||
renderer,
|
||||
source: this.image,
|
||||
sourceWidth: mediaW,
|
||||
sourceHeight: mediaH,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,12 @@
|
|||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { BaseNode, type BaseNodeParams } from "./base-node";
|
||||
import type { Transform } from "@/types/timeline";
|
||||
import { VisualNode, type VisualNodeParams } from "./visual-node";
|
||||
|
||||
const STICKER_EPSILON = 1 / 1000;
|
||||
|
||||
export type StickerNodeParams = BaseNodeParams & {
|
||||
export interface StickerNodeParams extends VisualNodeParams {
|
||||
iconName: string;
|
||||
duration: number;
|
||||
timeOffset: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
color?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class StickerNode extends BaseNode<StickerNodeParams> {
|
||||
export class StickerNode extends VisualNode<StickerNodeParams> {
|
||||
private image?: HTMLImageElement;
|
||||
private readyPromise: Promise<void>;
|
||||
|
||||
|
|
@ -40,18 +31,6 @@ export class StickerNode extends BaseNode<StickerNodeParams> {
|
|||
});
|
||||
}
|
||||
|
||||
private getStickerTime(time: number) {
|
||||
return time - this.params.timeOffset + this.params.trimStart;
|
||||
}
|
||||
|
||||
private isInRange(time: number) {
|
||||
const stickerTime = this.getStickerTime(time);
|
||||
return (
|
||||
stickerTime >= this.params.trimStart - STICKER_EPSILON &&
|
||||
stickerTime < this.params.trimStart + this.params.duration
|
||||
);
|
||||
}
|
||||
|
||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||
await super.render({ renderer, time });
|
||||
|
||||
|
|
@ -65,23 +44,11 @@ export class StickerNode extends BaseNode<StickerNodeParams> {
|
|||
return;
|
||||
}
|
||||
|
||||
const { transform, opacity } = this.params;
|
||||
const size = 200 * transform.scale;
|
||||
const x = renderer.width / 2 + transform.position.x - size / 2;
|
||||
const y = renderer.height / 2 + transform.position.y - size / 2;
|
||||
|
||||
renderer.context.save();
|
||||
renderer.context.globalAlpha = opacity;
|
||||
|
||||
if (transform.rotate !== 0) {
|
||||
const centerX = x + size / 2;
|
||||
const centerY = y + size / 2;
|
||||
renderer.context.translate(centerX, centerY);
|
||||
renderer.context.rotate((transform.rotate * Math.PI) / 180);
|
||||
renderer.context.translate(-centerX, -centerY);
|
||||
}
|
||||
|
||||
renderer.context.drawImage(this.image, x, y, size, size);
|
||||
renderer.context.restore();
|
||||
this.renderVisual({
|
||||
renderer,
|
||||
source: this.image,
|
||||
sourceWidth: 200,
|
||||
sourceHeight: 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { BaseNode } from "./base-node";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants";
|
||||
|
||||
function scaleFontSize({
|
||||
fontSize,
|
||||
canvasHeight,
|
||||
}: {
|
||||
fontSize: number;
|
||||
canvasHeight: number;
|
||||
}): number {
|
||||
return fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
|
||||
}
|
||||
|
||||
export type TextNodeParams = TextElement & {
|
||||
canvasCenter: { x: number; y: number };
|
||||
canvasHeight: number;
|
||||
textBaseline?: CanvasTextBaseline;
|
||||
};
|
||||
|
||||
|
|
@ -32,7 +44,11 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
|||
|
||||
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal";
|
||||
renderer.context.font = `${fontStyle} ${fontWeight} ${this.params.fontSize}px ${this.params.fontFamily}`;
|
||||
const scaledFontSize = scaleFontSize({
|
||||
fontSize: this.params.fontSize,
|
||||
canvasHeight: this.params.canvasHeight,
|
||||
});
|
||||
renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${this.params.fontFamily}`;
|
||||
renderer.context.textAlign = this.params.textAlign;
|
||||
renderer.context.textBaseline = this.params.textBaseline || "middle";
|
||||
renderer.context.fillStyle = this.params.color;
|
||||
|
|
@ -42,10 +58,8 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
|||
|
||||
if (this.params.backgroundColor) {
|
||||
const metrics = renderer.context.measureText(this.params.content);
|
||||
const ascent =
|
||||
metrics.actualBoundingBoxAscent ?? this.params.fontSize * 0.8;
|
||||
const descent =
|
||||
metrics.actualBoundingBoxDescent ?? this.params.fontSize * 0.2;
|
||||
const ascent = metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8;
|
||||
const descent = metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2;
|
||||
const textW = metrics.width;
|
||||
const textH = ascent + descent;
|
||||
const padX = 8;
|
||||
|
|
|
|||
|
|
@ -1,37 +1,14 @@
|
|||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { BaseNode } from "./base-node";
|
||||
import { VisualNode, type VisualNodeParams } from "./visual-node";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
|
||||
const VIDEO_EPSILON = 1 / 1000;
|
||||
|
||||
export interface VideoNodeParams {
|
||||
export interface VideoNodeParams extends VisualNodeParams {
|
||||
url: string;
|
||||
file: File;
|
||||
mediaId: string;
|
||||
duration: number;
|
||||
timeOffset: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export class VideoNode extends BaseNode<VideoNodeParams> {
|
||||
private getVideoTime(time: number) {
|
||||
return time - this.params.timeOffset + this.params.trimStart;
|
||||
}
|
||||
|
||||
private isInRange(time: number) {
|
||||
const videoTime = this.getVideoTime(time);
|
||||
return (
|
||||
videoTime >= this.params.trimStart - VIDEO_EPSILON &&
|
||||
videoTime < this.params.trimStart + this.params.duration
|
||||
);
|
||||
}
|
||||
|
||||
export class VideoNode extends VisualNode<VideoNodeParams> {
|
||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||
await super.render({ renderer, time });
|
||||
|
||||
|
|
@ -39,7 +16,7 @@ export class VideoNode extends BaseNode<VideoNodeParams> {
|
|||
return;
|
||||
}
|
||||
|
||||
const videoTime = this.getVideoTime(time);
|
||||
const videoTime = this.getLocalTime(time);
|
||||
const frame = await videoCache.getFrameAt({
|
||||
mediaId: this.params.mediaId,
|
||||
file: this.params.file,
|
||||
|
|
@ -47,36 +24,12 @@ export class VideoNode extends BaseNode<VideoNodeParams> {
|
|||
});
|
||||
|
||||
if (frame) {
|
||||
renderer.context.save();
|
||||
|
||||
if (this.params.opacity !== undefined) {
|
||||
renderer.context.globalAlpha = this.params.opacity;
|
||||
}
|
||||
|
||||
if (
|
||||
this.params.x !== undefined &&
|
||||
this.params.y !== undefined &&
|
||||
this.params.width !== undefined &&
|
||||
this.params.height !== undefined
|
||||
) {
|
||||
renderer.context.drawImage(
|
||||
frame.canvas,
|
||||
this.params.x,
|
||||
this.params.y,
|
||||
this.params.width,
|
||||
this.params.height,
|
||||
);
|
||||
} else {
|
||||
renderer.context.drawImage(
|
||||
frame.canvas,
|
||||
0,
|
||||
0,
|
||||
renderer.width,
|
||||
renderer.height,
|
||||
);
|
||||
}
|
||||
|
||||
renderer.context.restore();
|
||||
this.renderVisual({
|
||||
renderer,
|
||||
source: frame.canvas,
|
||||
sourceWidth: frame.canvas.width,
|
||||
sourceHeight: frame.canvas.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { BaseNode } from "./base-node";
|
||||
import type { Transform } from "@/types/timeline";
|
||||
|
||||
const VISUAL_EPSILON = 1 / 1000;
|
||||
|
||||
export interface VisualNodeParams {
|
||||
duration: number;
|
||||
timeOffset: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export abstract class VisualNode<
|
||||
Params extends VisualNodeParams = VisualNodeParams,
|
||||
> extends BaseNode<Params> {
|
||||
protected getLocalTime(time: number): number {
|
||||
return time - this.params.timeOffset + this.params.trimStart;
|
||||
}
|
||||
|
||||
protected isInRange(time: number): boolean {
|
||||
const localTime = this.getLocalTime(time);
|
||||
return (
|
||||
localTime >= this.params.trimStart - VISUAL_EPSILON &&
|
||||
localTime < this.params.trimStart + this.params.duration
|
||||
);
|
||||
}
|
||||
|
||||
protected renderVisual({
|
||||
renderer,
|
||||
source,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
}: {
|
||||
renderer: CanvasRenderer;
|
||||
source: CanvasImageSource;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
}): void {
|
||||
renderer.context.save();
|
||||
|
||||
const { transform, opacity } = this.params;
|
||||
const containScale = Math.min(
|
||||
renderer.width / sourceWidth,
|
||||
renderer.height / sourceHeight,
|
||||
);
|
||||
const scaledWidth = sourceWidth * containScale * transform.scale;
|
||||
const scaledHeight = sourceHeight * containScale * transform.scale;
|
||||
const x = renderer.width / 2 + transform.position.x - scaledWidth / 2;
|
||||
const y = renderer.height / 2 + transform.position.y - scaledHeight / 2;
|
||||
|
||||
renderer.context.globalAlpha = opacity;
|
||||
|
||||
if (transform.rotate !== 0) {
|
||||
const centerX = x + scaledWidth / 2;
|
||||
const centerY = y + scaledHeight / 2;
|
||||
renderer.context.translate(centerX, centerY);
|
||||
renderer.context.rotate((transform.rotate * Math.PI) / 180);
|
||||
renderer.context.translate(-centerX, -centerY);
|
||||
}
|
||||
|
||||
renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
|
||||
renderer.context.restore();
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,8 @@ export function buildScene(params: BuildSceneParams) {
|
|||
timeOffset: element.startTime,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
opacity: element.opacity,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -75,6 +77,8 @@ export function buildScene(params: BuildSceneParams) {
|
|||
timeOffset: element.startTime,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
opacity: element.opacity,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -85,6 +89,7 @@ export function buildScene(params: BuildSceneParams) {
|
|||
new TextNode({
|
||||
...element,
|
||||
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
|
||||
canvasHeight: canvasSize.height,
|
||||
textBaseline: "middle",
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { generateUUID } from "@/utils/id";
|
||||
import type { SerializedScene } from "@/services/storage/types";
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
import { isRecord } from "./utils";
|
||||
|
||||
export interface TransformV0ToV1Options {
|
||||
now?: Date;
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type TextPropertiesTab = "text" | "transform";
|
||||
|
||||
export interface TextPropertiesTabMeta {
|
||||
value: TextPropertiesTab;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta> = [
|
||||
{ value: "text", label: "Text" },
|
||||
{ value: "transform", label: "Transform" },
|
||||
] as const;
|
||||
|
||||
export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
|
||||
return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
|
||||
}
|
||||
|
||||
interface TextPropertiesState {
|
||||
activeTab: TextPropertiesTab;
|
||||
setActiveTab: (tab: TextPropertiesTab) => void;
|
||||
}
|
||||
|
||||
export const useTextPropertiesStore = create<TextPropertiesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeTab: "text",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}),
|
||||
{ name: "text-properties" },
|
||||
),
|
||||
);
|
||||
Loading…
Reference in New Issue