11import classNames from "classnames" ;
22import { ValueStatus } from "mendix" ;
3- import { ReactElement , SetStateAction , useCallback , useEffect , useRef , useState } from "react" ;
4- import { type Crop , type PixelCrop } from "react-image-crop" ;
3+ import { observer } from "mobx-react-lite" ;
4+ import { ReactElement , useEffect , useRef } from "react" ;
5+ import { GateProvider } from "@mendix/widget-plugin-mobx-kit/main" ;
6+ import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst" ;
7+ import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup" ;
58import { CropArea } from "./CropArea" ;
69import { CropToolbar } from "./CropToolbar" ;
710import { ImageCropperContainerProps } from "../../typings/ImageCropperProps" ;
8- import { useAutoApplyCrop } from "../hooks/useAutoApplyCrop" ;
9- import { useImageCropperState } from "../hooks/useImageCropperState" ;
1011import { useOriginalImage } from "../hooks/useOriginalImage" ;
1112import { usePreviewSrc } from "../hooks/usePreviewSrc" ;
12- import { resolveAspectRatio } from "../utils/aspectRatio" ;
13- import { cropImage , CropError , CENTER_ANCHOR , type ZoomAnchor } from "../utils/cropImage" ;
14- import { buildInitialCrop } from "../utils/initialCrop" ;
15- import { rotateImage } from "../utils/rotateImage" ;
13+ import { ImageCropperStore } from "../stores/ImageCropperStore" ;
1614
17- export function ImageCropperContainer ( props : ImageCropperContainerProps ) : ReactElement | null {
18- const state = useImageCropperState ( Number ( props . minZoom ) ) ;
15+ export const ImageCropperContainer = observer ( function ImageCropperContainer (
16+ props : ImageCropperContainerProps
17+ ) : ReactElement | null {
18+ const gateProvider = useConst ( ( ) => new GateProvider < ImageCropperContainerProps > ( props ) ) ;
19+ const store = useSetup ( ( ) => new ImageCropperStore ( gateProvider . gate ) ) ;
1920
20- const { setZoom, setLiveCrop, setCommittedCrop, setGrayscale } = state ;
21-
22- const committedCropRef = useRef < PixelCrop | undefined > ( undefined ) ;
23- committedCropRef . current = state . committedCrop ;
24- const zoomRef = useRef ( state . zoom ) ;
25- zoomRef . current = state . zoom ;
26- const grayscaleRef = useRef ( state . grayscale ) ;
27- grayscaleRef . current = state . grayscale ;
28- const userDraggedRef = useRef ( false ) ;
29- // Live crop mirror so handleZoomChange (stable identity) can read the box's current center.
30- const liveCropRef = useRef < Crop | undefined > ( undefined ) ;
31- liveCropRef . current = state . liveCrop ;
32-
33- // Fixed point of the zoom, captured from the box center when zoom changes and frozen while the
34- // box moves/draws (so the image stays stable). State drives CropArea's transformOrigin; the ref
35- // mirror lets applyCrop's stable closure read it for the matching export math.
36- const [ zoomAnchor , setZoomAnchor ] = useState < ZoomAnchor > ( CENTER_ANCHOR ) ;
37- const zoomAnchorRef = useRef ( zoomAnchor ) ;
38- zoomAnchorRef . current = zoomAnchor ;
39-
40- const applyCrop = useCallback ( async ( ) => {
41- const img = state . imageRef . current ;
42- const committedCrop = committedCropRef . current ;
43- if (
44- ! img ||
45- ! committedCrop ||
46- props . image . readOnly ||
47- props . image . status !== ValueStatus . Available ||
48- ! props . image . value
49- ) {
50- return ;
51- }
52- try {
53- const file = await cropImage ( {
54- image : img ,
55- pixelCrop : committedCrop ,
56- zoom : zoomRef . current ,
57- zoomAnchor : zoomAnchorRef . current ,
58- outputFormat : props . outputFormat ,
59- outputQuality : Number ( props . outputQuality ) ,
60- outputSize : props . outputSize ,
61- cropShape : props . cropShape ,
62- viewportWidth : props . boundaryWidth ,
63- viewportHeight : props . boundaryHeight ,
64- grayscale : grayscaleRef . current ,
65- originalName : props . image . value . name
66- } ) ;
67- if ( props . outputSize === "viewport" ) {
68- props . image . setThumbnailSize ( props . boundaryWidth , props . boundaryHeight ) ;
69- }
70- markInternalRef . current ( ) ;
71- props . image . setValue ( file ) ;
72- if ( props . onCropAction ?. canExecute ) {
73- props . onCropAction . execute ( ) ;
74- }
75- } catch ( err ) {
76- if ( err instanceof CropError ) {
77- console . error ( "[image-cropper-web] CropError:" , err . message ) ;
78- } else {
79- console . error ( "[image-cropper-web] unexpected error:" , err ) ;
80- throw err ;
81- }
82- }
83- } , [
84- state . imageRef ,
85- props . image ,
86- props . outputFormat ,
87- props . outputQuality ,
88- props . outputSize ,
89- props . cropShape ,
90- props . boundaryWidth ,
91- props . boundaryHeight ,
92- props . onCropAction
93- ] ) ;
21+ // Push fresh Mendix props into the gate every render (config + image read live off it).
22+ useEffect ( ( ) => {
23+ gateProvider . setProps ( props ) ;
24+ } ) ;
9425
95- const auto = useAutoApplyCrop ( applyCrop ) ;
96- const { armed } = auto ;
97-
98- const handleImageLoad = useCallback (
99- ( percentCrop : Crop , pixelCrop : PixelCrop ) => {
100- setZoom ( Number ( props . minZoom ) ) ;
101- setZoomAnchor ( CENTER_ANCHOR ) ;
102- setLiveCrop ( percentCrop ) ;
103- setCommittedCrop ( pixelCrop ) ;
104- armed ( ) ;
105- } ,
106- [ setZoom , setLiveCrop , setCommittedCrop , props . minZoom , armed ]
107- ) ;
26+ const imageRef = useRef < HTMLImageElement > ( null ) ;
10827
10928 const uri = props . image . status === ValueStatus . Available ? props . image . value ?. uri : undefined ;
11029 const original = useOriginalImage (
11130 uri ,
11231 props . image . status === ValueStatus . Available ? props . image . value ?. name : undefined
11332 ) ;
114-
115- // Ref mirror so applyCrop's stable identity is untouched (same reason zoomRef exists).
116- const markInternalRef = useRef ( original . markInternalChange ) ;
117- markInternalRef . current = original . markInternalChange ;
118-
119- // Live preview for baked rotations: setValue defers the commit, so show a local
33+ // Live preview for baked rotations/reset: setValue defers the commit, so show a local
12034 // blob URL until the bound uri catches up on Save.
12135 const { previewSrc, showPreview } = usePreviewSrc ( uri ) ;
122- const showPreviewRef = useRef ( showPreview ) ;
123- showPreviewRef . current = showPreview ;
12436
37+ // Feed the store its imperative React-owned deps. getImage/getOriginal close over refs, so
38+ // re-inject every render; assigning plain (non-observable) fields triggers no reactions.
12539 useEffect ( ( ) => {
126- setLiveCrop ( undefined ) ;
127- setCommittedCrop ( undefined ) ;
128- armed ( ) ;
129- } , [ uri , setLiveCrop , setCommittedCrop , armed ] ) ;
130-
131- const handleCropComplete = useCallback (
132- ( pixelCrop : PixelCrop ) => {
133- committedCropRef . current = pixelCrop ;
134- setCommittedCrop ( pixelCrop ) ;
135- if ( userDraggedRef . current ) {
136- userDraggedRef . current = false ;
137- auto . applyNow ( ) ;
138- }
139- } ,
140- [ setCommittedCrop , auto ]
141- ) ;
142-
143- const handleZoomChange = useCallback (
144- ( next : SetStateAction < number > ) => {
145- // Freeze the zoom anchor at the current box center. Recomputing it only here (not while
146- // the box moves) keeps the image stable during drags/draws but still zooms into the box.
147- const live = liveCropRef . current ;
148- if ( live && live . unit === "%" ) {
149- setZoomAnchor ( { x : ( live . x + live . width / 2 ) / 100 , y : ( live . y + live . height / 2 ) / 100 } ) ;
150- }
151- setZoom ( next ) ;
152- auto . applyDebounced ( ) ;
153- } ,
154- [ setZoom , auto ]
155- ) ;
156-
157- const handleRotate = useCallback (
158- async ( deltaDeg : number ) => {
159- const img = state . imageRef . current ;
160- if ( ! img || props . image . readOnly || props . image . status !== ValueStatus . Available || ! props . image . value ) {
161- return ;
162- }
163- try {
164- // Working image is ALWAYS color so toggling grayscale OFF stays reversible.
165- const working = await rotateImage ( {
166- image : img ,
167- rotation : deltaDeg ,
168- outputFormat : props . outputFormat ,
169- outputQuality : Number ( props . outputQuality ) ,
170- grayscale : false ,
171- originalName : props . image . value . name
172- } ) ;
173- // Commit a baked B&W file only while the toggle is ON, so a rotate-then-Save
174- // with no further crop still persists grayscale.
175- const committed = grayscaleRef . current
176- ? await rotateImage ( {
177- image : img ,
178- rotation : deltaDeg ,
179- outputFormat : props . outputFormat ,
180- outputQuality : Number ( props . outputQuality ) ,
181- grayscale : true ,
182- originalName : props . image . value . name
183- } )
184- : working ;
185- setLiveCrop ( undefined ) ;
186- setCommittedCrop ( undefined ) ;
187- committedCropRef . current = undefined ;
188- armed ( ) ;
189- // Show COLOR working pixels; CropArea reloads from this blob and rebuilds
190- // a fresh crop against the swapped dimensions on its onLoad.
191- // The CSS grayscale filter from state.grayscale still renders gray on screen.
192- showPreviewRef . current ( working ) ;
193- markInternalRef . current ( ) ;
194- props . image . setValue ( committed ) ;
195- } catch ( err ) {
196- if ( err instanceof CropError ) {
197- console . error ( "[image-cropper-web] CropError:" , err . message ) ;
198- } else {
199- throw err ;
200- }
201- }
202- } ,
203- [ state . imageRef , props . image , props . outputFormat , props . outputQuality , setLiveCrop , setCommittedCrop , armed ]
204- ) ;
205-
206- const handleToggleGrayscale = useCallback ( ( ) => {
207- setGrayscale ( prev => ! prev ) ;
208- auto . applyDebounced ( ) ;
209- } , [ setGrayscale , auto ] ) ;
210-
211- const handleReset = useCallback ( ( ) => {
212- setZoom ( Number ( props . minZoom ) ) ;
213- setZoomAnchor ( CENTER_ANCHOR ) ;
214- setGrayscale ( false ) ;
215- armed ( ) ; // do not auto-apply the reset itself
216- const file = original . getOriginal ( ) ;
217- if ( file && ! props . image . readOnly && props . image . status === ValueStatus . Available ) {
218- // Mirror handleRotate: setValue defers the commit, so drive the live preview with the
219- // original bytes too — otherwise a stale rotated blob keeps rendering after Reset.
220- showPreviewRef . current ( file ) ;
221- markInternalRef . current ( ) ;
222- props . image . setValue ( file ) ;
223- }
224- // Re-seed the default cropbox to its initial state. If restoring original bytes changed
225- // the uri (e.g. after a rotation), CropArea's onLoad re-seeds again against the correct
226- // dimensions; when no edit occurred the uri is unchanged and onLoad won't refire, so this
227- // direct re-seed is what puts the box back.
228- const img = state . imageRef . current ;
229- if ( img && img . naturalWidth ) {
230- const aspect = resolveAspectRatio ( props . aspectRatio , props . customAspectWidth , props . customAspectHeight ) ;
231- const { percentCrop, pixelCrop } = buildInitialCrop ( img , aspect ) ;
232- setLiveCrop ( percentCrop ) ;
233- setCommittedCrop ( pixelCrop ) ;
234- } else {
235- setLiveCrop ( undefined ) ;
236- setCommittedCrop ( undefined ) ;
237- }
238- } , [
239- setZoom ,
240- props . minZoom ,
241- props . image ,
242- props . aspectRatio ,
243- props . customAspectWidth ,
244- props . customAspectHeight ,
245- setGrayscale ,
246- setLiveCrop ,
247- setCommittedCrop ,
248- armed ,
249- original ,
250- state . imageRef
251- ] ) ;
40+ store . setDeps ( {
41+ getImage : ( ) => imageRef . current ,
42+ showPreview,
43+ markInternalChange : original . markInternalChange ,
44+ getOriginal : original . getOriginal
45+ } ) ;
46+ } ) ;
47+
48+ // Inbound image sync: a new bound uri means the source changed — clear the crop and disarm.
49+ useEffect ( ( ) => {
50+ store . onImageChanged ( ) ;
51+ } , [ uri , store ] ) ;
25252
25353 if ( props . image . status === ValueStatus . Loading ) {
25454 return (
@@ -293,41 +93,37 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE
29393 ) ;
29494 }
29595
296- const aspect = resolveAspectRatio ( props . aspectRatio , props . customAspectWidth , props . customAspectHeight ) ;
297-
29896 return (
29997 < div className = { classNames ( "widget-image-cropper" , props . class ) } style = { props . style } tabIndex = { props . tabIndex } >
30098 < CropArea
30199 src = { previewSrc ?? props . image . value . uri }
302- crop = { state . liveCrop }
303- onCropChange = { state . setLiveCrop }
304- onCropComplete = { handleCropComplete }
305- onUserInteractStart = { ( ) => {
306- userDraggedRef . current = true ;
307- } }
308- aspect = { aspect }
100+ crop = { store . liveCrop }
101+ onCropChange = { crop => store . setLiveCrop ( crop ) }
102+ onCropComplete = { pixelCrop => store . commitCrop ( pixelCrop ) }
103+ onUserInteractStart = { ( ) => store . markUserDragged ( ) }
104+ aspect = { store . aspect }
309105 circular = { props . cropShape === "circle" }
310106 resizable = { props . resizableEnabled }
311107 boundaryWidth = { props . boundaryWidth }
312108 boundaryHeight = { props . boundaryHeight }
313- onImageLoad = { handleImageLoad }
314- zoom = { state . zoom }
109+ onImageLoad = { ( percentCrop , pixelCrop ) => store . initFromImageLoad ( percentCrop , pixelCrop ) }
110+ zoom = { store . zoom }
315111 minZoom = { Number ( props . minZoom ) }
316112 maxZoom = { Number ( props . maxZoom ) }
317- setZoom = { handleZoomChange }
318- zoomAnchor = { zoomAnchor }
113+ setZoom = { next => store . setZoom ( typeof next === "function" ? next ( store . zoom ) : next ) }
114+ zoomAnchor = { store . zoomAnchor }
319115 wheelZoomMode = { props . zoomEnabled ? props . wheelZoomMode : "off" }
320- grayscale = { state . grayscale }
321- imageRef = { state . imageRef }
116+ grayscale = { store . grayscale }
117+ imageRef = { imageRef }
322118 />
323119 < CropToolbar
324120 showRotation = { props . enableRotation }
325121 showGrayscale = { props . enableGrayscale }
326122 showZoom = { props . zoomEnabled && props . showZoomSlider }
327123 showReset = { props . showResetButton }
328- grayscale = { state . grayscale }
124+ grayscale = { store . grayscale }
329125 canReset = { original . canRestore }
330- zoom = { state . zoom }
126+ zoom = { store . zoom }
331127 minZoom = { Number ( props . minZoom ) }
332128 maxZoom = { Number ( props . maxZoom ) }
333129 rotateLeftLabel = { props . rotateLeftLabel ?. value ?? "Rotate left" }
@@ -338,12 +134,12 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE
338134 resetAriaLabel = { props . resetAriaLabel ?. value ?? "Reset crop" }
339135 zoomCaption = { props . zoomCaption ?. value ?? "Zoom" }
340136 zoomAriaLabel = { props . zoomAriaLabel ?. value ?? "Zoom" }
341- onZoomChange = { handleZoomChange }
342- onRotateLeft = { ( ) => handleRotate ( - 90 ) }
343- onRotateRight = { ( ) => handleRotate ( 90 ) }
344- onToggleGrayscale = { handleToggleGrayscale }
345- onReset = { handleReset }
137+ onZoomChange = { zoom => store . setZoom ( zoom ) }
138+ onRotateLeft = { ( ) => store . rotate ( - 90 ) }
139+ onRotateRight = { ( ) => store . rotate ( 90 ) }
140+ onToggleGrayscale = { ( ) => store . toggleGrayscale ( ) }
141+ onReset = { ( ) => store . reset ( ) }
346142 />
347143 </ div >
348144 ) ;
349- }
145+ } ) ;
0 commit comments