diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/export-button.tsx | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/export-button.tsx')
| -rw-r--r-- | apps/web/src/components/editor/export-button.tsx | 588 |
1 files changed, 309 insertions, 279 deletions
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index 246e8b6f..f79710bf 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -1,317 +1,347 @@ "use client"; -import { useState } from "react"; -import { TransitionUpIcon } from "../icons"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { Button } from "../ui/button"; -import { Label } from "../ui/label"; -import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; -import { Progress } from "../ui/progress"; -import { Checkbox } from "../ui/checkbox"; -import { cn } from "@/lib/utils"; +import { useState, useRef } from "react"; +import { TransitionTopIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { - exportProject, - getExportMimeType, - getExportFileExtension, - DEFAULT_EXPORT_OPTIONS, -} from "@/lib/export"; -import { useProjectStore } from "@/stores/project-store"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +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 { Check, Copy, Download, RotateCcw, X } from "lucide-react"; -import { ExportFormat, ExportQuality, ExportResult } from "@/types/export"; -import { PropertyGroup } from "./properties-panel/property-item"; +import { + EXPORT_FORMAT_VALUES, + EXPORT_QUALITY_VALUES, + type ExportFormat, + type ExportQuality, + type ExportResult, +} from "@/types/export"; +import { PropertyGroup } from "@/components/editor/panels/properties/property-item"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; export function ExportButton() { - const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); - const { activeProject } = useProjectStore(); + const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); + const editor = useEditor(); - const handleExport = () => { - setIsExportPopoverOpen(true); - }; + const handleExport = () => { + setIsExportPopoverOpen(true); + }; - const hasProject = !!activeProject; + const hasProject = !!editor.project.getActive(); - return ( - <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}> - <PopoverTrigger asChild> - <button - type="button" - className={cn( - "flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] transition-all duration-200", - hasProject - ? "cursor-pointer hover:brightness-95" - : "cursor-not-allowed opacity-50" - )} - onClick={hasProject ? handleExport : undefined} - disabled={!hasProject} - onKeyDown={(event) => { - if (hasProject && (event.key === "Enter" || event.key === " ")) { - event.preventDefault(); - handleExport(); - } - }} - > - <div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]"> - <TransitionUpIcon className="z-50" /> - <span className="text-[0.875rem] z-50">Export</span> - <div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center"> - <div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div> - </div> - </div> - </button> - </PopoverTrigger> - {hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />} - </Popover> - ); + return ( + <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}> + <PopoverTrigger asChild> + <button + type="button" + className={cn( + "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white", + hasProject + ? "cursor-pointer hover:brightness-105" + : "cursor-not-allowed opacity-50", + )} + onClick={hasProject ? handleExport : undefined} + disabled={!hasProject} + onKeyDown={(event) => { + if (hasProject && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + handleExport(); + } + }} + > + <div className="relative flex items-center gap-1.5 rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]"> + <HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" /> + <span className="z-50 text-[0.875rem]">Export</span> + <div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.6rem] bg-linear-to-t from-white/0 to-white/50"> + <div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div> + </div> + </div> + </button> + </PopoverTrigger> + {hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />} + </Popover> + ); } function ExportPopover({ - onOpenChange, + onOpenChange, }: { - onOpenChange: (open: boolean) => void; + onOpenChange: (open: boolean) => void; }) { - const { activeProject } = useProjectStore(); - 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 [isExporting, setIsExporting] = useState(false); - const [progress, setProgress] = useState(0); - const [exportResult, setExportResult] = useState<ExportResult | null>(null); + const editor = useEditor(); + const activeProject = editor.project.getActive(); + 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 [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, + }, + }); - const handleExport = async () => { - if (!activeProject) return; + setIsExporting(false); - setIsExporting(true); - setProgress(0); - setExportResult(null); + if (result.cancelled) { + setExportResult(null); + setProgress(0); + return; + } - const result = await exportProject({ - format, - quality, - fps: activeProject.fps, - includeAudio, - onProgress: setProgress, - onCancel: () => false, // TODO: Add cancel functionality - }); + setExportResult(result); - setIsExporting(false); - 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); - if (result.success && result.buffer) { - // Download the file - 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); - const a = document.createElement("a"); - a.href = url; - a.download = `${activeProject.name}${extension}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + onOpenChange(false); + setExportResult(null); + setProgress(0); + } + }; - onOpenChange(false); - setExportResult(null); - setProgress(0); - } - }; + const handleClose = () => { + if (!isExporting) { + onOpenChange(false); + setExportResult(null); + setProgress(0); + } + }; - const handleClose = () => { - if (!isExporting) { - onOpenChange(false); - setExportResult(null); - setProgress(0); - } - }; + const handleCancel = () => { + cancelRequestedRef.current = true; + }; - return ( - <PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background"> - <> - {exportResult && !exportResult.success ? ( - <ExportError - error={exportResult.error || "Unknown error occurred"} - onRetry={handleExport} - /> - ) : ( - <> - <div className="flex items-center justify-between"> - <h3 className=" font-medium"> - {isExporting ? "Exporting project" : "Export project"} - </h3> - <Button variant="text" size="icon" onClick={handleClose}> - <X className="!size-5 text-foreground/85" /> - </Button> - </div> + return ( + <PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3"> + {exportResult && !exportResult.success ? ( + <ExportError + error={exportResult.error || "Unknown error occurred"} + onRetry={handleExport} + /> + ) : ( + <> + <div className="flex items-center justify-between"> + <h3 className="font-medium"> + {isExporting ? "Exporting project" : "Export project"} + </h3> + <Button variant="text" size="icon" onClick={handleClose}> + <X className="text-foreground/85 !size-5" /> + </Button> + </div> - <div className="flex flex-col gap-4"> - {!isExporting && ( - <> - <div className="flex flex-col gap-3"> - <PropertyGroup - title="Format" - titleClassName="text-sm" - defaultExpanded={false} - > - <RadioGroup - value={format} - onValueChange={(value) => - setFormat(value as ExportFormat) - } - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="mp4" id="mp4" /> - <Label htmlFor="mp4"> - MP4 (H.264) - Better compatibility - </Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="webm" id="webm" /> - <Label htmlFor="webm"> - WebM (VP9) - Smaller file size - </Label> - </div> - </RadioGroup> - </PropertyGroup> + <div className="flex flex-col gap-4"> + {!isExporting && ( + <> + <div className="flex flex-col gap-3"> + <PropertyGroup + title="Format" + titleClassName="text-sm" + defaultExpanded={false} + > + <RadioGroup + value={format} + onValueChange={(value) => { + if (isExportFormat(value)) { + setFormat(value); + } + }} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="mp4" id="mp4" /> + <Label htmlFor="mp4"> + MP4 (H.264) - Better compatibility + </Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="webm" id="webm" /> + <Label htmlFor="webm"> + WebM (VP9) - Smaller file size + </Label> + </div> + </RadioGroup> + </PropertyGroup> - <PropertyGroup - title="Quality" - titleClassName="text-sm" - defaultExpanded={false} - > - <RadioGroup - value={quality} - onValueChange={(value) => - setQuality(value as ExportQuality) - } - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="low" id="low" /> - <Label htmlFor="low">Low - Smallest file size</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="medium" id="medium" /> - <Label htmlFor="medium">Medium - Balanced</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="high" id="high" /> - <Label htmlFor="high">High - Recommended</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="very_high" id="very_high" /> - <Label htmlFor="very_high"> - Very High - Largest file size - </Label> - </div> - </RadioGroup> - </PropertyGroup> + <PropertyGroup + title="Quality" + titleClassName="text-sm" + defaultExpanded={false} + > + <RadioGroup + value={quality} + onValueChange={(value) => { + if (isExportQuality(value)) { + setQuality(value); + } + }} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="low" id="low" /> + <Label htmlFor="low">Low - Smallest file size</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="medium" id="medium" /> + <Label htmlFor="medium">Medium - Balanced</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="high" id="high" /> + <Label htmlFor="high">High - Recommended</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="very_high" id="very_high" /> + <Label htmlFor="very_high"> + Very High - Largest file size + </Label> + </div> + </RadioGroup> + </PropertyGroup> - <PropertyGroup - title="Audio" - titleClassName="text-sm" - defaultExpanded={false} - > - <div className="flex items-center space-x-2"> - <Checkbox - id="include-audio" - checked={includeAudio} - onCheckedChange={(checked) => - setIncludeAudio(!!checked) - } - /> - <Label htmlFor="include-audio"> - Include audio in export - </Label> - </div> - </PropertyGroup> - </div> + <PropertyGroup + title="Audio" + titleClassName="text-sm" + defaultExpanded={false} + > + <div className="flex items-center space-x-2"> + <Checkbox + id="include-audio" + checked={includeAudio} + onCheckedChange={(checked) => + setIncludeAudio(!!checked) + } + /> + <Label htmlFor="include-audio"> + Include audio in export + </Label> + </div> + </PropertyGroup> + </div> - <Button onClick={handleExport} className="w-full gap-2"> - <Download className="w-4 h-4" /> - Export - </Button> - </> - )} + <Button onClick={handleExport} className="w-full gap-2"> + <Download className="size-4" /> + Export + </Button> + </> + )} - {isExporting && ( - <div className="space-y-4"> - <div className="flex flex-col"> - <div className="text-center flex items-center justify-between"> - <p className="text-sm text-muted-foreground mb-2"> - {Math.round(progress * 100)}% - </p> - <p className="text-sm text-muted-foreground mb-2">100%</p> - </div> - <Progress value={progress * 100} className="w-full" /> - </div> + {isExporting && ( + <div className="space-y-4"> + <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> + + <Button + variant="outline" + className="w-full rounded-md" + onClick={handleCancel} + > + Cancel + </Button> + </div> + )} + </div> + </> + )} + </PopoverContent> + ); +} + +function isExportFormat(value: string): value is ExportFormat { + return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); +} - <Button - variant="outline" - className="rounded-md w-full" - onClick={() => {}} - > - Cancel - </Button> - </div> - )} - </div> - </> - )} - </> - </PopoverContent> - ); +function isExportQuality(value: string): value is ExportQuality { + return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value); } function ExportError({ - error, - onRetry, + error, + onRetry, }: { - error: string; - onRetry: () => void; + error: string; + onRetry: () => void; }) { - const [copied, setCopied] = useState(false); + const [copied, setCopied] = useState(false); - const handleCopy = async () => { - await navigator.clipboard.writeText(error); - setCopied(true); - setTimeout(() => setCopied(false), 1000); - }; + const handleCopy = async () => { + await navigator.clipboard.writeText(error); + setCopied(true); + setTimeout(() => setCopied(false), 1000); + }; - return ( - <div className="space-y-4"> - <div className="flex flex-col gap-1.5"> - <p className="text-sm font-medium text-red-400">Export failed</p> - <p className="text-xs text-muted-foreground"> - {error} - </p> - </div> + return ( + <div className="space-y-4"> + <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> + </div> - <div className="flex gap-2"> - <Button - variant="outline" - size="sm" - className="flex-1 text-xs h-8" - onClick={handleCopy} - > - {copied ? <Check className="text-green-500" /> : <Copy />} - Copy - </Button> - <Button - variant="outline" - size="sm" - className="flex-1 text-xs h-8" - onClick={onRetry} - > - <RotateCcw /> - Retry - </Button> - </div> - </div> - ); + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + className="h-8 flex-1 text-xs" + onClick={handleCopy} + > + {copied ? <Check className="text-constructive" /> : <Copy />} + Copy + </Button> + <Button + variant="outline" + size="sm" + className="h-8 flex-1 text-xs" + onClick={onRetry} + > + <RotateCcw /> + Retry + </Button> + </div> + </div> + ); } |
