Skip to content

Commit b0fdee8

Browse files
dkotterdkotterjeffpaul
authored
Merge pull request #312 from dkotter/feature/image-remove-replace
Add preset image editing options to remove an item or replace an item Co-authored-by: dkotter <dkotter@git.wordpress.org> Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org>
2 parents 0d1ec92 + e491390 commit b0fdee8

File tree

5 files changed

+706
-11
lines changed

5 files changed

+706
-11
lines changed
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
/**
2+
* Drawable canvas overlay for mask-based image editing.
3+
*
4+
* Renders an image with a transparent canvas overlay that the user can draw
5+
* on to select regions. Drawn areas appear as semi-transparent red highlights.
6+
* The raw mask canvas is exposed via ref so the parent can read pixel data.
7+
*/
8+
9+
/**
10+
* WordPress dependencies
11+
*/
12+
import {
13+
useRef,
14+
useState,
15+
useEffect,
16+
useCallback,
17+
useImperativeHandle,
18+
forwardRef,
19+
} from '@wordpress/element';
20+
21+
export interface MaskCanvasHandle {
22+
undo: () => void;
23+
clear: () => void;
24+
getCanvas: () => HTMLCanvasElement | null;
25+
}
26+
27+
interface Props {
28+
imageSrc: string;
29+
brushSize: number;
30+
onMaskChange?: ( hasMask: boolean ) => void;
31+
}
32+
33+
const MAX_UNDO_STACK = 20;
34+
35+
/**
36+
* Drawable canvas overlay for selecting image regions.
37+
*
38+
* @param {Props} props Component props.
39+
* @param {Object} ref Imperative handle ref.
40+
*/
41+
export const MaskCanvas = forwardRef< MaskCanvasHandle, Props >(
42+
function MaskCanvas( { imageSrc, brushSize, onMaskChange }, ref ) {
43+
const canvasRef = useRef< HTMLCanvasElement >( null );
44+
const ctxRef = useRef< CanvasRenderingContext2D | null >( null );
45+
const wrapperRef = useRef< HTMLDivElement >( null );
46+
const [ naturalSize, setNaturalSize ] = useState< {
47+
width: number;
48+
height: number;
49+
} | null >( null );
50+
const isDrawingRef = useRef( false );
51+
const undoStackRef = useRef< ImageData[] >( [] );
52+
const lastPointRef = useRef< { x: number; y: number } | null >( null );
53+
54+
// Load image to get natural dimensions.
55+
useEffect( () => {
56+
const img = new Image();
57+
img.crossOrigin = 'anonymous';
58+
img.onload = () => {
59+
setNaturalSize( {
60+
width: img.naturalWidth,
61+
height: img.naturalHeight,
62+
} );
63+
};
64+
img.src = imageSrc;
65+
}, [ imageSrc ] );
66+
67+
// Acquire 2D context with willReadFrequently once canvas is ready.
68+
useEffect( () => {
69+
if ( ! canvasRef.current || ! naturalSize ) {
70+
ctxRef.current = null;
71+
return;
72+
}
73+
ctxRef.current = canvasRef.current.getContext( '2d', {
74+
willReadFrequently: true,
75+
} );
76+
}, [ naturalSize ] );
77+
78+
// Clear canvas when image source changes.
79+
useEffect( () => {
80+
if ( ! ctxRef.current || ! naturalSize ) {
81+
return;
82+
}
83+
ctxRef.current.clearRect(
84+
0,
85+
0,
86+
naturalSize.width,
87+
naturalSize.height
88+
);
89+
undoStackRef.current = [];
90+
onMaskChange?.( false );
91+
}, [ imageSrc, naturalSize, onMaskChange ] );
92+
93+
/**
94+
* Checks whether the mask canvas has any drawn pixels.
95+
*/
96+
const checkHasMask = useCallback( () => {
97+
if ( ! ctxRef.current || ! naturalSize ) {
98+
return false;
99+
}
100+
const data = ctxRef.current.getImageData(
101+
0,
102+
0,
103+
naturalSize.width,
104+
naturalSize.height
105+
).data;
106+
for ( let i = 3; i < data.length; i += 4 ) {
107+
if ( ( data[ i ] as number ) > 0 ) {
108+
return true;
109+
}
110+
}
111+
return false;
112+
}, [ naturalSize ] );
113+
114+
/**
115+
* Saves a snapshot of the current canvas to the undo stack.
116+
*/
117+
const saveSnapshot = useCallback( () => {
118+
if ( ! ctxRef.current || ! naturalSize ) {
119+
return;
120+
}
121+
const snapshot = ctxRef.current.getImageData(
122+
0,
123+
0,
124+
naturalSize.width,
125+
naturalSize.height
126+
);
127+
undoStackRef.current.push( snapshot );
128+
if ( undoStackRef.current.length > MAX_UNDO_STACK ) {
129+
undoStackRef.current.shift();
130+
}
131+
}, [ naturalSize ] );
132+
133+
/**
134+
* Converts pointer event coordinates to canvas coordinates.
135+
*/
136+
const toCanvasCoords = useCallback(
137+
( e: React.PointerEvent< HTMLCanvasElement > ) => {
138+
if ( ! canvasRef.current || ! naturalSize ) {
139+
return { x: 0, y: 0 };
140+
}
141+
const rect = canvasRef.current.getBoundingClientRect();
142+
return {
143+
x:
144+
( ( e.clientX - rect.left ) / rect.width ) *
145+
naturalSize.width,
146+
y:
147+
( ( e.clientY - rect.top ) / rect.height ) *
148+
naturalSize.height,
149+
};
150+
},
151+
[ naturalSize ]
152+
);
153+
154+
/**
155+
* Draws a brush stroke segment between two points.
156+
*/
157+
const drawSegment = useCallback(
158+
(
159+
from: { x: number; y: number },
160+
to: { x: number; y: number }
161+
) => {
162+
if ( ! canvasRef.current || ! ctxRef.current ) {
163+
return;
164+
}
165+
166+
// Scale brush size relative to canvas resolution.
167+
const displayWidth =
168+
canvasRef.current.getBoundingClientRect().width;
169+
const scaledBrush =
170+
( brushSize / displayWidth ) *
171+
( naturalSize?.width ?? displayWidth );
172+
173+
ctxRef.current.strokeStyle = 'rgba(255, 0, 0, 1)';
174+
ctxRef.current.lineWidth = scaledBrush;
175+
ctxRef.current.lineCap = 'round';
176+
ctxRef.current.lineJoin = 'round';
177+
ctxRef.current.beginPath();
178+
ctxRef.current.moveTo( from.x, from.y );
179+
ctxRef.current.lineTo( to.x, to.y );
180+
ctxRef.current.stroke();
181+
},
182+
[ brushSize, naturalSize ]
183+
);
184+
185+
/**
186+
* Draws a single dot at the given point.
187+
*/
188+
const drawDot = useCallback(
189+
( point: { x: number; y: number } ) => {
190+
if ( ! canvasRef.current || ! ctxRef.current ) {
191+
return;
192+
}
193+
194+
const displayWidth =
195+
canvasRef.current.getBoundingClientRect().width;
196+
const scaledBrush =
197+
( brushSize / displayWidth ) *
198+
( naturalSize?.width ?? displayWidth );
199+
200+
ctxRef.current.fillStyle = 'rgba(255, 0, 0, 1)';
201+
ctxRef.current.beginPath();
202+
ctxRef.current.arc(
203+
point.x,
204+
point.y,
205+
scaledBrush / 2,
206+
0,
207+
Math.PI * 2
208+
);
209+
ctxRef.current.fill();
210+
},
211+
[ brushSize, naturalSize ]
212+
);
213+
214+
const handlePointerDown = useCallback(
215+
( e: React.PointerEvent< HTMLCanvasElement > ) => {
216+
e.preventDefault();
217+
isDrawingRef.current = true;
218+
saveSnapshot();
219+
const point = toCanvasCoords( e );
220+
lastPointRef.current = point;
221+
drawDot( point );
222+
},
223+
[ saveSnapshot, toCanvasCoords, drawDot ]
224+
);
225+
226+
const handlePointerMove = useCallback(
227+
( e: React.PointerEvent< HTMLCanvasElement > ) => {
228+
if ( ! isDrawingRef.current || ! lastPointRef.current ) {
229+
return;
230+
}
231+
const point = toCanvasCoords( e );
232+
drawSegment( lastPointRef.current, point );
233+
lastPointRef.current = point;
234+
},
235+
[ toCanvasCoords, drawSegment ]
236+
);
237+
238+
const handlePointerUp = useCallback( () => {
239+
if ( isDrawingRef.current ) {
240+
isDrawingRef.current = false;
241+
lastPointRef.current = null;
242+
onMaskChange?.( checkHasMask() );
243+
}
244+
}, [ checkHasMask, onMaskChange ] );
245+
246+
// Expose imperative methods to parent.
247+
useImperativeHandle(
248+
ref,
249+
() => ( {
250+
undo() {
251+
if (
252+
! ctxRef.current ||
253+
! naturalSize ||
254+
undoStackRef.current.length === 0
255+
) {
256+
return;
257+
}
258+
const prev = undoStackRef.current.pop()!;
259+
ctxRef.current.putImageData( prev, 0, 0 );
260+
onMaskChange?.( checkHasMask() );
261+
},
262+
clear() {
263+
if ( ! ctxRef.current || ! naturalSize ) {
264+
return;
265+
}
266+
saveSnapshot();
267+
ctxRef.current.clearRect(
268+
0,
269+
0,
270+
naturalSize.width,
271+
naturalSize.height
272+
);
273+
undoStackRef.current = [];
274+
onMaskChange?.( false );
275+
},
276+
getCanvas() {
277+
return canvasRef.current;
278+
},
279+
} ),
280+
[ naturalSize, checkHasMask, saveSnapshot, onMaskChange ]
281+
);
282+
283+
if ( ! naturalSize ) {
284+
return null;
285+
}
286+
287+
return (
288+
<div ref={ wrapperRef } className="ai-mask-canvas">
289+
<img
290+
src={ imageSrc }
291+
alt=""
292+
className="ai-mask-canvas__image"
293+
draggable={ false }
294+
/>
295+
<canvas
296+
ref={ canvasRef }
297+
className="ai-mask-canvas__overlay"
298+
width={ naturalSize.width }
299+
height={ naturalSize.height }
300+
onPointerDown={ handlePointerDown }
301+
onPointerMove={ handlePointerMove }
302+
onPointerUp={ handlePointerUp }
303+
onPointerLeave={ handlePointerUp }
304+
/>
305+
</div>
306+
);
307+
}
308+
);

0 commit comments

Comments
 (0)