image-tool / frontend /src /components /MaskEditor.tsx
JunyoungK7's picture
Claude Sonnet 4.6
Make UI responsive for mobile: scale canvas, scrollable tabs, flexible toolbar
4a4b2e4
Raw History Blame Contribute Delete
26.6 kB
/**
* 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 (
<div style={histStyles.wrap}>
<span style={histStyles.label}>
<History size={13} style={{ verticalAlign: "middle", marginRight: 4 }} />
History
</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 ? "Uploaded original" : `Result after step ${s.step_number}`}
>
{s.step_number === 0 ? "Original" : `Step ${s.step_number}`}
</button>
);
})}
</div>
{currentStep < maxStep && (
<p style={histStyles.discardNote}>
Doing more work from here will discard steps {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",
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<Phase>("painting");
const [generateError, setGenerateError] = useState<string | null>(null);
// Which step is shown in the result panel. null = show currentStep's result.
const [viewedStepNumber, setViewedStepNumber] = useState<number | null>(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 (
<div style={styles.page}>
{/* Header */}
<div style={styles.header}>
<h2 style={styles.heading}>Paint over text / watermarks</h2>
<p style={styles.meta}>
Job: <code>{jobId}</code>
&nbsp;·&nbsp;Original: {imageNaturalWidth}×{imageNaturalHeight}px
{isScaledDown
? ` · Canvas: ${displayWidth}×${displayHeight}px · scale ${(1 / toOriginalScale).toFixed(3)}×`
: " · displayed at natural size (1.0×)"}
</p>
</div>
{/* Toolbar — visible only while painting */}
{isPainting && (
<div style={styles.toolbar}>
<label style={styles.label}>
<Paintbrush size={15} style={styles.iconGap} />
Brush:&nbsp;<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} />
Clear ({strokeCount})
</button>
<button
style={styles.btnSecondary}
onClick={handleUndo}
disabled={strokeCount === 0}
>
<Undo2 size={15} style={styles.iconGap} />
Undo
</button>
<button
style={{
...styles.btnPrimary,
...(strokeCount === 0 ? styles.btnDisabled : {}),
}}
onClick={handleGenerate}
disabled={strokeCount === 0}
>
<Wand2 size={15} style={styles.iconGap} />
Remove Watermark
</button>
</div>
)}
{/* Canvas + overlays — outer div clips & sizes to the CSS-scaled dimensions */}
<div style={{ width: "100%", overflow: "hidden" }}>
{/* position: relative context for the overlay; sized to the VISUAL (scaled) area */}
<div style={{
position: "relative",
width: displayWidth * canvasScale,
height: displayHeight * canvasScale,
}}>
{/* Canvas rendered at full pixel dimensions, then scaled down via CSS transform */}
<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}>Removing watermark…</p>
<p style={styles.overlaySubtitle}>Polling for result every 2 s</p>
</div>
</div>
)}
</div>
</div>
{/* Painting hint */}
{isPainting && (
<p style={styles.hint}>
{strokeCount === 0
? <>Paint red over every watermark region, then click <strong>Remove Watermark</strong>.</>
: <>{strokeCount} stroke{strokeCount !== 1 ? "s" : ""} painted — clear, adjust, or click <strong>Remove Watermark</strong>.</>}
</p>
)}
{/* Completed result section */}
{isCompleted && displayedStep && (
<div style={styles.resultSection}>
{/* Step history navigator */}
<StepHistory
steps={steps}
activeStepNumber={effectiveViewedStep}
currentStep={currentStep}
onGotoStep={handleGotoStep}
/>
{/* Before / After comparison */}
<div style={styles.comparisonHeader}>
{effectiveViewedStep === 0 ? (
<p style={styles.bannerTitle}>Step 0 — uploaded original</p>
) : (
<p style={styles.bannerTitle}>
✓ Step {effectiveViewedStep} completed.
</p>
)}
</div>
<div style={styles.comparison}>
{effectiveViewedStep > 0 && (
<div style={styles.compareCol}>
<p style={styles.compareLabel}>Before</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 ? "Original" : "After"}
</p>
{displayedStep.result_image_url && (
<img
src={`${API_BASE}${displayedStep.result_image_url}`}
alt="After"
style={styles.compareImg}
/>
)}
</div>
</div>
{/* Action buttons */}
<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} />
Download
</button>
<button style={styles.btnGhost} onClick={handleStartOver}>
<RotateCcw size={14} style={styles.iconGap} />
Start Over
</button>
</div>
</div>
)}
{/* Failed section */}
{isFailed && (
<div style={styles.errorBanner}>
<p style={styles.bannerTitle}>✗ Inpainting failed.</p>
<p style={styles.bannerSub}>
<strong>Error:</strong> {displayError}
</p>
<p style={styles.bannerSub}>Your strokes are preserved — try again.</p>
{/* Show history even after failure — user can go back to a good step */}
{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}>
← Back to Painting
</button>
<button style={styles.btnGhost} onClick={handleStartOver}>
<RotateCcw size={14} style={styles.iconGap} />
Start Over
</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 = "__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<string, React.CSSProperties> = {
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,
},
};