Skip to content

Commit 21df7c6

Browse files
committed
feat: Midjourney Redraw Helper
1 parent 770cfd5 commit 21df7c6

File tree

2 files changed

+392
-59
lines changed

2 files changed

+392
-59
lines changed

app/components/ImageMask.tsx

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
// app/components/ImageMask.tsx
2+
3+
import React, {useEffect, useRef, useState} from 'react';
4+
import {Button, Modal, Segmented, Slider, Spin} from 'antd';
5+
import {GatewayOutlined, HighlightOutlined, RedoOutlined, ZoomInOutlined, ZoomOutOutlined} from '@ant-design/icons';
6+
7+
type BrushType = 'free' | 'rectangle';
8+
9+
const ImageMaskModal = (props: {
10+
open: boolean;
11+
onClose: () => void;
12+
originalImageUrl: string;
13+
onFinished: (maskBase64: string) => void;
14+
}) => {
15+
const canvasRef = useRef<HTMLCanvasElement>(null);
16+
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
17+
const tempCanvasRef = useRef<HTMLCanvasElement>(null);
18+
const containerRef = useRef<HTMLDivElement>(null);
19+
const [isDrawing, setIsDrawing] = useState(false);
20+
const [maskCtx, setMaskCtx] = useState<CanvasRenderingContext2D | null>(null);
21+
const [tempCtx, setTempCtx] = useState<CanvasRenderingContext2D | null>(null);
22+
const [isLoading, setIsLoading] = useState(true);
23+
const [imageLoadError, setImageLoadError] = useState<string | null>(null);
24+
// const [debugInfo, setDebugInfo] = useState<string>('');
25+
const [scale, setScale] = useState(1);
26+
const [originalSize, setOriginalSize] = useState({width: 0, height: 0});
27+
const [brushType, setBrushType] = useState<BrushType>('free');
28+
const [startPoint, setStartPoint] = useState<{ x: number; y: number } | null>(null);
29+
30+
useEffect(() => {
31+
if (props.open) {
32+
// setDebugInfo(`Attempting to load image: ${props.originalImageUrl}`);
33+
setIsLoading(true);
34+
setImageLoadError(null);
35+
36+
const img = new Image();
37+
img.crossOrigin = "Anonymous";
38+
39+
img.onload = () => {
40+
// setDebugInfo(prev => `${prev}\nImage loaded successfully. Size: ${img.width}x${img.height}`);
41+
setOriginalSize({width: img.width, height: img.height});
42+
if (canvasRef.current && maskCanvasRef.current && tempCanvasRef.current && containerRef.current) {
43+
const canvas = canvasRef.current;
44+
const maskCanvas = maskCanvasRef.current;
45+
const tempCanvas = tempCanvasRef.current;
46+
const container = containerRef.current;
47+
const context = canvas.getContext('2d');
48+
const maskContext = maskCanvas.getContext('2d');
49+
const tempContext = tempCanvas.getContext('2d');
50+
canvas.width = img.width;
51+
canvas.height = img.height;
52+
maskCanvas.width = img.width;
53+
maskCanvas.height = img.height;
54+
tempCanvas.width = img.width;
55+
tempCanvas.height = img.height;
56+
context?.drawImage(img, 0, 0);
57+
// setCtx(context);
58+
setMaskCtx(maskContext);
59+
setTempCtx(tempContext);
60+
61+
// Calculate initial scale
62+
const scaleX = container.clientWidth / img.width;
63+
const scaleY = container.clientHeight / img.height;
64+
const initialScale = Math.min(scaleX, scaleY, 1);
65+
setScale(initialScale);
66+
}
67+
setIsLoading(false);
68+
};
69+
70+
img.onerror = (e) => {
71+
// setDebugInfo(prev => `${prev}\nImage failed to load. Error: ${e}`);
72+
console.error(e);
73+
setImageLoadError("图片加载失败");
74+
setIsLoading(false);
75+
};
76+
77+
if (props.originalImageUrl.startsWith('data:image')) {
78+
img.src = props.originalImageUrl;
79+
} else {
80+
img.src = `${props.originalImageUrl}?t=${new Date().getTime()}`;
81+
}
82+
}
83+
}, [props.open, props.originalImageUrl]);
84+
85+
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
86+
if (!tempCtx) return;
87+
setIsDrawing(true);
88+
const {x, y} = getCoordinates(e);
89+
if (brushType === 'rectangle') {
90+
setStartPoint({x, y});
91+
} else {
92+
tempCtx.beginPath();
93+
tempCtx.moveTo(x, y);
94+
}
95+
};
96+
97+
const stopDrawing = () => {
98+
if (!tempCtx || !maskCtx || !isDrawing) return;
99+
setIsDrawing(false);
100+
if (brushType === 'rectangle' && startPoint) {
101+
const {x, y} = startPoint;
102+
const width = Math.abs(x - startPoint.x);
103+
const height = Math.abs(y - startPoint.y);
104+
const startX = Math.min(x, startPoint.x);
105+
const startY = Math.min(y, startPoint.y);
106+
tempCtx.fillRect(startX, startY, width, height);
107+
}
108+
maskCtx.drawImage(tempCanvasRef.current!, 0, 0);
109+
tempCtx.clearRect(0, 0, tempCanvasRef.current!.width, tempCanvasRef.current!.height);
110+
setStartPoint(null);
111+
};
112+
113+
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
114+
if (!isDrawing || !tempCtx || !tempCanvasRef.current) return;
115+
116+
const {x, y} = getCoordinates(e);
117+
118+
tempCtx.lineWidth = 10 / scale;
119+
tempCtx.lineCap = 'round';
120+
tempCtx.strokeStyle = 'white';
121+
tempCtx.fillStyle = 'white';
122+
123+
if (brushType === 'free') {
124+
tempCtx.lineTo(x, y);
125+
tempCtx.stroke();
126+
} else if (brushType === 'rectangle' && startPoint) {
127+
tempCtx.clearRect(0, 0, tempCanvasRef.current.width, tempCanvasRef.current.height);
128+
const width = x - startPoint.x;
129+
const height = y - startPoint.y;
130+
tempCtx.fillRect(startPoint.x, startPoint.y, width, height);
131+
}
132+
};
133+
134+
const handleReset = () => {
135+
if (maskCtx && maskCanvasRef.current) {
136+
maskCtx.clearRect(0, 0, maskCanvasRef.current.width, maskCanvasRef.current.height);
137+
}
138+
if (tempCtx && tempCanvasRef.current) {
139+
tempCtx.clearRect(0, 0, tempCanvasRef.current.width, tempCanvasRef.current.height);
140+
}
141+
};
142+
143+
const getCoordinates = (e: React.MouseEvent<HTMLCanvasElement>): { x: number; y: number } => {
144+
const canvas = maskCanvasRef.current!;
145+
const rect = canvas.getBoundingClientRect();
146+
return {
147+
x: (e.clientX - rect.left) / scale,
148+
y: (e.clientY - rect.top) / scale
149+
};
150+
};
151+
152+
const getMaskBase64 = () => {
153+
if (!maskCanvasRef.current) return '';
154+
const tempCanvas = document.createElement('canvas');
155+
tempCanvas.width = maskCanvasRef.current.width;
156+
tempCanvas.height = maskCanvasRef.current.height;
157+
const tempCtx = tempCanvas.getContext('2d');
158+
if (tempCtx) {
159+
tempCtx.fillStyle = 'black';
160+
tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
161+
tempCtx.globalCompositeOperation = 'destination-out';
162+
tempCtx.drawImage(maskCanvasRef.current, 0, 0);
163+
tempCtx.globalCompositeOperation = 'source-over';
164+
}
165+
return tempCanvas.toDataURL('image/png').split(',')[1];
166+
};
167+
168+
const handleFinish = () => {
169+
const maskBase64 = getMaskBase64();
170+
props.onFinished(maskBase64);
171+
props.onClose();
172+
};
173+
174+
const handleZoom = (newScale: number) => {
175+
setScale(newScale);
176+
};
177+
178+
return (
179+
<Modal
180+
open={props.open}
181+
onCancel={props.onClose}
182+
footer={[
183+
<Button key="cancel" onClick={props.onClose}>取消</Button>,
184+
<Button key="finish" type="primary" onClick={handleFinish}>完成</Button>,
185+
]}
186+
closeIcon={false}
187+
centered={true}
188+
destroyOnClose={true}
189+
width="70%"
190+
style={{maxHeight: '80vh', overflow: 'auto'}}
191+
>
192+
<div>
193+
{/*<p>请在图片上绘制需要重绘的区域</p>*/}
194+
<h3>Please draw the area to be redrawn on the image</h3>
195+
<div
196+
style={{
197+
marginBottom: 20,
198+
display: 'flex',
199+
alignItems: 'center',
200+
justifyContent: 'space-between'
201+
}}
202+
>
203+
<Segmented
204+
options={[
205+
// {label: '自由画笔', value: 'free', icon: <HighlightOutlined/>},
206+
// {label: '矩形工具', value: 'rectangle', icon: <GatewayOutlined/>}
207+
{label: 'Free Brush', value: 'free', icon: <HighlightOutlined/>, disabled: isLoading},
208+
{
209+
label: 'Rectangle Tool',
210+
value: 'rectangle',
211+
icon: <GatewayOutlined/>,
212+
disabled: isLoading
213+
},
214+
]}
215+
value={brushType}
216+
onChange={(value) => setBrushType(value as BrushType)}
217+
/>
218+
<Button icon={<RedoOutlined/>} onClick={handleReset} disabled={isLoading}>Reset</Button>
219+
<div style={{display: 'flex', alignItems: 'center'}}>
220+
<ZoomOutOutlined/>
221+
<Slider
222+
style={{width: 100, margin: '0 10px'}}
223+
min={0.1}
224+
max={2}
225+
step={0.1}
226+
value={scale}
227+
onChange={handleZoom}
228+
disabled={isLoading}
229+
/>
230+
<ZoomInOutlined/>
231+
</div>
232+
</div>
233+
<Spin spinning={isLoading}>
234+
<div
235+
ref={containerRef}
236+
style={{
237+
height: '60vh',
238+
display: 'flex',
239+
flexDirection: 'column',
240+
justifyContent: 'center',
241+
alignItems: 'center'
242+
}}
243+
>
244+
{imageLoadError ? (
245+
<p>{imageLoadError}</p>
246+
) : (
247+
<div style={{position: 'relative', overflow: 'auto', maxHeight: '100%', maxWidth: '100%'}}>
248+
<canvas
249+
ref={canvasRef}
250+
style={{
251+
width: `${originalSize.width * scale}px`,
252+
height: `${originalSize.height * scale}px`
253+
}}
254+
/>
255+
<canvas
256+
ref={maskCanvasRef}
257+
style={{
258+
position: 'absolute',
259+
top: 0,
260+
left: 0,
261+
width: `${originalSize.width * scale}px`,
262+
height: `${originalSize.height * scale}px`,
263+
opacity: 0.5
264+
}}
265+
/>
266+
<canvas
267+
ref={tempCanvasRef}
268+
onMouseDown={startDrawing}
269+
onMouseUp={stopDrawing}
270+
onMouseOut={stopDrawing}
271+
onMouseMove={draw}
272+
style={{
273+
position: 'absolute',
274+
top: 0,
275+
left: 0,
276+
cursor: "crosshair",
277+
width: `${originalSize.width * scale}px`,
278+
height: `${originalSize.height * scale}px`,
279+
opacity: 0.5
280+
}}
281+
/>
282+
</div>
283+
)}
284+
</div>
285+
{/*<pre style={{ marginTop: 10, fontSize: '12px', whiteSpace: 'pre-wrap' }}>*/}
286+
{/* {debugInfo}*/}
287+
{/*</pre>*/}
288+
</Spin>
289+
</div>
290+
</Modal>
291+
);
292+
};
293+
294+
export default ImageMaskModal;

0 commit comments

Comments
 (0)