| 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 |