/**
* 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 (
onGotoStep(s.step_number)}
style={{
...histStyles.stepBtn,
...(isActive ? histStyles.stepBtnActive : {}),
}}
title={s.step_number === 0 ? "Uploaded original" : `Result after step ${s.step_number}`}
>
{s.step_number === 0 ? "Original" : `Step ${s.step_number}`}
);
})}
{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 && (
)}
)}
{effectiveViewedStep === 0 ? "Original" : "After"}
{displayedStep.result_image_url && (
)}
{/* Action buttons */}
{canDoMoreWork && (
추가 작업하기
)}
handleDownload(displayedStep.result_image_url)}
>
Download
Start Over
)}
{/* 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 && (
)}
← Back to Painting
Start Over
)}
);
}
// ---------------------------------------------------------------------------
// 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,
},
};