/** * MaskEditor.tsx — fabric.js canvas for painting mask strokes, multi-step * iterative editing, and step history navigation. * * Phases * ------ * "painting" — canvas is interactive; user paints strokes. * "generating" — POST /generate sent; polling in progress; canvas locked. * "completed" — current step finished; before/after shown; step navigator shown. * "failed" — POST failed OR job failed; error shown; user can try again. * * Canvas re-initialisation * ------------------------ * The canvas effect depends on `currentBaseUrl` (not the raw File object). * On first mount, `currentBaseUrl` is set from URL.createObjectURL(imageFile). * On "Do More Work", it is set to the latest step's result HTTP URL. * Either way, FabricImage.fromURL() accepts both object URLs and HTTP URLs, * so the same canvas init path handles both cases. * * Step navigation * --------------- * `viewedStepNumber` controls which step's result is displayed in the result * panel. null = the just-completed step (currentStep from the hook). * Clicking a step in the history strip calls goToStep() on the backend (sets * backend current_step) and then refreshResult() to sync local hook state, * then sets viewedStepNumber to that step. "Do More Work" clears it back to * null and resets the canvas for a fresh painting session from that result. * * Coordinate contract * ------------------- * fabric.js coordinates are in CANVAS pixel space. * Backend expects ORIGINAL IMAGE pixel space. * toOriginalScale = imageNaturalWidth / displayWidth * original_x = canvas_x * toOriginalScale * Applied to every brush point AND brushSize before sending to /generate. * The result images always have the same pixel dimensions as the original, * so toOriginalScale stays correct across all steps. */ import { useEffect, useState } from "react"; import { Paintbrush, Eraser, Undo2, Wand2, Download, PencilLine, RotateCcw, History, } from "lucide-react"; import { useEditor } from "../store/editorStore"; import { generateMask, goToStep, API_BASE } from "../api/client"; import { useJobPolling } from "../hooks/useJobPolling"; import { useMaskCanvas } from "../hooks/useMaskCanvas"; import type { StepSummary } from "../api/client"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type Phase = "painting" | "generating" | "completed" | "failed"; // --------------------------------------------------------------------------- // StepHistory — horizontal strip of completed steps; clicking navigates // --------------------------------------------------------------------------- function StepHistory({ steps, activeStepNumber, currentStep, onGotoStep, }: { steps: StepSummary[]; activeStepNumber: number; // the step currently displayed in the result panel currentStep: number; // backend's current_step (determines "Do More Work" base) 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; // nothing useful to navigate const maxStep = completed[completed.length - 1].step_number; return (
History
{completed.map(s => { const isActive = s.step_number === activeStepNumber; return ( ); })}
{currentStep < maxStep && (

Doing more work from here will discard steps {currentStep + 1}–{maxStep}.

)}
); } const histStyles: Record = { 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", letterSpacing: "0.07em", display: "block", marginBottom: 10, }, strip: { display: "flex", gap: 6, flexWrap: "wrap", }, 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 MaskEditor() { const editor = useEditor(); const { jobId, imageFile, imageNaturalWidth, imageNaturalHeight } = editor; const { canvasElRef, displayWidth, displayHeight, toOriginalScale, brushSize, setBrushSize, strokeCount, buildMaskJson, handleClear, handleUndo, setDrawingMode, setBaseUrl, } = useMaskCanvas({ imageFile: imageFile ?? null, imageNaturalWidth, imageNaturalHeight }); const [phase, setPhase] = useState("painting"); const [generateError, setGenerateError] = useState(null); // Which step is shown in the result panel. null = show currentStep's result. const [viewedStepNumber, setViewedStepNumber] = useState(null); // CSS scale factor applied to the canvas wrapper so it fits narrow viewports. // Recomputed on mount and on window resize. ≤1.0; 1.0 = no scaling (desktop). const [canvasScale, setCanvasScale] = useState(1); const { pollPhase, errorMessage: pollError, steps, currentStep, startPolling, refreshResult, reset: resetPoll, resetPollPhase, } = useJobPolling(); // ------------------------------------------------------------------------- // Sync pollPhase → component phase // Only reset viewedStepNumber when first arriving at "completed" // (NOT on subsequent refreshResult calls, which don't change pollPhase). // ------------------------------------------------------------------------- useEffect(() => { if (pollPhase === "completed" && phase !== "completed") { setPhase("completed"); setViewedStepNumber(null); // show the just-completed step } if (pollPhase === "failed") setPhase("failed"); }, [pollPhase, phase]); // ------------------------------------------------------------------------- // Sync phase → fabric isDrawingMode (via hook) // ------------------------------------------------------------------------- useEffect(() => { setDrawingMode(phase === "painting"); }, [phase, setDrawingMode]); // ------------------------------------------------------------------------- // Canvas CSS scale — keeps the painted canvas within the page container width // (max 880px - 48px padding = 832px) on narrow viewports. Recomputed on // resize so orientation changes are handled. Fabric.js uses // getBoundingClientRect() internally, so the coordinate mapping stays correct // even 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]); // ------------------------------------------------------------------------- // Generate — POST /generate then start polling // ------------------------------------------------------------------------- const handleGenerate = async () => { if (!jobId) return; const maskJson = buildMaskJson(); if (!maskJson) return; setPhase("generating"); setGenerateError(null); try { await generateMask(jobId, maskJson); startPolling(jobId); } catch (err) { setGenerateError((err as Error).message); setPhase("failed"); } }; // ------------------------------------------------------------------------- // Do More Work — reset canvas to latest result and return to painting // ------------------------------------------------------------------------- const handleDoMoreWork = () => { const latestStepData = steps.find(s => s.step_number === currentStep); if (!latestStepData?.result_image_url) return; // resetPollPhase() clears pollPhase → "idle" without touching steps[]. // This is critical: the pollPhase→phase sync effect (line ~261) re-fires // whenever phase changes. If pollPhase is still "completed" when we set // phase to "painting", that effect immediately overwrites it back to // "completed", which then makes the phase-sync effect set // fc.isDrawingMode = false on the freshly-created canvas — killing drawing. resetPollPhase(); // Setting base URL triggers the canvas init effect in useMaskCanvas, // which also resets strokesRef and strokeCount. setBaseUrl(`${API_BASE}${latestStepData.result_image_url}`); setViewedStepNumber(null); setGenerateError(null); setPhase("painting"); }; // ------------------------------------------------------------------------- // Navigate to a specific step in history // ------------------------------------------------------------------------- const handleGotoStep = async (stepNumber: number) => { if (!jobId) return; try { await goToStep(jobId, stepNumber); // Update the result panel display first (instant feedback), // then silently sync hook's currentStep from the backend. setViewedStepNumber(stepNumber); refreshResult(jobId); } catch { /* goToStep failure is non-critical — display unchanged */ } }; // ------------------------------------------------------------------------- // Try Again — return to painting without losing strokes // ------------------------------------------------------------------------- const handleTryAgain = () => { // Don't call resetPoll() — it would clear steps[]. setGenerateError(null); setPhase("painting"); }; // ------------------------------------------------------------------------- // Start Over — full reset back to ImageUploader // ------------------------------------------------------------------------- const handleStartOver = () => { resetPoll(); // clears everything including steps[] setViewedStepNumber(null); setGenerateError(null); editor.reset(); // clears jobId → App re-renders ImageUploader (unmounts canvas) }; // ------------------------------------------------------------------------- // Download result // ------------------------------------------------------------------------- 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 */ } }; // ------------------------------------------------------------------------- // Guard // ------------------------------------------------------------------------- if (!imageFile || !displayWidth || !displayHeight) return null; // Derived display state 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; // Which step to show in the result panel const effectiveViewedStep = viewedStepNumber ?? currentStep; const displayedStep = steps.find(s => s.step_number === effectiveViewedStep) ?? null; // "Do More Work" is always available from the step the backend thinks is current. const latestStepData = steps.find(s => s.step_number === currentStep) ?? null; const canDoMoreWork = isCompleted && !!latestStepData?.result_image_url; return (
{/* Header */}

Paint over text / watermarks

Job: {jobId}  · Original: {imageNaturalWidth}×{imageNaturalHeight}px {isScaledDown ? ` · Canvas: ${displayWidth}×${displayHeight}px · scale ${(1 / toOriginalScale).toFixed(3)}×` : " · displayed at natural size (1.0×)"}

{/* Toolbar — visible only while painting */} {isPainting && (
)} {/* Canvas + overlays — outer div clips & sizes to the CSS-scaled dimensions */}
{/* position: relative context for the overlay; sized to the VISUAL (scaled) area */}
{/* Canvas rendered at full pixel dimensions, then scaled down via CSS transform */}
{isGenerating && (

Removing watermark…

Polling for result every 2 s

)}
{/* Painting hint */} {isPainting && (

{strokeCount === 0 ? <>Paint red over every watermark region, then click Remove Watermark. : <>{strokeCount} stroke{strokeCount !== 1 ? "s" : ""} painted — clear, adjust, or click Remove Watermark.}

)} {/* Completed result section */} {isCompleted && displayedStep && (
{/* Step history navigator */} {/* Before / After comparison */}
{effectiveViewedStep === 0 ? (

Step 0 — uploaded original

) : (

✓ Step {effectiveViewedStep} completed.

)}
{effectiveViewedStep > 0 && (

Before

{displayedStep.original_image_url && ( Before )}
)}

{effectiveViewedStep === 0 ? "Original" : "After"}

{displayedStep.result_image_url && ( After )}
{/* Action buttons */}
{canDoMoreWork && ( )}
)} {/* Failed section */} {isFailed && (

✗ Inpainting failed.

Error: {displayError}

Your strokes are preserved — try again.

{/* Show history even after failure — user can go back to a good step */} {steps.length > 1 && ( )}
)}
); } // --------------------------------------------------------------------------- // Spinner // --------------------------------------------------------------------------- function Spinner() { return
; } 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 = "__mask_editor_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 styles: Record = { page: { maxWidth: 880, margin: "48px auto 48px", padding: "0 24px", fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", color: "#111827", }, header: { marginBottom: 24, paddingBottom: 16, borderBottom: "1px solid #f0f2f4", }, heading: { fontSize: 22, fontWeight: 600, margin: "0 0 4px", letterSpacing: "-0.01em", color: "#111827", }, meta: { fontSize: 12, color: "#9ca3af", margin: 0, }, 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, }, };