返回 oh-my-ppt
MasterGradientEditor.tsx
根目录 / src / renderer / src / components / gradient-editor / MasterGradientEditor.tsx
1 import { useEffect, useRef, useState } from 'react'
2 import {
3 ArrowDown,
4 ArrowRight,
5 ArrowUp,
6 ArrowUpRight,
7 Blend,
8 Circle,
9 FolderOpen,
10 ImageIcon,
11 Trash2,
12 Upload,
13 X
14 } from 'lucide-react'
15 import { useT } from '@renderer/i18n'
16 import { ipc } from '@renderer/lib/ipc'
17 import { useMasterWorkbenchStore, useSessionStore, useToastStore } from '@renderer/store'
18 import {
19 MAX_MASTER_GRADIENT_STOPS,
20 MIN_MASTER_GRADIENT_STOPS,
21 addMasterGradientStop,
22 buildMasterGradientCss,
23 normalizeMasterGradient,
24 removeMasterGradientStop,
25 updateMasterGradientStop,
26 type MasterGradient
27 } from '@shared/master'
28 import { localAssetUrl } from '@shared/local-asset'
29 import { resolveSlideSize } from '@shared/slide-size'
30 import { Button } from '../ui/Button'
31 import { ColorPicker } from '../ui/ColorPicker'
32 import {
33 Dialog,
34 DialogContent,
35 DialogDescription,
36 DialogFooter,
37 DialogHeader,
38 DialogTitle
39 } from '../ui/Dialog'
40 import { Input } from '../ui/Input'
41 import { ToggleGroup, ToggleGroupItem } from '../ui/ToggleGroup'
42 import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip'
43 import { AssetPickerDialog } from '../session-detail/modal/AssetPickerDialog'
44
45 const gradientPresets = [
46 ['#fccb90', '#d57eeb'],
47 ['#67e8f9', '#4f46e5'],
48 ['#34d399', '#059669'],
49 ['#fda4af', '#f97316'],
50 ['#c4b5fd', '#ec4899'],
51 ['#facc15', '#ef4444'],
52 ['#1e293b', '#0f766e'],
53 ['#f1f5f9', '#94a3b8']
54 ] as const
55
56 const directionPresets = [
57 { angle: 0, Icon: ArrowUp },
58 { angle: 45, Icon: ArrowUpRight },
59 { angle: 90, Icon: ArrowRight },
60 { angle: 180, Icon: ArrowDown }
61 ] as const
62
63 type DraggingStop = {
64 color: string
65 position: number
66 }
67
68 export function MasterGradientEditor(): React.JSX.Element {
69 const t = useT()
70 const config = useMasterWorkbenchStore((state) => state.config)
71 const updateConfig = useMasterWorkbenchStore((state) => state.updateConfig)
72 const currentSession = useSessionStore((state) => state.currentSession)
73 const sessionId = currentSession?.id || ''
74 const slideSize = resolveSlideSize({
75 id: currentSession?.slideSizeId,
76 width: currentSession?.slideWidth,
77 height: currentSession?.slideHeight
78 })
79 const toastError = useToastStore((state) => state.error)
80 const [editorOpen, setEditorOpen] = useState(false)
81 const [assetPickerOpen, setAssetPickerOpen] = useState(false)
82 const [uploading, setUploading] = useState(false)
83 const [activeBackgroundStyle, setActiveBackgroundStyle] = useState(config.backgroundStyle)
84 const [gradientDraft, setGradientDraft] = useState<MasterGradient>(() =>
85 normalizeMasterGradient(config.backgroundGradient)
86 )
87 const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null)
88 const [selectedStopIndex, setSelectedStopIndex] = useState(0)
89 const draggingStopRef = useRef<DraggingStop | null>(null)
90 const gradient = gradientDraft
91 const selectedStop = gradient.stops[Math.min(selectedStopIndex, gradient.stops.length - 1)]
92 const isGradient = activeBackgroundStyle === 'gradient'
93 const isImage = activeBackgroundStyle === 'image'
94
95 useEffect(() => {
96 setSelectedStopIndex((current) => Math.min(current, gradient.stops.length - 1))
97 }, [gradient.stops.length])
98
99 useEffect(() => {
100 setActiveBackgroundStyle(config.backgroundStyle)
101 }, [config.backgroundStyle])
102
103 useEffect(() => {
104 if (!sessionId || !config.backgroundImage) {
105 setImagePreviewUrl(null)
106 return
107 }
108 let cancelled = false
109 void ipc
110 .listAssets(sessionId, 'image')
111 .then(({ assets }) => {
112 const image = assets.find((asset) => asset.relativePath === config.backgroundImage)
113 if (!cancelled) setImagePreviewUrl(image ? localAssetUrl(image.absolutePath) : null)
114 })
115 .catch(() => {
116 if (!cancelled) setImagePreviewUrl(null)
117 })
118 return () => {
119 cancelled = true
120 }
121 }, [config.backgroundImage, sessionId])
122
123 const openGradientEditor = (): void => {
124 setGradientDraft(normalizeMasterGradient(config.backgroundGradient))
125 setSelectedStopIndex(0)
126 setEditorOpen(true)
127 }
128
129 const cancelGradientEditor = (): void => {
130 setActiveBackgroundStyle(config.backgroundStyle)
131 setEditorOpen(false)
132 }
133
134 const selectBackgroundStyle = (value: string): void => {
135 if (value !== 'solid' && value !== 'gradient' && value !== 'image') return
136 if (value === 'gradient') {
137 setActiveBackgroundStyle('gradient')
138 openGradientEditor()
139 return
140 }
141 setActiveBackgroundStyle(value)
142 if (value === 'solid') {
143 updateConfig({ backgroundMode: 'override', backgroundStyle: 'solid' })
144 }
145 }
146
147 const updateSelectedStop = (patch: { color?: string; position?: number }): void => {
148 if (!selectedStop) return
149 setGradientDraft(updateMasterGradientStop(gradient, selectedStopIndex, patch))
150 }
151
152 const updateAngle = (angle: number): void => {
153 setGradientDraft(normalizeMasterGradient({ ...gradient, angle }))
154 }
155
156 const applyPreset = (colors: readonly string[]): void => {
157 const stops = colors.map((color, index) => ({
158 color,
159 position: Math.round((index / (colors.length - 1)) * 100)
160 }))
161 setGradientDraft(normalizeMasterGradient({ ...gradient, stops } satisfies MasterGradient))
162 setSelectedStopIndex(0)
163 }
164
165 const findStopIndex = (
166 stops: MasterGradient['stops'],
167 color: string,
168 position: number
169 ): number => {
170 let closestIndex = 0
171 let closestDistance = Number.POSITIVE_INFINITY
172 stops.forEach((stop, index) => {
173 if (stop.color !== color) return
174 const distance = Math.abs(stop.position - position)
175 if (distance < closestDistance) {
176 closestDistance = distance
177 closestIndex = index
178 }
179 })
180 return closestIndex
181 }
182
183 const getTrackPosition = (event: React.PointerEvent<HTMLDivElement>): number => {
184 const { left, width } = event.currentTarget.getBoundingClientRect()
185 if (width === 0) return 0
186 return Math.round(Math.min(100, Math.max(0, ((event.clientX - left) / width) * 100)))
187 }
188
189 const addStopAtPosition = (position: number): void => {
190 if (gradient.stops.length >= MAX_MASTER_GRADIENT_STOPS) return
191 const next = addMasterGradientStop(gradient, position)
192 setGradientDraft(next)
193 setSelectedStopIndex(
194 next.stops.reduce(
195 (closestIndex, stop, index) =>
196 Math.abs(stop.position - position) <
197 Math.abs(next.stops[closestIndex].position - position)
198 ? index
199 : closestIndex,
200 0
201 )
202 )
203 }
204
205 const startDraggingStop = (event: React.PointerEvent<HTMLButtonElement>, index: number): void => {
206 event.stopPropagation()
207 event.currentTarget.setPointerCapture(event.pointerId)
208 const stop = gradient.stops[index]
209 draggingStopRef.current = { color: stop.color, position: stop.position }
210 setSelectedStopIndex(index)
211 }
212
213 const moveDraggingStop = (event: React.PointerEvent<HTMLDivElement>): void => {
214 const draggingStop = draggingStopRef.current
215 if (!draggingStop) return
216 const position = getTrackPosition(event)
217 if (position === draggingStop.position) return
218 const sourceIndex = findStopIndex(gradient.stops, draggingStop.color, draggingStop.position)
219 const next = updateMasterGradientStop(gradient, sourceIndex, { position })
220 const nextIndex = findStopIndex(next.stops, draggingStop.color, position)
221 draggingStopRef.current = { color: draggingStop.color, position }
222 setSelectedStopIndex(nextIndex)
223 setGradientDraft(next)
224 }
225
226 const stopDragging = (): void => {
227 draggingStopRef.current = null
228 }
229
230 const removeSelectedStop = (): void => {
231 const next = removeMasterGradientStop(gradient, selectedStopIndex)
232 setGradientDraft(next)
233 setSelectedStopIndex((current) => Math.max(0, Math.min(current - 1, next.stops.length - 1)))
234 }
235
236 const applyGradient = (): void => {
237 updateConfig({
238 backgroundMode: 'override',
239 backgroundStyle: 'gradient',
240 backgroundGradient: normalizeMasterGradient(gradientDraft)
241 })
242 setActiveBackgroundStyle('gradient')
243 setEditorOpen(false)
244 }
245
246 const useBackgroundImage = (relativePath: string, absolutePath?: string): void => {
247 updateConfig({
248 backgroundMode: 'override',
249 backgroundStyle: 'image',
250 backgroundImage: relativePath
251 })
252 setActiveBackgroundStyle('image')
253 setImagePreviewUrl(absolutePath ? localAssetUrl(absolutePath) : null)
254 }
255
256 const uploadBackgroundImage = async (): Promise<void> => {
257 if (!sessionId || uploading) return
258 setUploading(true)
259 try {
260 const result = await ipc.chooseAndUploadAssets(sessionId, 'image')
261 const image = result.assets[0]
262 if (result.cancelled || !image) return
263 useBackgroundImage(image.relativePath, image.absolutePath)
264 } catch (error) {
265 toastError(
266 error instanceof Error
267 ? error.message
268 : t('sessionDetail.masterBackgroundImageUploadFailed')
269 )
270 } finally {
271 setUploading(false)
272 }
273 }
274
275 const clearBackgroundImage = (): void => {
276 updateConfig({ backgroundMode: 'override', backgroundStyle: 'solid', backgroundImage: null })
277 setActiveBackgroundStyle('solid')
278 setImagePreviewUrl(null)
279 }
280
281 return (
282 <>
283 <div className="space-y-2.5">
284 <div className="flex items-center justify-between gap-3 text-sm text-[#4a563d]">
285 <span>{t('sessionDetail.masterBackgroundStyle')}</span>
286 <ToggleGroup
287 type="single"
288 value={activeBackgroundStyle}
289 onValueChange={selectBackgroundStyle}
290 className="flex-wrap rounded-md border border-[#d7cbb7]/70 bg-[#fffdf8] p-0.5"
291 >
292 <ToggleGroupItem
293 value="solid"
294 className="w-auto gap-1 rounded px-2 text-[11px] font-medium"
295 >
296 <Circle className="h-3.5 w-3.5" />
297 {t('sessionDetail.masterBackgroundSolid')}
298 </ToggleGroupItem>
299 <ToggleGroupItem
300 value="gradient"
301 className="w-auto gap-1 rounded px-2 text-[11px] font-medium"
302 >
303 <Blend className="h-3.5 w-3.5" />
304 {t('sessionDetail.masterBackgroundGradient')}
305 </ToggleGroupItem>
306 <ToggleGroupItem
307 value="image"
308 className="w-auto gap-1 rounded px-2 text-[11px] font-medium"
309 >
310 <ImageIcon className="h-3.5 w-3.5" />
311 {t('sessionDetail.masterBackgroundImage')}
312 </ToggleGroupItem>
313 </ToggleGroup>
314 </div>
315
316 {!isGradient && !isImage ? (
317 <div className="flex items-center justify-between rounded-md border border-[#e4dac9] bg-[#fffdf8]/70 px-2.5 py-2 text-sm text-[#4a563d]">
318 <span>{t('sessionDetail.masterBackground')}</span>
319 <div className="flex items-center gap-2">
320 <span className="font-mono text-xs text-[#667257]">{config.backgroundColor}</span>
321 <ColorPicker
322 value={config.backgroundColor}
323 allowAlpha={false}
324 ariaLabel={t('sessionDetail.masterBackground')}
325 onChange={(backgroundColor) =>
326 updateConfig({ backgroundColor, backgroundMode: 'override' })
327 }
328 />
329 </div>
330 </div>
331 ) : isGradient ? (
332 <Tooltip>
333 <TooltipTrigger asChild>
334 <button
335 type="button"
336 aria-label={t('sessionDetail.masterGradientConfigure')}
337 className="block h-12 w-full cursor-pointer overflow-hidden rounded-md border border-[#d7cbb7]/75 shadow-[inset_0_1px_2px_rgba(74,59,42,0.08)] transition-shadow hover:shadow-[inset_0_0_0_1px_rgba(80,102,66,0.72)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5d6b4d]"
338 style={{ background: buildMasterGradientCss(config.backgroundGradient) }}
339 onClick={openGradientEditor}
340 />
341 </TooltipTrigger>
342 <TooltipContent>{t('sessionDetail.masterGradientConfigure')}</TooltipContent>
343 </Tooltip>
344 ) : (
345 <div className="overflow-hidden rounded-md border border-[#e4dac9] bg-[#fffdf8]/70">
346 <div className="flex h-[196px] items-center justify-center bg-[#eee8dc] p-2">
347 <div
348 className="relative h-full max-w-full overflow-hidden"
349 style={{ aspectRatio: `${slideSize.width}/${slideSize.height}` }}
350 >
351 {imagePreviewUrl ? (
352 <img src={imagePreviewUrl} alt="" className="h-full w-full object-cover" />
353 ) : (
354 <div className="flex h-full items-center justify-center gap-2 text-xs text-[#667257]">
355 <ImageIcon className="h-4 w-4" />
356 <span>{t('sessionDetail.masterBackgroundImageEmpty')}</span>
357 </div>
358 )}
359 {config.backgroundImage && (
360 <Tooltip>
361 <TooltipTrigger asChild>
362 <Button
363 type="button"
364 variant="ghost"
365 size="sm"
366 className="absolute right-2 top-2 h-7 w-7 rounded-md bg-[#fffdf8]/90 p-0 text-[#667257] shadow-sm hover:bg-white hover:text-[#a14f4a]"
367 aria-label={t('sessionDetail.masterBackgroundImageClear')}
368 onClick={clearBackgroundImage}
369 >
370 <X className="h-3.5 w-3.5" />
371 </Button>
372 </TooltipTrigger>
373 <TooltipContent>{t('sessionDetail.masterBackgroundImageClear')}</TooltipContent>
374 </Tooltip>
375 )}
376 </div>
377 </div>
378 <div className="flex items-center justify-end gap-2 px-2.5 py-2">
379 <Button
380 type="button"
381 variant="outline"
382 size="sm"
383 className="h-7 gap-1.5 text-xs"
384 disabled={!sessionId || uploading}
385 onClick={() => setAssetPickerOpen(true)}
386 >
387 <FolderOpen className="h-3.5 w-3.5" />
388 {t('sessionDetail.masterBackgroundImageChoose')}
389 </Button>
390 <Button
391 type="button"
392 size="sm"
393 className="h-7 gap-1.5 text-xs"
394 disabled={!sessionId || uploading}
395 onClick={() => void uploadBackgroundImage()}
396 >
397 <Upload className="h-3.5 w-3.5" />
398 {t('sessionDetail.masterBackgroundImageUpload')}
399 </Button>
400 </div>
401 </div>
402 )}
403 </div>
404
405 <Dialog
406 open={editorOpen}
407 onOpenChange={(nextOpen) => {
408 if (!nextOpen) cancelGradientEditor()
409 }}
410 >
411 <DialogContent className="!max-w-[520px] gap-3 p-4">
412 <DialogHeader>
413 <DialogTitle>{t('sessionDetail.masterGradientEditorTitle')}</DialogTitle>
414 <DialogDescription className="sr-only">
415 {t('sessionDetail.masterGradientEditorDescription')}
416 </DialogDescription>
417 </DialogHeader>
418
419 <div className="space-y-3">
420 <div
421 aria-label={t('sessionDetail.masterGradientPreview')}
422 className="h-16 overflow-hidden rounded-md border border-[#d7cbb7]/75 shadow-[inset_0_1px_2px_rgba(74,59,42,0.08)]"
423 style={{ background: buildMasterGradientCss(gradient) }}
424 />
425
426 <div className="space-y-2 rounded-md border border-[#e4dac9] bg-[#fffdf8]/70 p-2.5">
427 <div className="flex items-center justify-between gap-2 text-xs text-[#667257]">
428 <span>{t('sessionDetail.masterGradientType')}</span>
429 <ToggleGroup
430 type="single"
431 value={gradient.type}
432 onValueChange={(type) => {
433 if (type === 'linear' || type === 'radial') {
434 setGradientDraft(normalizeMasterGradient({ ...gradient, type }))
435 }
436 }}
437 className="rounded-md border border-[#d7cbb7]/70 bg-[#fffdf8] p-0.5"
438 >
439 <ToggleGroupItem
440 value="linear"
441 className="w-auto rounded px-2 text-[11px] font-medium"
442 >
443 {t('sessionDetail.masterGradientLinear')}
444 </ToggleGroupItem>
445 <ToggleGroupItem
446 value="radial"
447 className="w-auto rounded px-2 text-[11px] font-medium"
448 >
449 {t('sessionDetail.masterGradientRadial')}
450 </ToggleGroupItem>
451 </ToggleGroup>
452 </div>
453
454 {gradient.type === 'linear' && (
455 <>
456 <div className="flex items-center justify-between gap-2">
457 <span className="text-xs text-[#667257]">
458 {t('sessionDetail.masterGradientAngle')}
459 </span>
460 <div className="flex items-center gap-1">
461 {directionPresets.map(({ angle, Icon }) => (
462 <button
463 key={angle}
464 type="button"
465 aria-label={`${t('sessionDetail.masterGradientAngle')} ${angle}°`}
466 title={`${t('sessionDetail.masterGradientAngle')} ${angle}°`}
467 className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-[#667257] transition-colors hover:bg-[#ebe4d6] hover:text-[#3e4a32] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5d6b4d]"
468 onClick={() => updateAngle(angle)}
469 >
470 <Icon className="h-3.5 w-3.5" />
471 </button>
472 ))}
473 </div>
474 </div>
475 <label className="grid grid-cols-[1fr_48px] items-center gap-2">
476 <input
477 type="range"
478 min={0}
479 max={359}
480 value={gradient.angle}
481 aria-label={t('sessionDetail.masterGradientAngle')}
482 className="h-2 w-full cursor-pointer accent-[#5d6b4d]"
483 onChange={(event) => updateAngle(Number(event.target.value))}
484 />
485 <Input
486 type="number"
487 min={0}
488 max={359}
489 value={gradient.angle}
490 aria-label={t('sessionDetail.masterGradientAngle')}
491 className="h-7 rounded-md border-[#d7cbb7]/70 bg-[#fffdf8] px-1 text-center text-xs"
492 onChange={(event) => updateAngle(Number(event.target.value))}
493 />
494 </label>
495 </>
496 )}
497 </div>
498
499 <div className="space-y-2">
500 <span className="text-xs text-[#667257]">
501 {t('sessionDetail.masterGradientStops')}
502 </span>
503 <div
504 aria-label={t('sessionDetail.masterGradientAddStop')}
505 title={t('sessionDetail.masterGradientAddStop')}
506 className="relative h-10 cursor-copy select-none touch-none rounded-md border border-[#d7cbb7]/75"
507 style={{ background: buildMasterGradientCss(gradient) }}
508 onPointerDown={(event) => {
509 if (event.target === event.currentTarget)
510 addStopAtPosition(getTrackPosition(event))
511 }}
512 onPointerMove={moveDraggingStop}
513 onPointerUp={stopDragging}
514 onPointerCancel={stopDragging}
515 >
516 {gradient.stops.map((stop, index) => (
517 <button
518 key={`${stop.color}-${stop.position}-${index}`}
519 type="button"
520 aria-label={t('sessionDetail.masterGradientStop', { position: stop.position })}
521 title={t('sessionDetail.masterGradientStop', { position: stop.position })}
522 className="absolute top-1/2 z-10 h-9 w-4 -translate-y-1/2 cursor-grab rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(62,74,50,0.54)] active:cursor-grabbing focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5d6b4d]"
523 style={{
524 backgroundColor: stop.color,
525 left: `calc(${stop.position}% - 8px)`,
526 outline: index === selectedStopIndex ? '2px solid #314028' : undefined
527 }}
528 onPointerDown={(event) => startDraggingStop(event, index)}
529 onPointerUp={stopDragging}
530 onPointerCancel={stopDragging}
531 />
532 ))}
533 </div>
534 </div>
535
536 {selectedStop && (
537 <div className="grid grid-cols-[40px_1fr_46px_28px] items-center gap-2 rounded-md border border-[#e4dac9] bg-[#fffdf8]/70 p-2">
538 <ColorPicker
539 value={selectedStop.color}
540 allowAlpha={false}
541 ariaLabel={t('sessionDetail.masterGradientStopColor')}
542 onChange={(color) => updateSelectedStop({ color })}
543 />
544 <input
545 type="range"
546 min={0}
547 max={100}
548 value={selectedStop.position}
549 aria-label={t('sessionDetail.masterGradientStop', {
550 position: selectedStop.position
551 })}
552 className="h-2 w-full cursor-pointer accent-[#5d6b4d]"
553 onChange={(event) => updateSelectedStop({ position: Number(event.target.value) })}
554 />
555 <Input
556 type="number"
557 min={0}
558 max={100}
559 value={selectedStop.position}
560 aria-label={t('sessionDetail.masterGradientStop', {
561 position: selectedStop.position
562 })}
563 className="h-7 rounded-md border-[#d7cbb7]/70 bg-[#fffdf8] px-1 text-center text-xs"
564 onChange={(event) => updateSelectedStop({ position: Number(event.target.value) })}
565 />
566 <Tooltip>
567 <TooltipTrigger asChild>
568 <Button
569 type="button"
570 variant="ghost"
571 size="sm"
572 className="h-7 w-7 cursor-pointer rounded-md p-0 text-[#a14f4a] hover:text-[#8d3b36]"
573 disabled={gradient.stops.length <= MIN_MASTER_GRADIENT_STOPS}
574 onClick={removeSelectedStop}
575 aria-label={t('sessionDetail.masterGradientRemoveStop')}
576 >
577 <Trash2 className="h-3.5 w-3.5" />
578 </Button>
579 </TooltipTrigger>
580 <TooltipContent>{t('sessionDetail.masterGradientRemoveStop')}</TooltipContent>
581 </Tooltip>
582 </div>
583 )}
584
585 <div className="space-y-2">
586 <span className="text-xs text-[#667257]">
587 {t('sessionDetail.masterGradientPresets')}
588 </span>
589 <div className="grid grid-cols-8 gap-1.5">
590 {gradientPresets.map((colors) => (
591 <button
592 key={colors.join('-')}
593 type="button"
594 aria-label={t('sessionDetail.masterGradientPresets')}
595 title={colors.join(' → ')}
596 className="h-7 cursor-pointer rounded-md border border-[#d7cbb7]/70 transition-transform hover:-translate-y-0.5 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5d6b4d]"
597 style={{ background: `linear-gradient(135deg, ${colors.join(', ')})` }}
598 onClick={() => applyPreset(colors)}
599 />
600 ))}
601 </div>
602 </div>
603 </div>
604 <DialogFooter>
605 <Button type="button" variant="ghost" size="sm" onClick={cancelGradientEditor}>
606 {t('common.cancel')}
607 </Button>
608 <Button type="button" size="sm" onClick={applyGradient}>
609 {t('sessionDetail.masterGradientConfirm')}
610 </Button>
611 </DialogFooter>
612 </DialogContent>
613 </Dialog>
614
615 <AssetPickerDialog
616 sessionId={sessionId}
617 assetType="image"
618 open={assetPickerOpen}
619 onClose={() => setAssetPickerOpen(false)}
620 onConfirm={(relativePath) => useBackgroundImage(relativePath)}
621 />
622 </>
623 )
624 }
625
625 lines Plain Text