返回 oh-my-ppt
HtmlEditorMediaInsertDialog.tsx
根目录 / src / renderer / src / components / html-editor / HtmlEditorMediaInsertDialog.tsx
1 import { useCallback, useEffect, useState, type ReactNode } from 'react'
2 import { ImagePlus, Link, Play, Upload, Video } from 'lucide-react'
3 import { useT } from '@renderer/i18n'
4 import { ipc } from '@renderer/lib/ipc'
5 import { useToastStore } from '@renderer/store'
6 import { localAssetUrl } from '@shared/local-asset'
7 import { cn } from '@renderer/lib/utils'
8 import { useHtmlEditorStore } from '../../store/htmlEditorStore'
9 import {
10 Dialog,
11 DialogContent,
12 DialogDescription,
13 DialogFooter,
14 DialogHeader,
15 DialogTitle
16 } from '../ui/Dialog'
17 import type { useHtmlElementInsertion } from './useHtmlElementInsertion'
18
19 type MediaType = 'image' | 'video'
20 type MediaSource = 'library' | 'external'
21 type Insertion = ReturnType<typeof useHtmlElementInsertion>
22 type MediaAsset = {
23 fileName: string
24 filePath: string
25 relativePath: string
26 url: string
27 }
28
29 function CheckIcon({ checked }: { checked: boolean }): ReactNode {
30 return (
31 <span
32 className={cn(
33 'flex h-5 w-5 items-center justify-center rounded-full border-2 transition-all duration-200',
34 checked
35 ? 'border-[#6f8159] bg-[#6f8159] text-white'
36 : 'border-[#ded2bd]/80 bg-white/85 text-transparent group-hover:border-[#b5c9a0]'
37 )}
38 >
39 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
40 <path
41 d="M2.5 6L5 8.5L9.5 3.5"
42 stroke="currentColor"
43 strokeWidth="1.8"
44 strokeLinecap="round"
45 strokeLinejoin="round"
46 />
47 </svg>
48 </span>
49 )
50 }
51
52 function normalizeExternalMediaUrl(value: string): string | null {
53 try {
54 const url = new URL(value.trim())
55 return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null
56 } catch {
57 return null
58 }
59 }
60
61 export function HtmlEditorMediaInsertDialog({
62 mediaType,
63 insertion,
64 onClose
65 }: {
66 mediaType: MediaType | null
67 insertion: Insertion
68 onClose: () => void
69 }): ReactNode {
70 const t = useT()
71 const docId = useHtmlEditorStore((state) => state.docId)
72 const toastError = useToastStore((state) => state.error)
73 const [source, setSource] = useState<MediaSource>('library')
74 const [url, setUrl] = useState('')
75 const [urlError, setUrlError] = useState(false)
76 const [inserting, setInserting] = useState(false)
77 const [uploading, setUploading] = useState(false)
78 const [loadingAssets, setLoadingAssets] = useState(false)
79 const [assets, setAssets] = useState<MediaAsset[]>([])
80 const [selectedAssetPath, setSelectedAssetPath] = useState<string | null>(null)
81 const [playingPath, setPlayingPath] = useState<string | null>(null)
82
83 const loadAssets = useCallback(async (): Promise<MediaAsset[]> => {
84 if (!docId || !mediaType) {
85 setAssets([])
86 return []
87 }
88 setLoadingAssets(true)
89 try {
90 const result = await ipc.listHtmlEditorMedia({ docId, mediaType })
91 setAssets(result.assets)
92 return result.assets
93 } catch (error) {
94 setAssets([])
95 toastError(error instanceof Error ? error.message : t('common.retryLater'))
96 return []
97 } finally {
98 setLoadingAssets(false)
99 }
100 }, [docId, mediaType, t, toastError])
101
102 useEffect(() => {
103 if (!mediaType) {
104 setSource('library')
105 setUrl('')
106 setUrlError(false)
107 setInserting(false)
108 setUploading(false)
109 setLoadingAssets(false)
110 setAssets([])
111 setSelectedAssetPath(null)
112 setPlayingPath(null)
113 return
114 }
115 if (source === 'library') void loadAssets()
116 }, [loadAssets, mediaType, source])
117
118 const addMedia = async (src: string): Promise<boolean> => {
119 if (mediaType === 'image') return insertion.addImage(src)
120 if (mediaType === 'video') return insertion.addVideo(src)
121 return false
122 }
123
124 const chooseLocalFile = async (): Promise<void> => {
125 if (!docId || !mediaType || uploading || inserting) return
126 setUploading(true)
127 try {
128 const result = await ipc.chooseAndImportHtmlMedia({ docId, mediaType })
129 if (result.cancelled) return
130 const nextAssets = await loadAssets()
131 setSelectedAssetPath(
132 nextAssets.some((asset) => asset.relativePath === result.relativePath)
133 ? result.relativePath
134 : null
135 )
136 } catch (error) {
137 toastError(error instanceof Error ? error.message : t('common.retryLater'))
138 } finally {
139 setUploading(false)
140 }
141 }
142
143 const insertExternalUrl = async (): Promise<void> => {
144 const normalizedUrl = normalizeExternalMediaUrl(url)
145 if (!normalizedUrl) {
146 setUrlError(true)
147 return
148 }
149 setInserting(true)
150 try {
151 if (await addMedia(normalizedUrl)) onClose()
152 else toastError(t('htmlEditor.insertMediaFailed'))
153 } catch (error) {
154 toastError(error instanceof Error ? error.message : t('htmlEditor.insertMediaFailed'))
155 } finally {
156 setInserting(false)
157 }
158 }
159
160 const insertSelectedAsset = async (): Promise<void> => {
161 const asset = assets.find((item) => item.relativePath === selectedAssetPath)
162 if (!asset) return
163 setInserting(true)
164 try {
165 if (await addMedia(asset.url)) onClose()
166 else toastError(t('htmlEditor.insertMediaFailed'))
167 } catch (error) {
168 toastError(error instanceof Error ? error.message : t('htmlEditor.insertMediaFailed'))
169 } finally {
170 setInserting(false)
171 }
172 }
173
174 const Icon = mediaType === 'video' ? Video : ImagePlus
175 const title = mediaType === 'video' ? t('htmlEditor.insertVideo') : t('htmlEditor.insertImage')
176
177 return (
178 <Dialog open={mediaType !== null} onOpenChange={(open) => !open && onClose()}>
179 <DialogContent className="max-w-lg">
180 <DialogHeader>
181 <DialogTitle>{title}</DialogTitle>
182 <DialogDescription>{t('htmlEditor.mediaSourceHint')}</DialogDescription>
183 </DialogHeader>
184
185 <div className="grid grid-cols-2 gap-2 rounded-md bg-[#eee6d8] p-1">
186 {(
187 [
188 ['library', ImagePlus, 'htmlEditor.mediaLibrary'],
189 ['external', Link, 'htmlEditor.mediaSourceExternal']
190 ] as const
191 ).map(([value, SourceIcon, label]) => (
192 <button
193 key={value}
194 type="button"
195 onClick={() => {
196 setSource(value)
197 setUrlError(false)
198 }}
199 className={`flex h-9 items-center justify-center gap-1.5 rounded text-sm transition-colors ${
200 source === value
201 ? 'bg-white font-medium text-[#3e4a32] shadow-sm'
202 : 'text-[#766d5e] hover:bg-white/60'
203 }`}
204 >
205 <SourceIcon className="h-3.5 w-3.5" />
206 {t(label)}
207 </button>
208 ))}
209 </div>
210
211 {source === 'library' ? (
212 <div className="space-y-3">
213 <div className="flex items-center justify-between gap-3">
214 <p className="text-sm leading-5 text-[#6f6658]">{t('htmlEditor.localMediaHint')}</p>
215 <button
216 type="button"
217 disabled={uploading || inserting || !docId}
218 onClick={() => void chooseLocalFile()}
219 className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-md bg-[#5d6b4d] px-3 text-sm font-medium text-white hover:bg-[#4b593d] disabled:cursor-not-allowed disabled:opacity-50"
220 >
221 <Upload className="h-3.5 w-3.5" />
222 {t('htmlEditor.chooseMediaFile')}
223 </button>
224 </div>
225
226 {loadingAssets ? (
227 <div className="flex h-48 items-center justify-center text-sm text-[#6f6658]">
228 {t('common.loading')}
229 </div>
230 ) : assets.length === 0 ? (
231 <div className="flex h-48 flex-col items-center justify-center gap-2 rounded-md border border-dashed border-[#cfc2aa] bg-[#fffdf7] text-sm text-[#6f6658]">
232 <Icon className="h-6 w-6 text-[#6f8159]" />
233 {t('htmlEditor.mediaLibraryEmpty')}
234 </div>
235 ) : (
236 <div className="grid max-h-[340px] grid-cols-3 gap-2 overflow-y-auto p-1">
237 {assets.map((asset) => {
238 const selected = selectedAssetPath === asset.relativePath
239 return (
240 <div
241 key={asset.relativePath}
242 className={cn(
243 'group overflow-hidden rounded-lg border-2 transition-all duration-200',
244 selected
245 ? 'border-[#6f8159] ring-2 ring-[#6f8159]/40 shadow-md shadow-[#6f8159]/20'
246 : 'border-[#ded2bd]/60 hover:border-[#b5c9a0] hover:shadow-md hover:shadow-[#c7d9b4]/40'
247 )}
248 >
249 <div className="relative aspect-[4/3]">
250 {mediaType === 'video' ? (
251 playingPath === asset.relativePath ? (
252 <video
253 src={localAssetUrl(asset.filePath)}
254 controls
255 autoPlay
256 playsInline
257 className="h-full w-full bg-black"
258 />
259 ) : (
260 <>
261 <video
262 src={localAssetUrl(asset.filePath)}
263 preload="metadata"
264 muted
265 playsInline
266 className="h-full w-full object-cover bg-black"
267 />
268 <button
269 type="button"
270 onClick={() => setPlayingPath(asset.relativePath)}
271 className="absolute inset-0 flex items-center justify-center bg-black/15 transition-colors hover:bg-black/25"
272 >
273 <span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/80 shadow backdrop-blur-sm">
274 <Play className="h-4 w-4 translate-x-px text-[#3e4a32]" />
275 </span>
276 </button>
277 </>
278 )
279 ) : (
280 <img
281 src={localAssetUrl(asset.filePath)}
282 alt={asset.fileName}
283 className={cn(
284 'h-full w-full object-cover transition-transform duration-200',
285 !selected && 'group-hover:scale-105'
286 )}
287 />
288 )}
289 <button
290 type="button"
291 onClick={() => setSelectedAssetPath(selected ? null : asset.relativePath)}
292 className="absolute right-1.5 top-1.5 z-10 cursor-pointer"
293 title={selected ? t('common.cancel') : t('htmlEditor.insertMedia')}
294 >
295 <CheckIcon checked={selected} />
296 </button>
297 </div>
298 <div className="truncate bg-[#faf6ef] px-1.5 py-1 text-[10px] text-[#6f6658]">
299 {asset.fileName}
300 </div>
301 </div>
302 )
303 })}
304 </div>
305 )}
306 </div>
307 ) : (
308 <div className="space-y-2">
309 <label className="sr-only" htmlFor="html-editor-media-url">
310 {t('htmlEditor.mediaSourceExternal')}
311 </label>
312 <input
313 id="html-editor-media-url"
314 type="url"
315 value={url}
316 autoFocus
317 onChange={(event) => {
318 setUrl(event.target.value)
319 setUrlError(false)
320 }}
321 onKeyDown={(event) => {
322 if (event.key === 'Enter') void insertExternalUrl()
323 }}
324 placeholder={t('htmlEditor.mediaUrlPlaceholder')}
325 className={`h-10 w-full rounded-md border bg-white px-3 text-sm text-[#3e4a32] outline-none placeholder:text-[#9a907f] focus:ring-2 focus:ring-[#8fbc8f]/50 ${
326 urlError ? 'border-[#b65c50]' : 'border-[#cfc2aa]'
327 }`}
328 />
329 {urlError ? (
330 <p className="text-xs text-[#a44c43]">{t('htmlEditor.invalidMediaUrl')}</p>
331 ) : null}
332 </div>
333 )}
334
335 {source === 'library' ? (
336 <DialogFooter>
337 <button
338 type="button"
339 disabled={!selectedAssetPath || inserting}
340 onClick={() => void insertSelectedAsset()}
341 className="inline-flex h-9 items-center gap-1.5 rounded-md bg-[#5d6b4d] px-3 text-sm font-medium text-white hover:bg-[#4b593d] disabled:cursor-not-allowed disabled:opacity-50"
342 >
343 <Icon className="h-3.5 w-3.5" />
344 {t('htmlEditor.insertMedia')}
345 </button>
346 </DialogFooter>
347 ) : (
348 <DialogFooter>
349 <button
350 type="button"
351 disabled={inserting}
352 onClick={() => void insertExternalUrl()}
353 className="inline-flex h-9 items-center gap-1.5 rounded-md bg-[#5d6b4d] px-3 text-sm font-medium text-white hover:bg-[#4b593d]"
354 >
355 <Icon className="h-3.5 w-3.5" />
356 {t('htmlEditor.insertMedia')}
357 </button>
358 </DialogFooter>
359 )}
360 </DialogContent>
361 </Dialog>
362 )
363 }
364
364 lines Plain Text