返回 AiToEarn
1 /**
2 * ImageExportControls - 图片导出操作组件
3 * 通过按钮触发图片压缩与 JPG/PNG 格式转换
4 */
5
6 'use client'
7
8 import type { ImageExportFormat } from '../imageExport'
9 import { Check, ImageDown, Images, SlidersHorizontal } from 'lucide-react'
10 import { memo, useState } from 'react'
11 import { useTransClient } from '@/app/i18n/client'
12 import { Button } from '@/components/ui/button'
13 import { Slider } from '@/components/ui/slider'
14 import { cn } from '@/utils/className'
15 import {
16 getImageExportLabel,
17 IMAGE_EXPORT_FORMATS,
18 IMAGE_EXPORT_QUALITY,
19 } from '../imageExport'
20
21 type ExportPanelType = 'compress' | 'convert' | null
22
23 export interface ImageExportControlsProps {
24 /** 当前输出格式 */
25 format: ImageExportFormat
26 /** 设置输出格式 */
27 onFormatChange: (format: ImageExportFormat) => void
28 /** JPEG 输出质量 */
29 quality: number
30 /** 设置 JPEG 输出质量 */
31 onQualityChange: (quality: number) => void
32 /** 是否禁用 */
33 disabled?: boolean
34 /** 自定义类名 */
35 className?: string
36 }
37
38 const panelButtonClassName = 'h-8 rounded-full px-3 text-xs font-medium cursor-pointer'
39
40 export const ImageExportControls = memo(
41 ({ format, onFormatChange, quality, onQualityChange, disabled, className }: ImageExportControlsProps) => {
42 const { t } = useTransClient('common')
43 const [activePanel, setActivePanel] = useState<ExportPanelType>(null)
44 const isCompressOpen = activePanel === 'compress'
45 const isConvertOpen = activePanel === 'convert'
46
47 const handleTogglePanel = (panelType: Exclude<ExportPanelType, null>) => {
48 setActivePanel(current => (current === panelType ? null : panelType))
49 }
50
51 const handleFormatChange = (nextFormat: ImageExportFormat) => {
52 onFormatChange(nextFormat)
53 setActivePanel(null)
54 }
55
56 const handleQualityChange = (nextQuality: number) => {
57 onQualityChange(nextQuality)
58 if (format !== 'image/jpeg')
59 onFormatChange('image/jpeg')
60 }
61
62 return (
63 <div className={cn('relative flex items-center gap-2', className)}>
64 <div className="flex items-center gap-1.5 rounded-full border border-white/15 bg-background/95 p-1 shadow-lg shadow-background/20 backdrop-blur-md">
65 <Button
66 type="button"
67 variant={isCompressOpen ? 'default' : 'ghost'}
68 size="sm"
69 disabled={disabled}
70 onClick={() => handleTogglePanel('compress')}
71 className={cn(
72 panelButtonClassName,
73 isCompressOpen
74 ? 'bg-gradient-back text-gradient-foreground shadow-sm shadow-primary/25'
75 : 'text-foreground hover:bg-accent hover:text-accent-foreground',
76 )}
77 >
78 <SlidersHorizontal className="w-4 h-4" />
79 <span className="hidden sm:inline">{t('brushEditor.compressImage')}</span>
80 <span className="sm:hidden">{t('brushEditor.compressShort')}</span>
81 </Button>
82
83 <Button
84 type="button"
85 variant={isConvertOpen ? 'default' : 'ghost'}
86 size="sm"
87 disabled={disabled}
88 onClick={() => handleTogglePanel('convert')}
89 className={cn(
90 panelButtonClassName,
91 isConvertOpen
92 ? 'bg-gradient-back text-gradient-foreground shadow-sm shadow-primary/25'
93 : 'text-foreground hover:bg-accent hover:text-accent-foreground',
94 )}
95 >
96 <Images className="w-4 h-4" />
97 <span className="hidden sm:inline">{t('brushEditor.convertFormat')}</span>
98 <span className="sm:hidden">{t('brushEditor.convertShort')}</span>
99 </Button>
100 </div>
101
102 <div className="hidden items-center gap-1 rounded-full border border-white/15 bg-background/95 px-2.5 py-1 text-xs text-foreground shadow-lg shadow-background/20 backdrop-blur-md sm:flex">
103 <ImageDown className="w-3.5 h-3.5 text-primary" />
104 <span>{getImageExportLabel(format)}</span>
105 <span className="text-muted-foreground">·</span>
106 <span>{format === 'image/jpeg' ? `${quality}%` : t('brushEditor.pngLosslessShort')}</span>
107 </div>
108
109 {activePanel && (
110 <div className="absolute left-1/2 top-full z-[10002] mt-2 w-[calc(100vw-32px)] max-w-[360px] -translate-x-1/2 rounded-2xl border border-border bg-popover p-3 text-popover-foreground shadow-2xl shadow-background/30 sm:left-0 sm:translate-x-0">
111 {isCompressOpen && (
112 <div className="space-y-3">
113 <div className="flex items-center justify-between gap-3">
114 <div>
115 <p className="text-sm font-medium text-foreground">{t('brushEditor.compressImage')}</p>
116 <p className="mt-0.5 text-xs text-muted-foreground">{t('brushEditor.compressHint')}</p>
117 </div>
118 <span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary">
119 {quality}
120 %
121 </span>
122 </div>
123 <Slider
124 value={[quality]}
125 onValueChange={([value]) => handleQualityChange(value)}
126 min={IMAGE_EXPORT_QUALITY.min}
127 max={IMAGE_EXPORT_QUALITY.max}
128 step={IMAGE_EXPORT_QUALITY.step}
129 disabled={disabled}
130 className="w-full"
131 />
132 {format !== 'image/jpeg' && (
133 <p className="text-xs text-muted-foreground">{t('brushEditor.compressJpgTip')}</p>
134 )}
135 </div>
136 )}
137
138 {isConvertOpen && (
139 <div className="space-y-3">
140 <div>
141 <p className="text-sm font-medium text-foreground">{t('brushEditor.convertFormat')}</p>
142 <p className="mt-0.5 text-xs text-muted-foreground">{t('brushEditor.convertHint')}</p>
143 </div>
144 <div className="grid grid-cols-2 gap-2">
145 {IMAGE_EXPORT_FORMATS.map(item => (
146 <button
147 key={item.value}
148 type="button"
149 disabled={disabled}
150 onClick={() => handleFormatChange(item.value)}
151 className={cn(
152 'flex h-12 items-center justify-between rounded-xl border px-3 text-sm font-semibold transition-all cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
153 format === item.value
154 ? 'border-primary bg-primary/12 text-primary shadow-sm shadow-primary/10'
155 : 'border-border bg-background text-foreground hover:border-primary/50 hover:bg-accent',
156 )}
157 aria-label={t('brushEditor.convertToFormat', { format: item.label })}
158 >
159 <span>{item.label}</span>
160 {format === item.value && <Check className="w-4 h-4" />}
161 </button>
162 ))}
163 </div>
164 </div>
165 )}
166 </div>
167 )}
168 </div>
169 )
170 },
171 )
172
173 export default ImageExportControls
174
174 lines Plain Text