From 4a5b6c40327c086ad7a8dcbc0a087b1f1ffd26a1 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 21 May 2026 22:54:39 +0200 Subject: [PATCH 01/42] feat(image-crop-web): scaffold package metadata --- .../image-crop-web/.eslintrc.js | 3 + .../image-crop-web/.gitignore | 3 + .../image-crop-web/CHANGELOG.md | 13 +++++ .../pluggableWidgets/image-crop-web/LICENSE | 15 +++++ .../pluggableWidgets/image-crop-web/README.md | 5 ++ .../image-crop-web/package.json | 56 +++++++++++++++++++ 6 files changed, 95 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/.eslintrc.js create mode 100644 packages/pluggableWidgets/image-crop-web/.gitignore create mode 100644 packages/pluggableWidgets/image-crop-web/CHANGELOG.md create mode 100644 packages/pluggableWidgets/image-crop-web/LICENSE create mode 100644 packages/pluggableWidgets/image-crop-web/README.md create mode 100644 packages/pluggableWidgets/image-crop-web/package.json diff --git a/packages/pluggableWidgets/image-crop-web/.eslintrc.js b/packages/pluggableWidgets/image-crop-web/.eslintrc.js new file mode 100644 index 0000000000..5dbc3056b7 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: "@mendix/eslint-config-web-widgets/widget-ts" +}; diff --git a/packages/pluggableWidgets/image-crop-web/.gitignore b/packages/pluggableWidgets/image-crop-web/.gitignore new file mode 100644 index 0000000000..2d55399e96 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/.gitignore @@ -0,0 +1,3 @@ +dist/ +*.mpk +typings/ diff --git a/packages/pluggableWidgets/image-crop-web/CHANGELOG.md b/packages/pluggableWidgets/image-crop-web/CHANGELOG.md new file mode 100644 index 0000000000..bc5fa0bfdd --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this widget will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2026-05-21 + +### Added + +- Initial release. Crops a bound `EditableImageValue` attribute with rectangular or circular viewport, optional zoom (slider + wheel), live preview pane, and PNG/JPEG output. Replaces the legacy ImageCrop widget. diff --git a/packages/pluggableWidgets/image-crop-web/LICENSE b/packages/pluggableWidgets/image-crop-web/LICENSE new file mode 100644 index 0000000000..035fced0d9 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/LICENSE @@ -0,0 +1,15 @@ +The Apache License v2.0 + +Copyright © Mendix Technology BV 2022. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/pluggableWidgets/image-crop-web/README.md b/packages/pluggableWidgets/image-crop-web/README.md new file mode 100644 index 0000000000..5d710abd1f --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/README.md @@ -0,0 +1,5 @@ +# Image Crop + +Crops images bound to a Mendix image attribute. The cropped result is written back to the same attribute via `EditableImageValue.setValue(file)`. + +See the [Mendix Marketplace listing](https://marketplace.mendix.com/) for usage docs. diff --git a/packages/pluggableWidgets/image-crop-web/package.json b/packages/pluggableWidgets/image-crop-web/package.json new file mode 100644 index 0000000000..8c2e10e29c --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/package.json @@ -0,0 +1,56 @@ +{ + "name": "@mendix/image-crop-web", + "widgetName": "ImageCrop", + "version": "1.0.0", + "description": "Crop images bound to a Mendix image attribute", + "copyright": "© Mendix Technology BV 2026. All rights reserved.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/mendix/web-widgets.git" + }, + "config": {}, + "mxpackage": { + "name": "ImageCrop", + "type": "widget", + "mpkName": "com.mendix.widget.web.ImageCrop.mpk" + }, + "packagePath": "com.mendix.widget.web", + "marketplace": { + "minimumMXVersion": "10.21.0", + "appName": "Image Crop", + "appNumber": 0, + "reactReady": true + }, + "testProject": { + "githubUrl": "https://github.com/mendix/testProjects", + "branchName": "image-crop-web" + }, + "scripts": { + "build": "pluggable-widgets-tools build:web", + "create-gh-release": "rui-create-gh-release", + "create-translation": "rui-create-translation", + "dev": "pluggable-widgets-tools start:web", + "format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .", + "lint": "eslint src/ package.json", + "publish-marketplace": "rui-publish-marketplace", + "release": "pluggable-widgets-tools release:web", + "start": "pluggable-widgets-tools start:server", + "test": "pluggable-widgets-tools test:unit:web", + "update-changelog": "rui-update-changelog-widget", + "verify": "rui-verify-package-format" + }, + "dependencies": { + "classnames": "^2.5.1", + "react-image-crop": "^11.0.10" + }, + "devDependencies": { + "@mendix/automation-utils": "workspace:*", + "@mendix/eslint-config-web-widgets": "workspace:*", + "@mendix/pluggable-widgets-tools": "*", + "@mendix/prettier-config-web-widgets": "workspace:*", + "@mendix/rollup-web-widgets": "workspace:*", + "@mendix/widget-plugin-platform": "workspace:*", + "jest-canvas-mock": "^2.5.2" + } +} From fccfd7fd0004f2ffca904d26cf9fa200466f28dc Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 21 May 2026 23:02:28 +0200 Subject: [PATCH 02/42] fix(image-crop-web): align LICENSE copyright year with package.json --- packages/pluggableWidgets/image-crop-web/LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/image-crop-web/LICENSE b/packages/pluggableWidgets/image-crop-web/LICENSE index 035fced0d9..e5576bd26b 100644 --- a/packages/pluggableWidgets/image-crop-web/LICENSE +++ b/packages/pluggableWidgets/image-crop-web/LICENSE @@ -1,6 +1,6 @@ The Apache License v2.0 -Copyright © Mendix Technology BV 2022. All rights reserved. +Copyright © Mendix Technology BV 2026. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From b0e19ac205753fe9c561e3a30a709bdb8e00ee03 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 21 May 2026 23:17:02 +0200 Subject: [PATCH 03/42] chore(image-crop-web): add TypeScript and Rollup config --- .../image-crop-web/rollup.config.mjs | 5 +++ .../image-crop-web/tsconfig.json | 27 +++++++++++++ pnpm-lock.yaml | 40 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/rollup.config.mjs create mode 100644 packages/pluggableWidgets/image-crop-web/tsconfig.json diff --git a/packages/pluggableWidgets/image-crop-web/rollup.config.mjs b/packages/pluggableWidgets/image-crop-web/rollup.config.mjs new file mode 100644 index 0000000000..688a1a7197 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/rollup.config.mjs @@ -0,0 +1,5 @@ +import copyFiles from "@mendix/rollup-web-widgets/copyFiles.mjs"; + +export default args => { + return copyFiles(args); +}; diff --git a/packages/pluggableWidgets/image-crop-web/tsconfig.json b/packages/pluggableWidgets/image-crop-web/tsconfig.json new file mode 100644 index 0000000000..63d18d877f --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": ["./src", "./typings"], + "compilerOptions": { + "baseUrl": "./", + "noEmitOnError": true, + "sourceMap": true, + "module": "esnext", + "target": "es6", + "lib": ["esnext", "dom"], + "types": ["jest", "node"], + "moduleResolution": "node", + "declaration": false, + "noLib": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "strict": true, + "strictFunctionTypes": false, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "useUnknownInCatchVariables": false, + "exactOptionalPropertyTypes": false + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb1ef83ce6..c081a4ea89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1729,6 +1729,37 @@ importers: specifier: workspace:* version: link:../../shared/widget-plugin-platform + packages/pluggableWidgets/image-crop-web: + dependencies: + classnames: + specifier: ^2.5.1 + version: 2.5.1 + react-image-crop: + specifier: ^11.0.10 + version: 11.0.10(react@18.3.1) + devDependencies: + '@mendix/automation-utils': + specifier: workspace:* + version: link:../../../automation/utils + '@mendix/eslint-config-web-widgets': + specifier: workspace:* + version: link:../../shared/eslint-config-web-widgets + '@mendix/pluggable-widgets-tools': + specifier: 11.8.0 + version: 11.8.0(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + '@mendix/prettier-config-web-widgets': + specifier: workspace:* + version: link:../../shared/prettier-config-web-widgets + '@mendix/rollup-web-widgets': + specifier: workspace:* + version: link:../../shared/rollup-web-widgets + '@mendix/widget-plugin-platform': + specifier: workspace:* + version: link:../../shared/widget-plugin-platform + jest-canvas-mock: + specifier: ^2.5.2 + version: 2.5.2 + packages/pluggableWidgets/image-web: dependencies: '@mendix/widget-plugin-component-kit': @@ -9445,6 +9476,11 @@ packages: peerDependencies: react: '>=18.0.0 <19.0.0' + react-image-crop@11.0.10: + resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} + peerDependencies: + react: '>=18.0.0 <19.0.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -18731,6 +18767,10 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 + react-image-crop@11.0.10(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@16.13.1: {} react-is@17.0.2: {} From bce5543b89838bd313155dd70f9c6e4301b3295b Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 21 May 2026 23:20:32 +0200 Subject: [PATCH 04/42] feat(image-crop-web): declare widget XML properties --- .../image-crop-web/src/ImageCrop.xml | 130 ++++++++++++++++++ .../image-crop-web/src/package.xml | 11 ++ 2 files changed, 141 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml create mode 100644 packages/pluggableWidgets/image-crop-web/src/package.xml diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml new file mode 100644 index 0000000000..91dc440a83 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml @@ -0,0 +1,130 @@ + + + Image Crop + Crop an image attribute + https://docs.mendix.com/appstore/widgets/image-crop + + + + + Image attribute + Editable image attribute. The cropped image overwrites this attribute. + + + + + Crop shape + + + Rectangle + Circle + + + + Aspect ratio + + + Free + 1:1 + 16:9 + 4:3 + 3:4 + Custom + + + + Custom aspect width + + + + Custom aspect height + + + + Canvas width (px) + + + + Canvas height (px) + + + + Resizable handles + Allow the user to resize the crop area by dragging its corners. + + + + + Enable zoom + + + + Mouse wheel zoom + + + Off + On + On (hold Ctrl) + + + + Minimum zoom + + + + Maximum zoom + + + + + + Show preview + + + + Preview width (px) + + + + Preview height (px) + + + + + + Output format + + + PNG + JPEG + + + + JPEG quality (0.0 - 1.0) + + + + Output size + + + Viewport (canvas dims) + Original (source resolution) + + + + + + Crop button caption + + + Crop + + + + On crop + Action executed after the cropped image is committed. + + + + + diff --git a/packages/pluggableWidgets/image-crop-web/src/package.xml b/packages/pluggableWidgets/image-crop-web/src/package.xml new file mode 100644 index 0000000000..6afc242dce --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/package.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + From 4bef6b0d9a9d93719de79ded0bc73b6ad2cad423 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 09:53:59 +0200 Subject: [PATCH 05/42] feat(image-crop-web): generate typings and add minimal widget stubs Co-Authored-By: Claude Opus 4.7 --- .../image-crop-web/eslint.config.mjs | 3 + .../src/ImageCrop.editorConfig.ts | 25 ++++++ .../src/ImageCrop.editorPreview.tsx | 10 +++ .../src/ImageCrop.icon.dark.png | Bin 0 -> 1223 bytes .../image-crop-web/src/ImageCrop.icon.png | Bin 0 -> 1272 bytes .../src/ImageCrop.tile.dark.png | Bin 0 -> 5694 bytes .../image-crop-web/src/ImageCrop.tile.png | Bin 0 -> 5897 bytes .../image-crop-web/src/ImageCrop.tsx | 6 ++ .../typings/ImageCropProps.d.ts | 78 ++++++++++++++++++ 9 files changed, 122 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/eslint.config.mjs create mode 100644 packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts create mode 100644 packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx create mode 100755 packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.dark.png create mode 100755 packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.png create mode 100755 packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.dark.png create mode 100755 packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.png create mode 100644 packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx create mode 100644 packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts diff --git a/packages/pluggableWidgets/image-crop-web/eslint.config.mjs b/packages/pluggableWidgets/image-crop-web/eslint.config.mjs new file mode 100644 index 0000000000..ed68ae9e78 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/eslint.config.mjs @@ -0,0 +1,3 @@ +import config from "@mendix/eslint-config-web-widgets/widget-ts.mjs"; + +export default config; diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts new file mode 100644 index 0000000000..ae53416874 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts @@ -0,0 +1,25 @@ +import { hidePropertiesIn, Properties } from "@mendix/pluggable-widgets-tools"; +import { ImageCropPreviewProps } from "../typings/ImageCropProps"; + +export function getProperties(values: ImageCropPreviewProps, defaultProperties: Properties): Properties { + const propsToHide: Array = []; + + if (values.aspectRatio !== "custom") { + propsToHide.push("customAspectWidth", "customAspectHeight"); + } + + if (!values.zoomEnabled) { + propsToHide.push("wheelZoomMode", "minZoom", "maxZoom"); + } + + if (!values.showPreview) { + propsToHide.push("previewWidth", "previewHeight"); + } + + if (values.outputFormat !== "jpeg") { + propsToHide.push("outputQuality"); + } + + hidePropertiesIn(defaultProperties, values, propsToHide); + return defaultProperties; +} diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx new file mode 100644 index 0000000000..fd831d3ac4 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx @@ -0,0 +1,10 @@ +import { ReactElement } from "react"; +import { ImageCropPreviewProps } from "../typings/ImageCropProps"; + +export function preview(_props: ImageCropPreviewProps): ReactElement { + return
Image Crop
; +} + +export function getPreviewCss(): string { + return ""; +} diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.dark.png b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.dark.png new file mode 100755 index 0000000000000000000000000000000000000000..1cae9739f5d08cb2853b6ea73c92e4a2734c67ba GIT binary patch literal 1223 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBC{d9b;hE;^%b*2hb1<+l zN-=;;U<6`2Mrks064nT^tz$3Dlfr0M`2s2LA=96Y%VBYEJ;uuoF_%`Zd=`8~hQ<(>|^Dg8} zC~KU=YsC;SL*XooubPJR0|w@l<|V==(kjv|`41f4XY0yGZv3hed91CgxMYd?OWV8E zx+gdJinPWDC};?XaB#7(HZlEQ&7>>*==0A%$=h$wmFH_e9k%+a`Nx%;`3>xH%x2Gi zS@P@D!vt1^PjmgAf7cWUKe2B6?YHWFE!!S_ImhyIzPiV}iZ7Yh_9-t3*}&BBC;aI1 zDQCR-a@t=n*v8-PTy}GX-SI8qtFJmr_1|58LsEg&`gD@O10DU zzPxwyVFtS?N;xaeZ%Du4{_bSnq4kqg70&S~RC7;acAUZ&q;dT8(@(9xI(}Jw_F_43 z^Pyn)?gA*FQ%zm(> zF(2%cTejim-GgzDxBXx)XS(R}b92s>z;AZU4j-obu)MBmTE0nqiTM`aCuUR9yJ|}= z?`GJ2H;--6dKuwYms^-8mNRA?ZsrKL5?s;yt3XM1@!j1FEVIh~T+Arhc>n!!gQunc zSU#02oZq){;>pK?-=$S|o(tdE2o>&{9k9xYMW5+O!*(IXDpw~s?Q~1enRVPix zt^?c~=5V;n?&pz$-$5d8!PqsOm@_);v4GSdu z=gDr7iA{4@^C;+G_Bx)QoBn0hoV(U@e5acpQ%cjSC$5V%S18pB9yc+U_3sV$V&Jo3 z@A@2mu*j_U_}9SOb6E{o<5ikg?&rF({jExaZbc|dbp8D$Pb;o5?PwBL+VSi3g4$_o z<2!e*F=pJ+v%ce@_4av;`xvjt>T?8q7hO?Z^V)Vn-T@Q4zUQ|dujO?MW2iY1@%VS# zuTb;nHxH!kIrcU6JHv$O``?)KF)+Vq%zQqd^~3Tu&9h8;0hezz{^Y%pZxQo`XTkfL zFS`7Uk)}HjdTqI^yfaYg(Tm?4XXbNOcN#WlBjv61bqC}*nwXzli8fvb%Hf`_elF{r G5}E+&GwbmH literal 0 HcmV?d00001 diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.png b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.png new file mode 100755 index 0000000000000000000000000000000000000000..8c7b266490c19bbe9fcaf0eeaf807cde455ab36c GIT binary patch literal 1272 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 literal 0 HcmV?d00001 diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.png b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.png new file mode 100755 index 0000000000000000000000000000000000000000..f7f7732cc77300c122b3a642e25e836520486d18 GIT binary patch literal 5897 zcmc&&XIvA_*58Bx0Tew01zq+0Jy&Z;231#RskSD8UWVq z06;kd0C>DJ8%$Ke55W(t^qdR~05On;12705@OKY?kD(6X|36v>av7lehYtmSSXTh{ zZy7_d{QI&4pTFDuZ%LO6`L{I4%Z2_&8Up0f{f7oxKxyEm&rugxFnQmw_5%Rcj=vvB zsyHh@0G#=yr=@W_7_ymV;K$W{>PHRm2l&}nT5=nLxL#I}kMBbQ&y54-Rt;iokg^Rw(>G@-R-3z=wC?kHxA2mv(kG(;lTa%4P9H zJ(AP^AiF<&air4pz0~6$;#X-Qmpe>;{?5u{HfPfg&;p=yaGJzKS*d4fwPDkt>C<@& zyC3-C*umLoS=#&Oc@|G!9Pk8K=+}>OFEA*@tg?tcV`$$ue1)yE?uiOt*8|(ze`JVh zrY*#94Sb>0<6^Y5!f}b?=-QhlvA@QdbMsc$svHUJ?y$a&>eYh zIY2+I`1;$AyAgBas`96TR@v>nP8@sa&C@Z@6C#!9v zU+vfwLC%A{Eb@BS_N*YWGU7p%ctM>;D{I%D5$nk4ap&^Ue6J->=%-KPySq(Rqd5Sq zCHmUKD`<4SLneXiaoK`tTo&W6pg^Gw=LFTp$gsq~>99ZT1GU3UA<{JUIzUS-x;PY;n>K3wq=XzN1(5bxm`)fI=sgokOl z=-Zn&L-?8vGf9ipqBCO~!FpK!hb5%}U5Sj#*Z$ft0+*zS;M)J$v!_9>KRf$qr)fKp z#7cK;h%5O$K3?-pd5QMEmM$z;f~6R$Bjqj2DwMWdgg&%p3;!~lw}E=RWVck2ZR zJm<}>hdVB#QjfnS2z@^V;v#A4rS6_dnt;$0Ir`*o5FuE!b^dPo!H*!w&%_=J{omak zZ2w5%aXE?i-)c#5NQr)%no<#D9l?J65###?9aVpJo{s|uq$yDx+axClTl*PDN_ z*A+=xDz-ei(b&;lY{nT>6^^PZsdDpiqTXlW9<41;j&#s1y6#`s0V;dO~C0X>pY{a$M!@?P6= zlVejA9t@yfYnvt68P+wETTP$XN*b4pX}uEsZF?erYeZO+o5S`=6d){W?fl#;?M3q> zyNklSwKAXf3B3J<0iedd;?=i@MHvIS?)mKtVtQs@j}VjRyAy#=XSL%83Bx8~ha%q;W$0AVUd5#$r0Wcnp4D!W^++P!-3OBNg+MEbvGWS>g1t_{i z%}S)x#Ckn#PV%%~aYcg|*nHuo{>udCwE5-85Q9B>JJ4!f z+%Q{5(Yc=rF^L6@1M9rsDC4ilMbVbT5(i4@F>Baw>| z5|RSS>UVAyJd61gvSRB0pvqCG-7@SJzU|`IrC+Z8L(tKeGS)C<$eIInxr=1d9Csl_ z1at>ELo2P^k#Anp=*=Q}1wXZ@FgL7u66f+X+c<|_6f$z&uX7!*g=J=?THU#iM8ez9o=*~T4YaR%yhpW1FJzwUst6kVNjdAAQO32wc zI>9A%MaAwUz4m#v@zc3s@;ue&(17EHY(znaxoB+H^_nK4O7xlV`R0;hOY89^O;l1#~_EJI1_2|TFzvT6p_cBVF;dAV{NkkcDH8%W3 zvKLdxJW@Ww@L=HJA^Jjl9R_Q(R<_<({w$FWfm7IwHzqLMl8d+&{BgbUp|nT&x8tUI zf;O({+#|2DVqadU@zSW~gfr^-$YH=68msUj$}tX5ydoFxKu`Ctx*ia6s1e!)kg!gFSd9;ejWdvZHi5fAYN;7cZLs{fCHUmv``#xgpo( zw*ERHn4OOCcqtz&D&224sb6fft+Sj&LSeEhQCQYPL!=j)Re1oFj=-VTP@ASqT%NQB zSqW%+t?j)EZCnkbjOK=H z3u?`z3deX^UI&raaH+C{3*t$mZTE=BK66$#TtX}+3I0zy#ncFu)C zU9camS8VJ#Ot9qFgL2K`Qv;poMut}W@qcGSmq5XXJk2OOZob9!ZVWNIR?hC-tZ~QIl^}{@Ep{x zmU+Pzk>NlKTJxJKcvr8q5@$EDQE__gLyiU$#syt%lUsVedbx~ zK;WoG*O`98B)_E*he}(&(@C5ny8gzm7M^oJ08!nYM$M9Y(!U~hMH+?;lE>!LgA~mq3D|;i0O=IjYEc8jas|YfYhgT2 zSu>I43)B7Y-&`PYLZpftO(<&E=$AXQrzT7`+l1+(JDJT&yxw7JwAwkMYRkv|3RM3| z*K%MJr%UgJX{JRHDB%p?N%P;HmcH=Xex2Aig@g*HD7CSZYoEnZ?_%06#{=~O%`WkP zr!bG>Q5;5y-tH-t(<}7AjfR9WBq&9hC16_*iFBu@pYe!637j}Ba{Bt&)5C|=mp&`4 z6HmT+@IArlIqpjvjJB!-ISN*CsX2s?{PmMoMS41G77z5PQMxCE(pB&}OfB+cqG+Df zHil3Uzx-K?{qT@a?dg2F4)Xq|GtM-LYPS8KKMD)*n?lg}?{pg-z5@>LE8y}vz_*0i zOxaKg=%~U3#c-~mZUQRk5xd`mkg}<|G?aN|M1^n`O6Bn zdcE55Qfm+tJ=9?C9`07X4@K~;2g!&ml3FPp^@`-N;oTM9r@C6m%-F4udjtb(t)CKE zkt1o+zkTmWv<@H#)LOD0%bvJh0VWSMqp!BAE)}cr*VW5PW}=`a4UheY62#*NHaG}5 zTkktCW#k(4?C4Dnd`bPVDd4ityFSCON{p<1LUqs3Lt&(#{WWRn%xiDn?v%CpzM}33 zjkdDYhME`Yj`U<8p)cQ=s6gz<#I8QlZlcahgIj0Sc2Z{xGbEu$d;RZs_LZ`oXe7bP zM`f1gJvBw}VSS*QTAPNYm>)HcJn`diHq?y=_PE3v;|u#qE0+>x6GvsVaE5&z&69AP zEA5f{Lwl?7!vJvNANUUqA00LPMg27*i-(^YwzR%!&H~{jJToO8^cz)4ISna<6~XR1={+nM7n<{AuT;c)Le6P_@DQWa zyl;I^R)cNHeMYIH6Y>JMEAi4ea4eOBQ)UFGLrk(-LCtwu@Zop#pH!Tkp zkrpf9M3VL&yd|Iqq{N$sOTR1?P($gcXfFmyWw+BzJ10zl^ep_JUzJ= zXK4r&@}UW7%5{-M%GB@tWd-%FjE}35uZri#gZ*BQ%{Qvt4!JiG!(CMHE)HIR8(+3 z842~jJ?AkTme@Xd7{6|u8pZbd=0#&B$Y8|cR4D|jts;E>_?$LLm#XO691#N_WMtY| zsqL~S9X`Mdyt{>he-xVg1h|mP--xeaU^<_wP^h`$E_>c)6;1!tsxc@+UCJ_)gJ~=4 zv#oa#J`XkbvTL4lqVM6Qzhx$~L23R1cXqF~rurwrDf~yOYOL*t@iMvZi0ik2!n5d)scuo!jbxE~Fzy}2Q{=Jy z%4#M>cAPxf>&;!!D^S`|pU1@}POJOshilPA9c78+%U_R$Y$WjC=#l=CGHb@ZJ*wfK zQ52B?Zamoah7XsI&}H4|qKDW6;{N407H9!8ZeN0Prv@F6tByWSxUR8A^}A-SCW-CrUM?vUg7JyBr&o8?vM9g}`QDo`u4B%7u zwBVRufGtuhc%z@>Cqxoype|1!-0h6 zihaFXiU_=3VtXrPjq%q6G0yNSoZrpB&K{ECL@QffD-MW1n_8vHIyM>V1lD@)gGb)$(qE96`z#tD+kVHx^Pa;3urKv155 zbqv-o@eZ3d1W(U&pBunamsn6;^T;_i{12#Iia>bZXp(*BUcY34BR}2&%nTd9bkdKK@MI7BX{8s*VdV#++dhY4>LiyaDZp@k-y15yPA*ty#5R)wPTqtL}t~XYoY9g`~`ZxnV$0+fb|Qx?R-&0O@0vwg3PC literal 0 HcmV?d00001 diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx new file mode 100644 index 0000000000..4352e42929 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx @@ -0,0 +1,6 @@ +import { ReactElement } from "react"; +import { ImageCropContainerProps } from "../typings/ImageCropProps"; + +export function ImageCrop(_props: ImageCropContainerProps): ReactElement | null { + return null; +} diff --git a/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts b/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts new file mode 100644 index 0000000000..723099d423 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts @@ -0,0 +1,78 @@ +/** + * This file was generated from ImageCrop.xml + * WARNING: All changes made to this file will be overwritten + * @author Mendix Widgets Framework Team + */ +import { CSSProperties } from "react"; +import { ActionValue, DynamicValue, EditableImageValue, WebImage } from "mendix"; +import { Big } from "big.js"; + +export type CropShapeEnum = "rect" | "circle"; + +export type AspectRatioEnum = "free" | "square" | "landscape16x9" | "landscape4x3" | "portrait3x4" | "custom"; + +export type WheelZoomModeEnum = "off" | "on" | "onWithCtrl"; + +export type OutputFormatEnum = "png" | "jpeg"; + +export type OutputSizeEnum = "viewport" | "original"; + +export interface ImageCropContainerProps { + name: string; + class: string; + style?: CSSProperties; + tabIndex?: number; + image: EditableImageValue; + cropShape: CropShapeEnum; + aspectRatio: AspectRatioEnum; + customAspectWidth?: number; + customAspectHeight?: number; + boundaryWidth: number; + boundaryHeight: number; + resizableEnabled: boolean; + zoomEnabled: boolean; + wheelZoomMode: WheelZoomModeEnum; + minZoom: Big; + maxZoom: Big; + showPreview: boolean; + previewWidth?: number; + previewHeight?: number; + outputFormat: OutputFormatEnum; + outputQuality?: Big; + outputSize: OutputSizeEnum; + cropButtonCaption?: DynamicValue; + onCropAction?: ActionValue; +} + +export interface ImageCropPreviewProps { + /** + * @deprecated Deprecated since version 9.18.0. Please use class property instead. + */ + className: string; + class: string; + style: string; + styleObject?: CSSProperties; + readOnly: boolean; + renderMode: "design" | "xray" | "structure"; + translate: (text: string) => string; + image: { type: "static"; imageUrl: string; } | { type: "dynamic"; entity: string; } | null; + cropShape: CropShapeEnum; + aspectRatio: AspectRatioEnum; + customAspectWidth: number | null; + customAspectHeight: number | null; + boundaryWidth: number | null; + boundaryHeight: number | null; + resizableEnabled: boolean; + zoomEnabled: boolean; + wheelZoomMode: WheelZoomModeEnum; + minZoom: number | null; + maxZoom: number | null; + showPreview: boolean; + previewWidth: number | null; + previewHeight: number | null; + outputFormat: OutputFormatEnum; + outputQuality: number | null; + outputSize: OutputSizeEnum; + cropButtonCaption: string; + onCropAction: {} | null; +} From 3eabd2fe345c3a307153fe686a4febd9b1268bc0 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 09:57:04 +0200 Subject: [PATCH 06/42] chore(image-crop-web): remove legacy .eslintrc.js, flat config wins --- packages/pluggableWidgets/image-crop-web/.eslintrc.js | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 packages/pluggableWidgets/image-crop-web/.eslintrc.js diff --git a/packages/pluggableWidgets/image-crop-web/.eslintrc.js b/packages/pluggableWidgets/image-crop-web/.eslintrc.js deleted file mode 100644 index 5dbc3056b7..0000000000 --- a/packages/pluggableWidgets/image-crop-web/.eslintrc.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - extends: "@mendix/eslint-config-web-widgets/widget-ts" -}; From 7f623120a893b3a6973846462c5db721c2894f41 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 14:11:30 +0200 Subject: [PATCH 07/42] feat(image-crop-web): add aspect ratio resolver --- .../src/utils/__tests__/aspectRatio.spec.ts | 39 +++++++++++++++++++ .../image-crop-web/src/utils/aspectRatio.ts | 25 ++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/utils/__tests__/aspectRatio.spec.ts create mode 100644 packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/aspectRatio.spec.ts b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/aspectRatio.spec.ts new file mode 100644 index 0000000000..71f5966172 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/aspectRatio.spec.ts @@ -0,0 +1,39 @@ +import { resolveAspectRatio } from "../aspectRatio"; + +describe("resolveAspectRatio", () => { + test("returns undefined for 'free'", () => { + expect(resolveAspectRatio("free", 0, 0)).toBeUndefined(); + }); + + test("returns 1 for 'square'", () => { + expect(resolveAspectRatio("square", 0, 0)).toBe(1); + }); + + test("returns 16/9 for 'landscape16x9'", () => { + expect(resolveAspectRatio("landscape16x9", 0, 0)).toBeCloseTo(16 / 9); + }); + + test("returns 4/3 for 'landscape4x3'", () => { + expect(resolveAspectRatio("landscape4x3", 0, 0)).toBeCloseTo(4 / 3); + }); + + test("returns 3/4 for 'portrait3x4'", () => { + expect(resolveAspectRatio("portrait3x4", 0, 0)).toBeCloseTo(3 / 4); + }); + + test("returns custom width/height when both positive", () => { + expect(resolveAspectRatio("custom", 21, 9)).toBeCloseTo(21 / 9); + }); + + test("returns undefined when custom width is zero", () => { + expect(resolveAspectRatio("custom", 0, 9)).toBeUndefined(); + }); + + test("returns undefined when custom height is zero", () => { + expect(resolveAspectRatio("custom", 16, 0)).toBeUndefined(); + }); + + test("returns undefined when custom width is negative", () => { + expect(resolveAspectRatio("custom", -1, 9)).toBeUndefined(); + }); +}); diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts b/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts new file mode 100644 index 0000000000..fc241ac5f3 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts @@ -0,0 +1,25 @@ +import { AspectRatioEnum } from "../../typings/ImageCropProps"; + +export function resolveAspectRatio( + aspect: AspectRatioEnum, + customWidth: number, + customHeight: number +): number | undefined { + switch (aspect) { + case "free": + return undefined; + case "square": + return 1; + case "landscape16x9": + return 16 / 9; + case "landscape4x3": + return 4 / 3; + case "portrait3x4": + return 3 / 4; + case "custom": + if (customWidth > 0 && customHeight > 0) { + return customWidth / customHeight; + } + return undefined; + } +} From b00a414ab9081df9ec44fa982823f1cc87ac6329 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 15:52:07 +0200 Subject: [PATCH 08/42] feat(image-crop-web): add cropImage canvas extraction utility Co-Authored-By: Claude Sonnet 4.6 --- .../image-crop-web/jest.config.js | 6 + .../image-crop-web/jest.setup.ts | 61 +++++++ .../image-crop-web/package.json | 2 +- .../src/utils/__tests__/cropImage.spec.ts | 149 ++++++++++++++++++ .../image-crop-web/src/utils/cropImage.ts | 99 ++++++++++++ 5 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 packages/pluggableWidgets/image-crop-web/jest.config.js create mode 100644 packages/pluggableWidgets/image-crop-web/jest.setup.ts create mode 100644 packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts create mode 100644 packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts diff --git a/packages/pluggableWidgets/image-crop-web/jest.config.js b/packages/pluggableWidgets/image-crop-web/jest.config.js new file mode 100644 index 0000000000..8ee98da701 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/jest.config.js @@ -0,0 +1,6 @@ +const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"); + +module.exports = { + ...base, + setupFilesAfterEnv: [...(base.setupFilesAfterEnv ?? []), require("path").join(__dirname, "jest.setup.ts")] +}; diff --git a/packages/pluggableWidgets/image-crop-web/jest.setup.ts b/packages/pluggableWidgets/image-crop-web/jest.setup.ts new file mode 100644 index 0000000000..3b61a5e739 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/jest.setup.ts @@ -0,0 +1,61 @@ +/** + * Jest setup for image-crop-web tests. + * + * Problem: when `canvas` npm package is installed, jsdom uses node-canvas. Its `drawImage` + * rejects jsdom HTMLImageElement objects. Also, the test's `captureDrawImageCalls` helper spies on + * `CanvasRenderingContext2D.prototype.drawImage` — which must be the mock class prototype for the + * spy to fire. + * + * Fix: + * 1. Replace `global.CanvasRenderingContext2D` with the jest-canvas-mock class. + * 2. Override `HTMLCanvasElement.prototype.getContext` to return a MockCRC2D instance. + * This makes the context returned by our code an instance of MockCRC2D, so the spec's spy + * on `CanvasRenderingContext2D.prototype.drawImage` (which equals MockCRC2D.prototype.drawImage) + * fires correctly. + * 3. Override `HTMLCanvasElement.prototype.toBlob` to return a valid Blob synchronously + * (avoiding node-canvas toBuffer issues in tests). + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const MockCRC2D = require("jest-canvas-mock/lib/classes/CanvasRenderingContext2D").default; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const MockImageBitmap = require("jest-canvas-mock/lib/classes/ImageBitmap").default; + +// Make global.CanvasRenderingContext2D the mock class so spec spies on the right prototype +(global as any).CanvasRenderingContext2D = MockCRC2D; +// MockCRC2D's drawImage references ImageBitmap globally — provide a stub if jsdom doesn't have it +if (!(global as any).ImageBitmap) { + (global as any).ImageBitmap = MockImageBitmap; +} + +// Per-canvas context map for idempotency +const contextMap = new WeakMap>(); + +// Patch HTMLCanvasElement.prototype.getContext — jsdom exposes this as a regular JS method +const origGetContext = HTMLCanvasElement.prototype.getContext; +(HTMLCanvasElement.prototype as any).getContext = function ( + this: HTMLCanvasElement, + type: string, + ...rest: unknown[] +): unknown { + if (type === "2d") { + if (!contextMap.has(this)) { + contextMap.set(this, new MockCRC2D(this)); + } + return contextMap.get(this); + } + return (origGetContext as Function).apply(this, [type, ...rest]); +}; + +// Patch HTMLCanvasElement.prototype.toBlob to avoid node-canvas's toBuffer path +(HTMLCanvasElement.prototype as any).toBlob = function ( + this: HTMLCanvasElement, + callback: (blob: Blob | null) => void, + type?: string +): void { + const mime = type === "image/jpeg" || type === "image/webp" ? type : "image/png"; + const length = this.width * this.height * 4; + const data = new Uint8Array(length); + const blob = new Blob([data], { type: mime }); + setTimeout(() => callback(blob), 0); +}; diff --git a/packages/pluggableWidgets/image-crop-web/package.json b/packages/pluggableWidgets/image-crop-web/package.json index 8c2e10e29c..4c7d888abb 100644 --- a/packages/pluggableWidgets/image-crop-web/package.json +++ b/packages/pluggableWidgets/image-crop-web/package.json @@ -36,7 +36,7 @@ "publish-marketplace": "rui-publish-marketplace", "release": "pluggable-widgets-tools release:web", "start": "pluggable-widgets-tools start:server", - "test": "pluggable-widgets-tools test:unit:web", + "test": "jest --config jest.config.js", "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts new file mode 100644 index 0000000000..e881a4e0f7 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts @@ -0,0 +1,149 @@ +import { cropImage, CropError } from "../cropImage"; +import type { PixelCrop } from "react-image-crop"; + +function makeImg(naturalW: number, naturalH: number, renderedW = naturalW, renderedH = naturalH): HTMLImageElement { + const img = new Image(); + Object.defineProperty(img, "naturalWidth", { value: naturalW }); + Object.defineProperty(img, "naturalHeight", { value: naturalH }); + Object.defineProperty(img, "width", { value: renderedW }); + Object.defineProperty(img, "height", { value: renderedH }); + return img; +} + +const baseCrop: PixelCrop = { unit: "px", x: 10, y: 20, width: 100, height: 80 }; + +describe("cropImage", () => { + test("rejects when the image element has zero natural width", async () => { + const img = makeImg(0, 0); + await expect( + cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }) + ).rejects.toBeInstanceOf(CropError); + }); + + test("returns a File whose name has a .png extension when outputFormat is png", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }); + expect(file.name.endsWith(".png")).toBe(true); + expect(file.type).toBe("image/png"); + }); + + test("returns a File whose name has a .jpg extension when outputFormat is jpeg", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "jpeg", + outputQuality: 0.7, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }); + expect(file.name.endsWith(".jpg")).toBe(true); + expect(file.type).toBe("image/jpeg"); + }); + + test("uses viewport dims as canvas size when outputSize is viewport", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "viewport", + cropShape: "rect", + viewportWidth: 50, + viewportHeight: 40 + }); + const lastCanvas = (file as any)._lastCanvas as HTMLCanvasElement | undefined; + if (lastCanvas) { + expect(lastCanvas.width).toBe(50); + expect(lastCanvas.height).toBe(40); + } + }); + + test("divides source rect by zoom factor when zoom > 1", async () => { + const img = makeImg(1000, 800, 1000, 800); + const calls = await captureDrawImageCalls(() => + cropImage({ + image: img, + pixelCrop: { unit: "px", x: 100, y: 100, width: 200, height: 200 }, + zoom: 2, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }) + ); + const [, sx, sy, sw, sh] = calls[0]; + expect(sx).toBe(50); + expect(sy).toBe(50); + expect(sw).toBe(100); + expect(sh).toBe(100); + }); + + test("rejects with CropError when toBlob returns null (tainted canvas)", async () => { + const img = makeImg(1000, 800); + const originalToBlob = HTMLCanvasElement.prototype.toBlob; + HTMLCanvasElement.prototype.toBlob = function (cb: (b: Blob | null) => void) { + cb(null); + }; + try { + await expect( + cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }) + ).rejects.toBeInstanceOf(CropError); + } finally { + HTMLCanvasElement.prototype.toBlob = originalToBlob; + } + }); +}); + +async function captureDrawImageCalls(fn: () => Promise): Promise { + const calls: any[] = []; + const proto = CanvasRenderingContext2D.prototype as any; + const original = proto.drawImage; + proto.drawImage = function (...args: any[]) { + calls.push(args); + return original?.apply(this, args); + }; + try { + await fn(); + } finally { + proto.drawImage = original; + } + return calls; +} diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts b/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts new file mode 100644 index 0000000000..ec543bb7a1 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts @@ -0,0 +1,99 @@ +import type { PixelCrop } from "react-image-crop"; +import type { CropShapeEnum, OutputFormatEnum, OutputSizeEnum } from "../../typings/ImageCropProps"; + +export class CropError extends Error { + constructor(message: string) { + super(message); + this.name = "CropError"; + } +} + +export interface CropImageOptions { + image: HTMLImageElement; + pixelCrop: PixelCrop; + zoom: number; + outputFormat: OutputFormatEnum; + outputQuality: number; + outputSize: OutputSizeEnum; + cropShape: CropShapeEnum; + viewportWidth: number; + viewportHeight: number; +} + +export async function cropImage(options: CropImageOptions): Promise { + const { + image, + pixelCrop, + zoom, + outputFormat, + outputQuality, + outputSize, + cropShape, + viewportWidth, + viewportHeight + } = options; + + if (!image.naturalWidth || !image.naturalHeight) { + throw new CropError("Image not loaded."); + } + + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + const z = zoom > 0 ? zoom : 1; + + const sx = (pixelCrop.x / z) * scaleX; + const sy = (pixelCrop.y / z) * scaleY; + const sw = (pixelCrop.width / z) * scaleX; + const sh = (pixelCrop.height / z) * scaleY; + + const destW = outputSize === "viewport" ? viewportWidth : sw; + const destH = outputSize === "viewport" ? viewportHeight : sh; + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(destW)); + canvas.height = Math.max(1, Math.round(destH)); + + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new CropError("Canvas 2D context unavailable."); + } + + if (outputFormat === "jpeg") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + if (cropShape === "circle") { + ctx.save(); + ctx.beginPath(); + ctx.ellipse(canvas.width / 2, canvas.height / 2, canvas.width / 2, canvas.height / 2, 0, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + } + + ctx.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); + + if (cropShape === "circle") { + ctx.restore(); + } + + const mime = outputFormat === "jpeg" ? "image/jpeg" : "image/png"; + const ext = outputFormat === "jpeg" ? "jpg" : "png"; + const quality = outputFormat === "jpeg" ? Math.min(1, Math.max(0, outputQuality)) : undefined; + + const blob = await new Promise(resolve => { + try { + canvas.toBlob(resolve, mime, quality); + } catch (_e) { + resolve(null); + } + }); + + if (!blob) { + throw new CropError( + "Could not export the cropped image. The source may be tainted by cross-origin restrictions." + ); + } + + return new File([blob], `crop-${Date.now()}.${ext}`, { type: mime }); +} From d185f327bf54b8e641dccea76e67d54207fbc42e Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 16:09:56 +0200 Subject: [PATCH 09/42] chore(image-crop-web): align test script with sibling --projects convention --- packages/pluggableWidgets/image-crop-web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/image-crop-web/package.json b/packages/pluggableWidgets/image-crop-web/package.json index 4c7d888abb..456dac5ae7 100644 --- a/packages/pluggableWidgets/image-crop-web/package.json +++ b/packages/pluggableWidgets/image-crop-web/package.json @@ -36,7 +36,7 @@ "publish-marketplace": "rui-publish-marketplace", "release": "pluggable-widgets-tools release:web", "start": "pluggable-widgets-tools start:server", - "test": "jest --config jest.config.js", + "test": "jest --projects jest.config.js", "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, From 015e7b7c394167bb8e64651eccfe4724767be7bc Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 16:21:33 +0200 Subject: [PATCH 10/42] feat(image-crop-web): add useWheelZoom hook with mode gating Co-Authored-By: Claude Sonnet 4.6 --- .../src/hooks/__tests__/useWheelZoom.spec.ts | 76 +++++++++++++++++++ .../image-crop-web/src/hooks/useWheelZoom.ts | 33 ++++++++ 2 files changed, 109 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts create mode 100644 packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts new file mode 100644 index 0000000000..e14e0a41ec --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts @@ -0,0 +1,76 @@ +import { renderHook, act } from "@testing-library/react"; +import type { WheelEvent } from "react"; +import { useWheelZoom } from "../useWheelZoom"; + +function makeWheelEvent(deltaY: number, ctrlKey = false): WheelEvent { + return { + deltaY, + ctrlKey, + metaKey: false, + preventDefault: jest.fn(), + stopPropagation: jest.fn() + } as unknown as WheelEvent; +} + +describe("useWheelZoom", () => { + test("mode 'off' does nothing", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => useWheelZoom({ mode: "off", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + const e = makeWheelEvent(-100); + act(() => result.current(e)); + expect(setZoom).not.toHaveBeenCalled(); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + test("mode 'on' zooms in on negative deltaY", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + const e = makeWheelEvent(-100); + act(() => result.current(e)); + expect(setZoom).toHaveBeenCalledWith(1.1); + expect(e.preventDefault).toHaveBeenCalled(); + }); + + test("mode 'on' zooms out on positive deltaY", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 2, minZoom: 1, maxZoom: 4, setZoom })); + act(() => result.current(makeWheelEvent(100))); + expect(setZoom).toHaveBeenCalledWith(1.8); + }); + + test("mode 'onWithCtrl' ignores wheel without Ctrl/Meta", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => + useWheelZoom({ mode: "onWithCtrl", zoom: 1, minZoom: 1, maxZoom: 4, setZoom }) + ); + const e = makeWheelEvent(-100, false); + act(() => result.current(e)); + expect(setZoom).not.toHaveBeenCalled(); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + test("mode 'onWithCtrl' zooms when Ctrl is held", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => + useWheelZoom({ mode: "onWithCtrl", zoom: 1, minZoom: 1, maxZoom: 4, setZoom }) + ); + const e = makeWheelEvent(-100, true); + act(() => result.current(e)); + expect(setZoom).toHaveBeenCalledWith(1.1); + expect(e.preventDefault).toHaveBeenCalled(); + }); + + test("clamps to maxZoom", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 4, minZoom: 1, maxZoom: 4, setZoom })); + act(() => result.current(makeWheelEvent(-100))); + expect(setZoom).toHaveBeenCalledWith(4); + }); + + test("clamps to minZoom", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + act(() => result.current(makeWheelEvent(100))); + expect(setZoom).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts new file mode 100644 index 0000000000..f117d6e659 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts @@ -0,0 +1,33 @@ +import { WheelEvent, useCallback } from "react"; +import { WheelZoomModeEnum } from "../../typings/ImageCropProps"; + +interface UseWheelZoomArgs { + mode: WheelZoomModeEnum; + zoom: number; + minZoom: number; + maxZoom: number; + setZoom: (z: number) => void; +} + +const STEP = 0.1; + +export function useWheelZoom(args: UseWheelZoomArgs): (e: WheelEvent) => void { + const { mode, zoom, minZoom, maxZoom, setZoom } = args; + + return useCallback( + (e: WheelEvent) => { + if (mode === "off") { + return; + } + if (mode === "onWithCtrl" && !(e.ctrlKey || e.metaKey)) { + return; + } + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + const next = zoom * (1 + STEP * direction); + const clamped = Math.min(maxZoom, Math.max(minZoom, Number(next.toFixed(4)))); + setZoom(clamped); + }, + [mode, zoom, minZoom, maxZoom, setZoom] + ); +} From 13da656130fee619f919d3eef62053ee8e180745 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 16:23:14 +0200 Subject: [PATCH 11/42] feat(image-crop-web): add useImageCropState hook --- .../src/hooks/useImageCropState.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts new file mode 100644 index 0000000000..6725f85ebb --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts @@ -0,0 +1,20 @@ +import { Dispatch, RefObject, SetStateAction, useRef, useState } from "react"; +import type { Crop, PixelCrop } from "react-image-crop"; + +interface ImageCropState { + crop: Crop | undefined; + setCrop: Dispatch>; + completedCrop: PixelCrop | undefined; + setCompletedCrop: Dispatch>; + zoom: number; + setZoom: Dispatch>; + imageRef: RefObject; +} + +export function useImageCropState(initialZoom: number): ImageCropState { + const [crop, setCrop] = useState(undefined); + const [completedCrop, setCompletedCrop] = useState(undefined); + const [zoom, setZoom] = useState(initialZoom); + const imageRef = useRef(null); + return { crop, setCrop, completedCrop, setCompletedCrop, zoom, setZoom, imageRef }; +} From 98f3fcfbe8a46849c56935bb026176560d1376a6 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 09:39:20 +0200 Subject: [PATCH 12/42] feat(image-crop-web): add CropButton, ZoomSlider, PreviewPane leaves --- .../src/components/CropButton.tsx | 21 +++++++ .../src/components/PreviewPane.tsx | 59 +++++++++++++++++++ .../src/components/ZoomSlider.tsx | 24 ++++++++ 3 files changed, 104 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/components/CropButton.tsx create mode 100644 packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx create mode 100644 packages/pluggableWidgets/image-crop-web/src/components/ZoomSlider.tsx diff --git a/packages/pluggableWidgets/image-crop-web/src/components/CropButton.tsx b/packages/pluggableWidgets/image-crop-web/src/components/CropButton.tsx new file mode 100644 index 0000000000..54b135436c --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/CropButton.tsx @@ -0,0 +1,21 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; + +interface CropButtonProps { + caption: string; + disabled: boolean; + onClick: () => void; +} + +export function CropButton({ caption, disabled, onClick }: CropButtonProps): ReactElement { + return ( + + ); +} diff --git a/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx b/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx new file mode 100644 index 0000000000..6bbd45ac8c --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx @@ -0,0 +1,59 @@ +import { ReactElement, useEffect, useRef } from "react"; +import type { PixelCrop } from "react-image-crop"; + +interface PreviewPaneProps { + image: HTMLImageElement | null; + pixelCrop: PixelCrop | undefined; + zoom: number; + width: number; + height: number; + circle: boolean; +} + +export function PreviewPane({ image, pixelCrop, zoom, width, height, circle }: PreviewPaneProps): ReactElement { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !image || !pixelCrop || !image.naturalWidth) { + return; + } + + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + ctx.clearRect(0, 0, width, height); + if (pixelCrop.width === 0 || pixelCrop.height === 0) { + // Why: drawImage with a 0-sized source rect throws IndexSizeError in node-canvas / older Safari. + return; + } + if (circle) { + ctx.save(); + ctx.beginPath(); + ctx.ellipse(width / 2, height / 2, width / 2, height / 2, 0, 0, Math.PI * 2); + ctx.clip(); + } + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + const z = zoom > 0 ? zoom : 1; + ctx.drawImage( + image, + (pixelCrop.x / z) * scaleX, + (pixelCrop.y / z) * scaleY, + (pixelCrop.width / z) * scaleX, + (pixelCrop.height / z) * scaleY, + 0, + 0, + width, + height + ); + if (circle) { + ctx.restore(); + } + }, [image, pixelCrop, zoom, width, height, circle]); + + return ; +} diff --git a/packages/pluggableWidgets/image-crop-web/src/components/ZoomSlider.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ZoomSlider.tsx new file mode 100644 index 0000000000..b05196b7ef --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/ZoomSlider.tsx @@ -0,0 +1,24 @@ +import { ChangeEvent, ReactElement } from "react"; + +interface ZoomSliderProps { + zoom: number; + minZoom: number; + maxZoom: number; + onChange: (zoom: number) => void; +} + +export function ZoomSlider({ zoom, minZoom, maxZoom, onChange }: ZoomSliderProps): ReactElement { + return ( + + ); +} From ba94c90e4061e69db629b871fb99db84f0f3320e Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 09:39:44 +0200 Subject: [PATCH 13/42] feat(image-crop-web): add CropArea with ReactCrop + zoom wrapper --- .../src/components/CropArea.tsx | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx diff --git a/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx new file mode 100644 index 0000000000..b7a0e4c321 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx @@ -0,0 +1,74 @@ +import classNames from "classnames"; +import { ReactElement, RefObject, SyntheticEvent, useState } from "react"; +import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop"; +import { WheelZoomModeEnum } from "../../typings/ImageCropProps"; +import { useWheelZoom } from "../hooks/useWheelZoom"; + +interface CropAreaProps { + src: string; + crop: Crop | undefined; + onCropChange: (crop: Crop) => void; + onCropComplete: (pixelCrop: PixelCrop) => void; + aspect: number | undefined; + circular: boolean; + resizable: boolean; + boundaryWidth: number; + boundaryHeight: number; + zoom: number; + minZoom: number; + maxZoom: number; + setZoom: (z: number) => void; + wheelZoomMode: WheelZoomModeEnum; + imageRef: RefObject; +} + +export function CropArea(props: CropAreaProps): ReactElement { + const [loadError, setLoadError] = useState(false); + const onWheel = useWheelZoom({ + mode: props.wheelZoomMode, + zoom: props.zoom, + minZoom: props.minZoom, + maxZoom: props.maxZoom, + setZoom: props.setZoom + }); + + if (loadError) { + return ( +
+ Image source does not allow cropping. Upload locally or configure CORS. +
+ ); + } + + return ( +
+ props.onCropChange(percent)} + onComplete={pixel => props.onCropComplete(pixel)} + aspect={props.aspect} + circularCrop={props.circular} + disabled={!props.resizable} + locked={!props.resizable} + keepSelection + > + { + props.imageRef.current = img; + }} + src={props.src} + alt="" + crossOrigin="anonymous" + style={{ transform: `scale(${props.zoom})`, transformOrigin: "center" }} + onError={(_e: SyntheticEvent) => setLoadError(true)} + /> + +
+ ); +} From af22b686fb941f59a76492d74db4565c5ca4cac5 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 09:41:50 +0200 Subject: [PATCH 14/42] feat(image-crop-web): assemble container with state, area, preview, button --- .../image-crop-web/src/ImageCrop.tsx | 5 +- .../src/components/ImageCropContainer.tsx | 103 ++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx index 4352e42929..3820f76d83 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx @@ -1,6 +1,7 @@ import { ReactElement } from "react"; import { ImageCropContainerProps } from "../typings/ImageCropProps"; +import { ImageCropContainer } from "./components/ImageCropContainer"; -export function ImageCrop(_props: ImageCropContainerProps): ReactElement | null { - return null; +export function ImageCrop(props: ImageCropContainerProps): ReactElement | null { + return ; } diff --git a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx new file mode 100644 index 0000000000..f7574ed862 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx @@ -0,0 +1,103 @@ +import classNames from "classnames"; +import { ValueStatus } from "mendix"; +import { ReactElement, useCallback } from "react"; +import { CropArea } from "./CropArea"; +import { CropButton } from "./CropButton"; +import { PreviewPane } from "./PreviewPane"; +import { ZoomSlider } from "./ZoomSlider"; +import { ImageCropContainerProps } from "../../typings/ImageCropProps"; +import { useImageCropState } from "../hooks/useImageCropState"; +import { resolveAspectRatio } from "../utils/aspectRatio"; +import { cropImage, CropError } from "../utils/cropImage"; + +export function ImageCropContainer(props: ImageCropContainerProps): ReactElement | null { + const state = useImageCropState(Number(props.minZoom)); + + const handleCrop = useCallback(async () => { + const img = state.imageRef.current; + if (!img || !state.completedCrop || props.image.readOnly || props.image.status !== ValueStatus.Available) { + return; + } + try { + const file = await cropImage({ + image: img, + pixelCrop: state.completedCrop, + zoom: state.zoom, + outputFormat: props.outputFormat, + outputQuality: Number(props.outputQuality ?? 0.92), + outputSize: props.outputSize, + cropShape: props.cropShape, + viewportWidth: props.boundaryWidth, + viewportHeight: props.boundaryHeight + }); + if (props.outputSize === "viewport") { + props.image.setThumbnailSize(props.boundaryWidth, props.boundaryHeight); + } + props.image.setValue(file); + if (props.onCropAction?.canExecute) { + props.onCropAction.execute(); + } + } catch (err) { + if (err instanceof CropError) { + console.error("[image-crop-web]", err.message); + } else { + throw err; + } + } + }, [state, props]); + + if (props.image.status === ValueStatus.Loading) { + return
; + } + if (props.image.status !== ValueStatus.Available || !props.image.value) { + return
No image
; + } + + const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth ?? 0, props.customAspectHeight ?? 0); + const caption = props.cropButtonCaption?.value ?? "Crop"; + + return ( +
+ + {props.zoomEnabled ? ( + + ) : null} + {props.showPreview ? ( + + ) : null} + +
+ ); +} From f28c44ae1bb3b528d05f2992f3a3880764471108 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 09:45:10 +0200 Subject: [PATCH 15/42] feat(image-crop-web): bundle ReactCrop CSS via SCSS import --- .../image-crop-web/src/ImageCrop.tsx | 1 + .../image-crop-web/src/ui/ImageCrop.scss | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx index 3820f76d83..1166d68880 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx @@ -1,6 +1,7 @@ import { ReactElement } from "react"; import { ImageCropContainerProps } from "../typings/ImageCropProps"; import { ImageCropContainer } from "./components/ImageCropContainer"; +import "./ui/ImageCrop.scss"; export function ImageCrop(props: ImageCropContainerProps): ReactElement | null { return ; diff --git a/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss b/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss new file mode 100644 index 0000000000..40fe5d6e8f --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss @@ -0,0 +1,52 @@ +@import "react-image-crop/dist/ReactCrop.css"; + +.widget-image-crop { + display: inline-flex; + flex-direction: column; + gap: 8px; + + &__canvas { + position: relative; + overflow: hidden; + background: #f5f5f5; + + img { + display: block; + max-width: 100%; + transition: transform 80ms linear; + } + + &--circle .ReactCrop__crop-selection { + border-radius: 50%; + } + } + + &__zoom { + display: flex; + align-items: center; + gap: 8px; + + input[type="range"] { + flex: 1; + } + } + + &__preview { + border: 1px solid #ddd; + background: #fff; + } + + &__button { + align-self: flex-start; + } + + &__error, + &--empty { + padding: 8px; + color: #b00; + } + + &--loading { + min-height: 200px; + } +} From 48eb336a1489104de5ac127b15179be9ba6eb949 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 09:51:06 +0200 Subject: [PATCH 16/42] test(image-crop-web): add container RTL tests for state guards --- .../src/__tests__/ImageCrop.spec.tsx | 76 +++++++++++++++++++ .../image-crop-web/tsconfig.json | 2 +- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx diff --git a/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx new file mode 100644 index 0000000000..60bb475ce1 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx @@ -0,0 +1,76 @@ +import { ValueStatus } from "mendix"; +import { render, screen } from "@testing-library/react"; +import { ImageCrop } from "../ImageCrop"; +import type { ImageCropContainerProps } from "../../typings/ImageCropProps"; + +function makeImageProp(overrides: Partial = {}): any { + return { + status: ValueStatus.Available, + value: { uri: "http://localhost/img.png" }, + readOnly: false, + validation: undefined, + setValidator: jest.fn(), + setValue: jest.fn(), + setThumbnailSize: jest.fn(), + ...overrides + }; +} + +function makeProps(overrides: Partial = {}): ImageCropContainerProps { + const base: any = { + name: "imageCrop", + class: "", + style: undefined, + tabIndex: 0, + image: makeImageProp(), + cropShape: "rect", + aspectRatio: "free", + customAspectWidth: 1, + customAspectHeight: 1, + boundaryWidth: 300, + boundaryHeight: 300, + resizableEnabled: true, + zoomEnabled: true, + wheelZoomMode: "onWithCtrl", + minZoom: 1, + maxZoom: 4, + showPreview: false, + previewWidth: 100, + previewHeight: 100, + outputFormat: "png", + outputQuality: 0.92, + outputSize: "original", + cropButtonCaption: { value: "Crop", status: ValueStatus.Available }, + onCropAction: { canExecute: true, execute: jest.fn(), isExecuting: false } + }; + return { ...base, ...overrides } as ImageCropContainerProps; +} + +describe("", () => { + test("renders skeleton while image is loading", () => { + const props = makeProps({ image: makeImageProp({ status: ValueStatus.Loading, value: undefined }) }); + const { container } = render(); + expect(container.querySelector(".widget-image-crop--loading")).not.toBeNull(); + }); + + test("renders empty state when image has no value", () => { + const props = makeProps({ image: makeImageProp({ value: undefined }) }); + render(); + expect(screen.getByText("No image")).toBeInTheDocument(); + }); + + test("disables Crop button when image is read-only", () => { + const props = makeProps({ image: makeImageProp({ readOnly: true }) }); + render(); + const btn = screen.getByRole("button", { name: "Crop" }); + expect(btn).toBeDisabled(); + }); + + test("Crop button is disabled until a completedCrop exists", () => { + const props = makeProps(); + const { container } = render(); + const btn = container.querySelector("button.widget-image-crop__button"); + expect(btn).not.toBeNull(); + expect(btn).toBeDisabled(); + }); +}); diff --git a/packages/pluggableWidgets/image-crop-web/tsconfig.json b/packages/pluggableWidgets/image-crop-web/tsconfig.json index 63d18d877f..3296cb98f5 100644 --- a/packages/pluggableWidgets/image-crop-web/tsconfig.json +++ b/packages/pluggableWidgets/image-crop-web/tsconfig.json @@ -7,7 +7,7 @@ "module": "esnext", "target": "es6", "lib": ["esnext", "dom"], - "types": ["jest", "node"], + "types": ["jest", "node", "testing-library__jest-dom"], "moduleResolution": "node", "declaration": false, "noLib": false, From 31f6be9c9e9c03f12c92966247b8ad9acbc8c1a6 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 15:14:33 +0200 Subject: [PATCH 17/42] fix(image-crop-web): make primitive props required per Mendix XSD --- .../pluggableWidgets/image-crop-web/src/ImageCrop.xml | 10 +++++----- .../src/components/ImageCropContainer.tsx | 8 ++++---- .../image-crop-web/typings/ImageCropProps.d.ts | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml index 91dc440a83..a3e490c1b4 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml @@ -32,11 +32,11 @@ Custom - + Custom aspect width - + Custom aspect height @@ -81,11 +81,11 @@ Show preview - + Preview width (px) - + Preview height (px) @@ -99,7 +99,7 @@ JPEG - + JPEG quality (0.0 - 1.0) diff --git a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx index f7574ed862..fb15af797f 100644 --- a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx @@ -24,7 +24,7 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement pixelCrop: state.completedCrop, zoom: state.zoom, outputFormat: props.outputFormat, - outputQuality: Number(props.outputQuality ?? 0.92), + outputQuality: Number(props.outputQuality), outputSize: props.outputSize, cropShape: props.cropShape, viewportWidth: props.boundaryWidth, @@ -53,7 +53,7 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement return
No image
; } - const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth ?? 0, props.customAspectHeight ?? 0); + const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth, props.customAspectHeight); const caption = props.cropButtonCaption?.value ?? "Crop"; return ( @@ -88,8 +88,8 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement image={state.imageRef.current} pixelCrop={state.completedCrop} zoom={state.zoom} - width={props.previewWidth ?? 100} - height={props.previewHeight ?? 100} + width={props.previewWidth} + height={props.previewHeight} circle={props.cropShape === "circle"} /> ) : null} diff --git a/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts b/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts index 723099d423..9e946fb1fe 100644 --- a/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts +++ b/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts @@ -25,8 +25,8 @@ export interface ImageCropContainerProps { image: EditableImageValue; cropShape: CropShapeEnum; aspectRatio: AspectRatioEnum; - customAspectWidth?: number; - customAspectHeight?: number; + customAspectWidth: number; + customAspectHeight: number; boundaryWidth: number; boundaryHeight: number; resizableEnabled: boolean; @@ -35,10 +35,10 @@ export interface ImageCropContainerProps { minZoom: Big; maxZoom: Big; showPreview: boolean; - previewWidth?: number; - previewHeight?: number; + previewWidth: number; + previewHeight: number; outputFormat: OutputFormatEnum; - outputQuality?: Big; + outputQuality: Big; outputSize: OutputSizeEnum; cropButtonCaption?: DynamicValue; onCropAction?: ActionValue; From 0dd24364e5cc4f6113c83f9823efb1e412bea839 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 16:56:02 +0200 Subject: [PATCH 18/42] docs(image-crop-web): add property descriptions for Studio Pro tooltips --- .../image-crop-web/src/ImageCrop.xml | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml index a3e490c1b4..3d348ae921 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml @@ -14,7 +14,7 @@ Crop shape - + Geometry of the crop selection. Rectangle keeps straight edges; Circle masks the result with a round cutout (transparent corners on PNG, white corners on JPEG). Rectangle Circle @@ -22,7 +22,7 @@ Aspect ratio - + Locks the proportions of the crop selection while the user drags. Choose Free to let the user resize without constraint, a preset, or Custom to define your own ratio below. Free 1:1 @@ -34,33 +34,33 @@ Custom aspect width - + Numerator of the custom ratio (e.g. 3 in a 3:2 crop). Only used when Aspect ratio is set to Custom. Custom aspect height - + Denominator of the custom ratio (e.g. 2 in a 3:2 crop). Only used when Aspect ratio is set to Custom. Canvas width (px) - + On-screen width of the crop area in the page. The image is fit inside this box; it does not affect the resolution of the saved crop. Canvas height (px) - + On-screen height of the crop area in the page. The image is fit inside this box; it does not affect the resolution of the saved crop. Resizable handles - Allow the user to resize the crop area by dragging its corners. + If on, the user can drag the corners of the selection to resize it. If off, the selection stays at its initial size and can only be moved. Enable zoom - + If on, a zoom slider is shown below the crop area so the user can scale the image up or down. If off, the image is always shown at 1× (fit-to-canvas). Mouse wheel zoom - + Controls whether scrolling the mouse wheel over the image changes the zoom. "On (hold Ctrl)" is recommended so normal page scrolling still works. Off On @@ -69,31 +69,31 @@ Minimum zoom - + Smallest zoom factor the user can reach. 1 means "fit-to-canvas" (no shrinking below the visible area). Values below 1 let the user zoom out so the image is smaller than the canvas; values above 1 force the image to start zoomed-in. Maximum zoom - + Largest zoom factor the user can reach. For example, 4 means the image can be magnified up to 4× its fit-to-canvas size, which is useful for cropping fine detail. Must be greater than Minimum zoom. Show preview - + If on, a small live thumbnail of the current crop selection is shown next to the canvas. Useful to confirm the framing before pressing the Crop button. Preview width (px) - + On-screen width of the live preview thumbnail. Has no effect on the saved image. Preview height (px) - + On-screen height of the live preview thumbnail. Has no effect on the saved image. Output format - + File format of the saved crop. PNG preserves transparency (best for circle crops) but produces larger files; JPEG is smaller but has no transparency and applies lossy compression. PNG JPEG @@ -101,11 +101,11 @@ JPEG quality (0.0 - 1.0) - + Compression level for JPEG output. 1.0 keeps the most detail (largest file); 0.5 is roughly average web quality. Ignored when Output format is PNG. Output size - + Resolution of the saved crop. "Original" preserves the pixels from the source image (sharpest, larger file). "Viewport" downscales the crop to the on-screen canvas size — useful for avatars and thumbnails. Viewport (canvas dims) Original (source resolution) @@ -115,7 +115,7 @@ Crop button caption - + Label shown on the button that commits the crop. Defaults to "Crop"; override for translation or custom wording (e.g. "Save avatar"). Crop From bcd4ea9400130d1113086b2de6f73e3c25fa3945 Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 27 May 2026 15:50:07 +0200 Subject: [PATCH 19/42] feat(image-crop-web): add Studio Pro structure preview and pre-PR polish Adds getPreview structure tile (icon + caption with shape/aspect/output), fit-and-scale canvas sizing in CropArea (no more gaps after horizontal crop), DPR-aware PreviewPane buffer, exhaustiveness guard in aspectRatio, and required="true" on three XML booleans. SVG asset replaces inline data URL. Co-Authored-By: Claude Sonnet 4.6 --- .../src/ImageCrop.editorConfig.ts | 92 +++++++++++++++ .../src/ImageCrop.editorPreview.tsx | 14 ++- .../image-crop-web/src/ImageCrop.xml | 54 ++++----- .../src/__tests__/ImageCrop.spec.tsx | 50 ++++++++- .../image-crop-web/src/assets/crop-icon.svg | 8 ++ .../src/components/CropArea.tsx | 106 ++++++++++++++++-- .../src/components/ImageCropContainer.tsx | 38 ++++++- .../src/components/PreviewPane.tsx | 8 +- .../src/hooks/__tests__/useWheelZoom.spec.ts | 73 ++++++------ .../image-crop-web/src/hooks/useWheelZoom.ts | 20 ++-- .../image-crop-web/src/ui/ImageCrop.scss | 40 ++++++- .../src/utils/__tests__/cropImage.spec.ts | 62 ++++++---- .../image-crop-web/src/utils/aspectRatio.ts | 4 + .../image-crop-web/src/utils/cropImage.ts | 7 +- 14 files changed, 458 insertions(+), 118 deletions(-) create mode 100644 packages/pluggableWidgets/image-crop-web/src/assets/crop-icon.svg diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts index ae53416874..16c4a6ad61 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts @@ -1,5 +1,10 @@ import { hidePropertiesIn, Properties } from "@mendix/pluggable-widgets-tools"; +import { + StructurePreviewProps, + structurePreviewPalette +} from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { ImageCropPreviewProps } from "../typings/ImageCropProps"; +import CropIconSvg from "./assets/crop-icon.svg"; export function getProperties(values: ImageCropPreviewProps, defaultProperties: Properties): Properties { const propsToHide: Array = []; @@ -23,3 +28,90 @@ export function getProperties(values: ImageCropPreviewProps, defaultProperties: hidePropertiesIn(defaultProperties, values, propsToHide); return defaultProperties; } + +export function getPreview(values: ImageCropPreviewProps, isDarkMode: boolean): StructurePreviewProps { + const palette = structurePreviewPalette[isDarkMode ? "dark" : "light"]; + const iconDocument = decodeURIComponent(CropIconSvg.replace("data:image/svg+xml,", "")); + + return { + type: "Container", + borders: true, + borderRadius: 4, + backgroundColor: palette.background.containerFill, + children: [ + { + type: "RowLayout", + columnSize: "grow", + padding: 12, + children: [ + { + type: "Container", + grow: 0, + padding: 4, + children: [ + { + type: "Image", + document: iconDocument, + width: 28, + height: 22 + } + ] + }, + { + type: "Container", + grow: 1, + children: [ + { + type: "Text", + content: "Image Crop", + bold: true, + fontColor: palette.text.primary, + fontSize: 10 + }, + { + type: "Text", + content: describeConfig(values), + fontColor: palette.text.secondary, + fontSize: 8 + } + ] + } + ] + } + ] + }; +} + +export function getCustomCaption(values: ImageCropPreviewProps): string { + const shape = values.cropShape === "circle" ? "Circle" : "Rectangle"; + return `Image Crop (${shape})`; +} + +function describeConfig(values: ImageCropPreviewProps): string { + const parts: string[] = []; + parts.push(values.cropShape === "circle" ? "Circle" : "Rectangle"); + parts.push(aspectLabel(values)); + parts.push(`${values.outputFormat.toUpperCase()} · ${values.outputSize === "viewport" ? "Viewport" : "Original"}`); + return parts.join(" · "); +} + +function aspectLabel(values: ImageCropPreviewProps): string { + switch (values.aspectRatio) { + case "free": + return "Free aspect"; + case "square": + return "1:1"; + case "landscape16x9": + return "16:9"; + case "landscape4x3": + return "4:3"; + case "portrait3x4": + return "3:4"; + case "custom": + return `${values.customAspectWidth}:${values.customAspectHeight}`; + default: { + const _exhaustive: never = values.aspectRatio; + return _exhaustive; + } + } +} diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx index fd831d3ac4..636598f663 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx @@ -1,10 +1,18 @@ +import classNames from "classnames"; import { ReactElement } from "react"; import { ImageCropPreviewProps } from "../typings/ImageCropProps"; -export function preview(_props: ImageCropPreviewProps): ReactElement { - return
Image Crop
; +export function preview(props: ImageCropPreviewProps): ReactElement { + return ( +
+
+
+

Image Crop

+
+
+ ); } export function getPreviewCss(): string { - return ""; + return require("./ui/ImageCrop.scss"); } diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml index 3d348ae921..8a47edf692 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml @@ -8,13 +8,13 @@ Image attribute - Editable image attribute. The cropped image overwrites this attribute. + The image to crop. The cropped result is saved back to it. Crop shape - Geometry of the crop selection. Rectangle keeps straight edges; Circle masks the result with a round cutout (transparent corners on PNG, white corners on JPEG). + Shape of the crop. Circle masks the corners. Rectangle Circle @@ -22,7 +22,7 @@ Aspect ratio - Locks the proportions of the crop selection while the user drags. Choose Free to let the user resize without constraint, a preset, or Custom to define your own ratio below. + Locks the crop proportions. Free lets the user resize freely. Free 1:1 @@ -34,33 +34,33 @@ Custom aspect width - Numerator of the custom ratio (e.g. 3 in a 3:2 crop). Only used when Aspect ratio is set to Custom. + Width side of the ratio (e.g. 3 in 3:2). Used when Aspect ratio is Custom. Custom aspect height - Denominator of the custom ratio (e.g. 2 in a 3:2 crop). Only used when Aspect ratio is set to Custom. + Height side of the ratio (e.g. 2 in 3:2). Used when Aspect ratio is Custom. - - Canvas width (px) - On-screen width of the crop area in the page. The image is fit inside this box; it does not affect the resolution of the saved crop. + + Canvas max width (px) + Maximum on-screen width of the crop area. The image scales down to fit; the canvas wraps the rendered image, so smaller crops produce a smaller canvas with no blank gaps. Does not change the saved image size. - - Canvas height (px) - On-screen height of the crop area in the page. The image is fit inside this box; it does not affect the resolution of the saved crop. + + Canvas max height (px) + Maximum on-screen height of the crop area. The image scales down to fit; the canvas wraps the rendered image, so smaller crops produce a smaller canvas with no blank gaps. Does not change the saved image size. - + Resizable handles - If on, the user can drag the corners of the selection to resize it. If off, the selection stays at its initial size and can only be moved. + Let the user resize the selection by dragging its corners. - + Enable zoom - If on, a zoom slider is shown below the crop area so the user can scale the image up or down. If off, the image is always shown at 1× (fit-to-canvas). + Show a zoom slider below the crop area. Mouse wheel zoom - Controls whether scrolling the mouse wheel over the image changes the zoom. "On (hold Ctrl)" is recommended so normal page scrolling still works. + Whether the mouse wheel zooms the image. "On (hold Ctrl)" keeps page scroll working. Off On @@ -69,31 +69,31 @@ Minimum zoom - Smallest zoom factor the user can reach. 1 means "fit-to-canvas" (no shrinking below the visible area). Values below 1 let the user zoom out so the image is smaller than the canvas; values above 1 force the image to start zoomed-in. + Smallest zoom level. 1 = image fits the canvas. Below 1 lets the user zoom out further. Maximum zoom - Largest zoom factor the user can reach. For example, 4 means the image can be magnified up to 4× its fit-to-canvas size, which is useful for cropping fine detail. Must be greater than Minimum zoom. + Largest zoom level. 4 means up to 4× the canvas size. Must be greater than Minimum zoom. - + Show preview - If on, a small live thumbnail of the current crop selection is shown next to the canvas. Useful to confirm the framing before pressing the Crop button. + Show a live thumbnail of the current crop next to the canvas. Preview width (px) - On-screen width of the live preview thumbnail. Has no effect on the saved image. + Width of the preview thumbnail. Preview height (px) - On-screen height of the live preview thumbnail. Has no effect on the saved image. + Height of the preview thumbnail. Output format - File format of the saved crop. PNG preserves transparency (best for circle crops) but produces larger files; JPEG is smaller but has no transparency and applies lossy compression. + File format. PNG keeps transparency; JPEG produces smaller files. PNG JPEG @@ -101,11 +101,11 @@ JPEG quality (0.0 - 1.0) - Compression level for JPEG output. 1.0 keeps the most detail (largest file); 0.5 is roughly average web quality. Ignored when Output format is PNG. + JPEG compression. Higher = sharper and larger. Ignored for PNG. Output size - Resolution of the saved crop. "Original" preserves the pixels from the source image (sharpest, larger file). "Viewport" downscales the crop to the on-screen canvas size — useful for avatars and thumbnails. + Resolution of the saved crop. Original is sharpest; Viewport matches the on-screen canvas size. Viewport (canvas dims) Original (source resolution) @@ -115,14 +115,14 @@ Crop button caption - Label shown on the button that commits the crop. Defaults to "Crop"; override for translation or custom wording (e.g. "Save avatar"). + Label on the Crop button. Crop On crop - Action executed after the cropped image is committed. + Runs after the cropped image is saved. diff --git a/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx index 60bb475ce1..5124639092 100644 --- a/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx @@ -1,7 +1,7 @@ +import { fireEvent, render, screen } from "@testing-library/react"; import { ValueStatus } from "mendix"; -import { render, screen } from "@testing-library/react"; -import { ImageCrop } from "../ImageCrop"; import type { ImageCropContainerProps } from "../../typings/ImageCropProps"; +import { ImageCrop } from "../ImageCrop"; function makeImageProp(overrides: Partial = {}): any { return { @@ -66,11 +66,53 @@ describe("", () => { expect(btn).toBeDisabled(); }); - test("Crop button is disabled until a completedCrop exists", () => { + test("Crop button is enabled after image loads (initial crop auto-set)", () => { const props = makeProps(); const { container } = render(); + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + fireEvent.load(img!); const btn = container.querySelector("button.widget-image-crop__button"); expect(btn).not.toBeNull(); - expect(btn).toBeDisabled(); + expect(btn).not.toBeDisabled(); + }); + + test("before load, image is bounded by boundary as max-width/max-height ceiling", () => { + const props = makeProps({ boundaryWidth: 800, boundaryHeight: 600 }); + const { container } = render(); + const img = container.querySelector("img") as HTMLImageElement | null; + expect(img).not.toBeNull(); + expect(img!.style.maxWidth).toBe("800px"); + expect(img!.style.maxHeight).toBe("600px"); + }); + + test("after load, image gets fit-and-scaled pixel dims; canvas wraps via inline-block + ceiling", () => { + const props = makeProps({ boundaryWidth: 800, boundaryHeight: 600 }); + const { container } = render(); + const img = container.querySelector("img") as HTMLImageElement; + Object.defineProperty(img, "naturalWidth", { value: 400, configurable: true }); + Object.defineProperty(img, "naturalHeight", { value: 300, configurable: true }); + fireEvent.load(img); + const canvas = container.querySelector(".widget-image-crop__canvas") as HTMLDivElement; + expect(img.style.width).toBe("800px"); + expect(img.style.height).toBe("600px"); + expect(canvas.style.maxWidth).toBe("800px"); + expect(canvas.style.maxHeight).toBe("600px"); + }); + + test("crop is cleared between image src change and next load (button disabled)", () => { + const props = makeProps({ image: makeImageProp({ value: { uri: "http://localhost/img1.png" } }) }); + const { container, rerender } = render(); + const img1 = container.querySelector("img"); + fireEvent.load(img1!); + expect(container.querySelector("button.widget-image-crop__button")).not.toBeDisabled(); + + const newProps = makeProps({ image: makeImageProp({ value: { uri: "http://localhost/img2.png" } }) }); + rerender(); + expect(container.querySelector("button.widget-image-crop__button")).toBeDisabled(); + + const img2 = container.querySelector("img"); + fireEvent.load(img2!); + expect(container.querySelector("button.widget-image-crop__button")).not.toBeDisabled(); }); }); diff --git a/packages/pluggableWidgets/image-crop-web/src/assets/crop-icon.svg b/packages/pluggableWidgets/image-crop-web/src/assets/crop-icon.svg new file mode 100644 index 0000000000..534cf020b2 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/assets/crop-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx index b7a0e4c321..8b6843193d 100644 --- a/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx @@ -1,6 +1,23 @@ import classNames from "classnames"; -import { ReactElement, RefObject, SyntheticEvent, useState } from "react"; -import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop"; +import { + ReactElement, + RefObject, + SyntheticEvent, + useCallback, + useEffect, + useRef, + useState, + Dispatch, + SetStateAction +} from "react"; +import { + default as ReactCrop, + centerCrop, + convertToPixelCrop, + makeAspectCrop, + type Crop, + type PixelCrop +} from "react-image-crop"; import { WheelZoomModeEnum } from "../../typings/ImageCropProps"; import { useWheelZoom } from "../hooks/useWheelZoom"; @@ -14,24 +31,87 @@ interface CropAreaProps { resizable: boolean; boundaryWidth: number; boundaryHeight: number; + onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; zoom: number; minZoom: number; maxZoom: number; - setZoom: (z: number) => void; + setZoom: Dispatch>; wheelZoomMode: WheelZoomModeEnum; imageRef: RefObject; } +function buildInitialCrop( + img: HTMLImageElement, + aspect: number | undefined +): { percentCrop: Crop; pixelCrop: PixelCrop } { + const { naturalWidth, naturalHeight, width, height } = img; + const safeAspect = aspect ?? naturalWidth / naturalHeight; + const percentCrop = centerCrop( + makeAspectCrop({ unit: "%", width: 80 }, safeAspect, naturalWidth, naturalHeight), + naturalWidth, + naturalHeight + ); + return { percentCrop, pixelCrop: convertToPixelCrop(percentCrop, width, height) }; +} + +function fitToBoundary( + naturalWidth: number, + naturalHeight: number, + boundaryWidth: number, + boundaryHeight: number +): { width: number; height: number } { + if (naturalWidth <= 0 || naturalHeight <= 0) { + return { width: boundaryWidth, height: boundaryHeight }; + } + const scale = Math.min(boundaryWidth / naturalWidth, boundaryHeight / naturalHeight); + return { width: Math.round(naturalWidth * scale), height: Math.round(naturalHeight * scale) }; +} + export function CropArea(props: CropAreaProps): ReactElement { const [loadError, setLoadError] = useState(false); + const [displaySize, setDisplaySize] = useState<{ width: number; height: number } | null>(null); + const containerRef = useRef(null); const onWheel = useWheelZoom({ mode: props.wheelZoomMode, - zoom: props.zoom, minZoom: props.minZoom, maxZoom: props.maxZoom, setZoom: props.setZoom }); + useEffect(() => { + const el = containerRef.current; + if (!el) { + return; + } + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [onWheel]); + + const { aspect, onImageLoad, imageRef, boundaryWidth, boundaryHeight, src } = props; + + const [prevSrc, setPrevSrc] = useState(src); + if (prevSrc !== src) { + setPrevSrc(src); + setDisplaySize(null); + } + + const handleImageLoad = useCallback( + (e: SyntheticEvent) => { + const img = e.currentTarget; + setDisplaySize(fitToBoundary(img.naturalWidth, img.naturalHeight, boundaryWidth, boundaryHeight)); + const { percentCrop, pixelCrop } = buildInitialCrop(img, aspect); + onImageLoad(percentCrop, pixelCrop); + }, + [aspect, onImageLoad, boundaryWidth, boundaryHeight] + ); + + const setImageRef = useCallback( + (img: HTMLImageElement | null) => { + imageRef.current = img; + }, + [imageRef] + ); + if (loadError) { return (
@@ -42,11 +122,11 @@ export function CropArea(props: CropAreaProps): ReactElement { return (
{ - props.imageRef.current = img; - }} + ref={setImageRef} src={props.src} alt="" crossOrigin="anonymous" - style={{ transform: `scale(${props.zoom})`, transformOrigin: "center" }} + style={{ + width: displaySize?.width, + height: displaySize?.height, + maxWidth: displaySize ? undefined : props.boundaryWidth, + maxHeight: displaySize ? undefined : props.boundaryHeight, + transform: `scale(${props.zoom})`, + transformOrigin: "center" + }} + onLoad={handleImageLoad} onError={(_e: SyntheticEvent) => setLoadError(true)} /> diff --git a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx index fb15af797f..2202bf066e 100644 --- a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx @@ -1,6 +1,7 @@ import classNames from "classnames"; import { ValueStatus } from "mendix"; -import { ReactElement, useCallback } from "react"; +import { ReactElement, useCallback, useEffect } from "react"; +import { type Crop, type PixelCrop } from "react-image-crop"; import { CropArea } from "./CropArea"; import { CropButton } from "./CropButton"; import { PreviewPane } from "./PreviewPane"; @@ -13,6 +14,23 @@ import { cropImage, CropError } from "../utils/cropImage"; export function ImageCropContainer(props: ImageCropContainerProps): ReactElement | null { const state = useImageCropState(Number(props.minZoom)); + const { setZoom, setCrop, setCompletedCrop } = state; + + const handleImageLoad = useCallback( + (percentCrop: Crop, pixelCrop: PixelCrop) => { + setZoom(Number(props.minZoom)); + setCrop(percentCrop); + setCompletedCrop(pixelCrop); + }, + [setZoom, setCrop, setCompletedCrop, props.minZoom] + ); + + const uri = props.image.status === ValueStatus.Available ? props.image.value?.uri : undefined; + useEffect(() => { + setCrop(undefined); + setCompletedCrop(undefined); + }, [uri, setCrop, setCompletedCrop]); + const handleCrop = useCallback(async () => { const img = state.imageRef.current; if (!img || !state.completedCrop || props.image.readOnly || props.image.status !== ValueStatus.Available) { @@ -28,7 +46,8 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement outputSize: props.outputSize, cropShape: props.cropShape, viewportWidth: props.boundaryWidth, - viewportHeight: props.boundaryHeight + viewportHeight: props.boundaryHeight, + originalName: props.image.value.name }); if (props.outputSize === "viewport") { props.image.setThumbnailSize(props.boundaryWidth, props.boundaryHeight); @@ -44,7 +63,19 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement throw err; } } - }, [state, props]); + }, [ + state.completedCrop, + state.zoom, + state.imageRef, + props.image, + props.outputFormat, + props.outputQuality, + props.outputSize, + props.cropShape, + props.boundaryWidth, + props.boundaryHeight, + props.onCropAction + ]); if (props.image.status === ValueStatus.Loading) { return
; @@ -68,6 +99,7 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement resizable={props.resizableEnabled} boundaryWidth={props.boundaryWidth} boundaryHeight={props.boundaryHeight} + onImageLoad={handleImageLoad} zoom={state.zoom} minZoom={Number(props.minZoom)} maxZoom={Number(props.maxZoom)} diff --git a/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx b/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx index 6bbd45ac8c..e2548df811 100644 --- a/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx @@ -19,12 +19,16 @@ export function PreviewPane({ image, pixelCrop, zoom, width, height, circle }: P return; } - canvas.width = width; - canvas.height = height; + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; const ctx = canvas.getContext("2d"); if (!ctx) { return; } + ctx.scale(dpr, dpr); ctx.clearRect(0, 0, width, height); if (pixelCrop.width === 0 || pixelCrop.height === 0) { // Why: drawImage with a 0-sized source rect throws IndexSizeError in node-canvas / older Safari. diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts index e14e0a41ec..efccf0e83b 100644 --- a/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts @@ -1,76 +1,77 @@ import { renderHook, act } from "@testing-library/react"; -import type { WheelEvent } from "react"; import { useWheelZoom } from "../useWheelZoom"; -function makeWheelEvent(deltaY: number, ctrlKey = false): WheelEvent { - return { - deltaY, - ctrlKey, - metaKey: false, - preventDefault: jest.fn(), - stopPropagation: jest.fn() - } as unknown as WheelEvent; +function makeWheelEvent(deltaY: number, ctrlKey = false): globalThis.WheelEvent { + return new globalThis.WheelEvent("wheel", { deltaY, ctrlKey, bubbles: true, cancelable: true }); +} + +function makeSetZoom(initial: number): { setZoom: jest.Mock; getZoom: () => number } { + let current = initial; + const setZoom = jest.fn((updater: ((prev: number) => number) | number) => { + current = typeof updater === "function" ? updater(current) : updater; + }); + return { setZoom, getZoom: () => current }; } describe("useWheelZoom", () => { test("mode 'off' does nothing", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => useWheelZoom({ mode: "off", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + const { setZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "off", minZoom: 1, maxZoom: 4, setZoom })); const e = makeWheelEvent(-100); + const spy = jest.spyOn(e, "preventDefault"); act(() => result.current(e)); expect(setZoom).not.toHaveBeenCalled(); - expect(e.preventDefault).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); test("mode 'on' zooms in on negative deltaY", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + const { setZoom, getZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); const e = makeWheelEvent(-100); + const spy = jest.spyOn(e, "preventDefault"); act(() => result.current(e)); - expect(setZoom).toHaveBeenCalledWith(1.1); - expect(e.preventDefault).toHaveBeenCalled(); + expect(getZoom()).toBe(1.1); + expect(spy).toHaveBeenCalled(); }); test("mode 'on' zooms out on positive deltaY", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 2, minZoom: 1, maxZoom: 4, setZoom })); + const { setZoom, getZoom } = makeSetZoom(2); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); act(() => result.current(makeWheelEvent(100))); - expect(setZoom).toHaveBeenCalledWith(1.8); + expect(getZoom()).toBe(1.8); }); test("mode 'onWithCtrl' ignores wheel without Ctrl/Meta", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => - useWheelZoom({ mode: "onWithCtrl", zoom: 1, minZoom: 1, maxZoom: 4, setZoom }) - ); + const { setZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "onWithCtrl", minZoom: 1, maxZoom: 4, setZoom })); const e = makeWheelEvent(-100, false); + const spy = jest.spyOn(e, "preventDefault"); act(() => result.current(e)); expect(setZoom).not.toHaveBeenCalled(); - expect(e.preventDefault).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); test("mode 'onWithCtrl' zooms when Ctrl is held", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => - useWheelZoom({ mode: "onWithCtrl", zoom: 1, minZoom: 1, maxZoom: 4, setZoom }) - ); + const { setZoom, getZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "onWithCtrl", minZoom: 1, maxZoom: 4, setZoom })); const e = makeWheelEvent(-100, true); + const spy = jest.spyOn(e, "preventDefault"); act(() => result.current(e)); - expect(setZoom).toHaveBeenCalledWith(1.1); - expect(e.preventDefault).toHaveBeenCalled(); + expect(getZoom()).toBe(1.1); + expect(spy).toHaveBeenCalled(); }); test("clamps to maxZoom", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 4, minZoom: 1, maxZoom: 4, setZoom })); + const { setZoom, getZoom } = makeSetZoom(4); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); act(() => result.current(makeWheelEvent(-100))); - expect(setZoom).toHaveBeenCalledWith(4); + expect(getZoom()).toBe(4); }); test("clamps to minZoom", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + const { setZoom, getZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); act(() => result.current(makeWheelEvent(100))); - expect(setZoom).toHaveBeenCalledWith(1); + expect(getZoom()).toBe(1); }); }); diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts index f117d6e659..7cb7d2fe8b 100644 --- a/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts @@ -1,21 +1,20 @@ -import { WheelEvent, useCallback } from "react"; +import { Dispatch, SetStateAction, useCallback } from "react"; import { WheelZoomModeEnum } from "../../typings/ImageCropProps"; interface UseWheelZoomArgs { mode: WheelZoomModeEnum; - zoom: number; minZoom: number; maxZoom: number; - setZoom: (z: number) => void; + setZoom: Dispatch>; } const STEP = 0.1; -export function useWheelZoom(args: UseWheelZoomArgs): (e: WheelEvent) => void { - const { mode, zoom, minZoom, maxZoom, setZoom } = args; +export function useWheelZoom(args: UseWheelZoomArgs): (e: globalThis.WheelEvent) => void { + const { mode, minZoom, maxZoom, setZoom } = args; return useCallback( - (e: WheelEvent) => { + (e: globalThis.WheelEvent) => { if (mode === "off") { return; } @@ -24,10 +23,11 @@ export function useWheelZoom(args: UseWheelZoomArgs): (e: WheelEvent) => void { } e.preventDefault(); const direction = e.deltaY < 0 ? 1 : -1; - const next = zoom * (1 + STEP * direction); - const clamped = Math.min(maxZoom, Math.max(minZoom, Number(next.toFixed(4)))); - setZoom(clamped); + setZoom(prev => { + const next = prev * (1 + STEP * direction); + return Math.min(maxZoom, Math.max(minZoom, Number(next.toFixed(4)))); + }); }, - [mode, zoom, minZoom, maxZoom, setZoom] + [mode, minZoom, maxZoom, setZoom] ); } diff --git a/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss b/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss index 40fe5d6e8f..bbe6f2a7c9 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss +++ b/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss @@ -1,18 +1,23 @@ @import "react-image-crop/dist/ReactCrop.css"; +$image-crop-bg-color: #f5f7fa; +$image-crop-border-color-default: #b0bec5; +$image-crop-gray-light: #6c757d; +$image-crop-icon: url(../assets/crop-icon.svg); + .widget-image-crop { display: inline-flex; flex-direction: column; gap: 8px; &__canvas { + display: inline-block; position: relative; overflow: hidden; background: #f5f5f5; img { display: block; - max-width: 100%; transition: transform 80ms linear; } @@ -28,6 +33,7 @@ input[type="range"] { flex: 1; + accent-color: var(--brand-primary, #264ae5); } } @@ -49,4 +55,36 @@ &--loading { min-height: 200px; } + + &--preview { + display: flex; + flex-direction: column; + + .widget-image-crop__dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + height: 106px; + padding: 12px 20px; + border-radius: 5px; + border: 1.5px dashed var(--border-color-default, $image-crop-border-color-default); + background-color: var(--bg-color, $image-crop-bg-color); + } + + .widget-image-crop__icon { + width: 42px; + height: 33px; + background-image: var(--image-crop-icon, $image-crop-icon); + background-repeat: no-repeat; + background-size: contain; + } + + .widget-image-crop__label { + margin: 0; + font-size: 11px; + color: var(--gray-light, $image-crop-gray-light); + } + } } diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts index e881a4e0f7..2d5a2fb578 100644 --- a/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts +++ b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts @@ -1,5 +1,5 @@ -import { cropImage, CropError } from "../cropImage"; import type { PixelCrop } from "react-image-crop"; +import { cropImage, CropError } from "../cropImage"; function makeImg(naturalW: number, naturalH: number, renderedW = naturalW, renderedH = naturalH): HTMLImageElement { const img = new Image(); @@ -66,22 +66,22 @@ describe("cropImage", () => { test("uses viewport dims as canvas size when outputSize is viewport", async () => { const img = makeImg(1000, 800); - const file = await cropImage({ - image: img, - pixelCrop: baseCrop, - zoom: 1, - outputFormat: "png", - outputQuality: 1, - outputSize: "viewport", - cropShape: "rect", - viewportWidth: 50, - viewportHeight: 40 - }); - const lastCanvas = (file as any)._lastCanvas as HTMLCanvasElement | undefined; - if (lastCanvas) { - expect(lastCanvas.width).toBe(50); - expect(lastCanvas.height).toBe(40); - } + const calls = await captureDrawImageCalls(() => + cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "viewport", + cropShape: "rect", + viewportWidth: 50, + viewportHeight: 40 + }) + ); + const ctx = calls[0].ctx as CanvasRenderingContext2D; + expect(ctx.canvas.width).toBe(50); + expect(ctx.canvas.height).toBe(40); }); test("divides source rect by zoom factor when zoom > 1", async () => { @@ -106,6 +106,23 @@ describe("cropImage", () => { expect(sh).toBe(100); }); + test("returns a valid File when cropShape is circle", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "circle", + viewportWidth: 300, + viewportHeight: 300 + }); + expect(file).toBeInstanceOf(File); + expect(file.name.endsWith(".png")).toBe(true); + }); + test("rejects with CropError when toBlob returns null (tainted canvas)", async () => { const img = makeImg(1000, 800); const originalToBlob = HTMLCanvasElement.prototype.toBlob; @@ -132,12 +149,17 @@ describe("cropImage", () => { }); }); -async function captureDrawImageCalls(fn: () => Promise): Promise { +async function captureDrawImageCalls( + fn: () => Promise +): Promise> { const calls: any[] = []; const proto = CanvasRenderingContext2D.prototype as any; const original = proto.drawImage; - proto.drawImage = function (...args: any[]) { - calls.push(args); + proto.drawImage = function (this: CanvasRenderingContext2D, ...args: any[]) { + const entry: any = [...args]; + entry.ctx = this; + entry.args = args; + calls.push(entry); return original?.apply(this, args); }; try { diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts b/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts index fc241ac5f3..74537c1fa7 100644 --- a/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts +++ b/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts @@ -21,5 +21,9 @@ export function resolveAspectRatio( return customWidth / customHeight; } return undefined; + default: { + const _exhaustive: never = aspect; + return _exhaustive; + } } } diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts b/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts index ec543bb7a1..1968240edb 100644 --- a/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts +++ b/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts @@ -18,6 +18,7 @@ export interface CropImageOptions { cropShape: CropShapeEnum; viewportWidth: number; viewportHeight: number; + originalName?: string; } export async function cropImage(options: CropImageOptions): Promise { @@ -30,7 +31,8 @@ export async function cropImage(options: CropImageOptions): Promise { outputSize, cropShape, viewportWidth, - viewportHeight + viewportHeight, + originalName } = options; if (!image.naturalWidth || !image.naturalHeight) { @@ -95,5 +97,6 @@ export async function cropImage(options: CropImageOptions): Promise { ); } - return new File([blob], `crop-${Date.now()}.${ext}`, { type: mime }); + const baseName = originalName ? originalName.replace(/\.[^.]+$/, "") : `crop-${Date.now()}`; + return new File([blob], `${baseName}.${ext}`, { type: mime }); } From 7266146eeeee3c36373bb94e2fff6f0d4a74c2ee Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 21 May 2026 22:54:39 +0200 Subject: [PATCH 20/42] feat(image-crop-web): scaffold package metadata --- .../image-crop-web/.eslintrc.js | 3 + .../image-crop-web/.gitignore | 3 + .../image-crop-web/CHANGELOG.md | 13 +++++ .../pluggableWidgets/image-crop-web/LICENSE | 15 +++++ .../pluggableWidgets/image-crop-web/README.md | 5 ++ .../image-crop-web/package.json | 56 +++++++++++++++++++ 6 files changed, 95 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/.eslintrc.js create mode 100644 packages/pluggableWidgets/image-crop-web/.gitignore create mode 100644 packages/pluggableWidgets/image-crop-web/CHANGELOG.md create mode 100644 packages/pluggableWidgets/image-crop-web/LICENSE create mode 100644 packages/pluggableWidgets/image-crop-web/README.md create mode 100644 packages/pluggableWidgets/image-crop-web/package.json diff --git a/packages/pluggableWidgets/image-crop-web/.eslintrc.js b/packages/pluggableWidgets/image-crop-web/.eslintrc.js new file mode 100644 index 0000000000..5dbc3056b7 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: "@mendix/eslint-config-web-widgets/widget-ts" +}; diff --git a/packages/pluggableWidgets/image-crop-web/.gitignore b/packages/pluggableWidgets/image-crop-web/.gitignore new file mode 100644 index 0000000000..2d55399e96 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/.gitignore @@ -0,0 +1,3 @@ +dist/ +*.mpk +typings/ diff --git a/packages/pluggableWidgets/image-crop-web/CHANGELOG.md b/packages/pluggableWidgets/image-crop-web/CHANGELOG.md new file mode 100644 index 0000000000..bc5fa0bfdd --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this widget will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2026-05-21 + +### Added + +- Initial release. Crops a bound `EditableImageValue` attribute with rectangular or circular viewport, optional zoom (slider + wheel), live preview pane, and PNG/JPEG output. Replaces the legacy ImageCrop widget. diff --git a/packages/pluggableWidgets/image-crop-web/LICENSE b/packages/pluggableWidgets/image-crop-web/LICENSE new file mode 100644 index 0000000000..035fced0d9 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/LICENSE @@ -0,0 +1,15 @@ +The Apache License v2.0 + +Copyright © Mendix Technology BV 2022. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/pluggableWidgets/image-crop-web/README.md b/packages/pluggableWidgets/image-crop-web/README.md new file mode 100644 index 0000000000..5d710abd1f --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/README.md @@ -0,0 +1,5 @@ +# Image Crop + +Crops images bound to a Mendix image attribute. The cropped result is written back to the same attribute via `EditableImageValue.setValue(file)`. + +See the [Mendix Marketplace listing](https://marketplace.mendix.com/) for usage docs. diff --git a/packages/pluggableWidgets/image-crop-web/package.json b/packages/pluggableWidgets/image-crop-web/package.json new file mode 100644 index 0000000000..8c2e10e29c --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/package.json @@ -0,0 +1,56 @@ +{ + "name": "@mendix/image-crop-web", + "widgetName": "ImageCrop", + "version": "1.0.0", + "description": "Crop images bound to a Mendix image attribute", + "copyright": "© Mendix Technology BV 2026. All rights reserved.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/mendix/web-widgets.git" + }, + "config": {}, + "mxpackage": { + "name": "ImageCrop", + "type": "widget", + "mpkName": "com.mendix.widget.web.ImageCrop.mpk" + }, + "packagePath": "com.mendix.widget.web", + "marketplace": { + "minimumMXVersion": "10.21.0", + "appName": "Image Crop", + "appNumber": 0, + "reactReady": true + }, + "testProject": { + "githubUrl": "https://github.com/mendix/testProjects", + "branchName": "image-crop-web" + }, + "scripts": { + "build": "pluggable-widgets-tools build:web", + "create-gh-release": "rui-create-gh-release", + "create-translation": "rui-create-translation", + "dev": "pluggable-widgets-tools start:web", + "format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .", + "lint": "eslint src/ package.json", + "publish-marketplace": "rui-publish-marketplace", + "release": "pluggable-widgets-tools release:web", + "start": "pluggable-widgets-tools start:server", + "test": "pluggable-widgets-tools test:unit:web", + "update-changelog": "rui-update-changelog-widget", + "verify": "rui-verify-package-format" + }, + "dependencies": { + "classnames": "^2.5.1", + "react-image-crop": "^11.0.10" + }, + "devDependencies": { + "@mendix/automation-utils": "workspace:*", + "@mendix/eslint-config-web-widgets": "workspace:*", + "@mendix/pluggable-widgets-tools": "*", + "@mendix/prettier-config-web-widgets": "workspace:*", + "@mendix/rollup-web-widgets": "workspace:*", + "@mendix/widget-plugin-platform": "workspace:*", + "jest-canvas-mock": "^2.5.2" + } +} From 047d2c1c2ae11b5d2871abac3b7b5a77dbaee579 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 21 May 2026 23:02:28 +0200 Subject: [PATCH 21/42] fix(image-crop-web): align LICENSE copyright year with package.json --- packages/pluggableWidgets/image-crop-web/LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/image-crop-web/LICENSE b/packages/pluggableWidgets/image-crop-web/LICENSE index 035fced0d9..e5576bd26b 100644 --- a/packages/pluggableWidgets/image-crop-web/LICENSE +++ b/packages/pluggableWidgets/image-crop-web/LICENSE @@ -1,6 +1,6 @@ The Apache License v2.0 -Copyright © Mendix Technology BV 2022. All rights reserved. +Copyright © Mendix Technology BV 2026. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From df0179ffeb35a5c10be75016332dc99d823bd8d8 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 21 May 2026 23:17:02 +0200 Subject: [PATCH 22/42] chore(image-crop-web): add TypeScript and Rollup config --- .../image-crop-web/rollup.config.mjs | 5 +++ .../image-crop-web/tsconfig.json | 27 +++++++++++++ pnpm-lock.yaml | 40 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/rollup.config.mjs create mode 100644 packages/pluggableWidgets/image-crop-web/tsconfig.json diff --git a/packages/pluggableWidgets/image-crop-web/rollup.config.mjs b/packages/pluggableWidgets/image-crop-web/rollup.config.mjs new file mode 100644 index 0000000000..688a1a7197 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/rollup.config.mjs @@ -0,0 +1,5 @@ +import copyFiles from "@mendix/rollup-web-widgets/copyFiles.mjs"; + +export default args => { + return copyFiles(args); +}; diff --git a/packages/pluggableWidgets/image-crop-web/tsconfig.json b/packages/pluggableWidgets/image-crop-web/tsconfig.json new file mode 100644 index 0000000000..63d18d877f --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": ["./src", "./typings"], + "compilerOptions": { + "baseUrl": "./", + "noEmitOnError": true, + "sourceMap": true, + "module": "esnext", + "target": "es6", + "lib": ["esnext", "dom"], + "types": ["jest", "node"], + "moduleResolution": "node", + "declaration": false, + "noLib": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "strict": true, + "strictFunctionTypes": false, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "useUnknownInCatchVariables": false, + "exactOptionalPropertyTypes": false + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50ed2ff076..6586e9a6e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1732,6 +1732,37 @@ importers: specifier: workspace:* version: link:../../shared/widget-plugin-platform + packages/pluggableWidgets/image-crop-web: + dependencies: + classnames: + specifier: ^2.5.1 + version: 2.5.1 + react-image-crop: + specifier: ^11.0.10 + version: 11.0.10(react@18.3.1) + devDependencies: + '@mendix/automation-utils': + specifier: workspace:* + version: link:../../../automation/utils + '@mendix/eslint-config-web-widgets': + specifier: workspace:* + version: link:../../shared/eslint-config-web-widgets + '@mendix/pluggable-widgets-tools': + specifier: 11.8.0 + version: 11.8.0(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + '@mendix/prettier-config-web-widgets': + specifier: workspace:* + version: link:../../shared/prettier-config-web-widgets + '@mendix/rollup-web-widgets': + specifier: workspace:* + version: link:../../shared/rollup-web-widgets + '@mendix/widget-plugin-platform': + specifier: workspace:* + version: link:../../shared/widget-plugin-platform + jest-canvas-mock: + specifier: ^2.5.2 + version: 2.5.2 + packages/pluggableWidgets/image-web: dependencies: '@mendix/widget-plugin-component-kit': @@ -9448,6 +9479,11 @@ packages: peerDependencies: react: '>=18.0.0 <19.0.0' + react-image-crop@11.0.10: + resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} + peerDependencies: + react: '>=18.0.0 <19.0.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -18734,6 +18770,10 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 + react-image-crop@11.0.10(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@16.13.1: {} react-is@17.0.2: {} From 1fd939ab2ec3db534994f2115d66f6b3114d1fea Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 21 May 2026 23:20:32 +0200 Subject: [PATCH 23/42] feat(image-crop-web): declare widget XML properties --- .../image-crop-web/src/ImageCrop.xml | 130 ++++++++++++++++++ .../image-crop-web/src/package.xml | 11 ++ 2 files changed, 141 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml create mode 100644 packages/pluggableWidgets/image-crop-web/src/package.xml diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml new file mode 100644 index 0000000000..91dc440a83 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml @@ -0,0 +1,130 @@ + + + Image Crop + Crop an image attribute + https://docs.mendix.com/appstore/widgets/image-crop + + + + + Image attribute + Editable image attribute. The cropped image overwrites this attribute. + + + + + Crop shape + + + Rectangle + Circle + + + + Aspect ratio + + + Free + 1:1 + 16:9 + 4:3 + 3:4 + Custom + + + + Custom aspect width + + + + Custom aspect height + + + + Canvas width (px) + + + + Canvas height (px) + + + + Resizable handles + Allow the user to resize the crop area by dragging its corners. + + + + + Enable zoom + + + + Mouse wheel zoom + + + Off + On + On (hold Ctrl) + + + + Minimum zoom + + + + Maximum zoom + + + + + + Show preview + + + + Preview width (px) + + + + Preview height (px) + + + + + + Output format + + + PNG + JPEG + + + + JPEG quality (0.0 - 1.0) + + + + Output size + + + Viewport (canvas dims) + Original (source resolution) + + + + + + Crop button caption + + + Crop + + + + On crop + Action executed after the cropped image is committed. + + + + + diff --git a/packages/pluggableWidgets/image-crop-web/src/package.xml b/packages/pluggableWidgets/image-crop-web/src/package.xml new file mode 100644 index 0000000000..6afc242dce --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/package.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + From 21bd669d8dda7dcf58e231e514cd4ec02a645976 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 09:53:59 +0200 Subject: [PATCH 24/42] feat(image-crop-web): generate typings and add minimal widget stubs Co-Authored-By: Claude Opus 4.7 --- .../image-crop-web/eslint.config.mjs | 3 + .../src/ImageCrop.editorConfig.ts | 25 ++++++ .../src/ImageCrop.editorPreview.tsx | 10 +++ .../src/ImageCrop.icon.dark.png | Bin 0 -> 1223 bytes .../image-crop-web/src/ImageCrop.icon.png | Bin 0 -> 1272 bytes .../src/ImageCrop.tile.dark.png | Bin 0 -> 5694 bytes .../image-crop-web/src/ImageCrop.tile.png | Bin 0 -> 5897 bytes .../image-crop-web/src/ImageCrop.tsx | 6 ++ .../typings/ImageCropProps.d.ts | 78 ++++++++++++++++++ 9 files changed, 122 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/eslint.config.mjs create mode 100644 packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts create mode 100644 packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx create mode 100755 packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.dark.png create mode 100755 packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.png create mode 100755 packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.dark.png create mode 100755 packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.png create mode 100644 packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx create mode 100644 packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts diff --git a/packages/pluggableWidgets/image-crop-web/eslint.config.mjs b/packages/pluggableWidgets/image-crop-web/eslint.config.mjs new file mode 100644 index 0000000000..ed68ae9e78 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/eslint.config.mjs @@ -0,0 +1,3 @@ +import config from "@mendix/eslint-config-web-widgets/widget-ts.mjs"; + +export default config; diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts new file mode 100644 index 0000000000..ae53416874 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts @@ -0,0 +1,25 @@ +import { hidePropertiesIn, Properties } from "@mendix/pluggable-widgets-tools"; +import { ImageCropPreviewProps } from "../typings/ImageCropProps"; + +export function getProperties(values: ImageCropPreviewProps, defaultProperties: Properties): Properties { + const propsToHide: Array = []; + + if (values.aspectRatio !== "custom") { + propsToHide.push("customAspectWidth", "customAspectHeight"); + } + + if (!values.zoomEnabled) { + propsToHide.push("wheelZoomMode", "minZoom", "maxZoom"); + } + + if (!values.showPreview) { + propsToHide.push("previewWidth", "previewHeight"); + } + + if (values.outputFormat !== "jpeg") { + propsToHide.push("outputQuality"); + } + + hidePropertiesIn(defaultProperties, values, propsToHide); + return defaultProperties; +} diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx new file mode 100644 index 0000000000..fd831d3ac4 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx @@ -0,0 +1,10 @@ +import { ReactElement } from "react"; +import { ImageCropPreviewProps } from "../typings/ImageCropProps"; + +export function preview(_props: ImageCropPreviewProps): ReactElement { + return
Image Crop
; +} + +export function getPreviewCss(): string { + return ""; +} diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.dark.png b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.dark.png new file mode 100755 index 0000000000000000000000000000000000000000..1cae9739f5d08cb2853b6ea73c92e4a2734c67ba GIT binary patch literal 1223 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBC{d9b;hE;^%b*2hb1<+l zN-=;;U<6`2Mrks064nT^tz$3Dlfr0M`2s2LA=96Y%VBYEJ;uuoF_%`Zd=`8~hQ<(>|^Dg8} zC~KU=YsC;SL*XooubPJR0|w@l<|V==(kjv|`41f4XY0yGZv3hed91CgxMYd?OWV8E zx+gdJinPWDC};?XaB#7(HZlEQ&7>>*==0A%$=h$wmFH_e9k%+a`Nx%;`3>xH%x2Gi zS@P@D!vt1^PjmgAf7cWUKe2B6?YHWFE!!S_ImhyIzPiV}iZ7Yh_9-t3*}&BBC;aI1 zDQCR-a@t=n*v8-PTy}GX-SI8qtFJmr_1|58LsEg&`gD@O10DU zzPxwyVFtS?N;xaeZ%Du4{_bSnq4kqg70&S~RC7;acAUZ&q;dT8(@(9xI(}Jw_F_43 z^Pyn)?gA*FQ%zm(> zF(2%cTejim-GgzDxBXx)XS(R}b92s>z;AZU4j-obu)MBmTE0nqiTM`aCuUR9yJ|}= z?`GJ2H;--6dKuwYms^-8mNRA?ZsrKL5?s;yt3XM1@!j1FEVIh~T+Arhc>n!!gQunc zSU#02oZq){;>pK?-=$S|o(tdE2o>&{9k9xYMW5+O!*(IXDpw~s?Q~1enRVPix zt^?c~=5V;n?&pz$-$5d8!PqsOm@_);v4GSdu z=gDr7iA{4@^C;+G_Bx)QoBn0hoV(U@e5acpQ%cjSC$5V%S18pB9yc+U_3sV$V&Jo3 z@A@2mu*j_U_}9SOb6E{o<5ikg?&rF({jExaZbc|dbp8D$Pb;o5?PwBL+VSi3g4$_o z<2!e*F=pJ+v%ce@_4av;`xvjt>T?8q7hO?Z^V)Vn-T@Q4zUQ|dujO?MW2iY1@%VS# zuTb;nHxH!kIrcU6JHv$O``?)KF)+Vq%zQqd^~3Tu&9h8;0hezz{^Y%pZxQo`XTkfL zFS`7Uk)}HjdTqI^yfaYg(Tm?4XXbNOcN#WlBjv61bqC}*nwXzli8fvb%Hf`_elF{r G5}E+&GwbmH literal 0 HcmV?d00001 diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.png b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.icon.png new file mode 100755 index 0000000000000000000000000000000000000000..8c7b266490c19bbe9fcaf0eeaf807cde455ab36c GIT binary patch literal 1272 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 literal 0 HcmV?d00001 diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.png b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tile.png new file mode 100755 index 0000000000000000000000000000000000000000..f7f7732cc77300c122b3a642e25e836520486d18 GIT binary patch literal 5897 zcmc&&XIvA_*58Bx0Tew01zq+0Jy&Z;231#RskSD8UWVq z06;kd0C>DJ8%$Ke55W(t^qdR~05On;12705@OKY?kD(6X|36v>av7lehYtmSSXTh{ zZy7_d{QI&4pTFDuZ%LO6`L{I4%Z2_&8Up0f{f7oxKxyEm&rugxFnQmw_5%Rcj=vvB zsyHh@0G#=yr=@W_7_ymV;K$W{>PHRm2l&}nT5=nLxL#I}kMBbQ&y54-Rt;iokg^Rw(>G@-R-3z=wC?kHxA2mv(kG(;lTa%4P9H zJ(AP^AiF<&air4pz0~6$;#X-Qmpe>;{?5u{HfPfg&;p=yaGJzKS*d4fwPDkt>C<@& zyC3-C*umLoS=#&Oc@|G!9Pk8K=+}>OFEA*@tg?tcV`$$ue1)yE?uiOt*8|(ze`JVh zrY*#94Sb>0<6^Y5!f}b?=-QhlvA@QdbMsc$svHUJ?y$a&>eYh zIY2+I`1;$AyAgBas`96TR@v>nP8@sa&C@Z@6C#!9v zU+vfwLC%A{Eb@BS_N*YWGU7p%ctM>;D{I%D5$nk4ap&^Ue6J->=%-KPySq(Rqd5Sq zCHmUKD`<4SLneXiaoK`tTo&W6pg^Gw=LFTp$gsq~>99ZT1GU3UA<{JUIzUS-x;PY;n>K3wq=XzN1(5bxm`)fI=sgokOl z=-Zn&L-?8vGf9ipqBCO~!FpK!hb5%}U5Sj#*Z$ft0+*zS;M)J$v!_9>KRf$qr)fKp z#7cK;h%5O$K3?-pd5QMEmM$z;f~6R$Bjqj2DwMWdgg&%p3;!~lw}E=RWVck2ZR zJm<}>hdVB#QjfnS2z@^V;v#A4rS6_dnt;$0Ir`*o5FuE!b^dPo!H*!w&%_=J{omak zZ2w5%aXE?i-)c#5NQr)%no<#D9l?J65###?9aVpJo{s|uq$yDx+axClTl*PDN_ z*A+=xDz-ei(b&;lY{nT>6^^PZsdDpiqTXlW9<41;j&#s1y6#`s0V;dO~C0X>pY{a$M!@?P6= zlVejA9t@yfYnvt68P+wETTP$XN*b4pX}uEsZF?erYeZO+o5S`=6d){W?fl#;?M3q> zyNklSwKAXf3B3J<0iedd;?=i@MHvIS?)mKtVtQs@j}VjRyAy#=XSL%83Bx8~ha%q;W$0AVUd5#$r0Wcnp4D!W^++P!-3OBNg+MEbvGWS>g1t_{i z%}S)x#Ckn#PV%%~aYcg|*nHuo{>udCwE5-85Q9B>JJ4!f z+%Q{5(Yc=rF^L6@1M9rsDC4ilMbVbT5(i4@F>Baw>| z5|RSS>UVAyJd61gvSRB0pvqCG-7@SJzU|`IrC+Z8L(tKeGS)C<$eIInxr=1d9Csl_ z1at>ELo2P^k#Anp=*=Q}1wXZ@FgL7u66f+X+c<|_6f$z&uX7!*g=J=?THU#iM8ez9o=*~T4YaR%yhpW1FJzwUst6kVNjdAAQO32wc zI>9A%MaAwUz4m#v@zc3s@;ue&(17EHY(znaxoB+H^_nK4O7xlV`R0;hOY89^O;l1#~_EJI1_2|TFzvT6p_cBVF;dAV{NkkcDH8%W3 zvKLdxJW@Ww@L=HJA^Jjl9R_Q(R<_<({w$FWfm7IwHzqLMl8d+&{BgbUp|nT&x8tUI zf;O({+#|2DVqadU@zSW~gfr^-$YH=68msUj$}tX5ydoFxKu`Ctx*ia6s1e!)kg!gFSd9;ejWdvZHi5fAYN;7cZLs{fCHUmv``#xgpo( zw*ERHn4OOCcqtz&D&224sb6fft+Sj&LSeEhQCQYPL!=j)Re1oFj=-VTP@ASqT%NQB zSqW%+t?j)EZCnkbjOK=H z3u?`z3deX^UI&raaH+C{3*t$mZTE=BK66$#TtX}+3I0zy#ncFu)C zU9camS8VJ#Ot9qFgL2K`Qv;poMut}W@qcGSmq5XXJk2OOZob9!ZVWNIR?hC-tZ~QIl^}{@Ep{x zmU+Pzk>NlKTJxJKcvr8q5@$EDQE__gLyiU$#syt%lUsVedbx~ zK;WoG*O`98B)_E*he}(&(@C5ny8gzm7M^oJ08!nYM$M9Y(!U~hMH+?;lE>!LgA~mq3D|;i0O=IjYEc8jas|YfYhgT2 zSu>I43)B7Y-&`PYLZpftO(<&E=$AXQrzT7`+l1+(JDJT&yxw7JwAwkMYRkv|3RM3| z*K%MJr%UgJX{JRHDB%p?N%P;HmcH=Xex2Aig@g*HD7CSZYoEnZ?_%06#{=~O%`WkP zr!bG>Q5;5y-tH-t(<}7AjfR9WBq&9hC16_*iFBu@pYe!637j}Ba{Bt&)5C|=mp&`4 z6HmT+@IArlIqpjvjJB!-ISN*CsX2s?{PmMoMS41G77z5PQMxCE(pB&}OfB+cqG+Df zHil3Uzx-K?{qT@a?dg2F4)Xq|GtM-LYPS8KKMD)*n?lg}?{pg-z5@>LE8y}vz_*0i zOxaKg=%~U3#c-~mZUQRk5xd`mkg}<|G?aN|M1^n`O6Bn zdcE55Qfm+tJ=9?C9`07X4@K~;2g!&ml3FPp^@`-N;oTM9r@C6m%-F4udjtb(t)CKE zkt1o+zkTmWv<@H#)LOD0%bvJh0VWSMqp!BAE)}cr*VW5PW}=`a4UheY62#*NHaG}5 zTkktCW#k(4?C4Dnd`bPVDd4ityFSCON{p<1LUqs3Lt&(#{WWRn%xiDn?v%CpzM}33 zjkdDYhME`Yj`U<8p)cQ=s6gz<#I8QlZlcahgIj0Sc2Z{xGbEu$d;RZs_LZ`oXe7bP zM`f1gJvBw}VSS*QTAPNYm>)HcJn`diHq?y=_PE3v;|u#qE0+>x6GvsVaE5&z&69AP zEA5f{Lwl?7!vJvNANUUqA00LPMg27*i-(^YwzR%!&H~{jJToO8^cz)4ISna<6~XR1={+nM7n<{AuT;c)Le6P_@DQWa zyl;I^R)cNHeMYIH6Y>JMEAi4ea4eOBQ)UFGLrk(-LCtwu@Zop#pH!Tkp zkrpf9M3VL&yd|Iqq{N$sOTR1?P($gcXfFmyWw+BzJ10zl^ep_JUzJ= zXK4r&@}UW7%5{-M%GB@tWd-%FjE}35uZri#gZ*BQ%{Qvt4!JiG!(CMHE)HIR8(+3 z842~jJ?AkTme@Xd7{6|u8pZbd=0#&B$Y8|cR4D|jts;E>_?$LLm#XO691#N_WMtY| zsqL~S9X`Mdyt{>he-xVg1h|mP--xeaU^<_wP^h`$E_>c)6;1!tsxc@+UCJ_)gJ~=4 zv#oa#J`XkbvTL4lqVM6Qzhx$~L23R1cXqF~rurwrDf~yOYOL*t@iMvZi0ik2!n5d)scuo!jbxE~Fzy}2Q{=Jy z%4#M>cAPxf>&;!!D^S`|pU1@}POJOshilPA9c78+%U_R$Y$WjC=#l=CGHb@ZJ*wfK zQ52B?Zamoah7XsI&}H4|qKDW6;{N407H9!8ZeN0Prv@F6tByWSxUR8A^}A-SCW-CrUM?vUg7JyBr&o8?vM9g}`QDo`u4B%7u zwBVRufGtuhc%z@>Cqxoype|1!-0h6 zihaFXiU_=3VtXrPjq%q6G0yNSoZrpB&K{ECL@QffD-MW1n_8vHIyM>V1lD@)gGb)$(qE96`z#tD+kVHx^Pa;3urKv155 zbqv-o@eZ3d1W(U&pBunamsn6;^T;_i{12#Iia>bZXp(*BUcY34BR}2&%nTd9bkdKK@MI7BX{8s*VdV#++dhY4>LiyaDZp@k-y15yPA*ty#5R)wPTqtL}t~XYoY9g`~`ZxnV$0+fb|Qx?R-&0O@0vwg3PC literal 0 HcmV?d00001 diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx new file mode 100644 index 0000000000..4352e42929 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx @@ -0,0 +1,6 @@ +import { ReactElement } from "react"; +import { ImageCropContainerProps } from "../typings/ImageCropProps"; + +export function ImageCrop(_props: ImageCropContainerProps): ReactElement | null { + return null; +} diff --git a/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts b/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts new file mode 100644 index 0000000000..723099d423 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts @@ -0,0 +1,78 @@ +/** + * This file was generated from ImageCrop.xml + * WARNING: All changes made to this file will be overwritten + * @author Mendix Widgets Framework Team + */ +import { CSSProperties } from "react"; +import { ActionValue, DynamicValue, EditableImageValue, WebImage } from "mendix"; +import { Big } from "big.js"; + +export type CropShapeEnum = "rect" | "circle"; + +export type AspectRatioEnum = "free" | "square" | "landscape16x9" | "landscape4x3" | "portrait3x4" | "custom"; + +export type WheelZoomModeEnum = "off" | "on" | "onWithCtrl"; + +export type OutputFormatEnum = "png" | "jpeg"; + +export type OutputSizeEnum = "viewport" | "original"; + +export interface ImageCropContainerProps { + name: string; + class: string; + style?: CSSProperties; + tabIndex?: number; + image: EditableImageValue; + cropShape: CropShapeEnum; + aspectRatio: AspectRatioEnum; + customAspectWidth?: number; + customAspectHeight?: number; + boundaryWidth: number; + boundaryHeight: number; + resizableEnabled: boolean; + zoomEnabled: boolean; + wheelZoomMode: WheelZoomModeEnum; + minZoom: Big; + maxZoom: Big; + showPreview: boolean; + previewWidth?: number; + previewHeight?: number; + outputFormat: OutputFormatEnum; + outputQuality?: Big; + outputSize: OutputSizeEnum; + cropButtonCaption?: DynamicValue; + onCropAction?: ActionValue; +} + +export interface ImageCropPreviewProps { + /** + * @deprecated Deprecated since version 9.18.0. Please use class property instead. + */ + className: string; + class: string; + style: string; + styleObject?: CSSProperties; + readOnly: boolean; + renderMode: "design" | "xray" | "structure"; + translate: (text: string) => string; + image: { type: "static"; imageUrl: string; } | { type: "dynamic"; entity: string; } | null; + cropShape: CropShapeEnum; + aspectRatio: AspectRatioEnum; + customAspectWidth: number | null; + customAspectHeight: number | null; + boundaryWidth: number | null; + boundaryHeight: number | null; + resizableEnabled: boolean; + zoomEnabled: boolean; + wheelZoomMode: WheelZoomModeEnum; + minZoom: number | null; + maxZoom: number | null; + showPreview: boolean; + previewWidth: number | null; + previewHeight: number | null; + outputFormat: OutputFormatEnum; + outputQuality: number | null; + outputSize: OutputSizeEnum; + cropButtonCaption: string; + onCropAction: {} | null; +} From 8a3792136e2e10c335e1b25b1cc2fa24c895e5fd Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 09:57:04 +0200 Subject: [PATCH 25/42] chore(image-crop-web): remove legacy .eslintrc.js, flat config wins --- packages/pluggableWidgets/image-crop-web/.eslintrc.js | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 packages/pluggableWidgets/image-crop-web/.eslintrc.js diff --git a/packages/pluggableWidgets/image-crop-web/.eslintrc.js b/packages/pluggableWidgets/image-crop-web/.eslintrc.js deleted file mode 100644 index 5dbc3056b7..0000000000 --- a/packages/pluggableWidgets/image-crop-web/.eslintrc.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - extends: "@mendix/eslint-config-web-widgets/widget-ts" -}; From 470f95e82ff4ab7bbfb7b71382ff581568d6d5b2 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 14:11:30 +0200 Subject: [PATCH 26/42] feat(image-crop-web): add aspect ratio resolver --- .../src/utils/__tests__/aspectRatio.spec.ts | 39 +++++++++++++++++++ .../image-crop-web/src/utils/aspectRatio.ts | 25 ++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/utils/__tests__/aspectRatio.spec.ts create mode 100644 packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/aspectRatio.spec.ts b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/aspectRatio.spec.ts new file mode 100644 index 0000000000..71f5966172 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/aspectRatio.spec.ts @@ -0,0 +1,39 @@ +import { resolveAspectRatio } from "../aspectRatio"; + +describe("resolveAspectRatio", () => { + test("returns undefined for 'free'", () => { + expect(resolveAspectRatio("free", 0, 0)).toBeUndefined(); + }); + + test("returns 1 for 'square'", () => { + expect(resolveAspectRatio("square", 0, 0)).toBe(1); + }); + + test("returns 16/9 for 'landscape16x9'", () => { + expect(resolveAspectRatio("landscape16x9", 0, 0)).toBeCloseTo(16 / 9); + }); + + test("returns 4/3 for 'landscape4x3'", () => { + expect(resolveAspectRatio("landscape4x3", 0, 0)).toBeCloseTo(4 / 3); + }); + + test("returns 3/4 for 'portrait3x4'", () => { + expect(resolveAspectRatio("portrait3x4", 0, 0)).toBeCloseTo(3 / 4); + }); + + test("returns custom width/height when both positive", () => { + expect(resolveAspectRatio("custom", 21, 9)).toBeCloseTo(21 / 9); + }); + + test("returns undefined when custom width is zero", () => { + expect(resolveAspectRatio("custom", 0, 9)).toBeUndefined(); + }); + + test("returns undefined when custom height is zero", () => { + expect(resolveAspectRatio("custom", 16, 0)).toBeUndefined(); + }); + + test("returns undefined when custom width is negative", () => { + expect(resolveAspectRatio("custom", -1, 9)).toBeUndefined(); + }); +}); diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts b/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts new file mode 100644 index 0000000000..fc241ac5f3 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts @@ -0,0 +1,25 @@ +import { AspectRatioEnum } from "../../typings/ImageCropProps"; + +export function resolveAspectRatio( + aspect: AspectRatioEnum, + customWidth: number, + customHeight: number +): number | undefined { + switch (aspect) { + case "free": + return undefined; + case "square": + return 1; + case "landscape16x9": + return 16 / 9; + case "landscape4x3": + return 4 / 3; + case "portrait3x4": + return 3 / 4; + case "custom": + if (customWidth > 0 && customHeight > 0) { + return customWidth / customHeight; + } + return undefined; + } +} From d2980b26d4775c829924342c0076d6211a02a9d5 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 15:52:07 +0200 Subject: [PATCH 27/42] feat(image-crop-web): add cropImage canvas extraction utility Co-Authored-By: Claude Sonnet 4.6 --- .../image-crop-web/jest.config.js | 6 + .../image-crop-web/jest.setup.ts | 61 +++++++ .../image-crop-web/package.json | 2 +- .../src/utils/__tests__/cropImage.spec.ts | 149 ++++++++++++++++++ .../image-crop-web/src/utils/cropImage.ts | 99 ++++++++++++ 5 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 packages/pluggableWidgets/image-crop-web/jest.config.js create mode 100644 packages/pluggableWidgets/image-crop-web/jest.setup.ts create mode 100644 packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts create mode 100644 packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts diff --git a/packages/pluggableWidgets/image-crop-web/jest.config.js b/packages/pluggableWidgets/image-crop-web/jest.config.js new file mode 100644 index 0000000000..8ee98da701 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/jest.config.js @@ -0,0 +1,6 @@ +const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"); + +module.exports = { + ...base, + setupFilesAfterEnv: [...(base.setupFilesAfterEnv ?? []), require("path").join(__dirname, "jest.setup.ts")] +}; diff --git a/packages/pluggableWidgets/image-crop-web/jest.setup.ts b/packages/pluggableWidgets/image-crop-web/jest.setup.ts new file mode 100644 index 0000000000..3b61a5e739 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/jest.setup.ts @@ -0,0 +1,61 @@ +/** + * Jest setup for image-crop-web tests. + * + * Problem: when `canvas` npm package is installed, jsdom uses node-canvas. Its `drawImage` + * rejects jsdom HTMLImageElement objects. Also, the test's `captureDrawImageCalls` helper spies on + * `CanvasRenderingContext2D.prototype.drawImage` — which must be the mock class prototype for the + * spy to fire. + * + * Fix: + * 1. Replace `global.CanvasRenderingContext2D` with the jest-canvas-mock class. + * 2. Override `HTMLCanvasElement.prototype.getContext` to return a MockCRC2D instance. + * This makes the context returned by our code an instance of MockCRC2D, so the spec's spy + * on `CanvasRenderingContext2D.prototype.drawImage` (which equals MockCRC2D.prototype.drawImage) + * fires correctly. + * 3. Override `HTMLCanvasElement.prototype.toBlob` to return a valid Blob synchronously + * (avoiding node-canvas toBuffer issues in tests). + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const MockCRC2D = require("jest-canvas-mock/lib/classes/CanvasRenderingContext2D").default; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const MockImageBitmap = require("jest-canvas-mock/lib/classes/ImageBitmap").default; + +// Make global.CanvasRenderingContext2D the mock class so spec spies on the right prototype +(global as any).CanvasRenderingContext2D = MockCRC2D; +// MockCRC2D's drawImage references ImageBitmap globally — provide a stub if jsdom doesn't have it +if (!(global as any).ImageBitmap) { + (global as any).ImageBitmap = MockImageBitmap; +} + +// Per-canvas context map for idempotency +const contextMap = new WeakMap>(); + +// Patch HTMLCanvasElement.prototype.getContext — jsdom exposes this as a regular JS method +const origGetContext = HTMLCanvasElement.prototype.getContext; +(HTMLCanvasElement.prototype as any).getContext = function ( + this: HTMLCanvasElement, + type: string, + ...rest: unknown[] +): unknown { + if (type === "2d") { + if (!contextMap.has(this)) { + contextMap.set(this, new MockCRC2D(this)); + } + return contextMap.get(this); + } + return (origGetContext as Function).apply(this, [type, ...rest]); +}; + +// Patch HTMLCanvasElement.prototype.toBlob to avoid node-canvas's toBuffer path +(HTMLCanvasElement.prototype as any).toBlob = function ( + this: HTMLCanvasElement, + callback: (blob: Blob | null) => void, + type?: string +): void { + const mime = type === "image/jpeg" || type === "image/webp" ? type : "image/png"; + const length = this.width * this.height * 4; + const data = new Uint8Array(length); + const blob = new Blob([data], { type: mime }); + setTimeout(() => callback(blob), 0); +}; diff --git a/packages/pluggableWidgets/image-crop-web/package.json b/packages/pluggableWidgets/image-crop-web/package.json index 8c2e10e29c..4c7d888abb 100644 --- a/packages/pluggableWidgets/image-crop-web/package.json +++ b/packages/pluggableWidgets/image-crop-web/package.json @@ -36,7 +36,7 @@ "publish-marketplace": "rui-publish-marketplace", "release": "pluggable-widgets-tools release:web", "start": "pluggable-widgets-tools start:server", - "test": "pluggable-widgets-tools test:unit:web", + "test": "jest --config jest.config.js", "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts new file mode 100644 index 0000000000..e881a4e0f7 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts @@ -0,0 +1,149 @@ +import { cropImage, CropError } from "../cropImage"; +import type { PixelCrop } from "react-image-crop"; + +function makeImg(naturalW: number, naturalH: number, renderedW = naturalW, renderedH = naturalH): HTMLImageElement { + const img = new Image(); + Object.defineProperty(img, "naturalWidth", { value: naturalW }); + Object.defineProperty(img, "naturalHeight", { value: naturalH }); + Object.defineProperty(img, "width", { value: renderedW }); + Object.defineProperty(img, "height", { value: renderedH }); + return img; +} + +const baseCrop: PixelCrop = { unit: "px", x: 10, y: 20, width: 100, height: 80 }; + +describe("cropImage", () => { + test("rejects when the image element has zero natural width", async () => { + const img = makeImg(0, 0); + await expect( + cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }) + ).rejects.toBeInstanceOf(CropError); + }); + + test("returns a File whose name has a .png extension when outputFormat is png", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }); + expect(file.name.endsWith(".png")).toBe(true); + expect(file.type).toBe("image/png"); + }); + + test("returns a File whose name has a .jpg extension when outputFormat is jpeg", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "jpeg", + outputQuality: 0.7, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }); + expect(file.name.endsWith(".jpg")).toBe(true); + expect(file.type).toBe("image/jpeg"); + }); + + test("uses viewport dims as canvas size when outputSize is viewport", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "viewport", + cropShape: "rect", + viewportWidth: 50, + viewportHeight: 40 + }); + const lastCanvas = (file as any)._lastCanvas as HTMLCanvasElement | undefined; + if (lastCanvas) { + expect(lastCanvas.width).toBe(50); + expect(lastCanvas.height).toBe(40); + } + }); + + test("divides source rect by zoom factor when zoom > 1", async () => { + const img = makeImg(1000, 800, 1000, 800); + const calls = await captureDrawImageCalls(() => + cropImage({ + image: img, + pixelCrop: { unit: "px", x: 100, y: 100, width: 200, height: 200 }, + zoom: 2, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }) + ); + const [, sx, sy, sw, sh] = calls[0]; + expect(sx).toBe(50); + expect(sy).toBe(50); + expect(sw).toBe(100); + expect(sh).toBe(100); + }); + + test("rejects with CropError when toBlob returns null (tainted canvas)", async () => { + const img = makeImg(1000, 800); + const originalToBlob = HTMLCanvasElement.prototype.toBlob; + HTMLCanvasElement.prototype.toBlob = function (cb: (b: Blob | null) => void) { + cb(null); + }; + try { + await expect( + cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "rect", + viewportWidth: 300, + viewportHeight: 300 + }) + ).rejects.toBeInstanceOf(CropError); + } finally { + HTMLCanvasElement.prototype.toBlob = originalToBlob; + } + }); +}); + +async function captureDrawImageCalls(fn: () => Promise): Promise { + const calls: any[] = []; + const proto = CanvasRenderingContext2D.prototype as any; + const original = proto.drawImage; + proto.drawImage = function (...args: any[]) { + calls.push(args); + return original?.apply(this, args); + }; + try { + await fn(); + } finally { + proto.drawImage = original; + } + return calls; +} diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts b/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts new file mode 100644 index 0000000000..ec543bb7a1 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts @@ -0,0 +1,99 @@ +import type { PixelCrop } from "react-image-crop"; +import type { CropShapeEnum, OutputFormatEnum, OutputSizeEnum } from "../../typings/ImageCropProps"; + +export class CropError extends Error { + constructor(message: string) { + super(message); + this.name = "CropError"; + } +} + +export interface CropImageOptions { + image: HTMLImageElement; + pixelCrop: PixelCrop; + zoom: number; + outputFormat: OutputFormatEnum; + outputQuality: number; + outputSize: OutputSizeEnum; + cropShape: CropShapeEnum; + viewportWidth: number; + viewportHeight: number; +} + +export async function cropImage(options: CropImageOptions): Promise { + const { + image, + pixelCrop, + zoom, + outputFormat, + outputQuality, + outputSize, + cropShape, + viewportWidth, + viewportHeight + } = options; + + if (!image.naturalWidth || !image.naturalHeight) { + throw new CropError("Image not loaded."); + } + + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + const z = zoom > 0 ? zoom : 1; + + const sx = (pixelCrop.x / z) * scaleX; + const sy = (pixelCrop.y / z) * scaleY; + const sw = (pixelCrop.width / z) * scaleX; + const sh = (pixelCrop.height / z) * scaleY; + + const destW = outputSize === "viewport" ? viewportWidth : sw; + const destH = outputSize === "viewport" ? viewportHeight : sh; + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(destW)); + canvas.height = Math.max(1, Math.round(destH)); + + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new CropError("Canvas 2D context unavailable."); + } + + if (outputFormat === "jpeg") { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + if (cropShape === "circle") { + ctx.save(); + ctx.beginPath(); + ctx.ellipse(canvas.width / 2, canvas.height / 2, canvas.width / 2, canvas.height / 2, 0, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + } + + ctx.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); + + if (cropShape === "circle") { + ctx.restore(); + } + + const mime = outputFormat === "jpeg" ? "image/jpeg" : "image/png"; + const ext = outputFormat === "jpeg" ? "jpg" : "png"; + const quality = outputFormat === "jpeg" ? Math.min(1, Math.max(0, outputQuality)) : undefined; + + const blob = await new Promise(resolve => { + try { + canvas.toBlob(resolve, mime, quality); + } catch (_e) { + resolve(null); + } + }); + + if (!blob) { + throw new CropError( + "Could not export the cropped image. The source may be tainted by cross-origin restrictions." + ); + } + + return new File([blob], `crop-${Date.now()}.${ext}`, { type: mime }); +} From 502a233ba5e36b3d362814e74d4c544e7a491089 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 16:09:56 +0200 Subject: [PATCH 28/42] chore(image-crop-web): align test script with sibling --projects convention --- packages/pluggableWidgets/image-crop-web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/image-crop-web/package.json b/packages/pluggableWidgets/image-crop-web/package.json index 4c7d888abb..456dac5ae7 100644 --- a/packages/pluggableWidgets/image-crop-web/package.json +++ b/packages/pluggableWidgets/image-crop-web/package.json @@ -36,7 +36,7 @@ "publish-marketplace": "rui-publish-marketplace", "release": "pluggable-widgets-tools release:web", "start": "pluggable-widgets-tools start:server", - "test": "jest --config jest.config.js", + "test": "jest --projects jest.config.js", "update-changelog": "rui-update-changelog-widget", "verify": "rui-verify-package-format" }, From 0b8c2a8228e63a850913c0fc4427f9f4728936f0 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 16:21:33 +0200 Subject: [PATCH 29/42] feat(image-crop-web): add useWheelZoom hook with mode gating Co-Authored-By: Claude Sonnet 4.6 --- .../src/hooks/__tests__/useWheelZoom.spec.ts | 76 +++++++++++++++++++ .../image-crop-web/src/hooks/useWheelZoom.ts | 33 ++++++++ 2 files changed, 109 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts create mode 100644 packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts new file mode 100644 index 0000000000..e14e0a41ec --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts @@ -0,0 +1,76 @@ +import { renderHook, act } from "@testing-library/react"; +import type { WheelEvent } from "react"; +import { useWheelZoom } from "../useWheelZoom"; + +function makeWheelEvent(deltaY: number, ctrlKey = false): WheelEvent { + return { + deltaY, + ctrlKey, + metaKey: false, + preventDefault: jest.fn(), + stopPropagation: jest.fn() + } as unknown as WheelEvent; +} + +describe("useWheelZoom", () => { + test("mode 'off' does nothing", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => useWheelZoom({ mode: "off", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + const e = makeWheelEvent(-100); + act(() => result.current(e)); + expect(setZoom).not.toHaveBeenCalled(); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + test("mode 'on' zooms in on negative deltaY", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + const e = makeWheelEvent(-100); + act(() => result.current(e)); + expect(setZoom).toHaveBeenCalledWith(1.1); + expect(e.preventDefault).toHaveBeenCalled(); + }); + + test("mode 'on' zooms out on positive deltaY", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 2, minZoom: 1, maxZoom: 4, setZoom })); + act(() => result.current(makeWheelEvent(100))); + expect(setZoom).toHaveBeenCalledWith(1.8); + }); + + test("mode 'onWithCtrl' ignores wheel without Ctrl/Meta", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => + useWheelZoom({ mode: "onWithCtrl", zoom: 1, minZoom: 1, maxZoom: 4, setZoom }) + ); + const e = makeWheelEvent(-100, false); + act(() => result.current(e)); + expect(setZoom).not.toHaveBeenCalled(); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + test("mode 'onWithCtrl' zooms when Ctrl is held", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => + useWheelZoom({ mode: "onWithCtrl", zoom: 1, minZoom: 1, maxZoom: 4, setZoom }) + ); + const e = makeWheelEvent(-100, true); + act(() => result.current(e)); + expect(setZoom).toHaveBeenCalledWith(1.1); + expect(e.preventDefault).toHaveBeenCalled(); + }); + + test("clamps to maxZoom", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 4, minZoom: 1, maxZoom: 4, setZoom })); + act(() => result.current(makeWheelEvent(-100))); + expect(setZoom).toHaveBeenCalledWith(4); + }); + + test("clamps to minZoom", () => { + const setZoom = jest.fn(); + const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + act(() => result.current(makeWheelEvent(100))); + expect(setZoom).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts new file mode 100644 index 0000000000..f117d6e659 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts @@ -0,0 +1,33 @@ +import { WheelEvent, useCallback } from "react"; +import { WheelZoomModeEnum } from "../../typings/ImageCropProps"; + +interface UseWheelZoomArgs { + mode: WheelZoomModeEnum; + zoom: number; + minZoom: number; + maxZoom: number; + setZoom: (z: number) => void; +} + +const STEP = 0.1; + +export function useWheelZoom(args: UseWheelZoomArgs): (e: WheelEvent) => void { + const { mode, zoom, minZoom, maxZoom, setZoom } = args; + + return useCallback( + (e: WheelEvent) => { + if (mode === "off") { + return; + } + if (mode === "onWithCtrl" && !(e.ctrlKey || e.metaKey)) { + return; + } + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + const next = zoom * (1 + STEP * direction); + const clamped = Math.min(maxZoom, Math.max(minZoom, Number(next.toFixed(4)))); + setZoom(clamped); + }, + [mode, zoom, minZoom, maxZoom, setZoom] + ); +} From 74f69e9faf8287db235628682cd554b71dbd3d0c Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 22 May 2026 16:23:14 +0200 Subject: [PATCH 30/42] feat(image-crop-web): add useImageCropState hook --- .../src/hooks/useImageCropState.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts new file mode 100644 index 0000000000..6725f85ebb --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts @@ -0,0 +1,20 @@ +import { Dispatch, RefObject, SetStateAction, useRef, useState } from "react"; +import type { Crop, PixelCrop } from "react-image-crop"; + +interface ImageCropState { + crop: Crop | undefined; + setCrop: Dispatch>; + completedCrop: PixelCrop | undefined; + setCompletedCrop: Dispatch>; + zoom: number; + setZoom: Dispatch>; + imageRef: RefObject; +} + +export function useImageCropState(initialZoom: number): ImageCropState { + const [crop, setCrop] = useState(undefined); + const [completedCrop, setCompletedCrop] = useState(undefined); + const [zoom, setZoom] = useState(initialZoom); + const imageRef = useRef(null); + return { crop, setCrop, completedCrop, setCompletedCrop, zoom, setZoom, imageRef }; +} From 1e71de92935b958c8ad2d64e1511e022e25e59b2 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 09:39:20 +0200 Subject: [PATCH 31/42] feat(image-crop-web): add CropButton, ZoomSlider, PreviewPane leaves --- .../src/components/CropButton.tsx | 21 +++++++ .../src/components/PreviewPane.tsx | 59 +++++++++++++++++++ .../src/components/ZoomSlider.tsx | 24 ++++++++ 3 files changed, 104 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/components/CropButton.tsx create mode 100644 packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx create mode 100644 packages/pluggableWidgets/image-crop-web/src/components/ZoomSlider.tsx diff --git a/packages/pluggableWidgets/image-crop-web/src/components/CropButton.tsx b/packages/pluggableWidgets/image-crop-web/src/components/CropButton.tsx new file mode 100644 index 0000000000..54b135436c --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/CropButton.tsx @@ -0,0 +1,21 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; + +interface CropButtonProps { + caption: string; + disabled: boolean; + onClick: () => void; +} + +export function CropButton({ caption, disabled, onClick }: CropButtonProps): ReactElement { + return ( + + ); +} diff --git a/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx b/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx new file mode 100644 index 0000000000..6bbd45ac8c --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx @@ -0,0 +1,59 @@ +import { ReactElement, useEffect, useRef } from "react"; +import type { PixelCrop } from "react-image-crop"; + +interface PreviewPaneProps { + image: HTMLImageElement | null; + pixelCrop: PixelCrop | undefined; + zoom: number; + width: number; + height: number; + circle: boolean; +} + +export function PreviewPane({ image, pixelCrop, zoom, width, height, circle }: PreviewPaneProps): ReactElement { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !image || !pixelCrop || !image.naturalWidth) { + return; + } + + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + ctx.clearRect(0, 0, width, height); + if (pixelCrop.width === 0 || pixelCrop.height === 0) { + // Why: drawImage with a 0-sized source rect throws IndexSizeError in node-canvas / older Safari. + return; + } + if (circle) { + ctx.save(); + ctx.beginPath(); + ctx.ellipse(width / 2, height / 2, width / 2, height / 2, 0, 0, Math.PI * 2); + ctx.clip(); + } + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + const z = zoom > 0 ? zoom : 1; + ctx.drawImage( + image, + (pixelCrop.x / z) * scaleX, + (pixelCrop.y / z) * scaleY, + (pixelCrop.width / z) * scaleX, + (pixelCrop.height / z) * scaleY, + 0, + 0, + width, + height + ); + if (circle) { + ctx.restore(); + } + }, [image, pixelCrop, zoom, width, height, circle]); + + return ; +} diff --git a/packages/pluggableWidgets/image-crop-web/src/components/ZoomSlider.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ZoomSlider.tsx new file mode 100644 index 0000000000..b05196b7ef --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/ZoomSlider.tsx @@ -0,0 +1,24 @@ +import { ChangeEvent, ReactElement } from "react"; + +interface ZoomSliderProps { + zoom: number; + minZoom: number; + maxZoom: number; + onChange: (zoom: number) => void; +} + +export function ZoomSlider({ zoom, minZoom, maxZoom, onChange }: ZoomSliderProps): ReactElement { + return ( + + ); +} From 21326b0a537ad2472ee807627d1851d431c61eb7 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 09:39:44 +0200 Subject: [PATCH 32/42] feat(image-crop-web): add CropArea with ReactCrop + zoom wrapper --- .../src/components/CropArea.tsx | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx diff --git a/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx new file mode 100644 index 0000000000..b7a0e4c321 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx @@ -0,0 +1,74 @@ +import classNames from "classnames"; +import { ReactElement, RefObject, SyntheticEvent, useState } from "react"; +import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop"; +import { WheelZoomModeEnum } from "../../typings/ImageCropProps"; +import { useWheelZoom } from "../hooks/useWheelZoom"; + +interface CropAreaProps { + src: string; + crop: Crop | undefined; + onCropChange: (crop: Crop) => void; + onCropComplete: (pixelCrop: PixelCrop) => void; + aspect: number | undefined; + circular: boolean; + resizable: boolean; + boundaryWidth: number; + boundaryHeight: number; + zoom: number; + minZoom: number; + maxZoom: number; + setZoom: (z: number) => void; + wheelZoomMode: WheelZoomModeEnum; + imageRef: RefObject; +} + +export function CropArea(props: CropAreaProps): ReactElement { + const [loadError, setLoadError] = useState(false); + const onWheel = useWheelZoom({ + mode: props.wheelZoomMode, + zoom: props.zoom, + minZoom: props.minZoom, + maxZoom: props.maxZoom, + setZoom: props.setZoom + }); + + if (loadError) { + return ( +
+ Image source does not allow cropping. Upload locally or configure CORS. +
+ ); + } + + return ( +
+ props.onCropChange(percent)} + onComplete={pixel => props.onCropComplete(pixel)} + aspect={props.aspect} + circularCrop={props.circular} + disabled={!props.resizable} + locked={!props.resizable} + keepSelection + > + { + props.imageRef.current = img; + }} + src={props.src} + alt="" + crossOrigin="anonymous" + style={{ transform: `scale(${props.zoom})`, transformOrigin: "center" }} + onError={(_e: SyntheticEvent) => setLoadError(true)} + /> + +
+ ); +} From 9a74a5f6e7f3506855aafdfcd5675b66c69904d4 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 09:41:50 +0200 Subject: [PATCH 33/42] feat(image-crop-web): assemble container with state, area, preview, button --- .../image-crop-web/src/ImageCrop.tsx | 5 +- .../src/components/ImageCropContainer.tsx | 103 ++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx index 4352e42929..3820f76d83 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx @@ -1,6 +1,7 @@ import { ReactElement } from "react"; import { ImageCropContainerProps } from "../typings/ImageCropProps"; +import { ImageCropContainer } from "./components/ImageCropContainer"; -export function ImageCrop(_props: ImageCropContainerProps): ReactElement | null { - return null; +export function ImageCrop(props: ImageCropContainerProps): ReactElement | null { + return ; } diff --git a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx new file mode 100644 index 0000000000..f7574ed862 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx @@ -0,0 +1,103 @@ +import classNames from "classnames"; +import { ValueStatus } from "mendix"; +import { ReactElement, useCallback } from "react"; +import { CropArea } from "./CropArea"; +import { CropButton } from "./CropButton"; +import { PreviewPane } from "./PreviewPane"; +import { ZoomSlider } from "./ZoomSlider"; +import { ImageCropContainerProps } from "../../typings/ImageCropProps"; +import { useImageCropState } from "../hooks/useImageCropState"; +import { resolveAspectRatio } from "../utils/aspectRatio"; +import { cropImage, CropError } from "../utils/cropImage"; + +export function ImageCropContainer(props: ImageCropContainerProps): ReactElement | null { + const state = useImageCropState(Number(props.minZoom)); + + const handleCrop = useCallback(async () => { + const img = state.imageRef.current; + if (!img || !state.completedCrop || props.image.readOnly || props.image.status !== ValueStatus.Available) { + return; + } + try { + const file = await cropImage({ + image: img, + pixelCrop: state.completedCrop, + zoom: state.zoom, + outputFormat: props.outputFormat, + outputQuality: Number(props.outputQuality ?? 0.92), + outputSize: props.outputSize, + cropShape: props.cropShape, + viewportWidth: props.boundaryWidth, + viewportHeight: props.boundaryHeight + }); + if (props.outputSize === "viewport") { + props.image.setThumbnailSize(props.boundaryWidth, props.boundaryHeight); + } + props.image.setValue(file); + if (props.onCropAction?.canExecute) { + props.onCropAction.execute(); + } + } catch (err) { + if (err instanceof CropError) { + console.error("[image-crop-web]", err.message); + } else { + throw err; + } + } + }, [state, props]); + + if (props.image.status === ValueStatus.Loading) { + return
; + } + if (props.image.status !== ValueStatus.Available || !props.image.value) { + return
No image
; + } + + const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth ?? 0, props.customAspectHeight ?? 0); + const caption = props.cropButtonCaption?.value ?? "Crop"; + + return ( +
+ + {props.zoomEnabled ? ( + + ) : null} + {props.showPreview ? ( + + ) : null} + +
+ ); +} From dee55bcd2f0cdd620636cfe3cab16ba319e4802a Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 09:45:10 +0200 Subject: [PATCH 34/42] feat(image-crop-web): bundle ReactCrop CSS via SCSS import --- .../image-crop-web/src/ImageCrop.tsx | 1 + .../image-crop-web/src/ui/ImageCrop.scss | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx index 3820f76d83..1166d68880 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.tsx @@ -1,6 +1,7 @@ import { ReactElement } from "react"; import { ImageCropContainerProps } from "../typings/ImageCropProps"; import { ImageCropContainer } from "./components/ImageCropContainer"; +import "./ui/ImageCrop.scss"; export function ImageCrop(props: ImageCropContainerProps): ReactElement | null { return ; diff --git a/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss b/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss new file mode 100644 index 0000000000..40fe5d6e8f --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss @@ -0,0 +1,52 @@ +@import "react-image-crop/dist/ReactCrop.css"; + +.widget-image-crop { + display: inline-flex; + flex-direction: column; + gap: 8px; + + &__canvas { + position: relative; + overflow: hidden; + background: #f5f5f5; + + img { + display: block; + max-width: 100%; + transition: transform 80ms linear; + } + + &--circle .ReactCrop__crop-selection { + border-radius: 50%; + } + } + + &__zoom { + display: flex; + align-items: center; + gap: 8px; + + input[type="range"] { + flex: 1; + } + } + + &__preview { + border: 1px solid #ddd; + background: #fff; + } + + &__button { + align-self: flex-start; + } + + &__error, + &--empty { + padding: 8px; + color: #b00; + } + + &--loading { + min-height: 200px; + } +} From a77ce2eebf3fcc8131592471a503bad271c950f6 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 09:51:06 +0200 Subject: [PATCH 35/42] test(image-crop-web): add container RTL tests for state guards --- .../src/__tests__/ImageCrop.spec.tsx | 76 +++++++++++++++++++ .../image-crop-web/tsconfig.json | 2 +- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx diff --git a/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx new file mode 100644 index 0000000000..60bb475ce1 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx @@ -0,0 +1,76 @@ +import { ValueStatus } from "mendix"; +import { render, screen } from "@testing-library/react"; +import { ImageCrop } from "../ImageCrop"; +import type { ImageCropContainerProps } from "../../typings/ImageCropProps"; + +function makeImageProp(overrides: Partial = {}): any { + return { + status: ValueStatus.Available, + value: { uri: "http://localhost/img.png" }, + readOnly: false, + validation: undefined, + setValidator: jest.fn(), + setValue: jest.fn(), + setThumbnailSize: jest.fn(), + ...overrides + }; +} + +function makeProps(overrides: Partial = {}): ImageCropContainerProps { + const base: any = { + name: "imageCrop", + class: "", + style: undefined, + tabIndex: 0, + image: makeImageProp(), + cropShape: "rect", + aspectRatio: "free", + customAspectWidth: 1, + customAspectHeight: 1, + boundaryWidth: 300, + boundaryHeight: 300, + resizableEnabled: true, + zoomEnabled: true, + wheelZoomMode: "onWithCtrl", + minZoom: 1, + maxZoom: 4, + showPreview: false, + previewWidth: 100, + previewHeight: 100, + outputFormat: "png", + outputQuality: 0.92, + outputSize: "original", + cropButtonCaption: { value: "Crop", status: ValueStatus.Available }, + onCropAction: { canExecute: true, execute: jest.fn(), isExecuting: false } + }; + return { ...base, ...overrides } as ImageCropContainerProps; +} + +describe("", () => { + test("renders skeleton while image is loading", () => { + const props = makeProps({ image: makeImageProp({ status: ValueStatus.Loading, value: undefined }) }); + const { container } = render(); + expect(container.querySelector(".widget-image-crop--loading")).not.toBeNull(); + }); + + test("renders empty state when image has no value", () => { + const props = makeProps({ image: makeImageProp({ value: undefined }) }); + render(); + expect(screen.getByText("No image")).toBeInTheDocument(); + }); + + test("disables Crop button when image is read-only", () => { + const props = makeProps({ image: makeImageProp({ readOnly: true }) }); + render(); + const btn = screen.getByRole("button", { name: "Crop" }); + expect(btn).toBeDisabled(); + }); + + test("Crop button is disabled until a completedCrop exists", () => { + const props = makeProps(); + const { container } = render(); + const btn = container.querySelector("button.widget-image-crop__button"); + expect(btn).not.toBeNull(); + expect(btn).toBeDisabled(); + }); +}); diff --git a/packages/pluggableWidgets/image-crop-web/tsconfig.json b/packages/pluggableWidgets/image-crop-web/tsconfig.json index 63d18d877f..3296cb98f5 100644 --- a/packages/pluggableWidgets/image-crop-web/tsconfig.json +++ b/packages/pluggableWidgets/image-crop-web/tsconfig.json @@ -7,7 +7,7 @@ "module": "esnext", "target": "es6", "lib": ["esnext", "dom"], - "types": ["jest", "node"], + "types": ["jest", "node", "testing-library__jest-dom"], "moduleResolution": "node", "declaration": false, "noLib": false, From 5f134608dd9b506d96cf29d050a2aedd53deca02 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 15:14:33 +0200 Subject: [PATCH 36/42] fix(image-crop-web): make primitive props required per Mendix XSD --- .../pluggableWidgets/image-crop-web/src/ImageCrop.xml | 10 +++++----- .../src/components/ImageCropContainer.tsx | 8 ++++---- .../image-crop-web/typings/ImageCropProps.d.ts | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml index 91dc440a83..a3e490c1b4 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml @@ -32,11 +32,11 @@ Custom - + Custom aspect width - + Custom aspect height @@ -81,11 +81,11 @@ Show preview - + Preview width (px) - + Preview height (px) @@ -99,7 +99,7 @@ JPEG - + JPEG quality (0.0 - 1.0) diff --git a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx index f7574ed862..fb15af797f 100644 --- a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx @@ -24,7 +24,7 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement pixelCrop: state.completedCrop, zoom: state.zoom, outputFormat: props.outputFormat, - outputQuality: Number(props.outputQuality ?? 0.92), + outputQuality: Number(props.outputQuality), outputSize: props.outputSize, cropShape: props.cropShape, viewportWidth: props.boundaryWidth, @@ -53,7 +53,7 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement return
No image
; } - const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth ?? 0, props.customAspectHeight ?? 0); + const aspect = resolveAspectRatio(props.aspectRatio, props.customAspectWidth, props.customAspectHeight); const caption = props.cropButtonCaption?.value ?? "Crop"; return ( @@ -88,8 +88,8 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement image={state.imageRef.current} pixelCrop={state.completedCrop} zoom={state.zoom} - width={props.previewWidth ?? 100} - height={props.previewHeight ?? 100} + width={props.previewWidth} + height={props.previewHeight} circle={props.cropShape === "circle"} /> ) : null} diff --git a/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts b/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts index 723099d423..9e946fb1fe 100644 --- a/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts +++ b/packages/pluggableWidgets/image-crop-web/typings/ImageCropProps.d.ts @@ -25,8 +25,8 @@ export interface ImageCropContainerProps { image: EditableImageValue; cropShape: CropShapeEnum; aspectRatio: AspectRatioEnum; - customAspectWidth?: number; - customAspectHeight?: number; + customAspectWidth: number; + customAspectHeight: number; boundaryWidth: number; boundaryHeight: number; resizableEnabled: boolean; @@ -35,10 +35,10 @@ export interface ImageCropContainerProps { minZoom: Big; maxZoom: Big; showPreview: boolean; - previewWidth?: number; - previewHeight?: number; + previewWidth: number; + previewHeight: number; outputFormat: OutputFormatEnum; - outputQuality?: Big; + outputQuality: Big; outputSize: OutputSizeEnum; cropButtonCaption?: DynamicValue; onCropAction?: ActionValue; From 822ecad69e5744a21fb8929ddd8b33a02c9caae5 Mon Sep 17 00:00:00 2001 From: Rahman Date: Tue, 26 May 2026 16:56:02 +0200 Subject: [PATCH 37/42] docs(image-crop-web): add property descriptions for Studio Pro tooltips --- .../image-crop-web/src/ImageCrop.xml | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml index a3e490c1b4..3d348ae921 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml @@ -14,7 +14,7 @@ Crop shape - + Geometry of the crop selection. Rectangle keeps straight edges; Circle masks the result with a round cutout (transparent corners on PNG, white corners on JPEG). Rectangle Circle @@ -22,7 +22,7 @@ Aspect ratio - + Locks the proportions of the crop selection while the user drags. Choose Free to let the user resize without constraint, a preset, or Custom to define your own ratio below. Free 1:1 @@ -34,33 +34,33 @@ Custom aspect width - + Numerator of the custom ratio (e.g. 3 in a 3:2 crop). Only used when Aspect ratio is set to Custom. Custom aspect height - + Denominator of the custom ratio (e.g. 2 in a 3:2 crop). Only used when Aspect ratio is set to Custom. Canvas width (px) - + On-screen width of the crop area in the page. The image is fit inside this box; it does not affect the resolution of the saved crop. Canvas height (px) - + On-screen height of the crop area in the page. The image is fit inside this box; it does not affect the resolution of the saved crop. Resizable handles - Allow the user to resize the crop area by dragging its corners. + If on, the user can drag the corners of the selection to resize it. If off, the selection stays at its initial size and can only be moved. Enable zoom - + If on, a zoom slider is shown below the crop area so the user can scale the image up or down. If off, the image is always shown at 1× (fit-to-canvas). Mouse wheel zoom - + Controls whether scrolling the mouse wheel over the image changes the zoom. "On (hold Ctrl)" is recommended so normal page scrolling still works. Off On @@ -69,31 +69,31 @@ Minimum zoom - + Smallest zoom factor the user can reach. 1 means "fit-to-canvas" (no shrinking below the visible area). Values below 1 let the user zoom out so the image is smaller than the canvas; values above 1 force the image to start zoomed-in. Maximum zoom - + Largest zoom factor the user can reach. For example, 4 means the image can be magnified up to 4× its fit-to-canvas size, which is useful for cropping fine detail. Must be greater than Minimum zoom. Show preview - + If on, a small live thumbnail of the current crop selection is shown next to the canvas. Useful to confirm the framing before pressing the Crop button. Preview width (px) - + On-screen width of the live preview thumbnail. Has no effect on the saved image. Preview height (px) - + On-screen height of the live preview thumbnail. Has no effect on the saved image. Output format - + File format of the saved crop. PNG preserves transparency (best for circle crops) but produces larger files; JPEG is smaller but has no transparency and applies lossy compression. PNG JPEG @@ -101,11 +101,11 @@ JPEG quality (0.0 - 1.0) - + Compression level for JPEG output. 1.0 keeps the most detail (largest file); 0.5 is roughly average web quality. Ignored when Output format is PNG. Output size - + Resolution of the saved crop. "Original" preserves the pixels from the source image (sharpest, larger file). "Viewport" downscales the crop to the on-screen canvas size — useful for avatars and thumbnails. Viewport (canvas dims) Original (source resolution) @@ -115,7 +115,7 @@ Crop button caption - + Label shown on the button that commits the crop. Defaults to "Crop"; override for translation or custom wording (e.g. "Save avatar"). Crop From b19bb26c6a9fa653f8e3683dd776c122684edb8c Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 27 May 2026 15:50:07 +0200 Subject: [PATCH 38/42] feat(image-crop-web): add Studio Pro structure preview and pre-PR polish Adds getPreview structure tile (icon + caption with shape/aspect/output), fit-and-scale canvas sizing in CropArea (no more gaps after horizontal crop), DPR-aware PreviewPane buffer, exhaustiveness guard in aspectRatio, and required="true" on three XML booleans. SVG asset replaces inline data URL. Co-Authored-By: Claude Sonnet 4.6 --- .../src/ImageCrop.editorConfig.ts | 92 +++++++++++++++ .../src/ImageCrop.editorPreview.tsx | 14 ++- .../image-crop-web/src/ImageCrop.xml | 54 ++++----- .../src/__tests__/ImageCrop.spec.tsx | 50 ++++++++- .../image-crop-web/src/assets/crop-icon.svg | 8 ++ .../src/components/CropArea.tsx | 106 ++++++++++++++++-- .../src/components/ImageCropContainer.tsx | 38 ++++++- .../src/components/PreviewPane.tsx | 8 +- .../src/hooks/__tests__/useWheelZoom.spec.ts | 73 ++++++------ .../image-crop-web/src/hooks/useWheelZoom.ts | 20 ++-- .../image-crop-web/src/ui/ImageCrop.scss | 40 ++++++- .../src/utils/__tests__/cropImage.spec.ts | 62 ++++++---- .../image-crop-web/src/utils/aspectRatio.ts | 4 + .../image-crop-web/src/utils/cropImage.ts | 7 +- 14 files changed, 458 insertions(+), 118 deletions(-) create mode 100644 packages/pluggableWidgets/image-crop-web/src/assets/crop-icon.svg diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts index ae53416874..16c4a6ad61 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorConfig.ts @@ -1,5 +1,10 @@ import { hidePropertiesIn, Properties } from "@mendix/pluggable-widgets-tools"; +import { + StructurePreviewProps, + structurePreviewPalette +} from "@mendix/widget-plugin-platform/preview/structure-preview-api"; import { ImageCropPreviewProps } from "../typings/ImageCropProps"; +import CropIconSvg from "./assets/crop-icon.svg"; export function getProperties(values: ImageCropPreviewProps, defaultProperties: Properties): Properties { const propsToHide: Array = []; @@ -23,3 +28,90 @@ export function getProperties(values: ImageCropPreviewProps, defaultProperties: hidePropertiesIn(defaultProperties, values, propsToHide); return defaultProperties; } + +export function getPreview(values: ImageCropPreviewProps, isDarkMode: boolean): StructurePreviewProps { + const palette = structurePreviewPalette[isDarkMode ? "dark" : "light"]; + const iconDocument = decodeURIComponent(CropIconSvg.replace("data:image/svg+xml,", "")); + + return { + type: "Container", + borders: true, + borderRadius: 4, + backgroundColor: palette.background.containerFill, + children: [ + { + type: "RowLayout", + columnSize: "grow", + padding: 12, + children: [ + { + type: "Container", + grow: 0, + padding: 4, + children: [ + { + type: "Image", + document: iconDocument, + width: 28, + height: 22 + } + ] + }, + { + type: "Container", + grow: 1, + children: [ + { + type: "Text", + content: "Image Crop", + bold: true, + fontColor: palette.text.primary, + fontSize: 10 + }, + { + type: "Text", + content: describeConfig(values), + fontColor: palette.text.secondary, + fontSize: 8 + } + ] + } + ] + } + ] + }; +} + +export function getCustomCaption(values: ImageCropPreviewProps): string { + const shape = values.cropShape === "circle" ? "Circle" : "Rectangle"; + return `Image Crop (${shape})`; +} + +function describeConfig(values: ImageCropPreviewProps): string { + const parts: string[] = []; + parts.push(values.cropShape === "circle" ? "Circle" : "Rectangle"); + parts.push(aspectLabel(values)); + parts.push(`${values.outputFormat.toUpperCase()} · ${values.outputSize === "viewport" ? "Viewport" : "Original"}`); + return parts.join(" · "); +} + +function aspectLabel(values: ImageCropPreviewProps): string { + switch (values.aspectRatio) { + case "free": + return "Free aspect"; + case "square": + return "1:1"; + case "landscape16x9": + return "16:9"; + case "landscape4x3": + return "4:3"; + case "portrait3x4": + return "3:4"; + case "custom": + return `${values.customAspectWidth}:${values.customAspectHeight}`; + default: { + const _exhaustive: never = values.aspectRatio; + return _exhaustive; + } + } +} diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx index fd831d3ac4..636598f663 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.editorPreview.tsx @@ -1,10 +1,18 @@ +import classNames from "classnames"; import { ReactElement } from "react"; import { ImageCropPreviewProps } from "../typings/ImageCropProps"; -export function preview(_props: ImageCropPreviewProps): ReactElement { - return
Image Crop
; +export function preview(props: ImageCropPreviewProps): ReactElement { + return ( +
+
+
+

Image Crop

+
+
+ ); } export function getPreviewCss(): string { - return ""; + return require("./ui/ImageCrop.scss"); } diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml index 3d348ae921..8a47edf692 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml @@ -8,13 +8,13 @@ Image attribute - Editable image attribute. The cropped image overwrites this attribute. + The image to crop. The cropped result is saved back to it. Crop shape - Geometry of the crop selection. Rectangle keeps straight edges; Circle masks the result with a round cutout (transparent corners on PNG, white corners on JPEG). + Shape of the crop. Circle masks the corners. Rectangle Circle @@ -22,7 +22,7 @@ Aspect ratio - Locks the proportions of the crop selection while the user drags. Choose Free to let the user resize without constraint, a preset, or Custom to define your own ratio below. + Locks the crop proportions. Free lets the user resize freely. Free 1:1 @@ -34,33 +34,33 @@ Custom aspect width - Numerator of the custom ratio (e.g. 3 in a 3:2 crop). Only used when Aspect ratio is set to Custom. + Width side of the ratio (e.g. 3 in 3:2). Used when Aspect ratio is Custom. Custom aspect height - Denominator of the custom ratio (e.g. 2 in a 3:2 crop). Only used when Aspect ratio is set to Custom. + Height side of the ratio (e.g. 2 in 3:2). Used when Aspect ratio is Custom. - - Canvas width (px) - On-screen width of the crop area in the page. The image is fit inside this box; it does not affect the resolution of the saved crop. + + Canvas max width (px) + Maximum on-screen width of the crop area. The image scales down to fit; the canvas wraps the rendered image, so smaller crops produce a smaller canvas with no blank gaps. Does not change the saved image size. - - Canvas height (px) - On-screen height of the crop area in the page. The image is fit inside this box; it does not affect the resolution of the saved crop. + + Canvas max height (px) + Maximum on-screen height of the crop area. The image scales down to fit; the canvas wraps the rendered image, so smaller crops produce a smaller canvas with no blank gaps. Does not change the saved image size. - + Resizable handles - If on, the user can drag the corners of the selection to resize it. If off, the selection stays at its initial size and can only be moved. + Let the user resize the selection by dragging its corners. - + Enable zoom - If on, a zoom slider is shown below the crop area so the user can scale the image up or down. If off, the image is always shown at 1× (fit-to-canvas). + Show a zoom slider below the crop area. Mouse wheel zoom - Controls whether scrolling the mouse wheel over the image changes the zoom. "On (hold Ctrl)" is recommended so normal page scrolling still works. + Whether the mouse wheel zooms the image. "On (hold Ctrl)" keeps page scroll working. Off On @@ -69,31 +69,31 @@ Minimum zoom - Smallest zoom factor the user can reach. 1 means "fit-to-canvas" (no shrinking below the visible area). Values below 1 let the user zoom out so the image is smaller than the canvas; values above 1 force the image to start zoomed-in. + Smallest zoom level. 1 = image fits the canvas. Below 1 lets the user zoom out further. Maximum zoom - Largest zoom factor the user can reach. For example, 4 means the image can be magnified up to 4× its fit-to-canvas size, which is useful for cropping fine detail. Must be greater than Minimum zoom. + Largest zoom level. 4 means up to 4× the canvas size. Must be greater than Minimum zoom. - + Show preview - If on, a small live thumbnail of the current crop selection is shown next to the canvas. Useful to confirm the framing before pressing the Crop button. + Show a live thumbnail of the current crop next to the canvas. Preview width (px) - On-screen width of the live preview thumbnail. Has no effect on the saved image. + Width of the preview thumbnail. Preview height (px) - On-screen height of the live preview thumbnail. Has no effect on the saved image. + Height of the preview thumbnail. Output format - File format of the saved crop. PNG preserves transparency (best for circle crops) but produces larger files; JPEG is smaller but has no transparency and applies lossy compression. + File format. PNG keeps transparency; JPEG produces smaller files. PNG JPEG @@ -101,11 +101,11 @@ JPEG quality (0.0 - 1.0) - Compression level for JPEG output. 1.0 keeps the most detail (largest file); 0.5 is roughly average web quality. Ignored when Output format is PNG. + JPEG compression. Higher = sharper and larger. Ignored for PNG. Output size - Resolution of the saved crop. "Original" preserves the pixels from the source image (sharpest, larger file). "Viewport" downscales the crop to the on-screen canvas size — useful for avatars and thumbnails. + Resolution of the saved crop. Original is sharpest; Viewport matches the on-screen canvas size. Viewport (canvas dims) Original (source resolution) @@ -115,14 +115,14 @@ Crop button caption - Label shown on the button that commits the crop. Defaults to "Crop"; override for translation or custom wording (e.g. "Save avatar"). + Label on the Crop button. Crop On crop - Action executed after the cropped image is committed. + Runs after the cropped image is saved. diff --git a/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx index 60bb475ce1..5124639092 100644 --- a/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx @@ -1,7 +1,7 @@ +import { fireEvent, render, screen } from "@testing-library/react"; import { ValueStatus } from "mendix"; -import { render, screen } from "@testing-library/react"; -import { ImageCrop } from "../ImageCrop"; import type { ImageCropContainerProps } from "../../typings/ImageCropProps"; +import { ImageCrop } from "../ImageCrop"; function makeImageProp(overrides: Partial = {}): any { return { @@ -66,11 +66,53 @@ describe("", () => { expect(btn).toBeDisabled(); }); - test("Crop button is disabled until a completedCrop exists", () => { + test("Crop button is enabled after image loads (initial crop auto-set)", () => { const props = makeProps(); const { container } = render(); + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + fireEvent.load(img!); const btn = container.querySelector("button.widget-image-crop__button"); expect(btn).not.toBeNull(); - expect(btn).toBeDisabled(); + expect(btn).not.toBeDisabled(); + }); + + test("before load, image is bounded by boundary as max-width/max-height ceiling", () => { + const props = makeProps({ boundaryWidth: 800, boundaryHeight: 600 }); + const { container } = render(); + const img = container.querySelector("img") as HTMLImageElement | null; + expect(img).not.toBeNull(); + expect(img!.style.maxWidth).toBe("800px"); + expect(img!.style.maxHeight).toBe("600px"); + }); + + test("after load, image gets fit-and-scaled pixel dims; canvas wraps via inline-block + ceiling", () => { + const props = makeProps({ boundaryWidth: 800, boundaryHeight: 600 }); + const { container } = render(); + const img = container.querySelector("img") as HTMLImageElement; + Object.defineProperty(img, "naturalWidth", { value: 400, configurable: true }); + Object.defineProperty(img, "naturalHeight", { value: 300, configurable: true }); + fireEvent.load(img); + const canvas = container.querySelector(".widget-image-crop__canvas") as HTMLDivElement; + expect(img.style.width).toBe("800px"); + expect(img.style.height).toBe("600px"); + expect(canvas.style.maxWidth).toBe("800px"); + expect(canvas.style.maxHeight).toBe("600px"); + }); + + test("crop is cleared between image src change and next load (button disabled)", () => { + const props = makeProps({ image: makeImageProp({ value: { uri: "http://localhost/img1.png" } }) }); + const { container, rerender } = render(); + const img1 = container.querySelector("img"); + fireEvent.load(img1!); + expect(container.querySelector("button.widget-image-crop__button")).not.toBeDisabled(); + + const newProps = makeProps({ image: makeImageProp({ value: { uri: "http://localhost/img2.png" } }) }); + rerender(); + expect(container.querySelector("button.widget-image-crop__button")).toBeDisabled(); + + const img2 = container.querySelector("img"); + fireEvent.load(img2!); + expect(container.querySelector("button.widget-image-crop__button")).not.toBeDisabled(); }); }); diff --git a/packages/pluggableWidgets/image-crop-web/src/assets/crop-icon.svg b/packages/pluggableWidgets/image-crop-web/src/assets/crop-icon.svg new file mode 100644 index 0000000000..534cf020b2 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/assets/crop-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx index b7a0e4c321..8b6843193d 100644 --- a/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx @@ -1,6 +1,23 @@ import classNames from "classnames"; -import { ReactElement, RefObject, SyntheticEvent, useState } from "react"; -import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop"; +import { + ReactElement, + RefObject, + SyntheticEvent, + useCallback, + useEffect, + useRef, + useState, + Dispatch, + SetStateAction +} from "react"; +import { + default as ReactCrop, + centerCrop, + convertToPixelCrop, + makeAspectCrop, + type Crop, + type PixelCrop +} from "react-image-crop"; import { WheelZoomModeEnum } from "../../typings/ImageCropProps"; import { useWheelZoom } from "../hooks/useWheelZoom"; @@ -14,24 +31,87 @@ interface CropAreaProps { resizable: boolean; boundaryWidth: number; boundaryHeight: number; + onImageLoad: (percentCrop: Crop, pixelCrop: PixelCrop) => void; zoom: number; minZoom: number; maxZoom: number; - setZoom: (z: number) => void; + setZoom: Dispatch>; wheelZoomMode: WheelZoomModeEnum; imageRef: RefObject; } +function buildInitialCrop( + img: HTMLImageElement, + aspect: number | undefined +): { percentCrop: Crop; pixelCrop: PixelCrop } { + const { naturalWidth, naturalHeight, width, height } = img; + const safeAspect = aspect ?? naturalWidth / naturalHeight; + const percentCrop = centerCrop( + makeAspectCrop({ unit: "%", width: 80 }, safeAspect, naturalWidth, naturalHeight), + naturalWidth, + naturalHeight + ); + return { percentCrop, pixelCrop: convertToPixelCrop(percentCrop, width, height) }; +} + +function fitToBoundary( + naturalWidth: number, + naturalHeight: number, + boundaryWidth: number, + boundaryHeight: number +): { width: number; height: number } { + if (naturalWidth <= 0 || naturalHeight <= 0) { + return { width: boundaryWidth, height: boundaryHeight }; + } + const scale = Math.min(boundaryWidth / naturalWidth, boundaryHeight / naturalHeight); + return { width: Math.round(naturalWidth * scale), height: Math.round(naturalHeight * scale) }; +} + export function CropArea(props: CropAreaProps): ReactElement { const [loadError, setLoadError] = useState(false); + const [displaySize, setDisplaySize] = useState<{ width: number; height: number } | null>(null); + const containerRef = useRef(null); const onWheel = useWheelZoom({ mode: props.wheelZoomMode, - zoom: props.zoom, minZoom: props.minZoom, maxZoom: props.maxZoom, setZoom: props.setZoom }); + useEffect(() => { + const el = containerRef.current; + if (!el) { + return; + } + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [onWheel]); + + const { aspect, onImageLoad, imageRef, boundaryWidth, boundaryHeight, src } = props; + + const [prevSrc, setPrevSrc] = useState(src); + if (prevSrc !== src) { + setPrevSrc(src); + setDisplaySize(null); + } + + const handleImageLoad = useCallback( + (e: SyntheticEvent) => { + const img = e.currentTarget; + setDisplaySize(fitToBoundary(img.naturalWidth, img.naturalHeight, boundaryWidth, boundaryHeight)); + const { percentCrop, pixelCrop } = buildInitialCrop(img, aspect); + onImageLoad(percentCrop, pixelCrop); + }, + [aspect, onImageLoad, boundaryWidth, boundaryHeight] + ); + + const setImageRef = useCallback( + (img: HTMLImageElement | null) => { + imageRef.current = img; + }, + [imageRef] + ); + if (loadError) { return (
@@ -42,11 +122,11 @@ export function CropArea(props: CropAreaProps): ReactElement { return (
{ - props.imageRef.current = img; - }} + ref={setImageRef} src={props.src} alt="" crossOrigin="anonymous" - style={{ transform: `scale(${props.zoom})`, transformOrigin: "center" }} + style={{ + width: displaySize?.width, + height: displaySize?.height, + maxWidth: displaySize ? undefined : props.boundaryWidth, + maxHeight: displaySize ? undefined : props.boundaryHeight, + transform: `scale(${props.zoom})`, + transformOrigin: "center" + }} + onLoad={handleImageLoad} onError={(_e: SyntheticEvent) => setLoadError(true)} /> diff --git a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx index fb15af797f..2202bf066e 100644 --- a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx @@ -1,6 +1,7 @@ import classNames from "classnames"; import { ValueStatus } from "mendix"; -import { ReactElement, useCallback } from "react"; +import { ReactElement, useCallback, useEffect } from "react"; +import { type Crop, type PixelCrop } from "react-image-crop"; import { CropArea } from "./CropArea"; import { CropButton } from "./CropButton"; import { PreviewPane } from "./PreviewPane"; @@ -13,6 +14,23 @@ import { cropImage, CropError } from "../utils/cropImage"; export function ImageCropContainer(props: ImageCropContainerProps): ReactElement | null { const state = useImageCropState(Number(props.minZoom)); + const { setZoom, setCrop, setCompletedCrop } = state; + + const handleImageLoad = useCallback( + (percentCrop: Crop, pixelCrop: PixelCrop) => { + setZoom(Number(props.minZoom)); + setCrop(percentCrop); + setCompletedCrop(pixelCrop); + }, + [setZoom, setCrop, setCompletedCrop, props.minZoom] + ); + + const uri = props.image.status === ValueStatus.Available ? props.image.value?.uri : undefined; + useEffect(() => { + setCrop(undefined); + setCompletedCrop(undefined); + }, [uri, setCrop, setCompletedCrop]); + const handleCrop = useCallback(async () => { const img = state.imageRef.current; if (!img || !state.completedCrop || props.image.readOnly || props.image.status !== ValueStatus.Available) { @@ -28,7 +46,8 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement outputSize: props.outputSize, cropShape: props.cropShape, viewportWidth: props.boundaryWidth, - viewportHeight: props.boundaryHeight + viewportHeight: props.boundaryHeight, + originalName: props.image.value.name }); if (props.outputSize === "viewport") { props.image.setThumbnailSize(props.boundaryWidth, props.boundaryHeight); @@ -44,7 +63,19 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement throw err; } } - }, [state, props]); + }, [ + state.completedCrop, + state.zoom, + state.imageRef, + props.image, + props.outputFormat, + props.outputQuality, + props.outputSize, + props.cropShape, + props.boundaryWidth, + props.boundaryHeight, + props.onCropAction + ]); if (props.image.status === ValueStatus.Loading) { return
; @@ -68,6 +99,7 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement resizable={props.resizableEnabled} boundaryWidth={props.boundaryWidth} boundaryHeight={props.boundaryHeight} + onImageLoad={handleImageLoad} zoom={state.zoom} minZoom={Number(props.minZoom)} maxZoom={Number(props.maxZoom)} diff --git a/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx b/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx index 6bbd45ac8c..e2548df811 100644 --- a/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx @@ -19,12 +19,16 @@ export function PreviewPane({ image, pixelCrop, zoom, width, height, circle }: P return; } - canvas.width = width; - canvas.height = height; + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; const ctx = canvas.getContext("2d"); if (!ctx) { return; } + ctx.scale(dpr, dpr); ctx.clearRect(0, 0, width, height); if (pixelCrop.width === 0 || pixelCrop.height === 0) { // Why: drawImage with a 0-sized source rect throws IndexSizeError in node-canvas / older Safari. diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts index e14e0a41ec..efccf0e83b 100644 --- a/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/__tests__/useWheelZoom.spec.ts @@ -1,76 +1,77 @@ import { renderHook, act } from "@testing-library/react"; -import type { WheelEvent } from "react"; import { useWheelZoom } from "../useWheelZoom"; -function makeWheelEvent(deltaY: number, ctrlKey = false): WheelEvent { - return { - deltaY, - ctrlKey, - metaKey: false, - preventDefault: jest.fn(), - stopPropagation: jest.fn() - } as unknown as WheelEvent; +function makeWheelEvent(deltaY: number, ctrlKey = false): globalThis.WheelEvent { + return new globalThis.WheelEvent("wheel", { deltaY, ctrlKey, bubbles: true, cancelable: true }); +} + +function makeSetZoom(initial: number): { setZoom: jest.Mock; getZoom: () => number } { + let current = initial; + const setZoom = jest.fn((updater: ((prev: number) => number) | number) => { + current = typeof updater === "function" ? updater(current) : updater; + }); + return { setZoom, getZoom: () => current }; } describe("useWheelZoom", () => { test("mode 'off' does nothing", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => useWheelZoom({ mode: "off", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + const { setZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "off", minZoom: 1, maxZoom: 4, setZoom })); const e = makeWheelEvent(-100); + const spy = jest.spyOn(e, "preventDefault"); act(() => result.current(e)); expect(setZoom).not.toHaveBeenCalled(); - expect(e.preventDefault).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); test("mode 'on' zooms in on negative deltaY", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + const { setZoom, getZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); const e = makeWheelEvent(-100); + const spy = jest.spyOn(e, "preventDefault"); act(() => result.current(e)); - expect(setZoom).toHaveBeenCalledWith(1.1); - expect(e.preventDefault).toHaveBeenCalled(); + expect(getZoom()).toBe(1.1); + expect(spy).toHaveBeenCalled(); }); test("mode 'on' zooms out on positive deltaY", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 2, minZoom: 1, maxZoom: 4, setZoom })); + const { setZoom, getZoom } = makeSetZoom(2); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); act(() => result.current(makeWheelEvent(100))); - expect(setZoom).toHaveBeenCalledWith(1.8); + expect(getZoom()).toBe(1.8); }); test("mode 'onWithCtrl' ignores wheel without Ctrl/Meta", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => - useWheelZoom({ mode: "onWithCtrl", zoom: 1, minZoom: 1, maxZoom: 4, setZoom }) - ); + const { setZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "onWithCtrl", minZoom: 1, maxZoom: 4, setZoom })); const e = makeWheelEvent(-100, false); + const spy = jest.spyOn(e, "preventDefault"); act(() => result.current(e)); expect(setZoom).not.toHaveBeenCalled(); - expect(e.preventDefault).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); }); test("mode 'onWithCtrl' zooms when Ctrl is held", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => - useWheelZoom({ mode: "onWithCtrl", zoom: 1, minZoom: 1, maxZoom: 4, setZoom }) - ); + const { setZoom, getZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "onWithCtrl", minZoom: 1, maxZoom: 4, setZoom })); const e = makeWheelEvent(-100, true); + const spy = jest.spyOn(e, "preventDefault"); act(() => result.current(e)); - expect(setZoom).toHaveBeenCalledWith(1.1); - expect(e.preventDefault).toHaveBeenCalled(); + expect(getZoom()).toBe(1.1); + expect(spy).toHaveBeenCalled(); }); test("clamps to maxZoom", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 4, minZoom: 1, maxZoom: 4, setZoom })); + const { setZoom, getZoom } = makeSetZoom(4); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); act(() => result.current(makeWheelEvent(-100))); - expect(setZoom).toHaveBeenCalledWith(4); + expect(getZoom()).toBe(4); }); test("clamps to minZoom", () => { - const setZoom = jest.fn(); - const { result } = renderHook(() => useWheelZoom({ mode: "on", zoom: 1, minZoom: 1, maxZoom: 4, setZoom })); + const { setZoom, getZoom } = makeSetZoom(1); + const { result } = renderHook(() => useWheelZoom({ mode: "on", minZoom: 1, maxZoom: 4, setZoom })); act(() => result.current(makeWheelEvent(100))); - expect(setZoom).toHaveBeenCalledWith(1); + expect(getZoom()).toBe(1); }); }); diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts index f117d6e659..7cb7d2fe8b 100644 --- a/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/useWheelZoom.ts @@ -1,21 +1,20 @@ -import { WheelEvent, useCallback } from "react"; +import { Dispatch, SetStateAction, useCallback } from "react"; import { WheelZoomModeEnum } from "../../typings/ImageCropProps"; interface UseWheelZoomArgs { mode: WheelZoomModeEnum; - zoom: number; minZoom: number; maxZoom: number; - setZoom: (z: number) => void; + setZoom: Dispatch>; } const STEP = 0.1; -export function useWheelZoom(args: UseWheelZoomArgs): (e: WheelEvent) => void { - const { mode, zoom, minZoom, maxZoom, setZoom } = args; +export function useWheelZoom(args: UseWheelZoomArgs): (e: globalThis.WheelEvent) => void { + const { mode, minZoom, maxZoom, setZoom } = args; return useCallback( - (e: WheelEvent) => { + (e: globalThis.WheelEvent) => { if (mode === "off") { return; } @@ -24,10 +23,11 @@ export function useWheelZoom(args: UseWheelZoomArgs): (e: WheelEvent) => void { } e.preventDefault(); const direction = e.deltaY < 0 ? 1 : -1; - const next = zoom * (1 + STEP * direction); - const clamped = Math.min(maxZoom, Math.max(minZoom, Number(next.toFixed(4)))); - setZoom(clamped); + setZoom(prev => { + const next = prev * (1 + STEP * direction); + return Math.min(maxZoom, Math.max(minZoom, Number(next.toFixed(4)))); + }); }, - [mode, zoom, minZoom, maxZoom, setZoom] + [mode, minZoom, maxZoom, setZoom] ); } diff --git a/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss b/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss index 40fe5d6e8f..bbe6f2a7c9 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss +++ b/packages/pluggableWidgets/image-crop-web/src/ui/ImageCrop.scss @@ -1,18 +1,23 @@ @import "react-image-crop/dist/ReactCrop.css"; +$image-crop-bg-color: #f5f7fa; +$image-crop-border-color-default: #b0bec5; +$image-crop-gray-light: #6c757d; +$image-crop-icon: url(../assets/crop-icon.svg); + .widget-image-crop { display: inline-flex; flex-direction: column; gap: 8px; &__canvas { + display: inline-block; position: relative; overflow: hidden; background: #f5f5f5; img { display: block; - max-width: 100%; transition: transform 80ms linear; } @@ -28,6 +33,7 @@ input[type="range"] { flex: 1; + accent-color: var(--brand-primary, #264ae5); } } @@ -49,4 +55,36 @@ &--loading { min-height: 200px; } + + &--preview { + display: flex; + flex-direction: column; + + .widget-image-crop__dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + height: 106px; + padding: 12px 20px; + border-radius: 5px; + border: 1.5px dashed var(--border-color-default, $image-crop-border-color-default); + background-color: var(--bg-color, $image-crop-bg-color); + } + + .widget-image-crop__icon { + width: 42px; + height: 33px; + background-image: var(--image-crop-icon, $image-crop-icon); + background-repeat: no-repeat; + background-size: contain; + } + + .widget-image-crop__label { + margin: 0; + font-size: 11px; + color: var(--gray-light, $image-crop-gray-light); + } + } } diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts index e881a4e0f7..2d5a2fb578 100644 --- a/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts +++ b/packages/pluggableWidgets/image-crop-web/src/utils/__tests__/cropImage.spec.ts @@ -1,5 +1,5 @@ -import { cropImage, CropError } from "../cropImage"; import type { PixelCrop } from "react-image-crop"; +import { cropImage, CropError } from "../cropImage"; function makeImg(naturalW: number, naturalH: number, renderedW = naturalW, renderedH = naturalH): HTMLImageElement { const img = new Image(); @@ -66,22 +66,22 @@ describe("cropImage", () => { test("uses viewport dims as canvas size when outputSize is viewport", async () => { const img = makeImg(1000, 800); - const file = await cropImage({ - image: img, - pixelCrop: baseCrop, - zoom: 1, - outputFormat: "png", - outputQuality: 1, - outputSize: "viewport", - cropShape: "rect", - viewportWidth: 50, - viewportHeight: 40 - }); - const lastCanvas = (file as any)._lastCanvas as HTMLCanvasElement | undefined; - if (lastCanvas) { - expect(lastCanvas.width).toBe(50); - expect(lastCanvas.height).toBe(40); - } + const calls = await captureDrawImageCalls(() => + cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "viewport", + cropShape: "rect", + viewportWidth: 50, + viewportHeight: 40 + }) + ); + const ctx = calls[0].ctx as CanvasRenderingContext2D; + expect(ctx.canvas.width).toBe(50); + expect(ctx.canvas.height).toBe(40); }); test("divides source rect by zoom factor when zoom > 1", async () => { @@ -106,6 +106,23 @@ describe("cropImage", () => { expect(sh).toBe(100); }); + test("returns a valid File when cropShape is circle", async () => { + const img = makeImg(1000, 800); + const file = await cropImage({ + image: img, + pixelCrop: baseCrop, + zoom: 1, + outputFormat: "png", + outputQuality: 1, + outputSize: "original", + cropShape: "circle", + viewportWidth: 300, + viewportHeight: 300 + }); + expect(file).toBeInstanceOf(File); + expect(file.name.endsWith(".png")).toBe(true); + }); + test("rejects with CropError when toBlob returns null (tainted canvas)", async () => { const img = makeImg(1000, 800); const originalToBlob = HTMLCanvasElement.prototype.toBlob; @@ -132,12 +149,17 @@ describe("cropImage", () => { }); }); -async function captureDrawImageCalls(fn: () => Promise): Promise { +async function captureDrawImageCalls( + fn: () => Promise +): Promise> { const calls: any[] = []; const proto = CanvasRenderingContext2D.prototype as any; const original = proto.drawImage; - proto.drawImage = function (...args: any[]) { - calls.push(args); + proto.drawImage = function (this: CanvasRenderingContext2D, ...args: any[]) { + const entry: any = [...args]; + entry.ctx = this; + entry.args = args; + calls.push(entry); return original?.apply(this, args); }; try { diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts b/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts index fc241ac5f3..74537c1fa7 100644 --- a/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts +++ b/packages/pluggableWidgets/image-crop-web/src/utils/aspectRatio.ts @@ -21,5 +21,9 @@ export function resolveAspectRatio( return customWidth / customHeight; } return undefined; + default: { + const _exhaustive: never = aspect; + return _exhaustive; + } } } diff --git a/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts b/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts index ec543bb7a1..1968240edb 100644 --- a/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts +++ b/packages/pluggableWidgets/image-crop-web/src/utils/cropImage.ts @@ -18,6 +18,7 @@ export interface CropImageOptions { cropShape: CropShapeEnum; viewportWidth: number; viewportHeight: number; + originalName?: string; } export async function cropImage(options: CropImageOptions): Promise { @@ -30,7 +31,8 @@ export async function cropImage(options: CropImageOptions): Promise { outputSize, cropShape, viewportWidth, - viewportHeight + viewportHeight, + originalName } = options; if (!image.naturalWidth || !image.naturalHeight) { @@ -95,5 +97,6 @@ export async function cropImage(options: CropImageOptions): Promise { ); } - return new File([blob], `crop-${Date.now()}.${ext}`, { type: mime }); + const baseName = originalName ? originalName.replace(/\.[^.]+$/, "") : `crop-${Date.now()}`; + return new File([blob], `${baseName}.${ext}`, { type: mime }); } From 97c25d22dfd19dbeaf42365d00fdcff1fee08e52 Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 27 May 2026 16:06:02 +0200 Subject: [PATCH 39/42] feat(image-crop-web): add github labeler --- .github/configs/labeler.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/configs/labeler.yml b/.github/configs/labeler.yml index bbd16badda..44196c6f94 100644 --- a/.github/configs/labeler.yml +++ b/.github/configs/labeler.yml @@ -82,6 +82,8 @@ combobox-web: - packages/*/combobox-web/**/* google-tag-web: - packages/*/google-tag-web/**/* +image-crop-web: + - packages/*/image-crop-web/**/* # Internals shared: From dbe4d4c70c45fa9362e87bc6cd5e0d3fb070d640 Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 27 May 2026 16:15:28 +0200 Subject: [PATCH 40/42] fix(image-crop-web): pnpm-lock fix --- pnpm-lock.yaml | 131 ++++++++++++++++++++++--------------------------- 1 file changed, 58 insertions(+), 73 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6586e9a6e3..2692a8c6c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,7 +281,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -463,7 +463,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -488,7 +488,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -528,7 +528,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -556,7 +556,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -587,7 +587,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -627,7 +627,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -658,7 +658,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -695,7 +695,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -741,7 +741,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -772,7 +772,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -818,7 +818,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -855,7 +855,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -926,7 +926,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -972,7 +972,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1018,7 +1018,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1052,7 +1052,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1113,7 +1113,7 @@ importers: version: 18.0.1 '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1150,7 +1150,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1196,7 +1196,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1236,7 +1236,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1294,7 +1294,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1361,7 +1361,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1410,7 +1410,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/rollup-web-widgets': specifier: workspace:* version: link:../../shared/rollup-web-widgets @@ -1432,7 +1432,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1478,7 +1478,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1515,7 +1515,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1558,7 +1558,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1622,7 +1622,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1659,7 +1659,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1693,7 +1693,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1724,7 +1724,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1749,7 +1749,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1783,7 +1783,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1817,7 +1817,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1854,7 +1854,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1897,7 +1897,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1946,7 +1946,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1998,7 +1998,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2029,7 +2029,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2060,7 +2060,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2094,7 +2094,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2134,7 +2134,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2171,7 +2171,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2247,7 +2247,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2323,7 +2323,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2360,7 +2360,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2400,7 +2400,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2431,7 +2431,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2477,7 +2477,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2508,7 +2508,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2542,7 +2542,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2573,7 +2573,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2601,7 +2601,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2789,7 +2789,7 @@ importers: devDependencies: '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) rollup-plugin-copy: specifier: ^3.5.0 version: 3.5.0 @@ -10671,6 +10671,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -12611,7 +12612,7 @@ snapshots: '@melloware/coloris@0.25.0': {} - '@mendix/pluggable-widgets-tools@11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1)': + '@mendix/pluggable-widgets-tools@11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1)': dependencies: '@babel/core': 7.29.0 '@babel/eslint-parser': 7.28.6(@babel/core@7.29.0)(eslint@9.39.3(jiti@2.6.1)) @@ -12671,7 +12672,7 @@ snapshots: rollup: 3.29.5 rollup-plugin-clear: 2.0.7 rollup-plugin-command: 1.1.3 - rollup-plugin-license: 3.7.0(rollup@3.29.5) + rollup-plugin-license: 3.7.0(picomatch@4.0.4)(rollup@3.29.5) rollup-plugin-livereload: 2.0.5 rollup-plugin-postcss: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) rollup-plugin-re: 1.0.7 @@ -15789,8 +15790,6 @@ snapshots: dependencies: bser: 2.1.1 - fdir@6.5.0: {} - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -19180,20 +19179,6 @@ snapshots: transitivePeerDependencies: - picomatch - rollup-plugin-license@3.7.0(rollup@3.29.5): - dependencies: - commenting: 1.1.0 - fdir: 6.5.0 - lodash: 4.18.1 - magic-string: 0.30.19 - moment: 2.30.1 - package-name-regex: 2.0.6 - rollup: 3.29.5 - spdx-expression-validate: 2.0.0 - spdx-satisfies: 5.0.1 - transitivePeerDependencies: - - picomatch - rollup-plugin-livereload@2.0.5: dependencies: livereload: 0.9.3 From 3c3708a20ea236098953389335d720b4ce89b3f7 Mon Sep 17 00:00:00 2001 From: Rahman Date: Wed, 27 May 2026 16:17:25 +0200 Subject: [PATCH 41/42] fix(image-crop-web): correct readme language --- packages/pluggableWidgets/image-crop-web/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/image-crop-web/README.md b/packages/pluggableWidgets/image-crop-web/README.md index 5d710abd1f..63b3ae1a70 100644 --- a/packages/pluggableWidgets/image-crop-web/README.md +++ b/packages/pluggableWidgets/image-crop-web/README.md @@ -1,5 +1,5 @@ # Image Crop -Crops images bound to a Mendix image attribute. The cropped result is written back to the same attribute via `EditableImageValue.setValue(file)`. +Crops images bound to a Mendix image attribute. The cropped result is written back to the same attribute. See the [Mendix Marketplace listing](https://marketplace.mendix.com/) for usage docs. From f376009832a81647d871a8b0079917fd16335838 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 28 May 2026 15:54:49 +0200 Subject: [PATCH 42/42] fix(image-crop-web): adress comments --- .../image-crop-web/package.json | 3 +- .../image-crop-web/src/ImageCrop.xml | 3 +- .../src/__tests__/ImageCrop.spec.tsx | 28 ++-- .../src/components/CropArea.tsx | 65 +++------ .../src/components/ImageCropContainer.tsx | 8 +- .../src/components/PreviewPane.tsx | 2 +- .../src/components/ZoomContainer.tsx | 46 ++++++ .../src/hooks/useImageCropState.ts | 2 +- pnpm-lock.yaml | 133 ++++++++++-------- 9 files changed, 169 insertions(+), 121 deletions(-) create mode 100644 packages/pluggableWidgets/image-crop-web/src/components/ZoomContainer.tsx diff --git a/packages/pluggableWidgets/image-crop-web/package.json b/packages/pluggableWidgets/image-crop-web/package.json index 456dac5ae7..5a3dfbc90d 100644 --- a/packages/pluggableWidgets/image-crop-web/package.json +++ b/packages/pluggableWidgets/image-crop-web/package.json @@ -19,7 +19,7 @@ "marketplace": { "minimumMXVersion": "10.21.0", "appName": "Image Crop", - "appNumber": 0, + "appNumber": 1, "reactReady": true }, "testProject": { @@ -51,6 +51,7 @@ "@mendix/prettier-config-web-widgets": "workspace:*", "@mendix/rollup-web-widgets": "workspace:*", "@mendix/widget-plugin-platform": "workspace:*", + "@mendix/widget-plugin-test-utils": "workspace:*", "jest-canvas-mock": "^2.5.2" } } diff --git a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml index 8a47edf692..b656b212ae 100644 --- a/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml +++ b/packages/pluggableWidgets/image-crop-web/src/ImageCrop.xml @@ -107,7 +107,7 @@ Output size Resolution of the saved crop. Original is sharpest; Viewport matches the on-screen canvas size. - Viewport (canvas dims) + Viewport (canvas dimensions) Original (source resolution) @@ -118,6 +118,7 @@ Label on the Crop button. Crop + Bijsnijden diff --git a/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx index 5124639092..e773e330c1 100644 --- a/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/__tests__/ImageCrop.spec.tsx @@ -1,23 +1,28 @@ import { fireEvent, render, screen } from "@testing-library/react"; +import { Big } from "big.js"; import { ValueStatus } from "mendix"; +import { actionValue } from "@mendix/widget-plugin-test-utils"; import type { ImageCropContainerProps } from "../../typings/ImageCropProps"; import { ImageCrop } from "../ImageCrop"; -function makeImageProp(overrides: Partial = {}): any { +type ImageProp = ImageCropContainerProps["image"]; +type WebImage = NonNullable; + +function makeImageProp(overrides: Partial = {}): ImageProp { return { status: ValueStatus.Available, - value: { uri: "http://localhost/img.png" }, + value: { uri: "http://localhost/img.png", name: "img.png" } as WebImage, readOnly: false, validation: undefined, setValidator: jest.fn(), setValue: jest.fn(), setThumbnailSize: jest.fn(), ...overrides - }; + } as ImageProp; } function makeProps(overrides: Partial = {}): ImageCropContainerProps { - const base: any = { + return { name: "imageCrop", class: "", style: undefined, @@ -32,18 +37,21 @@ function makeProps(overrides: Partial = {}): ImageCropC resizableEnabled: true, zoomEnabled: true, wheelZoomMode: "onWithCtrl", - minZoom: 1, - maxZoom: 4, + minZoom: new Big(1), + maxZoom: new Big(4), showPreview: false, previewWidth: 100, previewHeight: 100, outputFormat: "png", - outputQuality: 0.92, + outputQuality: new Big(0.92), outputSize: "original", - cropButtonCaption: { value: "Crop", status: ValueStatus.Available }, - onCropAction: { canExecute: true, execute: jest.fn(), isExecuting: false } + cropButtonCaption: { + value: "Crop", + status: ValueStatus.Available + } as ImageCropContainerProps["cropButtonCaption"], + onCropAction: actionValue(), + ...overrides }; - return { ...base, ...overrides } as ImageCropContainerProps; } describe("", () => { diff --git a/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx b/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx index 8b6843193d..35f0ac5ed8 100644 --- a/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/components/CropArea.tsx @@ -1,15 +1,4 @@ -import classNames from "classnames"; -import { - ReactElement, - RefObject, - SyntheticEvent, - useCallback, - useEffect, - useRef, - useState, - Dispatch, - SetStateAction -} from "react"; +import { Dispatch, ReactElement, Ref, SetStateAction, SyntheticEvent, useCallback, useState } from "react"; import { default as ReactCrop, centerCrop, @@ -18,8 +7,8 @@ import { type Crop, type PixelCrop } from "react-image-crop"; +import { ZoomContainer } from "./ZoomContainer"; import { WheelZoomModeEnum } from "../../typings/ImageCropProps"; -import { useWheelZoom } from "../hooks/useWheelZoom"; interface CropAreaProps { src: string; @@ -37,7 +26,7 @@ interface CropAreaProps { maxZoom: number; setZoom: Dispatch>; wheelZoomMode: WheelZoomModeEnum; - imageRef: RefObject; + imageRef: Ref; } function buildInitialCrop( @@ -70,24 +59,8 @@ function fitToBoundary( export function CropArea(props: CropAreaProps): ReactElement { const [loadError, setLoadError] = useState(false); const [displaySize, setDisplaySize] = useState<{ width: number; height: number } | null>(null); - const containerRef = useRef(null); - const onWheel = useWheelZoom({ - mode: props.wheelZoomMode, - minZoom: props.minZoom, - maxZoom: props.maxZoom, - setZoom: props.setZoom - }); - - useEffect(() => { - const el = containerRef.current; - if (!el) { - return; - } - el.addEventListener("wheel", onWheel, { passive: false }); - return () => el.removeEventListener("wheel", onWheel); - }, [onWheel]); - const { aspect, onImageLoad, imageRef, boundaryWidth, boundaryHeight, src } = props; + const { aspect, onImageLoad, boundaryWidth, boundaryHeight, src } = props; const [prevSrc, setPrevSrc] = useState(src); if (prevSrc !== src) { @@ -105,28 +78,23 @@ export function CropArea(props: CropAreaProps): ReactElement { [aspect, onImageLoad, boundaryWidth, boundaryHeight] ); - const setImageRef = useCallback( - (img: HTMLImageElement | null) => { - imageRef.current = img; - }, - [imageRef] - ); - if (loadError) { return (
- Image source does not allow cropping. Upload locally or configure CORS. + Could not load this image. If it is a remote image, the server must allow cross-origin access.
); } return ( -
) => setLoadError(true)} + onError={() => setLoadError(true)} /> -
+ ); } diff --git a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx index 2202bf066e..895f026a95 100644 --- a/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/components/ImageCropContainer.tsx @@ -33,7 +33,13 @@ export function ImageCropContainer(props: ImageCropContainerProps): ReactElement const handleCrop = useCallback(async () => { const img = state.imageRef.current; - if (!img || !state.completedCrop || props.image.readOnly || props.image.status !== ValueStatus.Available) { + if ( + !img || + !state.completedCrop || + props.image.readOnly || + props.image.status !== ValueStatus.Available || + !props.image.value + ) { return; } try { diff --git a/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx b/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx index e2548df811..918e799698 100644 --- a/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx +++ b/packages/pluggableWidgets/image-crop-web/src/components/PreviewPane.tsx @@ -11,7 +11,7 @@ interface PreviewPaneProps { } export function PreviewPane({ image, pixelCrop, zoom, width, height, circle }: PreviewPaneProps): ReactElement { - const canvasRef = useRef(null); + const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; diff --git a/packages/pluggableWidgets/image-crop-web/src/components/ZoomContainer.tsx b/packages/pluggableWidgets/image-crop-web/src/components/ZoomContainer.tsx new file mode 100644 index 0000000000..840cdb05e5 --- /dev/null +++ b/packages/pluggableWidgets/image-crop-web/src/components/ZoomContainer.tsx @@ -0,0 +1,46 @@ +import classNames from "classnames"; +import { Dispatch, ReactElement, ReactNode, SetStateAction, useEffect, useRef } from "react"; +import { WheelZoomModeEnum } from "../../typings/ImageCropProps"; +import { useWheelZoom } from "../hooks/useWheelZoom"; + +interface ZoomContainerProps { + mode: WheelZoomModeEnum; + minZoom: number; + maxZoom: number; + setZoom: Dispatch>; + boundaryWidth: number; + boundaryHeight: number; + circular: boolean; + children: ReactNode; +} + +export function ZoomContainer(props: ZoomContainerProps): ReactElement { + const containerRef = useRef(null); + const onWheel = useWheelZoom({ + mode: props.mode, + minZoom: props.minZoom, + maxZoom: props.maxZoom, + setZoom: props.setZoom + }); + + useEffect(() => { + const el = containerRef.current; + if (!el) { + return; + } + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [onWheel]); + + return ( +
+ {props.children} +
+ ); +} diff --git a/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts b/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts index 6725f85ebb..8b159b4385 100644 --- a/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts +++ b/packages/pluggableWidgets/image-crop-web/src/hooks/useImageCropState.ts @@ -15,6 +15,6 @@ export function useImageCropState(initialZoom: number): ImageCropState { const [crop, setCrop] = useState(undefined); const [completedCrop, setCompletedCrop] = useState(undefined); const [zoom, setZoom] = useState(initialZoom); - const imageRef = useRef(null); + const imageRef = useRef(null); return { crop, setCrop, completedCrop, setCompletedCrop, zoom, setZoom, imageRef }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2692a8c6c4..9e2601b17d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,7 +281,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -463,7 +463,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -488,7 +488,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -528,7 +528,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -556,7 +556,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -587,7 +587,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -627,7 +627,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -658,7 +658,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -695,7 +695,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -741,7 +741,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -772,7 +772,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -818,7 +818,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -855,7 +855,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -926,7 +926,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -972,7 +972,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1018,7 +1018,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1052,7 +1052,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1113,7 +1113,7 @@ importers: version: 18.0.1 '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1150,7 +1150,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1196,7 +1196,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1236,7 +1236,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1294,7 +1294,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1361,7 +1361,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1410,7 +1410,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/rollup-web-widgets': specifier: workspace:* version: link:../../shared/rollup-web-widgets @@ -1432,7 +1432,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1478,7 +1478,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1515,7 +1515,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1558,7 +1558,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1622,7 +1622,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1659,7 +1659,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1693,7 +1693,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1724,7 +1724,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1749,7 +1749,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1759,6 +1759,9 @@ importers: '@mendix/widget-plugin-platform': specifier: workspace:* version: link:../../shared/widget-plugin-platform + '@mendix/widget-plugin-test-utils': + specifier: workspace:* + version: link:../../shared/widget-plugin-test-utils jest-canvas-mock: specifier: ^2.5.2 version: 2.5.2 @@ -1783,7 +1786,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1817,7 +1820,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1854,7 +1857,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1897,7 +1900,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1946,7 +1949,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -1998,7 +2001,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2029,7 +2032,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2060,7 +2063,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2094,7 +2097,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2134,7 +2137,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2171,7 +2174,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2247,7 +2250,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2323,7 +2326,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2360,7 +2363,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2400,7 +2403,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2431,7 +2434,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2477,7 +2480,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2508,7 +2511,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2542,7 +2545,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2573,7 +2576,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2601,7 +2604,7 @@ importers: version: link:../../shared/eslint-config-web-widgets '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../shared/prettier-config-web-widgets @@ -2789,7 +2792,7 @@ importers: devDependencies: '@mendix/pluggable-widgets-tools': specifier: 11.8.0 - version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) + version: 11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1) rollup-plugin-copy: specifier: ^3.5.0 version: 3.5.0 @@ -12612,7 +12615,7 @@ snapshots: '@melloware/coloris@0.25.0': {} - '@mendix/pluggable-widgets-tools@11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(picomatch@4.0.4)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1)': + '@mendix/pluggable-widgets-tools@11.8.0(patch_hash=93aef2ae0fe74bac64b1ea6c76a0ac387d0acbbc48ff8f8832f081f0f1747148)(@jest/transform@29.7.0)(@jest/types@30.2.0)(@swc/core@1.13.5)(@types/babel__core@7.20.5)(@types/node@22.14.1)(eslint@9.39.3(jiti@2.6.1))(jest-util@30.2.0)(prettier@3.8.1)(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1)(tslib@2.8.1)': dependencies: '@babel/core': 7.29.0 '@babel/eslint-parser': 7.28.6(@babel/core@7.29.0)(eslint@9.39.3(jiti@2.6.1)) @@ -12672,7 +12675,7 @@ snapshots: rollup: 3.29.5 rollup-plugin-clear: 2.0.7 rollup-plugin-command: 1.1.3 - rollup-plugin-license: 3.7.0(picomatch@4.0.4)(rollup@3.29.5) + rollup-plugin-license: 3.7.0(rollup@3.29.5) rollup-plugin-livereload: 2.0.5 rollup-plugin-postcss: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) rollup-plugin-re: 1.0.7 @@ -15790,6 +15793,8 @@ snapshots: dependencies: bser: 2.1.1 + fdir@6.5.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -19179,6 +19184,20 @@ snapshots: transitivePeerDependencies: - picomatch + rollup-plugin-license@3.7.0(rollup@3.29.5): + dependencies: + commenting: 1.1.0 + fdir: 6.5.0 + lodash: 4.18.1 + magic-string: 0.30.19 + moment: 2.30.1 + package-name-regex: 2.0.6 + rollup: 3.29.5 + spdx-expression-validate: 2.0.0 + spdx-satisfies: 5.0.1 + transitivePeerDependencies: + - picomatch + rollup-plugin-livereload@2.0.5: dependencies: livereload: 0.9.3