summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/export-button.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
commite7dcb586c0408255a0bf788e355fdb6d50d9b923 (patch)
tree74ff0fc0f1ba501a075342db9b4e454450926154 /apps/web/src/components/editor/export-button.tsx
parent93bea01c9ed3fcbebc64c9cef382a51d24c925ed (diff)
downloadopencut-e7dcb586c0408255a0bf788e355fdb6d50d9b923.tar.gz
feat: Clip effects, asset sorting, and timeline improvements
Major features and improvements: * **Clip Effects**: * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder). * Implemented dynamic parameter fields for effects. * Added support for keyframing effect parameters. * **Assets Panel**: * Added sorting options: Name, Type, Duration, and File Size. * Persisted view preferences (grid/list mode, sort order) to local storage. * Refactored media item rendering and drag interactions. * **Timeline & Interaction**: * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element. * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps). * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline. * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key. * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled. * **Text Elements**: * Refactored text background storage to use an explicit `enabled` flag. * Added `V8toV9` storage migration to update existing projects. * **Architecture**: * Moved export state management to `ProjectManager` for better lifecycle handling. * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
Diffstat (limited to 'apps/web/src/components/editor/export-button.tsx')
-rw-r--r--apps/web/src/components/editor/export-button.tsx136
1 files changed, 62 insertions, 74 deletions
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index bc44e650..ddf8308d 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useRef } from "react";
+import { useState } from "react";
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -14,35 +14,47 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/utils/ui";
-import { getExportMimeType, getExportFileExtension } from "@/lib/export";
+import { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export";
import { Check, Copy, Download, RotateCcw } from "lucide-react";
import {
EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
type ExportFormat,
type ExportQuality,
- type ExportResult,
} from "@/types/export";
import {
Section,
SectionContent,
SectionHeader,
+ SectionTitle,
} from "@/components/editor/panels/properties/section";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
+function isExportFormat(value: string): value is ExportFormat {
+ return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
+}
+
+function isExportQuality(value: string): value is ExportQuality {
+ return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
+}
+
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
const editor = useEditor();
- const handleExport = () => {
- setIsExportPopoverOpen(true);
- };
+ const hasProject = !!editor.project.getActiveOrNull();
- const hasProject = !!editor.project.getActive();
+ const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
+ if (!open) {
+ editor.project.cancelExport();
+ editor.project.clearExportState();
+ }
+ setIsExportPopoverOpen(open);
+ };
return (
- <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
+ <Popover open={isExportPopoverOpen} onOpenChange={(open) => handlePopoverOpenChange({ open })}>
<PopoverTrigger asChild>
<button
type="button"
@@ -50,12 +62,12 @@ export function ExportButton() {
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
)}
- onClick={hasProject ? handleExport : undefined}
+ onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined}
disabled={!hasProject}
onKeyDown={(event) => {
if (hasProject && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
- handleExport();
+ setIsExportPopoverOpen(true);
}
}}
>
@@ -80,71 +92,49 @@ function ExportPopover({
}) {
const editor = useEditor();
const activeProject = editor.project.getActive();
+ const { isExporting, progress, result: exportResult } =
+ editor.project.getExportState();
const [format, setFormat] = useState<ExportFormat>(
DEFAULT_EXPORT_OPTIONS.format,
);
const [quality, setQuality] = useState<ExportQuality>(
DEFAULT_EXPORT_OPTIONS.quality,
);
- const [includeAudio, setIncludeAudio] = useState<boolean>(
- DEFAULT_EXPORT_OPTIONS.includeAudio || true,
+ const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>(
+ DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
);
- const [isExporting, setIsExporting] = useState(false);
- const [progress, setProgress] = useState(0);
- const [exportResult, setExportResult] = useState<ExportResult | null>(null);
- const cancelRequestedRef = useRef(false);
const handleExport = async () => {
if (!activeProject) return;
- cancelRequestedRef.current = false;
- setIsExporting(true);
- setProgress(0);
- setExportResult(null);
-
const result = await editor.project.export({
options: {
- format,
- quality,
- fps: activeProject.settings.fps,
- includeAudio,
- onProgress: ({ progress }) => setProgress(progress),
- onCancel: () => cancelRequestedRef.current,
+ format,
+ quality,
+ fps: activeProject.settings.fps,
+ includeAudio: shouldIncludeAudio,
},
});
- setIsExporting(false);
-
if (result.cancelled) {
- setExportResult(null);
- setProgress(0);
+ editor.project.clearExportState();
return;
}
- setExportResult(result);
-
if (result.success && result.buffer) {
- const mimeType = getExportMimeType({ format });
- const extension = getExportFileExtension({ format });
- const blob = new Blob([result.buffer], { type: mimeType });
- const url = URL.createObjectURL(blob);
-
- const a = document.createElement("a");
- a.href = url;
- a.download = `${activeProject.metadata.name}${extension}`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
+ downloadBuffer({
+ buffer: result.buffer,
+ filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
+ mimeType: getExportMimeType({ format }),
+ });
+ editor.project.clearExportState();
onOpenChange(false);
- setExportResult(null);
- setProgress(0);
}
};
const handleCancel = () => {
- cancelRequestedRef.current = true;
+ editor.project.cancelExport();
};
return (
@@ -166,8 +156,10 @@ function ExportPopover({
{!isExporting && (
<>
<div className="flex flex-col">
- <Section collapsible defaultOpen={false} hasBorderTop={false}>
- <SectionHeader title="Format" />
+ <Section collapsible defaultOpen={false} showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle>Format</SectionTitle>
+ </SectionHeader>
<SectionContent>
<RadioGroup
value={format}
@@ -194,7 +186,9 @@ function ExportPopover({
</Section>
<Section collapsible defaultOpen={false}>
- <SectionHeader title="Quality" />
+ <SectionHeader>
+ <SectionTitle>Quality</SectionTitle>
+ </SectionHeader>
<SectionContent>
<RadioGroup
value={quality}
@@ -219,7 +213,7 @@ function ExportPopover({
<div className="flex items-center space-x-2">
<RadioGroupItem value="very_high" id="very_high" />
<Label htmlFor="very_high">
- Very High - Largest file size
+ Very high - Largest file size
</Label>
</div>
</RadioGroup>
@@ -227,15 +221,17 @@ function ExportPopover({
</Section>
<Section collapsible defaultOpen={false}>
- <SectionHeader title="Audio" />
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
<SectionContent>
<div className="flex items-center space-x-2">
<Checkbox
id="include-audio"
- checked={includeAudio}
- onCheckedChange={(checked) =>
- setIncludeAudio(!!checked)
- }
+ checked={shouldIncludeAudio}
+ onCheckedChange={(checked) =>
+ setShouldIncludeAudio(!!checked)
+ }
/>
<Label htmlFor="include-audio">
Include audio in export
@@ -256,15 +252,15 @@ function ExportPopover({
{isExporting && (
<div className="space-y-4 p-3">
- <div className="flex flex-col">
- <div className="flex items-center justify-between text-center">
- <p className="text-muted-foreground mb-2 text-sm">
- {Math.round(progress * 100)}%
- </p>
- <p className="text-muted-foreground mb-2 text-sm">100%</p>
- </div>
- <Progress value={progress * 100} className="w-full" />
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center justify-between text-center">
+ <p className="text-muted-foreground text-sm">
+ {Math.round(progress * 100)}%
+ </p>
+ <p className="text-muted-foreground text-sm">100%</p>
</div>
+ <Progress value={progress * 100} className="w-full" />
+ </div>
<Button
variant="outline"
@@ -282,14 +278,6 @@ function ExportPopover({
);
}
-function isExportFormat(value: string): value is ExportFormat {
- return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
-}
-
-function isExportQuality(value: string): value is ExportQuality {
- return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
-}
-
function ExportError({
error,
onRetry,
@@ -306,7 +294,7 @@ function ExportError({
};
return (
- <div className="space-y-4">
+ <div className="space-y-4 p-3">
<div className="flex flex-col gap-1.5">
<p className="text-destructive text-sm font-medium">Export failed</p>
<p className="text-muted-foreground text-xs">{error}</p>