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