fabric 7.1.0 → 7.2.0

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 (253) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/CHANGELOG.md +13 -0
  3. package/dist/extensions/cropping_controls/croppingControls.d.ts +12 -8
  4. package/dist/extensions/cropping_controls/croppingControls.d.ts.map +1 -1
  5. package/dist/extensions/cropping_controls/croppingHandlers.d.ts +19 -1
  6. package/dist/extensions/cropping_controls/croppingHandlers.d.ts.map +1 -1
  7. package/dist/extensions/cropping_controls/enterCropMode.d.ts.map +1 -1
  8. package/dist/index.js +189 -160
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.min.js +1 -1
  11. package/dist/index.min.js.map +1 -1
  12. package/dist/index.min.mjs +1 -1
  13. package/dist/index.min.mjs.map +1 -1
  14. package/dist/index.mjs +189 -160
  15. package/dist/index.mjs.map +1 -1
  16. package/dist/index.node.cjs +189 -160
  17. package/dist/index.node.cjs.map +1 -1
  18. package/dist/index.node.mjs +189 -160
  19. package/dist/index.node.mjs.map +1 -1
  20. package/dist/package.json.min.mjs +1 -1
  21. package/dist/package.json.mjs +1 -1
  22. package/dist/src/EventTypeDefs.d.ts +3 -0
  23. package/dist/src/EventTypeDefs.d.ts.map +1 -1
  24. package/dist/src/Pattern/Pattern.d.ts.map +1 -1
  25. package/dist/src/Pattern/Pattern.min.mjs +1 -1
  26. package/dist/src/Pattern/Pattern.min.mjs.map +1 -1
  27. package/dist/src/Pattern/Pattern.mjs +2 -1
  28. package/dist/src/Pattern/Pattern.mjs.map +1 -1
  29. package/dist/src/Shadow.d.ts +1 -1
  30. package/dist/src/Shadow.d.ts.map +1 -1
  31. package/dist/src/Shadow.min.mjs +1 -1
  32. package/dist/src/Shadow.min.mjs.map +1 -1
  33. package/dist/src/Shadow.mjs +5 -4
  34. package/dist/src/Shadow.mjs.map +1 -1
  35. package/dist/src/canvas/CanvasOptions.d.ts.map +1 -1
  36. package/dist/src/canvas/CanvasOptions.min.mjs.map +1 -1
  37. package/dist/src/canvas/CanvasOptions.mjs.map +1 -1
  38. package/dist/src/canvas/SelectableCanvas.d.ts +2 -0
  39. package/dist/src/canvas/SelectableCanvas.d.ts.map +1 -1
  40. package/dist/src/canvas/SelectableCanvas.min.mjs +1 -1
  41. package/dist/src/canvas/SelectableCanvas.min.mjs.map +1 -1
  42. package/dist/src/canvas/SelectableCanvas.mjs +6 -1
  43. package/dist/src/canvas/SelectableCanvas.mjs.map +1 -1
  44. package/dist/src/canvas/StaticCanvas.d.ts.map +1 -1
  45. package/dist/src/canvas/StaticCanvas.min.mjs +1 -1
  46. package/dist/src/canvas/StaticCanvas.min.mjs.map +1 -1
  47. package/dist/src/canvas/StaticCanvas.mjs +3 -1
  48. package/dist/src/canvas/StaticCanvas.mjs.map +1 -1
  49. package/dist/src/canvas/StaticCanvasOptions.d.ts.map +1 -1
  50. package/dist/src/canvas/StaticCanvasOptions.min.mjs.map +1 -1
  51. package/dist/src/canvas/StaticCanvasOptions.mjs.map +1 -1
  52. package/dist/src/controls/Control.d.ts +9 -1
  53. package/dist/src/controls/Control.d.ts.map +1 -1
  54. package/dist/src/controls/Control.min.mjs +1 -1
  55. package/dist/src/controls/Control.min.mjs.map +1 -1
  56. package/dist/src/controls/Control.mjs +8 -0
  57. package/dist/src/controls/Control.mjs.map +1 -1
  58. package/dist/src/gradient/Gradient.d.ts.map +1 -1
  59. package/dist/src/gradient/Gradient.min.mjs +1 -1
  60. package/dist/src/gradient/Gradient.min.mjs.map +1 -1
  61. package/dist/src/gradient/Gradient.mjs +19 -6
  62. package/dist/src/gradient/Gradient.mjs.map +1 -1
  63. package/dist/src/shapes/Circle.d.ts.map +1 -1
  64. package/dist/src/shapes/Circle.min.mjs +1 -1
  65. package/dist/src/shapes/Circle.min.mjs.map +1 -1
  66. package/dist/src/shapes/Circle.mjs +10 -7
  67. package/dist/src/shapes/Circle.mjs.map +1 -1
  68. package/dist/src/shapes/Ellipse.d.ts.map +1 -1
  69. package/dist/src/shapes/Ellipse.min.mjs +1 -1
  70. package/dist/src/shapes/Ellipse.min.mjs.map +1 -1
  71. package/dist/src/shapes/Ellipse.mjs +2 -1
  72. package/dist/src/shapes/Ellipse.mjs.map +1 -1
  73. package/dist/src/shapes/Group.d.ts.map +1 -1
  74. package/dist/src/shapes/Group.min.mjs +1 -1
  75. package/dist/src/shapes/Group.min.mjs.map +1 -1
  76. package/dist/src/shapes/Group.mjs +2 -1
  77. package/dist/src/shapes/Group.mjs.map +1 -1
  78. package/dist/src/shapes/IText/IText.d.ts.map +1 -1
  79. package/dist/src/shapes/IText/IText.min.mjs.map +1 -1
  80. package/dist/src/shapes/IText/IText.mjs.map +1 -1
  81. package/dist/src/shapes/Image.d.ts +1 -1
  82. package/dist/src/shapes/Image.d.ts.map +1 -1
  83. package/dist/src/shapes/Image.min.mjs +1 -1
  84. package/dist/src/shapes/Image.min.mjs.map +1 -1
  85. package/dist/src/shapes/Image.mjs +4 -3
  86. package/dist/src/shapes/Image.mjs.map +1 -1
  87. package/dist/src/shapes/Line.d.ts.map +1 -1
  88. package/dist/src/shapes/Line.min.mjs +1 -1
  89. package/dist/src/shapes/Line.min.mjs.map +1 -1
  90. package/dist/src/shapes/Line.mjs +6 -10
  91. package/dist/src/shapes/Line.mjs.map +1 -1
  92. package/dist/src/shapes/Object/FabricObjectSVGExportMixin.d.ts.map +1 -1
  93. package/dist/src/shapes/Object/FabricObjectSVGExportMixin.min.mjs +1 -1
  94. package/dist/src/shapes/Object/FabricObjectSVGExportMixin.min.mjs.map +1 -1
  95. package/dist/src/shapes/Object/FabricObjectSVGExportMixin.mjs +5 -4
  96. package/dist/src/shapes/Object/FabricObjectSVGExportMixin.mjs.map +1 -1
  97. package/dist/src/shapes/Object/InteractiveObject.d.ts.map +1 -1
  98. package/dist/src/shapes/Object/InteractiveObject.min.mjs.map +1 -1
  99. package/dist/src/shapes/Object/InteractiveObject.mjs.map +1 -1
  100. package/dist/src/shapes/Object/Object.d.ts.map +1 -1
  101. package/dist/src/shapes/Object/Object.min.mjs +1 -1
  102. package/dist/src/shapes/Object/Object.min.mjs.map +1 -1
  103. package/dist/src/shapes/Object/Object.mjs +3 -0
  104. package/dist/src/shapes/Object/Object.mjs.map +1 -1
  105. package/dist/src/shapes/Object/types/FabricObjectProps.d.ts.map +1 -1
  106. package/dist/src/shapes/Object/types/ObjectProps.d.ts.map +1 -1
  107. package/dist/src/shapes/Path.d.ts.map +1 -1
  108. package/dist/src/shapes/Path.min.mjs.map +1 -1
  109. package/dist/src/shapes/Path.mjs +1 -2
  110. package/dist/src/shapes/Path.mjs.map +1 -1
  111. package/dist/src/shapes/Polyline.d.ts.map +1 -1
  112. package/dist/src/shapes/Polyline.min.mjs +1 -1
  113. package/dist/src/shapes/Polyline.min.mjs.map +1 -1
  114. package/dist/src/shapes/Polyline.mjs +10 -6
  115. package/dist/src/shapes/Polyline.mjs.map +1 -1
  116. package/dist/src/shapes/Rect.d.ts.map +1 -1
  117. package/dist/src/shapes/Rect.min.mjs +1 -1
  118. package/dist/src/shapes/Rect.min.mjs.map +1 -1
  119. package/dist/src/shapes/Rect.mjs +2 -1
  120. package/dist/src/shapes/Rect.mjs.map +1 -1
  121. package/dist/src/shapes/Text/Text.d.ts.map +1 -1
  122. package/dist/src/shapes/Text/Text.min.mjs.map +1 -1
  123. package/dist/src/shapes/Text/Text.mjs.map +1 -1
  124. package/dist/src/shapes/Text/TextSVGExportMixin.min.mjs +1 -1
  125. package/dist/src/shapes/Text/TextSVGExportMixin.min.mjs.map +1 -1
  126. package/dist/src/shapes/Text/TextSVGExportMixin.mjs +5 -5
  127. package/dist/src/shapes/Text/TextSVGExportMixin.mjs.map +1 -1
  128. package/dist/src/shapes/Textbox.d.ts.map +1 -1
  129. package/dist/src/shapes/Textbox.min.mjs.map +1 -1
  130. package/dist/src/shapes/Textbox.mjs.map +1 -1
  131. package/dist/src/shapes/Triangle.d.ts.map +1 -1
  132. package/dist/src/shapes/Triangle.min.mjs.map +1 -1
  133. package/dist/src/shapes/Triangle.mjs.map +1 -1
  134. package/dist/src/util/lang_string.d.ts +1 -1
  135. package/dist/src/util/lang_string.d.ts.map +1 -1
  136. package/dist/src/util/lang_string.min.mjs +1 -1
  137. package/dist/src/util/lang_string.min.mjs.map +1 -1
  138. package/dist/src/util/lang_string.mjs +1 -1
  139. package/dist/src/util/lang_string.mjs.map +1 -1
  140. package/dist/src/util/misc/svgParsing.d.ts.map +1 -1
  141. package/dist/src/util/misc/svgParsing.min.mjs +1 -1
  142. package/dist/src/util/misc/svgParsing.min.mjs.map +1 -1
  143. package/dist/src/util/misc/svgParsing.mjs +2 -1
  144. package/dist/src/util/misc/svgParsing.mjs.map +1 -1
  145. package/dist-extensions/cropping_controls/croppingControls.mjs +39 -9
  146. package/dist-extensions/cropping_controls/croppingControls.mjs.map +1 -1
  147. package/dist-extensions/cropping_controls/croppingHandlers.mjs +84 -2
  148. package/dist-extensions/cropping_controls/croppingHandlers.mjs.map +1 -1
  149. package/dist-extensions/cropping_controls/enterCropMode.mjs +7 -2
  150. package/dist-extensions/cropping_controls/enterCropMode.mjs.map +1 -1
  151. package/dist-extensions/extensions/cropping_controls/croppingControls.d.ts +12 -8
  152. package/dist-extensions/extensions/cropping_controls/croppingControls.d.ts.map +1 -1
  153. package/dist-extensions/extensions/cropping_controls/croppingHandlers.d.ts +19 -1
  154. package/dist-extensions/extensions/cropping_controls/croppingHandlers.d.ts.map +1 -1
  155. package/dist-extensions/extensions/cropping_controls/enterCropMode.d.ts.map +1 -1
  156. package/dist-extensions/fabric-extensions.min.js +1 -1
  157. package/dist-extensions/fabric-extensions.min.js.map +1 -1
  158. package/dist-extensions/src/EventTypeDefs.d.ts +3 -0
  159. package/dist-extensions/src/EventTypeDefs.d.ts.map +1 -1
  160. package/dist-extensions/src/Pattern/Pattern.d.ts.map +1 -1
  161. package/dist-extensions/src/Shadow.d.ts +1 -1
  162. package/dist-extensions/src/Shadow.d.ts.map +1 -1
  163. package/dist-extensions/src/canvas/CanvasOptions.d.ts.map +1 -1
  164. package/dist-extensions/src/canvas/SelectableCanvas.d.ts +2 -0
  165. package/dist-extensions/src/canvas/SelectableCanvas.d.ts.map +1 -1
  166. package/dist-extensions/src/canvas/StaticCanvas.d.ts.map +1 -1
  167. package/dist-extensions/src/canvas/StaticCanvasOptions.d.ts.map +1 -1
  168. package/dist-extensions/src/controls/Control.d.ts +9 -1
  169. package/dist-extensions/src/controls/Control.d.ts.map +1 -1
  170. package/dist-extensions/src/gradient/Gradient.d.ts.map +1 -1
  171. package/dist-extensions/src/shapes/Circle.d.ts.map +1 -1
  172. package/dist-extensions/src/shapes/Ellipse.d.ts.map +1 -1
  173. package/dist-extensions/src/shapes/Group.d.ts.map +1 -1
  174. package/dist-extensions/src/shapes/IText/IText.d.ts.map +1 -1
  175. package/dist-extensions/src/shapes/Image.d.ts.map +1 -1
  176. package/dist-extensions/src/shapes/Line.d.ts.map +1 -1
  177. package/dist-extensions/src/shapes/Object/FabricObjectSVGExportMixin.d.ts.map +1 -1
  178. package/dist-extensions/src/shapes/Object/InteractiveObject.d.ts.map +1 -1
  179. package/dist-extensions/src/shapes/Object/Object.d.ts.map +1 -1
  180. package/dist-extensions/src/shapes/Object/types/FabricObjectProps.d.ts.map +1 -1
  181. package/dist-extensions/src/shapes/Object/types/ObjectProps.d.ts.map +1 -1
  182. package/dist-extensions/src/shapes/Path.d.ts +1 -1
  183. package/dist-extensions/src/shapes/Path.d.ts.map +1 -1
  184. package/dist-extensions/src/shapes/Polyline.d.ts.map +1 -1
  185. package/dist-extensions/src/shapes/Rect.d.ts.map +1 -1
  186. package/dist-extensions/src/shapes/Text/Text.d.ts.map +1 -1
  187. package/dist-extensions/src/shapes/Textbox.d.ts.map +1 -1
  188. package/dist-extensions/src/shapes/Triangle.d.ts.map +1 -1
  189. package/dist-extensions/src/util/lang_string.d.ts +1 -1
  190. package/dist-extensions/src/util/lang_string.d.ts.map +1 -1
  191. package/dist-extensions/src/util/misc/svgParsing.d.ts.map +1 -1
  192. package/eslint.config.mjs +2 -0
  193. package/extensions/cropping_controls/croppingControls.spec.ts +65 -27
  194. package/extensions/cropping_controls/croppingControls.ts +40 -8
  195. package/extensions/cropping_controls/croppingHandlers.spec.ts +355 -46
  196. package/extensions/cropping_controls/croppingHandlers.ts +123 -0
  197. package/extensions/cropping_controls/enterCropMode.ts +6 -2
  198. package/package.json +17 -8
  199. package/src/ClassRegistry.spec.ts +18 -19
  200. package/src/EventTypeDefs.ts +13 -11
  201. package/src/Pattern/Pattern.spec.ts +12 -0
  202. package/src/Pattern/Pattern.ts +3 -2
  203. package/src/Shadow.ts +9 -8
  204. package/src/brushes/PencilBrush.spec.ts +11 -11
  205. package/src/canvas/Canvas-dispose.spec.ts +8 -7
  206. package/src/canvas/Canvas.spec.ts +27 -29
  207. package/src/canvas/CanvasOptions.ts +2 -1
  208. package/src/canvas/SelectableCanvas.ts +11 -4
  209. package/src/canvas/StaticCanvas.spec.ts +20 -0
  210. package/src/canvas/StaticCanvas.ts +7 -4
  211. package/src/canvas/StaticCanvasOptions.ts +1 -3
  212. package/src/controls/Control.ts +24 -1
  213. package/src/gradient/Gradient.spec.ts +101 -46
  214. package/src/gradient/Gradient.ts +27 -14
  215. package/src/shapes/Circle.spec.ts +10 -39
  216. package/src/shapes/Circle.ts +11 -11
  217. package/src/shapes/Ellipse.spec.ts +8 -37
  218. package/src/shapes/Ellipse.ts +7 -7
  219. package/src/shapes/Group.ts +3 -3
  220. package/src/shapes/IText/IText-click-behavior.spec.ts +36 -49
  221. package/src/shapes/IText/IText.ts +5 -6
  222. package/src/shapes/IText/__snapshots__/ITextBehavior.test.ts.snap +6 -6
  223. package/src/shapes/Image.spec.ts +17 -33
  224. package/src/shapes/Image.ts +15 -11
  225. package/src/shapes/Line.spec.ts +4 -30
  226. package/src/shapes/Line.ts +11 -16
  227. package/src/shapes/Object/FabricObjectSVGExportMixin.ts +11 -4
  228. package/src/shapes/Object/InteractiveObject.ts +4 -4
  229. package/src/shapes/Object/Object.ts +6 -5
  230. package/src/shapes/Object/objectSvgExport.spec.ts +112 -0
  231. package/src/shapes/Object/types/FabricObjectProps.ts +1 -4
  232. package/src/shapes/Object/types/ObjectProps.ts +1 -3
  233. package/src/shapes/Path.spec.ts +4 -27
  234. package/src/shapes/Path.ts +2 -4
  235. package/src/shapes/Polygon.spec.ts +4 -31
  236. package/src/shapes/Polyline.spec.ts +4 -31
  237. package/src/shapes/Polyline.ts +11 -12
  238. package/src/shapes/Rect.spec.ts +25 -33
  239. package/src/shapes/Rect.ts +7 -7
  240. package/src/shapes/Text/Text.spec.ts +3 -32
  241. package/src/shapes/Text/Text.ts +5 -6
  242. package/src/shapes/Text/TextSVGExportMixin.ts +14 -14
  243. package/src/shapes/Text/__snapshots__/Text.spec.ts.snap +1 -1
  244. package/src/shapes/Textbox.spec.ts +5 -5
  245. package/src/shapes/Textbox.ts +6 -5
  246. package/src/shapes/Triangle.ts +4 -4
  247. package/src/shapes/__snapshots__/Image.spec.ts.snap +4 -4
  248. package/src/shapes/__snapshots__/Textbox.spec.ts.snap +5 -5
  249. package/src/util/lang_string.ts +3 -2
  250. package/src/util/misc/svgParsing.ts +2 -1
  251. package/tsconfig.spec.json +1 -0
  252. package/vitest.config.ts +12 -2
  253. package/vitest.extend.ts +6 -2
@@ -1,17 +1,18 @@
1
1
  import type { Transform } from 'fabric';
2
- import { FabricImage, Canvas } from 'fabric';
2
+ import { FabricImage, Canvas, Control, Point } from 'fabric';
3
+ import { createImageCroppingControls } from './croppingControls';
3
4
  import {
4
5
  changeImageWidth,
5
- changeCropWidth,
6
6
  changeImageHeight,
7
- changeCropHeight,
8
7
  changeImageCropX,
9
- changeCropX,
10
8
  changeImageCropY,
11
- changeCropY,
9
+ cropPanMoveHandler,
10
+ ghostScalePositionHandler,
11
+ scaleEquallyCropGenerator,
12
+ renderGhostImage,
12
13
  } from './croppingHandlers';
13
14
 
14
- import { describe, expect, test, beforeEach, afterEach } from 'vitest';
15
+ import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest';
15
16
 
16
17
  describe('croppingHandlers', () => {
17
18
  let canvas: Canvas;
@@ -26,7 +27,7 @@ describe('croppingHandlers', () => {
26
27
  corner,
27
28
  originX: origin.x,
28
29
  originY: origin.y,
29
- } as Transform;
30
+ } as unknown as Transform;
30
31
  }
31
32
 
32
33
  function createMockImage(
@@ -57,6 +58,8 @@ describe('croppingHandlers', () => {
57
58
  cropX,
58
59
  cropY,
59
60
  });
61
+ img.controls = createImageCroppingControls();
62
+
60
63
  return img;
61
64
  }
62
65
 
@@ -65,7 +68,7 @@ describe('croppingHandlers', () => {
65
68
  image = createMockImage();
66
69
  canvas.add(image);
67
70
  eventData = {};
68
- transform = prepareTransform(image, 'mr');
71
+ transform = prepareTransform(image, 'mrc');
69
72
  });
70
73
 
71
74
  afterEach(() => {
@@ -85,7 +88,7 @@ describe('croppingHandlers', () => {
85
88
  // Image element is 200px wide, cropX is 0, so max available is 200
86
89
  image = createMockImage({ width: 100, cropX: 50, elementWidth: 200 });
87
90
  canvas.add(image);
88
- transform = prepareTransform(image, 'mr');
91
+ transform = prepareTransform(image, 'mrc');
89
92
 
90
93
  // Try to set width beyond available (200 - 50 = 150 available)
91
94
  changeImageWidth(eventData, transform, 500, 50);
@@ -94,7 +97,7 @@ describe('croppingHandlers', () => {
94
97
 
95
98
  test('constrains width to minimum of 1 (lower limit)', () => {
96
99
  image = createMockImage({ width: 100, cropX: 50, elementWidth: 200 });
97
- transform = prepareTransform(image, 'mr');
100
+ transform = prepareTransform(image, 'mrc');
98
101
  changeImageWidth(eventData, transform, 0.1, 50);
99
102
  expect(image.width).toBe(1);
100
103
  });
@@ -105,7 +108,7 @@ describe('croppingHandlers', () => {
105
108
  cropX: 50,
106
109
  elementWidth: 200,
107
110
  });
108
- transform = prepareTransform(image, 'mr');
111
+ transform = prepareTransform(image, 'mrc');
109
112
  const changed = changeImageWidth(eventData, transform, 200, 50);
110
113
  expect(changed).toBe(true);
111
114
  const changed2 = changeImageWidth(eventData, transform, 200, 50);
@@ -113,17 +116,6 @@ describe('croppingHandlers', () => {
113
116
  });
114
117
  });
115
118
 
116
- describe('changeCropWidth', () => {
117
- test('is wrapped with wrapWithFireEvent and wrapWithFixedAnchor', () => {
118
- // Re-import to trigger the wrapping
119
- // Since the module is already loaded, we verify the export is a function
120
- expect(typeof changeCropWidth).toBe('function');
121
-
122
- // The wrapped function should be different from the base function
123
- expect(changeCropWidth).not.toBe(changeImageWidth);
124
- });
125
- });
126
-
127
119
  describe('changeImageHeight', () => {
128
120
  beforeEach(() => {
129
121
  image = createMockImage({
@@ -131,7 +123,7 @@ describe('croppingHandlers', () => {
131
123
  cropY: 50,
132
124
  elementHeight: 200,
133
125
  });
134
- transform = prepareTransform(image, 'mb');
126
+ transform = prepareTransform(image, 'mbc');
135
127
  });
136
128
 
137
129
  test('changes height normally when within bounds', () => {
@@ -161,14 +153,6 @@ describe('croppingHandlers', () => {
161
153
  });
162
154
  });
163
155
 
164
- describe('changeCropHeight', () => {
165
- test('is wrapped with wrapWithFireEvent and wrapWithFixedAnchor', () => {
166
- // The wrapped function should be different from the base function
167
- expect(typeof changeCropHeight).toBe('function');
168
- expect(changeCropHeight).not.toBe(changeImageHeight);
169
- });
170
- });
171
-
172
156
  describe('changeImageCropX', () => {
173
157
  beforeEach(() => {
174
158
  image = createMockImage({
@@ -177,7 +161,7 @@ describe('croppingHandlers', () => {
177
161
  elementWidth: 200,
178
162
  });
179
163
  // Use 'ml' corner for cropX - changing left side moves cropX
180
- transform = prepareTransform(image, 'ml');
164
+ transform = prepareTransform(image, 'mlc');
181
165
  });
182
166
 
183
167
  test('changes cropX and width together', () => {
@@ -189,7 +173,7 @@ describe('croppingHandlers', () => {
189
173
 
190
174
  test('constrains cropX to minimum of 0 and adjusts width accordingly', () => {
191
175
  image = createMockImage({ width: 100, cropX: 10, elementWidth: 200 });
192
- transform = prepareTransform(image, 'ml');
176
+ transform = prepareTransform(image, 'mlc');
193
177
 
194
178
  changeImageCropX(eventData, transform, -10, 50);
195
179
 
@@ -215,13 +199,6 @@ describe('croppingHandlers', () => {
215
199
  });
216
200
  });
217
201
 
218
- describe('changeCropX', () => {
219
- test('is wrapped with wrapWithFireEvent and wrapWithFixedAnchor', () => {
220
- expect(typeof changeCropX).toBe('function');
221
- expect(changeCropX).not.toBe(changeImageCropX);
222
- });
223
- });
224
-
225
202
  describe('changeImageCropY', () => {
226
203
  beforeEach(() => {
227
204
  image = createMockImage({
@@ -230,7 +207,7 @@ describe('croppingHandlers', () => {
230
207
  elementHeight: 200,
231
208
  });
232
209
  // Use 'mt' corner for cropY - changing top side moves cropY
233
- transform = prepareTransform(image, 'mt');
210
+ transform = prepareTransform(image, 'mtc');
234
211
  });
235
212
 
236
213
  test('changes cropY and height together', () => {
@@ -245,7 +222,7 @@ describe('croppingHandlers', () => {
245
222
  test('constrains cropY to minimum of 0 and adjusts height accordingly', () => {
246
223
  image = createMockImage({ height: 100, cropY: 10, elementHeight: 200 });
247
224
  canvas.add(image);
248
- transform = prepareTransform(image, 'mt');
225
+ transform = prepareTransform(image, 'mtc');
249
226
 
250
227
  changeImageCropY(eventData, transform, 50, -30);
251
228
 
@@ -261,10 +238,342 @@ describe('croppingHandlers', () => {
261
238
  });
262
239
  });
263
240
 
264
- describe('changeCropY', () => {
265
- test('is wrapped with wrapWithFireEvent and wrapWithFixedAnchor', () => {
266
- expect(typeof changeCropY).toBe('function');
267
- expect(changeCropY).not.toBe(changeImageCropY);
241
+ describe('cropPanMoveHandler', () => {
242
+ beforeEach(() => {
243
+ image = createMockImage({
244
+ width: 100,
245
+ height: 100,
246
+ cropX: 50,
247
+ cropY: 50,
248
+ elementWidth: 300,
249
+ elementHeight: 300,
250
+ });
251
+ canvas.add(image);
252
+ });
253
+
254
+ test('pans the image by adjusting cropX and cropY', () => {
255
+ const original = {
256
+ left: image.left,
257
+ top: image.top,
258
+ cropX: image.cropX,
259
+ cropY: image.cropY,
260
+ };
261
+
262
+ // Simulate moving the image 10px to the right and 10px down
263
+ image.left = original.left + 10;
264
+ image.top = original.top + 10;
265
+
266
+ const moveEvent = {
267
+ transform: {
268
+ target: image,
269
+ original,
270
+ } as unknown as Transform,
271
+ };
272
+
273
+ cropPanMoveHandler(moveEvent as any);
274
+
275
+ // cropX should decrease (panning right means showing more of the left side)
276
+ expect(image.cropX).toBeLessThan(original.cropX);
277
+ // cropY should decrease (panning down means showing more of the top)
278
+ expect(image.cropY).toBeLessThan(original.cropY);
279
+ // Position should be restored to original
280
+ expect(image.left).toBe(original.left);
281
+ expect(image.top).toBe(original.top);
282
+ });
283
+
284
+ test('constrains cropX to minimum of 0', () => {
285
+ const original = {
286
+ left: image.left,
287
+ top: image.top,
288
+ cropX: 10,
289
+ cropY: 50,
290
+ };
291
+ image.cropX = 10;
292
+
293
+ // Move far right to try to get negative cropX
294
+ image.left = original.left + 100;
295
+ image.top = original.top;
296
+
297
+ const moveEvent = {
298
+ transform: {
299
+ target: image,
300
+ original,
301
+ } as unknown as Transform,
302
+ };
303
+
304
+ cropPanMoveHandler(moveEvent as any);
305
+
306
+ expect(image.cropX).toBeGreaterThanOrEqual(0);
307
+ });
308
+
309
+ test('constrains cropY to minimum of 0', () => {
310
+ const original = {
311
+ left: image.left,
312
+ top: image.top,
313
+ cropX: 50,
314
+ cropY: 10,
315
+ };
316
+ image.cropY = 10;
317
+
318
+ // Move far down to try to get negative cropY
319
+ image.left = original.left;
320
+ image.top = original.top + 100;
321
+
322
+ const moveEvent = {
323
+ transform: {
324
+ target: image,
325
+ original,
326
+ } as unknown as Transform,
327
+ };
328
+
329
+ cropPanMoveHandler(moveEvent as any);
330
+
331
+ expect(image.cropY).toBeGreaterThanOrEqual(0);
332
+ });
333
+
334
+ test('constrains cropX so crop area stays within element bounds', () => {
335
+ const original = {
336
+ left: image.left,
337
+ top: image.top,
338
+ cropX: 150, // Near the right edge (element is 300px wide)
339
+ cropY: 50,
340
+ };
341
+ image.cropX = 150;
342
+
343
+ // Move far left to try to exceed element width
344
+ image.left = original.left - 200;
345
+ image.top = original.top;
346
+
347
+ const moveEvent = {
348
+ transform: {
349
+ target: image,
350
+ original,
351
+ } as unknown as Transform,
352
+ };
353
+
354
+ cropPanMoveHandler(moveEvent as any);
355
+
356
+ // cropX + width should not exceed element width
357
+ expect(image.cropX + image.width).toBeLessThanOrEqual(300);
358
+ });
359
+
360
+ test('constrains cropY so crop area stays within element bounds', () => {
361
+ const original = {
362
+ left: image.left,
363
+ top: image.top,
364
+ cropX: 50,
365
+ cropY: 150, // Near the bottom edge (element is 300px tall)
366
+ };
367
+ image.cropY = 150;
368
+
369
+ // Move far up to try to exceed element height
370
+ image.left = original.left;
371
+ image.top = original.top - 200;
372
+
373
+ const moveEvent = {
374
+ transform: {
375
+ target: image,
376
+ original,
377
+ } as unknown as Transform,
378
+ };
379
+
380
+ cropPanMoveHandler(moveEvent as any);
381
+
382
+ // cropY + height should not exceed element height
383
+ expect(image.cropY + image.height).toBeLessThanOrEqual(300);
384
+ });
385
+ });
386
+
387
+ describe('ghostScalePositionHandler', () => {
388
+ beforeEach(() => {
389
+ image = createMockImage({
390
+ width: 100,
391
+ height: 100,
392
+ cropX: 50,
393
+ cropY: 50,
394
+ elementWidth: 300,
395
+ elementHeight: 300,
396
+ });
397
+ canvas.add(image);
398
+ });
399
+
400
+ test('positions top-left corner control correctly', () => {
401
+ const control = new Control({ x: -0.5, y: -0.5 });
402
+ const result = ghostScalePositionHandler.call(
403
+ control,
404
+ new Point(100, 100),
405
+ [1, 2, 3, 4, 5, 6], // this matrix is not used
406
+ image,
407
+ );
408
+
409
+ expect(result).toEqual({ x: -50, y: -50 });
410
+ });
411
+
412
+ test('positions bottom-right corner control correctly', () => {
413
+ const control = new Control({ x: 0.5, y: 0.5 });
414
+ const result = ghostScalePositionHandler.call(
415
+ control,
416
+ new Point(100, 100),
417
+ [1, 2, 3, 4, 5, 6], // this matrix is not used
418
+ image,
419
+ );
420
+
421
+ expect(result).toEqual({ x: 250, y: 250 });
422
+ });
423
+
424
+ test('positions top-right corner control correctly', () => {
425
+ const control = new Control({ x: 0.5, y: -0.5 });
426
+ const result = ghostScalePositionHandler.call(
427
+ control,
428
+ new Point(100, 100),
429
+ [1, 2, 3, 4, 5, 6], // this matrix is not used
430
+ image,
431
+ );
432
+
433
+ expect(result).toEqual({ x: 250, y: -50 });
434
+ });
435
+
436
+ test('positions bottom-left corner control correctly', () => {
437
+ const control = new Control({ x: -0.5, y: 0.5 });
438
+ const result = ghostScalePositionHandler.call(
439
+ control,
440
+ new Point(100, 100),
441
+ [1, 2, 3, 4, 5, 6], // this matrix is not used
442
+ image,
443
+ );
444
+
445
+ expect(result).toEqual({ x: -50, y: 250 });
446
+ });
447
+ });
448
+
449
+ describe('scaleEquallyCropGenerator', () => {
450
+ beforeEach(() => {
451
+ image = createMockImage({
452
+ width: 100,
453
+ height: 100,
454
+ cropX: 50,
455
+ cropY: 50,
456
+ elementWidth: 300,
457
+ elementHeight: 300,
458
+ });
459
+ canvas.add(image);
460
+ });
461
+
462
+ test('returns a TransformActionHandler function', () => {
463
+ const handler = scaleEquallyCropGenerator(-0.5, -0.5);
464
+ expect(typeof handler).toBe('function');
465
+ });
466
+
467
+ test('scales image uniformly from top-left corner', () => {
468
+ const handler = scaleEquallyCropGenerator(-0.5, -0.5);
469
+ transform = prepareTransform(image, 'tls');
470
+ expect(image.scaleX).toBe(1);
471
+ // Simulate dragging to scale up
472
+ const result = handler(eventData, transform, -400, -400);
473
+
474
+ // The handler should return a boolean
475
+ expect(result).toBe(true);
476
+ expect(image.scaleX.toFixed(2)).toBe('2.17');
477
+ expect(image.scaleX).toBe(image.scaleY);
478
+ });
479
+
480
+ test('scales image uniformly from bottom-right corner', () => {
481
+ const handler = scaleEquallyCropGenerator(0.5, 0.5);
482
+ transform = prepareTransform(image, 'brs');
483
+ expect(image.scaleX).toBe(1);
484
+ const result = handler(eventData, transform, 400, 400);
485
+ expect(result).toBe(true);
486
+ expect(image.scaleX).toBe(1.5);
487
+ expect(image.scaleX).toBe(image.scaleY);
488
+ });
489
+
490
+ test('returns false when scaling would exceed element bounds', () => {
491
+ // Set up image near the edge of element
492
+ image = createMockImage({
493
+ width: 250,
494
+ height: 250,
495
+ cropX: 25,
496
+ cropY: 25,
497
+ elementWidth: 300,
498
+ elementHeight: 300,
499
+ });
500
+ canvas.add(image);
501
+
502
+ const handler = scaleEquallyCropGenerator(-0.5, -0.5);
503
+ transform = prepareTransform(image, 'tls');
504
+
505
+ // Try to scale down significantly which might push bounds
506
+ const result = handler(eventData, transform, 10, 10);
507
+
508
+ expect(result).toBe(false);
509
+ });
510
+
511
+ test('adjusts cropX and cropY when scaling from negative corner', () => {
512
+ image = createMockImage({
513
+ width: 90,
514
+ height: 90,
515
+ cropX: 25,
516
+ cropY: 25,
517
+ elementWidth: 300,
518
+ elementHeight: 300,
519
+ });
520
+ canvas.add(image);
521
+ const handler = scaleEquallyCropGenerator(-0.5, -0.5);
522
+ transform = prepareTransform(image, 'tls');
523
+ expect(image.cropX).toBe(25);
524
+ expect(image.cropY).toBe(25);
525
+ const result = handler(eventData, transform, 5, 5);
526
+ expect(result).toBe(true);
527
+ // When scaling from top-left, cropX and cropY should be recalculated
528
+ expect(image.cropX).toBe(0);
529
+ expect(image.cropY).toBe(0);
530
+ });
531
+ });
532
+
533
+ describe('renderGhostImage', () => {
534
+ beforeEach(() => {
535
+ image = createMockImage({
536
+ width: 100,
537
+ height: 100,
538
+ cropX: 50,
539
+ cropY: 50,
540
+ elementWidth: 300,
541
+ elementHeight: 300,
542
+ });
543
+ });
544
+
545
+ test('draws image at correct position based on crop values', () => {
546
+ const mockCtx = {
547
+ globalAlpha: 1,
548
+ drawImage: vi.fn(),
549
+ } as unknown as CanvasRenderingContext2D;
550
+
551
+ renderGhostImage.call(image, { ctx: mockCtx });
552
+
553
+ // Should draw at (-width/2 - cropX, -height/2 - cropY)
554
+ // = (-50 - 50, -50 - 50) = (-100, -100)
555
+ expect(mockCtx.drawImage).toHaveBeenCalledWith(
556
+ image._element,
557
+ -100,
558
+ -100,
559
+ );
560
+ });
561
+
562
+ test('temporarily reduces globalAlpha by 50%', () => {
563
+ let alphaWhenDrawing: number | undefined;
564
+ const mockCtx = {
565
+ globalAlpha: 0.8,
566
+ drawImage: vi.fn(() => {
567
+ alphaWhenDrawing = mockCtx.globalAlpha;
568
+ }),
569
+ } as unknown as CanvasRenderingContext2D;
570
+
571
+ renderGhostImage.call(image, { ctx: mockCtx });
572
+
573
+ // During draw, alpha should be 0.8 * 0.5 = 0.4
574
+ expect(alphaWhenDrawing).toBe(0.4);
575
+ // After render, alpha should be restored
576
+ expect(mockCtx.globalAlpha).toBe(0.8);
268
577
  });
269
578
  });
270
579
  });
@@ -4,6 +4,8 @@ import type {
4
4
  TransformActionHandler,
5
5
  FabricImage,
6
6
  ObjectEvents,
7
+ Control,
8
+ TMat2D,
7
9
  } from 'fabric';
8
10
  import { controlsUtils, Point, util } from 'fabric';
9
11
 
@@ -160,3 +162,124 @@ export const cropPanMoveHandler = ({ transform }: ObjectEvents['moving']) => {
160
162
  fabricImage.left = original.left;
161
163
  fabricImage.top = original.top;
162
164
  };
165
+
166
+ /**
167
+ * This position handler works only for this specific use case.
168
+ * It does not support padding nor offset, and it reduces all possible positions
169
+ * to the main 4 corners only.
170
+ * Any position that is < 0 is the extreme left/top, the rest are right/bottom
171
+ */
172
+ export function ghostScalePositionHandler(
173
+ this: Control,
174
+ dim: Point, // currentDimension
175
+ finalMatrix: TMat2D,
176
+ fabricObject: FabricImage,
177
+ // currentControl: Control,
178
+ ) {
179
+ const matrix = fabricObject.calcTransformMatrix();
180
+ const vpt = fabricObject.getViewportTransform();
181
+ const _finalMatrix = util.multiplyTransformMatrices(vpt, matrix);
182
+
183
+ let x = 0;
184
+ let y = 0;
185
+ if (this.x < 0) {
186
+ x = -fabricObject.width / 2 - fabricObject.cropX;
187
+ } else {
188
+ x =
189
+ fabricObject.getElement().width -
190
+ fabricObject.width / 2 -
191
+ fabricObject.cropX;
192
+ }
193
+
194
+ if (this.y < 0) {
195
+ y = -fabricObject.height / 2 - fabricObject.cropY;
196
+ } else {
197
+ y =
198
+ fabricObject.getElement().height -
199
+ fabricObject.height / 2 -
200
+ fabricObject.cropY;
201
+ }
202
+ return new Point(x, y).transform(_finalMatrix);
203
+ }
204
+
205
+ const calcScale = (currentPoint: Point, height: number, width: number) =>
206
+ Math.min(Math.abs(currentPoint.x / width), Math.abs(currentPoint.y / height));
207
+
208
+ /**
209
+ * Action handler generator that handles scaling of an image in crop mode.
210
+ * The goal is to keep the current bounding box steady.
211
+ * So this action handler has its own calculations for a dynamic anchor point
212
+ */
213
+ export const scaleEquallyCropGenerator =
214
+ (cx: number, cy: number): TransformActionHandler =>
215
+ (eventData, transform, x, y) => {
216
+ const { target } = transform as unknown as { target: FabricImage };
217
+ const { width: fullWidth, height: fullHeight } = target.getElement();
218
+ const remainderX = fullWidth - target.width - target.cropX;
219
+ const remainderY = fullHeight - target.height - target.cropY;
220
+ const anchorOriginX =
221
+ cx < 0 ? 1 + remainderX / target.width : -target.cropX / target.width;
222
+ const anchorOriginY =
223
+ cy < 0 ? 1 + remainderY / target.height : -target.cropY / target.height;
224
+ const constraint = target.translateToOriginPoint(
225
+ target.getCenterPoint(),
226
+ anchorOriginX,
227
+ anchorOriginY,
228
+ );
229
+ const newPoint = controlsUtils.getLocalPoint(
230
+ transform,
231
+ anchorOriginX,
232
+ anchorOriginY,
233
+ x,
234
+ y,
235
+ );
236
+ const scale = calcScale(newPoint, fullHeight, fullWidth);
237
+ const scaleChangeX = scale / target.scaleX;
238
+ const scaleChangeY = scale / target.scaleY;
239
+ const scaledRemainderX = remainderX / scaleChangeX;
240
+ const scaledRemainderY = remainderY / scaleChangeY;
241
+ const newWidth = target.width / scaleChangeX;
242
+ const newHeight = target.height / scaleChangeY;
243
+ const newCropX =
244
+ cx < 0
245
+ ? fullWidth - newWidth - scaledRemainderX
246
+ : target.cropX / scaleChangeX;
247
+ const newCropY =
248
+ cy < 0
249
+ ? fullHeight - newHeight - scaledRemainderY
250
+ : target.cropY / scaleChangeY;
251
+
252
+ if (
253
+ (cx < 0 ? scaledRemainderX : newCropX) + newWidth > fullWidth ||
254
+ (cy < 0 ? scaledRemainderY : newCropY) + newHeight > fullHeight
255
+ ) {
256
+ return false;
257
+ }
258
+
259
+ target.scaleX = scale;
260
+ target.scaleY = scale;
261
+ target.width = newWidth;
262
+ target.height = newHeight;
263
+ target.cropX = newCropX;
264
+ target.cropY = newCropY;
265
+ const newAnchorOriginX =
266
+ cx < 0 ? 1 + scaledRemainderX / newWidth : -newCropX / newWidth;
267
+ const newAnchorOriginY =
268
+ cy < 0 ? 1 + scaledRemainderY / newHeight : -newCropY / newHeight;
269
+ target.setPositionByOrigin(constraint, newAnchorOriginX, newAnchorOriginY);
270
+ return true;
271
+ };
272
+
273
+ export function renderGhostImage(
274
+ this: FabricImage,
275
+ { ctx }: { ctx: CanvasRenderingContext2D },
276
+ ) {
277
+ const alpha = ctx.globalAlpha;
278
+ ctx.globalAlpha *= 0.5;
279
+ ctx.drawImage(
280
+ this._element,
281
+ -this.width / 2 - this.cropX,
282
+ -this.height / 2 - this.cropY,
283
+ );
284
+ ctx.globalAlpha = alpha;
285
+ }
@@ -1,6 +1,6 @@
1
1
  import { type FabricImage, type TPointerEventInfo } from 'fabric';
2
2
  import { createImageCroppingControls } from './croppingControls';
3
- import { cropPanMoveHandler } from './croppingHandlers';
3
+ import { cropPanMoveHandler, renderGhostImage } from './croppingHandlers';
4
4
  /**
5
5
  * Coordinates the change to image to enter crop mode and returns
6
6
  * a function to exit crop mode
@@ -10,12 +10,16 @@ export const enterCropMode = function enterCropMode(
10
10
  { target }: TPointerEventInfo,
11
11
  ) {
12
12
  const fabricImage = target as FabricImage;
13
- const { controls } = fabricImage;
13
+ const { controls, padding } = fabricImage;
14
+ fabricImage.padding = 0;
14
15
  fabricImage.controls = createImageCroppingControls();
15
16
  fabricImage.on('moving', cropPanMoveHandler);
17
+ fabricImage.on('before:render', renderGhostImage);
16
18
  fabricImage.setCoords();
17
19
  const exitCropMode = () => {
20
+ fabricImage.padding = padding;
18
21
  fabricImage.off('moving', cropPanMoveHandler);
22
+ fabricImage.off('before:render', renderGhostImage);
19
23
  fabricImage.controls = controls;
20
24
  fabricImage.setCoords();
21
25
  fabricImage.once('mousedblclick', enterCropMode);