mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94e75278f8 |
@@ -21,17 +21,3 @@ Each app is a frontend that calls into Rust. Logic is never duplicated between a
|
||||
|
||||
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
|
||||
|
||||
### TypeScript
|
||||
|
||||
Function signatures should make the call site readable and let the function evolve without breaking callers. Positional parameters fail both: `formatTime(30, 24)` hides which number is which, and adding, removing, or reordering an argument silently breaks every caller whose types happen to still line up. A single destructured object fixes both at once - each argument names itself at the call site, and the shape can grow without churn. So signatures default to one object parameter:
|
||||
|
||||
```tsx
|
||||
// ❌ meaning depends on order; the shape can't evolve without touching every caller
|
||||
function formatTime(seconds: number, fps: number) { ... }
|
||||
|
||||
// ✅ each argument names itself; fields can be added, reordered, or made optional freely
|
||||
function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... }
|
||||
```
|
||||
|
||||
The one real exception is type predicates (`element is VideoElement`) — the language requires a positional subject, so the reasoning above doesn't get to apply.
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -3820,7 +3820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.2.9"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"compositor",
|
||||
|
||||
@@ -85,19 +85,6 @@ If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps
|
||||
|
||||
Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
|
||||
|
||||
**Prerequisites** — install these once before anything else:
|
||||
|
||||
```bash
|
||||
# Rust toolchain
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# build the WASM package
|
||||
cargo install wasm-pack
|
||||
|
||||
# reruns the build on file changes, used by bun dev:wasm
|
||||
cargo install cargo-watch
|
||||
```
|
||||
|
||||
1. Build the package once from the repo root:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use gpui::{
|
||||
div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
|
||||
WindowBounds, WindowOptions,
|
||||
App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div,
|
||||
prelude::*, px, rgb, size,
|
||||
};
|
||||
|
||||
struct AppWindow {
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
import * as dotenv from "dotenv";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
if (webEnv.NODE_ENV === "production") {
|
||||
dotenv.config({ path: ".env.production" });
|
||||
} else {
|
||||
dotenv.config({ path: ".env.local" });
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
export default {
|
||||
schema: "./src/lib/db/schema.ts",
|
||||
schema: "./src/schema.ts",
|
||||
dialect: "postgresql",
|
||||
migrations: {
|
||||
table: "drizzle_migrations",
|
||||
},
|
||||
dbCredentials: {
|
||||
url: databaseUrl,
|
||||
url: webEnv.DATABASE_URL,
|
||||
},
|
||||
out: "./migrations",
|
||||
strict: process.env.NODE_ENV === "production",
|
||||
strict: webEnv.NODE_ENV === "production",
|
||||
} satisfies Config;
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.2.9",
|
||||
"opencut-wasm": "^0.2.5",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -1,19 +0,0 @@
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V6State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
|
||||
export function v6ToV7({ state }: { state: unknown }): unknown {
|
||||
const v6 = state as V6State;
|
||||
const keybindings = { ...v6.keybindings };
|
||||
|
||||
for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) {
|
||||
if (keybindings[key] === ("split-element" as never)) {
|
||||
keybindings[key] = "split";
|
||||
}
|
||||
}
|
||||
|
||||
return { ...v6, keybindings };
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { getElementKeyframes } from "@/animation";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
|
||||
export function getAnimationKeyframeSnapPointsForTimeline({
|
||||
tracks,
|
||||
excludeElementIds,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
excludeElementIds?: Set<string>;
|
||||
}): SnapPoint[] {
|
||||
const snapPoints: SnapPoint[] = [];
|
||||
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||
|
||||
for (const track of orderedTracks) {
|
||||
for (const element of track.elements) {
|
||||
if (excludeElementIds?.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const keyframe of getElementKeyframes({
|
||||
animations: element.animations,
|
||||
})) {
|
||||
snapPoints.push({
|
||||
time: element.startTime + keyframe.time,
|
||||
type: "keyframe",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snapPoints;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from "@/auth/server";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/auth/rate-limit";
|
||||
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/feedback";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/lib/feedback";
|
||||
|
||||
const submitSchema = z.object({
|
||||
message: z
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { webEnv } from "@/env/web";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/auth/rate-limit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
q: z.string().max(500, "Query too long").optional(),
|
||||
|
||||
@@ -4,8 +4,8 @@ import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import Prose from "@/components/ui/prose";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/blog/query";
|
||||
import type { Author, Post } from "@/blog/types";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
|
||||
import type { Author, Post } from "@/lib/blog/types";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts } from "@/blog/query";
|
||||
import type { Post } from "@/blog/types";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { Post } from "@/lib/blog/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - OpenCut",
|
||||
|
||||
@@ -3,15 +3,15 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { getReleaseByVersion, getSortedReleases } from "@/changelog/utils";
|
||||
import { getReleaseByVersion, getSortedReleases } from "@/lib/changelog/utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "@/changelog/components/release";
|
||||
import { CopyMarkdownButton } from "@/changelog/components/copy-markdown-button";
|
||||
} from "@/lib/changelog/components/release";
|
||||
import { CopyMarkdownButton } from "@/lib/changelog/components/copy-markdown-button";
|
||||
|
||||
type Props = { params: Promise<{ version: string }> };
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
type Release as ReleaseType,
|
||||
getSortedReleases,
|
||||
} from "@/changelog/utils";
|
||||
} from "@/lib/changelog/utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "@/changelog/components/release";
|
||||
} from "@/lib/changelog/components/release";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Changelog - OpenCut",
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EXTERNAL_TOOLS } from "@/site/external-tools";
|
||||
import { EXTERNAL_TOOLS } from "@/lib/site/external-tools";
|
||||
import { BasePage } from "../base-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@@ -8,32 +8,21 @@ import {
|
||||
} from "@/components/ui/resizable";
|
||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||
import { Timeline } from "@/timeline/components";
|
||||
import { PreviewPanel } from "@/preview/components";
|
||||
import { Timeline } from "@/components/editor/panels/timeline";
|
||||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||
import { usePanelStore } from "@/editor/panel-store";
|
||||
import { usePasteMedia } from "@/media/use-paste-media";
|
||||
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";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useState } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||
import {
|
||||
createPreviewOverlayControl,
|
||||
isPreviewOverlayVisible,
|
||||
mergePreviewOverlaySources,
|
||||
} from "@/preview/overlays";
|
||||
import { usePreviewStore } from "@/preview/preview-store";
|
||||
import { getGuidePreviewOverlaySource } from "@/guides";
|
||||
import {
|
||||
bookmarkNotesPreviewOverlay,
|
||||
getBookmarkPreviewOverlaySource,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
@@ -81,49 +70,6 @@ function DegradedRendererBanner() {
|
||||
function EditorLayout() {
|
||||
usePasteMedia();
|
||||
const { panels, setPanel } = usePanelStore();
|
||||
const activeScene = useEditor((editor) =>
|
||||
editor.scenes.getActiveSceneOrNull(),
|
||||
);
|
||||
const currentTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||
const activeGuide = usePreviewStore((state) => state.activeGuide);
|
||||
const overlays = usePreviewStore((state) => state.overlays);
|
||||
const setOverlayVisibility = usePreviewStore(
|
||||
(state) => state.setOverlayVisibility,
|
||||
);
|
||||
const showBookmarkNotes = isPreviewOverlayVisible({
|
||||
overlay: bookmarkNotesPreviewOverlay,
|
||||
overlays,
|
||||
});
|
||||
|
||||
const overlaySource = useMemo(
|
||||
() =>
|
||||
mergePreviewOverlaySources({
|
||||
sources: [
|
||||
getGuidePreviewOverlaySource({
|
||||
guideId: activeGuide,
|
||||
}),
|
||||
activeScene
|
||||
? getBookmarkPreviewOverlaySource({
|
||||
bookmarks: activeScene.bookmarks,
|
||||
time: currentTime,
|
||||
isVisible: showBookmarkNotes,
|
||||
})
|
||||
: {
|
||||
definitions: [bookmarkNotesPreviewOverlay],
|
||||
instances: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[activeGuide, activeScene, currentTime, showBookmarkNotes],
|
||||
);
|
||||
|
||||
const overlayControls = useMemo(
|
||||
() =>
|
||||
overlaySource.definitions.map((overlay) =>
|
||||
createPreviewOverlayControl({ overlay, overlays }),
|
||||
),
|
||||
[overlaySource.definitions, overlays],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
@@ -165,11 +111,7 @@ function EditorLayout() {
|
||||
minSize={30}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel
|
||||
overlayControls={overlayControls}
|
||||
overlayInstances={overlaySource.instances}
|
||||
onOverlayVisibilityChange={setOverlayVisibility}
|
||||
/>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
@@ -2,11 +2,11 @@ import { ThemeProvider } from "next-themes";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||
import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { baseMetaData } from "./metadata";
|
||||
import { BotIdClient } from "botid/client";
|
||||
import { webEnv } from "@/env/web";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
const siteFont = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_INFO, SITE_URL } from "@/site/brand";
|
||||
import { SITE_INFO, SITE_URL } from "@/lib/site/brand";
|
||||
|
||||
export const baseMetaData: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_URL } from "@/site/brand";
|
||||
import { SITE_URL } from "@/lib/site/brand";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SOCIAL_LINKS } from "@/site/social";
|
||||
import { SOCIAL_LINKS } from "@/lib/site/social";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy - OpenCut",
|
||||
|
||||
@@ -7,20 +7,20 @@ import type { KeyboardEvent, MouseEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { EditorCore } from "@/core";
|
||||
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||
import { StoragePersistenceDialog } from "@/services/storage/components/storage-persistence-dialog";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useProjectsStore } from "./store";
|
||||
import type {
|
||||
TProjectMetadata,
|
||||
TProjectSortKey,
|
||||
TProjectSortOption,
|
||||
} from "@/project/types";
|
||||
} from "@/lib/project/types";
|
||||
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
@@ -62,11 +62,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DeleteProjectDialog } from "@/project/components/delete-project-dialog";
|
||||
import { ProjectInfoDialog } from "@/project/components/project-info-dialog";
|
||||
import { RenameProjectDialog } from "@/project/components/rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
|
||||
import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
|
||||
import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||
import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification";
|
||||
const formatProjectDuration = ({
|
||||
duration,
|
||||
}: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { TProjectSortKey } from "@/project/types";
|
||||
import type { TProjectSortKey } from "@/lib/project/types";
|
||||
|
||||
export type ProjectsViewMode = "grid" | "list";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/site/brand";
|
||||
import { SITE_URL } from "@/lib/site/brand";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Feed } from "feed";
|
||||
import { getPosts } from "@/blog/query";
|
||||
import { SITE_INFO, SITE_URL } from "@/site/brand";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import { SITE_INFO, SITE_URL } from "@/lib/site/brand";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SITE_URL } from "@/site/brand";
|
||||
import { getPosts } from "@/blog/query";
|
||||
import { SITE_URL } from "@/lib/site/brand";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { SPONSORS, type Sponsor } from "@/site/sponsors";
|
||||
import { SPONSORS, type Sponsor } from "@/lib/site/sponsors";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SOCIAL_LINKS } from "@/site/social";
|
||||
import { SOCIAL_LINKS } from "@/lib/site/social";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service - OpenCut",
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
version: "0.3.0"
|
||||
date: "2026-03-04"
|
||||
published: false
|
||||
title: "Title"
|
||||
description: "Description"
|
||||
changes:
|
||||
- type: new
|
||||
text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
|
||||
- type: new
|
||||
text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
|
||||
- type: fixed
|
||||
text: "Preview panning now keeps working while the Masks tab is open."
|
||||
- type: new
|
||||
text: "Audio clips have a horizontal volume line you can drag up or down to change volume. The line position reflects the current volume setting and is consistent across clips: same dB always means the same position."
|
||||
- type: improved
|
||||
text: "Audio waveforms now show the actual shape of the audio. Quiet parts are visible, loud parts are tall, and any bar that peaks above 0 dBFS gets a colored tip at the top."
|
||||
- type: fixed
|
||||
text: "Trimming or cutting an audio clip no longer causes the waveform to compress or show the wrong section of audio."
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { EditorSelectionPatch } from "@/selection/editor-selection";
|
||||
import type { ElementRef } from "@/timeline/types";
|
||||
|
||||
export interface CommandResult {
|
||||
selection?: EditorSelectionPatch;
|
||||
}
|
||||
|
||||
export function createElementSelectionResult(
|
||||
selectedElements: ElementRef[],
|
||||
): CommandResult {
|
||||
return {
|
||||
selection: {
|
||||
selectedElements,
|
||||
selectedKeyframes: [],
|
||||
keyframeSelectionAnchor: null,
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class Command {
|
||||
abstract execute(): CommandResult | undefined;
|
||||
|
||||
undo(): void {
|
||||
throw new Error("Undo not implemented for this command");
|
||||
}
|
||||
|
||||
redo(): CommandResult | undefined {
|
||||
return this.execute();
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import {
|
||||
getCustomMaskClosedStateAfterPointRemoval,
|
||||
removeCustomMaskPoints,
|
||||
} from "@/masks/custom-path";
|
||||
import type { CustomMask } from "@/masks/types";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { MaskableElement, SceneTracks } from "@/timeline";
|
||||
|
||||
function deletePointsFromCustomMask({
|
||||
mask,
|
||||
pointIds,
|
||||
}: {
|
||||
mask: CustomMask;
|
||||
pointIds: string[];
|
||||
}): CustomMask {
|
||||
const points = mask.params.path;
|
||||
const nextPoints = removeCustomMaskPoints({ points, pointIds });
|
||||
if (nextPoints.length === points.length) {
|
||||
return mask;
|
||||
}
|
||||
|
||||
return {
|
||||
...mask,
|
||||
params: {
|
||||
...mask.params,
|
||||
path: nextPoints,
|
||||
closed: getCustomMaskClosedStateAfterPointRemoval({
|
||||
wasClosed: mask.params.closed,
|
||||
remainingPointCount: nextPoints.length,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deletePointsFromElementMask({
|
||||
element,
|
||||
maskId,
|
||||
pointIds,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
maskId: string;
|
||||
pointIds: string[];
|
||||
}): { element: MaskableElement; didDeletePoints: boolean } {
|
||||
const currentMasks = element.masks ?? [];
|
||||
let didDeletePoints = false;
|
||||
const nextMasks = currentMasks.map((mask) => {
|
||||
if (mask.id !== maskId || mask.type !== "custom") {
|
||||
return mask;
|
||||
}
|
||||
|
||||
const nextMask = deletePointsFromCustomMask({
|
||||
mask,
|
||||
pointIds,
|
||||
});
|
||||
didDeletePoints ||= nextMask !== mask;
|
||||
return nextMask;
|
||||
});
|
||||
|
||||
return {
|
||||
element: didDeletePoints ? { ...element, masks: nextMasks } : element,
|
||||
didDeletePoints,
|
||||
};
|
||||
}
|
||||
|
||||
export class DeleteCustomMaskPointsCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly maskId: string;
|
||||
private readonly pointIds: string[];
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
pointIds,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
pointIds: string[];
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.maskId = maskId;
|
||||
this.pointIds = pointIds;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
let didDeletePoints = false;
|
||||
const updatedTracks = updateElementInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) => {
|
||||
const result = deletePointsFromElementMask({
|
||||
element: element as MaskableElement,
|
||||
maskId: this.maskId,
|
||||
pointIds: this.pointIds,
|
||||
});
|
||||
didDeletePoints ||= result.didDeletePoints;
|
||||
return result.element;
|
||||
},
|
||||
});
|
||||
|
||||
if (didDeletePoints) {
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return {
|
||||
selection: {
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points";
|
||||
export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point";
|
||||
export { RemoveMaskCommand } from "./remove-mask";
|
||||
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
|
||||
@@ -1,169 +0,0 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom";
|
||||
import type { ElementBounds } from "@/preview/element-bounds";
|
||||
import type { CustomMask } from "@/masks/types";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { MaskableElement, SceneTracks } from "@/timeline";
|
||||
|
||||
function insertPointIntoCustomMask({
|
||||
mask,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
mask: CustomMask;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): { mask: CustomMask; insertedPointId: string | null } {
|
||||
const result = insertPointOnCustomMaskSegment({
|
||||
params: mask.params,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
});
|
||||
if (!result) {
|
||||
return {
|
||||
mask,
|
||||
insertedPointId: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mask: {
|
||||
...mask,
|
||||
params: result.params,
|
||||
},
|
||||
insertedPointId: result.pointId,
|
||||
};
|
||||
}
|
||||
|
||||
function insertPointIntoElementMask({
|
||||
element,
|
||||
maskId,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
maskId: string;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): {
|
||||
element: MaskableElement;
|
||||
didInsertPoint: boolean;
|
||||
insertedPointId: string | null;
|
||||
} {
|
||||
const currentMasks = element.masks ?? [];
|
||||
let insertedPointId: string | null = null;
|
||||
let didInsertPoint = false;
|
||||
|
||||
const nextMasks = currentMasks.map((mask) => {
|
||||
if (mask.id !== maskId || mask.type !== "custom") {
|
||||
return mask;
|
||||
}
|
||||
|
||||
const result = insertPointIntoCustomMask({
|
||||
mask,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
});
|
||||
if (result.insertedPointId) {
|
||||
insertedPointId = result.insertedPointId;
|
||||
didInsertPoint = true;
|
||||
}
|
||||
return result.mask;
|
||||
});
|
||||
|
||||
return {
|
||||
element: didInsertPoint ? { ...element, masks: nextMasks } : element,
|
||||
didInsertPoint,
|
||||
insertedPointId,
|
||||
};
|
||||
}
|
||||
|
||||
export class InsertCustomMaskPointCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly maskId: string;
|
||||
private readonly segmentIndex: number;
|
||||
private readonly canvasPoint: { x: number; y: number };
|
||||
private readonly bounds: ElementBounds;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.maskId = maskId;
|
||||
this.segmentIndex = segmentIndex;
|
||||
this.canvasPoint = canvasPoint;
|
||||
this.bounds = bounds;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
let didInsertPoint = false;
|
||||
let insertedPointId: string | null = null;
|
||||
const updatedTracks = updateElementInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) => {
|
||||
const result = insertPointIntoElementMask({
|
||||
element: element as MaskableElement,
|
||||
maskId: this.maskId,
|
||||
segmentIndex: this.segmentIndex,
|
||||
canvasPoint: this.canvasPoint,
|
||||
bounds: this.bounds,
|
||||
});
|
||||
didInsertPoint ||= result.didInsertPoint;
|
||||
insertedPointId ??= result.insertedPointId;
|
||||
return result.element;
|
||||
},
|
||||
});
|
||||
|
||||
if (!didInsertPoint || !insertedPointId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return {
|
||||
selection: {
|
||||
selectedMaskPoints: {
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
maskId: this.maskId,
|
||||
pointIds: [insertedPointId],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import {
|
||||
Command,
|
||||
createElementSelectionResult,
|
||||
type CommandResult,
|
||||
} from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
validateElementTrackCompatibility,
|
||||
} from "@/timeline/placement";
|
||||
import type {
|
||||
PlannedElementMove,
|
||||
PlannedTrackCreation,
|
||||
} from "@/timeline/group-move";
|
||||
import { normalizeTimelineElement } from "@/timeline/normalize";
|
||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
||||
constructor({
|
||||
moves,
|
||||
createTracks = [],
|
||||
}: {
|
||||
moves: PlannedElementMove[];
|
||||
createTracks?: PlannedTrackCreation[];
|
||||
}) {
|
||||
super();
|
||||
this.moves = moves;
|
||||
this.createTracks = createTracks;
|
||||
}
|
||||
|
||||
private readonly moves: PlannedElementMove[];
|
||||
private readonly createTracks: PlannedTrackCreation[];
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
let tracksToUpdate = this.savedState;
|
||||
for (const createTrack of [...this.createTracks].sort(
|
||||
(firstTrack, secondTrack) => firstTrack.index - secondTrack.index,
|
||||
)) {
|
||||
tracksToUpdate = insertTrackAtDisplayIndex({
|
||||
tracks: tracksToUpdate,
|
||||
track: buildEmptyTrack({
|
||||
id: createTrack.id,
|
||||
type: createTrack.type,
|
||||
}),
|
||||
insertIndex: createTrack.index,
|
||||
});
|
||||
}
|
||||
|
||||
const movedElementsById = new Map<string, TimelineElement>();
|
||||
for (const move of this.moves) {
|
||||
const sourceTrack = findTrackInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: move.sourceTrackId,
|
||||
});
|
||||
const sourceElement = sourceTrack?.elements.find(
|
||||
(trackElement) => trackElement.id === move.elementId,
|
||||
);
|
||||
if (!sourceTrack || !sourceElement) {
|
||||
throw new Error("Source track or element not found");
|
||||
}
|
||||
|
||||
const targetTrack = findTrackInSceneTracks({
|
||||
tracks: tracksToUpdate,
|
||||
trackId: move.targetTrackId,
|
||||
});
|
||||
if (!targetTrack) {
|
||||
throw new Error("Target track not found");
|
||||
}
|
||||
|
||||
const validation = validateElementTrackCompatibility({
|
||||
element: sourceElement,
|
||||
track: targetTrack,
|
||||
});
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.errorMessage);
|
||||
}
|
||||
|
||||
movedElementsById.set(move.elementId, {
|
||||
...normalizeTimelineElement({
|
||||
element: {
|
||||
...sourceElement,
|
||||
startTime: move.newStartTime,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const movedElementIds = new Set(this.moves.map((move) => move.elementId));
|
||||
const movedElementsByTargetTrackId = new Map<string, TimelineElement[]>();
|
||||
for (const move of this.moves) {
|
||||
const movedElement = movedElementsById.get(move.elementId);
|
||||
if (!movedElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextTargetElements =
|
||||
movedElementsByTargetTrackId.get(move.targetTrackId) ?? [];
|
||||
nextTargetElements.push(movedElement);
|
||||
movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements);
|
||||
}
|
||||
|
||||
const updatedTracks = mapSceneTracks({
|
||||
tracks: tracksToUpdate,
|
||||
update: (track) => ({
|
||||
...track,
|
||||
elements: [
|
||||
...track.elements.filter(
|
||||
(element) => !movedElementIds.has(element.id),
|
||||
),
|
||||
...(movedElementsByTargetTrackId.get(track.id) ?? []),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return createElementSelectionResult(
|
||||
this.moves.map(({ elementId, targetTrackId }) => ({
|
||||
trackId: targetTrackId,
|
||||
elementId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapSceneTracks({
|
||||
tracks,
|
||||
update,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
|
||||
}): SceneTracks {
|
||||
return {
|
||||
overlay: tracks.overlay.map((track) => update(track)),
|
||||
main: update(tracks.main),
|
||||
audio: tracks.audio.map((track) => update(track)),
|
||||
};
|
||||
}
|
||||
|
||||
function insertTrackAtDisplayIndex({
|
||||
tracks,
|
||||
track,
|
||||
insertIndex,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
track: TimelineTrack;
|
||||
insertIndex: number;
|
||||
}): SceneTracks {
|
||||
if (track.type === "audio") {
|
||||
const audioInsertIndex = Math.max(
|
||||
0,
|
||||
Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length),
|
||||
);
|
||||
return {
|
||||
...tracks,
|
||||
audio: [
|
||||
...tracks.audio.slice(0, audioInsertIndex),
|
||||
track,
|
||||
...tracks.audio.slice(audioInsertIndex),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const overlayInsertIndex = Math.max(
|
||||
0,
|
||||
Math.min(insertIndex, tracks.overlay.length),
|
||||
);
|
||||
return {
|
||||
...tracks,
|
||||
overlay: [
|
||||
...tracks.overlay.slice(0, overlayInsertIndex),
|
||||
track,
|
||||
...tracks.overlay.slice(overlayInsertIndex),
|
||||
],
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function MigrationDialog() {
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { TProjectMetadata } from "@/project/types";
|
||||
import type { TProjectMetadata } from "@/lib/project/types";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
+2
-2
@@ -5,8 +5,8 @@ import { toast } from "sonner";
|
||||
import {
|
||||
type KeyboardShortcut,
|
||||
useKeyboardShortcutsHelp,
|
||||
} from "@/actions/use-keyboard-shortcuts-help";
|
||||
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||
} from "@/hooks/use-keyboard-shortcuts-help";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useStoragePersistence } from "@/services/storage/use-storage-persistence";
|
||||
import { useStoragePersistence } from "@/hooks/use-storage-persistence";
|
||||
|
||||
export function StoragePersistenceDialog() {
|
||||
const { showDialog, onConfirm, onDismiss } = useStoragePersistence();
|
||||
@@ -10,20 +10,20 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { RenameProjectDialog } from "@/project/components/rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "@/project/components/delete-project-dialog";
|
||||
import { RenameProjectDialog } from "./dialogs/rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
import { ExportButton } from "./export-button";
|
||||
import { FeedbackPopover } from "@/feedback/components/feedback-popover";
|
||||
import { FeedbackPopover } from "@/lib/feedback/components/feedback-popover";
|
||||
import { ThemeToggle } from "../theme-toggle";
|
||||
import { DEFAULT_LOGO_URL } from "@/site/brand";
|
||||
import { SOCIAL_LINKS } from "@/site/social";
|
||||
import { DEFAULT_LOGO_URL } from "@/lib/site/brand";
|
||||
import { SOCIAL_LINKS } from "@/lib/site/social";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ShortcutsDialog } from "@/actions/components/shortcuts-dialog";
|
||||
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
|
||||
@@ -18,22 +18,22 @@ import {
|
||||
getExportMimeType,
|
||||
getExportFileExtension,
|
||||
downloadBuffer,
|
||||
} from "@/export";
|
||||
} from "@/lib/export";
|
||||
import { Check, Copy, Download, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
EXPORT_FORMAT_VALUES,
|
||||
EXPORT_QUALITY_VALUES,
|
||||
type ExportFormat,
|
||||
type ExportQuality,
|
||||
} from "@/export";
|
||||
} from "@/lib/export";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { DEFAULT_EXPORT_OPTIONS } from "@/export/defaults";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_EXPORT_OPTIONS } from "@/lib/export/defaults";
|
||||
|
||||
function isExportFormat(value: string): value is ExportFormat {
|
||||
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { SOCIAL_LINKS } from "@/site/social";
|
||||
import { useLocalStorage } from "@/services/storage/use-local-storage";
|
||||
import { SOCIAL_LINKS } from "@/lib/site/social";
|
||||
import { useLocalStorage } from "@/hooks/storage/use-local-storage";
|
||||
import { Button } from "../ui/button";
|
||||
import { Dialog, DialogBody, DialogContent, DialogTitle } from "../ui/dialog";
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { clearDragData, setDragData } from "@/timeline/drag-data";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clearDragData, setDragData } from "@/lib/drag-data";
|
||||
import type { TimelineDragData } from "@/lib/timeline/drag";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export interface DraggableItemProps {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { type Tab, useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store";
|
||||
import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import { TabBar } from "./tabbar";
|
||||
import { Captions } from "@/subtitles/components/assets-view";
|
||||
import { Captions } from "./views/captions";
|
||||
import { MediaView } from "./views/assets";
|
||||
import { SettingsView } from "./views/settings";
|
||||
import { SoundsView } from "@/sounds/components/assets-view";
|
||||
import { StickersView } from "@/stickers/components/assets-view";
|
||||
import { TextView } from "@/text/components/assets-view";
|
||||
import { EffectsView } from "@/effects/components/assets-view";
|
||||
import { SoundsView } from "./views/sounds";
|
||||
import { StickersView } from "./views/stickers";
|
||||
import { TextView } from "./views/text";
|
||||
import { EffectsView } from "./views/effects";
|
||||
|
||||
export function AssetsPanel() {
|
||||
const { activeTab } = useAssetsPanelStore();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TAB_KEYS,
|
||||
tabs,
|
||||
useAssetsPanelStore,
|
||||
} from "@/components/editor/panels/assets/assets-panel-store";
|
||||
} from "@/stores/assets-panel-store";
|
||||
|
||||
export function TabBar() {
|
||||
const { activeTab, setActiveTab } = useAssetsPanelStore();
|
||||
|
||||
@@ -25,28 +25,28 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useFileUpload } from "@/media/use-file-upload";
|
||||
import { invokeAction } from "@/actions";
|
||||
import { processMediaAssets } from "@/media/processing";
|
||||
import { showMediaUploadToast } from "@/media/upload-toast";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
|
||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { processMediaAssets } from "@/lib/media/processing";
|
||||
import { showMediaUploadToast } from "@/lib/media/upload-toast";
|
||||
import {
|
||||
SelectableItem,
|
||||
SelectableSurface,
|
||||
useSelection,
|
||||
useSelectionScope,
|
||||
} from "@/selection";
|
||||
import { buildElementFromMedia } from "@/timeline/element-utils";
|
||||
} from "@/lib/selection";
|
||||
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
type MediaSortKey,
|
||||
type MediaSortOrder,
|
||||
type MediaViewMode,
|
||||
useAssetsPanelStore,
|
||||
} from "@/components/editor/panels/assets/assets-panel-store";
|
||||
import { MASKABLE_ELEMENT_TYPES } from "@/timeline";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
} from "@/stores/assets-panel-store";
|
||||
import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
CloudUploadIcon,
|
||||
|
||||
+14
-12
@@ -8,21 +8,21 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useReducer, useRef, useState } from "react";
|
||||
import { extractTimelineAudio } from "@/media/mediabunny";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { TRANSCRIPTION_DIAGNOSTICS_SCOPE } from "@/transcription/diagnostics";
|
||||
import { DEFAULT_TRANSCRIPTION_SAMPLE_RATE } from "@/transcription/audio";
|
||||
import { TRANSCRIPTION_LANGUAGES } from "@/transcription/supported-languages";
|
||||
import { extractTimelineAudio } from "@/lib/media/mediabunny";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { TRANSCRIPTION_DIAGNOSTICS_SCOPE } from "@/lib/transcription/diagnostics";
|
||||
import { DEFAULT_TRANSCRIPTION_SAMPLE_RATE } from "@/lib/transcription/audio";
|
||||
import { TRANSCRIPTION_LANGUAGES } from "@/lib/transcription/supported-languages";
|
||||
import type {
|
||||
CaptionChunk,
|
||||
TranscriptionLanguage,
|
||||
TranscriptionProgress,
|
||||
} from "@/transcription/types";
|
||||
} from "@/lib/transcription/types";
|
||||
import { transcriptionService } from "@/services/transcription/service";
|
||||
import { decodeAudioToFloat32 } from "@/media/audio";
|
||||
import { buildCaptionChunks } from "@/transcription/caption";
|
||||
import { insertCaptionChunksAsTextTrack } from "@/subtitles/insert";
|
||||
import { parseSubtitleFile } from "@/subtitles/parse";
|
||||
import { decodeAudioToFloat32 } from "@/lib/media/audio";
|
||||
import { buildCaptionChunks } from "@/lib/transcription/caption";
|
||||
import { insertCaptionChunksAsTextTrack } from "@/lib/subtitles/insert";
|
||||
import { parseSubtitleFile } from "@/lib/subtitles/parse";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Section,
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { DiagnosticSeverity } from "@/diagnostics/types";
|
||||
import type { DiagnosticSeverity } from "@/lib/diagnostics/types";
|
||||
|
||||
const DIAGNOSTIC_BUTTON_VARIANT: Record<
|
||||
DiagnosticSeverity,
|
||||
@@ -254,7 +254,9 @@ export function Captions() {
|
||||
<HugeiconsIcon icon={AlertCircleIcon} size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{diagnostic.message}</TooltipContent>
|
||||
<TooltipContent>
|
||||
{diagnostic.message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Button
|
||||
+4
-4
@@ -3,11 +3,11 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
|
||||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/effects";
|
||||
import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
|
||||
import { effectPreviewService } from "@/services/renderer/effect-preview";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { buildEffectElement } from "@/timeline/element-utils";
|
||||
import type { EffectDefinition } from "@/effects/types";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { buildEffectElement } from "@/lib/timeline/element-utils";
|
||||
import type { EffectDefinition } from "@/lib/effects/types";
|
||||
|
||||
export function EffectsView() {
|
||||
const effects = effectsRegistry.getAll();
|
||||
@@ -1,309 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { ColorPickerContent } from "@/components/ui/color-picker";
|
||||
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BACKGROUND_BLUR_INTENSITY,
|
||||
} from "@/background/blur";
|
||||
import { DEFAULT_BACKGROUND_COLOR } from "@/background/color";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { effectPreviewService } from "@/services/renderer/effect-preview";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
} as const;
|
||||
|
||||
const CUSTOM_COLOR_SWATCH_BACKGROUND =
|
||||
"conic-gradient(from 180deg at 50% 50%, #ff5e5e 0deg, #ffb35e 55deg, #fff26b 110deg, #6bff8f 165deg, #5ee7ff 220deg, #6f7cff 275deg, #d76bff 330deg, #ff5e9b 360deg)";
|
||||
|
||||
const BlurPreview = memo(
|
||||
({
|
||||
blur,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
blur: { label: string; value: number };
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const renderPreview = () => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
effectPreviewService.renderPreview({
|
||||
effectType: "blur",
|
||||
params: { intensity: blur.value },
|
||||
targetCanvas: canvasRef.current,
|
||||
uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
|
||||
});
|
||||
};
|
||||
|
||||
renderPreview();
|
||||
return effectPreviewService.onPreviewImageReady({
|
||||
callback: renderPreview,
|
||||
});
|
||||
}, [blur.value]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
aria-label={`Select ${blur.label} blur`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute right-1 bottom-1 left-1 text-center">
|
||||
<span className="rounded bg-black/50 px-1 text-xs text-white">
|
||||
{blur.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BlurPreview.displayName = "BlurPreview";
|
||||
|
||||
const BackgroundPreviews = memo(
|
||||
({
|
||||
backgrounds,
|
||||
currentBackgroundColor,
|
||||
isColorBackground,
|
||||
onSelect,
|
||||
useBackgroundColor = false,
|
||||
}: {
|
||||
backgrounds: readonly string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
onSelect: (bg: string) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
backgrounds.map((bg) => (
|
||||
<button
|
||||
key={bg}
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
||||
isColorBackground &&
|
||||
bg.toLowerCase() === currentBackgroundColor.toLowerCase() &&
|
||||
"border-primary border-2",
|
||||
)}
|
||||
style={
|
||||
useBackgroundColor
|
||||
? { backgroundColor: bg }
|
||||
: {
|
||||
background: bg,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => onSelect(bg)}
|
||||
type="button"
|
||||
aria-label={`Select background ${bg}`}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
backgrounds,
|
||||
isColorBackground,
|
||||
currentBackgroundColor,
|
||||
onSelect,
|
||||
useBackgroundColor,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
function CustomColorPreview({
|
||||
currentBackgroundColor,
|
||||
isSelected,
|
||||
onPreview,
|
||||
onCommit,
|
||||
}: {
|
||||
currentBackgroundColor: string;
|
||||
isSelected: boolean;
|
||||
onPreview: (color: string) => void;
|
||||
onCommit: (color: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
type="button"
|
||||
aria-label="Pick a custom background color"
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0"
|
||||
style={{ background: CUSTOM_COLOR_SWATCH_BACKGROUND }}
|
||||
/>
|
||||
<span
|
||||
className="absolute right-1 bottom-1 size-5 rounded-sm border border-white/70 shadow-sm"
|
||||
style={{ backgroundColor: currentBackgroundColor }}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<ColorPickerContent
|
||||
value={currentBackgroundColor.replace(/^#/, "").toUpperCase()}
|
||||
onChange={(color) => onPreview(`#${color}`)}
|
||||
onChangeEnd={(color) => onCommit(`#${color}`)}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const COLOR_SECTIONS = [
|
||||
{ id: "colors", title: "Colors", backgrounds: colors, useBackgroundColor: true, showCustomPicker: true },
|
||||
{ id: "pattern-craft", title: "Pattern craft", backgrounds: patternCraftGradients, showCustomPicker: false },
|
||||
{ id: "syntax-ui", title: "Syntax UI", backgrounds: syntaxUIGradients, showCustomPicker: false },
|
||||
] as const;
|
||||
|
||||
export function BackgroundContent() {
|
||||
const editor = useEditor();
|
||||
const activeProject = useEditor((e) => e.project.getActive());
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async (blurIntensity: number) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "blur", blurIntensity } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const previewBackgroundColor = useCallback(
|
||||
async (color: string) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
pushHistory: false,
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const commitBackgroundColor = useCallback(
|
||||
async (color: string) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
pushHistory: true,
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const isBlurBackground = activeProject.settings.background.type === "blur";
|
||||
const isColorBackground = activeProject.settings.background.type === "color";
|
||||
|
||||
const currentBlurIntensity = isBlurBackground
|
||||
? (activeProject.settings.background as { blurIntensity: number })
|
||||
.blurIntensity
|
||||
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor = isColorBackground
|
||||
? (activeProject.settings.background as { color: string }).color
|
||||
: DEFAULT_BACKGROUND_COLOR;
|
||||
|
||||
const hasPresetColorMatch = colors.some(
|
||||
(color) => color.toLowerCase() === currentBackgroundColor.toLowerCase(),
|
||||
);
|
||||
|
||||
const handlePresetColorSelect = useCallback(
|
||||
(color: string) => {
|
||||
void commitBackgroundColor(color);
|
||||
},
|
||||
[commitBackgroundColor],
|
||||
);
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||
onSelect={() => handleBlurSelect(blur.value)}
|
||||
/>
|
||||
)),
|
||||
[isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Section
|
||||
collapsible
|
||||
defaultOpen={true}
|
||||
sectionKey="background-blur"
|
||||
showTopBorder={false}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Blur</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
{COLOR_SECTIONS.map((section) => (
|
||||
<Section
|
||||
key={section.id}
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
sectionKey={`settings:background-${section.id}`}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTitle>{section.title}</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{section.showCustomPicker ? (
|
||||
<CustomColorPreview
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isSelected={isColorBackground && !hasPresetColorMatch}
|
||||
onPreview={previewBackgroundColor}
|
||||
onCommit={commitBackgroundColor}
|
||||
/>
|
||||
) : null}
|
||||
<BackgroundPreviews
|
||||
backgrounds={section.backgrounds}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
onSelect={handlePresetColorSelect}
|
||||
useBackgroundColor={
|
||||
"useBackgroundColor" in section
|
||||
? section.useBackgroundColor
|
||||
: false
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import {
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BACKGROUND_BLUR_INTENSITY,
|
||||
} from "@/lib/background/blur";
|
||||
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { effectPreviewService } from "@/services/renderer/effect-preview";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const BLUR_PREVIEW_UNIFORM_DIMENSIONS = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
} as const;
|
||||
|
||||
const BlurPreview = memo(
|
||||
({
|
||||
blur,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
blur: { label: string; value: number };
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const renderPreview = () => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
effectPreviewService.renderPreview({
|
||||
effectType: "blur",
|
||||
params: { intensity: blur.value },
|
||||
targetCanvas: canvasRef.current,
|
||||
uniformDimensions: BLUR_PREVIEW_UNIFORM_DIMENSIONS,
|
||||
});
|
||||
};
|
||||
|
||||
renderPreview();
|
||||
return effectPreviewService.onPreviewImageReady({ callback: renderPreview });
|
||||
}, [blur.value]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
aria-label={`Select ${blur.label} blur`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute right-1 bottom-1 left-1 text-center">
|
||||
<span className="rounded bg-black/50 px-1 text-xs text-white">
|
||||
{blur.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BlurPreview.displayName = "BlurPreview";
|
||||
|
||||
const BackgroundPreviews = memo(
|
||||
({
|
||||
backgrounds,
|
||||
currentBackgroundColor,
|
||||
isColorBackground,
|
||||
onSelect,
|
||||
useBackgroundColor = false,
|
||||
}: {
|
||||
backgrounds: string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
onSelect: (bg: string) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
backgrounds.map((bg) => (
|
||||
<button
|
||||
key={bg}
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
||||
isColorBackground &&
|
||||
bg === currentBackgroundColor &&
|
||||
"border-primary border-2",
|
||||
)}
|
||||
style={
|
||||
useBackgroundColor
|
||||
? { backgroundColor: bg }
|
||||
: {
|
||||
background: bg,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => onSelect(bg)}
|
||||
type="button"
|
||||
aria-label={`Select background ${bg}`}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
backgrounds,
|
||||
isColorBackground,
|
||||
currentBackgroundColor,
|
||||
onSelect,
|
||||
useBackgroundColor,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
const COLOR_SECTIONS = [
|
||||
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
|
||||
{ title: "Pattern craft", backgrounds: patternCraftGradients },
|
||||
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
|
||||
] as const;
|
||||
|
||||
export function BackgroundContent() {
|
||||
const editor = useEditor();
|
||||
const activeProject = useEditor((e) => e.project.getActive());
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async (blurIntensity: number) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "blur", blurIntensity } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
async (color: string) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const isBlurBackground = activeProject.settings.background.type === "blur";
|
||||
const isColorBackground = activeProject.settings.background.type === "color";
|
||||
|
||||
const currentBlurIntensity = isBlurBackground
|
||||
? (activeProject.settings.background as { blurIntensity: number })
|
||||
.blurIntensity
|
||||
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor = isColorBackground
|
||||
? (activeProject.settings.background as { color: string }).color
|
||||
: DEFAULT_BACKGROUND_COLOR;
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||
onSelect={() => handleBlurSelect(blur.value)}
|
||||
/>
|
||||
)),
|
||||
[isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Blur</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
{COLOR_SECTIONS.map((section) => (
|
||||
<Section
|
||||
key={section.title}
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTitle>{section.title}</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BackgroundPreviews
|
||||
backgrounds={section.backgrounds as string[]}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
onSelect={handleColorSelect}
|
||||
useBackgroundColor={
|
||||
"useBackgroundColor" in section
|
||||
? section.useBackgroundColor
|
||||
: false
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FPS_PRESETS } from "@/fps/presets";
|
||||
import { floatToFrameRate, frameRateToFloat } from "@/fps/utils";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { FPS_PRESETS } from "@/lib/fps/presets";
|
||||
import { floatToFrameRate, frameRateToFloat } from "@/lib/fps/utils";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
@@ -22,7 +22,7 @@ import { BackgroundContent } from "./background";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditorStore } from "@/editor/editor-store";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
@@ -30,7 +30,7 @@ import { cn } from "@/utils/ui";
|
||||
import { dimensionToAspectRatio } from "@/utils/geometry";
|
||||
import { formatNumberForDisplay } from "@/utils/math";
|
||||
import { OcSquarePlusIcon } from "@/components/icons";
|
||||
import type { TCanvasSize } from "@/project/types";
|
||||
import type { TCanvasSize } from "@/lib/project/types";
|
||||
|
||||
type SettingsView = "project-info" | "background";
|
||||
|
||||
|
||||
+3
-3
@@ -22,9 +22,9 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { useSoundSearch } from "@/sounds/use-sound-search";
|
||||
import { useSoundsStore } from "@/sounds/sounds-store";
|
||||
import type { SavedSound, SoundEffect } from "@/sounds/types";
|
||||
import { useSoundSearch } from "@/hooks/use-sound-search";
|
||||
import { useSoundsStore } from "@/stores/sounds-store";
|
||||
import type { SavedSound, SoundEffect } from "@/lib/sounds/types";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
FavouriteIcon,
|
||||
+9
-9
@@ -9,22 +9,22 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { resolveStickerIntrinsicSize } from "@/stickers";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
|
||||
import {
|
||||
buildGraphicElement,
|
||||
buildStickerElement,
|
||||
} from "@/timeline/element-utils";
|
||||
import { STICKER_CATEGORIES } from "@/stickers/categories";
|
||||
import { getRegionLabel, resolveQueryToRegions } from "@/stickers";
|
||||
import { parseShapeStickerId } from "@/stickers/providers/shapes";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { STICKER_CATEGORIES } from "@/lib/stickers/categories";
|
||||
import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
|
||||
import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
|
||||
import type { TimelineDragData } from "@/lib/timeline/drag";
|
||||
import type {
|
||||
StickerBrowseSection,
|
||||
StickerCategory,
|
||||
StickerItem as StickerData,
|
||||
} from "@/stickers";
|
||||
import { useStickersStore } from "@/stickers/stickers-store";
|
||||
} from "@/lib/stickers";
|
||||
import { useStickersStore } from "@/stores/stickers-store";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
HappyIcon,
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { buildTextElement } from "@/timeline/element-utils";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { buildTextElement } from "@/lib/timeline/element-utils";
|
||||
|
||||
export function TextView() {
|
||||
const editor = useEditor();
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { findCurrentScene } from "@/lib/scenes";
|
||||
import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
|
||||
|
||||
export function BookmarkNoteOverlay() {
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
if (!activeProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeScene = findCurrentScene({
|
||||
scenes: activeProject.scenes,
|
||||
currentSceneId: activeProject.currentSceneId,
|
||||
});
|
||||
|
||||
if (!activeScene) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bookmarks = activeScene.bookmarks;
|
||||
const activeBookmarks = getBookmarksActiveAtTime({
|
||||
bookmarks,
|
||||
time: currentTime,
|
||||
});
|
||||
const bookmarksWithNotes = activeBookmarks.filter(
|
||||
(bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
|
||||
);
|
||||
|
||||
if (bookmarksWithNotes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
|
||||
aria-live="polite"
|
||||
>
|
||||
{bookmarksWithNotes.map((bookmark) => (
|
||||
<div
|
||||
key={bookmark.time}
|
||||
className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor: "rgb(0 0 0 / 0.5)",
|
||||
borderLeft: bookmark.color
|
||||
? `3px solid ${bookmark.color}`
|
||||
: "3px solid var(--primary)",
|
||||
}}
|
||||
>
|
||||
{bookmark.note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+73
-85
@@ -1,85 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { PreviewOverlayControl } from "@/preview/overlays";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function PreviewContextMenu({
|
||||
onToggleFullscreen,
|
||||
containerRef,
|
||||
overlayControls,
|
||||
onOverlayVisibilityChange,
|
||||
}: {
|
||||
onToggleFullscreen: () => void;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
overlayControls: PreviewOverlayControl[];
|
||||
onOverlayVisibilityChange: (params: {
|
||||
overlayId: string;
|
||||
isVisible: boolean;
|
||||
}) => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const viewport = usePreviewViewport();
|
||||
|
||||
const handleCopySnapshot = async () => {
|
||||
const result = await editor.renderer.copySnapshot();
|
||||
|
||||
if (!result.success) {
|
||||
toast.error("Failed to copy snapshot", {
|
||||
description: result.error ?? "Please try again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSnapshot = async () => {
|
||||
const result = await editor.renderer.saveSnapshot();
|
||||
|
||||
if (!result.success) {
|
||||
toast.error("Failed to save snapshot", {
|
||||
description: result.error ?? "Please try again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenuContent className="w-56" container={containerRef.current}>
|
||||
<ContextMenuItem onClick={viewport.fitToScreen} inset>
|
||||
Fit to screen
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onToggleFullscreen} inset>
|
||||
Full screen
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleSaveSnapshot} inset>
|
||||
Save snapshot
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleCopySnapshot} inset>
|
||||
Copy snapshot
|
||||
</ContextMenuItem>
|
||||
{overlayControls.length > 0 ? <ContextMenuSeparator /> : null}
|
||||
{overlayControls.map((overlayControl) => (
|
||||
<ContextMenuCheckboxItem
|
||||
key={overlayControl.id}
|
||||
checked={overlayControl.isVisible}
|
||||
onCheckedChange={(checked) =>
|
||||
onOverlayVisibilityChange({
|
||||
overlayId: overlayControl.id,
|
||||
isVisible: !!checked,
|
||||
})
|
||||
}
|
||||
>
|
||||
{overlayControl.label}
|
||||
</ContextMenuCheckboxItem>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function PreviewContextMenu({
|
||||
onToggleFullscreen,
|
||||
containerRef,
|
||||
}: {
|
||||
onToggleFullscreen: () => void;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const viewport = usePreviewViewport();
|
||||
const { overlays, setOverlayVisibility } = usePreviewStore();
|
||||
|
||||
const handleCopySnapshot = async () => {
|
||||
const result = await editor.renderer.copySnapshot();
|
||||
|
||||
if (!result.success) {
|
||||
toast.error("Failed to copy snapshot", {
|
||||
description: result.error ?? "Please try again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSnapshot = async () => {
|
||||
const result = await editor.renderer.saveSnapshot();
|
||||
|
||||
if (!result.success) {
|
||||
toast.error("Failed to save snapshot", {
|
||||
description: result.error ?? "Please try again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenuContent className="w-56" container={containerRef.current}>
|
||||
<ContextMenuItem onClick={viewport.fitToScreen} inset>
|
||||
Fit to screen
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onToggleFullscreen} inset>
|
||||
Full screen
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleSaveSnapshot} inset>
|
||||
Save snapshot
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleCopySnapshot} inset>
|
||||
Copy snapshot
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuCheckboxItem
|
||||
checked={overlays.bookmarks}
|
||||
onCheckedChange={(checked) =>
|
||||
setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
|
||||
}
|
||||
>
|
||||
Show bookmark notes
|
||||
</ContextMenuCheckboxItem>
|
||||
</ContextMenuContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { getGuideById } from "@/lib/guides";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
import { usePreviewViewport } from "./preview-viewport";
|
||||
|
||||
export function GuideOverlay() {
|
||||
const activeGuide = usePreviewStore((state) => state.activeGuide);
|
||||
const viewport = usePreviewViewport();
|
||||
const guide = getGuideById(activeGuide);
|
||||
|
||||
if (!guide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: viewport.sceneLeft,
|
||||
top: viewport.sceneTop,
|
||||
width: viewport.sceneWidth,
|
||||
height: viewport.sceneHeight,
|
||||
}}
|
||||
>
|
||||
{guide.renderOverlay({
|
||||
width: viewport.sceneWidth,
|
||||
height: viewport.sceneHeight,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { GUIDE_REGISTRY, getGuideById } from "@/guides";
|
||||
import { usePreviewStore } from "@/preview/preview-store";
|
||||
import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
+332
-427
@@ -1,427 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
|
||||
export const HANDLE_SIZE = 10;
|
||||
export const HANDLE_HIT_AREA_SIZE = 18;
|
||||
export const ICON_HANDLE_RADIUS = 10;
|
||||
export const EDGE_HANDLE_THIN_SIZE = 6;
|
||||
export const EDGE_HANDLE_THICK_SIZE = 14;
|
||||
export const LINE_HIT_AREA_SIZE = 48;
|
||||
|
||||
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
|
||||
const normalized = ((angleDeg % 180) + 180) % 180;
|
||||
if (normalized < 22.5 || normalized >= 157.5) return "ew-resize";
|
||||
if (normalized < 67.5) return "nwse-resize";
|
||||
if (normalized < 112.5) return "ns-resize";
|
||||
return "nesw-resize";
|
||||
}
|
||||
|
||||
export function HandleButton({
|
||||
screen,
|
||||
cursor,
|
||||
hitAreaSize,
|
||||
className,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
children,
|
||||
}: {
|
||||
screen: { x: number; y: number };
|
||||
cursor?: string;
|
||||
hitAreaSize: number;
|
||||
className?: string;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute flex items-center justify-center outline-none",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
left: screen.x - hitAreaSize / 2,
|
||||
top: screen.y - hitAreaSize / 2,
|
||||
width: hitAreaSize,
|
||||
height: hitAreaSize,
|
||||
pointerEvents: "auto",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CornerHandle({
|
||||
cursor,
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
cursor?: string;
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
cursor={cursor}
|
||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className="rounded-sm bg-white"
|
||||
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
|
||||
/>
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function CircleHandle({
|
||||
cursor,
|
||||
screen,
|
||||
size = HANDLE_SIZE,
|
||||
isSelected = false,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
cursor?: string;
|
||||
screen: { x: number; y: number };
|
||||
size?: number;
|
||||
isSelected?: boolean;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
cursor={cursor}
|
||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function EdgeHandle({
|
||||
edge,
|
||||
screen,
|
||||
rotation,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
edge: "right" | "left" | "bottom";
|
||||
screen: { x: number; y: number };
|
||||
rotation: number;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
const isHorizontalEdge = edge === "right" || edge === "left";
|
||||
const width = isHorizontalEdge
|
||||
? EDGE_HANDLE_THIN_SIZE
|
||||
: EDGE_HANDLE_THICK_SIZE;
|
||||
const height = isHorizontalEdge
|
||||
? EDGE_HANDLE_THICK_SIZE
|
||||
: EDGE_HANDLE_THIN_SIZE;
|
||||
const cursor = getResizeCursor({
|
||||
angleDeg: isHorizontalEdge ? rotation : rotation + 90,
|
||||
});
|
||||
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
cursor={cursor}
|
||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className="rounded-sm bg-white"
|
||||
style={{ width, height, transform: `rotate(${rotation}deg)` }}
|
||||
/>
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHandle({
|
||||
icon,
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
icon: IconSvgElement;
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
hitAreaSize={ICON_HANDLE_RADIUS * 2}
|
||||
className="rounded-full bg-white text-black shadow-sm"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoundingBoxOutline({
|
||||
center,
|
||||
outlineWidth,
|
||||
outlineHeight,
|
||||
rotation,
|
||||
cursor,
|
||||
dashed = false,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
outlineWidth: number;
|
||||
outlineHeight: number;
|
||||
rotation: number;
|
||||
cursor?: string;
|
||||
dashed?: boolean;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className="absolute overflow-visible"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
left: center.x - outlineWidth / 2,
|
||||
top: center.y - outlineHeight / 2,
|
||||
width: outlineWidth,
|
||||
height: outlineHeight,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<rect
|
||||
x={0.5}
|
||||
y={0.5}
|
||||
width={Math.max(outlineWidth - 1, 0)}
|
||||
height={Math.max(outlineHeight - 1, 0)}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeDasharray={dashed ? "4 4" : undefined}
|
||||
strokeOpacity={0.75}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShapeOutline({
|
||||
center,
|
||||
outlineWidth,
|
||||
outlineHeight,
|
||||
rotation,
|
||||
pathData,
|
||||
cursor,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
outlineWidth: number;
|
||||
outlineHeight: number;
|
||||
rotation: number;
|
||||
pathData: string;
|
||||
cursor?: string;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className="absolute overflow-visible"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
left: center.x - outlineWidth / 2,
|
||||
top: center.y - outlineHeight / 2,
|
||||
width: outlineWidth,
|
||||
height: outlineHeight,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<path
|
||||
d={pathData}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeOpacity={0.75}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasPathOutline({
|
||||
pathData,
|
||||
translateX = 0,
|
||||
translateY = 0,
|
||||
scaleX = 1,
|
||||
scaleY = 1,
|
||||
cursor,
|
||||
strokeWidth = 1,
|
||||
strokeOpacity = 0.75,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
pathData: string;
|
||||
translateX?: number;
|
||||
translateY?: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
cursor?: string;
|
||||
strokeWidth?: number;
|
||||
strokeOpacity?: number;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className="absolute inset-0 overflow-visible"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<g
|
||||
transform={`translate(${translateX} ${translateY}) scale(${scaleX} ${scaleY})`}
|
||||
>
|
||||
<path
|
||||
d={pathData}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeOpacity={strokeOpacity}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ pointerEvents: onPointerDown ? "stroke" : "none" }}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineOverlay({
|
||||
start,
|
||||
end,
|
||||
cursor,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
cursor?: string;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
const cx = (start.x + end.x) / 2;
|
||||
const cy = (start.y + end.y) / 2;
|
||||
|
||||
const sharedStyle = {
|
||||
left: cx - length / 2,
|
||||
width: length,
|
||||
transform: `rotate(${angleDeg}deg)`,
|
||||
transformOrigin: "center center",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{onPointerDown && (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
...sharedStyle,
|
||||
top: cy - LINE_HIT_AREA_SIZE / 2,
|
||||
height: LINE_HIT_AREA_SIZE,
|
||||
pointerEvents: "auto",
|
||||
cursor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...sharedStyle,
|
||||
top: cy - 0.5,
|
||||
height: 1,
|
||||
backgroundColor: "white",
|
||||
opacity: 0.75,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
|
||||
export const HANDLE_SIZE = 10;
|
||||
export const HANDLE_HIT_AREA_SIZE = 18;
|
||||
export const ICON_HANDLE_RADIUS = 10;
|
||||
export const EDGE_HANDLE_THIN_SIZE = 6;
|
||||
export const EDGE_HANDLE_THICK_SIZE = 14;
|
||||
export const LINE_HIT_AREA_SIZE = 48;
|
||||
|
||||
export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
|
||||
const normalized = ((angleDeg % 180) + 180) % 180;
|
||||
if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
|
||||
if (normalized < 67.5) return "cursor-nwse-resize";
|
||||
if (normalized < 112.5) return "cursor-ns-resize";
|
||||
return "cursor-nesw-resize";
|
||||
}
|
||||
|
||||
export function HandleButton({
|
||||
screen,
|
||||
cursor,
|
||||
hitAreaSize,
|
||||
className,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
children,
|
||||
}: {
|
||||
screen: { x: number; y: number };
|
||||
cursor?: string;
|
||||
hitAreaSize: number;
|
||||
className?: string;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute flex items-center justify-center outline-none",
|
||||
cursor,
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
left: screen.x - hitAreaSize / 2,
|
||||
top: screen.y - hitAreaSize / 2,
|
||||
width: hitAreaSize,
|
||||
height: hitAreaSize,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CornerHandle({
|
||||
cursor,
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
cursor?: string;
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
cursor={cursor}
|
||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className="rounded-sm bg-white"
|
||||
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
|
||||
/>
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function EdgeHandle({
|
||||
edge,
|
||||
screen,
|
||||
rotation,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
edge: "right" | "left" | "bottom";
|
||||
screen: { x: number; y: number };
|
||||
rotation: number;
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
const isHorizontalEdge = edge === "right" || edge === "left";
|
||||
const width = isHorizontalEdge
|
||||
? EDGE_HANDLE_THIN_SIZE
|
||||
: EDGE_HANDLE_THICK_SIZE;
|
||||
const height = isHorizontalEdge
|
||||
? EDGE_HANDLE_THICK_SIZE
|
||||
: EDGE_HANDLE_THIN_SIZE;
|
||||
const cursor = getResizeCursor({
|
||||
angleDeg: isHorizontalEdge ? rotation : rotation + 90,
|
||||
});
|
||||
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
cursor={cursor}
|
||||
hitAreaSize={HANDLE_HIT_AREA_SIZE}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className="rounded-sm bg-white"
|
||||
style={{ width, height, transform: `rotate(${rotation}deg)` }}
|
||||
/>
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHandle({
|
||||
icon,
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
icon: IconSvgElement;
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<HandleButton
|
||||
screen={screen}
|
||||
hitAreaSize={ICON_HANDLE_RADIUS * 2}
|
||||
className="rounded-full bg-white text-black shadow-sm"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
|
||||
</HandleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoundingBoxOutline({
|
||||
center,
|
||||
outlineWidth,
|
||||
outlineHeight,
|
||||
rotation,
|
||||
cursor,
|
||||
dashed = false,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
outlineWidth: number;
|
||||
outlineHeight: number;
|
||||
rotation: number;
|
||||
cursor?: string;
|
||||
dashed?: boolean;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute overflow-visible", cursor)}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
left: center.x - outlineWidth / 2,
|
||||
top: center.y - outlineHeight / 2,
|
||||
width: outlineWidth,
|
||||
height: outlineHeight,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<rect
|
||||
x={0.5}
|
||||
y={0.5}
|
||||
width={Math.max(outlineWidth - 1, 0)}
|
||||
height={Math.max(outlineHeight - 1, 0)}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeDasharray={dashed ? "4 4" : undefined}
|
||||
strokeOpacity={0.75}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShapeOutline({
|
||||
center,
|
||||
outlineWidth,
|
||||
outlineHeight,
|
||||
rotation,
|
||||
pathData,
|
||||
cursor,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
outlineWidth: number;
|
||||
outlineHeight: number;
|
||||
rotation: number;
|
||||
pathData: string;
|
||||
cursor?: string;
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("absolute overflow-visible", cursor)}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{
|
||||
left: center.x - outlineWidth / 2,
|
||||
top: center.y - outlineHeight / 2,
|
||||
width: outlineWidth,
|
||||
height: outlineHeight,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
pointerEvents: onPointerDown ? "auto" : "none",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<path
|
||||
d={pathData}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeOpacity={0.75}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ pointerEvents: onPointerDown ? "all" : "none" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineOverlay({
|
||||
start,
|
||||
end,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
onPointerDown?: (event: React.PointerEvent) => void;
|
||||
onPointerMove?: (event: React.PointerEvent) => void;
|
||||
onPointerUp?: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
const cx = (start.x + end.x) / 2;
|
||||
const cy = (start.y + end.y) / 2;
|
||||
|
||||
const sharedStyle = {
|
||||
left: cx - length / 2,
|
||||
width: length,
|
||||
transform: `rotate(${angleDeg}deg)`,
|
||||
transformOrigin: "center center",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{onPointerDown && (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
...sharedStyle,
|
||||
top: cy - LINE_HIT_AREA_SIZE / 2,
|
||||
height: LINE_HIT_AREA_SIZE,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
...sharedStyle,
|
||||
top: cy - 0.5,
|
||||
height: 1,
|
||||
backgroundColor: "white",
|
||||
opacity: 0.75,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+17
-51
@@ -2,21 +2,19 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import useDeepCompareEffect from "use-deep-compare-effect";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useRafLoop } from "@/hooks/use-raf-loop";
|
||||
import { useContainerSize } from "@/hooks/use-container-size";
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { PreviewOverlayLayer } from "./overlay-layer";
|
||||
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
|
||||
import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
|
||||
import { GuideOverlay } from "./guide-overlay";
|
||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import type {
|
||||
PreviewOverlayControl,
|
||||
PreviewOverlayInstance,
|
||||
} from "@/preview/overlays";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
import { PreviewContextMenu } from "./context-menu";
|
||||
import { PreviewToolbar } from "./toolbar";
|
||||
import {
|
||||
@@ -55,18 +53,7 @@ function normalizeWheelDelta({
|
||||
return delta;
|
||||
}
|
||||
|
||||
export function PreviewPanel({
|
||||
overlayControls,
|
||||
overlayInstances,
|
||||
onOverlayVisibilityChange,
|
||||
}: {
|
||||
overlayControls: PreviewOverlayControl[];
|
||||
overlayInstances: PreviewOverlayInstance[];
|
||||
onOverlayVisibilityChange: (params: {
|
||||
overlayId: string;
|
||||
isVisible: boolean;
|
||||
}) => void;
|
||||
}) {
|
||||
export function PreviewPanel() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { toggleFullscreen } = useFullscreen({ containerRef });
|
||||
|
||||
@@ -78,9 +65,6 @@ export function PreviewPanel({
|
||||
<PreviewCanvas
|
||||
containerRef={containerRef}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
overlayControls={overlayControls}
|
||||
overlayInstances={overlayInstances}
|
||||
onOverlayVisibilityChange={onOverlayVisibilityChange}
|
||||
/>
|
||||
<RenderTreeController />
|
||||
</div>
|
||||
@@ -119,18 +103,9 @@ function RenderTreeController() {
|
||||
function PreviewCanvas({
|
||||
containerRef,
|
||||
onToggleFullscreen,
|
||||
overlayControls,
|
||||
overlayInstances,
|
||||
onOverlayVisibilityChange,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
onToggleFullscreen: () => void;
|
||||
overlayControls: PreviewOverlayControl[];
|
||||
overlayInstances: PreviewOverlayInstance[];
|
||||
onOverlayVisibilityChange: (params: {
|
||||
overlayId: string;
|
||||
isVisible: boolean;
|
||||
}) => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
@@ -142,6 +117,7 @@ function PreviewCanvas({
|
||||
const editor = useEditor();
|
||||
const activeProject = useEditor((e) => e.project.getActive());
|
||||
const renderTree = useEditor((e) => e.renderer.getRenderTree());
|
||||
const { overlays } = usePreviewStore();
|
||||
const viewport = usePreviewViewportState({
|
||||
canvasHeight: nativeHeight,
|
||||
canvasWidth: nativeWidth,
|
||||
@@ -156,18 +132,16 @@ function PreviewCanvas({
|
||||
height: nativeHeight,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
|
||||
}, [nativeWidth, nativeHeight, activeProject.settings.fps.numerator, activeProject.settings.fps.denominator]);
|
||||
|
||||
const render = useCallback(() => {
|
||||
if (canvasRef.current && renderTree && !renderingRef.current) {
|
||||
const renderTime = Math.min(
|
||||
editor.playback.getCurrentTime(),
|
||||
editor.timeline.getLastFrameTime(),
|
||||
);
|
||||
const ticksPerFrame = Math.round(
|
||||
(TICKS_PER_SECOND * renderer.fps.denominator) / renderer.fps.numerator,
|
||||
);
|
||||
const frame = Math.floor(renderTime / ticksPerFrame);
|
||||
const renderTime = Math.min(
|
||||
editor.playback.getCurrentTime(),
|
||||
editor.timeline.getLastFrameTime(),
|
||||
);
|
||||
const ticksPerFrame = Math.round(TICKS_PER_SECOND * renderer.fps.denominator / renderer.fps.numerator);
|
||||
const frame = Math.floor(renderTime / ticksPerFrame);
|
||||
|
||||
if (
|
||||
frame !== lastFrameRef.current ||
|
||||
@@ -187,7 +161,7 @@ function PreviewCanvas({
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [renderer, renderTree, editor.playback, editor.timeline]);
|
||||
}, [renderer, renderTree, editor.playback]);
|
||||
|
||||
useRafLoop(render);
|
||||
|
||||
@@ -302,22 +276,14 @@ function PreviewCanvas({
|
||||
: activeProject?.settings.background.color,
|
||||
}}
|
||||
/>
|
||||
<PreviewOverlayLayer
|
||||
instances={overlayInstances}
|
||||
plane="under-interaction"
|
||||
/>
|
||||
<GuideOverlay />
|
||||
<PreviewInteractionOverlay />
|
||||
<PreviewOverlayLayer
|
||||
instances={overlayInstances}
|
||||
plane="over-interaction"
|
||||
/>
|
||||
{overlays.bookmarks && <BookmarkNoteOverlay />}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<PreviewContextMenu
|
||||
onToggleFullscreen={onToggleFullscreen}
|
||||
containerRef={containerRef}
|
||||
overlayControls={overlayControls}
|
||||
onOverlayVisibilityChange={onOverlayVisibilityChange}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import { useMaskHandles } from "@/hooks/use-mask-handles";
|
||||
import { masksRegistry } from "@/lib/masks";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type { RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
CornerHandle,
|
||||
EdgeHandle,
|
||||
IconHandle,
|
||||
LineOverlay,
|
||||
BoundingBoxOutline,
|
||||
ShapeOutline,
|
||||
} from "./handle-primitives";
|
||||
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
function hasRectangleOutlineParams(
|
||||
params: ParamValues,
|
||||
): params is RectangleMaskParams {
|
||||
return (
|
||||
typeof params.centerX === "number" &&
|
||||
typeof params.centerY === "number" &&
|
||||
typeof params.width === "number" &&
|
||||
typeof params.height === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function MaskHandles({
|
||||
onSnapLinesChange,
|
||||
}: {
|
||||
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
||||
}) {
|
||||
const viewport = usePreviewViewport();
|
||||
const {
|
||||
selectedWithMask,
|
||||
handlePositions,
|
||||
linePoints,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
} = useMaskHandles({ onSnapLinesChange });
|
||||
|
||||
if (!selectedWithMask) return null;
|
||||
|
||||
const toOverlay = ({
|
||||
canvasX,
|
||||
canvasY,
|
||||
}: {
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
}) =>
|
||||
viewport.canvasToOverlay({
|
||||
canvasX,
|
||||
canvasY,
|
||||
});
|
||||
|
||||
const def = masksRegistry.get(selectedWithMask.mask.type);
|
||||
const { bounds } = selectedWithMask;
|
||||
const maskRotation = selectedWithMask.mask.params.rotation;
|
||||
|
||||
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
|
||||
|
||||
const rectangleOutlineProps = hasRectangleOutlineParams(
|
||||
selectedWithMask.mask.params,
|
||||
)
|
||||
? {
|
||||
center: toOverlay({
|
||||
canvasX:
|
||||
bounds.cx + selectedWithMask.mask.params.centerX * bounds.width,
|
||||
canvasY:
|
||||
bounds.cy + selectedWithMask.mask.params.centerY * bounds.height,
|
||||
}),
|
||||
outlineWidth:
|
||||
selectedWithMask.mask.params.width * bounds.width * scaleX,
|
||||
outlineHeight:
|
||||
selectedWithMask.mask.params.height * bounds.height * scaleY,
|
||||
rotation: maskRotation,
|
||||
}
|
||||
: null;
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent) =>
|
||||
handlePointerMove({ event });
|
||||
const onPointerUp = () => handlePointerUp();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
{def.overlayShape === "line" && linePoints && (
|
||||
<LineOverlay
|
||||
start={toOverlay({
|
||||
canvasX: linePoints.start.x,
|
||||
canvasY: linePoints.start.y,
|
||||
})}
|
||||
end={toOverlay({
|
||||
canvasX: linePoints.end.x,
|
||||
canvasY: linePoints.end.y,
|
||||
})}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: "position" })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
)}
|
||||
{def.overlayShape === "box" && rectangleOutlineProps && (
|
||||
def.buildOverlayPath ? (
|
||||
<>
|
||||
<BoundingBoxOutline {...rectangleOutlineProps} dashed />
|
||||
<ShapeOutline
|
||||
{...rectangleOutlineProps}
|
||||
pathData={def.buildOverlayPath({
|
||||
width: rectangleOutlineProps.outlineWidth,
|
||||
height: rectangleOutlineProps.outlineHeight,
|
||||
})}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: "position" })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<BoundingBoxOutline
|
||||
{...rectangleOutlineProps}
|
||||
cursor="cursor-move"
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: "position" })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{handlePositions.map((handle) => {
|
||||
const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
|
||||
|
||||
if (handle.id === "rotation") {
|
||||
return (
|
||||
<IconHandle
|
||||
key={handle.id}
|
||||
icon={Rotate01Icon}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (handle.id === "feather") {
|
||||
return (
|
||||
<IconHandle
|
||||
key={handle.id}
|
||||
icon={FeatherIcon}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (handle.id === "right" || handle.id === "left") {
|
||||
return (
|
||||
<EdgeHandle
|
||||
key={handle.id}
|
||||
edge="right"
|
||||
screen={screen}
|
||||
rotation={maskRotation}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (handle.id === "bottom" || handle.id === "top") {
|
||||
return (
|
||||
<EdgeHandle
|
||||
key={handle.id}
|
||||
edge="bottom"
|
||||
screen={screen}
|
||||
rotation={maskRotation}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
handle.id === "top-left" ||
|
||||
handle.id === "top-right" ||
|
||||
handle.id === "bottom-left" ||
|
||||
handle.id === "bottom-right" ||
|
||||
handle.id === "scale"
|
||||
) {
|
||||
return (
|
||||
<CornerHandle
|
||||
key={handle.id}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CornerHandle
|
||||
key={handle.id}
|
||||
cursor={handle.cursor}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handlePointerDown({ event, handleId: handle.id })
|
||||
}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||
import { usePreviewInteraction } from "@/preview/hooks/use-preview-interaction";
|
||||
import type { SnapLine } from "@/preview/preview-snap";
|
||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
import { TransformHandles } from "./transform-handles";
|
||||
import { MaskHandles } from "./mask-handles";
|
||||
import { SnapGuides } from "./snap-guides";
|
||||
import { TextEditOverlay } from "./text-edit-overlay";
|
||||
import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { usePropertiesStore } from "../properties/stores/properties-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
export function PreviewInteractionOverlay() {
|
||||
const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
|
||||
+2
-2
@@ -15,9 +15,9 @@ import {
|
||||
positionToOverlay,
|
||||
screenPixelsToLogicalThreshold,
|
||||
screenToCanvas,
|
||||
} from "@/preview/preview-coords";
|
||||
} from "@/lib/preview/preview-coords";
|
||||
import { clamp, isNearlyEqual } from "@/utils/math";
|
||||
import { PREVIEW_ZOOM } from "@/preview/zoom";
|
||||
import { PREVIEW_ZOOM } from "@/lib/preview/zoom";
|
||||
|
||||
const MIDDLE_MOUSE_BUTTON = 1;
|
||||
const IS_AT_FIT_EPSILON = 0.001;
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||
import type { SnapLine } from "@/preview/preview-snap";
|
||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
|
||||
export function SnapGuides({ lines }: { lines: SnapLine[] }) {
|
||||
const viewport = usePreviewViewport();
|
||||
+18
-25
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { TextElement } from "@/timeline";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { TextElement } from "@/lib/timeline";
|
||||
import {
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
} from "@/lib/text/typography";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/animation";
|
||||
import { resolveTextLayout } from "@/text/primitives";
|
||||
} from "@/lib/animation";
|
||||
|
||||
export function TextEditOverlay({
|
||||
trackId,
|
||||
@@ -80,29 +82,20 @@ export function TextEditOverlay({
|
||||
});
|
||||
|
||||
const { x: displayScaleX } = viewport.getDisplayScale();
|
||||
const resolvedTextLayout = resolveTextLayout({
|
||||
text: {
|
||||
content: element.content,
|
||||
fontSize: element.fontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight: element.fontWeight,
|
||||
fontStyle: element.fontStyle,
|
||||
textAlign: element.textAlign,
|
||||
textDecoration: element.textDecoration,
|
||||
letterSpacing: element.letterSpacing,
|
||||
lineHeight: element.lineHeight,
|
||||
},
|
||||
canvasHeight: canvasSize.height,
|
||||
});
|
||||
|
||||
const scaledFontSize =
|
||||
element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE);
|
||||
|
||||
const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
|
||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
||||
const canvasLetterSpacing = element.letterSpacing ?? 0;
|
||||
const lineHeightPx = resolvedTextLayout.lineHeightPx;
|
||||
const lineHeightPx = scaledFontSize * lineHeight;
|
||||
|
||||
const bg = element.background;
|
||||
const shouldShowBackground =
|
||||
bg.enabled && bg.color && bg.color !== "transparent";
|
||||
const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
|
||||
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
|
||||
const canvasPaddingX = shouldShowBackground
|
||||
? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
|
||||
: 0;
|
||||
@@ -130,10 +123,10 @@ export function TextEditOverlay({
|
||||
aria-label="Edit text"
|
||||
className="cursor-text select-text outline-none whitespace-pre"
|
||||
style={{
|
||||
fontSize: resolvedTextLayout.scaledFontSize,
|
||||
fontSize: scaledFontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
|
||||
fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
textAlign: element.textAlign,
|
||||
letterSpacing: `${canvasLetterSpacing}px`,
|
||||
lineHeight,
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { formatTimecode } from "opencut-wasm";
|
||||
import { invokeAction } from "@/actions";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { EditableTimecode } from "@/components/editable-timecode";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
PlayIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { getGuideById } from "@/guides";
|
||||
import { getGuideById } from "@/lib/guides";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Select,
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
} from "@/components/ui/select";
|
||||
import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
|
||||
import { PREVIEW_ZOOM_PRESETS } from "@/lib/preview/zoom";
|
||||
import { usePreviewViewport } from "./preview-viewport";
|
||||
import { GridPopover } from "./guide-popover";
|
||||
import { usePreviewStore } from "@/preview/preview-store";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
|
||||
export function PreviewToolbar({
|
||||
onToggleFullscreen,
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||
import { useTransformHandles } from "@/preview/hooks/use-transform-handles";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
|
||||
import { useTransformHandles } from "@/hooks/use-transform-handles";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
getCornerPosition,
|
||||
getEdgeHandlePosition,
|
||||
ROTATION_HANDLE_OFFSET,
|
||||
type Corner,
|
||||
type Edge,
|
||||
} from "@/preview/element-bounds";
|
||||
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
|
||||
} from "@/lib/preview/element-bounds";
|
||||
import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
|
||||
import {
|
||||
BoundingBoxOutline,
|
||||
CornerHandle,
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ParamDefinition, NumberParamDefinition } from "@/params";
|
||||
import type { ParamDefinition, NumberParamDefinition } from "@/lib/params";
|
||||
import {
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { getElementLocalTime } from "@/animation";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { getElementLocalTime } from "@/lib/animation";
|
||||
|
||||
export function useElementPlayhead({
|
||||
startTime,
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
upsertElementKeyframe,
|
||||
} from "@/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
} from "@/lib/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
|
||||
export function useKeyframedColorProperty({
|
||||
trackId,
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
upsertElementKeyframe,
|
||||
} from "@/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
} from "@/lib/animation";
|
||||
import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import { usePropertyDraft } from "./use-property-draft";
|
||||
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
buildGraphicParamPath,
|
||||
coerceAnimationValueForParam,
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
getParamValueKind,
|
||||
hasKeyframesForPath,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
} from "@/lib/animation";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
|
||||
export interface KeyframedParamPropertyResult {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { usePropertiesStore } from "./stores/properties-store";
|
||||
import { getPropertiesConfig } from "./registry";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
@@ -11,8 +11,8 @@ import type {
|
||||
VideoElement,
|
||||
AudioElement,
|
||||
TimelineElement,
|
||||
} from "@/timeline";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
} from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
TextFontIcon,
|
||||
@@ -22,14 +22,14 @@ import {
|
||||
MagicWand05Icon,
|
||||
DashboardSpeed02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { TransformTab } from "@/rendering/components/transform-tab";
|
||||
import { BlendingTab } from "@/rendering/components/blending-tab";
|
||||
import { AudioTab } from "@/timeline/components/audio-tab";
|
||||
import { TextTab } from "@/text/components/text-tab";
|
||||
import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
|
||||
import { MasksTab } from "@/masks/components/masks-tab";
|
||||
import { SpeedTab } from "@/speed/components/speed-tab";
|
||||
import { GraphicTab } from "@/graphics/components/graphic-tab";
|
||||
import { TransformTab } from "./tabs/transform-tab";
|
||||
import { BlendingTab } from "./tabs/blending-tab";
|
||||
import { AudioTab } from "./tabs/audio-tab";
|
||||
import { TextTab } from "./tabs/text-tab";
|
||||
import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
|
||||
import { MasksTab } from "./tabs/masks-tab";
|
||||
import { SpeedTab } from "./tabs/speed-tab";
|
||||
import { GraphicTab } from "./tabs/graphic-tab";
|
||||
import { OcShapesIcon } from "@/components/icons";
|
||||
|
||||
export type TabContentProps = {
|
||||
|
||||
+9
-9
@@ -1,8 +1,8 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
|
||||
import { isSourceAudioSeparated } from "@/timeline/audio-separation";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||
import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import {
|
||||
clamp,
|
||||
formatNumberForDisplay,
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
isNearlyEqual,
|
||||
snapToStep,
|
||||
} from "@/utils/math";
|
||||
import type { AudioElement, VideoElement } from "@/timeline";
|
||||
import { resolveNumberAtTime } from "@/animation";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||
import { resolveNumberAtTime } from "@/lib/animation";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { VolumeHighIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
+10
-10
@@ -1,9 +1,9 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { OcCheckerboardIcon } from "@/components/icons";
|
||||
import { Fragment, useRef } from "react";
|
||||
import { useMenuPreview } from "@/editor/use-menu-preview";
|
||||
import { useMenuPreview } from "@/hooks/use-menu-preview";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
@@ -19,16 +19,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BlendMode } from "@/rendering";
|
||||
import type { ElementType } from "@/timeline";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import type { BlendMode } from "@/lib/rendering";
|
||||
import type { ElementType } from "@/lib/timeline";
|
||||
import type { ElementAnimations } from "@/lib/animation/types";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { resolveOpacityAtTime } from "@/animation";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { resolveOpacityAtTime } from "@/lib/animation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { isPropertyAtDefault } from "./transform-tab";
|
||||
|
||||
type BlendingElement = {
|
||||
+309
-309
@@ -1,309 +1,309 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { Effect } from "@/effects/types";
|
||||
import type { EffectElement, VisualElement } from "@/timeline";
|
||||
import { effectsRegistry } from "@/effects";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
SectionFields,
|
||||
} from "@/components/section";
|
||||
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
Delete02Icon,
|
||||
ViewIcon,
|
||||
ViewOffSlashIcon,
|
||||
MagicWand05Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store";
|
||||
|
||||
export function StandaloneEffectTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: EffectElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const { renderElement, previewUpdates, commit } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const effect: Effect = {
|
||||
id: element.id,
|
||||
type: element.effectType,
|
||||
params: element.params,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const previewParam = (key: string) => (value: number | string | boolean) => {
|
||||
previewUpdates({
|
||||
params: { ...(renderElement as EffectElement).params, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
|
||||
<SectionTitle>Effect</SectionTitle>
|
||||
</div>
|
||||
<EffectSection
|
||||
effect={effect}
|
||||
renderParams={(renderElement as EffectElement).params}
|
||||
previewParam={previewParam}
|
||||
onCommit={commit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClipEffectsTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
const editor = useEditor();
|
||||
const { renderElement, previewUpdates, commit } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const effects: Effect[] = element.effects ?? [];
|
||||
|
||||
const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
|
||||
return (
|
||||
(renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
|
||||
?.params ??
|
||||
effects.find((ef) => ef.id === effectId)?.params ??
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
const buildPreviewParam =
|
||||
(effectId: string) =>
|
||||
(key: string) =>
|
||||
(value: number | string | boolean) => {
|
||||
const updatedEffects = (
|
||||
(renderElement as VisualElement).effects ?? []
|
||||
).map((existing) =>
|
||||
existing.id !== effectId
|
||||
? existing
|
||||
: { ...existing, params: { ...existing.params, [key]: value } },
|
||||
);
|
||||
previewUpdates({ effects: updatedEffects });
|
||||
};
|
||||
|
||||
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 flex-col h-full">
|
||||
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
|
||||
<SectionTitle>Effects</SectionTitle>
|
||||
</div>
|
||||
{effects.length === 0 ? (
|
||||
<EmptyView />
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{effects.map((effect, index) => {
|
||||
const resolvedDragIndex = dragIndex ?? -1;
|
||||
const isDragging = dragIndex === index;
|
||||
const isDropTarget =
|
||||
dropIndex === index && dragIndex !== null && dragIndex !== index;
|
||||
const showTopDropIndicator =
|
||||
isDropTarget && index < resolvedDragIndex;
|
||||
const showBottomDropIndicator =
|
||||
isDropTarget && index > resolvedDragIndex;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={effect.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart({ index })}
|
||||
onDragOver={(event) => handleDragOver({ event, index })}
|
||||
onDrop={() => handleDrop({ toIndex: index })}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
"group list-none",
|
||||
isDragging && "opacity-40",
|
||||
showTopDropIndicator && "border-t-2 border-primary",
|
||||
showBottomDropIndicator && "border-b-2 border-primary",
|
||||
)}
|
||||
>
|
||||
<EffectSection
|
||||
effect={effect}
|
||||
renderParams={getRenderParams({ effectId: effect.id })}
|
||||
previewParam={buildPreviewParam(effect.id)}
|
||||
onCommit={commit}
|
||||
onToggle={() =>
|
||||
editor.timeline.toggleClipEffect({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
effectId: effect.id,
|
||||
})
|
||||
}
|
||||
onRemove={() =>
|
||||
editor.timeline.removeClipEffect({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
effectId: effect.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyView() {
|
||||
const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-center gap-4 text-center">
|
||||
<HugeiconsIcon
|
||||
icon={MagicWand05Icon}
|
||||
className="size-10 text-muted-foreground"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="font-medium text-foreground">No effects</h3>
|
||||
<p className="text-muted-foreground text-sm text-balance max-w-44">
|
||||
Add effects to this layer from the Assets panel.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setActiveTab("effects")}
|
||||
>
|
||||
Open effects
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectSection({
|
||||
effect,
|
||||
renderParams,
|
||||
previewParam,
|
||||
onCommit,
|
||||
onToggle,
|
||||
onRemove,
|
||||
}: {
|
||||
effect: Effect;
|
||||
renderParams: ParamValues;
|
||||
previewParam: (key: string) => (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
onToggle?: () => void;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
const definition = effectsRegistry.get(effect.type);
|
||||
|
||||
return (
|
||||
<Section
|
||||
sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
|
||||
showTopBorder={false}
|
||||
>
|
||||
<SectionHeader
|
||||
className={cn(onToggle && "cursor-move")}
|
||||
trailing={
|
||||
onToggle && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={effect.enabled ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
aria-label={`Toggle ${definition.name}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove ${definition.name}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<SectionTitle
|
||||
className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
|
||||
>
|
||||
{definition.name}
|
||||
</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent
|
||||
className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
|
||||
>
|
||||
<SectionFields>
|
||||
{definition.params.map((param) => (
|
||||
<div key={param.key} className="flex flex-col gap-3.5">
|
||||
<div className="px-4">
|
||||
<PropertyParamField
|
||||
param={param}
|
||||
value={renderParams[param.key] ?? param.default}
|
||||
onPreview={previewParam(param.key)}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type { Effect } from "@/lib/effects/types";
|
||||
import type { EffectElement, VisualElement } from "@/lib/timeline";
|
||||
import { effectsRegistry } from "@/lib/effects";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
SectionFields,
|
||||
} from "@/components/section";
|
||||
import { PropertyParamField } from "../components/property-param-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
Delete02Icon,
|
||||
ViewIcon,
|
||||
ViewOffSlashIcon,
|
||||
MagicWand05Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
|
||||
export function StandaloneEffectTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: EffectElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const { renderElement, previewUpdates, commit } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const effect: Effect = {
|
||||
id: element.id,
|
||||
type: element.effectType,
|
||||
params: element.params,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const previewParam = (key: string) => (value: number | string | boolean) => {
|
||||
previewUpdates({
|
||||
params: { ...(renderElement as EffectElement).params, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
|
||||
<SectionTitle>Effect</SectionTitle>
|
||||
</div>
|
||||
<EffectSection
|
||||
effect={effect}
|
||||
renderParams={(renderElement as EffectElement).params}
|
||||
previewParam={previewParam}
|
||||
onCommit={commit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClipEffectsTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
const editor = useEditor();
|
||||
const { renderElement, previewUpdates, commit } = useElementPreview({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fallback: element,
|
||||
});
|
||||
|
||||
const effects: Effect[] = element.effects ?? [];
|
||||
|
||||
const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
|
||||
return (
|
||||
(renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
|
||||
?.params ??
|
||||
effects.find((ef) => ef.id === effectId)?.params ??
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
const buildPreviewParam =
|
||||
(effectId: string) =>
|
||||
(key: string) =>
|
||||
(value: number | string | boolean) => {
|
||||
const updatedEffects = (
|
||||
(renderElement as VisualElement).effects ?? []
|
||||
).map((existing) =>
|
||||
existing.id !== effectId
|
||||
? existing
|
||||
: { ...existing, params: { ...existing.params, [key]: value } },
|
||||
);
|
||||
previewUpdates({ effects: updatedEffects });
|
||||
};
|
||||
|
||||
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 flex-col h-full">
|
||||
<div className="border-b px-3.5 h-11 shrink-0 flex items-center">
|
||||
<SectionTitle>Effects</SectionTitle>
|
||||
</div>
|
||||
{effects.length === 0 ? (
|
||||
<EmptyView />
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{effects.map((effect, index) => {
|
||||
const resolvedDragIndex = dragIndex ?? -1;
|
||||
const isDragging = dragIndex === index;
|
||||
const isDropTarget =
|
||||
dropIndex === index && dragIndex !== null && dragIndex !== index;
|
||||
const showTopDropIndicator =
|
||||
isDropTarget && index < resolvedDragIndex;
|
||||
const showBottomDropIndicator =
|
||||
isDropTarget && index > resolvedDragIndex;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={effect.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart({ index })}
|
||||
onDragOver={(event) => handleDragOver({ event, index })}
|
||||
onDrop={() => handleDrop({ toIndex: index })}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
"group list-none",
|
||||
isDragging && "opacity-40",
|
||||
showTopDropIndicator && "border-t-2 border-primary",
|
||||
showBottomDropIndicator && "border-b-2 border-primary",
|
||||
)}
|
||||
>
|
||||
<EffectSection
|
||||
effect={effect}
|
||||
renderParams={getRenderParams({ effectId: effect.id })}
|
||||
previewParam={buildPreviewParam(effect.id)}
|
||||
onCommit={commit}
|
||||
onToggle={() =>
|
||||
editor.timeline.toggleClipEffect({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
effectId: effect.id,
|
||||
})
|
||||
}
|
||||
onRemove={() =>
|
||||
editor.timeline.removeClipEffect({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
effectId: effect.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyView() {
|
||||
const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-center gap-4 text-center">
|
||||
<HugeiconsIcon
|
||||
icon={MagicWand05Icon}
|
||||
className="size-10 text-muted-foreground"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="font-medium text-foreground">No effects</h3>
|
||||
<p className="text-muted-foreground text-sm text-balance max-w-44">
|
||||
Add effects to this layer from the Assets panel.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setActiveTab("effects")}
|
||||
>
|
||||
Open effects
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectSection({
|
||||
effect,
|
||||
renderParams,
|
||||
previewParam,
|
||||
onCommit,
|
||||
onToggle,
|
||||
onRemove,
|
||||
}: {
|
||||
effect: Effect;
|
||||
renderParams: ParamValues;
|
||||
previewParam: (key: string) => (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
onToggle?: () => void;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
const definition = effectsRegistry.get(effect.type);
|
||||
|
||||
return (
|
||||
<Section
|
||||
sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
|
||||
showTopBorder={false}
|
||||
>
|
||||
<SectionHeader
|
||||
className={cn(onToggle && "cursor-move")}
|
||||
trailing={
|
||||
onToggle && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={effect.enabled ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
aria-label={`Toggle ${definition.name}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove ${definition.name}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<SectionTitle
|
||||
className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
|
||||
>
|
||||
{definition.name}
|
||||
</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent
|
||||
className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
|
||||
>
|
||||
<SectionFields>
|
||||
{definition.params.map((param) => (
|
||||
<div key={param.key} className="flex flex-col gap-3.5">
|
||||
<div className="px-4">
|
||||
<PropertyParamField
|
||||
param={param}
|
||||
value={renderParams[param.key] ?? param.default}
|
||||
onPreview={previewParam(param.key)}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
+27
-27
@@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import {
|
||||
useKeyframedParamProperty,
|
||||
type KeyframedParamPropertyResult,
|
||||
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
||||
import { resolveGraphicParamsAtTime } from "@/animation";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
import type { GraphicElement } from "@/timeline";
|
||||
import { graphicsRegistry, registerDefaultGraphics } from "@/graphics";
|
||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
} from "../hooks/use-keyframed-param-property";
|
||||
import { resolveGraphicParamsAtTime } from "@/lib/animation";
|
||||
import type { ParamDefinition, ParamValues } from "@/lib/params";
|
||||
import type { GraphicElement } from "@/lib/timeline";
|
||||
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
|
||||
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
|
||||
import { PropertyParamField } from "../components/property-param-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
@@ -78,7 +78,9 @@ export function GraphicTab({
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
|
||||
{hasStrokeParams && (
|
||||
<StrokeSection element={element} trackId={trackId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -204,23 +206,21 @@ function AnimatedGraphicParamField({
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedParams: ParamValues;
|
||||
}) {
|
||||
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
|
||||
{
|
||||
param,
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedParams[param.key] ?? param.default,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: value,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({
|
||||
param,
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedParams[param.key] ?? param.default,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<PropertyParamField
|
||||
+20
-139
@@ -1,26 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import type { MaskableElement } from "@/timeline";
|
||||
import type { Mask, MaskType, TextMask } from "@/masks/types";
|
||||
import type { NumberParamDefinition, SelectParamDefinition } from "@/params";
|
||||
import { masksRegistry, buildDefaultMaskInstance } from "@/masks";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||
import { useMenuPreview } from "@/editor/use-menu-preview";
|
||||
import { getVisibleElementsWithBounds } from "@/preview/element-bounds";
|
||||
import type { MaskableElement } from "@/lib/timeline";
|
||||
import type { Mask, MaskType } from "@/lib/masks/types";
|
||||
import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params";
|
||||
import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||
import { useMenuPreview } from "@/hooks/use-menu-preview";
|
||||
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowExpandIcon,
|
||||
Delete02Icon,
|
||||
FeatherIcon,
|
||||
PlusSignIcon,
|
||||
RotateClockwiseIcon,
|
||||
TextFontIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { FontPicker } from "@/components/ui/font-picker";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -35,7 +32,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -55,13 +51,8 @@ import {
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
|
||||
import {
|
||||
OcMirrorIcon,
|
||||
OcShapesIcon,
|
||||
OcTextHeightIcon,
|
||||
OcTextWidthIcon,
|
||||
} from "@/components/icons";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
type MasksTabProps = {
|
||||
@@ -87,10 +78,6 @@ type PreviewParamHandler = (
|
||||
|
||||
type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
|
||||
|
||||
function isTextMask(mask: Mask): mask is TextMask {
|
||||
return mask.type === "text";
|
||||
}
|
||||
|
||||
export function MasksTab({ element, trackId }: MasksTabProps) {
|
||||
const editor = useEditor();
|
||||
const { renderElement, previewUpdates, commit } =
|
||||
@@ -383,23 +370,12 @@ function MaskParamsFields({
|
||||
previewParam(key)(value);
|
||||
const previewStrokeColor = previewParam("strokeColor");
|
||||
const strokeAlignParam = definition.params.find(
|
||||
(param): param is SelectParamDefinition<string> =>
|
||||
(param): param is SelectParamDefinition =>
|
||||
param.key === "strokeAlign" && param.type === "select",
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionFields>
|
||||
{isTextMask(mask) ? (
|
||||
<TextMaskFields
|
||||
mask={mask}
|
||||
previewParam={previewParam}
|
||||
onCommit={onCommit}
|
||||
fontSizeParam={getNumberParamDefinition({
|
||||
definition,
|
||||
key: "fontSize",
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
{definition.features.hasPosition &&
|
||||
"centerX" in mask.params &&
|
||||
"centerY" in mask.params && (
|
||||
@@ -515,9 +491,7 @@ function MaskParamsFields({
|
||||
{definition.features.sizeMode === "uniform" && "scale" in mask.params && (
|
||||
<SectionField label="Scale">
|
||||
<MaskNumberField
|
||||
icon={
|
||||
isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S"
|
||||
}
|
||||
icon="S"
|
||||
param={getNumberParamDefinition({
|
||||
definition,
|
||||
key: "scale",
|
||||
@@ -613,100 +587,6 @@ function MaskParamsFields({
|
||||
);
|
||||
}
|
||||
|
||||
const LETTER_SPACING_PARAM: NumberParamDefinition = {
|
||||
key: "letterSpacing",
|
||||
label: "Letter spacing",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: -100,
|
||||
max: 500,
|
||||
step: 1,
|
||||
};
|
||||
|
||||
const LINE_HEIGHT_PARAM: NumberParamDefinition = {
|
||||
key: "lineHeight",
|
||||
label: "Line height",
|
||||
type: "number",
|
||||
default: 1.2,
|
||||
min: 0.1,
|
||||
max: 10,
|
||||
step: 0.1,
|
||||
};
|
||||
|
||||
function TextMaskFields({
|
||||
mask,
|
||||
previewParam,
|
||||
onCommit,
|
||||
fontSizeParam,
|
||||
}: {
|
||||
mask: TextMask;
|
||||
previewParam: PreviewParamHandler;
|
||||
onCommit: () => void;
|
||||
fontSizeParam: NumberParamDefinition;
|
||||
}) {
|
||||
const content = usePropertyDraft({
|
||||
displayValue: mask.params.content,
|
||||
parse: (input) => input,
|
||||
onPreview: (value) => previewParam("content")(value),
|
||||
onCommit,
|
||||
});
|
||||
|
||||
const previewNumberParam = (key: string) => (value: number) =>
|
||||
previewParam(key)(value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionField label="Content">
|
||||
<Textarea
|
||||
value={content.displayValue}
|
||||
className="min-h-20"
|
||||
onFocus={content.onFocus}
|
||||
onChange={content.onChange}
|
||||
onBlur={content.onBlur}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Font">
|
||||
<FontPicker
|
||||
defaultValue={mask.params.fontFamily}
|
||||
onValueChange={(value) => {
|
||||
previewParam("fontFamily")(value);
|
||||
onCommit();
|
||||
}}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Size">
|
||||
<MaskNumberField
|
||||
icon={<HugeiconsIcon icon={TextFontIcon} />}
|
||||
param={fontSizeParam}
|
||||
value={mask.params.fontSize}
|
||||
onPreview={previewNumberParam("fontSize")}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Spacing">
|
||||
<div className="flex items-start gap-2">
|
||||
<MaskNumberField
|
||||
className="w-1/2"
|
||||
icon={<OcTextWidthIcon size={14} />}
|
||||
param={LETTER_SPACING_PARAM}
|
||||
value={mask.params.letterSpacing ?? 0}
|
||||
onPreview={previewNumberParam("letterSpacing")}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
<MaskNumberField
|
||||
className="w-1/2"
|
||||
icon={<OcTextHeightIcon size={14} />}
|
||||
param={LINE_HEIGHT_PARAM}
|
||||
value={mask.params.lineHeight ?? 1.2}
|
||||
onPreview={previewNumberParam("lineHeight")}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
</div>
|
||||
</SectionField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getNumberParamDefinition({
|
||||
definition,
|
||||
key,
|
||||
@@ -723,10 +603,13 @@ function getNumberParamDefinition({
|
||||
return param;
|
||||
}
|
||||
|
||||
function getMaskNumber<
|
||||
TParams extends Mask["params"],
|
||||
TKey extends keyof TParams & string,
|
||||
>({ params, key }: { params: TParams; key: TKey }): number {
|
||||
function getMaskNumber({
|
||||
params,
|
||||
key,
|
||||
}: {
|
||||
params: Mask["params"];
|
||||
key: string;
|
||||
}): number {
|
||||
const value = params[key];
|
||||
|
||||
if (typeof value !== "number") {
|
||||
@@ -782,9 +665,7 @@ function MaskNumberField({
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return (
|
||||
clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
|
||||
);
|
||||
return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier;
|
||||
},
|
||||
onPreview,
|
||||
onCommit,
|
||||
+5
-5
@@ -1,18 +1,18 @@
|
||||
import { useRef } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons";
|
||||
import { buildConstantRetime } from "@/retime";
|
||||
import { buildConstantRetime } from "@/lib/retime";
|
||||
import {
|
||||
DEFAULT_RETIME_RATE,
|
||||
MIN_RETIME_RATE,
|
||||
MAX_RETIME_RATE,
|
||||
clampRetimeRate,
|
||||
canMaintainPitch,
|
||||
} from "@/retime/rate";
|
||||
import type { AudioElement, VideoElement } from "@/timeline";
|
||||
} from "@/lib/retime/rate";
|
||||
import type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import {
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
+15
-12
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FontPicker } from "@/components/ui/font-picker";
|
||||
import type { TextElement } from "@/timeline";
|
||||
import type { TextElement } from "@/lib/timeline";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
@@ -17,20 +17,23 @@ import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp, formatNumberForDisplay } from "@/utils/math";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "@/text/background";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/lib/text/background";
|
||||
import {
|
||||
DEFAULT_TEXT_COLOR,
|
||||
MAX_FONT_SIZE,
|
||||
MIN_FONT_SIZE,
|
||||
} from "@/text/typography";
|
||||
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
|
||||
import { useKeyframedColorProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-color-property";
|
||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import { isPropertyAtDefault } from "@/rendering/components/transform-tab";
|
||||
import { resolveColorAtTime, resolveNumberAtTime } from "@/animation";
|
||||
} from "@/lib/text/typography";
|
||||
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 { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
import { isPropertyAtDefault } from "./transform-tab";
|
||||
import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
MinusSignIcon,
|
||||
@@ -38,7 +41,7 @@ import {
|
||||
TextFontIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export function TextTab({
|
||||
+8
-8
@@ -1,7 +1,7 @@
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp, isNearlyEqual } from "@/utils/math";
|
||||
import type { VisualElement } from "@/timeline";
|
||||
import type { VisualElement } from "@/lib/timeline";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
@@ -21,12 +21,12 @@ import {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/animation";
|
||||
import { DEFAULTS } from "@/timeline/defaults";
|
||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
||||
import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
|
||||
} from "@/lib/animation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { usePropertiesStore } from "../stores/properties-store";
|
||||
|
||||
export function parseNumericInput({ input }: { input: string }): number | null {
|
||||
const parsed = parseFloat(input);
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useResizeObserver } from "@/hooks/use-resize-observer";
|
||||
import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio";
|
||||
import { findScrollParent } from "@/utils/browser";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const BAR_WIDTH = 2;
|
||||
const BAR_GAP = 1;
|
||||
const BAR_STEP = BAR_WIDTH + BAR_GAP;
|
||||
interface AudioWaveformProps {
|
||||
audioUrl?: string;
|
||||
audioBuffer?: AudioBuffer;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AudioWaveform({
|
||||
audioUrl,
|
||||
audioBuffer,
|
||||
color = "rgba(255, 255, 255, 0.7)",
|
||||
className = "",
|
||||
}: AudioWaveformProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bufferRef = useRef<AudioBuffer | null>(null);
|
||||
const globalMaxRef = useRef<number>(1);
|
||||
const scrollParentRef = useRef<HTMLElement | null>(null);
|
||||
const heightRef = useRef<number>(0);
|
||||
|
||||
const drawVisible = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const buffer = bufferRef.current;
|
||||
const height = heightRef.current;
|
||||
|
||||
if (!container || !canvas || !buffer || height <= 0) return;
|
||||
|
||||
const elementWidth = container.offsetWidth;
|
||||
if (elementWidth <= 0) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scrollParent = scrollParentRef.current;
|
||||
|
||||
let clipLeft: number;
|
||||
let clipRight: number;
|
||||
|
||||
if (scrollParent) {
|
||||
const parentRect = scrollParent.getBoundingClientRect();
|
||||
clipLeft = Math.max(0, parentRect.left - containerRect.left);
|
||||
clipRight = Math.min(elementWidth, parentRect.right - containerRect.left);
|
||||
} else {
|
||||
clipLeft = Math.max(0, -containerRect.left);
|
||||
clipRight = Math.min(
|
||||
elementWidth,
|
||||
window.innerWidth - containerRect.left,
|
||||
);
|
||||
}
|
||||
|
||||
const visibleWidth = clipRight - clipLeft;
|
||||
if (visibleWidth <= 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const canvasW = Math.round(visibleWidth * dpr);
|
||||
const canvasH = Math.round(height * dpr);
|
||||
|
||||
if (canvasW <= 0 || canvasH <= 0) return;
|
||||
|
||||
canvas.width = canvasW;
|
||||
canvas.height = canvasH;
|
||||
canvas.style.width = `${visibleWidth}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
canvas.style.left = `${clipLeft}px`;
|
||||
|
||||
const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
|
||||
const startFraction = clipLeft / elementWidth;
|
||||
const endFraction = clipRight / elementWidth;
|
||||
const startSample = Math.floor(startFraction * buffer.length);
|
||||
const endSample = Math.min(
|
||||
buffer.length,
|
||||
Math.ceil(endFraction * buffer.length),
|
||||
);
|
||||
|
||||
const peaks = extractRmsRange({
|
||||
buffer,
|
||||
count: barCount,
|
||||
startSample,
|
||||
endSample,
|
||||
globalMax: globalMaxRef.current,
|
||||
});
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvasW, canvasH);
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = color;
|
||||
|
||||
const maxBarHeight = height * 0.7;
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
|
||||
const barH = Math.max(1, scaled * maxBarHeight);
|
||||
ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
|
||||
}
|
||||
}, [color]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
async function load() {
|
||||
let buffer = audioBuffer ?? null;
|
||||
|
||||
if (!buffer && audioUrl) {
|
||||
try {
|
||||
const resp = await fetch(audioUrl);
|
||||
const arrayBuffer = await resp.arrayBuffer();
|
||||
const actx = new AudioContext();
|
||||
buffer = await actx.decodeAudioData(arrayBuffer);
|
||||
actx.close();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!buffer || isCancelled) return;
|
||||
|
||||
bufferRef.current = buffer;
|
||||
globalMaxRef.current = computeGlobalMaxRms({ buffer });
|
||||
drawVisible();
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [audioUrl, audioBuffer, drawVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
scrollParentRef.current = findScrollParent({ element: container });
|
||||
const scrollParent = scrollParentRef.current;
|
||||
if (!scrollParent) return;
|
||||
|
||||
scrollParent.addEventListener("scroll", drawVisible, { passive: true });
|
||||
return () => scrollParent.removeEventListener("scroll", drawVisible);
|
||||
}, [drawVisible]);
|
||||
|
||||
const onResize = useCallback(
|
||||
(entry: ResizeObserverEntry) => {
|
||||
heightRef.current = entry.contentRect.height;
|
||||
drawVisible();
|
||||
},
|
||||
[drawVisible],
|
||||
);
|
||||
|
||||
useResizeObserver({ ref: containerRef, onResize });
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative size-full", className)}>
|
||||
<canvas ref={canvasRef} className="absolute bottom-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
-11
@@ -2,18 +2,20 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { EditorCore } from "@/core";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { BookmarkDragState } from "../hooks/use-bookmark-drag";
|
||||
import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme";
|
||||
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout";
|
||||
import { DEFAULT_FPS } from "@/fps/defaults";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
|
||||
import {
|
||||
DEFAULT_TIMELINE_BOOKMARK_COLOR,
|
||||
} from "./theme";
|
||||
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
|
||||
import { DEFAULT_FPS } from "@/lib/fps/defaults";
|
||||
import { snappedSeekTime } from "opencut-wasm";
|
||||
import {
|
||||
ArrowTurnBackwardIcon,
|
||||
Delete02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { Bookmark } from "@/timeline";
|
||||
import type { Bookmark } from "@/lib/timeline";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
@@ -25,7 +27,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp, formatNumberForDisplay } from "@/utils/math";
|
||||
import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline";
|
||||
import {
|
||||
timelineTimeToPixels,
|
||||
timelineTimeToSnappedPixels,
|
||||
} from "@/lib/timeline";
|
||||
|
||||
const MIN_BOOKMARK_WIDTH_PX = 2;
|
||||
const BOOKMARK_MARKER_WIDTH_PX = 12;
|
||||
@@ -355,10 +360,7 @@ function BookmarkPopoverContent({
|
||||
/>
|
||||
{bookmark.color &&
|
||||
bookmark.color.replace(/^#/, "").toUpperCase() !==
|
||||
DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(
|
||||
/^#/,
|
||||
"",
|
||||
).toUpperCase() && (
|
||||
DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { getDropLineY } from "./drop-target";
|
||||
import type { TimelineTrack, DropTarget } from "@/timeline";
|
||||
import type { TimelineTrack, DropTarget } from "@/lib/timeline";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
|
||||
interface DragLineProps {
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import type { TimelineTrack, TimelineElement } from "@/timeline";
|
||||
import type { ComputeDropTargetParams, DropTarget } from "@/timeline";
|
||||
import { resolveTrackPlacement } from "@/timeline/placement";
|
||||
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
|
||||
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
|
||||
import { resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
import { TIMELINE_TRACK_GAP_PX } from "./layout";
|
||||
import { getTrackHeight } from "./track-layout";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||
|
||||
function findElementAtPosition({
|
||||
mouseX,
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import type {
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import type { TimelineTrack } from "@/timeline";
|
||||
import { getElementKeyframes } from "@/animation";
|
||||
} from "@/lib/animation/types";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { getElementKeyframes } from "@/lib/animation";
|
||||
import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
|
||||
|
||||
export interface ExpandedRow {
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useRef, useState, type PointerEvent } from "react";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { getBezierPoint } from "@/animation/bezier";
|
||||
import type { NormalizedCubicBezier } from "@/animation/types";
|
||||
import { getBezierPoint } from "@/lib/animation/bezier";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const GRAPH_WIDTH = 140;
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import type { NormalizedCubicBezier } from "@/animation/types";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
import type { EasingPreset } from "./easing-presets";
|
||||
|
||||
const STORAGE_KEY = "graph-editor-presets";
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { NormalizedCubicBezier } from "@/animation/types";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
|
||||
export const PRESET_MATCH_TOLERANCE = 0.02;
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ import {
|
||||
Delete02Icon,
|
||||
PlusSignIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { getBezierPoint } from "@/animation/bezier";
|
||||
import type { NormalizedCubicBezier } from "@/animation/types";
|
||||
import { getBezierPoint } from "@/lib/animation/bezier";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
import type { GraphEditorComponentOption } from "./session";
|
||||
import {
|
||||
BUILTIN_PRESETS,
|
||||
+3
-3
@@ -5,7 +5,7 @@ import {
|
||||
getNormalizedCubicBezierForScalarSegment,
|
||||
getScalarKeyframeContext,
|
||||
updateScalarKeyframeCurve,
|
||||
} from "@/animation";
|
||||
} from "@/lib/animation";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
@@ -13,8 +13,8 @@ import type {
|
||||
ScalarCurveKeyframePatch,
|
||||
ScalarGraphKeyframeContext,
|
||||
SelectedKeyframeRef,
|
||||
} from "@/animation/types";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
} from "@/lib/animation/types";
|
||||
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||
|
||||
const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
|
||||
const FLAT_VALUE_EPSILON = 1e-6;
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||
import type { NormalizedCubicBezier } from "@/animation/types";
|
||||
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { registerCanceller } from "@/lib/cancel-interaction";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
|
||||
import {
|
||||
applyGraphEditorCurvePreview,
|
||||
buildGraphEditorCurvePatches,
|
||||
+50
-49
@@ -20,7 +20,7 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { useTimelineZoom } from "@/timeline/hooks/use-timeline-zoom";
|
||||
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -29,14 +29,14 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { ElementDragState, DropTarget } from "@/timeline";
|
||||
import type { ElementDragState, DropTarget } from "@/lib/timeline";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import { TimelinePlayhead } from "./timeline-playhead";
|
||||
import { SelectionBox } from "@/selection/selection-box";
|
||||
import { useBoxSelect } from "@/selection/hooks/use-box-select";
|
||||
import { SelectionBox } from "@/lib/selection/selection-box";
|
||||
import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
|
||||
import { SnapIndicator } from "./snap-indicator";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
import type { TimelineTrack } from "@/timeline";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import {
|
||||
TIMELINE_SCROLLBAR_SIZE_PX,
|
||||
TIMELINE_CONTENT_TOP_PADDING_PX,
|
||||
@@ -44,14 +44,14 @@ import {
|
||||
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
|
||||
KEYFRAME_LANE_HEIGHT_PX,
|
||||
} from "./layout";
|
||||
import { useElementInteraction } from "@/timeline/hooks/element/use-element-interaction";
|
||||
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
|
||||
import {
|
||||
canTrackHaveAudio,
|
||||
canTrackBeHidden,
|
||||
getTimelineZoomMin,
|
||||
getTimelinePaddingPx,
|
||||
} from "@/timeline";
|
||||
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
|
||||
} from "@/lib/timeline";
|
||||
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
|
||||
import {
|
||||
getTrackHeight,
|
||||
getCumulativeHeightBefore,
|
||||
@@ -61,27 +61,25 @@ import { SELECTED_TRACK_ROW_CLASS } from "./theme";
|
||||
import {
|
||||
computeTrackExpansionHeight,
|
||||
getTrackExpandedRows,
|
||||
getExpansionHeight,
|
||||
getPropertyLabel,
|
||||
type ExpandedRow,
|
||||
} from "./expanded-layout";
|
||||
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
|
||||
import { TimelineToolbar } from "./timeline-toolbar";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import { useTimelineSeek } from "@/timeline/hooks/use-timeline-seek";
|
||||
import { useTimelineDragDrop } from "@/timeline/hooks/use-timeline-drag-drop";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
||||
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
|
||||
import { TimelineRuler } from "./timeline-ruler";
|
||||
import {
|
||||
TimelineBookmarksRow,
|
||||
useBookmarkDrag,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
|
||||
import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bottom";
|
||||
import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize";
|
||||
import { useTimelineStore } from "@/timeline/timeline-store";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
|
||||
import { TimelineBookmarksRow } from "./bookmarks";
|
||||
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
|
||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
import { DragLine } from "./drag-line";
|
||||
import { invokeAction } from "@/actions";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { resolveTimelineElementIntersections } from "./selection-hit-testing";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
@@ -121,9 +119,7 @@ export function Timeline() {
|
||||
} = useElementSelection();
|
||||
const editor = useEditor();
|
||||
const timeline = editor.timeline;
|
||||
const scene = useEditor((currentEditor) =>
|
||||
currentEditor.scenes.getActiveSceneOrNull(),
|
||||
);
|
||||
const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull());
|
||||
const tracks = useMemo<TimelineTrack[]>(
|
||||
() =>
|
||||
scene
|
||||
@@ -144,6 +140,7 @@ export function Timeline() {
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
|
||||
null,
|
||||
);
|
||||
@@ -151,6 +148,15 @@ export function Timeline() {
|
||||
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
|
||||
setCurrentSnapPoint(snapPoint);
|
||||
}, []);
|
||||
const handleResizeStateChange = useCallback(
|
||||
({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
|
||||
setIsResizing(nextIsResizing);
|
||||
if (!nextIsResizing) {
|
||||
setCurrentSnapPoint(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const timelineDuration = timeline.getTotalDuration() || 0;
|
||||
const minZoomLevel = getTimelineZoomMin({
|
||||
@@ -170,10 +176,6 @@ export function Timeline() {
|
||||
tracksScrollRef,
|
||||
rulerScrollRef,
|
||||
});
|
||||
const { isResizing, handleResizeStart } = useTimelineResize({
|
||||
zoomLevel,
|
||||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
|
||||
const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
|
||||
|
||||
@@ -364,10 +366,7 @@ export function Timeline() {
|
||||
|
||||
const containerWidth =
|
||||
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
|
||||
const contentWidth = timelineTimeToPixels({
|
||||
time: timelineDuration,
|
||||
zoomLevel,
|
||||
});
|
||||
const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel });
|
||||
const paddingPx = getTimelinePaddingPx({
|
||||
containerWidth,
|
||||
zoomLevel,
|
||||
@@ -514,10 +513,7 @@ export function Timeline() {
|
||||
TRACKS_CONTAINER_HEIGHT.min,
|
||||
Math.min(
|
||||
TRACKS_CONTAINER_HEIGHT.max,
|
||||
getTotalTracksHeight({
|
||||
tracks,
|
||||
getExtraHeight: getTrackExpansionHeight,
|
||||
}),
|
||||
getTotalTracksHeight({ tracks, getExtraHeight: getTrackExpansionHeight }),
|
||||
),
|
||||
) + TIMELINE_CONTENT_TOP_PADDING_PX
|
||||
}px`,
|
||||
@@ -537,13 +533,14 @@ export function Timeline() {
|
||||
}}
|
||||
>
|
||||
{tracks.length > 0 && (
|
||||
<TimelineTrackRows
|
||||
mainTrackId={mainTrackId}
|
||||
<TimelineTrackRows
|
||||
mainTrackId={mainTrackId}
|
||||
zoomLevel={zoomLevel}
|
||||
dragState={dragState}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
lastMouseXRef={lastMouseXRef}
|
||||
onResizeStart={handleResizeStart}
|
||||
onSnapPointChange={handleSnapPointChange}
|
||||
onResizeStateChange={handleResizeStateChange}
|
||||
onElementMouseDown={handleElementMouseDown}
|
||||
onElementClick={handleElementClick}
|
||||
onTrackMouseDown={(event) => {
|
||||
@@ -721,7 +718,8 @@ function TimelineTrackRows({
|
||||
dragState,
|
||||
tracksScrollRef,
|
||||
lastMouseXRef,
|
||||
onResizeStart,
|
||||
onSnapPointChange,
|
||||
onResizeStateChange,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
onTrackMouseDown,
|
||||
@@ -735,9 +733,8 @@ function TimelineTrackRows({
|
||||
dragState: ElementDragState;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
lastMouseXRef: React.RefObject<number>;
|
||||
onResizeStart: React.ComponentProps<
|
||||
typeof TimelineTrackContent
|
||||
>["onResizeStart"];
|
||||
onSnapPointChange: (snapPoint: SnapPoint | null) => void;
|
||||
onResizeStateChange: (params: { isResizing: boolean }) => void;
|
||||
onElementMouseDown: React.ComponentProps<
|
||||
typeof TimelineTrackContent
|
||||
>["onElementMouseDown"];
|
||||
@@ -801,7 +798,8 @@ function TimelineTrackRows({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 left-0 transition-colors",
|
||||
tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS,
|
||||
tracksWithSelection.has(track.id) &&
|
||||
SELECTED_TRACK_ROW_CLASS,
|
||||
)}
|
||||
style={{
|
||||
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
|
||||
@@ -815,7 +813,8 @@ function TimelineTrackRows({
|
||||
rulerScrollRef={tracksScrollRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
lastMouseXRef={lastMouseXRef}
|
||||
onResizeStart={onResizeStart}
|
||||
onSnapPointChange={onSnapPointChange}
|
||||
onResizeStateChange={onResizeStateChange}
|
||||
onElementMouseDown={onElementMouseDown}
|
||||
onElementClick={onElementClick}
|
||||
onTrackMouseDown={onTrackMouseDown}
|
||||
@@ -930,10 +929,12 @@ function TrackToggleIcon({
|
||||
function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
{rows.map((row) => (
|
||||
{rows.map((row, index) => (
|
||||
<div
|
||||
key={row.propertyPath}
|
||||
className={cn("flex shrink-0 items-center px-3 bg-muted/50")}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center px-3 bg-muted/50",
|
||||
)}
|
||||
style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
|
||||
>
|
||||
<span className="text-muted-foreground truncate text-xs leading-none">
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { TrackType } from "@/timeline";
|
||||
import type { TrackType } from "@/lib/timeline";
|
||||
|
||||
export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
|
||||
video: 65,
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { TimelineTrack } from "@/timeline";
|
||||
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
|
||||
import {
|
||||
TIMELINE_CONTENT_TOP_PADDING_PX,
|
||||
} from "./layout";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user