Skip to content

Commit fb16fae

Browse files
committed
refactor(image-cropper-web): drive container from ImageCropperStore
Wire ImageCropperContainer to the MobX store via the mobx-kit gate pattern (GateProvider + useConst + useSetup) and wrap it in observer. Crop operations become plain store method calls. Remove the five state-mirroring refs (committedCropRef, zoomRef, grayscaleRef, liveCropRef, zoomAnchorRef) and the two callback mirrors (markInternalRef, showPreviewRef); the store reads its own observable state, so stable-identity closures no longer need them. Config and the controlled image value are read live through the gate; the bound uri is synced inbound via a single effect calling store.onImageChanged. usePreviewSrc, useOriginalImage and the DOM image ref stay in React and are injected into the store through setDeps. Delete useAutoApplyCrop and useImageCropperState, now subsumed by the store.
1 parent 8d127e9 commit fb16fae

5 files changed

Lines changed: 51 additions & 355 deletions

File tree

packages/pluggableWidgets/image-cropper-web/src/components/CropArea.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { Dispatch, ReactElement, Ref, SetStateAction, SyntheticEvent, useCallbac
22
import { default as ReactCrop, type Crop, type PixelCrop } from "react-image-crop";
33
import { ZoomContainer } from "./ZoomContainer";
44
import { WheelZoomModeEnum } from "../../typings/ImageCropperProps";
5-
import { CENTER_ANCHOR, type ZoomAnchor } from "../utils/cropImage";
65
import { isStrayCrop, MIN_CROP_PX } from "../utils/cropGuard";
6+
import { CENTER_ANCHOR, type ZoomAnchor } from "../utils/cropImage";
77
import { buildInitialCrop } from "../utils/initialCrop";
88
import { safeImageUri } from "../utils/safeImageUri";
99

Lines changed: 50 additions & 254 deletions
Original file line numberDiff line numberDiff line change
@@ -1,254 +1,54 @@
11
import classNames from "classnames";
22
import { 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";
58
import { CropArea } from "./CropArea";
69
import { CropToolbar } from "./CropToolbar";
710
import { ImageCropperContainerProps } from "../../typings/ImageCropperProps";
8-
import { useAutoApplyCrop } from "../hooks/useAutoApplyCrop";
9-
import { useImageCropperState } from "../hooks/useImageCropperState";
1011
import { useOriginalImage } from "../hooks/useOriginalImage";
1112
import { 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 (
@@ -291,41 +91,37 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE
29191
);
29292
}
29393

294-
const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth, props.customAspectHeight);
295-
29694
return (
29795
<div className={classNames("widget-image-cropper", props.class)} style={props.style} tabIndex={props.tabIndex}>
29896
<CropArea
29997
src={previewSrc ?? props.image.value.uri}
300-
crop={state.liveCrop}
301-
onCropChange={state.setLiveCrop}
302-
onCropComplete={handleCropComplete}
303-
onUserInteractStart={() => {
304-
userDraggedRef.current = true;
305-
}}
306-
aspect={aspect}
98+
crop={store.liveCrop}
99+
onCropChange={crop => store.setLiveCrop(crop)}
100+
onCropComplete={pixelCrop => store.commitCrop(pixelCrop)}
101+
onUserInteractStart={() => store.markUserDragged()}
102+
aspect={store.aspect}
307103
circular={props.cropShape === "circle"}
308104
resizable={props.resizableEnabled}
309105
boundaryWidth={props.boundaryWidth}
310106
boundaryHeight={props.boundaryHeight}
311-
onImageLoad={handleImageLoad}
312-
zoom={state.zoom}
107+
onImageLoad={(percentCrop, pixelCrop) => store.initFromImageLoad(percentCrop, pixelCrop)}
108+
zoom={store.zoom}
313109
minZoom={Number(props.minZoom)}
314110
maxZoom={Number(props.maxZoom)}
315-
setZoom={handleZoomChange}
316-
zoomAnchor={zoomAnchor}
111+
setZoom={next => store.setZoom(typeof next === "function" ? next(store.zoom) : next)}
112+
zoomAnchor={store.zoomAnchor}
317113
wheelZoomMode={props.zoomEnabled ? props.wheelZoomMode : "off"}
318-
grayscale={state.grayscale}
319-
imageRef={state.imageRef}
114+
grayscale={store.grayscale}
115+
imageRef={imageRef}
320116
/>
321117
<CropToolbar
322118
showRotation={props.enableRotation}
323119
showGrayscale={props.enableGrayscale}
324120
showZoom={props.zoomEnabled && props.showZoomSlider}
325121
showReset={props.showResetButton}
326-
grayscale={state.grayscale}
122+
grayscale={store.grayscale}
327123
canReset={original.canRestore}
328-
zoom={state.zoom}
124+
zoom={store.zoom}
329125
minZoom={Number(props.minZoom)}
330126
maxZoom={Number(props.maxZoom)}
331127
rotateLeftLabel={props.rotateLeftLabel?.value ?? "Rotate left"}
@@ -336,12 +132,12 @@ export function ImageCropperContainer(props: ImageCropperContainerProps): ReactE
336132
resetAriaLabel={props.resetAriaLabel?.value ?? "Reset crop"}
337133
zoomCaption={props.zoomCaption?.value ?? "Zoom"}
338134
zoomAriaLabel={props.zoomAriaLabel?.value ?? "Zoom"}
339-
onZoomChange={handleZoomChange}
340-
onRotateLeft={() => handleRotate(-90)}
341-
onRotateRight={() => handleRotate(90)}
342-
onToggleGrayscale={handleToggleGrayscale}
343-
onReset={handleReset}
135+
onZoomChange={zoom => store.setZoom(zoom)}
136+
onRotateLeft={() => store.rotate(-90)}
137+
onRotateRight={() => store.rotate(90)}
138+
onToggleGrayscale={() => store.toggleGrayscale()}
139+
onReset={() => store.reset()}
344140
/>
345141
</div>
346142
);
347-
}
143+
});

packages/pluggableWidgets/image-cropper-web/src/hooks/__tests__/useImageCropperState.spec.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.

0 commit comments

Comments
 (0)