@nasser-sw/fabric 7.0.1-beta37 → 7.0.1-beta39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.history/package_20251227203809.json +164 -0
  2. package/.history/package_20251227220608.json +164 -0
  3. package/dist/index.js +756 -14
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +756 -14
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/index.node.cjs +756 -14
  8. package/dist/index.node.cjs.map +1 -1
  9. package/dist/index.node.mjs +756 -14
  10. package/dist/index.node.mjs.map +1 -1
  11. package/dist/package.json.mjs +1 -1
  12. package/dist/src/controls/imageCropControls.d.ts +40 -0
  13. package/dist/src/controls/imageCropControls.d.ts.map +1 -0
  14. package/dist/src/controls/imageCropControls.mjs +480 -0
  15. package/dist/src/controls/imageCropControls.mjs.map +1 -0
  16. package/dist/src/controls/index.d.ts +1 -0
  17. package/dist/src/controls/index.d.ts.map +1 -1
  18. package/dist/src/controls/index.mjs +1 -0
  19. package/dist/src/controls/index.mjs.map +1 -1
  20. package/dist/src/shapes/Frame.d.ts +5 -0
  21. package/dist/src/shapes/Frame.d.ts.map +1 -1
  22. package/dist/src/shapes/Frame.mjs +20 -0
  23. package/dist/src/shapes/Frame.mjs.map +1 -1
  24. package/dist/src/shapes/Image.d.ts +60 -0
  25. package/dist/src/shapes/Image.d.ts.map +1 -1
  26. package/dist/src/shapes/Image.mjs +249 -2
  27. package/dist/src/shapes/Image.mjs.map +1 -1
  28. package/dist/src/shapes/Object/InteractiveObject.d.ts.map +1 -1
  29. package/dist/src/shapes/Object/InteractiveObject.mjs +9 -11
  30. package/dist/src/shapes/Object/InteractiveObject.mjs.map +1 -1
  31. package/dist-extensions/src/controls/imageCropControls.d.ts +40 -0
  32. package/dist-extensions/src/controls/imageCropControls.d.ts.map +1 -0
  33. package/dist-extensions/src/controls/index.d.ts +1 -0
  34. package/dist-extensions/src/controls/index.d.ts.map +1 -1
  35. package/dist-extensions/src/shapes/Frame.d.ts +5 -0
  36. package/dist-extensions/src/shapes/Frame.d.ts.map +1 -1
  37. package/dist-extensions/src/shapes/Image.d.ts +60 -0
  38. package/dist-extensions/src/shapes/Image.d.ts.map +1 -1
  39. package/dist-extensions/src/shapes/Object/InteractiveObject.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/controls/imageCropControls.ts +656 -0
  42. package/src/controls/index.ts +1 -0
  43. package/src/shapes/Frame.ts +21 -0
  44. package/src/shapes/Image.ts +267 -2
  45. package/src/shapes/Object/InteractiveObject.ts +9 -15
package/dist/index.js CHANGED
@@ -360,7 +360,7 @@
360
360
  }
361
361
  const cache = new Cache();
362
362
 
363
- var version = "7.0.1-beta36";
363
+ var version = "7.0.1-beta38";
364
364
 
365
365
  // use this syntax so babel plugin see this import here
366
366
  const VERSION = version;
@@ -10392,21 +10392,19 @@
10392
10392
  * This ensures correct behavior with zoom and grouped objects.
10393
10393
  */
10394
10394
  drawExpandPreview(ctx, size, options) {
10395
- var _this$canvas6, _this$canvas7;
10396
10395
  if (!this.expandMode) return;
10397
10396
 
10398
- // Use options.scaleX/scaleY from qrDecompose which includes parent transforms and vpt
10399
- // This correctly handles grouped objects (like content images inside Frames)
10400
- // options comes from qrDecompose(vpt * calcTransformMatrix()) which includes:
10401
- // 1. Viewport zoom
10402
- // 2. Parent group's scale (Frame's scale)
10403
- // 3. Object's own scale
10404
- const scaleX = options !== null && options !== void 0 && options.scaleX ? Math.abs(options.scaleX) : (((_this$canvas6 = this.canvas) === null || _this$canvas6 === void 0 ? void 0 : _this$canvas6.getZoom()) || 1) * Math.abs(this.scaleX || 1);
10405
- const scaleY = options !== null && options !== void 0 && options.scaleY ? Math.abs(options.scaleY) : (((_this$canvas7 = this.canvas) === null || _this$canvas7 === void 0 ? void 0 : _this$canvas7.getZoom()) || 1) * Math.abs(this.scaleY || 1);
10397
+ // Use size but subtract borderScaleFactor and padding to match control positioning
10398
+ const borderOffset = this.borderScaleFactor || 1;
10399
+ const paddingOffset = (this.padding || 0) * 2;
10406
10400
  const dim = {
10407
- x: (this.width || 1) * scaleX,
10408
- y: (this.height || 1) * scaleY
10401
+ x: size.x - borderOffset - paddingOffset,
10402
+ y: size.y - borderOffset - paddingOffset
10409
10403
  };
10404
+
10405
+ // Calculate scale factor from dim for expansion scaling
10406
+ const scaleX = dim.x / (this.width || 1);
10407
+ const scaleY = dim.y / (this.height || 1);
10410
10408
  const {
10411
10409
  expandLeft,
10412
10410
  expandRight,
@@ -29821,6 +29819,474 @@
29821
29819
  _defineProperty(Textbox, "ownDefaults", textboxDefaultValues);
29822
29820
  classRegistry.setClass(Textbox);
29823
29821
 
29822
+ /**
29823
+ * Minimum size for cropped images (in pixels)
29824
+ */
29825
+ const MIN_SIZE = 20;
29826
+
29827
+ /**
29828
+ * Get the original element dimensions for an image
29829
+ */
29830
+ function getOriginalDimensions(target) {
29831
+ const element = target.getElement();
29832
+ if (!element) {
29833
+ return {
29834
+ width: target.width || 100,
29835
+ height: target.height || 100
29836
+ };
29837
+ }
29838
+ return {
29839
+ width: element.naturalWidth || element.width || target.width || 100,
29840
+ height: element.naturalHeight || element.height || target.height || 100
29841
+ };
29842
+ }
29843
+
29844
+ /**
29845
+ * Handler for resizing from RIGHT edge (mr control) - Canva style
29846
+ * Crops from right side, anchor on left
29847
+ */
29848
+ const resizeFromRightHandler = (eventData, transform, x, y) => {
29849
+ const target = transform.target;
29850
+ const original = getOriginalDimensions(target);
29851
+ const currentScale = target.scaleX || 1;
29852
+ const cropX = target.cropX || 0;
29853
+ const localPoint = getLocalPoint(transform, transform.originX, transform.originY, x, y);
29854
+ const requestedVisualWidth = Math.max(MIN_SIZE, Math.abs(localPoint.x));
29855
+ const maxAvailableWidth = (original.width - cropX) * currentScale;
29856
+ if (requestedVisualWidth <= maxAvailableWidth) {
29857
+ // Within bounds - just change visible width, cropX stays same (crops from right)
29858
+ target.width = requestedVisualWidth / currentScale;
29859
+ } else {
29860
+ // Beyond bounds - scale uniformly
29861
+ target.width = original.width - cropX;
29862
+ const newScale = requestedVisualWidth / target.width;
29863
+ target.scaleX = newScale;
29864
+ target.scaleY = newScale;
29865
+ const currentVisualHeight = (target.height || original.height) * currentScale;
29866
+ target.height = currentVisualHeight / newScale;
29867
+ }
29868
+ target.setCoords();
29869
+ return true;
29870
+ };
29871
+
29872
+ /**
29873
+ * Handler for resizing from LEFT edge (ml control) - Canva style
29874
+ * Crops from left side, anchor on right
29875
+ */
29876
+ const resizeFromLeftHandler = (eventData, transform, x, y) => {
29877
+ const target = transform.target;
29878
+ const original = getOriginalDimensions(target);
29879
+ const currentScale = target.scaleX || 1;
29880
+ const currentCropX = target.cropX || 0;
29881
+ const currentWidth = target.width || original.width;
29882
+ const localPoint = getLocalPoint(transform, transform.originX, transform.originY, x, y);
29883
+ const requestedVisualWidth = Math.max(MIN_SIZE, Math.abs(localPoint.x));
29884
+
29885
+ // Right edge position in original image coords (stays fixed)
29886
+ const rightEdgeInOriginal = currentCropX + currentWidth;
29887
+
29888
+ // Maximum we can expand to the left (cropX can go to 0)
29889
+ const maxAvailableWidth = rightEdgeInOriginal * currentScale;
29890
+ if (requestedVisualWidth <= maxAvailableWidth) {
29891
+ // Within bounds - adjust cropX and width (crops from left)
29892
+ const newWidthUnscaled = requestedVisualWidth / currentScale;
29893
+ const newCropX = rightEdgeInOriginal - newWidthUnscaled;
29894
+ if (newCropX >= 0) {
29895
+ target.cropX = newCropX;
29896
+ target.width = newWidthUnscaled;
29897
+ } else {
29898
+ // Hit left boundary
29899
+ target.cropX = 0;
29900
+ target.width = rightEdgeInOriginal;
29901
+ }
29902
+ } else {
29903
+ // Beyond bounds - scale uniformly
29904
+ target.cropX = 0;
29905
+ target.width = rightEdgeInOriginal;
29906
+ const newScale = requestedVisualWidth / target.width;
29907
+ target.scaleX = newScale;
29908
+ target.scaleY = newScale;
29909
+ const currentVisualHeight = (target.height || original.height) * currentScale;
29910
+ target.height = currentVisualHeight / newScale;
29911
+ }
29912
+ target.setCoords();
29913
+ return true;
29914
+ };
29915
+
29916
+ /**
29917
+ * Handler for cropping from the right edge (mr control) - for crop mode
29918
+ * - Drag inward: decrease width (crop right side)
29919
+ * - Drag outward: increase width until hitting boundary, then scale
29920
+ */
29921
+ const cropFromRightHandler = (eventData, transform, x, y) => {
29922
+ const target = transform.target;
29923
+ const localPoint = getLocalPoint(transform, 'left', 'center', x, y);
29924
+ const original = getOriginalDimensions(target);
29925
+ const currentCropX = target.cropX || 0;
29926
+ const currentScale = target.scaleX || 1;
29927
+
29928
+ // Maximum visible width at current scale (from current cropX to right edge of original)
29929
+ const maxVisibleWidth = (original.width - currentCropX) * currentScale;
29930
+
29931
+ // Requested width based on mouse position
29932
+ const requestedWidth = Math.max(MIN_SIZE, localPoint.x);
29933
+ if (requestedWidth <= maxVisibleWidth) {
29934
+ // Within bounds - just change visible width (crop/uncrop)
29935
+ // Convert to unscaled width for the width property
29936
+ target.width = requestedWidth / currentScale;
29937
+ } else {
29938
+ // Beyond bounds - need to scale
29939
+ // First, set width to maximum possible
29940
+ target.width = original.width - currentCropX;
29941
+ // Calculate new scale to reach requested size
29942
+ const newScale = requestedWidth / target.width;
29943
+ target.scaleX = newScale;
29944
+ target.scaleY = newScale; // Uniform scaling
29945
+ }
29946
+ target.setCoords();
29947
+ return true;
29948
+ };
29949
+
29950
+ /**
29951
+ * Handler for resizing from BOTTOM edge (mb control) - Canva style
29952
+ * Crops from bottom side, anchor on top
29953
+ */
29954
+ const resizeFromBottomHandler = (eventData, transform, x, y) => {
29955
+ const target = transform.target;
29956
+ const original = getOriginalDimensions(target);
29957
+ const currentScale = target.scaleY || 1;
29958
+ const cropY = target.cropY || 0;
29959
+ const localPoint = getLocalPoint(transform, transform.originX, transform.originY, x, y);
29960
+ const requestedVisualHeight = Math.max(MIN_SIZE, Math.abs(localPoint.y));
29961
+ const maxAvailableHeight = (original.height - cropY) * currentScale;
29962
+ if (requestedVisualHeight <= maxAvailableHeight) {
29963
+ // Within bounds - just change visible height, cropY stays same (crops from bottom)
29964
+ target.height = requestedVisualHeight / currentScale;
29965
+ } else {
29966
+ // Beyond bounds - scale uniformly
29967
+ target.height = original.height - cropY;
29968
+ const newScale = requestedVisualHeight / target.height;
29969
+ target.scaleX = newScale;
29970
+ target.scaleY = newScale;
29971
+ const currentVisualWidth = (target.width || original.width) * currentScale;
29972
+ target.width = currentVisualWidth / newScale;
29973
+ }
29974
+ target.setCoords();
29975
+ return true;
29976
+ };
29977
+
29978
+ /**
29979
+ * Handler for resizing from TOP edge (mt control) - Canva style
29980
+ * Crops from top side, anchor on bottom
29981
+ */
29982
+ const resizeFromTopHandler = (eventData, transform, x, y) => {
29983
+ const target = transform.target;
29984
+ const original = getOriginalDimensions(target);
29985
+ const currentScale = target.scaleY || 1;
29986
+ const currentCropY = target.cropY || 0;
29987
+ const currentHeight = target.height || original.height;
29988
+ const localPoint = getLocalPoint(transform, transform.originX, transform.originY, x, y);
29989
+ const requestedVisualHeight = Math.max(MIN_SIZE, Math.abs(localPoint.y));
29990
+
29991
+ // Bottom edge position in original image coords (stays fixed)
29992
+ const bottomEdgeInOriginal = currentCropY + currentHeight;
29993
+
29994
+ // Maximum we can expand to the top (cropY can go to 0)
29995
+ const maxAvailableHeight = bottomEdgeInOriginal * currentScale;
29996
+ if (requestedVisualHeight <= maxAvailableHeight) {
29997
+ // Within bounds - adjust cropY and height (crops from top)
29998
+ const newHeightUnscaled = requestedVisualHeight / currentScale;
29999
+ const newCropY = bottomEdgeInOriginal - newHeightUnscaled;
30000
+ if (newCropY >= 0) {
30001
+ target.cropY = newCropY;
30002
+ target.height = newHeightUnscaled;
30003
+ } else {
30004
+ // Hit top boundary
30005
+ target.cropY = 0;
30006
+ target.height = bottomEdgeInOriginal;
30007
+ }
30008
+ } else {
30009
+ // Beyond bounds - scale uniformly
30010
+ target.cropY = 0;
30011
+ target.height = bottomEdgeInOriginal;
30012
+ const newScale = requestedVisualHeight / target.height;
30013
+ target.scaleX = newScale;
30014
+ target.scaleY = newScale;
30015
+ const currentVisualWidth = (target.width || original.width) * currentScale;
30016
+ target.width = currentVisualWidth / newScale;
30017
+ }
30018
+ target.setCoords();
30019
+ return true;
30020
+ };
30021
+
30022
+ // Wrapped resize handlers with fixed anchor
30023
+ const resizeFromRight = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(resizeFromRightHandler));
30024
+ const resizeFromLeft = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(resizeFromLeftHandler));
30025
+ const resizeFromBottom = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(resizeFromBottomHandler));
30026
+ const resizeFromTop = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(resizeFromTopHandler));
30027
+
30028
+ /**
30029
+ * Handler for cropping from the left edge (ml control)
30030
+ * - Drag inward: increase cropX, decrease width (crop left side)
30031
+ * - Drag outward: decrease cropX until 0, then scale
30032
+ */
30033
+ const cropFromLeftHandler = (eventData, transform, x, y) => {
30034
+ const target = transform.target;
30035
+ const localPoint = getLocalPoint(transform, 'right', 'center', x, y);
30036
+ getOriginalDimensions(target);
30037
+ const currentCropX = target.cropX || 0;
30038
+ const currentWidth = target.width || 100;
30039
+ const currentScale = target.scaleX || 1;
30040
+
30041
+ // Requested width based on mouse position (localPoint.x is negative from right origin)
30042
+ const requestedWidth = Math.max(MIN_SIZE, Math.abs(localPoint.x));
30043
+
30044
+ // Current right edge position in original image coordinates
30045
+ const rightEdge = currentCropX + currentWidth;
30046
+
30047
+ // Maximum possible width at current scale (if cropX goes to 0)
30048
+ const maxVisibleWidth = rightEdge * currentScale;
30049
+ if (requestedWidth <= maxVisibleWidth) {
30050
+ // Within bounds - adjust cropX and width
30051
+ const newWidthUnscaled = requestedWidth / currentScale;
30052
+ const newCropX = rightEdge - newWidthUnscaled;
30053
+ if (newCropX >= 0) {
30054
+ target.cropX = newCropX;
30055
+ target.width = newWidthUnscaled;
30056
+ } else {
30057
+ // Would go past left edge - clamp cropX to 0
30058
+ target.cropX = 0;
30059
+ target.width = rightEdge;
30060
+ }
30061
+ } else {
30062
+ // Beyond bounds - need to scale
30063
+ target.cropX = 0;
30064
+ target.width = rightEdge;
30065
+ const newScale = requestedWidth / target.width;
30066
+ target.scaleX = newScale;
30067
+ target.scaleY = newScale;
30068
+ }
30069
+ target.setCoords();
30070
+ return true;
30071
+ };
30072
+
30073
+ /**
30074
+ * Handler for cropping from the bottom edge (mb control)
30075
+ */
30076
+ const cropFromBottomHandler = (eventData, transform, x, y) => {
30077
+ const target = transform.target;
30078
+ const localPoint = getLocalPoint(transform, 'center', 'top', x, y);
30079
+ const original = getOriginalDimensions(target);
30080
+ const currentCropY = target.cropY || 0;
30081
+ const currentScale = target.scaleY || 1;
30082
+ const maxVisibleHeight = (original.height - currentCropY) * currentScale;
30083
+ const requestedHeight = Math.max(MIN_SIZE, localPoint.y);
30084
+ if (requestedHeight <= maxVisibleHeight) {
30085
+ target.height = requestedHeight / currentScale;
30086
+ } else {
30087
+ target.height = original.height - currentCropY;
30088
+ const newScale = requestedHeight / target.height;
30089
+ target.scaleX = newScale;
30090
+ target.scaleY = newScale;
30091
+ }
30092
+ target.setCoords();
30093
+ return true;
30094
+ };
30095
+
30096
+ /**
30097
+ * Handler for cropping from the top edge (mt control)
30098
+ */
30099
+ const cropFromTopHandler = (eventData, transform, x, y) => {
30100
+ const target = transform.target;
30101
+ const localPoint = getLocalPoint(transform, 'center', 'bottom', x, y);
30102
+ getOriginalDimensions(target);
30103
+ const currentCropY = target.cropY || 0;
30104
+ const currentHeight = target.height || 100;
30105
+ const currentScale = target.scaleY || 1;
30106
+ const requestedHeight = Math.max(MIN_SIZE, Math.abs(localPoint.y));
30107
+ const bottomEdge = currentCropY + currentHeight;
30108
+ const maxVisibleHeight = bottomEdge * currentScale;
30109
+ if (requestedHeight <= maxVisibleHeight) {
30110
+ const newHeightUnscaled = requestedHeight / currentScale;
30111
+ const newCropY = bottomEdge - newHeightUnscaled;
30112
+ if (newCropY >= 0) {
30113
+ target.cropY = newCropY;
30114
+ target.height = newHeightUnscaled;
30115
+ } else {
30116
+ target.cropY = 0;
30117
+ target.height = bottomEdge;
30118
+ }
30119
+ } else {
30120
+ target.cropY = 0;
30121
+ target.height = bottomEdge;
30122
+ const newScale = requestedHeight / target.height;
30123
+ target.scaleX = newScale;
30124
+ target.scaleY = newScale;
30125
+ }
30126
+ target.setCoords();
30127
+ return true;
30128
+ };
30129
+
30130
+ /**
30131
+ * Handler for cropping from corners (tl, tr, bl, br controls)
30132
+ * Handles both dimensions simultaneously
30133
+ */
30134
+ const createCornerCropHandler = (xDirection, yDirection) => {
30135
+ const xHandler = xDirection === 'left' ? cropFromLeftHandler : cropFromRightHandler;
30136
+ const yHandler = yDirection === 'top' ? cropFromTopHandler : cropFromBottomHandler;
30137
+ return (eventData, transform, x, y) => {
30138
+ // Apply both handlers
30139
+ const xChanged = xHandler(eventData, transform, x, y);
30140
+ const yChanged = yHandler(eventData, transform, x, y);
30141
+ return xChanged || yChanged;
30142
+ };
30143
+ };
30144
+
30145
+ // Wrapped handlers with fixed anchor and fire event
30146
+ const cropFromRight = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(cropFromRightHandler));
30147
+ const cropFromLeft = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(cropFromLeftHandler));
30148
+ const cropFromBottom = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(cropFromBottomHandler));
30149
+ const cropFromTop = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(cropFromTopHandler));
30150
+ const cropFromTopLeft = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(createCornerCropHandler('left', 'top')));
30151
+ const cropFromTopRight = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(createCornerCropHandler('right', 'top')));
30152
+ const cropFromBottomLeft = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(createCornerCropHandler('left', 'bottom')));
30153
+ const cropFromBottomRight = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(createCornerCropHandler('right', 'bottom')));
30154
+
30155
+ /**
30156
+ * Creates Canva-like controls for FabricImage
30157
+ * - Side handles crop/resize visible area (Canva style)
30158
+ * - Corner handles scale uniformly
30159
+ * - Double-click enters crop mode for fine-tuning
30160
+ */
30161
+ const createImageCropControls = () => ({
30162
+ // Side controls - crop from each side
30163
+ ml: new Control({
30164
+ x: -0.5,
30165
+ y: 0,
30166
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30167
+ actionHandler: resizeFromLeft,
30168
+ actionName: RESIZING,
30169
+ render: renderHorizontalPillControl,
30170
+ sizeX: 6,
30171
+ sizeY: 20
30172
+ }),
30173
+ mr: new Control({
30174
+ x: 0.5,
30175
+ y: 0,
30176
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30177
+ actionHandler: resizeFromRight,
30178
+ actionName: RESIZING,
30179
+ render: renderHorizontalPillControl,
30180
+ sizeX: 6,
30181
+ sizeY: 20
30182
+ }),
30183
+ mb: new Control({
30184
+ x: 0,
30185
+ y: 0.5,
30186
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30187
+ actionHandler: resizeFromBottom,
30188
+ actionName: RESIZING,
30189
+ render: renderVerticalPillControl,
30190
+ sizeX: 20,
30191
+ sizeY: 6
30192
+ }),
30193
+ mt: new Control({
30194
+ x: 0,
30195
+ y: -0.5,
30196
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30197
+ actionHandler: resizeFromTop,
30198
+ actionName: RESIZING,
30199
+ render: renderVerticalPillControl,
30200
+ sizeX: 20,
30201
+ sizeY: 6
30202
+ }),
30203
+ // Corner controls - uniform scaling (like Canva)
30204
+ tl: new Control({
30205
+ x: -0.5,
30206
+ y: -0.5,
30207
+ cursorStyleHandler: scaleCursorStyleHandler,
30208
+ actionHandler: scalingEqually
30209
+ }),
30210
+ tr: new Control({
30211
+ x: 0.5,
30212
+ y: -0.5,
30213
+ cursorStyleHandler: scaleCursorStyleHandler,
30214
+ actionHandler: scalingEqually
30215
+ }),
30216
+ bl: new Control({
30217
+ x: -0.5,
30218
+ y: 0.5,
30219
+ cursorStyleHandler: scaleCursorStyleHandler,
30220
+ actionHandler: scalingEqually
30221
+ }),
30222
+ br: new Control({
30223
+ x: 0.5,
30224
+ y: 0.5,
30225
+ cursorStyleHandler: scaleCursorStyleHandler,
30226
+ actionHandler: scalingEqually
30227
+ }),
30228
+ mtr: new Control({
30229
+ x: 0,
30230
+ y: -0.5,
30231
+ actionHandler: rotationWithSnapping,
30232
+ cursorStyleHandler: rotationStyleHandler,
30233
+ offsetY: -40,
30234
+ withConnection: true,
30235
+ actionName: ROTATE
30236
+ })
30237
+ });
30238
+
30239
+ /**
30240
+ * Creates crop mode controls for FabricImage (used in crop mode after double-click)
30241
+ * - Side handles crop/uncrop single axis
30242
+ * - Corner handles crop/uncrop both axes
30243
+ */
30244
+ const createImageCropModeControls = () => ({
30245
+ ml: new Control({
30246
+ x: -0.5,
30247
+ y: 0,
30248
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30249
+ actionHandler: cropFromLeft,
30250
+ actionName: RESIZING,
30251
+ render: renderHorizontalPillControl,
30252
+ sizeX: 6,
30253
+ sizeY: 20
30254
+ }),
30255
+ mr: new Control({
30256
+ x: 0.5,
30257
+ y: 0,
30258
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30259
+ actionHandler: cropFromRight,
30260
+ actionName: RESIZING,
30261
+ render: renderHorizontalPillControl,
30262
+ sizeX: 6,
30263
+ sizeY: 20
30264
+ }),
30265
+ mb: new Control({
30266
+ x: 0,
30267
+ y: 0.5,
30268
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30269
+ actionHandler: cropFromBottom,
30270
+ actionName: RESIZING,
30271
+ render: renderVerticalPillControl,
30272
+ sizeX: 20,
30273
+ sizeY: 6
30274
+ }),
30275
+ mt: new Control({
30276
+ x: 0,
30277
+ y: -0.5,
30278
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30279
+ actionHandler: cropFromTop,
30280
+ actionName: RESIZING,
30281
+ render: renderVerticalPillControl,
30282
+ sizeX: 20,
30283
+ sizeY: 6
30284
+ })
30285
+
30286
+ // No corner controls in crop mode - or could add corner crop handlers
30287
+ // No rotation in crop mode
30288
+ });
30289
+
29824
30290
  /**
29825
30291
  * Canvas 2D filter backend.
29826
30292
  */
@@ -30231,7 +30697,8 @@
30231
30697
  minimumScaleTrigger: 0.5,
30232
30698
  cropX: 0,
30233
30699
  cropY: 0,
30234
- imageSmoothing: true
30700
+ imageSmoothing: true,
30701
+ cropMode: false
30235
30702
  };
30236
30703
  const IMAGE_PROPS = ['cropX', 'cropY'];
30237
30704
 
@@ -30245,6 +30712,154 @@
30245
30712
  ...FabricImage.ownDefaults
30246
30713
  };
30247
30714
  }
30715
+
30716
+ /**
30717
+ * Creates Canva-like controls for images
30718
+ * - All handles scale uniformly
30719
+ * - Double-click to enter crop mode
30720
+ */
30721
+ static createControls() {
30722
+ return {
30723
+ controls: createImageCropControls()
30724
+ };
30725
+ }
30726
+
30727
+ /**
30728
+ * Enter crop mode - switches to crop controls
30729
+ * Call this on double-click
30730
+ */
30731
+ enterCropMode() {
30732
+ var _this$canvas;
30733
+ if (this.cropMode) return;
30734
+ this.cropMode = true;
30735
+ // Backup current controls
30736
+ this._normalControls = {
30737
+ ...this.controls
30738
+ };
30739
+ // Switch to crop mode controls
30740
+ this.controls = createImageCropModeControls();
30741
+ // Dirty cache to force re-render with full image visible
30742
+ this.dirty = true;
30743
+ // Reset drag state
30744
+ this._cropModeDragActive = false;
30745
+ this.setCoords();
30746
+ (_this$canvas = this.canvas) === null || _this$canvas === void 0 || _this$canvas.requestRenderAll();
30747
+ }
30748
+
30749
+ /**
30750
+ * Exit crop mode - restores normal controls
30751
+ * Call this on click outside or escape
30752
+ */
30753
+ exitCropMode() {
30754
+ var _this$canvas2;
30755
+ if (!this.cropMode) return;
30756
+ this.cropMode = false;
30757
+ // Restore normal controls
30758
+ if (this._normalControls) {
30759
+ this.controls = this._normalControls;
30760
+ this._normalControls = undefined;
30761
+ } else {
30762
+ this.controls = createImageCropControls();
30763
+ }
30764
+ // Dirty cache to force re-render with cropped image only
30765
+ this.dirty = true;
30766
+ this.setCoords();
30767
+ (_this$canvas2 = this.canvas) === null || _this$canvas2 === void 0 || _this$canvas2.requestRenderAll();
30768
+ }
30769
+
30770
+ /**
30771
+ * Toggle crop mode
30772
+ */
30773
+ toggleCropMode() {
30774
+ if (this.cropMode) {
30775
+ this.exitCropMode();
30776
+ } else {
30777
+ this.enterCropMode();
30778
+ }
30779
+ }
30780
+
30781
+ /**
30782
+ * Override set to intercept movement in crop mode
30783
+ * In crop mode, dragging adjusts cropX/cropY instead of left/top
30784
+ */
30785
+ // @ts-ignore - override set with different signature for crop mode handling
30786
+ set(key, value) {
30787
+ // Only intercept in crop mode when actually dragging (isMoving is true)
30788
+ if (this.cropMode && this.isMoving && typeof key === 'string') {
30789
+ if (key === 'left' && typeof value === 'number') {
30790
+ return this._setCropModePosition('left', value);
30791
+ }
30792
+ if (key === 'top' && typeof value === 'number') {
30793
+ return this._setCropModePosition('top', value);
30794
+ }
30795
+ }
30796
+ return super.set(key, value);
30797
+ }
30798
+
30799
+ /**
30800
+ * Handle position changes in crop mode - converts to cropX/cropY changes
30801
+ * @private
30802
+ */
30803
+ _setCropModePosition(axis, newPos) {
30804
+ const element = this._element;
30805
+ if (!element) {
30806
+ return super.set(axis, newPos);
30807
+ }
30808
+
30809
+ // Capture baseline values at the start of each new drag
30810
+ if (!this._cropModeDragActive) {
30811
+ this._cropModeDragActive = true;
30812
+ this._cropModeOriginalLeft = this.left;
30813
+ this._cropModeOriginalTop = this.top;
30814
+ this._cropModeOriginalCropX = this.cropX;
30815
+ this._cropModeOriginalCropY = this.cropY;
30816
+ }
30817
+ const scale = axis === 'left' ? this.scaleX || 1 : this.scaleY || 1;
30818
+ const basePos = axis === 'left' ? this._cropModeOriginalLeft : this._cropModeOriginalTop;
30819
+ const baseCrop = axis === 'left' ? this._cropModeOriginalCropX : this._cropModeOriginalCropY;
30820
+ const cropProp = axis === 'left' ? 'cropX' : 'cropY';
30821
+ const sizeProp = axis === 'left' ? 'width' : 'height';
30822
+ const elSize = axis === 'left' ? element.naturalWidth || element.width : element.naturalHeight || element.height;
30823
+ if (basePos === undefined || baseCrop === undefined) {
30824
+ return super.set(axis, newPos);
30825
+ }
30826
+
30827
+ // Calculate total delta from drag start position
30828
+ const totalDelta = newPos - basePos;
30829
+
30830
+ // Convert screen delta to source image pixels
30831
+ // Dragging right (positive delta) should move the visible content right
30832
+ // This means showing content from further LEFT in the source = cropX decreases
30833
+ // So we negate the delta. Use Math.abs(scale) to handle flipped images.
30834
+ const cropOffset = -totalDelta / Math.abs(scale);
30835
+
30836
+ // Calculate new crop value from baseline crop + offset
30837
+ const currentSize = this[sizeProp] || elSize;
30838
+ let newCrop = baseCrop + cropOffset;
30839
+
30840
+ // Clamp to valid range: 0 to (elSize - visible size)
30841
+ const maxCrop = Math.max(0, elSize - currentSize);
30842
+ newCrop = Math.max(0, Math.min(maxCrop, newCrop));
30843
+
30844
+ // Update crop value
30845
+ super.set(cropProp, newCrop);
30846
+ // Keep position fixed at baseline
30847
+ super.set(axis, basePos);
30848
+ // Mark as dirty for re-render
30849
+ this.dirty = true;
30850
+ return this;
30851
+ }
30852
+
30853
+ /**
30854
+ * Reset crop mode drag state when drag ends
30855
+ * Called by canvas on mouse up
30856
+ */
30857
+ _onMouseUp() {
30858
+ if (this.cropMode) {
30859
+ this._cropModeDragActive = false;
30860
+ }
30861
+ }
30862
+
30248
30863
  /**
30249
30864
  * Constructor
30250
30865
  * Image can be initialized with any canvas drawable or a string.
@@ -30290,6 +30905,19 @@
30290
30905
  * @type Number
30291
30906
  */
30292
30907
  _defineProperty(this, "_filterScalingY", 1);
30908
+ /**
30909
+ * Backup of normal controls when entering crop mode
30910
+ */
30911
+ _defineProperty(this, "_normalControls", void 0);
30912
+ /**
30913
+ * Original position and crop values for drag-to-reposition in crop mode
30914
+ * Updated at the start of each drag
30915
+ */
30916
+ _defineProperty(this, "_cropModeOriginalLeft", void 0);
30917
+ _defineProperty(this, "_cropModeOriginalTop", void 0);
30918
+ _defineProperty(this, "_cropModeOriginalCropX", void 0);
30919
+ _defineProperty(this, "_cropModeOriginalCropY", void 0);
30920
+ _defineProperty(this, "_cropModeDragActive", void 0);
30293
30921
  this.filters = [];
30294
30922
  Object.assign(this, FabricImage.ownDefaults);
30295
30923
  this.setOptions(options);
@@ -30648,6 +31276,10 @@
30648
31276
  * @return {Boolean}
30649
31277
  */
30650
31278
  shouldCache() {
31279
+ // Don't cache in crop mode - we need to render the full image
31280
+ if (this.cropMode) {
31281
+ return false;
31282
+ }
30651
31283
  return this.needsItsOwnCache();
30652
31284
  }
30653
31285
  _renderFill(ctx) {
@@ -30673,7 +31305,87 @@
30673
31305
  y = -h / 2,
30674
31306
  maxDestW = Math.min(w, elWidth / scaleX - cropX),
30675
31307
  maxDestH = Math.min(h, elHeight / scaleY - cropY);
30676
- elementToDraw && ctx.drawImage(elementToDraw, sX, sY, sW, sH, x, y, maxDestW, maxDestH);
31308
+ if (this.cropMode) {
31309
+ // In crop mode: show full image with crop area highlighted
31310
+ this._renderCropMode(ctx, elementToDraw, elWidth, elHeight, scaleX, scaleY);
31311
+ } else {
31312
+ // Normal mode: just draw the cropped portion
31313
+ elementToDraw && ctx.drawImage(elementToDraw, sX, sY, sW, sH, x, y, maxDestW, maxDestH);
31314
+ }
31315
+ }
31316
+
31317
+ /**
31318
+ * Render the image in crop mode - shows full image dimmed with crop area highlighted
31319
+ * @private
31320
+ */
31321
+ _renderCropMode(ctx, elementToDraw, elWidth, elHeight, scaleX, scaleY) {
31322
+ const w = this.width,
31323
+ h = this.height,
31324
+ cropX = Math.max(this.cropX, 0),
31325
+ cropY = Math.max(this.cropY, 0);
31326
+
31327
+ // Calculate full image dimensions at current scale
31328
+ const fullW = elWidth / scaleX;
31329
+ const fullH = elHeight / scaleY;
31330
+
31331
+ // Position of the full image (crop area is centered at 0,0)
31332
+ // The crop window starts at (cropX, cropY) in the original image
31333
+ // We want the crop window to be at (-w/2, -h/2) to (w/2, h/2)
31334
+ // So the full image starts at (-w/2 - cropX, -h/2 - cropY)
31335
+ const fullX = -w / 2 - cropX;
31336
+ const fullY = -h / 2 - cropY;
31337
+
31338
+ // Draw the FULL image dimmed (outside crop area)
31339
+ ctx.save();
31340
+ ctx.globalAlpha = 0.3;
31341
+ ctx.drawImage(elementToDraw, 0, 0, elWidth, elHeight,
31342
+ // source: full image
31343
+ fullX, fullY, fullW, fullH // dest: positioned so crop area is centered
31344
+ );
31345
+ ctx.restore();
31346
+
31347
+ // Draw dark overlay on the dimmed parts (outside crop area)
31348
+ ctx.save();
31349
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
31350
+ // Left side
31351
+ if (cropX > 0) {
31352
+ ctx.fillRect(fullX, fullY, cropX, fullH);
31353
+ }
31354
+ // Right side
31355
+ const rightStart = -w / 2 + w;
31356
+ const rightWidth = fullW - cropX - w;
31357
+ if (rightWidth > 0) {
31358
+ ctx.fillRect(rightStart, fullY, rightWidth, fullH);
31359
+ }
31360
+ // Top side (between left and right)
31361
+ if (cropY > 0) {
31362
+ ctx.fillRect(-w / 2, fullY, w, cropY);
31363
+ }
31364
+ // Bottom side (between left and right)
31365
+ const bottomStart = -h / 2 + h;
31366
+ const bottomHeight = fullH - cropY - h;
31367
+ if (bottomHeight > 0) {
31368
+ ctx.fillRect(-w / 2, bottomStart, w, bottomHeight);
31369
+ }
31370
+ ctx.restore();
31371
+
31372
+ // Draw the crop area at FULL opacity
31373
+ const sX = cropX * scaleX,
31374
+ sY = cropY * scaleY,
31375
+ sW = Math.min(w * scaleX, elWidth - sX),
31376
+ sH = Math.min(h * scaleY, elHeight - sY),
31377
+ x = -w / 2,
31378
+ y = -h / 2,
31379
+ maxDestW = Math.min(w, elWidth / scaleX - cropX),
31380
+ maxDestH = Math.min(h, elHeight / scaleY - cropY);
31381
+ ctx.drawImage(elementToDraw, sX, sY, sW, sH, x, y, maxDestW, maxDestH);
31382
+
31383
+ // Draw a border around the crop area
31384
+ ctx.save();
31385
+ ctx.strokeStyle = '#fff';
31386
+ ctx.lineWidth = 2;
31387
+ ctx.strokeRect(-w / 2, -h / 2, w, h);
31388
+ ctx.restore();
30677
31389
  }
30678
31390
 
30679
31391
  /**
@@ -31032,6 +31744,11 @@
31032
31744
  * @private
31033
31745
  */
31034
31746
  _defineProperty(this, "_editModeObjectCaching", void 0);
31747
+ /**
31748
+ * Stored original controls of content image before edit mode
31749
+ * @private
31750
+ */
31751
+ _defineProperty(this, "_editModeOriginalControls", void 0);
31035
31752
  /**
31036
31753
  * Bound constraint handler references for cleanup
31037
31754
  * @private
@@ -31654,6 +32371,15 @@
31654
32371
  });
31655
32372
  this._contentImage.dirty = true;
31656
32373
 
32374
+ // Save original controls and replace with corner-only controls for scaling
32375
+ this._editModeOriginalControls = this._contentImage.controls;
32376
+ this._contentImage.controls = {
32377
+ tl: this._contentImage.controls.tl,
32378
+ tr: this._contentImage.controls.tr,
32379
+ bl: this._contentImage.controls.bl,
32380
+ br: this._contentImage.controls.br
32381
+ };
32382
+
31657
32383
  // Store and remove clipPath to show full image
31658
32384
  // We must actually remove it (not just skip rendering) because Fabric's
31659
32385
  // rendering pipeline checks clipPath existence in multiple places
@@ -31951,6 +32677,12 @@
31951
32677
  contentScaleY: contentScaleY
31952
32678
  };
31953
32679
 
32680
+ // Restore original controls before making non-interactive
32681
+ if (this._editModeOriginalControls) {
32682
+ this._contentImage.controls = this._editModeOriginalControls;
32683
+ this._editModeOriginalControls = undefined;
32684
+ }
32685
+
31954
32686
  // Make content non-interactive again and restore caching
31955
32687
  this._contentImage.set({
31956
32688
  selectable: false,
@@ -33504,6 +34236,8 @@
33504
34236
  changeWidth: changeWidth,
33505
34237
  createDefaultExpansion: createDefaultExpansion,
33506
34238
  createExpandControls: createExpandControls,
34239
+ createImageCropControls: createImageCropControls,
34240
+ createImageCropModeControls: createImageCropModeControls,
33507
34241
  createObjectDefaultControls: createObjectDefaultControls,
33508
34242
  createPathControls: createPathControls,
33509
34243
  createPolyActionHandler: createPolyActionHandler,
@@ -33511,6 +34245,14 @@
33511
34245
  createPolyPositionHandler: createPolyPositionHandler,
33512
34246
  createResizeControls: createResizeControls,
33513
34247
  createTextboxDefaultControls: createTextboxDefaultControls,
34248
+ cropFromBottom: cropFromBottom,
34249
+ cropFromBottomLeft: cropFromBottomLeft,
34250
+ cropFromBottomRight: cropFromBottomRight,
34251
+ cropFromLeft: cropFromLeft,
34252
+ cropFromRight: cropFromRight,
34253
+ cropFromTop: cropFromTop,
34254
+ cropFromTopLeft: cropFromTopLeft,
34255
+ cropFromTopRight: cropFromTopRight,
33514
34256
  dragHandler: dragHandler,
33515
34257
  expandBottomHandler: expandBottomHandler,
33516
34258
  expandLeftHandler: expandLeftHandler,