Spaces:
Sleeping
Sleeping
Claude Sonnet 4.6
Make UI responsive for mobile: scale canvas, scrollable tabs, flexible toolbar
4a4b2e4 Download frontend/src/components/ObjectRemover.tsx from junyoung137/image-tool: direct link, hf CLI and curl.
- Browser
- Download file 29.3 kB
-
https://huggingface.co/spaces/junyoung137/image-tool/resolve/main/frontend/src/components/ObjectRemover.tsx
- Command line
-
hf download hf://spaces/junyoung137/image-tool/frontend/src/components/ObjectRemover.tsx
-
curl -L -o ObjectRemover.tsx https://huggingface.co/spaces/junyoung137/image-tool/resolve/main/frontend/src/components/ObjectRemover.tsx
29.3 kB
| /** | |
| * ObjectRemover.tsx — self-contained component for the "객체 제거" tab. | |
| * | |
| * Fully independent of EditorProvider — all state is local. | |
| * Phases: "upload" → "uploading" → "painting" → "generating" → "completed" | "failed" | |
| * | |
| * Canvas logic is shared with MaskEditor via useMaskCanvas. | |
| * Polling is shared with MaskEditor via useJobPolling (custom fetcher). | |
| * Storage lives under storage/object-removal/{job_id}/steps/{n}/. | |
| */ | |
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import { | |
| Scissors, | |
| Upload, | |
| Paintbrush, | |
| Eraser, | |
| Undo2, | |
| Download, | |
| PencilLine, | |
| RotateCcw, | |
| History, | |
| RefreshCw, | |
| } from "lucide-react"; | |
| import { useMaskCanvas } from "../hooks/useMaskCanvas"; | |
| import { useJobPolling } from "../hooks/useJobPolling"; | |
| import { | |
| uploadForObjectRemoval, | |
| generateObjectRemoval, | |
| getObjectRemovalResult, | |
| } from "../api/objectRemovalClient"; | |
| import { goToStep, API_BASE } from "../api/client"; | |
| import type { StepSummary } from "../api/client"; | |
| // --------------------------------------------------------------------------- | |
| // Types | |
| // --------------------------------------------------------------------------- | |
| type Phase = "upload" | "uploading" | "painting" | "generating" | "completed" | "failed"; | |
| // --------------------------------------------------------------------------- | |
| // StepHistory — inline copy (same as MaskEditor; not imported to keep components independent) | |
| // --------------------------------------------------------------------------- | |
| function StepHistory({ | |
| steps, | |
| activeStepNumber, | |
| currentStep, | |
| onGotoStep, | |
| }: { | |
| steps: StepSummary[]; | |
| activeStepNumber: number; | |
| currentStep: number; | |
| onGotoStep: (n: number) => void; | |
| }) { | |
| const completed = steps | |
| .filter(s => s.status === "completed") | |
| .sort((a, b) => a.step_number - b.step_number); | |
| if (completed.length <= 1) return null; | |
| const maxStep = completed[completed.length - 1].step_number; | |
| return ( | |
| <div style={histStyles.wrap}> | |
| <span style={histStyles.label}> | |
| <History size={13} style={{ verticalAlign: "middle", marginRight: 4 }} /> | |
| 히스토리 | |
| </span> | |
| <div style={histStyles.strip}> | |
| {completed.map(s => { | |
| const isActive = s.step_number === activeStepNumber; | |
| return ( | |
| <button | |
| key={s.step_number} | |
| onClick={() => onGotoStep(s.step_number)} | |
| style={{ | |
| ...histStyles.stepBtn, | |
| ...(isActive ? histStyles.stepBtnActive : {}), | |
| }} | |
| title={s.step_number === 0 ? "업로드 원본" : `Step ${s.step_number} 결과`} | |
| > | |
| {s.step_number === 0 ? "원본" : `Step ${s.step_number}`} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {currentStep < maxStep && ( | |
| <p style={histStyles.discardNote}> | |
| 여기서 추가 작업 시 Step {currentStep + 1}–{maxStep}이 삭제됩니다. | |
| </p> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const histStyles: Record<string, React.CSSProperties> = { | |
| wrap: { | |
| marginTop: 24, | |
| padding: "14px 16px", | |
| background: "#ffffff", | |
| border: "1px solid #e8eaed", | |
| borderRadius: 10, | |
| boxShadow: "0 1px 6px rgba(0,0,0,0.10)", | |
| }, | |
| label: { | |
| fontSize: 11, | |
| fontWeight: 700, | |
| color: "#94a3b8", | |
| textTransform: "uppercase" as const, | |
| letterSpacing: "0.07em", | |
| display: "block", | |
| marginBottom: 10, | |
| }, | |
| strip: { | |
| display: "flex", | |
| gap: 6, | |
| flexWrap: "wrap" as const, | |
| }, | |
| stepBtn: { | |
| padding: "4px 12px", | |
| background: "#fff", | |
| border: "1px solid #e2e8f0", | |
| borderRadius: 6, | |
| cursor: "pointer", | |
| fontSize: 13, | |
| fontWeight: 500, | |
| color: "#475569", | |
| }, | |
| stepBtnActive: { | |
| background: "#3b82f6", | |
| border: "1px solid #2563eb", | |
| color: "#fff", | |
| fontWeight: 600, | |
| boxShadow: "0 1px 3px rgba(59,130,246,0.3)", | |
| }, | |
| discardNote: { | |
| margin: "12px 0 0", | |
| fontSize: 12, | |
| color: "#92400e", | |
| background: "#fffbeb", | |
| border: "1px solid #fde68a", | |
| borderRadius: 6, | |
| padding: "4px 10px", | |
| display: "inline-block", | |
| }, | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Main component | |
| // --------------------------------------------------------------------------- | |
| export function ObjectRemover() { | |
| const [imageFile, setImageFile] = useState<File | null>(null); | |
| const [imageNaturalWidth, setImageNaturalWidth] = useState<number | null>(null); | |
| const [imageNaturalHeight, setImageNaturalHeight] = useState<number | null>(null); | |
| const [jobId, setJobId] = useState<string | null>(null); | |
| const [phase, setPhase] = useState<Phase>("upload"); | |
| const [uploadError, setUploadError] = useState<string | null>(null); | |
| const [generateError, setGenerateError] = useState<string | null>(null); | |
| const [viewedStepNumber, setViewedStepNumber] = useState<number | null>(null); | |
| const [isDragOver, setIsDragOver] = useState(false); | |
| // CSS scale factor applied to the canvas wrapper so it fits narrow viewports. | |
| const [canvasScale, setCanvasScale] = useState(1); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| // ── Canvas hook ──────────────────────────────────────────────────────────── | |
| const { | |
| canvasElRef, | |
| displayWidth, | |
| displayHeight, | |
| toOriginalScale, | |
| brushSize, | |
| setBrushSize, | |
| strokeCount, | |
| buildMaskJson, | |
| handleClear, | |
| handleUndo, | |
| setDrawingMode, | |
| setBaseUrl, | |
| } = useMaskCanvas({ imageFile, imageNaturalWidth, imageNaturalHeight }); | |
| // ── Polling hook with object-removal fetcher ─────────────────────────────── | |
| const { | |
| pollPhase, | |
| errorMessage: pollError, | |
| steps, | |
| currentStep, | |
| startPolling, | |
| refreshResult, | |
| reset: resetPoll, | |
| resetPollPhase, | |
| } = useJobPolling(getObjectRemovalResult); | |
| // ── Phase sync effects ───────────────────────────────────────────────────── | |
| useEffect(() => { | |
| if (pollPhase === "completed" && phase !== "completed") { | |
| setPhase("completed"); | |
| setViewedStepNumber(null); | |
| } | |
| if (pollPhase === "failed") setPhase("failed"); | |
| }, [pollPhase, phase]); | |
| useEffect(() => { | |
| setDrawingMode(phase === "painting"); | |
| }, [phase, setDrawingMode]); | |
| // ── Canvas CSS scale — keeps canvas within page width on mobile ──────────── | |
| // Fabric.js uses getBoundingClientRect() internally, so coordinate mapping | |
| // stays correct when a CSS transform scale is applied to the canvas wrapper. | |
| useEffect(() => { | |
| if (!displayWidth) return; | |
| const update = () => { | |
| const avail = Math.min(window.innerWidth - 48, 880); | |
| setCanvasScale(Math.min(1, avail / displayWidth)); | |
| }; | |
| update(); | |
| window.addEventListener("resize", update); | |
| return () => window.removeEventListener("resize", update); | |
| }, [displayWidth]); | |
| // ── Upload handling ──────────────────────────────────────────────────────── | |
| const handleFile = useCallback(async (file: File) => { | |
| if (!file.type.startsWith("image/")) { | |
| setUploadError(`"${file.name}"은(는) 이미지 파일이 아닙니다.`); | |
| return; | |
| } | |
| setUploadError(null); | |
| setPhase("uploading"); | |
| try { | |
| // Backend returns EXIF-corrected width/height — use directly | |
| const { job_id, width, height } = await uploadForObjectRemoval(file); | |
| setImageFile(file); | |
| setImageNaturalWidth(width); | |
| setImageNaturalHeight(height); | |
| setJobId(job_id); | |
| setPhase("painting"); | |
| } catch (e) { | |
| setUploadError((e as Error).message); | |
| setPhase("upload"); | |
| } | |
| }, []); | |
| const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (file) handleFile(file); | |
| }; | |
| const onDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); }; | |
| const onDragLeave = () => setIsDragOver(false); | |
| const onDrop = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setIsDragOver(false); | |
| const file = e.dataTransfer.files?.[0]; | |
| if (file) handleFile(file); | |
| }; | |
| // ── Generate ─────────────────────────────────────────────────────────────── | |
| const handleGenerate = async () => { | |
| if (!jobId) return; | |
| const maskJson = buildMaskJson(); | |
| if (!maskJson) return; | |
| setPhase("generating"); | |
| setGenerateError(null); | |
| try { | |
| await generateObjectRemoval(jobId, maskJson); | |
| startPolling(jobId); | |
| } catch (err) { | |
| setGenerateError((err as Error).message); | |
| setPhase("failed"); | |
| } | |
| }; | |
| // ── Do More Work ─────────────────────────────────────────────────────────── | |
| const handleDoMoreWork = () => { | |
| const latestStepData = steps.find(s => s.step_number === currentStep); | |
| if (!latestStepData?.result_image_url) return; | |
| resetPollPhase(); | |
| setBaseUrl(`${API_BASE}${latestStepData.result_image_url}`); | |
| setViewedStepNumber(null); | |
| setGenerateError(null); | |
| setPhase("painting"); | |
| }; | |
| // ── Step navigation ──────────────────────────────────────────────────────── | |
| const handleGotoStep = async (stepNumber: number) => { | |
| if (!jobId) return; | |
| try { | |
| await goToStep(jobId, stepNumber); | |
| setViewedStepNumber(stepNumber); | |
| refreshResult(jobId); | |
| } catch { /* non-critical */ } | |
| }; | |
| // ── Try Again ────────────────────────────────────────────────────────────── | |
| const handleTryAgain = () => { | |
| setGenerateError(null); | |
| setPhase("painting"); | |
| }; | |
| // ── Start Over ───────────────────────────────────────────────────────────── | |
| const handleStartOver = () => { | |
| resetPoll(); | |
| setImageFile(null); | |
| setImageNaturalWidth(null); | |
| setImageNaturalHeight(null); | |
| setJobId(null); | |
| setViewedStepNumber(null); | |
| setUploadError(null); | |
| setGenerateError(null); | |
| setPhase("upload"); | |
| }; | |
| // ── Download ─────────────────────────────────────────────────────────────── | |
| const handleDownload = async (imageUrl: string | null | undefined) => { | |
| if (!imageUrl) return; | |
| try { | |
| const res = await fetch(`${API_BASE}${imageUrl}`); | |
| const blob = await res.blob(); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = "result.jpg"; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } catch { /* user can right-click as fallback */ } | |
| }; | |
| // ── Upload phase UI ──────────────────────────────────────────────────────── | |
| if (phase === "upload" || phase === "uploading") { | |
| const isLoading = phase === "uploading"; | |
| return ( | |
| <div style={styles.page}> | |
| <h1 style={styles.heading}> | |
| <Scissors size={20} style={{ verticalAlign: "middle", marginRight: 8 }} /> | |
| 오브젝트 제거 | |
| </h1> | |
| <p style={styles.sub}>지우고 싶은 물체가 있는 이미지를 업로드하세요.</p> | |
| <div | |
| style={{ | |
| ...styles.dropZone, | |
| ...(isDragOver ? styles.dropZoneActive : {}), | |
| ...(isLoading ? styles.dropZoneDisabled : {}), | |
| }} | |
| onDragOver={isLoading ? undefined : onDragOver} | |
| onDragLeave={isLoading ? undefined : onDragLeave} | |
| onDrop={isLoading ? undefined : onDrop} | |
| onClick={() => !isLoading && fileInputRef.current?.click()} | |
| role="button" | |
| tabIndex={0} | |
| onKeyDown={(e) => e.key === "Enter" && fileInputRef.current?.click()} | |
| aria-label="이미지 업로드" | |
| > | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="image/*" | |
| style={{ display: "none" }} | |
| onChange={onInputChange} | |
| disabled={isLoading} | |
| /> | |
| {isLoading ? ( | |
| <p style={styles.statusText}>업로드 중…</p> | |
| ) : ( | |
| <> | |
| <Upload size={32} color="#aaa" style={{ marginBottom: 10 }} /> | |
| <p style={styles.dropLabel}> | |
| {isDragOver ? "여기에 놓으세요!" : "클릭하거나 이미지를 여기에 드래그하세요"} | |
| </p> | |
| <p style={styles.dropHint}>JPEG, PNG, WEBP, HEIC…</p> | |
| </> | |
| )} | |
| </div> | |
| {uploadError && ( | |
| <div style={styles.errorBox}> | |
| <strong>오류: </strong>{uploadError} | |
| <button | |
| style={styles.retryBtn} | |
| onClick={() => { setUploadError(null); }} | |
| > | |
| <RefreshCw size={13} style={{ marginRight: 4, verticalAlign: "middle" }} /> | |
| 다시 시도 | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ── Canvas phase UI ──────────────────────────────────────────────────────── | |
| const isScaledDown = toOriginalScale > 1.0; | |
| const isPainting = phase === "painting"; | |
| const isGenerating = phase === "generating"; | |
| const isCompleted = phase === "completed"; | |
| const isFailed = phase === "failed"; | |
| const displayError = generateError ?? pollError; | |
| const effectiveViewedStep = viewedStepNumber ?? currentStep; | |
| const displayedStep = steps.find(s => s.step_number === effectiveViewedStep) ?? null; | |
| const latestStepData = steps.find(s => s.step_number === currentStep) ?? null; | |
| const canDoMoreWork = isCompleted && !!latestStepData?.result_image_url; | |
| return ( | |
| <div style={styles.page}> | |
| {/* Header */} | |
| <div style={styles.header}> | |
| <h2 style={styles.heading2}>지우고 싶은 부분을 칠하세요</h2> | |
| <p style={styles.meta}> | |
| Job: <code>{jobId}</code> | |
| · 원본: {imageNaturalWidth}×{imageNaturalHeight}px | |
| {isScaledDown | |
| ? ` · 캔버스: ${displayWidth}×${displayHeight}px · ${(1 / toOriginalScale).toFixed(3)}×` | |
| : " · 원본 크기 표시 (1.0×)"} | |
| </p> | |
| </div> | |
| {/* Toolbar — visible only while painting */} | |
| {isPainting && ( | |
| <div style={styles.toolbar}> | |
| <label style={styles.label}> | |
| <Paintbrush size={15} style={styles.iconGap} /> | |
| 브러시: <strong>{brushSize}px</strong> | |
| <input | |
| type="range" | |
| min={5} | |
| max={60} | |
| value={brushSize} | |
| onChange={e => setBrushSize(Number(e.target.value))} | |
| style={styles.slider} | |
| /> | |
| </label> | |
| <button | |
| style={styles.btnSecondary} | |
| onClick={handleClear} | |
| disabled={strokeCount === 0} | |
| > | |
| <Eraser size={15} style={styles.iconGap} /> | |
| 지우기 ({strokeCount}) | |
| </button> | |
| <button | |
| style={styles.btnSecondary} | |
| onClick={handleUndo} | |
| disabled={strokeCount === 0} | |
| > | |
| <Undo2 size={15} style={styles.iconGap} /> | |
| 실행 취소 | |
| </button> | |
| <button | |
| style={{ | |
| ...styles.btnPrimary, | |
| ...(strokeCount === 0 ? styles.btnDisabled : {}), | |
| }} | |
| onClick={handleGenerate} | |
| disabled={strokeCount === 0} | |
| > | |
| <Scissors size={15} style={styles.iconGap} /> | |
| 오브젝트 제거 | |
| </button> | |
| </div> | |
| )} | |
| {/* Canvas + overlay — scales down to fit narrow viewports */} | |
| <div style={{ width: "100%", overflow: "hidden" }}> | |
| <div style={{ | |
| position: "relative", | |
| width: displayWidth * canvasScale, | |
| height: displayHeight * canvasScale, | |
| }}> | |
| <div style={{ | |
| position: "absolute", | |
| top: 0, | |
| left: 0, | |
| width: displayWidth, | |
| height: displayHeight, | |
| transform: canvasScale < 1 ? `scale(${canvasScale})` : undefined, | |
| transformOrigin: "top left", | |
| }}> | |
| <div style={{ ...styles.canvasWrapper, width: displayWidth, height: displayHeight }}> | |
| <canvas ref={canvasElRef} /> | |
| </div> | |
| </div> | |
| {isGenerating && ( | |
| <div style={styles.overlay}> | |
| <div style={styles.overlayCard}> | |
| <Spinner /> | |
| <p style={styles.overlayTitle}>오브젝트 제거 중…</p> | |
| <p style={styles.overlaySubtitle}>2초마다 결과 확인 중</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Painting hint */} | |
| {isPainting && ( | |
| <p style={styles.hint}> | |
| {strokeCount === 0 | |
| ? <>지우고 싶은 부분을 빨간색으로 칠한 뒤 <strong>오브젝트 제거</strong>를 클릭하세요.</> | |
| : <>{strokeCount}개의 획을 그렸습니다 — 수정하거나 <strong>오브젝트 제거</strong>를 클릭하세요.</>} | |
| </p> | |
| )} | |
| {/* Completed result section */} | |
| {isCompleted && displayedStep && ( | |
| <div style={styles.resultSection}> | |
| <StepHistory | |
| steps={steps} | |
| activeStepNumber={effectiveViewedStep} | |
| currentStep={currentStep} | |
| onGotoStep={handleGotoStep} | |
| /> | |
| <div style={styles.comparisonHeader}> | |
| {effectiveViewedStep === 0 ? ( | |
| <p style={styles.bannerTitle}>Step 0 — 업로드 원본</p> | |
| ) : ( | |
| <p style={styles.bannerTitle}>✓ Step {effectiveViewedStep} 완료.</p> | |
| )} | |
| </div> | |
| <div style={styles.comparison}> | |
| {effectiveViewedStep > 0 && ( | |
| <div style={styles.compareCol}> | |
| <p style={styles.compareLabel}>이전</p> | |
| {displayedStep.original_image_url && ( | |
| <img | |
| src={`${API_BASE}${displayedStep.original_image_url}`} | |
| alt="Before" | |
| style={styles.compareImg} | |
| /> | |
| )} | |
| </div> | |
| )} | |
| <div style={styles.compareCol}> | |
| <p style={styles.compareLabel}> | |
| {effectiveViewedStep === 0 ? "원본" : "이후"} | |
| </p> | |
| {displayedStep.result_image_url && ( | |
| <img | |
| src={`${API_BASE}${displayedStep.result_image_url}`} | |
| alt="After" | |
| style={styles.compareImg} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| <div style={styles.resultActions}> | |
| {canDoMoreWork && ( | |
| <button style={styles.btnPrimary} onClick={handleDoMoreWork}> | |
| <PencilLine size={15} style={styles.iconGap} /> | |
| 추가 작업하기 | |
| </button> | |
| )} | |
| <button | |
| style={styles.btnSecondary} | |
| onClick={() => handleDownload(displayedStep.result_image_url)} | |
| > | |
| <Download size={15} style={styles.iconGap} /> | |
| 다운로드 | |
| </button> | |
| <button style={styles.btnGhost} onClick={handleStartOver}> | |
| <RotateCcw size={14} style={styles.iconGap} /> | |
| 처음부터 | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Failed section */} | |
| {isFailed && ( | |
| <div style={styles.errorBanner}> | |
| <p style={styles.bannerTitle}>✗ 제거에 실패했습니다.</p> | |
| <p style={styles.bannerSub}> | |
| <strong>오류:</strong> {displayError} | |
| </p> | |
| <p style={styles.bannerSub}>획은 보존되어 있습니다 — 다시 시도해 보세요.</p> | |
| {steps.length > 1 && ( | |
| <StepHistory | |
| steps={steps} | |
| activeStepNumber={effectiveViewedStep} | |
| currentStep={currentStep} | |
| onGotoStep={handleGotoStep} | |
| /> | |
| )} | |
| <div style={{ display: "flex", gap: 10, marginTop: 14 }}> | |
| <button style={styles.btnPrimary} onClick={handleTryAgain}> | |
| ← 다시 그리기 | |
| </button> | |
| <button style={styles.btnGhost} onClick={handleStartOver}> | |
| <RotateCcw size={14} style={styles.iconGap} /> | |
| 처음부터 | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Spinner | |
| // --------------------------------------------------------------------------- | |
| function Spinner() { | |
| return <div style={spinnerStyle} />; | |
| } | |
| const spinnerStyle: React.CSSProperties = { | |
| width: 36, | |
| height: 36, | |
| border: "4px solid #e5e7eb", | |
| borderTop: "4px solid #3b82f6", | |
| borderRadius: "50%", | |
| animation: "spin 0.9s linear infinite", | |
| }; | |
| if (typeof document !== "undefined") { | |
| const id = "__obj_remover_spin"; | |
| if (!document.getElementById(id)) { | |
| const s = document.createElement("style"); | |
| s.id = id; | |
| s.textContent = "@keyframes spin { to { transform: rotate(360deg); } }"; | |
| document.head.appendChild(s); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Styles | |
| // --------------------------------------------------------------------------- | |
| const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"; | |
| const styles: Record<string, React.CSSProperties> = { | |
| page: { | |
| maxWidth: 880, | |
| margin: "48px auto 48px", | |
| padding: "0 24px", | |
| fontFamily: FONT, | |
| color: "#111827", | |
| }, | |
| heading: { | |
| fontSize: 22, | |
| fontWeight: 600, | |
| marginBottom: 4, | |
| letterSpacing: "-0.01em", | |
| color: "#111827", | |
| }, | |
| heading2: { | |
| fontSize: 22, | |
| fontWeight: 600, | |
| margin: "0 0 4px", | |
| letterSpacing: "-0.01em", | |
| color: "#111827", | |
| }, | |
| sub: { | |
| color: "#6b7280", | |
| fontSize: 14, | |
| margin: "0 0 24px", | |
| }, | |
| header: { | |
| marginBottom: 24, | |
| paddingBottom: 16, | |
| borderBottom: "1px solid #f0f2f4", | |
| }, | |
| meta: { | |
| fontSize: 12, | |
| color: "#9ca3af", | |
| margin: 0, | |
| }, | |
| dropZone: { | |
| border: "2px dashed #b0b7c3", | |
| borderRadius: 12, | |
| minHeight: 220, | |
| display: "flex", | |
| flexDirection: "column", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| cursor: "pointer", | |
| transition: "border-color 0.15s, background 0.15s", | |
| background: "#ffffff", | |
| overflow: "hidden", | |
| boxShadow: "0 1px 4px rgba(0,0,0,0.08)", | |
| }, | |
| dropZoneActive: { | |
| borderColor: "#3b82f6", | |
| background: "#eff6ff", | |
| }, | |
| dropZoneDisabled: { | |
| cursor: "not-allowed", | |
| opacity: 0.6, | |
| }, | |
| dropLabel: { | |
| fontSize: 14, | |
| fontWeight: 500, | |
| color: "#374151", | |
| margin: 0, | |
| }, | |
| dropHint: { | |
| fontSize: 12, | |
| color: "#9ca3af", | |
| marginTop: 4, | |
| }, | |
| statusText: { | |
| color: "#3b82f6", | |
| fontSize: 14, | |
| }, | |
| errorBox: { | |
| marginTop: 16, | |
| padding: "12px 16px", | |
| background: "#fef2f2", | |
| border: "1px solid #fca5a5", | |
| borderRadius: 8, | |
| color: "#b91c1c", | |
| fontSize: 13, | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 12, | |
| boxShadow: "0 1px 3px rgba(239,68,68,0.08)", | |
| }, | |
| retryBtn: { | |
| marginLeft: "auto", | |
| padding: "4px 12px", | |
| background: "#fff", | |
| border: "1px solid #fca5a5", | |
| borderRadius: 6, | |
| cursor: "pointer", | |
| fontSize: 13, | |
| color: "#b91c1c", | |
| }, | |
| toolbar: { | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 8, | |
| marginBottom: 16, | |
| padding: "10px 12px", | |
| background: "#ffffff", | |
| borderRadius: 8, | |
| border: "1px solid #e8eaed", | |
| flexWrap: "wrap", | |
| }, | |
| label: { | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 6, | |
| fontSize: 14, | |
| color: "#374151", | |
| flex: "1 1 auto", | |
| minWidth: 0, | |
| }, | |
| iconGap: { | |
| marginRight: 4, | |
| verticalAlign: "middle", | |
| flexShrink: 0, | |
| } as React.CSSProperties, | |
| slider: { | |
| flex: "1 1 60px", | |
| minWidth: 60, | |
| maxWidth: 160, | |
| marginLeft: 6, | |
| accentColor: "#3b82f6", | |
| }, | |
| btnPrimary: { | |
| display: "inline-flex", | |
| alignItems: "center", | |
| padding: "8px 18px", | |
| background: "#3b82f6", | |
| border: "none", | |
| borderRadius: 7, | |
| cursor: "pointer", | |
| fontSize: 14, | |
| fontWeight: 600, | |
| color: "#fff", | |
| letterSpacing: "-0.01em", | |
| boxShadow: "0 1px 3px rgba(59,130,246,0.25)", | |
| }, | |
| btnSecondary: { | |
| display: "inline-flex", | |
| alignItems: "center", | |
| padding: "7px 14px", | |
| background: "#fff", | |
| border: "1px solid #d1d5db", | |
| borderRadius: 7, | |
| cursor: "pointer", | |
| fontSize: 14, | |
| fontWeight: 500, | |
| color: "#374151", | |
| boxShadow: "0 1px 2px rgba(0,0,0,0.05)", | |
| }, | |
| btnGhost: { | |
| display: "inline-flex", | |
| alignItems: "center", | |
| padding: "7px 12px", | |
| background: "transparent", | |
| border: "1px solid #e5e7eb", | |
| borderRadius: 7, | |
| cursor: "pointer", | |
| fontSize: 13, | |
| color: "#6b7280", | |
| }, | |
| btnDisabled: { | |
| background: "#d1d5db", | |
| cursor: "not-allowed", | |
| boxShadow: "none", | |
| }, | |
| canvasWrapper: { | |
| border: "1px solid #e5e7eb", | |
| borderRadius: 10, | |
| overflow: "hidden", | |
| lineHeight: 0, | |
| boxShadow: "0 2px 16px rgba(0,0,0,0.12)", | |
| }, | |
| overlay: { | |
| position: "absolute", | |
| inset: 0, | |
| background: "rgba(255,255,255,0.80)", | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| borderRadius: 10, | |
| backdropFilter: "blur(3px)", | |
| }, | |
| overlayCard: { | |
| display: "flex", | |
| flexDirection: "column", | |
| alignItems: "center", | |
| gap: 12, | |
| background: "#fff", | |
| padding: "28px 40px", | |
| borderRadius: 12, | |
| boxShadow: "0 8px 32px rgba(0,0,0,0.12)", | |
| }, | |
| overlayTitle: { | |
| margin: 0, | |
| fontWeight: 600, | |
| fontSize: 14, | |
| color: "#111827", | |
| }, | |
| overlaySubtitle: { | |
| margin: 0, | |
| fontSize: 13, | |
| color: "#6b7280", | |
| }, | |
| hint: { | |
| marginTop: 12, | |
| fontSize: 13, | |
| color: "#6b7280", | |
| lineHeight: 1.5, | |
| }, | |
| resultSection: { | |
| marginTop: 24, | |
| }, | |
| comparisonHeader: { | |
| marginTop: 20, | |
| }, | |
| comparison: { | |
| display: "flex", | |
| gap: 20, | |
| marginTop: 16, | |
| flexWrap: "wrap", | |
| }, | |
| compareCol: { | |
| flex: 1, | |
| minWidth: 200, | |
| textAlign: "center" as const, | |
| }, | |
| compareLabel: { | |
| margin: "0 0 8px", | |
| fontWeight: 600, | |
| fontSize: 13, | |
| color: "#6b7280", | |
| textTransform: "uppercase" as const, | |
| letterSpacing: "0.05em", | |
| }, | |
| compareImg: { | |
| maxWidth: "100%", | |
| borderRadius: 8, | |
| border: "1px solid #e5e7eb", | |
| display: "block", | |
| margin: "0 auto", | |
| boxShadow: "0 1px 4px rgba(0,0,0,0.06)", | |
| }, | |
| resultActions: { | |
| display: "flex", | |
| gap: 8, | |
| marginTop: 20, | |
| flexWrap: "wrap", | |
| }, | |
| bannerTitle: { | |
| margin: "0 0 6px", | |
| fontWeight: 600, | |
| fontSize: 14, | |
| color: "#111827", | |
| }, | |
| errorBanner: { | |
| marginTop: 16, | |
| padding: "16px 20px", | |
| background: "#fef2f2", | |
| border: "1px solid #fca5a5", | |
| borderRadius: 10, | |
| color: "#991b1b", | |
| boxShadow: "0 1px 4px rgba(239,68,68,0.08)", | |
| }, | |
| bannerSub: { | |
| margin: "4px 0", | |
| fontSize: 13, | |
| }, | |
| }; | |