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