mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
30
Commits
ripple-editing
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d62eb6aa9e | ||
|
|
fbb00cf9cc | ||
|
|
a63cdcfe7e | ||
|
|
1b870fd9a0 | ||
|
|
c7a00b6af1 | ||
|
|
9b6a1f7973 | ||
|
|
eee3233c97 | ||
|
|
14ae9b4f11 | ||
|
|
565d2595da | ||
|
|
a526066b61 | ||
|
|
b0165da764 | ||
|
|
228e2e4342 | ||
|
|
81e7a7fb32 | ||
|
|
887d1795dd | ||
|
|
9261589ff3 | ||
|
|
db50b5214d | ||
|
|
e7dcb586c0 | ||
|
|
93bea01c9e | ||
|
|
a2727f2ae5 | ||
|
|
82f8f76a84 | ||
|
|
2074dde3e3 | ||
|
|
feaeec8dbe | ||
|
|
3b74cddbfb | ||
|
|
a0695dadaa | ||
|
|
7bd0b43746 | ||
|
|
77a3708213 | ||
|
|
9a00e8ac5b | ||
|
|
51234e673b | ||
|
|
13237a04fd | ||
|
|
a352eb1aab |
@@ -54,6 +54,14 @@ Review every point below carefully to ensure files follow consistent code style
|
||||
|
||||
## Tailwind & Styling
|
||||
|
||||
- [ ] Always use `cn()` for `className` — never string interpolation with `${}` or ternaries inline
|
||||
```tsx
|
||||
// ❌ wrong
|
||||
className={`base-class ${isActive && "active"} ${someHelper()}`}
|
||||
|
||||
// ✅ correct
|
||||
className={cn("base-class", isActive && "active", someHelper())}
|
||||
```
|
||||
- [ ] Use `gap-*` instead of `mb-*` or `mt-*` for consistent spacing
|
||||
- [ ] Use `size-*` instead of `h-* w-*` when width and height are the same
|
||||
- [ ] When using `size-*` on icons inside `<Button>`, use `!` modifier to override default `size-4`
|
||||
|
||||
+2515
-2395
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Comment Guidelines
|
||||
|
||||
## Good Comments (Human-style)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Handling Uncertainty
|
||||
|
||||
## Principle
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Readability First
|
||||
|
||||
Optimize code for AI agents to understand and modify.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Separation of Concerns
|
||||
|
||||
## Core Principle
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
description: Ultracite Rules - AI-Ready Formatter and Linter
|
||||
globs: "**/*.{ts,tsx,js,jsx}"
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Project Context
|
||||
|
||||
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's formatter.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Scannable Code Guidelines/Separating Concerns.
|
||||
|
||||
## Core Principle
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
---
|
||||
version: "0.2.0"
|
||||
date: "2026-02-29"
|
||||
date: "2026-02-30"
|
||||
title: "Motion & effects"
|
||||
description: "This release adds the foundation for motion and effects."
|
||||
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
|
||||
changes:
|
||||
- type: new
|
||||
text: "Keyframe animation system, starting with transform properties (position, scale, rotation)."
|
||||
text: "Keyframe animation system for animating element properties over time."
|
||||
- type: new
|
||||
text: "Effects system (with our first effect: Blur!)"
|
||||
text: "Entirely new efects system. Effects can be applied per-clip or added as standalone elements on the timeline (with our first effect: Blur!)"
|
||||
- type: new
|
||||
text: "Ripple editing mode. Delete or split an element and everything after it automatically snaps to fill the gap."
|
||||
text: "Added a changelog page to the site."
|
||||
- type: new
|
||||
text: "Ripple editing mode."
|
||||
- type: fixed
|
||||
text: "Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below."
|
||||
- type: fixed
|
||||
text: "Audio could flicker or cut out at the start of playback when certain elements were selected. The scheduler now recovers from timing slips without losing audio."
|
||||
- type: fixed
|
||||
text: "Dragging a clip onto the timeline and pressing Ctrl+Z now removes both the clip and the track it created in one step, instead of requiring two undos."
|
||||
- type: fixed
|
||||
text: "A few values in the properties panel were previously showing incorrect values. This has been fixed."
|
||||
- type: improved
|
||||
text: "New text elements no longer have a black background by default."
|
||||
- type: improved
|
||||
text: "Selection outline in the preview is now dashed."
|
||||
- type: improved
|
||||
text: "Adjacent clips on the timeline now have a small gap between them so selected clips are always clearly visible."
|
||||
- type: fixed
|
||||
text: "Timeline trim handles now sit outside the clip instead of inside them, so the clip's visible area accurately represents where it starts and ends."
|
||||
- type: fixed
|
||||
text: "Clips being dragged on the timeline now render on top of other clips instead of underneath them."
|
||||
- type: fixed
|
||||
text: "Undoing a trim-from-start operation now correctly restores the clip to its original position and length, instead of stretching it to the right."
|
||||
- type: fixed
|
||||
text: "Resizing a clip on the timeline would deselect it when the mouse was released. The clip now stays selected after a resize."
|
||||
- type: improved
|
||||
text: "Press Escape to deselect the current element."
|
||||
- type: fixed
|
||||
text: "Resizing a clip could extend it past another clip on the same track, causing an overlap. The resize now stops at the next clip's edge."
|
||||
- type: fixed
|
||||
text: "Dragging a clip to a different track would deselect it on drop. The clip stays selected after moving."
|
||||
- type: fixed
|
||||
text: "Finishing a resize or drag with the mouse over empty track space would deselect the clip."
|
||||
---
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { allChangelogs } from "content-collections";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { getSortedReleases } from "../utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "../components/release";
|
||||
import { CopyMarkdownButton } from "../components/copy-markdown-button";
|
||||
|
||||
type Props = { params: Promise<{ version: string }> };
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return allChangelogs.map((r) => ({ version: r.version }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { version } = await params;
|
||||
const release = allChangelogs.find((r) => r.version === version);
|
||||
if (!release) return {};
|
||||
return {
|
||||
title: `${release.title} (${release.version}) - OpenCut Changelog`,
|
||||
description: release.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ReleaseDetailPage({ params }: Props) {
|
||||
const { version } = await params;
|
||||
const releases = getSortedReleases();
|
||||
const index = releases.findIndex((r) => r.version === version);
|
||||
|
||||
if (index === -1) notFound();
|
||||
|
||||
const release = releases[index];
|
||||
const newer = index > 0 ? releases[index - 1] : null;
|
||||
const older = index < releases.length - 1 ? releases[index + 1] : null;
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<div className="mx-auto w-full max-w-3xl flex flex-col gap-12">
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 w-fit"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
All releases
|
||||
</Link>
|
||||
|
||||
<ReleaseArticle variant="detail">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<ReleaseMeta release={release} />
|
||||
<CopyMarkdownButton
|
||||
description={release.description}
|
||||
changes={release.changes}
|
||||
/>
|
||||
</div>
|
||||
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
|
||||
{release.description && (
|
||||
<ReleaseDescription>{release.description}</ReleaseDescription>
|
||||
)}
|
||||
</div>
|
||||
<ReleaseChanges release={release} />
|
||||
</ReleaseArticle>
|
||||
|
||||
<nav className="flex items-center justify-between border-t pt-8">
|
||||
{older ? (
|
||||
<Link
|
||||
href={`/changelog/${older.version}`}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground/60">Older</span>
|
||||
<span className="font-medium">{older.title}</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{newer ? (
|
||||
<Link
|
||||
href={`/changelog/${newer.version}`}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group text-right"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground/60">Newer</span>
|
||||
<span className="font-medium">{newer.title}</span>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
||||
import type { Change } from "../utils";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function buildMarkdown({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (description) {
|
||||
lines.push(description, "");
|
||||
}
|
||||
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
||||
|
||||
for (const type of orderedTypes) {
|
||||
lines.push(`## ${getSectionTitle(type)}`);
|
||||
for (const change of grouped[type]) {
|
||||
lines.push(`- ${change.text}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
export function CopyMarkdownButton({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const markdown = buildMarkdown({ description, changes });
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleCopy}
|
||||
className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
|
||||
title="Copy as markdown"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-4" />
|
||||
)}
|
||||
{copied ? "Copied!" : "Copy markdown"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
||||
import type { Release } from "../utils";
|
||||
|
||||
export function ReleaseArticle({
|
||||
variant,
|
||||
isLatest,
|
||||
children,
|
||||
}: {
|
||||
variant: "list" | "detail";
|
||||
isLatest?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<article className="relative sm:pl-10">
|
||||
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[11px] rounded-full border-[1.5px]",
|
||||
isLatest
|
||||
? "border-foreground bg-foreground"
|
||||
: "border-muted-foreground/30 bg-background",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">{children}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return <article className="flex flex-col gap-8">{children}</article>;
|
||||
}
|
||||
|
||||
export function ReleaseMeta({ release }: { release: Release }) {
|
||||
return (
|
||||
<span className="text-sm font-medium tracking-widest text-muted-foreground">
|
||||
{release.version} — {release.date}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const titleSizes: Record<"h1" | "h2", string> = {
|
||||
h1: "text-4xl",
|
||||
h2: "text-2xl",
|
||||
};
|
||||
|
||||
export function ReleaseTitle({
|
||||
as: As,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
as: "h1" | "h2";
|
||||
href?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
|
||||
{href ? (
|
||||
<Link href={href} className="hover:underline underline-offset-4">
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</As>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseDescription({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="text-base text-foreground leading-relaxed max-w-xl">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseChanges({ release }: { release: Release }) {
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({
|
||||
changes: release.changes,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{orderedTypes.map((type) => (
|
||||
<div key={type} className="flex flex-col gap-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{getSectionTitle(type)}:
|
||||
</h3>
|
||||
<ul className="list-disc pl-5 space-y-1.5">
|
||||
{grouped[type].map((change) => (
|
||||
<li
|
||||
key={change.text}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
>
|
||||
{change.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { allChangelogs } from "content-collections";
|
||||
import { type Release as ReleaseType, getSortedReleases } from "./utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "./components/release";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Changelog - OpenCut",
|
||||
@@ -14,25 +20,8 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
|
||||
|
||||
const knownSectionTitles: Record<string, string> = {
|
||||
new: "Features",
|
||||
improved: "Improvements",
|
||||
fixed: "Fixes",
|
||||
breaking: "Breaking Changes",
|
||||
};
|
||||
|
||||
function getSectionTitle(type: string): string {
|
||||
return (
|
||||
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChangelogPage() {
|
||||
const releases = [...allChangelogs].sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
const releases = getSortedReleases();
|
||||
|
||||
return (
|
||||
<BasePage title="Changelog" description="See what's new in OpenCut">
|
||||
@@ -48,7 +37,6 @@ export default function ChangelogPage() {
|
||||
<div key={release.version} className="flex flex-col">
|
||||
<ReleaseEntry release={release} />
|
||||
{releaseIndex < releases.length - 1 && (
|
||||
// ml-1.5 aligns with the center of the 11px timeline dot
|
||||
<Separator className="my-10 sm:ml-1.5" />
|
||||
)}
|
||||
</div>
|
||||
@@ -60,83 +48,19 @@ export default function ChangelogPage() {
|
||||
);
|
||||
}
|
||||
|
||||
type Change = { type: string; text: string };
|
||||
type Release = (typeof allChangelogs)[number];
|
||||
|
||||
function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
||||
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
|
||||
if (!acc[change.type]) {
|
||||
acc[change.type] = [];
|
||||
}
|
||||
acc[change.type].push(change);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const customTypes = Object.keys(grouped).filter(
|
||||
(type) => !knownSectionOrder.includes(type),
|
||||
);
|
||||
const orderedTypes = [
|
||||
...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
|
||||
...customTypes,
|
||||
];
|
||||
|
||||
return { grouped, orderedTypes };
|
||||
}
|
||||
|
||||
function ReleaseEntry({ release }: { release: Release }) {
|
||||
const { grouped: groupedChanges, orderedTypes } = groupAndOrderChanges({
|
||||
changes: release.changes,
|
||||
});
|
||||
|
||||
function ReleaseEntry({ release }: { release: ReleaseType }) {
|
||||
return (
|
||||
<article className="relative sm:pl-10">
|
||||
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[11px] rounded-full border-[1.5px]",
|
||||
release.isLatest
|
||||
? "border-foreground bg-foreground"
|
||||
: "border-muted-foreground/30 bg-background",
|
||||
)}
|
||||
/>
|
||||
<ReleaseArticle variant="list" isLatest={release.isLatest}>
|
||||
<ReleaseMeta release={release} />
|
||||
<div className="flex flex-col gap-4">
|
||||
<ReleaseTitle as="h2" href={`/changelog/${release.version}`}>
|
||||
{release.title}
|
||||
</ReleaseTitle>
|
||||
{release.description && (
|
||||
<ReleaseDescription>{release.description}</ReleaseDescription>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<span className="text-sm font-medium tracking-widest text-muted-foreground">
|
||||
{release.version} - {release.date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{release.title}</h2>
|
||||
{release.description && (
|
||||
<p className="text-base text-foreground leading-relaxed max-w-xl">
|
||||
{release.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{orderedTypes.map((type) => (
|
||||
<div key={type} className="flex flex-col gap-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{getSectionTitle(type)}:
|
||||
</h3>
|
||||
<ul className="list-disc pl-5 space-y-1.5">
|
||||
{groupedChanges[type].map((change) => (
|
||||
<li
|
||||
key={change.text}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
>
|
||||
{change.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<ReleaseChanges release={release} />
|
||||
</ReleaseArticle>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { allChangelogs } from "content-collections";
|
||||
|
||||
export type Change = { type: string; text: string };
|
||||
export type Release = (typeof allChangelogs)[number];
|
||||
|
||||
const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
|
||||
|
||||
const knownSectionTitles: Record<string, string> = {
|
||||
new: "Features",
|
||||
improved: "Improvements",
|
||||
fixed: "Fixes",
|
||||
breaking: "Breaking Changes",
|
||||
};
|
||||
|
||||
export function getSectionTitle(type: string): string {
|
||||
return (
|
||||
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
|
||||
);
|
||||
}
|
||||
|
||||
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
||||
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
|
||||
if (!acc[change.type]) acc[change.type] = [];
|
||||
acc[change.type].push(change);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const customTypes = Object.keys(grouped).filter(
|
||||
(type) => !knownSectionOrder.includes(type),
|
||||
);
|
||||
const orderedTypes = [
|
||||
...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
|
||||
...customTypes,
|
||||
];
|
||||
|
||||
return { grouped, orderedTypes };
|
||||
}
|
||||
|
||||
export function getSortedReleases() {
|
||||
return [...allChangelogs].sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
}
|
||||
@@ -16,22 +16,25 @@ import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { usePasteMedia } from "@/hooks/use-paste-media";
|
||||
import { MobileGate } from "@/components/editor/mobile-gate";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
const projectId = params.project_id as string;
|
||||
|
||||
return (
|
||||
<EditorProvider projectId={projectId}>
|
||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
<MobileGate>
|
||||
<EditorProvider projectId={projectId}>
|
||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
</div>
|
||||
<Onboarding />
|
||||
<MigrationDialog />
|
||||
</div>
|
||||
<Onboarding />
|
||||
<MigrationDialog />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
</EditorProvider>
|
||||
</MobileGate>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,9 +60,7 @@
|
||||
--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% 93%);
|
||||
@@ -83,7 +81,6 @@
|
||||
--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% 5%);
|
||||
--ring: hsl(0, 0%, 50%);
|
||||
@@ -103,13 +100,12 @@
|
||||
--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: hsl(204, 67%, 9%);
|
||||
--secondary-border: hsl(204, 100%, 14%);
|
||||
--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%);
|
||||
|
||||
@@ -28,6 +28,13 @@ export default function RootLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<BotIdClient protect={protectedRoutes} />
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<Script
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
crossOrigin="anonymous"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={`${siteFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider
|
||||
|
||||
+241
-169
@@ -46,6 +46,13 @@ import {
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { OcVideoIcon } from "@opencut/ui/icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -534,10 +541,25 @@ function ProjectItem({
|
||||
const isSelected = selectedProjectIdSet.has(project.id);
|
||||
const selectedProjectCount = selectedProjectIds.length;
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
|
||||
const editor = useEditor();
|
||||
const durationLabel = formatProjectDuration({ duration: project.duration });
|
||||
const isMultiSelect = selectedProjectCount > 1;
|
||||
const isGridView = viewMode === "grid";
|
||||
|
||||
const handleRename = () => setIsRenameDialogOpen(true);
|
||||
const handleDuplicate = async () => {
|
||||
await duplicateProjects({ editor, ids: [project.id] });
|
||||
};
|
||||
const handleDeleteClick = () => setIsDeleteDialogOpen(true);
|
||||
const handleInfoClick = () => setIsInfoDialogOpen(true);
|
||||
const handleDeleteConfirm = async () => {
|
||||
await deleteProjects({ editor, ids: [project.id] });
|
||||
setIsDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = ({
|
||||
checked,
|
||||
shiftKey,
|
||||
@@ -622,9 +644,8 @@ function ProjectItem({
|
||||
|
||||
const listContent = (
|
||||
<div
|
||||
className={`flex items-center gap-4 py-2 px-4 border-b border-border/50 ${
|
||||
isSelected ? "bg-primary/5" : ""
|
||||
}`}
|
||||
className={`flex items-center gap-4 py-2 px-4 border-b border-border/50 ${isSelected ? "bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
@@ -635,7 +656,7 @@ function ProjectItem({
|
||||
shiftKey: event.shiftKey,
|
||||
});
|
||||
}}
|
||||
onCheckedChange={() => {}}
|
||||
onCheckedChange={() => { }}
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
|
||||
@@ -643,176 +664,71 @@ function ProjectItem({
|
||||
{listRowContent}
|
||||
</Link>
|
||||
|
||||
{!isMultiSelect && (
|
||||
<ProjectMenu
|
||||
isOpen={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
project={project}
|
||||
variant="list"
|
||||
/>
|
||||
)}
|
||||
{!isMultiSelect && (
|
||||
<ProjectMenu
|
||||
isOpen={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
variant="list"
|
||||
onRenameClick={handleRename}
|
||||
onDuplicateClick={handleDuplicate}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const cardContent = isGridView ? gridContent : listContent;
|
||||
|
||||
if (!isGridView) {
|
||||
return <div className="group relative">{listContent}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<Link href={`/editor/${project.id}`} className="block">
|
||||
{cardContent}
|
||||
</Link>
|
||||
|
||||
{isGridView && (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
handleCheckboxChange({
|
||||
checked: !isSelected,
|
||||
shiftKey: event.shiftKey,
|
||||
});
|
||||
}}
|
||||
onCheckedChange={() => {}}
|
||||
className={`absolute z-10 size-5 top-3 left-3 ${
|
||||
isSelected || isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{!isMultiSelect && (
|
||||
<ProjectMenu
|
||||
isOpen={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
project={project}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectMenu({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
project,
|
||||
variant = "grid",
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: TProjectMetadata;
|
||||
variant?: "grid" | "list";
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
|
||||
|
||||
const handleMenuClick = ({
|
||||
event,
|
||||
}: {
|
||||
event: MouseEvent<HTMLButtonElement>;
|
||||
}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleMenuKeyDown = ({
|
||||
event,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLButtonElement>;
|
||||
}) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
setIsRenameDialogOpen(true);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
await duplicateProjects({ editor, ids: [project.id] });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setIsDeleteDialogOpen(true);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
await deleteProjects({ editor, ids: [project.id] });
|
||||
setIsDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleInfoClick = () => {
|
||||
setIsInfoDialogOpen(true);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const isGrid = variant === "grid";
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="background"
|
||||
className={
|
||||
isGrid
|
||||
? `absolute z-10 top-3 right-3 ${isOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`
|
||||
: "!bg-transparent !shadow-none"
|
||||
}
|
||||
size="icon"
|
||||
aria-label="Project menu"
|
||||
onClick={(event) =>
|
||||
handleMenuClick({
|
||||
event: event as unknown as MouseEvent<HTMLButtonElement>,
|
||||
})
|
||||
}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) =>
|
||||
handleMenuKeyDown({
|
||||
event: event as unknown as KeyboardEvent<HTMLButtonElement>,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={MoreHorizontalIcon}
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48" align="end">
|
||||
<DropdownMenuItem onClick={handleRename}>
|
||||
<HugeiconsIcon icon={Edit03Icon} />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
<HugeiconsIcon icon={Copy01Icon} />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleInfoClick}>
|
||||
<HugeiconsIcon icon={InformationCircleIcon} />
|
||||
Info
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDeleteClick}>
|
||||
<HugeiconsIcon icon={Delete02Icon} />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="group relative">
|
||||
{isGridView ? (
|
||||
<>
|
||||
<Link href={`/editor/${project.id}`} className="block">
|
||||
{gridContent}
|
||||
</Link>
|
||||
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
handleCheckboxChange({
|
||||
checked: !isSelected,
|
||||
shiftKey: event.shiftKey,
|
||||
});
|
||||
}}
|
||||
onCheckedChange={() => {}}
|
||||
className={`absolute z-10 size-5 top-3 left-3 ${
|
||||
isSelected || isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{!isMultiSelect && (
|
||||
<ProjectMenu
|
||||
isOpen={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
onRenameClick={handleRename}
|
||||
onDuplicateClick={handleDuplicate}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
listContent
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ProjectContextMenuContent
|
||||
onRenameClick={handleRename}
|
||||
onDuplicateClick={handleDuplicate}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
<RenameProjectDialog
|
||||
isOpen={isRenameDialogOpen}
|
||||
@@ -840,6 +756,162 @@ function ProjectMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectContextMenuContent({
|
||||
onRenameClick,
|
||||
onDuplicateClick,
|
||||
onDeleteClick,
|
||||
onInfoClick,
|
||||
}: {
|
||||
onRenameClick: () => void;
|
||||
onDuplicateClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onInfoClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Edit03Icon} />}
|
||||
onClick={onRenameClick}
|
||||
>
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||
onClick={onDuplicateClick}
|
||||
>
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={InformationCircleIcon} />}
|
||||
onClick={onInfoClick}
|
||||
>
|
||||
Info
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
icon={<HugeiconsIcon icon={Delete02Icon} />}
|
||||
onClick={onDeleteClick}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectMenu({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
variant = "grid",
|
||||
onRenameClick,
|
||||
onDuplicateClick,
|
||||
onDeleteClick,
|
||||
onInfoClick,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
variant?: "grid" | "list";
|
||||
onRenameClick: () => void;
|
||||
onDuplicateClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onInfoClick: () => void;
|
||||
}) {
|
||||
const handleMenuClick = ({
|
||||
event,
|
||||
}: {
|
||||
event: MouseEvent<HTMLButtonElement>;
|
||||
}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleMenuKeyDown = ({
|
||||
event,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLButtonElement>;
|
||||
}) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
onRenameClick();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
onDuplicateClick();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
onDeleteClick();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleInfoClick = () => {
|
||||
onInfoClick();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const isGrid = variant === "grid";
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="background"
|
||||
className={
|
||||
isGrid
|
||||
? `absolute z-10 top-3 right-3 ${isOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`
|
||||
: "!bg-transparent !shadow-none"
|
||||
}
|
||||
size="icon"
|
||||
aria-label="Project menu"
|
||||
onClick={(event) =>
|
||||
handleMenuClick({
|
||||
event: event as unknown as MouseEvent<HTMLButtonElement>,
|
||||
})
|
||||
}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) =>
|
||||
handleMenuKeyDown({
|
||||
event: event as unknown as KeyboardEvent<HTMLButtonElement>,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={MoreHorizontalIcon}
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48" align="end">
|
||||
<DropdownMenuItem onClick={handleRename}>
|
||||
<HugeiconsIcon icon={Edit03Icon} />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
<HugeiconsIcon icon={Copy01Icon} />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleInfoClick}>
|
||||
<HugeiconsIcon icon={InformationCircleIcon} />
|
||||
Info
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDeleteClick}>
|
||||
<HugeiconsIcon icon={Delete02Icon} />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectsSkeleton() {
|
||||
const skeletonIds = Array.from(
|
||||
{ length: 24 },
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function RoadmapPage() {
|
||||
function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-lg font-medium">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg font-medium">
|
||||
<span className="leading-normal select-none">{index + 1}</span>
|
||||
<h3>{item.title}</h3>
|
||||
<StatusBadge status={item.status} className="ml-1" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
@@ -14,35 +14,47 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
|
||||
import { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export";
|
||||
import { Check, Copy, Download, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
EXPORT_FORMAT_VALUES,
|
||||
EXPORT_QUALITY_VALUES,
|
||||
type ExportFormat,
|
||||
type ExportQuality,
|
||||
type ExportResult,
|
||||
} from "@/types/export";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/editor/panels/properties/section";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
|
||||
|
||||
function isExportFormat(value: string): value is ExportFormat {
|
||||
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
|
||||
}
|
||||
|
||||
function isExportQuality(value: string): value is ExportQuality {
|
||||
return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
|
||||
}
|
||||
|
||||
export function ExportButton() {
|
||||
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleExport = () => {
|
||||
setIsExportPopoverOpen(true);
|
||||
const hasProject = !!editor.project.getActiveOrNull();
|
||||
|
||||
const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
|
||||
if (!open) {
|
||||
editor.project.cancelExport();
|
||||
editor.project.clearExportState();
|
||||
}
|
||||
setIsExportPopoverOpen(open);
|
||||
};
|
||||
|
||||
const hasProject = !!editor.project.getActive();
|
||||
|
||||
return (
|
||||
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
|
||||
<Popover open={isExportPopoverOpen} onOpenChange={(open) => handlePopoverOpenChange({ open })}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -50,12 +62,12 @@ export function ExportButton() {
|
||||
"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",
|
||||
)}
|
||||
onClick={hasProject ? handleExport : undefined}
|
||||
onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined}
|
||||
disabled={!hasProject}
|
||||
onKeyDown={(event) => {
|
||||
if (hasProject && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
handleExport();
|
||||
setIsExportPopoverOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -80,71 +92,49 @@ function ExportPopover({
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { isExporting, progress, result: exportResult } =
|
||||
editor.project.getExportState();
|
||||
const [format, setFormat] = useState<ExportFormat>(
|
||||
DEFAULT_EXPORT_OPTIONS.format,
|
||||
);
|
||||
const [quality, setQuality] = useState<ExportQuality>(
|
||||
DEFAULT_EXPORT_OPTIONS.quality,
|
||||
);
|
||||
const [includeAudio, setIncludeAudio] = useState<boolean>(
|
||||
DEFAULT_EXPORT_OPTIONS.includeAudio || true,
|
||||
const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>(
|
||||
DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
|
||||
);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [exportResult, setExportResult] = useState<ExportResult | null>(null);
|
||||
const cancelRequestedRef = useRef(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!activeProject) return;
|
||||
|
||||
cancelRequestedRef.current = false;
|
||||
setIsExporting(true);
|
||||
setProgress(0);
|
||||
setExportResult(null);
|
||||
|
||||
const result = await editor.project.export({
|
||||
options: {
|
||||
format,
|
||||
quality,
|
||||
fps: activeProject.settings.fps,
|
||||
includeAudio,
|
||||
onProgress: ({ progress }) => setProgress(progress),
|
||||
onCancel: () => cancelRequestedRef.current,
|
||||
format,
|
||||
quality,
|
||||
fps: activeProject.settings.fps,
|
||||
includeAudio: shouldIncludeAudio,
|
||||
},
|
||||
});
|
||||
|
||||
setIsExporting(false);
|
||||
|
||||
if (result.cancelled) {
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
editor.project.clearExportState();
|
||||
return;
|
||||
}
|
||||
|
||||
setExportResult(result);
|
||||
|
||||
if (result.success && result.buffer) {
|
||||
const mimeType = getExportMimeType({ format });
|
||||
const extension = getExportFileExtension({ format });
|
||||
const blob = new Blob([result.buffer], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${activeProject.metadata.name}${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadBuffer({
|
||||
buffer: result.buffer,
|
||||
filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
|
||||
mimeType: getExportMimeType({ format }),
|
||||
});
|
||||
|
||||
editor.project.clearExportState();
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelRequestedRef.current = true;
|
||||
editor.project.cancelExport();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -166,8 +156,10 @@ function ExportPopover({
|
||||
{!isExporting && (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<Section collapsible defaultOpen={false} hasBorderTop={false}>
|
||||
<SectionHeader title="Format" />
|
||||
<Section collapsible defaultOpen={false} showTopBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Format</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
@@ -194,7 +186,9 @@ function ExportPopover({
|
||||
</Section>
|
||||
|
||||
<Section collapsible defaultOpen={false}>
|
||||
<SectionHeader title="Quality" />
|
||||
<SectionHeader>
|
||||
<SectionTitle>Quality</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<RadioGroup
|
||||
value={quality}
|
||||
@@ -219,7 +213,7 @@ function ExportPopover({
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="very_high" id="very_high" />
|
||||
<Label htmlFor="very_high">
|
||||
Very High - Largest file size
|
||||
Very high - Largest file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
@@ -227,15 +221,17 @@ function ExportPopover({
|
||||
</Section>
|
||||
|
||||
<Section collapsible defaultOpen={false}>
|
||||
<SectionHeader title="Audio" />
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="include-audio"
|
||||
checked={includeAudio}
|
||||
onCheckedChange={(checked) =>
|
||||
setIncludeAudio(!!checked)
|
||||
}
|
||||
checked={shouldIncludeAudio}
|
||||
onCheckedChange={(checked) =>
|
||||
setShouldIncludeAudio(!!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="include-audio">
|
||||
Include audio in export
|
||||
@@ -256,15 +252,15 @@ function ExportPopover({
|
||||
|
||||
{isExporting && (
|
||||
<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">
|
||||
{Math.round(progress * 100)}%
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-2 text-sm">100%</p>
|
||||
</div>
|
||||
<Progress value={progress * 100} className="w-full" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{Math.round(progress * 100)}%
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">100%</p>
|
||||
</div>
|
||||
<Progress value={progress * 100} className="w-full" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -282,14 +278,6 @@ function ExportPopover({
|
||||
);
|
||||
}
|
||||
|
||||
function isExportFormat(value: string): value is ExportFormat {
|
||||
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
|
||||
}
|
||||
|
||||
function isExportQuality(value: string): value is ExportQuality {
|
||||
return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
|
||||
}
|
||||
|
||||
function ExportError({
|
||||
error,
|
||||
onRetry,
|
||||
@@ -306,7 +294,7 @@ function ExportError({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 p-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-destructive text-sm font-medium">Export failed</p>
|
||||
<p className="text-muted-foreground text-xs">{error}</p>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "../ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const STORAGE_KEY = "mobile-acknowledged";
|
||||
|
||||
interface MobileGateProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MobileGate({ children }: MobileGateProps) {
|
||||
const router = useRouter();
|
||||
const [show, setShow] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const isMobile = window.innerWidth < 1024;
|
||||
const acknowledged = localStorage.getItem(STORAGE_KEY) === "true";
|
||||
setShow(isMobile && !acknowledged);
|
||||
}, []);
|
||||
|
||||
if (show === null) return null;
|
||||
if (!show) return <>{children}</>;
|
||||
|
||||
const handleContinue = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
setShow(false);
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dark bg-background relative flex h-screen w-screen flex-col overflow-hidden">
|
||||
<Button
|
||||
variant="text"
|
||||
className="absolute top-6 left-6 flex items-center gap-1 text-muted-foreground"
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} className="size-4" />
|
||||
<span className=" text-sm">Go back</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-center gap-5 px-7">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-foreground text-3xl font-bold tracking-tight">
|
||||
Desktop only (for now)
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
OpenCut isn't optimized for mobile or iPad yet. Things will break
|
||||
and the layout will be a mess. Come back on a desktop for the real
|
||||
experience.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="foreground" onClick={handleContinue}>
|
||||
Take a look anyway
|
||||
</Button>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/roadmap" className="flex items-center gap-1">
|
||||
Roadmap
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} size={14} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,12 @@ import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { useRevealItem } from "@/hooks/use-reveal-item";
|
||||
import { processMediaAssets } from "@/lib/media/processing";
|
||||
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import {
|
||||
type MediaSortKey,
|
||||
type MediaSortOrder,
|
||||
type MediaViewMode,
|
||||
useAssetsPanelStore,
|
||||
} from "@/stores/assets-panel-store";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
@@ -50,8 +55,15 @@ export function MediaView() {
|
||||
const mediaFiles = editor.media.getAssets();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } =
|
||||
useAssetsPanelStore();
|
||||
const {
|
||||
mediaViewMode,
|
||||
setMediaViewMode,
|
||||
highlightMediaId,
|
||||
clearHighlight,
|
||||
mediaSortBy,
|
||||
mediaSortOrder,
|
||||
setMediaSort,
|
||||
} = useAssetsPanelStore();
|
||||
const { highlightedId, registerElement } = useRevealItem(
|
||||
highlightMediaId,
|
||||
clearHighlight,
|
||||
@@ -59,10 +71,6 @@ export function MediaView() {
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
|
||||
"name",
|
||||
);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const processFiles = async ({ files }: { files: FileList }) => {
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -121,27 +129,12 @@ export function MediaView() {
|
||||
});
|
||||
};
|
||||
|
||||
const addElementAtTime = ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}): boolean => {
|
||||
const duration =
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: asset.id,
|
||||
mediaType: asset.type,
|
||||
name: asset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
editor.timeline.insertElement({
|
||||
element,
|
||||
placement: { mode: "auto" },
|
||||
});
|
||||
return true;
|
||||
const handleSort = ({ key }: { key: MediaSortKey }) => {
|
||||
if (mediaSortBy === key) {
|
||||
setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setMediaSort(key, "asc");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMediaItems = useMemo(() => {
|
||||
@@ -151,7 +144,7 @@ export function MediaView() {
|
||||
let valueA: string | number;
|
||||
let valueB: string | number;
|
||||
|
||||
switch (sortBy) {
|
||||
switch (mediaSortBy) {
|
||||
case "name":
|
||||
valueA = a.name.toLowerCase();
|
||||
valueB = b.name.toLowerCase();
|
||||
@@ -172,154 +165,13 @@ export function MediaView() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
|
||||
if (valueA < valueB) return mediaSortOrder === "asc" ? -1 : 1;
|
||||
if (valueA > valueB) return mediaSortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [mediaFiles, sortBy, sortOrder]);
|
||||
|
||||
const previewComponents = useMemo(() => {
|
||||
const previews = new Map<string, React.ReactNode>();
|
||||
|
||||
filteredMediaItems.forEach((item) => {
|
||||
previews.set(item.id, <MediaPreview item={item} />);
|
||||
previews.set(
|
||||
`compact-${item.id}`,
|
||||
<MediaPreview item={item} variant="compact" />,
|
||||
);
|
||||
});
|
||||
|
||||
return previews;
|
||||
}, [filteredMediaItems]);
|
||||
|
||||
const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
|
||||
const renderCompactPreview = (item: MediaAsset) =>
|
||||
previewComponents.get(`compact-${item.id}`);
|
||||
|
||||
const mediaActions = (
|
||||
<div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
{mediaViewMode === "grid" ? (
|
||||
<HugeiconsIcon icon={LeftToRightListDashIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={GridViewIcon} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{mediaViewMode === "grid"
|
||||
? "Switch to list view"
|
||||
: "Switch to grid view"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
<Tooltip>
|
||||
<DropdownMenu>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
<HugeiconsIcon icon={SortingOneNineIcon} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<SortMenuItem
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Type"
|
||||
sortKey="type"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Duration"
|
||||
sortKey="duration"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="File size"
|
||||
sortKey="size"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Sort by {sortBy} (
|
||||
{sortOrder === "asc" ? "ascending" : "descending"})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openFilePicker}
|
||||
disabled={isProcessing}
|
||||
size="sm"
|
||||
className="items-center justify-center gap-1.5 ml-1.5"
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}, [mediaFiles, mediaSortBy, mediaSortOrder]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -327,8 +179,18 @@ export function MediaView() {
|
||||
|
||||
<PanelView
|
||||
title="Assets"
|
||||
actions={mediaActions}
|
||||
className={isDragOver ? "bg-accent/30" : ""}
|
||||
actions={
|
||||
<MediaActions
|
||||
mediaViewMode={mediaViewMode}
|
||||
setMediaViewMode={setMediaViewMode}
|
||||
isProcessing={isProcessing}
|
||||
sortBy={mediaSortBy}
|
||||
sortOrder={mediaSortOrder}
|
||||
onSort={handleSort}
|
||||
onImport={openFilePicker}
|
||||
/>
|
||||
}
|
||||
className={cn(isDragOver && "bg-accent/30")}
|
||||
{...dragProps}
|
||||
>
|
||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||
@@ -338,21 +200,11 @@ export function MediaView() {
|
||||
progress={progress}
|
||||
onClick={openFilePicker}
|
||||
/>
|
||||
) : mediaViewMode === "grid" ? (
|
||||
<GridView
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
<MediaItemList
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderCompactPreview}
|
||||
mode={mediaViewMode}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
@@ -362,6 +214,67 @@ export function MediaView() {
|
||||
);
|
||||
}
|
||||
|
||||
function MediaAssetDraggable({
|
||||
item,
|
||||
preview,
|
||||
isHighlighted,
|
||||
variant,
|
||||
isRounded,
|
||||
}: {
|
||||
item: MediaAsset;
|
||||
preview: React.ReactNode;
|
||||
isHighlighted: boolean;
|
||||
variant: "card" | "compact";
|
||||
isRounded?: boolean;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const addElementAtTime = ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}) => {
|
||||
const duration =
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: asset.id,
|
||||
mediaType: asset.type,
|
||||
name: asset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
editor.timeline.insertElement({
|
||||
element,
|
||||
placement: { mode: "auto" },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableItem
|
||||
name={item.name}
|
||||
preview={preview}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: "media",
|
||||
mediaType: item.type,
|
||||
name: item.name,
|
||||
...(item.type !== "audio" && {
|
||||
targetElementTypes: ["video", "image"] as const,
|
||||
}),
|
||||
}}
|
||||
shouldShowPlusOnDrag={false}
|
||||
onAddToTimeline={({ currentTime }) =>
|
||||
addElementAtTime({ asset: item, startTime: currentTime })
|
||||
}
|
||||
variant={variant}
|
||||
isRounded={isRounded}
|
||||
isHighlighted={isHighlighted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaItemWithContextMenu({
|
||||
item,
|
||||
children,
|
||||
@@ -387,55 +300,41 @@ function MediaItemWithContextMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function GridView({
|
||||
function MediaItemList({
|
||||
items,
|
||||
renderPreview,
|
||||
mode,
|
||||
onRemove,
|
||||
onAddToTimeline,
|
||||
highlightedId,
|
||||
registerElement,
|
||||
}: {
|
||||
items: MediaAsset[];
|
||||
renderPreview: (item: MediaAsset) => React.ReactNode;
|
||||
mode: MediaViewMode;
|
||||
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
||||
onAddToTimeline: ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}) => boolean;
|
||||
highlightedId: string | null;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
}) {
|
||||
const isGrid = mode === "grid";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||
}}
|
||||
className={cn(isGrid ? "grid gap-2" : "flex flex-col gap-1")}
|
||||
style={
|
||||
isGrid ? { gridTemplateColumns: "repeat(auto-fill, 160px)" } : undefined
|
||||
}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||
<div key={item.id} ref={(element) => registerElement(item.id, element)}>
|
||||
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
|
||||
<DraggableItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: "media",
|
||||
mediaType: item.type,
|
||||
name: item.name,
|
||||
...(item.type !== "audio" && {
|
||||
targetElementTypes: ["video", "image"] as const,
|
||||
}),
|
||||
}}
|
||||
shouldShowPlusOnDrag={false}
|
||||
onAddToTimeline={({ currentTime }) =>
|
||||
onAddToTimeline({ asset: item, startTime: currentTime })
|
||||
<MediaAssetDraggable
|
||||
item={item}
|
||||
preview={
|
||||
<MediaPreview
|
||||
item={item}
|
||||
variant={isGrid ? "grid" : "compact"}
|
||||
/>
|
||||
}
|
||||
isRounded={false}
|
||||
variant="card"
|
||||
variant={isGrid ? "card" : "compact"}
|
||||
isRounded={isGrid ? false : undefined}
|
||||
isHighlighted={highlightedId === item.id}
|
||||
/>
|
||||
</MediaItemWithContextMenu>
|
||||
@@ -445,63 +344,11 @@ function GridView({
|
||||
);
|
||||
}
|
||||
|
||||
function ListView({
|
||||
items,
|
||||
renderPreview,
|
||||
onRemove,
|
||||
onAddToTimeline,
|
||||
highlightedId,
|
||||
registerElement,
|
||||
}: {
|
||||
items: MediaAsset[];
|
||||
renderPreview: (item: MediaAsset) => React.ReactNode;
|
||||
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
||||
onAddToTimeline: ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}) => boolean;
|
||||
highlightedId: string | null;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
|
||||
<DraggableItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: "media",
|
||||
mediaType: item.type,
|
||||
name: item.name,
|
||||
...(item.type !== "audio" && {
|
||||
targetElementTypes: ["video", "image"] as const,
|
||||
}),
|
||||
}}
|
||||
shouldShowPlusOnDrag={false}
|
||||
onAddToTimeline={({ currentTime }) =>
|
||||
onAddToTimeline({ asset: item, startTime: currentTime })
|
||||
}
|
||||
variant="compact"
|
||||
isHighlighted={highlightedId === item.id}
|
||||
/>
|
||||
</MediaItemWithContextMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDuration = ({ duration }: { duration: number }) => {
|
||||
export function formatDuration({ duration }: { duration: number }) {
|
||||
const min = Math.floor(duration / 60);
|
||||
const sec = Math.floor(duration % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
}
|
||||
|
||||
function MediaDurationBadge({ duration }: { duration?: number }) {
|
||||
if (!duration) return null;
|
||||
@@ -619,6 +466,119 @@ function MediaPreview({
|
||||
);
|
||||
}
|
||||
|
||||
function MediaActions({
|
||||
mediaViewMode,
|
||||
setMediaViewMode,
|
||||
isProcessing,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSort,
|
||||
onImport,
|
||||
}: {
|
||||
mediaViewMode: MediaViewMode;
|
||||
setMediaViewMode: (mode: MediaViewMode) => void;
|
||||
isProcessing: boolean;
|
||||
sortBy: MediaSortKey;
|
||||
sortOrder: MediaSortOrder;
|
||||
onSort: ({ key }: { key: MediaSortKey }) => void;
|
||||
onImport: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
{mediaViewMode === "grid" ? (
|
||||
<HugeiconsIcon icon={LeftToRightListDashIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={GridViewIcon} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{mediaViewMode === "grid"
|
||||
? "Switch to list view"
|
||||
: "Switch to grid view"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<DropdownMenu>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
<HugeiconsIcon icon={SortingOneNineIcon} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<SortMenuItem
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Type"
|
||||
sortKey="type"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Duration"
|
||||
sortKey="duration"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="File size"
|
||||
sortKey="size"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Sort by {sortBy} (
|
||||
{sortOrder === "asc" ? "ascending" : "descending"})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onImport}
|
||||
disabled={isProcessing}
|
||||
size="sm"
|
||||
className="items-center justify-center gap-1.5"
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortMenuItem({
|
||||
label,
|
||||
sortKey,
|
||||
@@ -627,10 +587,10 @@ function SortMenuItem({
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: "name" | "type" | "duration" | "size";
|
||||
currentSortBy: string;
|
||||
currentSortOrder: "asc" | "desc";
|
||||
onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void;
|
||||
sortKey: MediaSortKey;
|
||||
currentSortBy: MediaSortKey;
|
||||
currentSortOrder: MediaSortOrder;
|
||||
onSort: ({ key }: { key: MediaSortKey }) => void;
|
||||
}) {
|
||||
const isActive = currentSortBy === sortKey;
|
||||
const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/editor/panels/properties/section";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -23,25 +24,46 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useState } from "react";
|
||||
|
||||
const ORIGINAL_PRESET_VALUE = "original";
|
||||
|
||||
export function findPresetIndexByAspectRatio({
|
||||
presets,
|
||||
targetAspectRatio,
|
||||
}: {
|
||||
presets: Array<{ width: number; height: number }>;
|
||||
targetAspectRatio: string;
|
||||
}) {
|
||||
for (let index = 0; index < presets.length; index++) {
|
||||
const preset = presets[index];
|
||||
const presetAspectRatio = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
if (presetAspectRatio === targetAspectRatio) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function SettingsView() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<PanelView contentClassName="px-0" hideHeader>
|
||||
<div className="flex flex-col">
|
||||
<Section hasBorderTop={false}>
|
||||
<Section showTopBorder={false}>
|
||||
<SectionContent>
|
||||
<ProjectInfoContent />
|
||||
</SectionContent>
|
||||
</Section>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover>
|
||||
<Section className="cursor-pointer">
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<SectionHeader title="Background">
|
||||
<div className="size-4 rounded-sm bg-red-500" />
|
||||
<SectionHeader
|
||||
trailing={<div className="size-4 rounded-sm bg-red-500" />}
|
||||
>
|
||||
<SectionTitle>Background</SectionTitle>
|
||||
</SectionHeader>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
@@ -60,26 +82,6 @@ function ProjectInfoContent() {
|
||||
const activeProject = editor.project.getActive();
|
||||
const { canvasPresets } = useEditorStore();
|
||||
|
||||
const findPresetIndexByAspectRatio = ({
|
||||
presets,
|
||||
targetAspectRatio,
|
||||
}: {
|
||||
presets: Array<{ width: number; height: number }>;
|
||||
targetAspectRatio: string;
|
||||
}) => {
|
||||
for (let index = 0; index < presets.length; index++) {
|
||||
const preset = presets[index];
|
||||
const presetAspectRatio = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
if (presetAspectRatio === targetAspectRatio) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const currentCanvasSize = activeProject.settings.canvasSize;
|
||||
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
|
||||
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
|
||||
@@ -87,12 +89,11 @@ function ProjectInfoContent() {
|
||||
presets: canvasPresets,
|
||||
targetAspectRatio: currentAspectRatio,
|
||||
});
|
||||
const originalPresetValue = "original";
|
||||
const selectedPresetValue =
|
||||
presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
|
||||
presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE;
|
||||
|
||||
const handleAspectRatioChange = ({ value }: { value: string }) => {
|
||||
if (value === originalPresetValue) {
|
||||
if (value === ORIGINAL_PRESET_VALUE) {
|
||||
const canvasSize = originalCanvasSize ?? currentCanvasSize;
|
||||
editor.project.updateSettings({
|
||||
settings: { canvasSize },
|
||||
@@ -106,7 +107,7 @@ function ProjectInfoContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFpsChange = (value: string) => {
|
||||
const handleFpsChange = ({ value }: { value: string }) => {
|
||||
const fps = parseFloat(value);
|
||||
editor.project.updateSettings({ settings: { fps } });
|
||||
};
|
||||
@@ -129,7 +130,7 @@ function ProjectInfoContent() {
|
||||
<SelectValue placeholder="Select an aspect ratio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={originalPresetValue}>Original</SelectItem>
|
||||
<SelectItem value={ORIGINAL_PRESET_VALUE}>Original</SelectItem>
|
||||
{canvasPresets.map((preset, index) => {
|
||||
const label = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
@@ -148,7 +149,7 @@ function ProjectInfoContent() {
|
||||
<Label>Frame rate</Label>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
onValueChange={(value) => handleFpsChange({ value })}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
|
||||
@@ -56,7 +56,7 @@ export function TextEditOverlay({
|
||||
}, [editor.timeline, trackId, elementId]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
({ event }: { event: React.KeyboardEvent }) => {
|
||||
const { key } = event;
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -98,10 +98,13 @@ export function TextEditOverlay({
|
||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
||||
const letterSpacing = element.letterSpacing ?? 0;
|
||||
const backgroundColor =
|
||||
element.background.color === "transparent"
|
||||
? "transparent"
|
||||
: element.background.color;
|
||||
const shouldShowBackground =
|
||||
element.background.enabled &&
|
||||
element.background.color &&
|
||||
element.background.color !== "transparent";
|
||||
const backgroundColor = shouldShowBackground
|
||||
? element.background.color
|
||||
: "transparent";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -134,13 +137,12 @@ export function TextEditOverlay({
|
||||
backgroundColor,
|
||||
minHeight: displayFontSize * lineHeight,
|
||||
textDecoration: element.textDecoration ?? "none",
|
||||
padding:
|
||||
backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING,
|
||||
padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0,
|
||||
minWidth: 1,
|
||||
}}
|
||||
onInput={handleInput}
|
||||
onBlur={onCommit}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDown={(event) => handleKeyDown({ event })}
|
||||
>
|
||||
{element.content || ""}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ const CORNERS: Corner[] = [
|
||||
"bottom-right",
|
||||
];
|
||||
|
||||
function getCornerPosition({
|
||||
export function getCornerPosition({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
@@ -47,7 +47,7 @@ function getCornerPosition({
|
||||
};
|
||||
}
|
||||
|
||||
function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
|
||||
export function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
@@ -61,7 +61,7 @@ function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
|
||||
};
|
||||
}
|
||||
|
||||
function getOverlayContext({
|
||||
export function getOverlayContext({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
}: {
|
||||
@@ -196,7 +196,7 @@ function BoundingBoxOutline({
|
||||
height: outlineHeight,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
border: "1px solid white",
|
||||
border: "1px dashed white",
|
||||
boxSizing: "border-box",
|
||||
opacity: 0.75,
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { AudioElement } from "@/types/timeline";
|
||||
|
||||
export function AudioProperties({ _element }: { _element: AudioElement }) {
|
||||
export function AudioProperties() {
|
||||
return <div className="space-y-4 p-5">Audio properties</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Effect } from "@/types/effects";
|
||||
import type { VisualElement } from "@/types/timeline";
|
||||
import { getEffect } from "@/lib/effects/registry";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { usePropertiesStore } from "@/stores/properties-store";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
SectionFields,
|
||||
} from "./section";
|
||||
import { EffectParamField } from "./effect-param-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowLeft01Icon,
|
||||
Delete02Icon,
|
||||
ViewIcon,
|
||||
ViewOffSlashIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/utils/ui";
|
||||
export function ClipEffectsProperties({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const closeClipEffects = usePropertiesStore(
|
||||
(state) => state.closeClipEffects,
|
||||
);
|
||||
const editor = useEditor();
|
||||
const effects = element.effects ?? [];
|
||||
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
|
||||
const handleDragStart = ({ index }: { index: number }) => {
|
||||
setDragIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => {
|
||||
event.preventDefault();
|
||||
if (index !== dropIndex) setDropIndex(index);
|
||||
};
|
||||
|
||||
const handleDrop = ({ toIndex }: { toIndex: number }) => {
|
||||
if (dragIndex !== null && dragIndex !== toIndex) {
|
||||
editor.timeline.reorderClipEffects({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fromIndex: dragIndex,
|
||||
toIndex,
|
||||
});
|
||||
}
|
||||
setDragIndex(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDragIndex(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-11 shrink-0 items-center gap-2 border-b px-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={closeClipEffects}
|
||||
aria-label="Back to properties"
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} />
|
||||
</Button>
|
||||
<span className="text-sm font-medium">Effects</span>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 scrollbar-hidden">
|
||||
{effects.map((effect, index) => (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder
|
||||
<div
|
||||
key={effect.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart({ index })}
|
||||
onDragOver={(event) => handleDragOver({ event, index })}
|
||||
onDrop={() => handleDrop({ toIndex: index })}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
"group",
|
||||
dragIndex === index && "opacity-40",
|
||||
dropIndex === index &&
|
||||
dragIndex !== null &&
|
||||
dragIndex !== index &&
|
||||
(index < dragIndex
|
||||
? "border-t-2 border-primary"
|
||||
: "border-b-2 border-primary"),
|
||||
)}
|
||||
>
|
||||
<ClipEffectSection
|
||||
effect={effect}
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClipEffectSection({
|
||||
effect,
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
effect: Effect;
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const definition = getEffect({ effectType: effect.type });
|
||||
|
||||
const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => {
|
||||
const updatedEffects = (element.effects ?? []).map((existing) =>
|
||||
existing.id !== effect.id
|
||||
? existing
|
||||
: { ...existing, params: { ...existing.params, [key]: value } },
|
||||
);
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { effects: updatedEffects },
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const commitParam = () => editor.timeline.commitPreview();
|
||||
|
||||
const toggleEffect = () =>
|
||||
editor.timeline.toggleClipEffect({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
effectId: effect.id,
|
||||
});
|
||||
|
||||
const removeEffect = () =>
|
||||
editor.timeline.removeClipEffect({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
effectId: effect.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<Section sectionKey={`clip-effect:${effect.id}`} showTopBorder={false}>
|
||||
<SectionHeader
|
||||
className="cursor-move"
|
||||
trailing={
|
||||
<>
|
||||
<Button
|
||||
variant={effect.enabled ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
aria-label={`Toggle ${definition.name}`}
|
||||
onClick={toggleEffect}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove ${definition.name}`}
|
||||
onClick={removeEffect}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SectionTitle
|
||||
className={cn(!effect.enabled && "text-muted-foreground")}
|
||||
>
|
||||
{definition.name}
|
||||
</SectionTitle>
|
||||
</SectionHeader>
|
||||
{effect.enabled && (
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
{definition.params.map((param) => (
|
||||
<EffectParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
value={effect.params[param.key] ?? param.default}
|
||||
onPreview={previewParam({ key: param.key })}
|
||||
onCommit={commitParam}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { SectionField } from "./section";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { usePropertyDraft } from "./hooks/use-property-draft";
|
||||
|
||||
export function EffectParamField({
|
||||
param,
|
||||
value,
|
||||
onPreview,
|
||||
onCommit,
|
||||
}: {
|
||||
param: EffectParamDefinition;
|
||||
value: number | string | boolean;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<SectionField label={param.label}>
|
||||
<EffectParamInput param={param} value={value} onPreview={onPreview} onCommit={onCommit} />
|
||||
</SectionField>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectParamInput({
|
||||
param,
|
||||
value,
|
||||
onPreview,
|
||||
onCommit,
|
||||
}: {
|
||||
param: EffectParamDefinition;
|
||||
value: number | string | boolean;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
}) {
|
||||
if (param.type === "number") {
|
||||
return (
|
||||
<NumberParamField
|
||||
param={param}
|
||||
value={typeof value === "number" ? value : Number(value)}
|
||||
onPreview={onPreview}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return (
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => {
|
||||
onPreview(checked);
|
||||
onCommit();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === "select") {
|
||||
return (
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(selected) => {
|
||||
onPreview(selected);
|
||||
onCommit();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{param.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return (
|
||||
<input
|
||||
type="color"
|
||||
className="h-8 w-full cursor-pointer rounded border"
|
||||
value={String(value)}
|
||||
onChange={(event) => onPreview(event.target.value)}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function NumberParamField({
|
||||
param,
|
||||
value,
|
||||
onPreview,
|
||||
onCommit,
|
||||
}: {
|
||||
param: NumberEffectParamDefinition;
|
||||
value: number;
|
||||
onPreview: (value: number) => void;
|
||||
onCommit: () => void;
|
||||
}) {
|
||||
const { min, max, step } = param;
|
||||
|
||||
const draft = usePropertyDraft({
|
||||
displayValue: String(value),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min, max });
|
||||
},
|
||||
onPreview,
|
||||
onCommit,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
className="flex-1"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[value]}
|
||||
onValueChange={([newValue]) => onPreview(newValue)}
|
||||
onValueCommit={onCommit}
|
||||
/>
|
||||
<NumberField
|
||||
className="w-16 shrink-0"
|
||||
value={draft.displayValue}
|
||||
onFocus={draft.onFocus}
|
||||
onChange={draft.onChange}
|
||||
onBlur={draft.onBlur}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import type { EffectElement } from "@/types/timeline";
|
||||
import type { EffectParamDefinition } from "@/types/effects";
|
||||
import { getEffect } from "@/lib/effects/registry";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { Section, SectionContent, SectionHeader, SectionField, SectionFields } from "./section";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { usePropertyDraft } from "./hooks/use-property-draft";
|
||||
|
||||
function EffectParamField({
|
||||
param,
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
param: EffectParamDefinition;
|
||||
element: EffectElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const currentValue = Number(element.params[param.key] ?? param.default);
|
||||
const min = param.min ?? 0;
|
||||
const max = param.max ?? 100;
|
||||
const step = param.step ?? 1;
|
||||
|
||||
const updateParam = (value: number) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { params: { ...element.params, [param.key]: value } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const commitParam = () => editor.timeline.commitPreview();
|
||||
|
||||
const draft = usePropertyDraft({
|
||||
displayValue: String(currentValue),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min, max });
|
||||
},
|
||||
onPreview: updateParam,
|
||||
onCommit: commitParam,
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionField label={param.label}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
className="flex-1"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[currentValue]}
|
||||
onValueChange={([value]) => updateParam(value)}
|
||||
onValueCommit={commitParam}
|
||||
/>
|
||||
<NumberField
|
||||
className="w-16 shrink-0"
|
||||
value={draft.displayValue}
|
||||
onFocus={draft.onFocus}
|
||||
onChange={draft.onChange}
|
||||
onBlur={draft.onBlur}
|
||||
/>
|
||||
</div>
|
||||
</SectionField>
|
||||
);
|
||||
}
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionFields,
|
||||
SectionTitle,
|
||||
} from "./section";
|
||||
import { EffectParamField } from "./effect-param-field";
|
||||
|
||||
export function EffectProperties({
|
||||
element,
|
||||
@@ -81,19 +19,36 @@ export function EffectProperties({
|
||||
element: EffectElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const definition = getEffect({ effectType: element.effectType });
|
||||
|
||||
const previewParam =
|
||||
({ key }: { key: string }) =>
|
||||
(value: number | string | boolean) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { params: { ...element.params, [key]: value } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<Section hasBorderTop={false}>
|
||||
<SectionHeader title={definition.name} />
|
||||
<Section showTopBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>{definition.name}</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
{definition.params.map((param) => (
|
||||
<EffectParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
value={element.params[param.key] ?? param.default}
|
||||
onPreview={previewParam({ key: param.key })}
|
||||
onCommit={() => editor.timeline.commitPreview()}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { getElementLocalTime } from "@/lib/animation";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
|
||||
export function useElementPlayhead({
|
||||
startTime,
|
||||
duration,
|
||||
}: {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: playheadTime,
|
||||
elementStartTime: startTime,
|
||||
elementDuration: duration,
|
||||
});
|
||||
const isPlayheadWithinElementRange =
|
||||
playheadTime >= startTime - TIME_EPSILON_SECONDS &&
|
||||
playheadTime <= startTime + duration + TIME_EPSILON_SECONDS;
|
||||
|
||||
return { localTime, isPlayheadWithinElementRange };
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
upsertElementKeyframe,
|
||||
} from "@/lib/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
|
||||
import type { TimelineElement } from "@/types/timeline";
|
||||
|
||||
export function useKeyframedColorProperty({
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedColor,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedColor: string;
|
||||
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const onChange = ({ color }: { color: string }) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertElementKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: color,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeEnd = () => editor.timeline.commitPreview();
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isKeyframedAtTime,
|
||||
hasAnimatedKeyframes,
|
||||
keyframeIdAtTime,
|
||||
onChange,
|
||||
onChangeEnd,
|
||||
toggleKeyframe,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -5,7 +5,7 @@ import {
|
||||
upsertElementKeyframe,
|
||||
} from "@/lib/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
|
||||
import type { ElementUpdatePatch } from "@/types/timeline";
|
||||
import type { TimelineElement } from "@/types/timeline";
|
||||
import { usePropertyDraft } from "./use-property-draft";
|
||||
|
||||
export function useKeyframedNumberProperty({
|
||||
@@ -29,7 +29,7 @@ export function useKeyframedNumberProperty({
|
||||
displayValue: string;
|
||||
parse: (input: string) => number | null;
|
||||
valueAtPlayhead: number;
|
||||
buildBaseUpdates: ({ value }: { value: number }) => ElementUpdatePatch;
|
||||
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import { AudioProperties } from "./audio-properties";
|
||||
import { VideoProperties } from "./video-properties";
|
||||
import { TextProperties } from "./text-properties";
|
||||
import { EffectProperties } from "./effect-properties";
|
||||
import { ClipEffectsProperties } from "./clip-effects-properties";
|
||||
import { EmptyView } from "./empty-view";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { usePropertiesStore } from "@/stores/properties-store";
|
||||
import { isVisualElement } from "@/lib/timeline";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
|
||||
function ElementProperties({
|
||||
@@ -21,7 +24,7 @@ function ElementProperties({
|
||||
return <TextProperties element={element} trackId={track.id} />;
|
||||
}
|
||||
if (element.type === "audio") {
|
||||
return <AudioProperties _element={element} />;
|
||||
return <AudioProperties />;
|
||||
}
|
||||
if (
|
||||
element.type === "video" ||
|
||||
@@ -39,16 +42,33 @@ function ElementProperties({
|
||||
export function PropertiesPanel() {
|
||||
const editor = useEditor();
|
||||
const { selectedElements } = useElementSelection();
|
||||
const clipEffectsTarget = usePropertiesStore(
|
||||
(state) => state.clipEffectsTarget,
|
||||
);
|
||||
|
||||
const clipEffectsTrack = clipEffectsTarget
|
||||
? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId })
|
||||
: null;
|
||||
const clipEffectsElement = clipEffectsTrack?.elements.find(
|
||||
(element) => element.id === clipEffectsTarget?.elementId,
|
||||
);
|
||||
const isShowingClipEffects =
|
||||
clipEffectsTrack &&
|
||||
clipEffectsElement &&
|
||||
isVisualElement(clipEffectsElement);
|
||||
|
||||
const elementsWithTracks = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
});
|
||||
|
||||
const hasSelection = selectedElements.length > 0;
|
||||
|
||||
return (
|
||||
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
|
||||
{hasSelection ? (
|
||||
{isShowingClipEffects ? (
|
||||
<ClipEffectsProperties
|
||||
element={clipEffectsElement}
|
||||
trackId={clipEffectsTrack.id}
|
||||
/>
|
||||
) : selectedElements.length > 0 ? (
|
||||
<ScrollArea className="h-full scrollbar-hidden">
|
||||
{elementsWithTracks.map(({ track, element }) => (
|
||||
<ElementProperties
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const sectionExpandedCache = new Map<string, boolean>();
|
||||
const mountedSectionKeys = new Set<string>();
|
||||
|
||||
interface SectionContext {
|
||||
isOpen: boolean;
|
||||
@@ -24,8 +26,8 @@ interface SectionProps {
|
||||
defaultOpen?: boolean;
|
||||
sectionKey?: string;
|
||||
className?: string;
|
||||
hasBorderTop?: boolean;
|
||||
hasBorderBottom?: boolean;
|
||||
showTopBorder?: boolean;
|
||||
showBottomBorder?: boolean;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
@@ -34,12 +36,21 @@ export function Section({
|
||||
defaultOpen = true,
|
||||
sectionKey,
|
||||
className,
|
||||
hasBorderTop = true,
|
||||
hasBorderBottom = true,
|
||||
showTopBorder = true,
|
||||
showBottomBorder = true,
|
||||
}: SectionProps) {
|
||||
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
|
||||
const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionKey) return;
|
||||
if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) {
|
||||
console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`);
|
||||
}
|
||||
mountedSectionKeys.add(sectionKey);
|
||||
return () => { mountedSectionKeys.delete(sectionKey); };
|
||||
}, [sectionKey]);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !isOpen;
|
||||
setIsOpen(next);
|
||||
@@ -51,8 +62,8 @@ export function Section({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
hasBorderTop && "border-t",
|
||||
hasBorderBottom && "last:border-b",
|
||||
showTopBorder && "border-t",
|
||||
showBottomBorder && "last:border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -63,15 +74,19 @@ export function Section({
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
trailing?: React.ReactNode;
|
||||
leading?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
children,
|
||||
trailing,
|
||||
leading,
|
||||
actions,
|
||||
onClick,
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
@@ -79,57 +94,108 @@ export function SectionHeader({
|
||||
const isCollapsible = ctx?.collapsible ?? false;
|
||||
const isOpen = ctx?.isOpen ?? true;
|
||||
const isInteractive = isCollapsible || !!onClick;
|
||||
|
||||
const handleClick = isCollapsible ? ctx?.toggle : onClick;
|
||||
|
||||
const content = (
|
||||
const chevronIcon = isCollapsible ? (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDownIcon}
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform duration-200 ease-out",
|
||||
isOpen ? "rotate-0 text-foreground" : "-rotate-90 text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const headerContent = (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isOpen ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
{isCollapsible && (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDownIcon}
|
||||
className={cn(
|
||||
"size-3 shrink-0 transition-transform duration-200 ease-out",
|
||||
isOpen
|
||||
? "rotate-0 text-foreground"
|
||||
: "-rotate-90 text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{leading}
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
{(trailing || chevronIcon) && (
|
||||
<div className="flex items-center">
|
||||
{trailing}
|
||||
{chevronIcon && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={isOpen ? "Collapse section" : "Expand section"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleClick?.();
|
||||
}}
|
||||
>
|
||||
{chevronIcon}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{actions}
|
||||
</>
|
||||
);
|
||||
|
||||
const baseClassName = cn(
|
||||
"flex w-full items-center justify-between h-11 px-3.5",
|
||||
className,
|
||||
);
|
||||
if (!isInteractive) {
|
||||
return (
|
||||
<div className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}>
|
||||
{headerContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInteractive) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested <Button> (chevron), making <button> invalid HTML here
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"flex h-11 w-full cursor-pointer items-center gap-2 px-3.5",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") handleClick?.(); }}
|
||||
>
|
||||
{headerContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionTitle({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const ctx = useSectionContext();
|
||||
const isOpen = ctx?.isOpen ?? true;
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(baseClassName, "cursor-pointer text-left")}
|
||||
onClick={(event) => {
|
||||
handleClick?.();
|
||||
event.currentTarget.blur();
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer text-sm font-medium",
|
||||
isOpen ? "text-foreground" : "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{content}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClassName}>{content}</div>;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isOpen ? "text-foreground" : "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionFields({
|
||||
@@ -157,7 +223,7 @@ export function SectionField({
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex h-4 items-center gap-1.5">
|
||||
{beforeLabel}
|
||||
<Label>{label}</Label>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import {
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
} from "@/constants/timeline-constants";
|
||||
import { OcCheckerboardIcon } from "@opencut/ui/icons";
|
||||
import { Fragment, useRef } from "react";
|
||||
import { Section, SectionContent, SectionField, SectionHeader } from "../section";
|
||||
import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,14 +18,23 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import type { BlendMode } from "@/types/rendering";
|
||||
import type { ElementType } from "@/types/timeline";
|
||||
import type { ElementAnimations } from "@/types/animation";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||
import { KeyframeToggle } from "../keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { resolveOpacityAtTime } from "@/lib/animation";
|
||||
import { isPropertyAtDefault } from "./transform";
|
||||
|
||||
type BlendingElement = {
|
||||
id: string;
|
||||
opacity: number;
|
||||
type: ElementType;
|
||||
blendMode?: BlendMode;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
|
||||
const BLEND_MODE_GROUPS = [
|
||||
@@ -74,7 +82,7 @@ export function BlendingSection({
|
||||
committedBlendModeRef.current = blendMode;
|
||||
}
|
||||
|
||||
const previewBlendMode = (value: BlendMode) =>
|
||||
const previewBlendMode = ({ value }: { value: BlendMode }) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { blendMode: value } },
|
||||
@@ -105,28 +113,50 @@ export function BlendingSection({
|
||||
}
|
||||
};
|
||||
|
||||
const opacity = usePropertyDraft({
|
||||
displayValue: Math.round(element.opacity * 100).toString(),
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedOpacity = resolveOpacityAtTime({
|
||||
baseOpacity: element.opacity,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const opacity = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "opacity",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedOpacity * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { opacity: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
valueAtPlayhead: resolvedOpacity,
|
||||
buildBaseUpdates: ({ value }) => ({ opacity: value }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.type}:blending`}>
|
||||
<SectionHeader title="Blending" />
|
||||
<SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField label="Opacity" className="w-1/2">
|
||||
<SectionField
|
||||
label="Opacity"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={opacity.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle opacity keyframe"
|
||||
onToggle={opacity.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
className="w-full"
|
||||
icon={
|
||||
@@ -140,18 +170,14 @@ export function BlendingSection({
|
||||
onBlur={opacity.onBlur}
|
||||
onScrub={opacity.scrubTo}
|
||||
onScrubEnd={opacity.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: DEFAULT_OPACITY },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={element.opacity === DEFAULT_OPACITY}
|
||||
onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedOpacity,
|
||||
staticValue: element.opacity,
|
||||
defaultValue: DEFAULT_OPACITY,
|
||||
})}
|
||||
dragSensitivity="slow"
|
||||
/>
|
||||
</SectionField>
|
||||
@@ -175,7 +201,7 @@ export function BlendingSection({
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onPointerEnter={() =>
|
||||
previewBlendMode(option.value as BlendMode)
|
||||
previewBlendMode({ value: option.value as BlendMode })
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SectionField,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "../section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
@@ -24,12 +25,12 @@ import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
|
||||
import { KeyframeToggle } from "../keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
|
||||
function parseNumericInput({ input }: { input: string }): number | null {
|
||||
export function parseNumericInput({ input }: { input: string }): number | null {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function isPropertyAtDefault({
|
||||
export function isPropertyAtDefault({
|
||||
hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
@@ -55,9 +56,11 @@ function isPropertyAtDefault({
|
||||
export function TransformSection({
|
||||
element,
|
||||
trackId,
|
||||
showTopBorder = true,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
showTopBorder?: boolean;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [isScaleLocked, setIsScaleLocked] = useState(false);
|
||||
@@ -239,8 +242,12 @@ export function TransformSection({
|
||||
};
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.type}:transform`}>
|
||||
<SectionHeader title="Transform" />
|
||||
<Section
|
||||
collapsible
|
||||
sectionKey={`${element.type}:transform`}
|
||||
showTopBorder={showTopBorder}
|
||||
>
|
||||
<SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
@@ -261,11 +268,11 @@ export function TransformSection({
|
||||
<NumberField icon="H" {...scaleFieldProps} />
|
||||
</>
|
||||
) : (
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
||||
{...scaleFieldProps}
|
||||
className="flex-1"
|
||||
/>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
||||
{...scaleFieldProps}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -3,13 +3,23 @@ import { FontPicker } from "@/components/ui/font-picker";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useRef } from "react";
|
||||
import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "./section";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_COLOR } from "@/constants/project-constants";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TEXT_BACKGROUND,
|
||||
@@ -18,32 +28,21 @@ import {
|
||||
MIN_FONT_SIZE,
|
||||
} from "@/constants/text-constants";
|
||||
import { usePropertyDraft } from "./hooks/use-property-draft";
|
||||
import { useKeyframedColorProperty } from "./hooks/use-keyframed-color-property";
|
||||
import { useKeyframedNumberProperty } from "./hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "./hooks/use-element-playhead";
|
||||
import { TransformSection, BlendingSection } from "./sections";
|
||||
import { KeyframeToggle } from "./keyframe-toggle";
|
||||
import { isPropertyAtDefault } from "./sections/transform";
|
||||
import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { TextFontIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
TextFontIcon,
|
||||
ViewIcon,
|
||||
ViewOffSlashIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
|
||||
|
||||
function createOffsetConverter({
|
||||
defaultValue,
|
||||
scale = 1,
|
||||
min,
|
||||
}: {
|
||||
defaultValue: number;
|
||||
scale?: number;
|
||||
min?: number;
|
||||
}) {
|
||||
return {
|
||||
toDisplay: (value: number) => Math.round((value - defaultValue) * scale),
|
||||
fromDisplay: (display: number) => {
|
||||
const stored = defaultValue + display / scale;
|
||||
return min !== undefined ? Math.max(min, stored) : stored;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const lineHeightConverter = createOffsetConverter({ defaultValue: DEFAULT_LINE_HEIGHT, scale: 10 });
|
||||
const paddingXConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, min: 0 });
|
||||
const paddingYConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, min: 0 });
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export function TextProperties({
|
||||
element,
|
||||
@@ -86,8 +85,10 @@ function ContentSection({
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:content" hasBorderTop={false}>
|
||||
<SectionHeader title="Content" />
|
||||
<Section collapsible sectionKey="text:content" showTopBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Content</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
@@ -110,6 +111,27 @@ function TypographySection({
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedTextColor = resolveColorAtTime({
|
||||
baseColor: element.color,
|
||||
animations: element.animations,
|
||||
propertyPath: "color",
|
||||
localTime,
|
||||
});
|
||||
|
||||
const textColor = useKeyframedColorProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "color",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedColor: resolvedTextColor,
|
||||
buildBaseUpdates: ({ value }) => ({ color: value }),
|
||||
});
|
||||
|
||||
const fontSize = usePropertyDraft({
|
||||
displayValue: element.fontSize.toString(),
|
||||
@@ -129,71 +151,73 @@ function TypographySection({
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:typography">
|
||||
<SectionHeader title="Typography" />
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField label="Font">
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value) =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontFamily: value },
|
||||
},
|
||||
],
|
||||
})
|
||||
<SectionHeader>
|
||||
<SectionTitle>Typography</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField label="Font">
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value) =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontFamily: value },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Size">
|
||||
<NumberField
|
||||
value={fontSize.displayValue}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
onFocus={fontSize.onFocus}
|
||||
onChange={fontSize.onChange}
|
||||
onBlur={fontSize.onBlur}
|
||||
onScrub={fontSize.scrubTo}
|
||||
onScrubEnd={fontSize.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
|
||||
icon={<HugeiconsIcon icon={TextFontIcon} />}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField
|
||||
label="Color"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={textColor.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle text color keyframe"
|
||||
onToggle={textColor.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Size">
|
||||
<NumberField
|
||||
value={fontSize.displayValue}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
onFocus={fontSize.onFocus}
|
||||
onChange={fontSize.onChange}
|
||||
onBlur={fontSize.onBlur}
|
||||
onScrub={fontSize.scrubTo}
|
||||
onScrubEnd={fontSize.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
|
||||
icon={<HugeiconsIcon icon={TextFontIcon} />}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Color">
|
||||
<ColorPicker
|
||||
value={uppercase({
|
||||
string: (element.color || "FFFFFF").replace("#", ""),
|
||||
})}
|
||||
onChange={(color) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
onChangeEnd={() => editor.timeline.commitPreview()}
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
>
|
||||
<ColorPicker
|
||||
value={uppercase({
|
||||
string: resolvedTextColor.replace("#", ""),
|
||||
})}
|
||||
onChange={(color) => textColor.onChange({ color: `#${color}` })}
|
||||
onChangeEnd={textColor.onChangeEnd}
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -208,72 +232,100 @@ function SpacingSection({
|
||||
const editor = useEditor();
|
||||
|
||||
const letterSpacing = usePropertyDraft({
|
||||
displayValue: Math.round(element.letterSpacing ?? DEFAULT_LETTER_SPACING).toString(),
|
||||
displayValue: Math.round(
|
||||
element.letterSpacing ?? DEFAULT_LETTER_SPACING,
|
||||
).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.round(parsed);
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [{ trackId, elementId: element.id, updates: { letterSpacing: value } }],
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { letterSpacing: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
const lineHeight = usePropertyDraft({
|
||||
displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(),
|
||||
displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed));
|
||||
return Number.isNaN(parsed)
|
||||
? null
|
||||
: Math.max(0.1, Math.round(parsed * 10) / 10);
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [{ trackId, elementId: element.id, updates: { lineHeight: value } }],
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { lineHeight: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:spacing" hasBorderBottom={false}>
|
||||
<SectionHeader title="Spacing" />
|
||||
<SectionContent>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField label="Letter spacing" className="w-1/2">
|
||||
<NumberField
|
||||
value={letterSpacing.displayValue}
|
||||
onFocus={letterSpacing.onFocus}
|
||||
onChange={letterSpacing.onChange}
|
||||
onBlur={letterSpacing.onBlur}
|
||||
onScrub={letterSpacing.scrubTo}
|
||||
onScrubEnd={letterSpacing.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [{ trackId, elementId: element.id, updates: { letterSpacing: DEFAULT_LETTER_SPACING } }],
|
||||
})
|
||||
}
|
||||
isDefault={(element.letterSpacing ?? DEFAULT_LETTER_SPACING) === DEFAULT_LETTER_SPACING}
|
||||
icon={<OcTextWidthIcon size={14} />}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Line height" className="w-1/2">
|
||||
<NumberField
|
||||
value={lineHeight.displayValue}
|
||||
onFocus={lineHeight.onFocus}
|
||||
onChange={lineHeight.onChange}
|
||||
onBlur={lineHeight.onBlur}
|
||||
onScrub={lineHeight.scrubTo}
|
||||
onScrubEnd={lineHeight.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [{ trackId, elementId: element.id, updates: { lineHeight: DEFAULT_LINE_HEIGHT } }],
|
||||
})
|
||||
}
|
||||
isDefault={(element.lineHeight ?? DEFAULT_LINE_HEIGHT) === DEFAULT_LINE_HEIGHT}
|
||||
icon={<OcTextHeightIcon size={14} />}
|
||||
/>
|
||||
</SectionField>
|
||||
</div>
|
||||
</SectionContent>
|
||||
<Section collapsible sectionKey="text:spacing" showBottomBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Spacing</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField label="Letter spacing" className="w-1/2">
|
||||
<NumberField
|
||||
value={letterSpacing.displayValue}
|
||||
onFocus={letterSpacing.onFocus}
|
||||
onChange={letterSpacing.onChange}
|
||||
onBlur={letterSpacing.onBlur}
|
||||
onScrub={letterSpacing.scrubTo}
|
||||
onScrubEnd={letterSpacing.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { letterSpacing: DEFAULT_LETTER_SPACING },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
(element.letterSpacing ?? DEFAULT_LETTER_SPACING) ===
|
||||
DEFAULT_LETTER_SPACING
|
||||
}
|
||||
icon={<OcTextWidthIcon size={14} />}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Line height" className="w-1/2">
|
||||
<NumberField
|
||||
value={lineHeight.displayValue}
|
||||
onFocus={lineHeight.onFocus}
|
||||
onChange={lineHeight.onChange}
|
||||
onBlur={lineHeight.onBlur}
|
||||
onScrub={lineHeight.scrubTo}
|
||||
onScrubEnd={lineHeight.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { lineHeight: DEFAULT_LINE_HEIGHT },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
(element.lineHeight ?? DEFAULT_LINE_HEIGHT) ===
|
||||
DEFAULT_LINE_HEIGHT
|
||||
}
|
||||
icon={<OcTextHeightIcon size={14} />}
|
||||
/>
|
||||
</SectionField>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -287,286 +339,390 @@ function BackgroundSection({
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedBgColor = resolveColorAtTime({
|
||||
baseColor: element.background.color,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.color",
|
||||
localTime,
|
||||
});
|
||||
|
||||
const cornerRadius = usePropertyDraft({
|
||||
displayValue: Math.round(element.background.cornerRadius ?? 0).toString(),
|
||||
const bgColor = useKeyframedColorProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.color",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedColor: resolvedBgColor,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...element.background, color: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const bg = element.background;
|
||||
|
||||
const resolvedPaddingX = resolveNumberAtTime({
|
||||
baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingX",
|
||||
localTime,
|
||||
});
|
||||
const resolvedPaddingY = resolveNumberAtTime({
|
||||
baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingY",
|
||||
localTime,
|
||||
});
|
||||
const resolvedOffsetX = resolveNumberAtTime({
|
||||
baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetX",
|
||||
localTime,
|
||||
});
|
||||
const resolvedOffsetY = resolveNumberAtTime({
|
||||
baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetY",
|
||||
localTime,
|
||||
});
|
||||
const resolvedCornerRadius = resolveNumberAtTime({
|
||||
baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.cornerRadius",
|
||||
localTime,
|
||||
});
|
||||
|
||||
const paddingX = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingX",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedPaddingX).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
background: { ...element.background, cornerRadius: value },
|
||||
valueAtPlayhead: resolvedPaddingX,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...bg, paddingX: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const paddingY = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingY",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedPaddingY).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
|
||||
},
|
||||
valueAtPlayhead: resolvedPaddingY,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...bg, paddingY: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const offsetX = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetX",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedOffsetX).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.round(parsed);
|
||||
},
|
||||
valueAtPlayhead: resolvedOffsetX,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...bg, offsetX: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const offsetY = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetY",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedOffsetY).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.round(parsed);
|
||||
},
|
||||
valueAtPlayhead: resolvedOffsetY,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...bg, offsetY: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const cornerRadius = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.cornerRadius",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedCornerRadius).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: Math.round(parsed), min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX });
|
||||
},
|
||||
valueAtPlayhead: resolvedCornerRadius,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...bg, cornerRadius: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const toggleBackgroundEnabled = () => {
|
||||
const enabled = !element.background.enabled;
|
||||
const color =
|
||||
enabled && element.background.color === "transparent"
|
||||
? lastSelectedColor.current
|
||||
: element.background.color;
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
background: {
|
||||
...element.background,
|
||||
enabled,
|
||||
color,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
const paddingX = usePropertyDraft({
|
||||
displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed));
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { background: { ...element.background, paddingX: value } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
const paddingY = usePropertyDraft({
|
||||
displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed));
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { background: { ...element.background, paddingY: value } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
const offsetX = usePropertyDraft({
|
||||
displayValue: Math.round(element.background.offsetX ?? 0).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.round(parsed);
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { background: { ...element.background, offsetX: value } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
const offsetY = usePropertyDraft({
|
||||
displayValue: Math.round(element.background.offsetY ?? 0).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.round(parsed);
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { background: { ...element.background, offsetY: value } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:background">
|
||||
<SectionHeader title="Background" />
|
||||
<SectionContent>
|
||||
<Section
|
||||
collapsible
|
||||
defaultOpen={element.background.enabled}
|
||||
sectionKey="text:background"
|
||||
>
|
||||
<SectionHeader
|
||||
trailing={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleBackgroundEnabled();
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SectionTitle>Background</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent
|
||||
className={cn(
|
||||
!element.background.enabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<SectionFields>
|
||||
<SectionField label="Color">
|
||||
<SectionField
|
||||
label="Color"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={bgColor.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle background color keyframe"
|
||||
onToggle={bgColor.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ColorPicker
|
||||
value={
|
||||
!element.background.enabled ||
|
||||
element.background.color === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: element.background.color.replace("#", "")
|
||||
: resolvedBgColor.replace("#", "")
|
||||
}
|
||||
onChange={(color) => {
|
||||
const hexColor = `#${color}`;
|
||||
if (color !== "transparent") {
|
||||
lastSelectedColor.current = hexColor;
|
||||
}
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
background: { ...element.background, color: hexColor },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
bgColor.onChange({ color: hexColor });
|
||||
}}
|
||||
onChangeEnd={() => editor.timeline.commitPreview()}
|
||||
className={
|
||||
element.background.color === "transparent"
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
onChangeEnd={bgColor.onChangeEnd}
|
||||
/>
|
||||
</SectionField>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField label="Width" className="w-1/2">
|
||||
<NumberField
|
||||
icon="W"
|
||||
value={paddingX.displayValue}
|
||||
min={0}
|
||||
<SectionField
|
||||
label="Width"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={paddingX.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle background width keyframe"
|
||||
onToggle={paddingX.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon="W"
|
||||
value={paddingX.displayValue}
|
||||
min={0}
|
||||
onFocus={paddingX.onFocus}
|
||||
onChange={paddingX.onChange}
|
||||
onBlur={paddingX.onBlur}
|
||||
onScrub={paddingX.scrubTo}
|
||||
onScrubEnd={paddingX.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
background: {
|
||||
...element.background,
|
||||
paddingX: DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) ===
|
||||
DEFAULT_TEXT_BACKGROUND.paddingX
|
||||
}
|
||||
onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedPaddingX,
|
||||
staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
})}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Height" className="w-1/2">
|
||||
<NumberField
|
||||
icon="H"
|
||||
value={paddingY.displayValue}
|
||||
min={0}
|
||||
<SectionField
|
||||
label="Height"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={paddingY.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle background height keyframe"
|
||||
onToggle={paddingY.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon="H"
|
||||
value={paddingY.displayValue}
|
||||
min={0}
|
||||
onFocus={paddingY.onFocus}
|
||||
onChange={paddingY.onChange}
|
||||
onBlur={paddingY.onBlur}
|
||||
onScrub={paddingY.scrubTo}
|
||||
onScrubEnd={paddingY.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
background: {
|
||||
...element.background,
|
||||
paddingY: DEFAULT_TEXT_BACKGROUND.paddingY,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) ===
|
||||
DEFAULT_TEXT_BACKGROUND.paddingY
|
||||
}
|
||||
onReset={() => paddingY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingY })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedPaddingY,
|
||||
staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
|
||||
defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY,
|
||||
})}
|
||||
/>
|
||||
</SectionField>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField label="X-offset" className="w-1/2">
|
||||
<NumberField
|
||||
icon="X"
|
||||
value={offsetX.displayValue}
|
||||
<SectionField
|
||||
label="X-offset"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={offsetX.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle x-offset keyframe"
|
||||
onToggle={offsetX.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon="X"
|
||||
value={offsetX.displayValue}
|
||||
onFocus={offsetX.onFocus}
|
||||
onChange={offsetX.onChange}
|
||||
onBlur={offsetX.onBlur}
|
||||
onScrub={offsetX.scrubTo}
|
||||
onScrubEnd={offsetX.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
background: { ...element.background, offsetX: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={(element.background.offsetX ?? 0) === 0}
|
||||
onReset={() => offsetX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetX })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedOffsetX,
|
||||
staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
|
||||
defaultValue: DEFAULT_TEXT_BACKGROUND.offsetX,
|
||||
})}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Y-offset" className="w-1/2">
|
||||
<NumberField
|
||||
icon="Y"
|
||||
value={offsetY.displayValue}
|
||||
<SectionField
|
||||
label="Y-offset"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={offsetY.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle y-offset keyframe"
|
||||
onToggle={offsetY.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon="Y"
|
||||
value={offsetY.displayValue}
|
||||
onFocus={offsetY.onFocus}
|
||||
onChange={offsetY.onChange}
|
||||
onBlur={offsetY.onBlur}
|
||||
onScrub={offsetY.scrubTo}
|
||||
onScrubEnd={offsetY.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
background: { ...element.background, offsetY: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={(element.background.offsetY ?? 0) === 0}
|
||||
onReset={() => offsetY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetY })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedOffsetY,
|
||||
staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
|
||||
defaultValue: DEFAULT_TEXT_BACKGROUND.offsetY,
|
||||
})}
|
||||
/>
|
||||
</SectionField>
|
||||
</div>
|
||||
<SectionField label="Corner Radius">
|
||||
<NumberField
|
||||
icon="R"
|
||||
value={cornerRadius.displayValue}
|
||||
min={0}
|
||||
<SectionField
|
||||
label="Corner radius"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={cornerRadius.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle corner radius keyframe"
|
||||
onToggle={cornerRadius.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon="R"
|
||||
value={cornerRadius.displayValue}
|
||||
min={CORNER_RADIUS_MIN}
|
||||
max={CORNER_RADIUS_MAX}
|
||||
onFocus={cornerRadius.onFocus}
|
||||
onChange={cornerRadius.onChange}
|
||||
onBlur={cornerRadius.onBlur}
|
||||
onScrub={cornerRadius.scrubTo}
|
||||
onScrubEnd={cornerRadius.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
background: {
|
||||
...element.background,
|
||||
cornerRadius: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={(element.background.cornerRadius ?? 0) === 0}
|
||||
onReset={() => cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedCornerRadius,
|
||||
staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
|
||||
defaultValue: CORNER_RADIUS_MIN,
|
||||
})}
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ImageElement, StickerElement, VideoElement } from "@/types/timeline";
|
||||
import type {
|
||||
ImageElement,
|
||||
StickerElement,
|
||||
VideoElement,
|
||||
} from "@/types/timeline";
|
||||
import { BlendingSection, TransformSection } from "./sections";
|
||||
|
||||
export function VideoProperties({
|
||||
@@ -10,7 +14,11 @@ export function VideoProperties({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<TransformSection element={element} trackId={trackId} />
|
||||
<TransformSection
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
showTopBorder={false}
|
||||
/>
|
||||
<BlendingSection element={element} trackId={trackId} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ export function DragLine({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5"
|
||||
className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
|
||||
style={{ top: `${lineTop}px` }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -55,16 +55,18 @@ import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
import { DragLine } from "./drag-line";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
|
||||
const TRACKS_CONTAINER_MAX_HEIGHT = 800;
|
||||
const FALLBACK_CONTAINER_WIDTH = 1000;
|
||||
|
||||
export function Timeline() {
|
||||
const tracksContainerHeight = { min: 0, max: 800 };
|
||||
const { snappingEnabled } = useTimelineStore();
|
||||
const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
|
||||
const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
|
||||
const { clearElementSelection, setElementSelection } = useElementSelection();
|
||||
const editor = useEditor();
|
||||
const timeline = editor.timeline;
|
||||
const tracks = timeline.getTracks();
|
||||
const seek = (time: number) => editor.playback.seek({ time });
|
||||
|
||||
// refs
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const timelineHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const rulerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -74,7 +76,6 @@ export function Timeline() {
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// state
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
|
||||
null,
|
||||
@@ -151,6 +152,7 @@ export function Timeline() {
|
||||
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
|
||||
containerRef: tracksContainerRef,
|
||||
headerRef: timelineHeaderRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
@@ -169,7 +171,7 @@ export function Timeline() {
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const containerWidth = tracksContainerRef.current?.clientWidth || 1000;
|
||||
const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
|
||||
const contentWidth =
|
||||
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const paddingPx = getTimelinePaddingPx({
|
||||
@@ -422,7 +424,20 @@ export function Timeline() {
|
||||
{tracks.length === 0 ? (
|
||||
<div />
|
||||
) : (
|
||||
tracks.map((track, index) => (
|
||||
[...tracks]
|
||||
.map((track, index) => ({ track, index }))
|
||||
.sort((a, b) => {
|
||||
const aHasDragged = a.track.elements.some(
|
||||
(element) => element.id === dragState.elementId,
|
||||
);
|
||||
const bHasDragged = b.track.elements.some(
|
||||
(element) => element.id === dragState.elementId,
|
||||
);
|
||||
if (aHasDragged) return 1;
|
||||
if (bHasDragged) return -1;
|
||||
return 0;
|
||||
})
|
||||
.map(({ track, index }) => (
|
||||
<ContextMenu key={track.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
@@ -465,50 +480,50 @@ export function Timeline() {
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
invokeAction("paste-copied");
|
||||
}}
|
||||
>
|
||||
Paste elements
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={VolumeHighIcon} />
|
||||
<span>
|
||||
{canTracktHaveAudio(track) && track.muted
|
||||
? "Unmute track"
|
||||
: "Mute track"}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
timeline.toggleTrackVisibility({
|
||||
trackId: track.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={ViewIcon} />
|
||||
<span>
|
||||
{canTrackBeHidden(track) && track.hidden
|
||||
? "Show track"
|
||||
: "Hide track"}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
timeline.removeTrack({
|
||||
trackId: track.id,
|
||||
});
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
invokeAction("paste-copied");
|
||||
}}
|
||||
>
|
||||
Paste elements
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={VolumeHighIcon} />
|
||||
<span>
|
||||
{canTracktHaveAudio(track) && track.muted
|
||||
? "Unmute track"
|
||||
: "Mute track"}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
timeline.toggleTrackVisibility({
|
||||
trackId: track.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={ViewIcon} />
|
||||
<span>
|
||||
{canTrackBeHidden(track) && track.hidden
|
||||
? "Show track"
|
||||
: "Hide track"}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
timeline.removeTrack({
|
||||
trackId: track.id,
|
||||
});
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} />
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import AudioWaveform from "./audio-waveform";
|
||||
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
|
||||
import {
|
||||
useKeyframeDrag,
|
||||
type KeyframeDragState,
|
||||
} from "@/hooks/timeline/element/use-keyframe-drag";
|
||||
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import { getElementKeyframes } from "@/lib/animation";
|
||||
@@ -26,6 +30,7 @@ import {
|
||||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
TimelineTrack,
|
||||
VisualElement,
|
||||
ElementDragState,
|
||||
} from "@/types/timeline";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
@@ -53,8 +58,11 @@ import { uppercase } from "@/utils/string";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePropertiesStore } from "@/stores/properties-store";
|
||||
|
||||
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
|
||||
const ELEMENT_RING_WIDTH_PX = 1.5;
|
||||
|
||||
interface KeyframeIndicator {
|
||||
time: number;
|
||||
@@ -62,7 +70,7 @@ interface KeyframeIndicator {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}
|
||||
|
||||
function buildKeyframeIndicator({
|
||||
export function buildKeyframeIndicator({
|
||||
keyframe,
|
||||
trackId,
|
||||
elementId,
|
||||
@@ -98,7 +106,7 @@ function buildKeyframeIndicator({
|
||||
};
|
||||
}
|
||||
|
||||
function getKeyframeIndicators({
|
||||
export function getKeyframeIndicators({
|
||||
keyframes,
|
||||
trackId,
|
||||
elementId,
|
||||
@@ -145,8 +153,8 @@ function getKeyframeIndicators({
|
||||
return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
function getDisplayShortcut(action: TAction) {
|
||||
const { defaultShortcuts } = getActionDefinition(action);
|
||||
export function getDisplayShortcut({ action }: { action: TAction }) {
|
||||
const { defaultShortcuts } = getActionDefinition({ action });
|
||||
if (!defaultShortcuts?.length) {
|
||||
return "";
|
||||
}
|
||||
@@ -164,10 +172,13 @@ interface TimelineElementProps {
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
onResizeStateChange?: (params: { isResizing: boolean }) => void;
|
||||
onElementMouseDown: (
|
||||
e: React.MouseEvent,
|
||||
event: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
onElementClick: (
|
||||
event: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
|
||||
dragState: ElementDragState;
|
||||
isDropTarget?: boolean;
|
||||
}
|
||||
@@ -188,12 +199,12 @@ export function TimelineElement({
|
||||
const { selectedElements } = useElementSelection();
|
||||
const { requestRevealMedia } = useAssetsPanelStore();
|
||||
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
let mediaAsset: MediaAsset | null = null;
|
||||
|
||||
if (hasMediaId(element)) {
|
||||
mediaAsset =
|
||||
mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
|
||||
editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
|
||||
null;
|
||||
}
|
||||
|
||||
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
|
||||
@@ -242,6 +253,13 @@ export function TimelineElement({
|
||||
elementWidth,
|
||||
})
|
||||
: [];
|
||||
|
||||
const {
|
||||
keyframeDragState,
|
||||
handleKeyframeMouseDown,
|
||||
handleKeyframeClick,
|
||||
getVisualOffsetPx,
|
||||
} = useKeyframeDrag({ zoomLevel, element, displayedStartTime });
|
||||
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
|
||||
event.stopPropagation();
|
||||
if (hasMediaId(element)) {
|
||||
@@ -255,7 +273,7 @@ export function TimelineElement({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={`absolute top-0 h-full select-none`}
|
||||
className="absolute top-0 h-full select-none"
|
||||
style={{
|
||||
left: `${elementLeft}px`,
|
||||
width: `${elementWidth}px`,
|
||||
@@ -269,15 +287,24 @@ export function TimelineElement({
|
||||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
hasAudio={hasAudio}
|
||||
isMuted={isMuted}
|
||||
mediaAssets={mediaAssets}
|
||||
onElementClick={onElementClick}
|
||||
onElementMouseDown={onElementMouseDown}
|
||||
handleResizeStart={handleResizeStart}
|
||||
isDropTarget={isDropTarget}
|
||||
/>
|
||||
{isSelected && <KeyframeIndicators indicators={keyframeIndicators} />}
|
||||
{isSelected && (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<KeyframeIndicators
|
||||
indicators={keyframeIndicators}
|
||||
dragState={keyframeDragState}
|
||||
displayedStartTime={displayedStartTime}
|
||||
elementLeft={elementLeft}
|
||||
onKeyframeMouseDown={handleKeyframeMouseDown}
|
||||
onKeyframeClick={handleKeyframeClick}
|
||||
getVisualOffsetPx={getVisualOffsetPx}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-64">
|
||||
@@ -344,9 +371,6 @@ function ElementInner({
|
||||
element,
|
||||
track,
|
||||
isSelected,
|
||||
hasAudio,
|
||||
isMuted,
|
||||
mediaAssets,
|
||||
onElementClick,
|
||||
onElementMouseDown,
|
||||
handleResizeStart,
|
||||
@@ -355,12 +379,12 @@ function ElementInner({
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
hasAudio: boolean;
|
||||
isMuted: boolean;
|
||||
mediaAssets: MediaAsset[];
|
||||
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
|
||||
onElementClick: (
|
||||
event: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
onElementMouseDown: (
|
||||
e: React.MouseEvent,
|
||||
event: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
handleResizeStart: (params: {
|
||||
@@ -374,47 +398,56 @@ function ElementInner({
|
||||
(canElementBeHidden(element) && element.hidden) || isDropTarget
|
||||
? "opacity-50"
|
||||
: "";
|
||||
const closeClipEffects = usePropertiesStore(
|
||||
(state) => state.closeClipEffects,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
|
||||
{
|
||||
type: track.type,
|
||||
},
|
||||
)} ${opacityClass}`}
|
||||
className="relative h-full cursor-pointer"
|
||||
style={{ marginInline: ELEMENT_RING_WIDTH_PX }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 size-full cursor-pointer"
|
||||
onClick={(e) => onElementClick(e, element)}
|
||||
onMouseDown={(e) => onElementMouseDown(e, element)}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-hidden rounded-sm",
|
||||
getTrackClasses({ type: track.type }),
|
||||
opacityClass,
|
||||
)}
|
||||
style={
|
||||
isSelected
|
||||
? {
|
||||
boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="absolute inset-0 flex h-full items-center">
|
||||
<ElementContent
|
||||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
mediaAssets={mediaAssets}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 size-full cursor-pointer flex flex-col"
|
||||
onClick={(event) => {
|
||||
closeClipEffects();
|
||||
onElementClick(event, element);
|
||||
}}
|
||||
onMouseDown={(event) => onElementMouseDown(event, element)}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 items-center overflow-hidden">
|
||||
<ElementContent
|
||||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{element.type !== "audio" && element.type !== "effect" && (
|
||||
<div className="sticky left-1 mt-1 ml-1 w-fit">
|
||||
<EffectsButton
|
||||
element={element as VisualElement}
|
||||
trackId={track.id}
|
||||
/>
|
||||
</div>
|
||||
{(hasAudio
|
||||
? isMuted
|
||||
: canElementBeHidden(element) && element.hidden) && (
|
||||
<div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
|
||||
{hasAudio ? (
|
||||
<HugeiconsIcon
|
||||
icon={VolumeHighIcon}
|
||||
className="size-6 text-white"
|
||||
/>
|
||||
) : (
|
||||
<HugeiconsIcon
|
||||
icon={VolumeOffIcon}
|
||||
className="size-6 text-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
@@ -451,75 +484,91 @@ function ResizeHandle({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
|
||||
className={cn(
|
||||
"absolute top-0 bottom-0 w-2",
|
||||
isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
|
||||
)}
|
||||
onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
|
||||
>
|
||||
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</button>
|
||||
></button>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyframeIndicators({
|
||||
indicators,
|
||||
dragState,
|
||||
displayedStartTime,
|
||||
elementLeft,
|
||||
onKeyframeMouseDown,
|
||||
onKeyframeClick,
|
||||
getVisualOffsetPx,
|
||||
}: {
|
||||
indicators: KeyframeIndicator[];
|
||||
dragState: KeyframeDragState;
|
||||
displayedStartTime: number;
|
||||
elementLeft: number;
|
||||
onKeyframeMouseDown: (params: {
|
||||
event: React.MouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}) => void;
|
||||
onKeyframeClick: (params: {
|
||||
event: React.MouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
indicatorTime: number;
|
||||
}) => void;
|
||||
getVisualOffsetPx: (params: {
|
||||
indicatorTime: number;
|
||||
indicatorOffsetPx: number;
|
||||
isBeingDragged: boolean;
|
||||
displayedStartTime: number;
|
||||
elementLeft: number;
|
||||
}) => number;
|
||||
}) {
|
||||
const { isKeyframeSelected, toggleKeyframeSelection, selectKeyframeRange } =
|
||||
useKeyframeSelection();
|
||||
const { isKeyframeSelected } = useKeyframeSelection();
|
||||
const orderedKeyframes = indicators.flatMap(
|
||||
(indicator) => indicator.keyframes,
|
||||
);
|
||||
|
||||
const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleKeyframeClick = ({
|
||||
event,
|
||||
keyframes,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
if (event.shiftKey) {
|
||||
selectKeyframeRange({
|
||||
orderedKeyframes,
|
||||
targetKeyframes: keyframes,
|
||||
isAdditive: event.metaKey || event.ctrlKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toggleKeyframeSelection({
|
||||
keyframes,
|
||||
isMultiKey: event.metaKey || event.ctrlKey,
|
||||
});
|
||||
};
|
||||
|
||||
return indicators.map((indicator) => {
|
||||
const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
|
||||
isKeyframeSelected({ keyframe }),
|
||||
);
|
||||
const isBeingDragged = indicator.keyframes.some((kf) =>
|
||||
dragState.draggingKeyframeIds.has(kf.keyframeId),
|
||||
);
|
||||
const visualOffsetPx = getVisualOffsetPx({
|
||||
indicatorTime: indicator.time,
|
||||
indicatorOffsetPx: indicator.offsetPx,
|
||||
isBeingDragged,
|
||||
displayedStartTime,
|
||||
elementLeft,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={indicator.time}
|
||||
type="button"
|
||||
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer"
|
||||
style={{ left: indicator.offsetPx }}
|
||||
onMouseDown={(event) => handleKeyframeMouseDown({ event })}
|
||||
className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab"
|
||||
style={{ left: visualOffsetPx }}
|
||||
onMouseDown={(event) =>
|
||||
onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
|
||||
}
|
||||
onClick={(event) =>
|
||||
handleKeyframeClick({ event, keyframes: indicator.keyframes })
|
||||
onKeyframeClick({
|
||||
event,
|
||||
keyframes: indicator.keyframes,
|
||||
orderedKeyframes,
|
||||
indicatorTime: indicator.time,
|
||||
})
|
||||
}
|
||||
aria-label="Select keyframe"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={KeyframeIcon}
|
||||
className={cn(
|
||||
"size-3.5 text-black",
|
||||
"size-3.5 mt-1.5 text-black",
|
||||
isIndicatorSelected ? "fill-primary" : "fill-white",
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
@@ -533,10 +582,82 @@ interface ElementContentProps {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
interface ElementContentRendererProps extends ElementContentProps {
|
||||
mediaAssets: MediaAsset[];
|
||||
}
|
||||
|
||||
type ElementContentRenderer = (props: ElementContentProps) => ReactNode;
|
||||
type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode;
|
||||
|
||||
export function renderTiledMedia({
|
||||
element,
|
||||
imageUrl,
|
||||
track,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
imageUrl: string | undefined;
|
||||
track: ElementContentProps["track"];
|
||||
}): ReactNode {
|
||||
if (!imageUrl) {
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const tileWidth = trackHeight * (16 / 9);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${trackHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectsButton({
|
||||
element,
|
||||
trackId,
|
||||
className,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const openClipEffects = usePropertiesStore((state) => state.openClipEffects);
|
||||
const { selectElement } = useElementSelection();
|
||||
|
||||
if (!element.effects?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
selectElement({ elementId: element.id, trackId });
|
||||
openClipEffects({ elementId: element.id, trackId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className={cn("rounded-sm !size-5 bg-background/50", className)}
|
||||
onClick={handleClick}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<HugeiconsIcon icon={MagicWand05Icon} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const ELEMENT_CONTENT_RENDERERS: Record<
|
||||
TimelineElementType["type"],
|
||||
@@ -548,7 +669,7 @@ const ELEMENT_CONTENT_RENDERERS: Record<
|
||||
{ type: "text" }
|
||||
>;
|
||||
return (
|
||||
<div className="flex size-full items-center justify-start pl-3">
|
||||
<div className="flex size-full items-center justify-start pl-2">
|
||||
<span className="truncate text-xs text-white">
|
||||
{textElement.content}
|
||||
</span>
|
||||
@@ -557,8 +678,11 @@ const ELEMENT_CONTENT_RENDERERS: Record<
|
||||
},
|
||||
effect: ({ element }) => (
|
||||
<div className="flex size-full items-center justify-start gap-1 pl-2">
|
||||
<HugeiconsIcon icon={MagicWand05Icon} className="size-4 shrink-0 text-white" />
|
||||
<span className="truncate text-xs text-white">{element.name}</span>
|
||||
<HugeiconsIcon
|
||||
icon={MagicWand05Icon}
|
||||
className="size-4 shrink-0 text-white"
|
||||
/>
|
||||
<span className="truncate text-xs text-white ml-1">{element.name}</span>
|
||||
</div>
|
||||
),
|
||||
sticker: ({ element }) => {
|
||||
@@ -618,7 +742,7 @@ const ELEMENT_CONTENT_RENDERERS: Record<
|
||||
</span>
|
||||
);
|
||||
},
|
||||
video: ({ element, track, isSelected, mediaAssets }) => {
|
||||
video: ({ element, track, mediaAssets }) => {
|
||||
const videoElement = element as Extract<
|
||||
TimelineElementType,
|
||||
{ type: "video" }
|
||||
@@ -626,48 +750,13 @@ const ELEMENT_CONTENT_RENDERERS: Record<
|
||||
const mediaAsset = mediaAssets.find(
|
||||
(asset) => asset.id === videoElement.mediaId,
|
||||
);
|
||||
|
||||
if (!mediaAsset) {
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">
|
||||
{videoElement.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaAsset.thumbnailUrl) {
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const tileWidth = trackHeight * (16 / 9);
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<div
|
||||
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
|
||||
>
|
||||
<div
|
||||
className="absolute right-0 left-0"
|
||||
style={{
|
||||
backgroundImage: `url(${mediaAsset.thumbnailUrl})`,
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${trackHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
top: isSelected ? "0.25rem" : "0rem",
|
||||
bottom: isSelected ? "0.25rem" : "0rem",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">
|
||||
{videoElement.name}
|
||||
</span>
|
||||
);
|
||||
return renderTiledMedia({
|
||||
element: videoElement,
|
||||
imageUrl: mediaAsset?.thumbnailUrl,
|
||||
track,
|
||||
});
|
||||
},
|
||||
image: ({ element, track, isSelected, mediaAssets }) => {
|
||||
image: ({ element, track, mediaAssets }) => {
|
||||
const imageElement = element as Extract<
|
||||
TimelineElementType,
|
||||
{ type: "image" }
|
||||
@@ -675,44 +764,27 @@ const ELEMENT_CONTENT_RENDERERS: Record<
|
||||
const mediaAsset = mediaAssets.find(
|
||||
(asset) => asset.id === imageElement.mediaId,
|
||||
);
|
||||
|
||||
if (!mediaAsset?.url) {
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">
|
||||
{imageElement.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const tileWidth = trackHeight * (16 / 9);
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<div
|
||||
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
|
||||
>
|
||||
<div
|
||||
className="absolute right-0 left-0"
|
||||
style={{
|
||||
backgroundImage: `url(${mediaAsset.url})`,
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${trackHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
top: isSelected ? "0.25rem" : "0rem",
|
||||
bottom: isSelected ? "0.25rem" : "0rem",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return renderTiledMedia({
|
||||
element: imageElement,
|
||||
imageUrl: mediaAsset?.url,
|
||||
track,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function ElementContent(props: ElementContentProps) {
|
||||
const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type];
|
||||
return <>{renderer(props)}</>;
|
||||
function ElementContent({ element, track, isSelected }: ElementContentProps) {
|
||||
const editor = useEditor();
|
||||
const renderer = ELEMENT_CONTENT_RENDERERS[element.type];
|
||||
return (
|
||||
<>
|
||||
{renderer({
|
||||
element,
|
||||
track,
|
||||
isSelected,
|
||||
mediaAssets: editor.media.getAssets(),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyMenuItem() {
|
||||
@@ -740,9 +812,9 @@ function MuteMenuItem({
|
||||
return <HugeiconsIcon icon={VolumeMute02Icon} />;
|
||||
}
|
||||
return isMuted ? (
|
||||
<HugeiconsIcon icon={VolumeHighIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={VolumeOffIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={VolumeHighIcon} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -823,7 +895,7 @@ function ActionMenuItem({
|
||||
event.stopPropagation();
|
||||
invokeAction(action);
|
||||
}}
|
||||
textRight={getDisplayShortcut(action)}
|
||||
textRight={getDisplayShortcut({ action })}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function TimelineToolbar({
|
||||
function ToolbarLeftSection() {
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
|
||||
const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime });
|
||||
|
||||
const handleAction = ({
|
||||
action,
|
||||
@@ -116,7 +116,7 @@ function ToolbarLeftSection() {
|
||||
|
||||
<ToolbarButton
|
||||
icon={<SplitSquareHorizontal />}
|
||||
tooltip="Coming soon" /* separate audio */
|
||||
tooltip="Separate audio (coming soon)"
|
||||
disabled={true}
|
||||
onClick={({ event: _event }) => {}}
|
||||
/>
|
||||
@@ -131,7 +131,7 @@ function ToolbarLeftSection() {
|
||||
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={SnowIcon} />}
|
||||
tooltip="Coming soon" /* freeze frame */
|
||||
tooltip="Freeze frame (coming soon)"
|
||||
disabled={true}
|
||||
onClick={({ event: _event }) => {}}
|
||||
/>
|
||||
@@ -149,8 +149,8 @@ function ToolbarLeftSection() {
|
||||
<Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={Bookmark02Icon} />}
|
||||
isActive={currentBookmarked}
|
||||
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
isActive={isCurrentlyBookmarked}
|
||||
tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-bookmark", event })
|
||||
}
|
||||
@@ -191,12 +191,10 @@ function ToolbarRightSection({
|
||||
onZoomChange: (zoom: number) => void;
|
||||
onZoom: (options: { direction: "in" | "out" }) => void;
|
||||
}) {
|
||||
const {
|
||||
snappingEnabled,
|
||||
rippleEditingEnabled,
|
||||
toggleSnapping,
|
||||
toggleRippleEditing,
|
||||
} = useTimelineStore();
|
||||
const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
|
||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -52,7 +52,7 @@ export function TimelineTrackContent({
|
||||
targetElementId = null,
|
||||
}: TimelineTrackContentProps) {
|
||||
const editor = useEditor();
|
||||
const { isElementSelected, clearElementSelection } = useElementSelection();
|
||||
const { isElementSelected } = useElementSelection();
|
||||
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
@@ -69,7 +69,6 @@ export function TimelineTrackContent({
|
||||
className="size-full"
|
||||
onClick={(event) => {
|
||||
if (shouldIgnoreClick?.()) return;
|
||||
clearElementSelection();
|
||||
onTrackClick?.(event);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function GitHubContributeSection({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button className="w-full" size="lg">
|
||||
<Button variant="foreground" className="w-full" size="lg">
|
||||
<HugeiconsIcon icon={GithubIcon} />
|
||||
Start contributing
|
||||
</Button>
|
||||
|
||||
@@ -17,6 +17,10 @@ export function Header() {
|
||||
const closeMenu = () => setIsMenuOpen(false);
|
||||
|
||||
const links = [
|
||||
{
|
||||
label: "Roadmap",
|
||||
href: "/roadmap",
|
||||
},
|
||||
{
|
||||
label: "Contributors",
|
||||
href: "/contributors",
|
||||
|
||||
@@ -31,7 +31,7 @@ const contextMenuItemVariants = cva(
|
||||
default:
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground",
|
||||
destructive:
|
||||
"text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive",
|
||||
"text-destructive focus:bg-destructive/10 focus:text-destructive [&_svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -17,7 +17,11 @@ export const FONT_SIZE_SCALE_REFERENCE = 90;
|
||||
export const DEFAULT_LETTER_SPACING = 0;
|
||||
export const DEFAULT_LINE_HEIGHT = 1.2;
|
||||
|
||||
export const CORNER_RADIUS_MIN = 0;
|
||||
export const CORNER_RADIUS_MAX = 100;
|
||||
|
||||
export const DEFAULT_TEXT_BACKGROUND = {
|
||||
enabled: false,
|
||||
color: "#000000",
|
||||
cornerRadius: 0,
|
||||
paddingX: 30,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
TProjectSettings,
|
||||
TTimelineViewState,
|
||||
} from "@/types/project";
|
||||
import type { ExportOptions, ExportResult } from "@/types/export";
|
||||
import type { ExportOptions, ExportResult, ExportState } from "@/types/export";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { toast } from "sonner";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
@@ -51,6 +51,12 @@ export class ProjectManager {
|
||||
toVersion: null,
|
||||
projectName: null,
|
||||
};
|
||||
private exportState: ExportState = {
|
||||
isExporting: false,
|
||||
progress: 0,
|
||||
result: null,
|
||||
};
|
||||
private exportCancelRequested = false;
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
@@ -191,7 +197,40 @@ export class ProjectManager {
|
||||
}
|
||||
|
||||
async export({ options }: { options: ExportOptions }): Promise<ExportResult> {
|
||||
return this.editor.renderer.exportProject({ options });
|
||||
this.exportCancelRequested = false;
|
||||
this.exportState = { isExporting: true, progress: 0, result: null };
|
||||
this.notify();
|
||||
|
||||
const result = await this.editor.renderer.exportProject({
|
||||
options,
|
||||
onProgress: ({ progress }) => {
|
||||
this.exportState = { ...this.exportState, progress };
|
||||
this.notify();
|
||||
},
|
||||
onCancel: () => this.exportCancelRequested,
|
||||
});
|
||||
|
||||
this.exportState = {
|
||||
isExporting: false,
|
||||
progress: this.exportState.progress,
|
||||
result,
|
||||
};
|
||||
this.notify();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
cancelExport(): void {
|
||||
this.exportCancelRequested = true;
|
||||
}
|
||||
|
||||
clearExportState(): void {
|
||||
this.exportState = { isExporting: false, progress: 0, result: null };
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getExportState(): ExportState {
|
||||
return this.exportState;
|
||||
}
|
||||
|
||||
async loadAllProjects(): Promise<void> {
|
||||
|
||||
@@ -88,11 +88,14 @@ export class RendererManager {
|
||||
|
||||
async exportProject({
|
||||
options,
|
||||
onProgress,
|
||||
onCancel,
|
||||
}: {
|
||||
options: ExportOptions;
|
||||
onProgress?: ({ progress }: { progress: number }) => void;
|
||||
onCancel?: () => boolean;
|
||||
}): Promise<ExportResult> {
|
||||
const { format, quality, fps, includeAudio, onProgress, onCancel } =
|
||||
options;
|
||||
const { format, quality, fps, includeAudio } = options;
|
||||
|
||||
try {
|
||||
const tracks = this.editor.timeline.getTracks();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { EffectParamValues } from "@/types/effects";
|
||||
import type {
|
||||
TrackType,
|
||||
TimelineTrack,
|
||||
@@ -32,6 +33,13 @@ import {
|
||||
UpsertKeyframeCommand,
|
||||
RemoveKeyframeCommand,
|
||||
RetimeKeyframeCommand,
|
||||
AddClipEffectCommand,
|
||||
RemoveClipEffectCommand,
|
||||
UpdateClipEffectParamsCommand,
|
||||
ToggleClipEffectCommand,
|
||||
ReorderClipEffectsCommand,
|
||||
UpsertEffectParamKeyframeCommand,
|
||||
RemoveEffectParamKeyframeCommand,
|
||||
} from "@/lib/commands/timeline";
|
||||
import { BatchCommand, PreviewTracker } from "@/lib/commands";
|
||||
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
||||
@@ -62,17 +70,26 @@ export class TimelineManager {
|
||||
elementId,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
startTime,
|
||||
duration,
|
||||
pushHistory = true,
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
elementId: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
startTime?: number;
|
||||
duration?: number;
|
||||
pushHistory?: boolean;
|
||||
rippleEnabled?: boolean;
|
||||
}): void {
|
||||
const command = new UpdateElementTrimCommand({
|
||||
elementId,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
startTime,
|
||||
duration,
|
||||
rippleEnabled,
|
||||
});
|
||||
if (pushHistory) {
|
||||
this.editor.command.execute({ command });
|
||||
@@ -124,12 +141,14 @@ export class TimelineManager {
|
||||
elementId,
|
||||
newStartTime,
|
||||
createTrack,
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
sourceTrackId: string;
|
||||
targetTrackId: string;
|
||||
elementId: string;
|
||||
newStartTime: number;
|
||||
createTrack?: { type: TrackType; index: number };
|
||||
rippleEnabled?: boolean;
|
||||
}): void {
|
||||
const command = new MoveElementCommand({
|
||||
sourceTrackId,
|
||||
@@ -137,6 +156,7 @@ export class TimelineManager {
|
||||
elementId,
|
||||
newStartTime,
|
||||
createTrack,
|
||||
rippleEnabled,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
@@ -253,6 +273,104 @@ export class TimelineManager {
|
||||
}
|
||||
}
|
||||
|
||||
addClipEffect({
|
||||
trackId,
|
||||
elementId,
|
||||
effectType,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectType: string;
|
||||
}): string {
|
||||
const command = new AddClipEffectCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
effectType,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
return command.getEffectId() ?? "";
|
||||
}
|
||||
|
||||
removeClipEffect({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
}): void {
|
||||
const command = new RemoveClipEffectCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
updateClipEffectParams({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
params,
|
||||
pushHistory = true,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
params: Partial<EffectParamValues>;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
const command = new UpdateClipEffectParamsCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
params,
|
||||
});
|
||||
if (pushHistory) {
|
||||
this.editor.command.execute({ command });
|
||||
} else {
|
||||
command.execute();
|
||||
}
|
||||
}
|
||||
|
||||
toggleClipEffect({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
}): void {
|
||||
const command = new ToggleClipEffectCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
reorderClipEffects({
|
||||
trackId,
|
||||
elementId,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
fromIndex: number;
|
||||
toIndex: number;
|
||||
}): void {
|
||||
const command = new ReorderClipEffectsCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
upsertKeyframes({
|
||||
keyframes,
|
||||
}: {
|
||||
@@ -346,6 +464,61 @@ export class TimelineManager {
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
upsertEffectParamKeyframe({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
paramKey,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
time: number;
|
||||
value: number;
|
||||
interpolation?: "linear" | "hold";
|
||||
keyframeId?: string;
|
||||
}): void {
|
||||
const command = new UpsertEffectParamKeyframeCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
paramKey,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
removeEffectParamKeyframe({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
paramKey,
|
||||
keyframeId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
keyframeId: string;
|
||||
}): void {
|
||||
const command = new RemoveEffectParamKeyframeCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
paramKey,
|
||||
keyframeId,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
isPreviewActive(): boolean {
|
||||
return this.previewTracker.isActive();
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
||||
import { useEditor } from "../use-editor";
|
||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
||||
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
||||
import { getElementsAtTime } from "@/lib/timeline";
|
||||
|
||||
export function useEditorActions() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { selectedElements, setElementSelection } = useElementSelection();
|
||||
const {
|
||||
clipboard,
|
||||
setClipboard,
|
||||
toggleSnapping,
|
||||
rippleEditingEnabled,
|
||||
toggleRippleEditing,
|
||||
} = useTimelineStore();
|
||||
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
||||
const clipboard = useTimelineStore((s) => s.clipboard);
|
||||
const setClipboard = useTimelineStore((s) => s.setClipboard);
|
||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||
|
||||
useActionHandler(
|
||||
"toggle-play",
|
||||
@@ -209,6 +209,11 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"delete-selected",
|
||||
() => {
|
||||
if (selectedKeyframes.length > 0) {
|
||||
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
||||
clearKeyframeSelection();
|
||||
return;
|
||||
}
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -235,6 +240,19 @@ export function useEditorActions() {
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"deselect-all",
|
||||
() => {
|
||||
setElementSelection({ elements: [] });
|
||||
clearKeyframeSelection();
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement instanceof HTMLButtonElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"duplicate-selected",
|
||||
() => {
|
||||
@@ -278,7 +296,7 @@ export function useEditorActions() {
|
||||
elements: selectedElements,
|
||||
});
|
||||
const items = results.map(({ track, element }) => {
|
||||
const { ...elementWithoutId } = element;
|
||||
const { id: _, ...elementWithoutId } = element;
|
||||
return {
|
||||
trackId: track.id,
|
||||
trackType: track.type,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
@@ -38,6 +39,8 @@ interface UseElementInteractionProps {
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
const MOUSE_BUTTON_RIGHT = 2;
|
||||
|
||||
const initialDragState: ElementDragState = {
|
||||
isDragging: false,
|
||||
elementId: null,
|
||||
@@ -162,6 +165,7 @@ export function useElementInteraction({
|
||||
onSnapPointChange,
|
||||
}: UseElementInteractionProps) {
|
||||
const editor = useEditor();
|
||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const {
|
||||
@@ -441,27 +445,33 @@ export function useElementInteraction({
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropTarget.isNewTrack) {
|
||||
const newTrackId = generateUUID();
|
||||
if (dropTarget.isNewTrack) {
|
||||
const newTrackId = generateUUID();
|
||||
|
||||
editor.timeline.moveElement({
|
||||
sourceTrackId: dragState.trackId,
|
||||
targetTrackId: newTrackId,
|
||||
elementId: dragState.elementId,
|
||||
newStartTime: snappedTime,
|
||||
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
|
||||
rippleEnabled: rippleEditingEnabled,
|
||||
});
|
||||
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
|
||||
} else {
|
||||
const targetTrack = tracks[dropTarget.trackIndex];
|
||||
if (targetTrack) {
|
||||
editor.timeline.moveElement({
|
||||
sourceTrackId: dragState.trackId,
|
||||
targetTrackId: newTrackId,
|
||||
targetTrackId: targetTrack.id,
|
||||
elementId: dragState.elementId,
|
||||
newStartTime: snappedTime,
|
||||
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
|
||||
rippleEnabled: rippleEditingEnabled,
|
||||
});
|
||||
} else {
|
||||
const targetTrack = tracks[dropTarget.trackIndex];
|
||||
if (targetTrack) {
|
||||
editor.timeline.moveElement({
|
||||
sourceTrackId: dragState.trackId,
|
||||
targetTrackId: targetTrack.id,
|
||||
elementId: dragState.elementId,
|
||||
newStartTime: snappedTime,
|
||||
});
|
||||
if (targetTrack.id !== dragState.trackId) {
|
||||
selectElement({ trackId: targetTrack.id, elementId: dragState.elementId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
@@ -483,6 +493,8 @@ export function useElementInteraction({
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
rippleEditingEnabled,
|
||||
selectElement,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -508,10 +520,10 @@ export function useElementInteraction({
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
const isRightClick = event.button === 2;
|
||||
const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
|
||||
|
||||
// right-click: don't stop propagation so ContextMenu can open
|
||||
if (isRightClick) {
|
||||
// right-click: don't stop propagation so ContextMenu can open
|
||||
if (isRightClick) {
|
||||
const alreadySelected = isElementSelected({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
@@ -526,14 +538,12 @@ export function useElementInteraction({
|
||||
return;
|
||||
}
|
||||
|
||||
// left-click: stop propagation for drag operations
|
||||
event.stopPropagation();
|
||||
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
|
||||
event.stopPropagation();
|
||||
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
|
||||
|
||||
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
|
||||
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
|
||||
|
||||
// multi-select: toggle selection
|
||||
if (isMultiSelect) {
|
||||
if (isMultiSelect) {
|
||||
handleSelectionClick({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
@@ -541,8 +551,7 @@ export function useElementInteraction({
|
||||
});
|
||||
}
|
||||
|
||||
// start drag
|
||||
const clickOffsetTime = getClickOffsetTime({
|
||||
const clickOffsetTime = getClickOffsetTime({
|
||||
clientX: event.clientX,
|
||||
elementRect: event.currentTarget.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
@@ -570,10 +579,9 @@ export function useElementInteraction({
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
event.stopPropagation();
|
||||
|
||||
// was it a drag or a click?
|
||||
if (mouseDownLocationRef.current) {
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
|
||||
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
|
||||
@@ -585,8 +593,7 @@ export function useElementInteraction({
|
||||
// modifier keys already handled in mousedown
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
|
||||
// single click: select if not selected
|
||||
const alreadySelected = isElementSelected({
|
||||
const alreadySelected = isElementSelected({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
|
||||
@@ -40,7 +40,9 @@ export function useTimelineElementResize({
|
||||
const activeProject = editor.project.getActive();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
||||
|
||||
const rippleEditingEnabled = useTimelineStore(
|
||||
(state) => state.rippleEditingEnabled,
|
||||
);
|
||||
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
|
||||
@@ -137,18 +139,52 @@ export function useTimelineElementResize({
|
||||
}
|
||||
onSnapPointChange?.(resizeSnapPoint);
|
||||
|
||||
const otherElements = track.elements.filter(({ id }) => id !== element.id);
|
||||
const initialEndTime = resizing.initialStartTime + resizing.initialDuration;
|
||||
|
||||
const rightNeighborBound =
|
||||
resizing.side === "right"
|
||||
? otherElements
|
||||
.filter(({ startTime }) => startTime >= initialEndTime)
|
||||
.reduce((min, { startTime }) => Math.min(min, startTime), Infinity)
|
||||
: Infinity;
|
||||
|
||||
const leftNeighborBound =
|
||||
resizing.side === "left"
|
||||
? otherElements
|
||||
.filter(
|
||||
({ startTime, duration }) =>
|
||||
startTime + duration <= resizing.initialStartTime,
|
||||
)
|
||||
.reduce(
|
||||
(max, { startTime, duration }) =>
|
||||
Math.max(max, startTime + duration),
|
||||
-Infinity,
|
||||
)
|
||||
: -Infinity;
|
||||
|
||||
if (resizing.side === "left") {
|
||||
const sourceDuration =
|
||||
resizing.initialTrimStart +
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound)
|
||||
? Math.max(
|
||||
0,
|
||||
resizing.initialTrimStart +
|
||||
(leftNeighborBound - resizing.initialStartTime),
|
||||
)
|
||||
: 0;
|
||||
const maxAllowed =
|
||||
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
|
||||
if (calculated >= 0 && calculated <= maxAllowed) {
|
||||
const newTrimStart = snapTimeToFrame({
|
||||
time: Math.min(maxAllowed, calculated),
|
||||
time: Math.min(
|
||||
maxAllowed,
|
||||
Math.max(minTrimStartForNeighbor, calculated),
|
||||
),
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
@@ -171,7 +207,16 @@ export function useTimelineElementResize({
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionAmount = Math.abs(calculated);
|
||||
const maxExtension = resizing.initialStartTime;
|
||||
const actualExtension = Math.min(extensionAmount, maxExtension);
|
||||
const actualExtension = Math.max(
|
||||
0,
|
||||
Number.isFinite(leftNeighborBound)
|
||||
? Math.min(
|
||||
extensionAmount,
|
||||
maxExtension,
|
||||
resizing.initialStartTime - leftNeighborBound,
|
||||
)
|
||||
: Math.min(extensionAmount, maxExtension),
|
||||
);
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime - actualExtension,
|
||||
fps: projectFps,
|
||||
@@ -188,7 +233,8 @@ export function useTimelineElementResize({
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
} else {
|
||||
const trimDelta = 0 - resizing.initialTrimStart;
|
||||
const trimDelta =
|
||||
minTrimStartForNeighbor - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
@@ -198,10 +244,10 @@ export function useTimelineElementResize({
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentTrimStart(minTrimStartForNeighbor);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimStartRef.current = 0;
|
||||
currentTrimStartRef.current = minTrimStartForNeighbor;
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
}
|
||||
@@ -212,6 +258,9 @@ export function useTimelineElementResize({
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
|
||||
const maxAllowedDuration = Number.isFinite(rightNeighborBound)
|
||||
? rightNeighborBound - resizing.initialStartTime
|
||||
: Infinity;
|
||||
|
||||
if (newTrimEnd < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
@@ -219,7 +268,7 @@ export function useTimelineElementResize({
|
||||
const baseDuration =
|
||||
resizing.initialDuration + resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: baseDuration + extensionNeeded,
|
||||
time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration),
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
@@ -230,7 +279,10 @@ export function useTimelineElementResize({
|
||||
} else {
|
||||
const extensionToLimit = resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + extensionToLimit,
|
||||
time: Math.min(
|
||||
resizing.initialDuration + extensionToLimit,
|
||||
maxAllowedDuration,
|
||||
),
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
@@ -240,9 +292,20 @@ export function useTimelineElementResize({
|
||||
currentTrimEndRef.current = 0;
|
||||
}
|
||||
} else {
|
||||
const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration)
|
||||
? Math.max(
|
||||
0,
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd -
|
||||
maxAllowedDuration,
|
||||
)
|
||||
: 0;
|
||||
const maxTrimEnd =
|
||||
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
|
||||
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
|
||||
const clampedTrimEnd = Math.min(
|
||||
maxTrimEnd,
|
||||
Math.max(minTrimEndForNeighbor, newTrimEnd),
|
||||
);
|
||||
const finalTrimEnd = snapTimeToFrame({
|
||||
time: clampedTrimEnd,
|
||||
fps: projectFps,
|
||||
@@ -267,6 +330,7 @@ export function useTimelineElementResize({
|
||||
snappingEnabled,
|
||||
editor,
|
||||
element.id,
|
||||
track.elements,
|
||||
onSnapPointChange,
|
||||
canExtendElementDuration,
|
||||
isShiftHeldRef,
|
||||
@@ -285,26 +349,14 @@ export function useTimelineElementResize({
|
||||
const startTimeChanged = finalStartTime !== resizing.initialStartTime;
|
||||
const durationChanged = finalDuration !== resizing.initialDuration;
|
||||
|
||||
if (trimStartChanged || trimEndChanged) {
|
||||
if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) {
|
||||
editor.timeline.updateElementTrim({
|
||||
elementId: element.id,
|
||||
trimStart: finalTrimStart,
|
||||
trimEnd: finalTrimEnd,
|
||||
});
|
||||
}
|
||||
|
||||
if (startTimeChanged) {
|
||||
editor.timeline.updateElementStartTime({
|
||||
elements: [{ trackId: track.id, elementId: element.id }],
|
||||
startTime: finalStartTime,
|
||||
});
|
||||
}
|
||||
|
||||
if (durationChanged) {
|
||||
editor.timeline.updateElementDuration({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
duration: finalDuration,
|
||||
startTime: startTimeChanged ? finalStartTime : undefined,
|
||||
duration: durationChanged ? finalDuration : undefined,
|
||||
rippleEnabled: rippleEditingEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -315,9 +367,9 @@ export function useTimelineElementResize({
|
||||
resizing,
|
||||
editor.timeline,
|
||||
element.id,
|
||||
track.id,
|
||||
onResizeStateChange,
|
||||
onSnapPointChange,
|
||||
rippleEditingEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useKeyframeSelection } from "./use-keyframe-selection";
|
||||
import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import type { SelectedKeyframeRef } from "@/types/animation";
|
||||
import type { TimelineElement } from "@/types/timeline";
|
||||
import type { Command } from "@/lib/commands/base-command";
|
||||
export interface KeyframeDragState {
|
||||
isDragging: boolean;
|
||||
draggingKeyframeIds: Set<string>;
|
||||
deltaTime: number;
|
||||
}
|
||||
|
||||
const initialDragState: KeyframeDragState = {
|
||||
isDragging: false,
|
||||
draggingKeyframeIds: new Set(),
|
||||
deltaTime: 0,
|
||||
};
|
||||
|
||||
interface PendingKeyframeDrag {
|
||||
keyframeRefs: SelectedKeyframeRef[];
|
||||
startMouseX: number;
|
||||
}
|
||||
|
||||
export function useKeyframeDrag({
|
||||
zoomLevel,
|
||||
element,
|
||||
displayedStartTime,
|
||||
}: {
|
||||
zoomLevel: number;
|
||||
element: TimelineElement;
|
||||
displayedStartTime: number;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const {
|
||||
selectedKeyframes,
|
||||
isKeyframeSelected,
|
||||
setKeyframeSelection,
|
||||
toggleKeyframeSelection,
|
||||
selectKeyframeRange,
|
||||
} = useKeyframeSelection();
|
||||
|
||||
const [dragState, setDragState] =
|
||||
useState<KeyframeDragState>(initialDragState);
|
||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||
|
||||
const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
|
||||
const mouseDownXRef = useRef<number | null>(null);
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
const fps = activeProject.settings.fps;
|
||||
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragState(initialDragState);
|
||||
}, []);
|
||||
|
||||
const commitDrag = useCallback(
|
||||
({
|
||||
keyframeRefs,
|
||||
deltaTime,
|
||||
}: {
|
||||
keyframeRefs: SelectedKeyframeRef[];
|
||||
deltaTime: number;
|
||||
}) => {
|
||||
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
||||
const channel = element.animations?.channels[keyframeRef.propertyPath];
|
||||
const keyframe = channel?.keyframes.find(
|
||||
(keyframe) => keyframe.id === keyframeRef.keyframeId,
|
||||
);
|
||||
if (!keyframe) return [];
|
||||
const nextTime = Math.max(
|
||||
0,
|
||||
Math.min(element.duration, keyframe.time + deltaTime),
|
||||
);
|
||||
return [
|
||||
new RetimeKeyframeCommand({
|
||||
trackId: keyframeRef.trackId,
|
||||
elementId: keyframeRef.elementId,
|
||||
propertyPath: keyframeRef.propertyPath,
|
||||
keyframeId: keyframeRef.keyframeId,
|
||||
nextTime,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
if (commands.length === 1) {
|
||||
editor.command.execute({ command: commands[0] });
|
||||
} else if (commands.length > 1) {
|
||||
editor.command.execute({ command: new BatchCommand(commands) });
|
||||
}
|
||||
},
|
||||
[editor.command, element],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging && !isPendingDrag) return;
|
||||
|
||||
const handleMouseMove = ({ clientX }: MouseEvent) => {
|
||||
if (isPendingDrag && pendingDragRef.current) {
|
||||
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
|
||||
if (deltaX <= DRAG_THRESHOLD_PX) return;
|
||||
|
||||
const pending = pendingDragRef.current;
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
draggingKeyframeIds: new Set(
|
||||
pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
|
||||
),
|
||||
deltaTime: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const startX = mouseDownXRef.current ?? clientX;
|
||||
const rawDelta = (clientX - startX) / pixelsPerSecond;
|
||||
const snappedDelta = snapTimeToFrame({ time: rawDelta, fps });
|
||||
|
||||
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
return () => document.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const draggingRefs = selectedKeyframes.filter(
|
||||
(keyframe) =>
|
||||
keyframe.elementId === element.id &&
|
||||
dragState.draggingKeyframeIds.has(keyframe.keyframeId),
|
||||
);
|
||||
|
||||
if (draggingRefs.length > 0 && dragState.deltaTime !== 0) {
|
||||
commitDrag({
|
||||
keyframeRefs: draggingRefs,
|
||||
deltaTime: dragState.deltaTime,
|
||||
});
|
||||
}
|
||||
|
||||
endDrag();
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.draggingKeyframeIds,
|
||||
dragState.deltaTime,
|
||||
selectedKeyframes,
|
||||
element.id,
|
||||
commitDrag,
|
||||
endDrag,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPendingDrag) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [isPendingDrag]);
|
||||
|
||||
const handleKeyframeMouseDown = useCallback(
|
||||
({
|
||||
event,
|
||||
keyframes,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
mouseDownXRef.current = event.clientX;
|
||||
|
||||
const anySelected = keyframes.some((keyframe) =>
|
||||
isKeyframeSelected({ keyframe }),
|
||||
);
|
||||
|
||||
const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
|
||||
if (!anySelected && !isModifierKey) {
|
||||
setKeyframeSelection({ keyframes });
|
||||
}
|
||||
|
||||
const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes;
|
||||
|
||||
pendingDragRef.current = {
|
||||
keyframeRefs: keyframeRefsToTrack,
|
||||
startMouseX: event.clientX,
|
||||
};
|
||||
setIsPendingDrag(true);
|
||||
},
|
||||
[isKeyframeSelected, selectedKeyframes, setKeyframeSelection],
|
||||
);
|
||||
|
||||
const handleKeyframeClick = useCallback(
|
||||
({
|
||||
event,
|
||||
keyframes,
|
||||
orderedKeyframes,
|
||||
indicatorTime,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
indicatorTime: number;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const wasDrag =
|
||||
mouseDownXRef.current !== null &&
|
||||
Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX;
|
||||
mouseDownXRef.current = null;
|
||||
|
||||
if (wasDrag) return;
|
||||
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const seekTime = getSnappedSeekTime({
|
||||
rawTime: displayedStartTime + indicatorTime,
|
||||
duration,
|
||||
fps,
|
||||
});
|
||||
editor.playback.seek({ time: seekTime });
|
||||
|
||||
if (event.shiftKey) {
|
||||
selectKeyframeRange({
|
||||
orderedKeyframes,
|
||||
targetKeyframes: keyframes,
|
||||
isAdditive: event.metaKey || event.ctrlKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toggleKeyframeSelection({
|
||||
keyframes,
|
||||
isMultiKey: event.metaKey || event.ctrlKey,
|
||||
});
|
||||
},
|
||||
[toggleKeyframeSelection, selectKeyframeRange, editor, displayedStartTime, fps],
|
||||
);
|
||||
|
||||
const getVisualOffsetPx = useCallback(
|
||||
({
|
||||
indicatorTime,
|
||||
indicatorOffsetPx,
|
||||
isBeingDragged,
|
||||
displayedStartTime,
|
||||
elementLeft,
|
||||
}: {
|
||||
indicatorTime: number;
|
||||
indicatorOffsetPx: number;
|
||||
isBeingDragged: boolean;
|
||||
displayedStartTime: number;
|
||||
elementLeft: number;
|
||||
}): number => {
|
||||
if (!isBeingDragged) return indicatorOffsetPx;
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(element.duration, indicatorTime + dragState.deltaTime),
|
||||
);
|
||||
return (
|
||||
timelineTimeToSnappedPixels({
|
||||
time: displayedStartTime + clampedTime,
|
||||
zoomLevel,
|
||||
}) - elementLeft
|
||||
);
|
||||
},
|
||||
[dragState.deltaTime, element.duration, zoomLevel],
|
||||
);
|
||||
|
||||
return {
|
||||
keyframeDragState: dragState,
|
||||
handleKeyframeMouseDown,
|
||||
handleKeyframeClick,
|
||||
getVisualOffsetPx,
|
||||
};
|
||||
}
|
||||
@@ -121,14 +121,14 @@ export function useKeyframeSelection() {
|
||||
isMultiKey: boolean;
|
||||
}) => {
|
||||
const anchorKeyframe = keyframes[0];
|
||||
const areAllKeyframesSelected = keyframes.every((keyframe) =>
|
||||
isKeyframeSelected({ keyframe }),
|
||||
);
|
||||
if (!isMultiKey) {
|
||||
setKeyframeSelection({ keyframes, anchorKeyframe });
|
||||
return;
|
||||
}
|
||||
|
||||
const areAllKeyframesSelected = keyframes.every((keyframe) =>
|
||||
isKeyframeSelected({ keyframe }),
|
||||
);
|
||||
if (areAllKeyframesSelected) {
|
||||
removeKeyframesFromSelection({ keyframes, anchorKeyframe });
|
||||
return;
|
||||
|
||||
@@ -26,12 +26,14 @@ import type {
|
||||
interface UseTimelineDragDropProps {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
headerRef?: RefObject<HTMLElement | null>;
|
||||
tracksScrollRef?: RefObject<HTMLDivElement | null>;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
export function useTimelineDragDrop({
|
||||
containerRef,
|
||||
headerRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
}: UseTimelineDragDropProps) {
|
||||
const editor = useEditor();
|
||||
@@ -104,11 +106,16 @@ export function useTimelineDragDrop({
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const scrollContainer = tracksScrollRef?.current;
|
||||
const referenceRect =
|
||||
scrollContainer?.getBoundingClientRect() ??
|
||||
containerRef.current?.getBoundingClientRect();
|
||||
if (!referenceRect) return;
|
||||
|
||||
const headerHeight =
|
||||
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
const isExternal =
|
||||
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
|
||||
@@ -131,8 +138,8 @@ export function useTimelineDragDrop({
|
||||
mediaId: dragData?.type === "media" ? dragData.id : undefined,
|
||||
});
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
|
||||
const mouseX = e.clientX - referenceRect.left + scrollLeft;
|
||||
const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
|
||||
|
||||
const targetElementTypes =
|
||||
dragData?.type === "effect"
|
||||
@@ -162,6 +169,7 @@ export function useTimelineDragDrop({
|
||||
[
|
||||
containerRef,
|
||||
headerRef,
|
||||
tracksScrollRef,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
@@ -200,19 +208,6 @@ export function useTimelineDragDrop({
|
||||
target: DropTarget;
|
||||
dragData: { name?: string; content?: string };
|
||||
}) => {
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "text",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildTextElement({
|
||||
raw: {
|
||||
name: dragData.name ?? "",
|
||||
@@ -221,12 +216,26 @@ export function useTimelineDragDrop({
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
placement: { mode: "explicit", trackId: track.id },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
[editor.command, editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeStickerDrop = useCallback(
|
||||
@@ -237,31 +246,32 @@ export function useTimelineDragDrop({
|
||||
target: DropTarget;
|
||||
dragData: StickerDragData;
|
||||
}) => {
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "sticker",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildStickerElement({
|
||||
stickerId: dragData.stickerId,
|
||||
name: dragData.name,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
placement: { mode: "explicit", trackId: track.id },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
[editor.command, editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeMediaDrop = useCallback(
|
||||
@@ -276,18 +286,6 @@ export function useTimelineDragDrop({
|
||||
|
||||
const trackType: TrackType =
|
||||
dragData.mediaType === "audio" ? "audio" : "video";
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const duration =
|
||||
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
@@ -299,31 +297,63 @@ export function useTimelineDragDrop({
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
placement: { mode: "explicit", trackId: track.id },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, mediaAssets, tracks],
|
||||
[editor.command, editor.timeline, mediaAssets, tracks],
|
||||
);
|
||||
|
||||
const executeEffectDrop = useCallback(
|
||||
({ target, dragData }: { target: DropTarget; dragData: EffectDragData }) => {
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: EffectDragData;
|
||||
}) => {
|
||||
if (target.targetElement) {
|
||||
editor.timeline.addClipEffect({
|
||||
trackId: target.targetElement.trackId,
|
||||
elementId: target.targetElement.elementId,
|
||||
effectType: dragData.effectType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const effectTrack = tracks.find((t) => t.type === "effect");
|
||||
let trackId: string;
|
||||
|
||||
if (effectTrack && !target.targetElement) {
|
||||
if (effectTrack) {
|
||||
trackId = effectTrack.id;
|
||||
} else if (target.targetElement) {
|
||||
trackId = effectTrack?.id ?? editor.timeline.addTrack({
|
||||
type: "effect",
|
||||
index: 0,
|
||||
});
|
||||
} else if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "effect",
|
||||
index: target.trackIndex,
|
||||
const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element: buildEffectElement({
|
||||
effectType: dragData.effectType,
|
||||
startTime: target.xPosition,
|
||||
}),
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track || track.type !== "effect") return;
|
||||
@@ -340,7 +370,7 @@ export function useTimelineDragDrop({
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
[editor.command, editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeFileDrop = useCallback(
|
||||
@@ -452,12 +482,18 @@ export function useTimelineDragDrop({
|
||||
executeMediaDrop({ target: currentTarget, dragData });
|
||||
}
|
||||
} else if (hasFiles) {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const scrollContainer = tracksScrollRef?.current;
|
||||
const referenceRect =
|
||||
scrollContainer?.getBoundingClientRect() ??
|
||||
containerRef.current?.getBoundingClientRect();
|
||||
if (!referenceRect) return;
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
const mouseX = e.clientX - referenceRect.left + scrollLeft;
|
||||
const headerHeight =
|
||||
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
|
||||
const mouseY =
|
||||
e.clientY - referenceRect.top + scrollTop - headerHeight;
|
||||
await executeFileDrop({
|
||||
files: Array.from(e.dataTransfer.files),
|
||||
mouseX,
|
||||
@@ -478,6 +514,7 @@ export function useTimelineDragDrop({
|
||||
executeFileDrop,
|
||||
containerRef,
|
||||
headerRef,
|
||||
tracksScrollRef,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
@@ -18,7 +17,13 @@ import {
|
||||
type SnapLine,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
setChannel,
|
||||
} from "@/lib/animation";
|
||||
import type { Transform } from "@/types/timeline";
|
||||
import type { ElementAnimations } from "@/types/animation";
|
||||
|
||||
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
type HandleType = Corner | "rotation";
|
||||
@@ -32,6 +37,8 @@ interface ScaleState {
|
||||
initialBoundsCy: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
interface RotationState {
|
||||
@@ -78,16 +85,16 @@ function getCornerDistance({
|
||||
};
|
||||
corner: Corner;
|
||||
}): number {
|
||||
const halfW = bounds.width / 2;
|
||||
const halfH = bounds.height / 2;
|
||||
const halfWidth = bounds.width / 2;
|
||||
const halfHeight = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||
corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
|
||||
|
||||
const rotatedX = localX * cos - localY * sin;
|
||||
const rotatedY = localX * sin + localY * cos;
|
||||
@@ -114,6 +121,8 @@ export function useTransformHandles({
|
||||
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const currentTimeRef = useRef(currentTime);
|
||||
currentTimeRef.current = currentTime;
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
@@ -144,19 +153,41 @@ export function useTransformHandles({
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTimeRef.current,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const initialDistance = getCornerDistance({ bounds, corner });
|
||||
const baseWidth = bounds.width / element.transform.scale;
|
||||
const baseHeight = bounds.height / element.transform.scale;
|
||||
const baseWidth = bounds.width / resolvedTransform.scale;
|
||||
const baseHeight = bounds.height / resolvedTransform.scale;
|
||||
const shouldClearScaleAnimation =
|
||||
!!element.animations?.channels["transform.scale"];
|
||||
const animationsWithoutScale = shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scale",
|
||||
channel: undefined,
|
||||
})
|
||||
: element.animations;
|
||||
|
||||
scaleStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: element.transform,
|
||||
initialTransform: resolvedTransform,
|
||||
initialDistance,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
};
|
||||
setActiveHandle(corner);
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
@@ -172,19 +203,30 @@ export function useTransformHandles({
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTimeRef.current,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const position = screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
canvas: canvasRef.current,
|
||||
});
|
||||
const dx = position.x - bounds.cx;
|
||||
const dy = position.y - bounds.cy;
|
||||
const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
const deltaX = position.x - bounds.cx;
|
||||
const deltaY = position.y - bounds.cy;
|
||||
const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
|
||||
rotationStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: element.transform,
|
||||
initialTransform: resolvedTransform,
|
||||
initialAngle,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
@@ -220,11 +262,13 @@ export function useTransformHandles({
|
||||
initialBoundsCy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
} = scaleStateRef.current;
|
||||
|
||||
const dx = position.x - initialBoundsCx;
|
||||
const dy = position.y - initialBoundsCy;
|
||||
const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
|
||||
const scaleFactor = currentDistance / initialDistance;
|
||||
const proposedScale = Math.max(
|
||||
MIN_SCALE,
|
||||
@@ -258,14 +302,22 @@ export function useTransformHandles({
|
||||
setSnapLines(activeLines);
|
||||
}
|
||||
|
||||
const updates: {
|
||||
transform: Transform;
|
||||
animations?: ElementAnimations;
|
||||
} = {
|
||||
transform: { ...initialTransform, scale: snappedScale },
|
||||
};
|
||||
if (shouldClearScaleAnimation) {
|
||||
updates.animations = animationsWithoutScale;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: { ...initialTransform, scale: snappedScale },
|
||||
},
|
||||
updates,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -282,9 +334,9 @@ export function useTransformHandles({
|
||||
initialBoundsCy,
|
||||
} = rotationStateRef.current;
|
||||
|
||||
const dx = position.x - initialBoundsCx;
|
||||
const dy = position.y - initialBoundsCy;
|
||||
const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
let deltaAngle = currentAngle - initialAngle;
|
||||
if (deltaAngle > 180) deltaAngle -= 360;
|
||||
if (deltaAngle < -180) deltaAngle += 360;
|
||||
|
||||
@@ -114,6 +114,11 @@ export const ACTIONS = {
|
||||
category: "selection",
|
||||
defaultShortcuts: ["ctrl+a"],
|
||||
},
|
||||
"deselect-all": {
|
||||
description: "Deselect all elements",
|
||||
category: "selection",
|
||||
defaultShortcuts: ["escape"],
|
||||
},
|
||||
"duplicate-selected": {
|
||||
description: "Duplicate selected element",
|
||||
category: "selection",
|
||||
@@ -145,7 +150,7 @@ export const ACTIONS = {
|
||||
|
||||
export type TAction = keyof typeof ACTIONS;
|
||||
|
||||
export function getActionDefinition(action: TAction): TActionDefinition {
|
||||
export function getActionDefinition({ action }: { action: TAction }): TActionDefinition {
|
||||
return ACTIONS[action];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type {
|
||||
AnimationPropertyPath,
|
||||
ColorAnimationChannel,
|
||||
ElementAnimations,
|
||||
} from "@/types/animation";
|
||||
|
||||
export function getColorChannelForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): ColorAnimationChannel | undefined {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
if (!channel || channel.valueKind !== "color") {
|
||||
return undefined;
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Effect, EffectParamValues } from "@/types/effects";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
NumberAnimationChannel,
|
||||
} from "@/types/animation";
|
||||
import {
|
||||
getChannel,
|
||||
removeKeyframe,
|
||||
setChannel,
|
||||
upsertKeyframe,
|
||||
} from "./keyframes";
|
||||
import { getChannelValueAtTime } from "./interpolation";
|
||||
|
||||
const EFFECT_PARAM_PATH_PREFIX = "effects.";
|
||||
const EFFECT_PARAM_PATH_SUFFIX = ".params.";
|
||||
|
||||
function buildEffectParamPath({
|
||||
effectId,
|
||||
paramKey,
|
||||
}: {
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
}): string {
|
||||
return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
|
||||
}
|
||||
|
||||
export function resolveEffectParamsAtTime({
|
||||
effect,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
effect: Effect;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): EffectParamValues {
|
||||
const resolved: EffectParamValues = {};
|
||||
|
||||
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
||||
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
||||
const channel = getChannel({ animations, propertyPath: path });
|
||||
if (channel && channel.keyframes.length > 0) {
|
||||
resolved[paramKey] = getChannelValueAtTime({
|
||||
channel,
|
||||
time: localTime,
|
||||
fallbackValue: staticValue,
|
||||
}) as number | string | boolean;
|
||||
} else {
|
||||
resolved[paramKey] = staticValue;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = {
|
||||
valueKind: "number",
|
||||
keyframes: [],
|
||||
};
|
||||
|
||||
export function upsertEffectParamKeyframe({
|
||||
animations,
|
||||
effectId,
|
||||
paramKey,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
time: number;
|
||||
value: number;
|
||||
interpolation?: "linear" | "hold";
|
||||
keyframeId?: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const path = buildEffectParamPath({ effectId, paramKey });
|
||||
const channel = getChannel({ animations, propertyPath: path });
|
||||
const targetChannel =
|
||||
channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL;
|
||||
const updatedChannel = upsertKeyframe({
|
||||
channel: targetChannel,
|
||||
time,
|
||||
value,
|
||||
interpolation: interpolation ?? "linear",
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
return (
|
||||
setChannel({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
channel: updatedChannel,
|
||||
}) ?? { channels: {} }
|
||||
);
|
||||
}
|
||||
|
||||
export function removeEffectParamKeyframe({
|
||||
animations,
|
||||
effectId,
|
||||
paramKey,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
keyframeId: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const path = buildEffectParamPath({ effectId, paramKey });
|
||||
const channel = getChannel({ animations, propertyPath: path });
|
||||
const updatedChannel = removeKeyframe({ channel, keyframeId });
|
||||
return setChannel({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
channel: updatedChannel,
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export {
|
||||
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
resolveVolumeAtTime,
|
||||
|
||||
@@ -277,7 +277,7 @@ export function getNumberChannelValueAtTime({
|
||||
});
|
||||
}
|
||||
|
||||
function getColorValueAtTime({
|
||||
export function getColorValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
|
||||
@@ -127,7 +127,9 @@ function buildKeyframe({
|
||||
}
|
||||
|
||||
if (typeof value !== "string" && typeof value !== "boolean") {
|
||||
throw new Error("Discrete channel keyframes require boolean or string values");
|
||||
throw new Error(
|
||||
"Discrete channel keyframes require boolean or string values",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -145,14 +147,23 @@ function createEmptyChannel({
|
||||
}): AnimationChannel {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (propertyDefinition.valueKind === "number") {
|
||||
return { valueKind: "number", keyframes: [] } satisfies NumberAnimationChannel;
|
||||
return {
|
||||
valueKind: "number",
|
||||
keyframes: [],
|
||||
} satisfies NumberAnimationChannel;
|
||||
}
|
||||
|
||||
if (propertyDefinition.valueKind === "color") {
|
||||
return { valueKind: "color", keyframes: [] } satisfies ColorAnimationChannel;
|
||||
return {
|
||||
valueKind: "color",
|
||||
keyframes: [],
|
||||
} satisfies ColorAnimationChannel;
|
||||
}
|
||||
|
||||
return { valueKind: "discrete", keyframes: [] } satisfies DiscreteAnimationChannel;
|
||||
return {
|
||||
valueKind: "discrete",
|
||||
keyframes: [],
|
||||
} satisfies DiscreteAnimationChannel;
|
||||
}
|
||||
|
||||
export function upsertKeyframe({
|
||||
@@ -432,7 +443,10 @@ export function splitAnimationsAtTime({
|
||||
const hasBoundaryOnRight = rightKeyframes.some((keyframe) =>
|
||||
isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }),
|
||||
);
|
||||
if (shouldIncludeSplitBoundary && (!hasBoundaryOnLeft || !hasBoundaryOnRight)) {
|
||||
if (
|
||||
shouldIncludeSplitBoundary &&
|
||||
(!hasBoundaryOnLeft || !hasBoundaryOnRight)
|
||||
) {
|
||||
const boundaryValue = getChannelValueAtTime({
|
||||
channel: normalizedChannel,
|
||||
time: splitTime,
|
||||
@@ -442,7 +456,9 @@ export function splitAnimationsAtTime({
|
||||
? (propertyPath as AnimationPropertyPath)
|
||||
: null;
|
||||
const boundaryInterpolation = knownPropertyPath
|
||||
? getDefaultInterpolationForProperty({ propertyPath: knownPropertyPath })
|
||||
? getDefaultInterpolationForProperty({
|
||||
propertyPath: knownPropertyPath,
|
||||
})
|
||||
: normalizedChannel.valueKind === "discrete"
|
||||
? "hold"
|
||||
: "linear";
|
||||
@@ -523,7 +539,9 @@ export function upsertElementKeyframe({
|
||||
return animations;
|
||||
}
|
||||
|
||||
const defaultInterpolation = getDefaultInterpolationForProperty({ propertyPath });
|
||||
const defaultInterpolation = getDefaultInterpolationForProperty({
|
||||
propertyPath,
|
||||
});
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
const channel = getChannel({ animations, propertyPath });
|
||||
const targetChannel =
|
||||
|
||||
@@ -7,6 +7,11 @@ import type {
|
||||
} from "@/types/animation";
|
||||
import type { TimelineElement } from "@/types/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
DEFAULT_TEXT_BACKGROUND,
|
||||
} from "@/constants/text-constants";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
|
||||
interface NumericRange {
|
||||
@@ -19,13 +24,13 @@ interface AnimationPropertyDefinition {
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRange?: NumericRange;
|
||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||
getValue: ({ element }: { element: TimelineElement }) => number | null;
|
||||
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
||||
setValue: ({
|
||||
element,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
value: number;
|
||||
value: AnimationValue;
|
||||
}) => TimelineElement;
|
||||
}
|
||||
|
||||
@@ -45,7 +50,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, x: value },
|
||||
position: { ...element.transform.position, x: value as number },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
@@ -62,7 +67,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, y: value },
|
||||
position: { ...element.transform.position, y: value as number },
|
||||
},
|
||||
}
|
||||
: element,
|
||||
@@ -76,7 +81,10 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
isVisualElement(element) ? element.transform.scale : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? { ...element, transform: { ...element.transform, scale: value } }
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scale: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.rotate": {
|
||||
@@ -87,7 +95,10 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
isVisualElement(element) ? element.transform.rotate : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? { ...element, transform: { ...element.transform, rotate: value } }
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, rotate: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
opacity: {
|
||||
@@ -98,7 +109,9 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.opacity : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element) ? { ...element, opacity: value } : element,
|
||||
isVisualElement(element)
|
||||
? { ...element, opacity: value as number }
|
||||
: element,
|
||||
},
|
||||
volume: {
|
||||
valueKind: "number",
|
||||
@@ -108,7 +121,116 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
getValue: ({ element }) =>
|
||||
element.type === "audio" ? element.volume : null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "audio" ? { ...element, volume: value } : element,
|
||||
element.type === "audio"
|
||||
? { ...element, volume: value as number }
|
||||
: element,
|
||||
},
|
||||
color: {
|
||||
valueKind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? { ...element, color: value as string }
|
||||
: element,
|
||||
},
|
||||
"background.color": {
|
||||
valueKind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text" ? element.background.color : null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, color: value as string },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.paddingX": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingX: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.paddingY": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingY: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.offsetX": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetX: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.offsetY": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetY: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.cornerRadius": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, cornerRadius: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -163,7 +285,7 @@ export function withElementBaseValueForProperty({
|
||||
value: AnimationValue;
|
||||
}): TimelineElement {
|
||||
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
|
||||
if (coercedValue === null || typeof coercedValue !== "number") {
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ElementAnimations } from "@/types/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
|
||||
import type { Transform } from "@/types/timeline";
|
||||
import { getNumberChannelValueAtTime } from "./interpolation";
|
||||
import { getColorValueAtTime, getNumberChannelValueAtTime } from "./interpolation";
|
||||
import { getColorChannelForPath } from "./color-channel";
|
||||
import { getNumberChannelForPath } from "./number-channel";
|
||||
|
||||
export function getElementLocalTime({
|
||||
@@ -91,6 +92,42 @@ export function resolveOpacityAtTime({
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNumberAtTime({
|
||||
baseValue,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({ animations, propertyPath }),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveColorAtTime({
|
||||
baseColor,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return getColorValueAtTime({
|
||||
channel: getColorChannelForPath({ animations, propertyPath }),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseColor,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveVolumeAtTime({
|
||||
baseVolume,
|
||||
animations,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/types/timeline";
|
||||
import { buildDefaultEffectInstance } from "@/lib/effects";
|
||||
|
||||
function addEffectToElement({
|
||||
element,
|
||||
effectType,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
effectType: string;
|
||||
}): VisualElement {
|
||||
const instance = buildDefaultEffectInstance({ effectType });
|
||||
const currentEffects = element.effects ?? [];
|
||||
return { ...element, effects: [...currentEffects, instance] };
|
||||
}
|
||||
|
||||
export class AddClipEffectCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private effectId: string | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectType: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectType,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectType: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectType = effectType;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
const updated = addEffectToElement({
|
||||
element: element as VisualElement,
|
||||
effectType: this.effectType,
|
||||
});
|
||||
const effects = updated.effects ?? [];
|
||||
this.effectId = effects[effects.length - 1]?.id ?? null;
|
||||
return updated;
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
|
||||
getEffectId(): string | null {
|
||||
return this.effectId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { AddClipEffectCommand } from "./add-effect";
|
||||
export { RemoveClipEffectCommand } from "./remove-effect";
|
||||
export { ToggleClipEffectCommand } from "./toggle-effect";
|
||||
export { UpdateClipEffectParamsCommand } from "./update-effect-params";
|
||||
export { ReorderClipEffectsCommand } from "./reorder-effect";
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/types/timeline";
|
||||
|
||||
function removeEffectFromElement({
|
||||
element,
|
||||
effectId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
effectId: string;
|
||||
}): VisualElement {
|
||||
const currentEffects = element.effects ?? [];
|
||||
const filtered = currentEffects.filter((effect) => effect.id !== effectId);
|
||||
return { ...element, effects: filtered };
|
||||
}
|
||||
|
||||
export class RemoveClipEffectCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectId = effectId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
return removeEffectFromElement({
|
||||
element: element as VisualElement,
|
||||
effectId: this.effectId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/types/timeline";
|
||||
|
||||
function reorderEffectsOnElement({
|
||||
element,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
fromIndex: number;
|
||||
toIndex: number;
|
||||
}): VisualElement {
|
||||
const effects = [...(element.effects ?? [])];
|
||||
const [moved] = effects.splice(fromIndex, 1);
|
||||
effects.splice(toIndex, 0, moved);
|
||||
return { ...element, effects };
|
||||
}
|
||||
|
||||
export class ReorderClipEffectsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly fromIndex: number;
|
||||
private readonly toIndex: number;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
fromIndex: number;
|
||||
toIndex: number;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.fromIndex = fromIndex;
|
||||
this.toIndex = toIndex;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
return reorderEffectsOnElement({
|
||||
element: element as VisualElement,
|
||||
fromIndex: this.fromIndex,
|
||||
toIndex: this.toIndex,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/types/timeline";
|
||||
|
||||
export function toggleEffectOnElement({
|
||||
element,
|
||||
effectId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
effectId: string;
|
||||
}): VisualElement {
|
||||
const currentEffects = element.effects ?? [];
|
||||
const updated = currentEffects.map((effect) =>
|
||||
effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect,
|
||||
);
|
||||
return { ...element, effects: updated };
|
||||
}
|
||||
|
||||
export class ToggleClipEffectCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectId = effectId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
return toggleEffectOnElement({
|
||||
element: element as VisualElement,
|
||||
effectId: this.effectId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { EffectParamValues } from "@/types/effects";
|
||||
import type { TimelineTrack, VisualElement } from "@/types/timeline";
|
||||
|
||||
function updateEffectParamsOnElement({
|
||||
element,
|
||||
effectId,
|
||||
params,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
effectId: string;
|
||||
params: Partial<EffectParamValues>;
|
||||
}): VisualElement {
|
||||
const currentEffects = element.effects ?? [];
|
||||
const updated = currentEffects.map((effect) => {
|
||||
if (effect.id !== effectId) {
|
||||
return effect;
|
||||
}
|
||||
|
||||
const nextParams = { ...effect.params };
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined) {
|
||||
nextParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...effect, params: nextParams };
|
||||
});
|
||||
return { ...element, effects: updated };
|
||||
}
|
||||
|
||||
export class UpdateClipEffectParamsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
private readonly params: Partial<EffectParamValues>;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
params,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
params: Partial<EffectParamValues>;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectId = effectId;
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
return updateEffectParamsOnElement({
|
||||
element: element as VisualElement,
|
||||
effectId: this.effectId,
|
||||
params: this.params,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
|
||||
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
|
||||
export { MoveElementCommand } from "./move-elements";
|
||||
export * from "./keyframes";
|
||||
export * from "./effects";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./remove-effect-param-keyframe";
|
||||
export * from "./remove-keyframe";
|
||||
export * from "./retime-keyframe";
|
||||
export * from "./upsert-effect-param-keyframe";
|
||||
export * from "./upsert-keyframe";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export class RemoveEffectParamKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
private readonly paramKey: string;
|
||||
private readonly keyframeId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
paramKey,
|
||||
keyframeId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
keyframeId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectId = effectId;
|
||||
this.paramKey = paramKey;
|
||||
this.keyframeId = keyframeId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
const animations = removeEffectParamKeyframe({
|
||||
animations: element.animations,
|
||||
effectId: this.effectId,
|
||||
paramKey: this.paramKey,
|
||||
keyframeId: this.keyframeId,
|
||||
});
|
||||
return { ...element, animations };
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
private readonly paramKey: string;
|
||||
private readonly time: number;
|
||||
private readonly value: number;
|
||||
private readonly interpolation: "linear" | "hold" | undefined;
|
||||
private readonly keyframeId: string | undefined;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
paramKey,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
time: number;
|
||||
value: number;
|
||||
interpolation?: "linear" | "hold";
|
||||
keyframeId?: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectId = effectId;
|
||||
this.paramKey = paramKey;
|
||||
this.time = time;
|
||||
this.value = value;
|
||||
this.interpolation = interpolation;
|
||||
this.keyframeId = keyframeId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
const animations = upsertEffectParamKeyframe({
|
||||
animations: element.animations,
|
||||
effectId: this.effectId,
|
||||
paramKey: this.paramKey,
|
||||
time: boundedTime,
|
||||
value: this.value,
|
||||
interpolation: this.interpolation,
|
||||
keyframeId: this.keyframeId,
|
||||
});
|
||||
return { ...element, animations };
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
validateElementTrackCompatibility,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
@@ -19,6 +20,7 @@ export class MoveElementCommand extends Command {
|
||||
private readonly elementId: string;
|
||||
private readonly newStartTime: number;
|
||||
private readonly createTrack: { type: TrackType; index: number } | undefined;
|
||||
private readonly rippleEnabled: boolean;
|
||||
|
||||
constructor({
|
||||
sourceTrackId,
|
||||
@@ -26,12 +28,14 @@ export class MoveElementCommand extends Command {
|
||||
elementId,
|
||||
newStartTime,
|
||||
createTrack,
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
sourceTrackId: string;
|
||||
targetTrackId: string;
|
||||
elementId: string;
|
||||
newStartTime: number;
|
||||
createTrack?: { type: TrackType; index: number };
|
||||
rippleEnabled?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.sourceTrackId = sourceTrackId;
|
||||
@@ -39,6 +43,7 @@ export class MoveElementCommand extends Command {
|
||||
this.elementId = elementId;
|
||||
this.newStartTime = newStartTime;
|
||||
this.createTrack = createTrack;
|
||||
this.rippleEnabled = rippleEnabled;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@@ -53,8 +58,7 @@ export class MoveElementCommand extends Command {
|
||||
);
|
||||
|
||||
if (!sourceTrack || !element) {
|
||||
console.error("Source track or element not found");
|
||||
return;
|
||||
throw new Error("Source track or element not found");
|
||||
}
|
||||
|
||||
let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
|
||||
@@ -69,8 +73,7 @@ export class MoveElementCommand extends Command {
|
||||
targetTrack = newTrack;
|
||||
}
|
||||
if (!targetTrack) {
|
||||
console.error("Target track not found");
|
||||
return;
|
||||
throw new Error("Target track not found");
|
||||
}
|
||||
|
||||
const validation = validateElementTrackCompatibility({
|
||||
@@ -79,8 +82,7 @@ export class MoveElementCommand extends Command {
|
||||
});
|
||||
|
||||
if (!validation.isValid) {
|
||||
console.error(validation.errorMessage);
|
||||
return;
|
||||
throw new Error(validation.errorMessage);
|
||||
}
|
||||
|
||||
const adjustedStartTime = enforceMainTrackStart({
|
||||
@@ -105,27 +107,32 @@ export class MoveElementCommand extends Command {
|
||||
elements: track.elements.map((trackElement) =>
|
||||
trackElement.id === this.elementId ? movedElement : trackElement,
|
||||
),
|
||||
};
|
||||
} as typeof track;
|
||||
}
|
||||
|
||||
if (track.id === this.sourceTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.filter(
|
||||
(trackElement) => trackElement.id !== this.elementId,
|
||||
),
|
||||
};
|
||||
const remainingElements = track.elements.filter(
|
||||
(trackElement) => trackElement.id !== this.elementId,
|
||||
);
|
||||
const shiftedElements = this.rippleEnabled
|
||||
? rippleShiftElements({
|
||||
elements: remainingElements,
|
||||
afterTime: element.startTime,
|
||||
shiftAmount: element.duration,
|
||||
})
|
||||
: remainingElements;
|
||||
return { ...track, elements: shiftedElements } as typeof track;
|
||||
}
|
||||
|
||||
if (track.id === this.targetTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: [...track.elements, movedElement],
|
||||
};
|
||||
} as typeof track;
|
||||
}
|
||||
|
||||
return track;
|
||||
});
|
||||
return track;
|
||||
});
|
||||
|
||||
if (!isSameTrack) {
|
||||
const sourceTrackAfterMove = updatedTracks.find(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
import { rippleShiftElements } from "@/lib/timeline";
|
||||
|
||||
export class UpdateElementTrimCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
@@ -10,6 +11,7 @@ export class UpdateElementTrimCommand extends Command {
|
||||
private readonly trimEnd: number;
|
||||
private readonly startTime: number | undefined;
|
||||
private readonly duration: number | undefined;
|
||||
private readonly rippleEnabled: boolean;
|
||||
|
||||
constructor({
|
||||
elementId,
|
||||
@@ -17,12 +19,14 @@ export class UpdateElementTrimCommand extends Command {
|
||||
trimEnd,
|
||||
startTime,
|
||||
duration,
|
||||
rippleEnabled = false,
|
||||
}: {
|
||||
elementId: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
startTime?: number;
|
||||
duration?: number;
|
||||
rippleEnabled?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.elementId = elementId;
|
||||
@@ -30,6 +34,7 @@ export class UpdateElementTrimCommand extends Command {
|
||||
this.trimEnd = trimEnd;
|
||||
this.startTime = startTime;
|
||||
this.duration = duration;
|
||||
this.rippleEnabled = rippleEnabled;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
@@ -37,25 +42,52 @@ export class UpdateElementTrimCommand extends Command {
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
const newElements = track.elements.map((element) => {
|
||||
if (element.id !== this.elementId) {
|
||||
return element;
|
||||
}
|
||||
const targetElement = track.elements.find(
|
||||
(element) => element.id === this.elementId,
|
||||
);
|
||||
if (!targetElement) return track;
|
||||
|
||||
const nextDuration = this.duration ?? element.duration;
|
||||
return {
|
||||
...element,
|
||||
trimStart: this.trimStart,
|
||||
trimEnd: this.trimEnd,
|
||||
startTime: this.startTime ?? element.startTime,
|
||||
const nextDuration = this.duration ?? targetElement.duration;
|
||||
const nextStartTime = this.startTime ?? targetElement.startTime;
|
||||
|
||||
const oldEndTime = targetElement.startTime + targetElement.duration;
|
||||
const newEndTime = nextStartTime + nextDuration;
|
||||
const shiftAmount = oldEndTime - newEndTime;
|
||||
|
||||
const updatedElement = {
|
||||
...targetElement,
|
||||
trimStart: this.trimStart,
|
||||
trimEnd: this.trimEnd,
|
||||
startTime: nextStartTime,
|
||||
duration: nextDuration,
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: targetElement.animations,
|
||||
duration: nextDuration,
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: element.animations,
|
||||
duration: nextDuration,
|
||||
}),
|
||||
};
|
||||
});
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
}),
|
||||
};
|
||||
|
||||
if (this.rippleEnabled && Math.abs(shiftAmount) > 0) {
|
||||
const shiftedOthers = rippleShiftElements({
|
||||
elements: track.elements.filter((element) => element.id !== this.elementId),
|
||||
afterTime: oldEndTime,
|
||||
shiftAmount,
|
||||
});
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((element) =>
|
||||
element.id === this.elementId
|
||||
? updatedElement
|
||||
: (shiftedOthers.find((shifted) => shifted.id === element.id) ?? element)
|
||||
),
|
||||
} as typeof track;
|
||||
}
|
||||
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((element) =>
|
||||
element.id === this.elementId ? updatedElement : element
|
||||
),
|
||||
} as typeof track;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
@@ -16,3 +16,23 @@ export function getExportFileExtension({
|
||||
}): string {
|
||||
return `.${format}`;
|
||||
}
|
||||
|
||||
export function downloadBuffer({
|
||||
buffer,
|
||||
filename,
|
||||
mimeType,
|
||||
}: {
|
||||
buffer: ArrayBuffer;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
}): void {
|
||||
const blob = new Blob([buffer], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const downloadLink = document.createElement("a");
|
||||
downloadLink.href = url;
|
||||
downloadLink.download = filename;
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import { isMainTrack } from "@/lib/timeline";
|
||||
import {
|
||||
DEFAULT_TEXT_ELEMENT,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TEXT_BACKGROUND,
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
} from "@/constants/text-constants";
|
||||
import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
|
||||
import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
resolveNumberAtTime,
|
||||
} from "@/lib/animation";
|
||||
|
||||
export interface ElementBounds {
|
||||
cx: number;
|
||||
@@ -147,10 +152,41 @@ export function getElementBounds({
|
||||
fallbackFontSize: scaledFontSize,
|
||||
});
|
||||
const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
|
||||
const resolvedBackground = {
|
||||
...element.background,
|
||||
paddingX: resolveNumberAtTime({
|
||||
baseValue:
|
||||
element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingX",
|
||||
localTime,
|
||||
}),
|
||||
paddingY: resolveNumberAtTime({
|
||||
baseValue:
|
||||
element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingY",
|
||||
localTime,
|
||||
}),
|
||||
offsetX: resolveNumberAtTime({
|
||||
baseValue:
|
||||
element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetX",
|
||||
localTime,
|
||||
}),
|
||||
offsetY: resolveNumberAtTime({
|
||||
baseValue:
|
||||
element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetY",
|
||||
localTime,
|
||||
}),
|
||||
};
|
||||
const visualRect = getTextVisualRect({
|
||||
textAlign: element.textAlign,
|
||||
block,
|
||||
background: element.background,
|
||||
background: resolvedBackground,
|
||||
fontSizeRatio,
|
||||
});
|
||||
measuredWidth = visualRect.width;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import type { TextBackground, TextElement } from "@/types/timeline";
|
||||
|
||||
type TextRect = {
|
||||
left: number;
|
||||
@@ -74,8 +74,12 @@ function getTextRect({
|
||||
textAlign: TextElement["textAlign"];
|
||||
block: TextBlockMeasurement;
|
||||
}): TextRect {
|
||||
const left =
|
||||
textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2;
|
||||
const textAlignToLeft: Record<typeof textAlign, number> = {
|
||||
left: 0,
|
||||
right: -block.maxWidth,
|
||||
center: -block.maxWidth / 2,
|
||||
};
|
||||
const left = textAlignToLeft[textAlign];
|
||||
|
||||
return {
|
||||
left,
|
||||
@@ -88,9 +92,13 @@ function getTextRect({
|
||||
function isTextBackgroundVisible({
|
||||
background,
|
||||
}: {
|
||||
background: TextElement["background"];
|
||||
background: TextBackground;
|
||||
}): boolean {
|
||||
return Boolean(background.color) && background.color !== "transparent";
|
||||
return (
|
||||
background.enabled &&
|
||||
Boolean(background.color) &&
|
||||
background.color !== "transparent"
|
||||
);
|
||||
}
|
||||
|
||||
export function getTextBackgroundRect({
|
||||
@@ -101,7 +109,7 @@ export function getTextBackgroundRect({
|
||||
}: {
|
||||
textAlign: TextElement["textAlign"];
|
||||
block: TextBlockMeasurement;
|
||||
background: TextElement["background"];
|
||||
background: TextBackground;
|
||||
fontSizeRatio?: number;
|
||||
}): TextRect | null {
|
||||
if (!isTextBackgroundVisible({ background })) {
|
||||
@@ -132,7 +140,7 @@ export function getTextVisualRect({
|
||||
}: {
|
||||
textAlign: TextElement["textAlign"];
|
||||
block: TextBlockMeasurement;
|
||||
background: TextElement["background"];
|
||||
background: TextBackground;
|
||||
fontSizeRatio?: number;
|
||||
}): TextRect {
|
||||
const textRect = getTextRect({ textAlign, block });
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
CreateStickerElement,
|
||||
CreateUploadAudioElement,
|
||||
CreateLibraryAudioElement,
|
||||
TextBackground,
|
||||
TextElement,
|
||||
TimelineElement,
|
||||
TimelineTrack,
|
||||
@@ -46,7 +47,7 @@ export function isVisualElement(
|
||||
export function canElementBeHidden(
|
||||
element: TimelineElement,
|
||||
): element is VisualElement {
|
||||
return element.type !== "audio";
|
||||
return isVisualElement(element);
|
||||
}
|
||||
|
||||
export function hasMediaId(
|
||||
@@ -127,13 +128,30 @@ export function wouldElementOverlap({
|
||||
endTime: number;
|
||||
excludeElementId?: string;
|
||||
}): boolean {
|
||||
return elements.some((el) => {
|
||||
if (excludeElementId && el.id === excludeElementId) return false;
|
||||
const elEnd = el.startTime + el.duration;
|
||||
return startTime < elEnd && endTime > el.startTime;
|
||||
return elements.some((element) => {
|
||||
if (excludeElementId && element.id === excludeElementId) return false;
|
||||
const elementEnd = element.startTime + element.duration;
|
||||
return startTime < elementEnd && endTime > element.startTime;
|
||||
});
|
||||
}
|
||||
|
||||
function buildTextBackground(
|
||||
raw: Partial<TextBackground> | undefined,
|
||||
): TextBackground {
|
||||
const color = raw?.color ?? DEFAULT_TEXT_ELEMENT.background.color;
|
||||
const enabled =
|
||||
typeof raw?.enabled === "boolean" ? raw.enabled : color !== "transparent";
|
||||
return {
|
||||
enabled,
|
||||
color,
|
||||
cornerRadius: raw?.cornerRadius,
|
||||
paddingX: raw?.paddingX,
|
||||
paddingY: raw?.paddingY,
|
||||
offsetX: raw?.offsetX,
|
||||
offsetY: raw?.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTextElement({
|
||||
raw,
|
||||
startTime,
|
||||
@@ -157,14 +175,7 @@ export function buildTextElement({
|
||||
: DEFAULT_TEXT_ELEMENT.fontSize,
|
||||
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
|
||||
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
|
||||
background: {
|
||||
color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
|
||||
cornerRadius: t.background?.cornerRadius,
|
||||
paddingX: t.background?.paddingX,
|
||||
paddingY: t.background?.paddingY,
|
||||
offsetX: t.background?.offsetX,
|
||||
offsetY: t.background?.offsetY,
|
||||
},
|
||||
background: buildTextBackground(t.background),
|
||||
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
|
||||
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
|
||||
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { createOffscreenCanvas } from "../canvas-utils";
|
||||
import { BaseNode } from "./base-node";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import {
|
||||
DEFAULT_TEXT_BACKGROUND,
|
||||
DEFAULT_TEXT_ELEMENT,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/constants/text-constants";
|
||||
import {
|
||||
getMetricAscent,
|
||||
@@ -14,9 +18,15 @@ import {
|
||||
} from "@/lib/text/layout";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
|
||||
import { getEffect } from "@/lib/effects";
|
||||
import { webglEffectRenderer } from "../webgl-effect-renderer";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
function scaleFontSize({
|
||||
fontSize,
|
||||
@@ -32,6 +42,9 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
|
||||
return `"${fontFamily.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
|
||||
const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
|
||||
|
||||
function drawTextDecoration({
|
||||
ctx,
|
||||
textDecoration,
|
||||
@@ -51,7 +64,7 @@ function drawTextDecoration({
|
||||
}): void {
|
||||
if (textDecoration === "none" || !textDecoration) return;
|
||||
|
||||
const thickness = Math.max(1, scaledFontSize * 0.07);
|
||||
const thickness = Math.max(1, scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO);
|
||||
const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
|
||||
const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
|
||||
|
||||
@@ -65,7 +78,7 @@ function drawTextDecoration({
|
||||
}
|
||||
|
||||
if (textDecoration === "line-through") {
|
||||
const strikeY = lineY - (ascent - descent) * 0.35;
|
||||
const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO;
|
||||
ctx.fillRect(xStart, strikeY, lineWidth, thickness);
|
||||
}
|
||||
}
|
||||
@@ -89,8 +102,6 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.context.save();
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: time,
|
||||
elementStartTime: this.params.startTime,
|
||||
@@ -106,15 +117,10 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
animations: this.params.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const x = transform.position.x + this.params.canvasCenter.x;
|
||||
const y = transform.position.y + this.params.canvasCenter.y;
|
||||
|
||||
renderer.context.translate(x, y);
|
||||
renderer.context.scale(transform.scale, transform.scale);
|
||||
if (transform.rotate) {
|
||||
renderer.context.rotate((transform.rotate * Math.PI) / 180);
|
||||
}
|
||||
|
||||
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal";
|
||||
const scaledFontSize = scaleFontSize({
|
||||
@@ -122,83 +128,193 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
canvasHeight: this.params.canvasHeight,
|
||||
});
|
||||
const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
|
||||
renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
||||
renderer.context.textAlign = this.params.textAlign;
|
||||
renderer.context.fillStyle = this.params.color;
|
||||
|
||||
const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
||||
const letterSpacing = this.params.letterSpacing ?? 0;
|
||||
const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
|
||||
if ("letterSpacing" in renderer.context) {
|
||||
(
|
||||
renderer.context as CanvasRenderingContext2D & { letterSpacing: string }
|
||||
).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
|
||||
const lines = this.params.content.split("\n");
|
||||
const lineHeightPx = scaledFontSize * lineHeight;
|
||||
const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
|
||||
const baseline = this.params.textBaseline ?? "middle";
|
||||
|
||||
renderer.context.textBaseline = baseline;
|
||||
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
|
||||
const lineCount = lines.length;
|
||||
|
||||
const block = measureTextBlock({
|
||||
lineMetrics,
|
||||
lineHeightPx,
|
||||
fallbackFontSize: scaledFontSize,
|
||||
});
|
||||
|
||||
const prevAlpha = renderer.context.globalAlpha;
|
||||
renderer.context.globalCompositeOperation = (
|
||||
const blendMode = (
|
||||
this.params.blendMode && this.params.blendMode !== "normal"
|
||||
? this.params.blendMode
|
||||
: "source-over"
|
||||
) as GlobalCompositeOperation;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
|
||||
if (
|
||||
this.params.background.color &&
|
||||
this.params.background.color !== "transparent" &&
|
||||
lineCount > 0
|
||||
) {
|
||||
const { color, cornerRadius = 0 } = this.params.background;
|
||||
const backgroundRect = getTextBackgroundRect({
|
||||
textAlign: this.params.textAlign,
|
||||
block,
|
||||
background: this.params.background,
|
||||
fontSizeRatio,
|
||||
});
|
||||
if (backgroundRect) {
|
||||
renderer.context.fillStyle = color;
|
||||
renderer.context.beginPath();
|
||||
renderer.context.roundRect(
|
||||
backgroundRect.left,
|
||||
backgroundRect.top,
|
||||
backgroundRect.width,
|
||||
backgroundRect.height,
|
||||
cornerRadius,
|
||||
);
|
||||
renderer.context.fill();
|
||||
renderer.context.fillStyle = this.params.color;
|
||||
renderer.context.save();
|
||||
renderer.context.font = fontString;
|
||||
renderer.context.textBaseline = baseline;
|
||||
if ("letterSpacing" in renderer.context) {
|
||||
(renderer.context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
|
||||
renderer.context.restore();
|
||||
|
||||
const lineCount = lines.length;
|
||||
const block = measureTextBlock({ lineMetrics, lineHeightPx, fallbackFontSize: scaledFontSize });
|
||||
|
||||
const textColor = resolveColorAtTime({
|
||||
baseColor: this.params.color,
|
||||
animations: this.params.animations,
|
||||
propertyPath: "color",
|
||||
localTime,
|
||||
});
|
||||
const bg = this.params.background;
|
||||
const resolvedBackground = {
|
||||
...bg,
|
||||
color: resolveColorAtTime({
|
||||
baseColor: bg.color,
|
||||
animations: this.params.animations,
|
||||
propertyPath: "background.color",
|
||||
localTime,
|
||||
}),
|
||||
paddingX: resolveNumberAtTime({
|
||||
baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
animations: this.params.animations,
|
||||
propertyPath: "background.paddingX",
|
||||
localTime,
|
||||
}),
|
||||
paddingY: resolveNumberAtTime({
|
||||
baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
|
||||
animations: this.params.animations,
|
||||
propertyPath: "background.paddingY",
|
||||
localTime,
|
||||
}),
|
||||
offsetX: resolveNumberAtTime({
|
||||
baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
|
||||
animations: this.params.animations,
|
||||
propertyPath: "background.offsetX",
|
||||
localTime,
|
||||
}),
|
||||
offsetY: resolveNumberAtTime({
|
||||
baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
|
||||
animations: this.params.animations,
|
||||
propertyPath: "background.offsetY",
|
||||
localTime,
|
||||
}),
|
||||
cornerRadius: resolveNumberAtTime({
|
||||
baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
|
||||
animations: this.params.animations,
|
||||
propertyPath: "background.cornerRadius",
|
||||
localTime,
|
||||
}),
|
||||
};
|
||||
|
||||
const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
|
||||
ctx.font = fontString;
|
||||
ctx.textAlign = this.params.textAlign;
|
||||
ctx.textBaseline = baseline;
|
||||
ctx.fillStyle = textColor;
|
||||
if ("letterSpacing" in ctx) {
|
||||
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
|
||||
if (
|
||||
this.params.background.enabled &&
|
||||
this.params.background.color &&
|
||||
this.params.background.color !== "transparent" &&
|
||||
lineCount > 0
|
||||
) {
|
||||
const backgroundRect = getTextBackgroundRect({
|
||||
textAlign: this.params.textAlign,
|
||||
block,
|
||||
background: resolvedBackground,
|
||||
fontSizeRatio,
|
||||
});
|
||||
if (backgroundRect) {
|
||||
const p = clamp({ value: resolvedBackground.cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100;
|
||||
const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p;
|
||||
ctx.fillStyle = resolvedBackground.color;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = textColor;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const lineY = i * lineHeightPx - block.visualCenterOffset;
|
||||
ctx.fillText(lines[i], 0, lineY);
|
||||
drawTextDecoration({
|
||||
ctx,
|
||||
textDecoration: this.params.textDecoration ?? "none",
|
||||
lineWidth: lineMetrics[i].width,
|
||||
lineY,
|
||||
metrics: lineMetrics[i],
|
||||
scaledFontSize,
|
||||
textAlign: this.params.textAlign,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const applyTransform = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
|
||||
ctx.translate(x, y);
|
||||
ctx.scale(transform.scale, transform.scale);
|
||||
if (transform.rotate) {
|
||||
ctx.rotate((transform.rotate * Math.PI) / 180);
|
||||
}
|
||||
};
|
||||
|
||||
const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? [];
|
||||
|
||||
if (enabledEffects.length === 0) {
|
||||
renderer.context.save();
|
||||
applyTransform(renderer.context);
|
||||
renderer.context.globalCompositeOperation = blendMode;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
drawContent(renderer.context);
|
||||
renderer.context.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const y = i * lineHeightPx - block.visualCenterOffset;
|
||||
renderer.context.fillText(lines[i], 0, y);
|
||||
drawTextDecoration({
|
||||
ctx: renderer.context,
|
||||
textDecoration: this.params.textDecoration ?? "none",
|
||||
lineWidth: lineMetrics[i].width,
|
||||
lineY: y,
|
||||
metrics: lineMetrics[i],
|
||||
scaledFontSize,
|
||||
textAlign: this.params.textAlign,
|
||||
// Effects path: render text to a same-size offscreen canvas so the blur
|
||||
// can spread into the surrounding transparent area without hard clipping.
|
||||
const offscreen = createOffscreenCanvas({ width: renderer.width, height: renderer.height });
|
||||
const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
|
||||
|
||||
if (!offscreenCtx) {
|
||||
renderer.context.save();
|
||||
applyTransform(renderer.context);
|
||||
renderer.context.globalCompositeOperation = blendMode;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
drawContent(renderer.context);
|
||||
renderer.context.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
offscreenCtx.save();
|
||||
applyTransform(offscreenCtx);
|
||||
drawContent(offscreenCtx);
|
||||
offscreenCtx.restore();
|
||||
|
||||
let currentSource: CanvasImageSource = offscreen;
|
||||
for (const effect of enabledEffects) {
|
||||
const resolvedParams = resolveEffectParamsAtTime({
|
||||
effect,
|
||||
animations: this.params.animations,
|
||||
localTime,
|
||||
});
|
||||
const definition = getEffect({ effectType: effect.type });
|
||||
const passes = definition.renderer.passes.map((pass) => ({
|
||||
fragmentShader: pass.fragmentShader,
|
||||
uniforms: pass.uniforms({
|
||||
effectParams: resolvedParams,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
}),
|
||||
}));
|
||||
currentSource = webglEffectRenderer.applyEffect({
|
||||
source: currentSource,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
passes,
|
||||
});
|
||||
}
|
||||
|
||||
renderer.context.globalAlpha = prevAlpha;
|
||||
renderer.context.save();
|
||||
renderer.context.globalCompositeOperation = blendMode;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
renderer.context.drawImage(currentSource, 0, 0);
|
||||
renderer.context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import { getEffect } from "@/lib/effects";
|
||||
import { webglEffectRenderer } from "../webgl-effect-renderer";
|
||||
@@ -127,11 +128,16 @@ export abstract class VisualNode<
|
||||
let currentResult: CanvasImageSource = elementCanvas;
|
||||
|
||||
for (const effect of enabledEffects) {
|
||||
const resolvedParams = resolveEffectParamsAtTime({
|
||||
effect,
|
||||
animations: this.params.animations,
|
||||
localTime: animationLocalTime,
|
||||
});
|
||||
const definition = getEffect({ effectType: effect.type });
|
||||
const passes = definition.renderer.passes.map((pass) => ({
|
||||
fragmentShader: pass.fragmentShader,
|
||||
uniforms: pass.uniforms({
|
||||
effectParams: effect.params,
|
||||
effectParams: resolvedParams,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV8ToV9 } from "../transformers/v8-to-v9";
|
||||
|
||||
const v8ProjectWithText = {
|
||||
id: "project-v8-text",
|
||||
version: 8,
|
||||
metadata: {
|
||||
id: "project-v8-text",
|
||||
name: "V8 Project with Text",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
settings: {
|
||||
fps: 30,
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
background: { type: "color", color: "#000000" },
|
||||
},
|
||||
currentSceneId: "scene-main",
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-main",
|
||||
name: "Main scene",
|
||||
isMain: true,
|
||||
tracks: [
|
||||
{
|
||||
id: "track-text",
|
||||
type: "text",
|
||||
name: "Text Track",
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: "text",
|
||||
content: "With color",
|
||||
startTime: 0,
|
||||
duration: 5,
|
||||
background: {
|
||||
color: "#ff0000",
|
||||
cornerRadius: 0,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "el-2",
|
||||
type: "text",
|
||||
content: "Transparent",
|
||||
startTime: 5,
|
||||
duration: 5,
|
||||
background: {
|
||||
color: "transparent",
|
||||
paddingX: 30,
|
||||
paddingY: 42,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
bookmarks: [],
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
|
||||
|
||||
describe("V8 to V9 Migration", () => {
|
||||
test("adds background.enabled from color (transparent => false, otherwise true)", () => {
|
||||
const result = transformProjectV8ToV9({ project: v8ProjectWithText });
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(9);
|
||||
|
||||
const track = (
|
||||
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
|
||||
)[0].tracks[0];
|
||||
const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>;
|
||||
|
||||
expect(elements[0].background.enabled).toBe(true);
|
||||
expect(elements[0].background.color).toBe("#ff0000");
|
||||
|
||||
expect(elements[1].background.enabled).toBe(false);
|
||||
expect(elements[1].background.color).toBe("transparent");
|
||||
});
|
||||
|
||||
test("preserves existing background.enabled if already present", () => {
|
||||
const projectWithEnabled = {
|
||||
...v8ProjectWithText,
|
||||
scenes: [
|
||||
{
|
||||
...(v8ProjectWithText.scenes as Record<string, unknown>[])[0],
|
||||
tracks: [
|
||||
{
|
||||
id: "track-text",
|
||||
type: "text",
|
||||
name: "Text Track",
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: "text",
|
||||
content: "Already has enabled",
|
||||
startTime: 0,
|
||||
duration: 5,
|
||||
background: {
|
||||
enabled: false,
|
||||
color: "#00ff00",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
|
||||
|
||||
const result = transformProjectV8ToV9({ project: projectWithEnabled });
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
const elements = (
|
||||
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
|
||||
)[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>;
|
||||
expect(elements[0].background.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("skips non-text elements and tracks", () => {
|
||||
const projectWithVideoOnly = {
|
||||
...v8ProjectWithText,
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-main",
|
||||
name: "Main scene",
|
||||
isMain: true,
|
||||
tracks: [
|
||||
{
|
||||
id: "track-video",
|
||||
type: "video",
|
||||
name: "Video Track",
|
||||
isMain: true,
|
||||
elements: [],
|
||||
},
|
||||
],
|
||||
bookmarks: [],
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
|
||||
|
||||
const result = transformProjectV8ToV9({ project: projectWithVideoOnly });
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(9);
|
||||
});
|
||||
|
||||
test("skips projects that are already v9", () => {
|
||||
const result = transformProjectV8ToV9({
|
||||
project: { ...v8ProjectWithText, version: 9 },
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("already v9");
|
||||
});
|
||||
|
||||
test("skips projects with no id", () => {
|
||||
const result = transformProjectV8ToV9({
|
||||
project: {
|
||||
version: 8,
|
||||
scenes: [],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"],
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("no project id");
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,11 @@ import { V4toV5Migration } from "./v4-to-v5";
|
||||
import { V5toV6Migration } from "./v5-to-v6";
|
||||
import { V6toV7Migration } from "./v6-to-v7";
|
||||
import { V7toV8Migration } from "./v7-to-v8";
|
||||
import { V8toV9Migration } from "./v8-to-v9";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 8;
|
||||
export const CURRENT_PROJECT_VERSION = 9;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
@@ -21,4 +22,5 @@ export const migrations = [
|
||||
new V5toV6Migration(),
|
||||
new V6toV7Migration(),
|
||||
new V7toV8Migration(),
|
||||
new V8toV9Migration(),
|
||||
];
|
||||
|
||||
@@ -388,7 +388,7 @@ async function transformMediaTrack({
|
||||
);
|
||||
|
||||
const validElements = transformedElements.filter(
|
||||
(el): el is VideoElement | ImageElement => el !== null,
|
||||
(element): element is VideoElement | ImageElement => element !== null,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -450,17 +450,18 @@ function transformTextTrack({
|
||||
value: textElement.color,
|
||||
fallback: "#000000",
|
||||
}),
|
||||
background: {
|
||||
color: getStringValue({
|
||||
value: textElement.backgroundColor,
|
||||
fallback: "transparent",
|
||||
}),
|
||||
cornerRadius: 0,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
background: {
|
||||
enabled: false,
|
||||
color: getStringValue({
|
||||
value: textElement.backgroundColor,
|
||||
fallback: "transparent",
|
||||
}),
|
||||
cornerRadius: 0,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
textAlign: (getStringValue({
|
||||
value: textElement.textAlign,
|
||||
fallback: "left",
|
||||
@@ -486,7 +487,7 @@ function transformTextTrack({
|
||||
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
|
||||
};
|
||||
})
|
||||
.filter((el): el is TextElement => el !== null);
|
||||
.filter((element): element is TextElement => element !== null);
|
||||
|
||||
return {
|
||||
id: getStringValue({ value: track.id, fallback: "" }),
|
||||
@@ -529,7 +530,7 @@ function transformAudioTrack({
|
||||
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
|
||||
};
|
||||
})
|
||||
.filter((el): el is AudioElement => el !== null);
|
||||
.filter((element): element is AudioElement => element !== null);
|
||||
|
||||
return {
|
||||
id: getStringValue({ value: track.id, fallback: "" }),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
export function transformProjectV8ToV9({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): MigrationResult<ProjectRecord> {
|
||||
const projectId = getProjectId({ project });
|
||||
if (!projectId) {
|
||||
return { project, skipped: true, reason: "no project id" };
|
||||
}
|
||||
|
||||
if (isV9Project({ project })) {
|
||||
return { project, skipped: true, reason: "already v9" };
|
||||
}
|
||||
|
||||
const migratedProject = migrateProjectTextElements({ project });
|
||||
|
||||
return {
|
||||
project: { ...migratedProject, version: 9 },
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateProjectTextElements({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
const scenesValue = project.scenes;
|
||||
if (!Array.isArray(scenesValue)) return project;
|
||||
|
||||
let hasChanges = false;
|
||||
const migratedScenes = scenesValue.map((scene) => {
|
||||
const migrated = migrateSceneTextElements({ scene });
|
||||
if (migrated !== scene) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return project;
|
||||
return { ...project, scenes: migratedScenes };
|
||||
}
|
||||
|
||||
function migrateSceneTextElements({ scene }: { scene: unknown }): unknown {
|
||||
if (!isRecord(scene)) return scene;
|
||||
|
||||
const tracksValue = scene.tracks;
|
||||
if (!Array.isArray(tracksValue)) return scene;
|
||||
|
||||
let hasChanges = false;
|
||||
const migratedTracks = tracksValue.map((track) => {
|
||||
const migrated = migrateTrackTextElements({ track });
|
||||
if (migrated !== track) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return scene;
|
||||
return { ...scene, tracks: migratedTracks };
|
||||
}
|
||||
|
||||
function migrateTrackTextElements({ track }: { track: unknown }): unknown {
|
||||
if (!isRecord(track)) return track;
|
||||
if (track.type !== "text") return track;
|
||||
|
||||
const elementsValue = track.elements;
|
||||
if (!Array.isArray(elementsValue)) return track;
|
||||
|
||||
let hasChanges = false;
|
||||
const migratedElements = elementsValue.map((element) => {
|
||||
const migrated = migrateTextElement({ element });
|
||||
if (migrated !== element) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return track;
|
||||
return { ...track, elements: migratedElements };
|
||||
}
|
||||
|
||||
function migrateTextElement({ element }: { element: unknown }): unknown {
|
||||
if (!isRecord(element)) return element;
|
||||
if (element.type !== "text") return element;
|
||||
|
||||
const bg = element.background;
|
||||
if (!isRecord(bg)) return element;
|
||||
if (typeof bg.enabled === "boolean") return element;
|
||||
|
||||
const color = typeof bg.color === "string" ? bg.color : "transparent";
|
||||
const enabled = color !== "transparent";
|
||||
|
||||
return {
|
||||
...element,
|
||||
background: { ...bg, enabled },
|
||||
};
|
||||
}
|
||||
|
||||
function isV9Project({ project }: { project: ProjectRecord }): boolean {
|
||||
return typeof project.version === "number" && project.version >= 9;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StorageMigration } from "./base";
|
||||
import type { ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV8ToV9 } from "./transformers/v8-to-v9";
|
||||
|
||||
export class V8toV9Migration extends StorageMigration {
|
||||
from = 8;
|
||||
to = 9;
|
||||
|
||||
async transform(project: ProjectRecord): Promise<{
|
||||
project: ProjectRecord;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
return transformProjectV8ToV9({ project });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ElementType } from "react";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import {
|
||||
ArrowRightDoubleIcon,
|
||||
ClosedCaptionIcon,
|
||||
@@ -81,7 +82,9 @@ export const tabs = {
|
||||
{ icon: ElementType<{ className?: string }>; label: string }
|
||||
>;
|
||||
|
||||
type MediaViewMode = "grid" | "list";
|
||||
export type MediaViewMode = "grid" | "list";
|
||||
export type MediaSortKey = "name" | "type" | "duration" | "size";
|
||||
export type MediaSortOrder = "asc" | "desc";
|
||||
|
||||
interface AssetsPanelStore {
|
||||
activeTab: Tab;
|
||||
@@ -93,15 +96,34 @@ interface AssetsPanelStore {
|
||||
/* Media */
|
||||
mediaViewMode: MediaViewMode;
|
||||
setMediaViewMode: (mode: MediaViewMode) => void;
|
||||
mediaSortBy: MediaSortKey;
|
||||
mediaSortOrder: MediaSortOrder;
|
||||
setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void;
|
||||
}
|
||||
|
||||
export const useAssetsPanelStore = create<AssetsPanelStore>((set) => ({
|
||||
activeTab: "media",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
highlightMediaId: null,
|
||||
requestRevealMedia: (mediaId) =>
|
||||
set({ activeTab: "media", highlightMediaId: mediaId }),
|
||||
clearHighlight: () => set({ highlightMediaId: null }),
|
||||
mediaViewMode: "grid",
|
||||
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
||||
}));
|
||||
export const useAssetsPanelStore = create<AssetsPanelStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeTab: "media",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
highlightMediaId: null,
|
||||
requestRevealMedia: (mediaId) =>
|
||||
set({ activeTab: "media", highlightMediaId: mediaId }),
|
||||
clearHighlight: () => set({ highlightMediaId: null }),
|
||||
mediaViewMode: "grid",
|
||||
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
||||
mediaSortBy: "name",
|
||||
mediaSortOrder: "asc",
|
||||
setMediaSort: (key, order) =>
|
||||
set({ mediaSortBy: key, mediaSortOrder: order }),
|
||||
}),
|
||||
{
|
||||
name: "assets-panel",
|
||||
partialize: (state) => ({
|
||||
mediaViewMode: state.mediaViewMode,
|
||||
mediaSortBy: state.mediaSortBy,
|
||||
mediaSortOrder: state.mediaSortOrder,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -39,8 +39,8 @@ interface KeybindingsState {
|
||||
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
||||
}
|
||||
|
||||
function isDOMElement(el: EventTarget | null): el is HTMLElement {
|
||||
return !!el && (el instanceof Element || el instanceof HTMLElement);
|
||||
function isDOMElement(element: EventTarget | null): element is HTMLElement {
|
||||
return element instanceof HTMLElement;
|
||||
}
|
||||
|
||||
export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
@@ -90,11 +90,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
set({ keybindingsEnabled: false });
|
||||
},
|
||||
|
||||
importKeybindings: (config: KeybindingConfig) => {
|
||||
// Validate all keys and actions
|
||||
for (const [key] of Object.entries(config)) {
|
||||
// Validate the key format
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
importKeybindings: (config: KeybindingConfig) => {
|
||||
for (const [key] of Object.entries(config)) {
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
throw new Error(`Invalid key format: ${key}`);
|
||||
}
|
||||
}
|
||||
@@ -153,20 +151,13 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
),
|
||||
);
|
||||
|
||||
// Utility functions
|
||||
function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
|
||||
const target = ev.target;
|
||||
|
||||
// We may or may not have a modifier key
|
||||
const modifierKey = getActiveModifier(ev);
|
||||
|
||||
// We will always have a non-modifier key
|
||||
const key = getPressedKey(ev);
|
||||
if (!key) return null;
|
||||
|
||||
// All key combos backed by modifiers are valid shortcuts (whether currently typing or not)
|
||||
if (modifierKey) {
|
||||
// If the modifier is shift and the target is an input, we ignore
|
||||
if (
|
||||
modifierKey === "shift" &&
|
||||
isDOMElement(target) &&
|
||||
@@ -178,61 +169,44 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
|
||||
return `${modifierKey}+${key}` as ShortcutKey;
|
||||
}
|
||||
|
||||
// no modifier key here then we do not do anything while on input
|
||||
if (
|
||||
isDOMElement(target) &&
|
||||
isTypableDOMElement({ element: target as HTMLElement })
|
||||
)
|
||||
if (isDOMElement(target) && isTypableDOMElement({ element: target as HTMLElement }))
|
||||
return null;
|
||||
|
||||
// single key while not input
|
||||
return `${key}` as ShortcutKey;
|
||||
}
|
||||
|
||||
function getPressedKey(ev: KeyboardEvent): string | null {
|
||||
// Sometimes the property code is not available on the KeyboardEvent object
|
||||
const key = (ev.key ?? "").toLowerCase();
|
||||
const code = ev.code ?? "";
|
||||
|
||||
if (code === "Space" || key === " " || key === "spacebar" || key === "space")
|
||||
return "space";
|
||||
|
||||
// Check arrow keys
|
||||
if (key.startsWith("arrow")) {
|
||||
return key.slice(5);
|
||||
}
|
||||
if (key.startsWith("arrow")) return key.slice(5);
|
||||
|
||||
// Check for special keys
|
||||
if (key === "escape") return "escape";
|
||||
if (key === "tab") return "tab";
|
||||
if (key === "home") return "home";
|
||||
if (key === "end") return "end";
|
||||
if (key === "delete") return "delete";
|
||||
if (key === "backspace") return "backspace";
|
||||
|
||||
// Check letter keys
|
||||
if (code.startsWith("Key")) {
|
||||
const letter = code.slice(3).toLowerCase();
|
||||
if (letter.length === 1 && letter >= "a" && letter <= "z") {
|
||||
return letter;
|
||||
}
|
||||
if (letter.length === 1 && letter >= "a" && letter <= "z") return letter;
|
||||
}
|
||||
|
||||
// Check number keys using physical position for AZERTY support
|
||||
// Use physical key position for AZERTY and other non-QWERTY layouts
|
||||
if (code.startsWith("Digit")) {
|
||||
const digit = code.slice(5);
|
||||
if (digit.length === 1 && digit >= "0" && digit <= "9") {
|
||||
return digit;
|
||||
}
|
||||
if (digit.length === 1 && digit >= "0" && digit <= "9") return digit;
|
||||
}
|
||||
|
||||
// Fallback for other layouts
|
||||
const isDigit = key.length === 1 && key >= "0" && key <= "9";
|
||||
if (isDigit) return key;
|
||||
|
||||
// Check if slash, period or enter
|
||||
if (key === "/" || key === "." || key === "enter") return key;
|
||||
|
||||
// If no other cases match, this is not a valid key
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -243,8 +217,6 @@ function getActiveModifier(ev: KeyboardEvent): string | null {
|
||||
shift: ev.shiftKey,
|
||||
};
|
||||
|
||||
// active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift
|
||||
// modiferKeys object's keys are sorted to match the above order
|
||||
const activeModifier = Object.keys(modifierKeys)
|
||||
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
|
||||
.join("+");
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { v2ToV3 } from "./v2-to-v3";
|
||||
import { v3ToV4 } from "./v3-to-v4";
|
||||
import { v4ToV5 } from "./v4-to-v5";
|
||||
|
||||
type MigrationFn = ({ state }: { state: unknown }) => unknown;
|
||||
|
||||
/**
|
||||
* key = version we're migrating from
|
||||
* value = migration function
|
||||
*/
|
||||
const migrations: Record<number, MigrationFn> = {
|
||||
2: v2ToV3,
|
||||
3: v3ToV4,
|
||||
4: v4ToV5,
|
||||
};
|
||||
|
||||
export const CURRENT_VERSION = 4;
|
||||
export const CURRENT_VERSION = 5;
|
||||
|
||||
export function runMigrations({
|
||||
state,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { KeybindingConfig } from "@/types/keybinding";
|
||||
|
||||
interface V4State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
|
||||
export function v4ToV5({ state }: { state: unknown }): unknown {
|
||||
const v4 = state as V4State;
|
||||
const keybindings = { ...v4.keybindings };
|
||||
|
||||
if (!keybindings.escape) {
|
||||
keybindings.escape = "deselect-all";
|
||||
}
|
||||
|
||||
return { ...v4, keybindings };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ClipEffectsTarget {
|
||||
elementId: string;
|
||||
trackId: string;
|
||||
}
|
||||
|
||||
interface PropertiesState {
|
||||
clipEffectsTarget: ClipEffectsTarget | null;
|
||||
openClipEffects: ({ elementId, trackId }: ClipEffectsTarget) => void;
|
||||
closeClipEffects: () => void;
|
||||
}
|
||||
|
||||
export const usePropertiesStore = create<PropertiesState>()((set) => ({
|
||||
clipEffectsTarget: null,
|
||||
openClipEffects: ({ elementId, trackId }) =>
|
||||
set({ clipEffectsTarget: { elementId, trackId } }),
|
||||
closeClipEffects: () => set({ clipEffectsTarget: null }),
|
||||
}));
|
||||
@@ -5,6 +5,13 @@ export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.rotate",
|
||||
"opacity",
|
||||
"volume",
|
||||
"color",
|
||||
"background.color",
|
||||
"background.paddingX",
|
||||
"background.paddingY",
|
||||
"background.offsetX",
|
||||
"background.offsetY",
|
||||
"background.cornerRadius",
|
||||
] as const;
|
||||
|
||||
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||
@@ -13,12 +20,10 @@ export type AnimationValueKind = "number" | "color" | "discrete";
|
||||
export type DiscreteValue = boolean | string;
|
||||
export type AnimationValue = number | string | boolean;
|
||||
|
||||
export type NumberKeyframeInterpolation = "linear" | "hold";
|
||||
export type ColorKeyframeInterpolation = "linear" | "hold";
|
||||
export type ContinuousKeyframeInterpolation = "linear" | "hold";
|
||||
export type DiscreteKeyframeInterpolation = "hold";
|
||||
export type AnimationInterpolation =
|
||||
| NumberKeyframeInterpolation
|
||||
| ColorKeyframeInterpolation
|
||||
| ContinuousKeyframeInterpolation
|
||||
| DiscreteKeyframeInterpolation;
|
||||
|
||||
interface BaseAnimationKeyframe<
|
||||
@@ -32,15 +37,18 @@ interface BaseAnimationKeyframe<
|
||||
}
|
||||
|
||||
export interface NumberKeyframe
|
||||
extends BaseAnimationKeyframe<number, NumberKeyframeInterpolation> {}
|
||||
extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
|
||||
|
||||
export interface ColorKeyframe
|
||||
extends BaseAnimationKeyframe<string, ColorKeyframeInterpolation> {}
|
||||
extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
|
||||
|
||||
export interface DiscreteKeyframe
|
||||
extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
|
||||
|
||||
export type AnimationKeyframe = NumberKeyframe | ColorKeyframe | DiscreteKeyframe;
|
||||
export type AnimationKeyframe =
|
||||
| NumberKeyframe
|
||||
| ColorKeyframe
|
||||
| DiscreteKeyframe;
|
||||
|
||||
interface BaseAnimationChannel<
|
||||
TValueKind extends AnimationValueKind,
|
||||
|
||||
@@ -9,17 +9,41 @@ export type EffectParamType = "number" | "boolean" | "select" | "color";
|
||||
|
||||
export type EffectParamValues = Record<string, number | string | boolean>;
|
||||
|
||||
export interface EffectParamDefinition {
|
||||
interface BaseEffectParamDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
type: EffectParamType;
|
||||
default: number | string | boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface NumberEffectParamDefinition extends BaseEffectParamDefinition {
|
||||
type: "number";
|
||||
default: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}
|
||||
|
||||
interface BooleanEffectParamDefinition extends BaseEffectParamDefinition {
|
||||
type: "boolean";
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
interface SelectEffectParamDefinition extends BaseEffectParamDefinition {
|
||||
type: "select";
|
||||
default: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
interface ColorEffectParamDefinition extends BaseEffectParamDefinition {
|
||||
type: "color";
|
||||
default: string;
|
||||
}
|
||||
|
||||
export type EffectParamDefinition =
|
||||
| NumberEffectParamDefinition
|
||||
| BooleanEffectParamDefinition
|
||||
| SelectEffectParamDefinition
|
||||
| ColorEffectParamDefinition;
|
||||
|
||||
export interface WebGLEffectPass {
|
||||
fragmentShader: string;
|
||||
uniforms(params: {
|
||||
|
||||
@@ -15,8 +15,6 @@ export interface ExportOptions {
|
||||
quality: ExportQuality;
|
||||
fps?: number;
|
||||
includeAudio?: boolean;
|
||||
onProgress?: ({ progress }: { progress: number }) => void;
|
||||
onCancel?: () => boolean;
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
@@ -25,3 +23,9 @@ export interface ExportResult {
|
||||
error?: string;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
|
||||
export interface ExportState {
|
||||
isExporting: boolean;
|
||||
progress: number;
|
||||
result: ExportResult | null;
|
||||
}
|
||||
|
||||
@@ -118,20 +118,23 @@ export interface ImageElement extends BaseTimelineElement {
|
||||
effects?: Effect[];
|
||||
}
|
||||
|
||||
export interface TextBackground {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
cornerRadius?: number;
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
}
|
||||
|
||||
export interface TextElement extends BaseTimelineElement {
|
||||
type: "text";
|
||||
content: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: string;
|
||||
background: {
|
||||
color: string;
|
||||
cornerRadius?: number;
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
};
|
||||
background: TextBackground;
|
||||
textAlign: "left" | "center" | "right";
|
||||
fontWeight: "normal" | "bold";
|
||||
fontStyle: "normal" | "italic";
|
||||
|
||||
@@ -17,6 +17,29 @@ Add an entry to the `ACTIONS` object:
|
||||
},
|
||||
```
|
||||
|
||||
**If your shortcut uses a special key** (not a letter/digit), check `getPressedKey` in `src/stores/keybindings-store.ts` and add a case if it's missing:
|
||||
|
||||
```typescript
|
||||
if (key === "escape") return "escape";
|
||||
```
|
||||
|
||||
**If your action has a `defaultShortcuts`**, also add a keybindings migration so existing users get it (keybindings are persisted in localStorage — new defaults only apply to fresh installs):
|
||||
|
||||
1. Create `src/stores/keybindings/migrations/vN-to-vN+1.ts`:
|
||||
|
||||
```typescript
|
||||
export function vNToVN1({ state }: { state: unknown }): unknown {
|
||||
const s = state as { keybindings: Record<string, string>; isCustomized: boolean };
|
||||
const keybindings = { ...s.keybindings };
|
||||
if (!keybindings["my-key"]) {
|
||||
keybindings["my-key"] = "my-action";
|
||||
}
|
||||
return { ...s, keybindings };
|
||||
}
|
||||
```
|
||||
|
||||
2. Register it in `src/stores/keybindings/migrations/index.ts` and bump `CURRENT_VERSION`.
|
||||
|
||||
### 2. Register the handler — `src/hooks/actions/use-editor-actions.ts`
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# Keyframe System
|
||||
|
||||
Keyframes allow element properties to change over time. The system is split into three layers: the **data model** (how keyframes are stored), the **registry** (which properties support keyframes and how to read/write them), and the **UI** (hooks and components that wire it all together).
|
||||
|
||||
## How It Works
|
||||
|
||||
### Data model
|
||||
|
||||
Every `BaseTimelineElement` has an optional `animations?: ElementAnimations` field:
|
||||
|
||||
```typescript
|
||||
interface ElementAnimations {
|
||||
channels: Record<string, AnimationChannel | undefined>;
|
||||
}
|
||||
```
|
||||
|
||||
A channel is a typed bucket of keyframes keyed by property path (e.g. `"opacity"`, `"background.color"`). Three channel types exist: `NumberAnimationChannel`, `ColorAnimationChannel`, and `DiscreteAnimationChannel`.
|
||||
|
||||
### Registry
|
||||
|
||||
`src/lib/animation/property-registry.ts` defines which property paths are animatable and how to read/write their values on an element. `src/types/animation.ts` holds the canonical list of valid paths in `ANIMATION_PROPERTY_PATHS`.
|
||||
|
||||
### Resolver
|
||||
|
||||
`src/lib/animation/resolve.ts` provides functions that return the effective value of a property at a given local time — falling back to the element's static value when no keyframes exist.
|
||||
|
||||
### Renderer
|
||||
|
||||
Nodes in `src/services/renderer/` call the resolve functions before drawing so that animated properties interpolate correctly during export and preview.
|
||||
|
||||
### UI
|
||||
|
||||
Two hooks in `src/components/editor/panels/properties/hooks/` handle the keyframe-aware field logic:
|
||||
|
||||
- `useKeyframedNumberProperty` — for numeric fields (opacity, position, scale, etc.)
|
||||
- `useKeyframedColorProperty` — for color pickers
|
||||
|
||||
Both hooks handle the toggle/add/remove keyframe flow and automatically switch between writing to the static property and writing to the animation channel depending on whether keyframes are active.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Animatable Property
|
||||
|
||||
Using `"background.paddingX"` as an example.
|
||||
|
||||
### 1. Register the path — `src/types/animation.ts`
|
||||
|
||||
```typescript
|
||||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
// ...existing paths
|
||||
"background.paddingX",
|
||||
] as const;
|
||||
```
|
||||
|
||||
### 2. Add a registry entry — `src/lib/animation/property-registry.ts`
|
||||
|
||||
```typescript
|
||||
"background.paddingX": {
|
||||
valueKind: "number", // "number" | "color" | "discrete"
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0 }, // optional, only for number properties
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? { ...element, background: { ...element.background, paddingX: value as number } }
|
||||
: element,
|
||||
},
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `getValue` must return the effective value including any defaults — this is what gets recorded when a keyframe is added.
|
||||
- `setValue` receives `AnimationValue` (`number | string | boolean`). Cast to the correct type since `coerceAnimationValueForProperty` already validated it upstream.
|
||||
- For color properties, use `valueKind: "color"` and cast `value as string`.
|
||||
|
||||
### 3. Add a resolve function — `src/lib/animation/resolve.ts`
|
||||
|
||||
For **numbers**, use the existing generic `resolveNumberAtTime`:
|
||||
|
||||
```typescript
|
||||
import { resolveNumberAtTime } from "@/lib/animation";
|
||||
|
||||
const resolvedPaddingX = resolveNumberAtTime({
|
||||
baseValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingX",
|
||||
localTime,
|
||||
});
|
||||
```
|
||||
|
||||
For **colors**, use `resolveColorAtTime`:
|
||||
|
||||
```typescript
|
||||
const resolvedColor = resolveColorAtTime({
|
||||
baseColor: element.color,
|
||||
animations: element.animations,
|
||||
propertyPath: "color",
|
||||
localTime,
|
||||
});
|
||||
```
|
||||
|
||||
If neither fits (new value kind), add a dedicated resolve function following the same pattern as `resolveOpacityAtTime` and export it from `src/lib/animation/index.ts`.
|
||||
|
||||
### 4. Wire the renderer
|
||||
|
||||
In the relevant node (`src/services/renderer/nodes/`), call the resolve function before drawing:
|
||||
|
||||
```typescript
|
||||
const resolvedPaddingX = resolveNumberAtTime({
|
||||
baseValue: this.params.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
animations: this.params.animations,
|
||||
propertyPath: "background.paddingX",
|
||||
localTime,
|
||||
});
|
||||
```
|
||||
|
||||
Use the resolved value (not `this.params.*`) anywhere that value affects rendering.
|
||||
|
||||
### 5. Wire the UI
|
||||
|
||||
In the properties panel, replace `usePropertyDraft` with the appropriate keyframe hook and add a `KeyframeToggle` to the field.
|
||||
|
||||
**For number fields:**
|
||||
|
||||
```typescript
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
|
||||
const resolvedPaddingX = resolveNumberAtTime({
|
||||
baseValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingX",
|
||||
localTime,
|
||||
});
|
||||
|
||||
const paddingX = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingX",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedPaddingX).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
|
||||
},
|
||||
valueAtPlayhead: resolvedPaddingX,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...element.background, paddingX: value },
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
In JSX:
|
||||
|
||||
```tsx
|
||||
<SectionField
|
||||
label="Width"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={paddingX.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle background width keyframe"
|
||||
onToggle={paddingX.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
value={paddingX.displayValue}
|
||||
onFocus={paddingX.onFocus}
|
||||
onChange={paddingX.onChange}
|
||||
onBlur={paddingX.onBlur}
|
||||
onScrub={paddingX.scrubTo}
|
||||
onScrubEnd={paddingX.commitScrub}
|
||||
onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedPaddingX,
|
||||
staticValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
})}
|
||||
/>
|
||||
</SectionField>
|
||||
```
|
||||
|
||||
**For color fields**, use `useKeyframedColorProperty` instead. It returns `{ onChange, onChangeEnd, toggleKeyframe, isKeyframedAtTime }` — wire `onChange({ color })` and `onChangeEnd` directly to the `ColorPicker`.
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Path added to `ANIMATION_PROPERTY_PATHS`
|
||||
- [ ] Registry entry added with correct `valueKind`, `supportsElement`, `getValue`, `setValue`
|
||||
- [ ] Resolve call added in the renderer node
|
||||
- [ ] UI field uses `useKeyframedNumberProperty` or `useKeyframedColorProperty` (not `usePropertyDraft`)
|
||||
- [ ] `KeyframeToggle` added to the `SectionField`
|
||||
- [ ] `onReset` calls `commitValue` (not `editor.timeline.updateElements` directly)
|
||||
Reference in New Issue
Block a user