@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
@@ -410,7 +410,7 @@ class Cache {
410
410
  }
411
411
  const cache = new Cache();
412
412
 
413
- var version = "7.0.1-beta36";
413
+ var version = "7.0.1-beta38";
414
414
 
415
415
  // use this syntax so babel plugin see this import here
416
416
  const VERSION = version;
@@ -10442,21 +10442,19 @@ class InteractiveFabricObject extends FabricObject$1 {
10442
10442
  * This ensures correct behavior with zoom and grouped objects.
10443
10443
  */
10444
10444
  drawExpandPreview(ctx, size, options) {
10445
- var _this$canvas6, _this$canvas7;
10446
10445
  if (!this.expandMode) return;
10447
10446
 
10448
- // Use options.scaleX/scaleY from qrDecompose which includes parent transforms and vpt
10449
- // This correctly handles grouped objects (like content images inside Frames)
10450
- // options comes from qrDecompose(vpt * calcTransformMatrix()) which includes:
10451
- // 1. Viewport zoom
10452
- // 2. Parent group's scale (Frame's scale)
10453
- // 3. Object's own scale
10454
- 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);
10455
- 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);
10447
+ // Use size but subtract borderScaleFactor and padding to match control positioning
10448
+ const borderOffset = this.borderScaleFactor || 1;
10449
+ const paddingOffset = (this.padding || 0) * 2;
10456
10450
  const dim = {
10457
- x: (this.width || 1) * scaleX,
10458
- y: (this.height || 1) * scaleY
10451
+ x: size.x - borderOffset - paddingOffset,
10452
+ y: size.y - borderOffset - paddingOffset
10459
10453
  };
10454
+
10455
+ // Calculate scale factor from dim for expansion scaling
10456
+ const scaleX = dim.x / (this.width || 1);
10457
+ const scaleY = dim.y / (this.height || 1);
10460
10458
  const {
10461
10459
  expandLeft,
10462
10460
  expandRight,
@@ -29871,6 +29869,474 @@ _defineProperty(Textbox, "textLayoutProperties", [...IText.textLayoutProperties,
29871
29869
  _defineProperty(Textbox, "ownDefaults", textboxDefaultValues);
29872
29870
  classRegistry.setClass(Textbox);
29873
29871
 
29872
+ /**
29873
+ * Minimum size for cropped images (in pixels)
29874
+ */
29875
+ const MIN_SIZE = 20;
29876
+
29877
+ /**
29878
+ * Get the original element dimensions for an image
29879
+ */
29880
+ function getOriginalDimensions(target) {
29881
+ const element = target.getElement();
29882
+ if (!element) {
29883
+ return {
29884
+ width: target.width || 100,
29885
+ height: target.height || 100
29886
+ };
29887
+ }
29888
+ return {
29889
+ width: element.naturalWidth || element.width || target.width || 100,
29890
+ height: element.naturalHeight || element.height || target.height || 100
29891
+ };
29892
+ }
29893
+
29894
+ /**
29895
+ * Handler for resizing from RIGHT edge (mr control) - Canva style
29896
+ * Crops from right side, anchor on left
29897
+ */
29898
+ const resizeFromRightHandler = (eventData, transform, x, y) => {
29899
+ const target = transform.target;
29900
+ const original = getOriginalDimensions(target);
29901
+ const currentScale = target.scaleX || 1;
29902
+ const cropX = target.cropX || 0;
29903
+ const localPoint = getLocalPoint(transform, transform.originX, transform.originY, x, y);
29904
+ const requestedVisualWidth = Math.max(MIN_SIZE, Math.abs(localPoint.x));
29905
+ const maxAvailableWidth = (original.width - cropX) * currentScale;
29906
+ if (requestedVisualWidth <= maxAvailableWidth) {
29907
+ // Within bounds - just change visible width, cropX stays same (crops from right)
29908
+ target.width = requestedVisualWidth / currentScale;
29909
+ } else {
29910
+ // Beyond bounds - scale uniformly
29911
+ target.width = original.width - cropX;
29912
+ const newScale = requestedVisualWidth / target.width;
29913
+ target.scaleX = newScale;
29914
+ target.scaleY = newScale;
29915
+ const currentVisualHeight = (target.height || original.height) * currentScale;
29916
+ target.height = currentVisualHeight / newScale;
29917
+ }
29918
+ target.setCoords();
29919
+ return true;
29920
+ };
29921
+
29922
+ /**
29923
+ * Handler for resizing from LEFT edge (ml control) - Canva style
29924
+ * Crops from left side, anchor on right
29925
+ */
29926
+ const resizeFromLeftHandler = (eventData, transform, x, y) => {
29927
+ const target = transform.target;
29928
+ const original = getOriginalDimensions(target);
29929
+ const currentScale = target.scaleX || 1;
29930
+ const currentCropX = target.cropX || 0;
29931
+ const currentWidth = target.width || original.width;
29932
+ const localPoint = getLocalPoint(transform, transform.originX, transform.originY, x, y);
29933
+ const requestedVisualWidth = Math.max(MIN_SIZE, Math.abs(localPoint.x));
29934
+
29935
+ // Right edge position in original image coords (stays fixed)
29936
+ const rightEdgeInOriginal = currentCropX + currentWidth;
29937
+
29938
+ // Maximum we can expand to the left (cropX can go to 0)
29939
+ const maxAvailableWidth = rightEdgeInOriginal * currentScale;
29940
+ if (requestedVisualWidth <= maxAvailableWidth) {
29941
+ // Within bounds - adjust cropX and width (crops from left)
29942
+ const newWidthUnscaled = requestedVisualWidth / currentScale;
29943
+ const newCropX = rightEdgeInOriginal - newWidthUnscaled;
29944
+ if (newCropX >= 0) {
29945
+ target.cropX = newCropX;
29946
+ target.width = newWidthUnscaled;
29947
+ } else {
29948
+ // Hit left boundary
29949
+ target.cropX = 0;
29950
+ target.width = rightEdgeInOriginal;
29951
+ }
29952
+ } else {
29953
+ // Beyond bounds - scale uniformly
29954
+ target.cropX = 0;
29955
+ target.width = rightEdgeInOriginal;
29956
+ const newScale = requestedVisualWidth / target.width;
29957
+ target.scaleX = newScale;
29958
+ target.scaleY = newScale;
29959
+ const currentVisualHeight = (target.height || original.height) * currentScale;
29960
+ target.height = currentVisualHeight / newScale;
29961
+ }
29962
+ target.setCoords();
29963
+ return true;
29964
+ };
29965
+
29966
+ /**
29967
+ * Handler for cropping from the right edge (mr control) - for crop mode
29968
+ * - Drag inward: decrease width (crop right side)
29969
+ * - Drag outward: increase width until hitting boundary, then scale
29970
+ */
29971
+ const cropFromRightHandler = (eventData, transform, x, y) => {
29972
+ const target = transform.target;
29973
+ const localPoint = getLocalPoint(transform, 'left', 'center', x, y);
29974
+ const original = getOriginalDimensions(target);
29975
+ const currentCropX = target.cropX || 0;
29976
+ const currentScale = target.scaleX || 1;
29977
+
29978
+ // Maximum visible width at current scale (from current cropX to right edge of original)
29979
+ const maxVisibleWidth = (original.width - currentCropX) * currentScale;
29980
+
29981
+ // Requested width based on mouse position
29982
+ const requestedWidth = Math.max(MIN_SIZE, localPoint.x);
29983
+ if (requestedWidth <= maxVisibleWidth) {
29984
+ // Within bounds - just change visible width (crop/uncrop)
29985
+ // Convert to unscaled width for the width property
29986
+ target.width = requestedWidth / currentScale;
29987
+ } else {
29988
+ // Beyond bounds - need to scale
29989
+ // First, set width to maximum possible
29990
+ target.width = original.width - currentCropX;
29991
+ // Calculate new scale to reach requested size
29992
+ const newScale = requestedWidth / target.width;
29993
+ target.scaleX = newScale;
29994
+ target.scaleY = newScale; // Uniform scaling
29995
+ }
29996
+ target.setCoords();
29997
+ return true;
29998
+ };
29999
+
30000
+ /**
30001
+ * Handler for resizing from BOTTOM edge (mb control) - Canva style
30002
+ * Crops from bottom side, anchor on top
30003
+ */
30004
+ const resizeFromBottomHandler = (eventData, transform, x, y) => {
30005
+ const target = transform.target;
30006
+ const original = getOriginalDimensions(target);
30007
+ const currentScale = target.scaleY || 1;
30008
+ const cropY = target.cropY || 0;
30009
+ const localPoint = getLocalPoint(transform, transform.originX, transform.originY, x, y);
30010
+ const requestedVisualHeight = Math.max(MIN_SIZE, Math.abs(localPoint.y));
30011
+ const maxAvailableHeight = (original.height - cropY) * currentScale;
30012
+ if (requestedVisualHeight <= maxAvailableHeight) {
30013
+ // Within bounds - just change visible height, cropY stays same (crops from bottom)
30014
+ target.height = requestedVisualHeight / currentScale;
30015
+ } else {
30016
+ // Beyond bounds - scale uniformly
30017
+ target.height = original.height - cropY;
30018
+ const newScale = requestedVisualHeight / target.height;
30019
+ target.scaleX = newScale;
30020
+ target.scaleY = newScale;
30021
+ const currentVisualWidth = (target.width || original.width) * currentScale;
30022
+ target.width = currentVisualWidth / newScale;
30023
+ }
30024
+ target.setCoords();
30025
+ return true;
30026
+ };
30027
+
30028
+ /**
30029
+ * Handler for resizing from TOP edge (mt control) - Canva style
30030
+ * Crops from top side, anchor on bottom
30031
+ */
30032
+ const resizeFromTopHandler = (eventData, transform, x, y) => {
30033
+ const target = transform.target;
30034
+ const original = getOriginalDimensions(target);
30035
+ const currentScale = target.scaleY || 1;
30036
+ const currentCropY = target.cropY || 0;
30037
+ const currentHeight = target.height || original.height;
30038
+ const localPoint = getLocalPoint(transform, transform.originX, transform.originY, x, y);
30039
+ const requestedVisualHeight = Math.max(MIN_SIZE, Math.abs(localPoint.y));
30040
+
30041
+ // Bottom edge position in original image coords (stays fixed)
30042
+ const bottomEdgeInOriginal = currentCropY + currentHeight;
30043
+
30044
+ // Maximum we can expand to the top (cropY can go to 0)
30045
+ const maxAvailableHeight = bottomEdgeInOriginal * currentScale;
30046
+ if (requestedVisualHeight <= maxAvailableHeight) {
30047
+ // Within bounds - adjust cropY and height (crops from top)
30048
+ const newHeightUnscaled = requestedVisualHeight / currentScale;
30049
+ const newCropY = bottomEdgeInOriginal - newHeightUnscaled;
30050
+ if (newCropY >= 0) {
30051
+ target.cropY = newCropY;
30052
+ target.height = newHeightUnscaled;
30053
+ } else {
30054
+ // Hit top boundary
30055
+ target.cropY = 0;
30056
+ target.height = bottomEdgeInOriginal;
30057
+ }
30058
+ } else {
30059
+ // Beyond bounds - scale uniformly
30060
+ target.cropY = 0;
30061
+ target.height = bottomEdgeInOriginal;
30062
+ const newScale = requestedVisualHeight / target.height;
30063
+ target.scaleX = newScale;
30064
+ target.scaleY = newScale;
30065
+ const currentVisualWidth = (target.width || original.width) * currentScale;
30066
+ target.width = currentVisualWidth / newScale;
30067
+ }
30068
+ target.setCoords();
30069
+ return true;
30070
+ };
30071
+
30072
+ // Wrapped resize handlers with fixed anchor
30073
+ const resizeFromRight = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(resizeFromRightHandler));
30074
+ const resizeFromLeft = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(resizeFromLeftHandler));
30075
+ const resizeFromBottom = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(resizeFromBottomHandler));
30076
+ const resizeFromTop = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(resizeFromTopHandler));
30077
+
30078
+ /**
30079
+ * Handler for cropping from the left edge (ml control)
30080
+ * - Drag inward: increase cropX, decrease width (crop left side)
30081
+ * - Drag outward: decrease cropX until 0, then scale
30082
+ */
30083
+ const cropFromLeftHandler = (eventData, transform, x, y) => {
30084
+ const target = transform.target;
30085
+ const localPoint = getLocalPoint(transform, 'right', 'center', x, y);
30086
+ getOriginalDimensions(target);
30087
+ const currentCropX = target.cropX || 0;
30088
+ const currentWidth = target.width || 100;
30089
+ const currentScale = target.scaleX || 1;
30090
+
30091
+ // Requested width based on mouse position (localPoint.x is negative from right origin)
30092
+ const requestedWidth = Math.max(MIN_SIZE, Math.abs(localPoint.x));
30093
+
30094
+ // Current right edge position in original image coordinates
30095
+ const rightEdge = currentCropX + currentWidth;
30096
+
30097
+ // Maximum possible width at current scale (if cropX goes to 0)
30098
+ const maxVisibleWidth = rightEdge * currentScale;
30099
+ if (requestedWidth <= maxVisibleWidth) {
30100
+ // Within bounds - adjust cropX and width
30101
+ const newWidthUnscaled = requestedWidth / currentScale;
30102
+ const newCropX = rightEdge - newWidthUnscaled;
30103
+ if (newCropX >= 0) {
30104
+ target.cropX = newCropX;
30105
+ target.width = newWidthUnscaled;
30106
+ } else {
30107
+ // Would go past left edge - clamp cropX to 0
30108
+ target.cropX = 0;
30109
+ target.width = rightEdge;
30110
+ }
30111
+ } else {
30112
+ // Beyond bounds - need to scale
30113
+ target.cropX = 0;
30114
+ target.width = rightEdge;
30115
+ const newScale = requestedWidth / target.width;
30116
+ target.scaleX = newScale;
30117
+ target.scaleY = newScale;
30118
+ }
30119
+ target.setCoords();
30120
+ return true;
30121
+ };
30122
+
30123
+ /**
30124
+ * Handler for cropping from the bottom edge (mb control)
30125
+ */
30126
+ const cropFromBottomHandler = (eventData, transform, x, y) => {
30127
+ const target = transform.target;
30128
+ const localPoint = getLocalPoint(transform, 'center', 'top', x, y);
30129
+ const original = getOriginalDimensions(target);
30130
+ const currentCropY = target.cropY || 0;
30131
+ const currentScale = target.scaleY || 1;
30132
+ const maxVisibleHeight = (original.height - currentCropY) * currentScale;
30133
+ const requestedHeight = Math.max(MIN_SIZE, localPoint.y);
30134
+ if (requestedHeight <= maxVisibleHeight) {
30135
+ target.height = requestedHeight / currentScale;
30136
+ } else {
30137
+ target.height = original.height - currentCropY;
30138
+ const newScale = requestedHeight / target.height;
30139
+ target.scaleX = newScale;
30140
+ target.scaleY = newScale;
30141
+ }
30142
+ target.setCoords();
30143
+ return true;
30144
+ };
30145
+
30146
+ /**
30147
+ * Handler for cropping from the top edge (mt control)
30148
+ */
30149
+ const cropFromTopHandler = (eventData, transform, x, y) => {
30150
+ const target = transform.target;
30151
+ const localPoint = getLocalPoint(transform, 'center', 'bottom', x, y);
30152
+ getOriginalDimensions(target);
30153
+ const currentCropY = target.cropY || 0;
30154
+ const currentHeight = target.height || 100;
30155
+ const currentScale = target.scaleY || 1;
30156
+ const requestedHeight = Math.max(MIN_SIZE, Math.abs(localPoint.y));
30157
+ const bottomEdge = currentCropY + currentHeight;
30158
+ const maxVisibleHeight = bottomEdge * currentScale;
30159
+ if (requestedHeight <= maxVisibleHeight) {
30160
+ const newHeightUnscaled = requestedHeight / currentScale;
30161
+ const newCropY = bottomEdge - newHeightUnscaled;
30162
+ if (newCropY >= 0) {
30163
+ target.cropY = newCropY;
30164
+ target.height = newHeightUnscaled;
30165
+ } else {
30166
+ target.cropY = 0;
30167
+ target.height = bottomEdge;
30168
+ }
30169
+ } else {
30170
+ target.cropY = 0;
30171
+ target.height = bottomEdge;
30172
+ const newScale = requestedHeight / target.height;
30173
+ target.scaleX = newScale;
30174
+ target.scaleY = newScale;
30175
+ }
30176
+ target.setCoords();
30177
+ return true;
30178
+ };
30179
+
30180
+ /**
30181
+ * Handler for cropping from corners (tl, tr, bl, br controls)
30182
+ * Handles both dimensions simultaneously
30183
+ */
30184
+ const createCornerCropHandler = (xDirection, yDirection) => {
30185
+ const xHandler = xDirection === 'left' ? cropFromLeftHandler : cropFromRightHandler;
30186
+ const yHandler = yDirection === 'top' ? cropFromTopHandler : cropFromBottomHandler;
30187
+ return (eventData, transform, x, y) => {
30188
+ // Apply both handlers
30189
+ const xChanged = xHandler(eventData, transform, x, y);
30190
+ const yChanged = yHandler(eventData, transform, x, y);
30191
+ return xChanged || yChanged;
30192
+ };
30193
+ };
30194
+
30195
+ // Wrapped handlers with fixed anchor and fire event
30196
+ const cropFromRight = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(cropFromRightHandler));
30197
+ const cropFromLeft = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(cropFromLeftHandler));
30198
+ const cropFromBottom = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(cropFromBottomHandler));
30199
+ const cropFromTop = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(cropFromTopHandler));
30200
+ const cropFromTopLeft = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(createCornerCropHandler('left', 'top')));
30201
+ const cropFromTopRight = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(createCornerCropHandler('right', 'top')));
30202
+ const cropFromBottomLeft = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(createCornerCropHandler('left', 'bottom')));
30203
+ const cropFromBottomRight = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(createCornerCropHandler('right', 'bottom')));
30204
+
30205
+ /**
30206
+ * Creates Canva-like controls for FabricImage
30207
+ * - Side handles crop/resize visible area (Canva style)
30208
+ * - Corner handles scale uniformly
30209
+ * - Double-click enters crop mode for fine-tuning
30210
+ */
30211
+ const createImageCropControls = () => ({
30212
+ // Side controls - crop from each side
30213
+ ml: new Control({
30214
+ x: -0.5,
30215
+ y: 0,
30216
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30217
+ actionHandler: resizeFromLeft,
30218
+ actionName: RESIZING,
30219
+ render: renderHorizontalPillControl,
30220
+ sizeX: 6,
30221
+ sizeY: 20
30222
+ }),
30223
+ mr: new Control({
30224
+ x: 0.5,
30225
+ y: 0,
30226
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30227
+ actionHandler: resizeFromRight,
30228
+ actionName: RESIZING,
30229
+ render: renderHorizontalPillControl,
30230
+ sizeX: 6,
30231
+ sizeY: 20
30232
+ }),
30233
+ mb: new Control({
30234
+ x: 0,
30235
+ y: 0.5,
30236
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30237
+ actionHandler: resizeFromBottom,
30238
+ actionName: RESIZING,
30239
+ render: renderVerticalPillControl,
30240
+ sizeX: 20,
30241
+ sizeY: 6
30242
+ }),
30243
+ mt: new Control({
30244
+ x: 0,
30245
+ y: -0.5,
30246
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30247
+ actionHandler: resizeFromTop,
30248
+ actionName: RESIZING,
30249
+ render: renderVerticalPillControl,
30250
+ sizeX: 20,
30251
+ sizeY: 6
30252
+ }),
30253
+ // Corner controls - uniform scaling (like Canva)
30254
+ tl: new Control({
30255
+ x: -0.5,
30256
+ y: -0.5,
30257
+ cursorStyleHandler: scaleCursorStyleHandler,
30258
+ actionHandler: scalingEqually
30259
+ }),
30260
+ tr: new Control({
30261
+ x: 0.5,
30262
+ y: -0.5,
30263
+ cursorStyleHandler: scaleCursorStyleHandler,
30264
+ actionHandler: scalingEqually
30265
+ }),
30266
+ bl: new Control({
30267
+ x: -0.5,
30268
+ y: 0.5,
30269
+ cursorStyleHandler: scaleCursorStyleHandler,
30270
+ actionHandler: scalingEqually
30271
+ }),
30272
+ br: new Control({
30273
+ x: 0.5,
30274
+ y: 0.5,
30275
+ cursorStyleHandler: scaleCursorStyleHandler,
30276
+ actionHandler: scalingEqually
30277
+ }),
30278
+ mtr: new Control({
30279
+ x: 0,
30280
+ y: -0.5,
30281
+ actionHandler: rotationWithSnapping,
30282
+ cursorStyleHandler: rotationStyleHandler,
30283
+ offsetY: -40,
30284
+ withConnection: true,
30285
+ actionName: ROTATE
30286
+ })
30287
+ });
30288
+
30289
+ /**
30290
+ * Creates crop mode controls for FabricImage (used in crop mode after double-click)
30291
+ * - Side handles crop/uncrop single axis
30292
+ * - Corner handles crop/uncrop both axes
30293
+ */
30294
+ const createImageCropModeControls = () => ({
30295
+ ml: new Control({
30296
+ x: -0.5,
30297
+ y: 0,
30298
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30299
+ actionHandler: cropFromLeft,
30300
+ actionName: RESIZING,
30301
+ render: renderHorizontalPillControl,
30302
+ sizeX: 6,
30303
+ sizeY: 20
30304
+ }),
30305
+ mr: new Control({
30306
+ x: 0.5,
30307
+ y: 0,
30308
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30309
+ actionHandler: cropFromRight,
30310
+ actionName: RESIZING,
30311
+ render: renderHorizontalPillControl,
30312
+ sizeX: 6,
30313
+ sizeY: 20
30314
+ }),
30315
+ mb: new Control({
30316
+ x: 0,
30317
+ y: 0.5,
30318
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30319
+ actionHandler: cropFromBottom,
30320
+ actionName: RESIZING,
30321
+ render: renderVerticalPillControl,
30322
+ sizeX: 20,
30323
+ sizeY: 6
30324
+ }),
30325
+ mt: new Control({
30326
+ x: 0,
30327
+ y: -0.5,
30328
+ cursorStyleHandler: scaleSkewCursorStyleHandler,
30329
+ actionHandler: cropFromTop,
30330
+ actionName: RESIZING,
30331
+ render: renderVerticalPillControl,
30332
+ sizeX: 20,
30333
+ sizeY: 6
30334
+ })
30335
+
30336
+ // No corner controls in crop mode - or could add corner crop handlers
30337
+ // No rotation in crop mode
30338
+ });
30339
+
29874
30340
  /**
29875
30341
  * Canvas 2D filter backend.
29876
30342
  */
@@ -30281,7 +30747,8 @@ const imageDefaultValues = {
30281
30747
  minimumScaleTrigger: 0.5,
30282
30748
  cropX: 0,
30283
30749
  cropY: 0,
30284
- imageSmoothing: true
30750
+ imageSmoothing: true,
30751
+ cropMode: false
30285
30752
  };
30286
30753
  const IMAGE_PROPS = ['cropX', 'cropY'];
30287
30754
 
@@ -30295,6 +30762,154 @@ class FabricImage extends FabricObject {
30295
30762
  ...FabricImage.ownDefaults
30296
30763
  };
30297
30764
  }
30765
+
30766
+ /**
30767
+ * Creates Canva-like controls for images
30768
+ * - All handles scale uniformly
30769
+ * - Double-click to enter crop mode
30770
+ */
30771
+ static createControls() {
30772
+ return {
30773
+ controls: createImageCropControls()
30774
+ };
30775
+ }
30776
+
30777
+ /**
30778
+ * Enter crop mode - switches to crop controls
30779
+ * Call this on double-click
30780
+ */
30781
+ enterCropMode() {
30782
+ var _this$canvas;
30783
+ if (this.cropMode) return;
30784
+ this.cropMode = true;
30785
+ // Backup current controls
30786
+ this._normalControls = {
30787
+ ...this.controls
30788
+ };
30789
+ // Switch to crop mode controls
30790
+ this.controls = createImageCropModeControls();
30791
+ // Dirty cache to force re-render with full image visible
30792
+ this.dirty = true;
30793
+ // Reset drag state
30794
+ this._cropModeDragActive = false;
30795
+ this.setCoords();
30796
+ (_this$canvas = this.canvas) === null || _this$canvas === void 0 || _this$canvas.requestRenderAll();
30797
+ }
30798
+
30799
+ /**
30800
+ * Exit crop mode - restores normal controls
30801
+ * Call this on click outside or escape
30802
+ */
30803
+ exitCropMode() {
30804
+ var _this$canvas2;
30805
+ if (!this.cropMode) return;
30806
+ this.cropMode = false;
30807
+ // Restore normal controls
30808
+ if (this._normalControls) {
30809
+ this.controls = this._normalControls;
30810
+ this._normalControls = undefined;
30811
+ } else {
30812
+ this.controls = createImageCropControls();
30813
+ }
30814
+ // Dirty cache to force re-render with cropped image only
30815
+ this.dirty = true;
30816
+ this.setCoords();
30817
+ (_this$canvas2 = this.canvas) === null || _this$canvas2 === void 0 || _this$canvas2.requestRenderAll();
30818
+ }
30819
+
30820
+ /**
30821
+ * Toggle crop mode
30822
+ */
30823
+ toggleCropMode() {
30824
+ if (this.cropMode) {
30825
+ this.exitCropMode();
30826
+ } else {
30827
+ this.enterCropMode();
30828
+ }
30829
+ }
30830
+
30831
+ /**
30832
+ * Override set to intercept movement in crop mode
30833
+ * In crop mode, dragging adjusts cropX/cropY instead of left/top
30834
+ */
30835
+ // @ts-ignore - override set with different signature for crop mode handling
30836
+ set(key, value) {
30837
+ // Only intercept in crop mode when actually dragging (isMoving is true)
30838
+ if (this.cropMode && this.isMoving && typeof key === 'string') {
30839
+ if (key === 'left' && typeof value === 'number') {
30840
+ return this._setCropModePosition('left', value);
30841
+ }
30842
+ if (key === 'top' && typeof value === 'number') {
30843
+ return this._setCropModePosition('top', value);
30844
+ }
30845
+ }
30846
+ return super.set(key, value);
30847
+ }
30848
+
30849
+ /**
30850
+ * Handle position changes in crop mode - converts to cropX/cropY changes
30851
+ * @private
30852
+ */
30853
+ _setCropModePosition(axis, newPos) {
30854
+ const element = this._element;
30855
+ if (!element) {
30856
+ return super.set(axis, newPos);
30857
+ }
30858
+
30859
+ // Capture baseline values at the start of each new drag
30860
+ if (!this._cropModeDragActive) {
30861
+ this._cropModeDragActive = true;
30862
+ this._cropModeOriginalLeft = this.left;
30863
+ this._cropModeOriginalTop = this.top;
30864
+ this._cropModeOriginalCropX = this.cropX;
30865
+ this._cropModeOriginalCropY = this.cropY;
30866
+ }
30867
+ const scale = axis === 'left' ? this.scaleX || 1 : this.scaleY || 1;
30868
+ const basePos = axis === 'left' ? this._cropModeOriginalLeft : this._cropModeOriginalTop;
30869
+ const baseCrop = axis === 'left' ? this._cropModeOriginalCropX : this._cropModeOriginalCropY;
30870
+ const cropProp = axis === 'left' ? 'cropX' : 'cropY';
30871
+ const sizeProp = axis === 'left' ? 'width' : 'height';
30872
+ const elSize = axis === 'left' ? element.naturalWidth || element.width : element.naturalHeight || element.height;
30873
+ if (basePos === undefined || baseCrop === undefined) {
30874
+ return super.set(axis, newPos);
30875
+ }
30876
+
30877
+ // Calculate total delta from drag start position
30878
+ const totalDelta = newPos - basePos;
30879
+
30880
+ // Convert screen delta to source image pixels
30881
+ // Dragging right (positive delta) should move the visible content right
30882
+ // This means showing content from further LEFT in the source = cropX decreases
30883
+ // So we negate the delta. Use Math.abs(scale) to handle flipped images.
30884
+ const cropOffset = -totalDelta / Math.abs(scale);
30885
+
30886
+ // Calculate new crop value from baseline crop + offset
30887
+ const currentSize = this[sizeProp] || elSize;
30888
+ let newCrop = baseCrop + cropOffset;
30889
+
30890
+ // Clamp to valid range: 0 to (elSize - visible size)
30891
+ const maxCrop = Math.max(0, elSize - currentSize);
30892
+ newCrop = Math.max(0, Math.min(maxCrop, newCrop));
30893
+
30894
+ // Update crop value
30895
+ super.set(cropProp, newCrop);
30896
+ // Keep position fixed at baseline
30897
+ super.set(axis, basePos);
30898
+ // Mark as dirty for re-render
30899
+ this.dirty = true;
30900
+ return this;
30901
+ }
30902
+
30903
+ /**
30904
+ * Reset crop mode drag state when drag ends
30905
+ * Called by canvas on mouse up
30906
+ */
30907
+ _onMouseUp() {
30908
+ if (this.cropMode) {
30909
+ this._cropModeDragActive = false;
30910
+ }
30911
+ }
30912
+
30298
30913
  /**
30299
30914
  * Constructor
30300
30915
  * Image can be initialized with any canvas drawable or a string.
@@ -30340,6 +30955,19 @@ class FabricImage extends FabricObject {
30340
30955
  * @type Number
30341
30956
  */
30342
30957
  _defineProperty(this, "_filterScalingY", 1);
30958
+ /**
30959
+ * Backup of normal controls when entering crop mode
30960
+ */
30961
+ _defineProperty(this, "_normalControls", void 0);
30962
+ /**
30963
+ * Original position and crop values for drag-to-reposition in crop mode
30964
+ * Updated at the start of each drag
30965
+ */
30966
+ _defineProperty(this, "_cropModeOriginalLeft", void 0);
30967
+ _defineProperty(this, "_cropModeOriginalTop", void 0);
30968
+ _defineProperty(this, "_cropModeOriginalCropX", void 0);
30969
+ _defineProperty(this, "_cropModeOriginalCropY", void 0);
30970
+ _defineProperty(this, "_cropModeDragActive", void 0);
30343
30971
  this.filters = [];
30344
30972
  Object.assign(this, FabricImage.ownDefaults);
30345
30973
  this.setOptions(options);
@@ -30698,6 +31326,10 @@ class FabricImage extends FabricObject {
30698
31326
  * @return {Boolean}
30699
31327
  */
30700
31328
  shouldCache() {
31329
+ // Don't cache in crop mode - we need to render the full image
31330
+ if (this.cropMode) {
31331
+ return false;
31332
+ }
30701
31333
  return this.needsItsOwnCache();
30702
31334
  }
30703
31335
  _renderFill(ctx) {
@@ -30723,7 +31355,87 @@ class FabricImage extends FabricObject {
30723
31355
  y = -h / 2,
30724
31356
  maxDestW = Math.min(w, elWidth / scaleX - cropX),
30725
31357
  maxDestH = Math.min(h, elHeight / scaleY - cropY);
30726
- elementToDraw && ctx.drawImage(elementToDraw, sX, sY, sW, sH, x, y, maxDestW, maxDestH);
31358
+ if (this.cropMode) {
31359
+ // In crop mode: show full image with crop area highlighted
31360
+ this._renderCropMode(ctx, elementToDraw, elWidth, elHeight, scaleX, scaleY);
31361
+ } else {
31362
+ // Normal mode: just draw the cropped portion
31363
+ elementToDraw && ctx.drawImage(elementToDraw, sX, sY, sW, sH, x, y, maxDestW, maxDestH);
31364
+ }
31365
+ }
31366
+
31367
+ /**
31368
+ * Render the image in crop mode - shows full image dimmed with crop area highlighted
31369
+ * @private
31370
+ */
31371
+ _renderCropMode(ctx, elementToDraw, elWidth, elHeight, scaleX, scaleY) {
31372
+ const w = this.width,
31373
+ h = this.height,
31374
+ cropX = Math.max(this.cropX, 0),
31375
+ cropY = Math.max(this.cropY, 0);
31376
+
31377
+ // Calculate full image dimensions at current scale
31378
+ const fullW = elWidth / scaleX;
31379
+ const fullH = elHeight / scaleY;
31380
+
31381
+ // Position of the full image (crop area is centered at 0,0)
31382
+ // The crop window starts at (cropX, cropY) in the original image
31383
+ // We want the crop window to be at (-w/2, -h/2) to (w/2, h/2)
31384
+ // So the full image starts at (-w/2 - cropX, -h/2 - cropY)
31385
+ const fullX = -w / 2 - cropX;
31386
+ const fullY = -h / 2 - cropY;
31387
+
31388
+ // Draw the FULL image dimmed (outside crop area)
31389
+ ctx.save();
31390
+ ctx.globalAlpha = 0.3;
31391
+ ctx.drawImage(elementToDraw, 0, 0, elWidth, elHeight,
31392
+ // source: full image
31393
+ fullX, fullY, fullW, fullH // dest: positioned so crop area is centered
31394
+ );
31395
+ ctx.restore();
31396
+
31397
+ // Draw dark overlay on the dimmed parts (outside crop area)
31398
+ ctx.save();
31399
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
31400
+ // Left side
31401
+ if (cropX > 0) {
31402
+ ctx.fillRect(fullX, fullY, cropX, fullH);
31403
+ }
31404
+ // Right side
31405
+ const rightStart = -w / 2 + w;
31406
+ const rightWidth = fullW - cropX - w;
31407
+ if (rightWidth > 0) {
31408
+ ctx.fillRect(rightStart, fullY, rightWidth, fullH);
31409
+ }
31410
+ // Top side (between left and right)
31411
+ if (cropY > 0) {
31412
+ ctx.fillRect(-w / 2, fullY, w, cropY);
31413
+ }
31414
+ // Bottom side (between left and right)
31415
+ const bottomStart = -h / 2 + h;
31416
+ const bottomHeight = fullH - cropY - h;
31417
+ if (bottomHeight > 0) {
31418
+ ctx.fillRect(-w / 2, bottomStart, w, bottomHeight);
31419
+ }
31420
+ ctx.restore();
31421
+
31422
+ // Draw the crop area at FULL opacity
31423
+ const sX = cropX * scaleX,
31424
+ sY = cropY * scaleY,
31425
+ sW = Math.min(w * scaleX, elWidth - sX),
31426
+ sH = Math.min(h * scaleY, elHeight - sY),
31427
+ x = -w / 2,
31428
+ y = -h / 2,
31429
+ maxDestW = Math.min(w, elWidth / scaleX - cropX),
31430
+ maxDestH = Math.min(h, elHeight / scaleY - cropY);
31431
+ ctx.drawImage(elementToDraw, sX, sY, sW, sH, x, y, maxDestW, maxDestH);
31432
+
31433
+ // Draw a border around the crop area
31434
+ ctx.save();
31435
+ ctx.strokeStyle = '#fff';
31436
+ ctx.lineWidth = 2;
31437
+ ctx.strokeRect(-w / 2, -h / 2, w, h);
31438
+ ctx.restore();
30727
31439
  }
30728
31440
 
30729
31441
  /**
@@ -31082,6 +31794,11 @@ class Frame extends Group {
31082
31794
  * @private
31083
31795
  */
31084
31796
  _defineProperty(this, "_editModeObjectCaching", void 0);
31797
+ /**
31798
+ * Stored original controls of content image before edit mode
31799
+ * @private
31800
+ */
31801
+ _defineProperty(this, "_editModeOriginalControls", void 0);
31085
31802
  /**
31086
31803
  * Bound constraint handler references for cleanup
31087
31804
  * @private
@@ -31704,6 +32421,15 @@ class Frame extends Group {
31704
32421
  });
31705
32422
  this._contentImage.dirty = true;
31706
32423
 
32424
+ // Save original controls and replace with corner-only controls for scaling
32425
+ this._editModeOriginalControls = this._contentImage.controls;
32426
+ this._contentImage.controls = {
32427
+ tl: this._contentImage.controls.tl,
32428
+ tr: this._contentImage.controls.tr,
32429
+ bl: this._contentImage.controls.bl,
32430
+ br: this._contentImage.controls.br
32431
+ };
32432
+
31707
32433
  // Store and remove clipPath to show full image
31708
32434
  // We must actually remove it (not just skip rendering) because Fabric's
31709
32435
  // rendering pipeline checks clipPath existence in multiple places
@@ -32001,6 +32727,12 @@ class Frame extends Group {
32001
32727
  contentScaleY: contentScaleY
32002
32728
  };
32003
32729
 
32730
+ // Restore original controls before making non-interactive
32731
+ if (this._editModeOriginalControls) {
32732
+ this._contentImage.controls = this._editModeOriginalControls;
32733
+ this._editModeOriginalControls = undefined;
32734
+ }
32735
+
32004
32736
  // Make content non-interactive again and restore caching
32005
32737
  this._contentImage.set({
32006
32738
  selectable: false,
@@ -33554,6 +34286,8 @@ var index = /*#__PURE__*/Object.freeze({
33554
34286
  changeWidth: changeWidth,
33555
34287
  createDefaultExpansion: createDefaultExpansion,
33556
34288
  createExpandControls: createExpandControls,
34289
+ createImageCropControls: createImageCropControls,
34290
+ createImageCropModeControls: createImageCropModeControls,
33557
34291
  createObjectDefaultControls: createObjectDefaultControls,
33558
34292
  createPathControls: createPathControls,
33559
34293
  createPolyActionHandler: createPolyActionHandler,
@@ -33561,6 +34295,14 @@ var index = /*#__PURE__*/Object.freeze({
33561
34295
  createPolyPositionHandler: createPolyPositionHandler,
33562
34296
  createResizeControls: createResizeControls,
33563
34297
  createTextboxDefaultControls: createTextboxDefaultControls,
34298
+ cropFromBottom: cropFromBottom,
34299
+ cropFromBottomLeft: cropFromBottomLeft,
34300
+ cropFromBottomRight: cropFromBottomRight,
34301
+ cropFromLeft: cropFromLeft,
34302
+ cropFromRight: cropFromRight,
34303
+ cropFromTop: cropFromTop,
34304
+ cropFromTopLeft: cropFromTopLeft,
34305
+ cropFromTopRight: cropFromTopRight,
33564
34306
  dragHandler: dragHandler,
33565
34307
  expandBottomHandler: expandBottomHandler,
33566
34308
  expandLeftHandler: expandLeftHandler,