@gradio/imageeditor 0.12.13 → 0.13.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 (130) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/Example.svelte +1 -1
  3. package/IMAGE_EDITOR_OVERVIEW.md +224 -0
  4. package/ImageEditor.stories.svelte +4 -4
  5. package/Index.svelte +35 -46
  6. package/{shared/InteractiveImageEditor.svelte → InteractiveImageEditor.svelte} +56 -103
  7. package/dist/Example.svelte.d.ts +1 -1
  8. package/dist/Index.svelte +29 -33
  9. package/dist/Index.svelte.d.ts +17 -16
  10. package/dist/{shared/InteractiveImageEditor.svelte → InteractiveImageEditor.svelte} +47 -88
  11. package/dist/{shared/InteractiveImageEditor.svelte.d.ts → InteractiveImageEditor.svelte.d.ts} +13 -15
  12. package/dist/shared/Anchor.svelte +311 -0
  13. package/dist/shared/Anchor.svelte.d.ts +18 -0
  14. package/dist/shared/Controls.svelte +162 -23
  15. package/dist/shared/Controls.svelte.d.ts +7 -2
  16. package/dist/shared/{tools/IconButton.svelte → IconButton.svelte} +19 -3
  17. package/dist/shared/{tools/IconButton.svelte.d.ts → IconButton.svelte.d.ts} +4 -1
  18. package/dist/shared/ImageEditor.svelte +546 -289
  19. package/dist/shared/ImageEditor.svelte.d.ts +51 -56
  20. package/dist/shared/Layers.svelte +233 -0
  21. package/dist/shared/Layers.svelte.d.ts +35 -0
  22. package/dist/shared/Resize.svelte +218 -0
  23. package/dist/shared/Resize.svelte.d.ts +29 -0
  24. package/dist/shared/SecondaryToolbar.svelte +54 -0
  25. package/dist/shared/SecondaryToolbar.svelte.d.ts +35 -0
  26. package/dist/shared/Toolbar.svelte +337 -0
  27. package/dist/shared/Toolbar.svelte.d.ts +42 -0
  28. package/dist/shared/{tools → brush}/BrushOptions.svelte +68 -64
  29. package/dist/shared/{tools → brush}/BrushOptions.svelte.d.ts +9 -9
  30. package/dist/shared/{tools → brush}/BrushSize.svelte +2 -27
  31. package/dist/shared/{tools → brush}/ColorField.svelte +2 -1
  32. package/dist/shared/{tools → brush}/ColorField.svelte.d.ts +1 -0
  33. package/dist/shared/{tools → brush}/ColorPicker.svelte +81 -9
  34. package/dist/shared/{tools → brush}/ColorPicker.svelte.d.ts +1 -0
  35. package/dist/shared/{tools → brush}/ColorSwatch.svelte +78 -38
  36. package/dist/shared/{tools → brush}/ColorSwatch.svelte.d.ts +6 -2
  37. package/dist/shared/brush/brush-cursor.d.ts +101 -0
  38. package/dist/shared/brush/brush-cursor.js +327 -0
  39. package/dist/shared/brush/brush-textures.d.ts +71 -0
  40. package/dist/shared/brush/brush-textures.js +404 -0
  41. package/dist/shared/brush/brush-utils.d.ts +32 -0
  42. package/dist/shared/brush/brush-utils.js +59 -0
  43. package/dist/shared/brush/brush.d.ts +152 -0
  44. package/dist/shared/brush/brush.js +396 -0
  45. package/dist/shared/brush/types.d.ts +37 -0
  46. package/dist/shared/brush/types.js +1 -0
  47. package/dist/shared/core/editor.d.ts +252 -0
  48. package/dist/shared/core/editor.js +1221 -0
  49. package/dist/shared/crop/crop.d.ts +201 -0
  50. package/dist/shared/crop/crop.js +727 -0
  51. package/dist/shared/image/image.d.ts +89 -0
  52. package/dist/shared/image/image.js +185 -0
  53. package/dist/shared/resize/resize.d.ts +270 -0
  54. package/dist/shared/resize/resize.js +1413 -0
  55. package/dist/shared/types.d.ts +12 -0
  56. package/dist/shared/utils/pixi.d.ts +16 -57
  57. package/dist/shared/utils/pixi.js +15 -113
  58. package/dist/shared/zoom/zoom.d.ts +136 -0
  59. package/dist/shared/zoom/zoom.js +548 -0
  60. package/package.json +11 -10
  61. package/shared/Anchor.svelte +384 -0
  62. package/shared/Controls.svelte +194 -27
  63. package/shared/{tools/IconButton.svelte → IconButton.svelte} +21 -4
  64. package/shared/ImageEditor.svelte +682 -349
  65. package/shared/Layers.svelte +249 -0
  66. package/shared/Resize.svelte +239 -0
  67. package/shared/SecondaryToolbar.svelte +61 -0
  68. package/shared/Toolbar.svelte +386 -0
  69. package/shared/brush/BRUSH_TOOL.md +134 -0
  70. package/shared/brush/BrushOptions.svelte +200 -0
  71. package/shared/{tools → brush}/BrushSize.svelte +2 -30
  72. package/shared/{tools → brush}/ColorField.svelte +3 -1
  73. package/shared/{tools → brush}/ColorPicker.svelte +86 -9
  74. package/shared/{tools → brush}/ColorSwatch.svelte +95 -42
  75. package/shared/brush/brush-cursor.ts +408 -0
  76. package/shared/brush/brush-textures.ts +508 -0
  77. package/shared/brush/brush-utils.ts +76 -0
  78. package/shared/brush/brush.ts +506 -0
  79. package/shared/brush/types.ts +41 -0
  80. package/shared/core/EDITOR.md +182 -0
  81. package/shared/core/editor.ts +1730 -0
  82. package/shared/crop/CROP.md +139 -0
  83. package/shared/crop/crop.ts +1027 -0
  84. package/shared/image/IMAGE.md +135 -0
  85. package/shared/image/image.ts +300 -0
  86. package/shared/resize/resize.ts +1894 -0
  87. package/shared/types.ts +14 -0
  88. package/shared/utils/pixi.ts +19 -246
  89. package/shared/zoom/zoom.ts +729 -0
  90. package/dist/shared/layers/Layers.svelte +0 -231
  91. package/dist/shared/layers/Layers.svelte.d.ts +0 -18
  92. package/dist/shared/layers/utils.d.ts +0 -73
  93. package/dist/shared/layers/utils.js +0 -156
  94. package/dist/shared/tools/Brush.svelte +0 -215
  95. package/dist/shared/tools/Brush.svelte.d.ts +0 -29
  96. package/dist/shared/tools/Crop.svelte +0 -155
  97. package/dist/shared/tools/Crop.svelte.d.ts +0 -16
  98. package/dist/shared/tools/Cropper.svelte +0 -271
  99. package/dist/shared/tools/Cropper.svelte.d.ts +0 -41
  100. package/dist/shared/tools/Handle.svelte +0 -260
  101. package/dist/shared/tools/Handle.svelte.d.ts +0 -29
  102. package/dist/shared/tools/Sources.svelte +0 -257
  103. package/dist/shared/tools/Sources.svelte.d.ts +0 -34
  104. package/dist/shared/tools/Tools.svelte +0 -102
  105. package/dist/shared/tools/Tools.svelte.d.ts +0 -48
  106. package/dist/shared/tools/brush.d.ts +0 -62
  107. package/dist/shared/tools/brush.js +0 -133
  108. package/dist/shared/tools/crop.d.ts +0 -15
  109. package/dist/shared/tools/crop.js +0 -154
  110. package/dist/shared/tools/index.d.ts +0 -5
  111. package/dist/shared/tools/index.js +0 -5
  112. package/dist/shared/tools/sources.d.ts +0 -46
  113. package/dist/shared/tools/sources.js +0 -121
  114. package/dist/shared/tools/types.d.ts +0 -1
  115. package/shared/layers/Layers.svelte +0 -250
  116. package/shared/layers/utils.ts +0 -294
  117. package/shared/tools/Brush.svelte +0 -280
  118. package/shared/tools/BrushOptions.svelte +0 -180
  119. package/shared/tools/Crop.svelte +0 -189
  120. package/shared/tools/Cropper.svelte +0 -346
  121. package/shared/tools/Handle.svelte +0 -289
  122. package/shared/tools/Sources.svelte +0 -283
  123. package/shared/tools/Tools.svelte +0 -162
  124. package/shared/tools/brush.ts +0 -247
  125. package/shared/tools/crop.ts +0 -213
  126. package/shared/tools/index.ts +0 -5
  127. package/shared/tools/sources.ts +0 -195
  128. package/shared/tools/types.ts +0 -1
  129. /package/dist/shared/{tools → brush}/BrushSize.svelte.d.ts +0 -0
  130. /package/dist/shared/{tools/types.js → types.js} +0 -0
@@ -0,0 +1,727 @@
1
+ import { Container, Graphics, Point, Rectangle, FederatedPointerEvent, Sprite } from "pixi.js";
2
+ import {} from "../core/editor";
3
+ import {} from "../Toolbar.svelte";
4
+ import { get_canvas_blob } from "../utils/pixi";
5
+ /**
6
+ * @class CropTool
7
+ * @implements {Tool}
8
+ * @description Implements the crop tool functionality for the image editor
9
+ */
10
+ export class CropTool {
11
+ name = "crop";
12
+ image_editor_context;
13
+ current_tool = "image";
14
+ current_subtool = "crop";
15
+ // Constants
16
+ CORNER_SIZE = 25;
17
+ LINE_THICKNESS = 5;
18
+ HANDLE_COLOR = 0x000000;
19
+ HIT_AREA_SIZE = 40;
20
+ // State
21
+ is_dragging = false;
22
+ selected_handle = null;
23
+ active_corner_index = -1;
24
+ active_edge_index = -1;
25
+ last_pointer_position = null;
26
+ crop_bounds = { x: 0, y: 0, width: 0, height: 0 };
27
+ crop_ui_container = null;
28
+ crop_mask = null;
29
+ dimensions = {
30
+ width: 0,
31
+ height: 0
32
+ };
33
+ position = { x: 0, y: 0 };
34
+ scale = 1;
35
+ is_dragging_window = false;
36
+ drag_start_position = null;
37
+ drag_start_bounds = null;
38
+ background_image_watcher = null;
39
+ has_been_manually_changed = false;
40
+ event_callbacks = new Map();
41
+ /**
42
+ * @method setup
43
+ * @async
44
+ * @description Sets up the crop tool with the given context and state
45
+ * @param {ImageEditorContext} context - The image editor context
46
+ * @param {ToolbarTool} tool - The current tool
47
+ * @param {Subtool} subtool - The current subtool
48
+ * @returns {Promise<void>}
49
+ */
50
+ async setup(context, tool, subtool) {
51
+ this.image_editor_context = context;
52
+ this.current_tool = tool;
53
+ this.current_subtool = subtool;
54
+ context.dimensions.subscribe((dimensions) => {
55
+ this.dimensions = dimensions;
56
+ // Update crop bounds to match new dimensions
57
+ this.crop_bounds = {
58
+ x: 0,
59
+ y: 0,
60
+ width: dimensions.width,
61
+ height: dimensions.height
62
+ };
63
+ // Force UI update if it exists
64
+ if (this.crop_ui_container) {
65
+ this.update_crop_ui();
66
+ }
67
+ // Always update the mask when dimensions change
68
+ this.set_crop_mask();
69
+ this.update_crop_mask();
70
+ });
71
+ context.position.subscribe((position) => {
72
+ this.position = position;
73
+ });
74
+ context.scale.subscribe((scale) => {
75
+ this.scale = scale;
76
+ });
77
+ // Setup a background image watcher to ensure we always apply the mask to the current background image
78
+ this.background_image_watcher = () => {
79
+ // Only apply when crop tool is active and we have a mask
80
+ if (this.crop_mask &&
81
+ this.current_tool === "image" &&
82
+ this.current_subtool === "crop") {
83
+ // Get a fresh reference to the background image and apply the mask
84
+ if (this.image_editor_context.background_image) {
85
+ this.image_editor_context.background_image.setMask(this.crop_mask);
86
+ }
87
+ }
88
+ };
89
+ // Add the watcher to the ticker to regularly check for background image changes
90
+ this.image_editor_context.app.ticker.add(this.background_image_watcher);
91
+ // Initialize crop UI
92
+ await this.init_crop_ui();
93
+ // Setup event listeners
94
+ this.setup_event_listeners();
95
+ // Ensure the crop UI visibility matches the current tool state
96
+ const isCropActive = tool === "image" && subtool === "crop";
97
+ if (this.crop_ui_container) {
98
+ this.crop_ui_container.visible = isCropActive;
99
+ }
100
+ }
101
+ /**
102
+ * @method cleanup
103
+ * @description Cleans up the crop tool resources and removes event listeners
104
+ * @returns {void}
105
+ */
106
+ cleanup() {
107
+ // Remove the crop UI container
108
+ if (this.crop_ui_container) {
109
+ this.image_editor_context.app.stage.removeChild(this.crop_ui_container);
110
+ }
111
+ // Remove the mask and reset the image container's mask property
112
+ if (this.crop_mask) {
113
+ if (this.crop_mask.parent) {
114
+ this.crop_mask.parent.removeChild(this.crop_mask);
115
+ }
116
+ // Clear mask from background_image if applied
117
+ if (this.image_editor_context.background_image &&
118
+ this.image_editor_context.background_image.mask === this.crop_mask) {
119
+ this.image_editor_context.background_image.mask = null;
120
+ }
121
+ // Clear mask from image_container if applied
122
+ if (this.image_editor_context.image_container.mask === this.crop_mask) {
123
+ this.image_editor_context.image_container.mask = null;
124
+ }
125
+ }
126
+ // Ensure all references to the crop mask are cleared
127
+ this.crop_mask = null;
128
+ this.crop_ui_container = null;
129
+ // Remove the background image watcher from the ticker
130
+ if (this.background_image_watcher) {
131
+ this.image_editor_context.app.ticker.remove(this.background_image_watcher);
132
+ this.background_image_watcher = null;
133
+ }
134
+ // Remove event listeners
135
+ this.cleanup_event_listeners();
136
+ }
137
+ /**
138
+ * @method set_tool
139
+ * @description Sets the current tool and subtool, updating UI visibility
140
+ * @param {ToolbarTool} tool - The tool to set
141
+ * @param {Subtool} subtool - The subtool to set
142
+ * @returns {void}
143
+ */
144
+ set_tool(tool, subtool) {
145
+ this.current_tool = tool;
146
+ this.current_subtool = subtool;
147
+ // Show/hide crop UI based on tool and subtool
148
+ const isCropActive = tool === "image" && subtool === "crop";
149
+ // Only update the UI visibility, not the mask
150
+ if (this.crop_ui_container) {
151
+ this.crop_ui_container.visible = isCropActive;
152
+ }
153
+ // If crop tool is active, ensure the mask is up to date
154
+ if (isCropActive && this.crop_mask) {
155
+ this.update_crop_mask();
156
+ }
157
+ }
158
+ /**
159
+ * @method set_crop_mask
160
+ * @private
161
+ * @description Sets up the crop mask for the image
162
+ * @returns {void}
163
+ */
164
+ set_crop_mask() {
165
+ // Remove any existing mask to avoid duplicates
166
+ if (this.crop_mask) {
167
+ if (this.crop_mask.parent) {
168
+ this.crop_mask.parent.removeChild(this.crop_mask);
169
+ }
170
+ // Clear mask from image_container if it was applied there
171
+ if (this.image_editor_context.image_container.mask === this.crop_mask) {
172
+ this.image_editor_context.image_container.mask = null;
173
+ }
174
+ // Clear mask from background_image if it was applied there
175
+ if (this.image_editor_context.background_image &&
176
+ this.image_editor_context.background_image.mask === this.crop_mask) {
177
+ this.image_editor_context.background_image.mask = null;
178
+ }
179
+ }
180
+ // Create a new mask that will actually be used as an overlay
181
+ this.crop_mask = new Graphics();
182
+ // Add the mask/overlay to the display list
183
+ // This time it should be visible since we want to see the semi-transparent overlay
184
+ this.image_editor_context.image_container.addChild(this.crop_mask);
185
+ }
186
+ /**
187
+ * @method init_crop_ui
188
+ * @private
189
+ * @async
190
+ * @description Initializes the crop UI elements and sets up the update loop
191
+ * @returns {Promise<void>}
192
+ */
193
+ async init_crop_ui() {
194
+ // Create crop UI with scaled dimensions
195
+ const { width, height } = this.image_editor_context.background_image?.getLocalBounds() || {
196
+ width: false,
197
+ height: false
198
+ };
199
+ this.crop_ui_container = this.make_crop_ui(this.dimensions.width * this.scale, this.dimensions.height * this.scale);
200
+ // Position the crop UI container at the image position
201
+ this.crop_ui_container.position.set(this.position.x, this.position.y);
202
+ // Initially hide the crop UI until explicitly enabled by set_tool
203
+ this.crop_ui_container.visible = false;
204
+ this.image_editor_context.app.stage.addChild(this.crop_ui_container);
205
+ // Create and apply mask to the image container
206
+ this.set_crop_mask();
207
+ // Start the update loop for the UI
208
+ this.image_editor_context.app.ticker.add(this.update_crop_ui.bind(this));
209
+ // Initialize the mask with the current crop bounds
210
+ this.update_crop_mask();
211
+ }
212
+ /**
213
+ * @method make_crop_ui
214
+ * @private
215
+ * @description Creates the crop UI with the specified dimensions
216
+ * @param {number} width - Width of the crop area
217
+ * @param {number} height - Height of the crop area
218
+ * @returns {Container} The created crop UI container
219
+ */
220
+ make_crop_ui(width, height) {
221
+ const crop_container = new Container();
222
+ crop_container.eventMode = "static";
223
+ crop_container.interactiveChildren = true;
224
+ // Create crop outline with hit area for dragging
225
+ const crop_outline = new Graphics()
226
+ .rect(0, 0, width, height)
227
+ .stroke({ width: 1, color: 0x000000, alignment: 0, alpha: 0.5 });
228
+ // Make the outline interactive
229
+ crop_outline.eventMode = "static";
230
+ crop_outline.cursor = "move";
231
+ // Set hit area to match the outline's dimensions
232
+ crop_outline.hitArea = new Rectangle(0, 0, width, height);
233
+ // Add drag handlers to the outline
234
+ crop_outline.on("pointerdown", this.handle_window_drag_start.bind(this));
235
+ crop_container.addChild(crop_outline);
236
+ // Create corner handles
237
+ this.create_corner_handles(crop_container, width, height);
238
+ // Create edge handles
239
+ this.create_edge_handles(crop_container, width, height);
240
+ return crop_container;
241
+ }
242
+ /**
243
+ * @method create_handle
244
+ * @private
245
+ * @description Creates a handle for the crop UI
246
+ * @param {boolean} [is_edge=false] - Whether the handle is an edge handle
247
+ * @returns {Container} The created handle container
248
+ */
249
+ create_handle(is_edge = false) {
250
+ const handle = new Container();
251
+ handle.eventMode = "static";
252
+ const handle_graphics = new Graphics();
253
+ if (is_edge) {
254
+ // Create a shorter bar for edge handles (2x instead of 3x)
255
+ handle_graphics
256
+ .rect(0, 0, this.CORNER_SIZE * 1.5, this.LINE_THICKNESS)
257
+ .fill(this.HANDLE_COLOR);
258
+ }
259
+ else {
260
+ // Create L-shaped corner handles
261
+ handle_graphics
262
+ .rect(0, 0, this.CORNER_SIZE, this.LINE_THICKNESS)
263
+ .rect(0, 0, this.LINE_THICKNESS, this.CORNER_SIZE)
264
+ .fill(this.HANDLE_COLOR);
265
+ }
266
+ handle.addChild(handle_graphics);
267
+ // Adjust hit area based on handle type
268
+ const hit_size = is_edge ? this.HIT_AREA_SIZE * 1.5 : this.HIT_AREA_SIZE;
269
+ handle.hitArea = new Rectangle(-hit_size / 2 + this.LINE_THICKNESS, -hit_size / 2 + this.LINE_THICKNESS, hit_size, hit_size);
270
+ return handle;
271
+ }
272
+ /**
273
+ * @method create_edge_handles
274
+ * @private
275
+ * @description Creates edge handles for the crop UI
276
+ * @param {Container} container - The container to add handles to
277
+ * @param {number} width - Width of the crop area
278
+ * @param {number} height - Height of the crop area
279
+ * @returns {void}
280
+ */
281
+ create_edge_handles(container, width, height) {
282
+ // Horizontal handles at top and bottom
283
+ [-1, 1].forEach((i, index) => {
284
+ const handle = this.create_handle(true); // true for edge handle
285
+ handle.rotation = 0;
286
+ handle.on("pointerdown", (event) => {
287
+ this.handle_pointer_down(event, handle, -1, index); // 0 for top, 1 for bottom
288
+ });
289
+ // Center the handle
290
+ handle.x = width / 2 - (this.CORNER_SIZE * 3) / 2;
291
+ handle.y = i < 0 ? -this.LINE_THICKNESS : height;
292
+ handle.cursor = "ns-resize";
293
+ container.addChild(handle);
294
+ });
295
+ // Vertical handles at left and right
296
+ [-1, 1].forEach((i, index) => {
297
+ const handle = this.create_handle(true); // true for edge handle
298
+ handle.rotation = Math.PI / 2; // Rotate 90 degrees
299
+ handle.on("pointerdown", (event) => {
300
+ this.handle_pointer_down(event, handle, -1, index + 2); // 2 for left, 3 for right
301
+ });
302
+ // Center the handle
303
+ handle.x = i < 0 ? -this.LINE_THICKNESS : width;
304
+ handle.y = height / 2 - (this.CORNER_SIZE * 3) / 2;
305
+ handle.cursor = "ew-resize";
306
+ container.addChild(handle);
307
+ });
308
+ }
309
+ /**
310
+ * @method create_corner_handles
311
+ * @private
312
+ * @description Creates corner handles for the crop UI
313
+ * @param {Container} container - The container to add handles to
314
+ * @param {number} width - Width of the crop area
315
+ * @param {number} height - Height of the crop area
316
+ * @returns {void}
317
+ */
318
+ create_corner_handles(container, width, height) {
319
+ const corners = [
320
+ { x: 0, y: 0, xScale: 1, yScale: 1, cursor: "nwse-resize" }, // Top-left
321
+ { x: width, y: 0, xScale: -1, yScale: 1, cursor: "nesw-resize" }, // Top-right
322
+ { x: 0, y: height, xScale: 1, yScale: -1, cursor: "nesw-resize" }, // Bottom-left
323
+ { x: width, y: height, xScale: -1, yScale: -1, cursor: "nwse-resize" } // Bottom-right
324
+ ];
325
+ corners.forEach(({ x, y, xScale, yScale, cursor }, i) => {
326
+ const corner = this.create_handle(false); // false for corner handle
327
+ corner.x = x - (xScale < 0 ? -this.LINE_THICKNESS : this.LINE_THICKNESS);
328
+ corner.y = y - (yScale < 0 ? -this.LINE_THICKNESS : this.LINE_THICKNESS);
329
+ corner.scale.set(xScale, yScale);
330
+ corner.on("pointerdown", (event) => {
331
+ this.handle_pointer_down(event, corner, i, -1);
332
+ });
333
+ corner.cursor = cursor;
334
+ container.addChild(corner);
335
+ });
336
+ }
337
+ /**
338
+ * @method handle_pointer_down
339
+ * @private
340
+ * @description Handles pointer down events on handles
341
+ * @param {FederatedPointerEvent} event - The pointer event
342
+ * @param {Container} handle - The handle that was clicked
343
+ * @param {number} corner_index - Index of the corner (-1 if not a corner)
344
+ * @param {number} edge_index - Index of the edge (-1 if not an edge)
345
+ * @returns {void}
346
+ */
347
+ handle_pointer_down(event, handle, corner_index, edge_index) {
348
+ // Only handle crop events if crop tool is active
349
+ if (this.current_subtool !== "crop")
350
+ return;
351
+ event.stopPropagation();
352
+ this.is_dragging = true;
353
+ this.selected_handle = handle;
354
+ this.active_corner_index = corner_index;
355
+ this.active_edge_index = edge_index;
356
+ // Convert global position to the container's local coordinates.
357
+ const local_pos = this.image_editor_context.image_container.toLocal(event.global);
358
+ this.last_pointer_position = new Point(local_pos.x, local_pos.y);
359
+ }
360
+ /**
361
+ * updates the crop bounds based on pointer movement
362
+ * @param delta - the change in position
363
+ */
364
+ update_crop_bounds(delta) {
365
+ // Set flag indicating the crop has been manually changed
366
+ this.has_been_manually_changed = true;
367
+ // Scale the delta to match the image coordinates
368
+ const scaled_delta = new Point(delta.x, delta.y);
369
+ // Store original values to calculate actual changes
370
+ const original = {
371
+ x: this.crop_bounds.x,
372
+ y: this.crop_bounds.y,
373
+ width: this.crop_bounds.width,
374
+ height: this.crop_bounds.height
375
+ };
376
+ if (this.active_corner_index !== -1) {
377
+ // Handle corner dragging
378
+ switch (this.active_corner_index) {
379
+ case 0: // Top-left
380
+ // Update width and x position
381
+ const newWidth0 = Math.max(20, original.width - scaled_delta.x);
382
+ const widthDiff0 = original.width - newWidth0;
383
+ this.crop_bounds.width = newWidth0;
384
+ this.crop_bounds.x = original.x + widthDiff0;
385
+ // Update height and y position
386
+ const newHeight0 = Math.max(20, original.height - scaled_delta.y);
387
+ const heightDiff0 = original.height - newHeight0;
388
+ this.crop_bounds.height = newHeight0;
389
+ this.crop_bounds.y = original.y + heightDiff0;
390
+ break;
391
+ case 1: // Top-right
392
+ // Update width (right side)
393
+ this.crop_bounds.width = Math.max(20, original.width + scaled_delta.x);
394
+ // Update height and y position (top side)
395
+ const newHeight1 = Math.max(20, original.height - scaled_delta.y);
396
+ const heightDiff1 = original.height - newHeight1;
397
+ this.crop_bounds.height = newHeight1;
398
+ this.crop_bounds.y = original.y + heightDiff1;
399
+ break;
400
+ case 2: // Bottom-left
401
+ // Update width and x position (left side)
402
+ const newWidth2 = Math.max(20, original.width - scaled_delta.x);
403
+ const widthDiff2 = original.width - newWidth2;
404
+ this.crop_bounds.width = newWidth2;
405
+ this.crop_bounds.x = original.x + widthDiff2;
406
+ // Update height (bottom side)
407
+ this.crop_bounds.height = Math.max(20, original.height + scaled_delta.y);
408
+ break;
409
+ case 3: // Bottom-right
410
+ // Update width (right side)
411
+ this.crop_bounds.width = Math.max(20, original.width + scaled_delta.x);
412
+ // Update height (bottom side)
413
+ this.crop_bounds.height = Math.max(20, original.height + scaled_delta.y);
414
+ break;
415
+ }
416
+ }
417
+ else if (this.active_edge_index !== -1) {
418
+ // Handle edge dragging
419
+ switch (this.active_edge_index) {
420
+ case 0: // Top
421
+ // Update height and y position
422
+ const newHeight = Math.max(20, original.height - scaled_delta.y);
423
+ const heightDiff = original.height - newHeight;
424
+ this.crop_bounds.height = newHeight;
425
+ this.crop_bounds.y = original.y + heightDiff;
426
+ break;
427
+ case 1: // Bottom
428
+ // Update height only
429
+ this.crop_bounds.height = Math.max(20, original.height + scaled_delta.y);
430
+ break;
431
+ case 2: // Left
432
+ // Update width and x position
433
+ const newWidth = Math.max(20, original.width - scaled_delta.x);
434
+ const widthDiff = original.width - newWidth;
435
+ this.crop_bounds.width = newWidth;
436
+ this.crop_bounds.x = original.x + widthDiff;
437
+ break;
438
+ case 3: // Right
439
+ // Update width only
440
+ this.crop_bounds.width = Math.max(20, original.width + scaled_delta.x);
441
+ break;
442
+ }
443
+ }
444
+ // Apply constraints
445
+ this.constrain_crop_bounds();
446
+ // Update the UI and mask
447
+ this.update_crop_ui();
448
+ this.update_crop_mask();
449
+ // Make sure to apply mask to the current background image
450
+ if (this.crop_mask && this.image_editor_context.background_image) {
451
+ this.image_editor_context.background_image.setMask(this.crop_mask);
452
+ }
453
+ }
454
+ /**
455
+ * constrains the crop bounds to the image dimensions
456
+ */
457
+ constrain_crop_bounds() {
458
+ // Store original values
459
+ const original = {
460
+ x: this.crop_bounds.x,
461
+ y: this.crop_bounds.y,
462
+ width: this.crop_bounds.width,
463
+ height: this.crop_bounds.height
464
+ };
465
+ // First, constrain width and height to prevent exceeding image dimensions
466
+ // and ensure minimum size
467
+ this.crop_bounds.width = Math.max(20, Math.min(this.crop_bounds.width, this.dimensions.width));
468
+ this.crop_bounds.height = Math.max(20, Math.min(this.crop_bounds.height, this.dimensions.height));
469
+ // Then constrain x and y to keep the crop window within the image bounds
470
+ const oldX = this.crop_bounds.x;
471
+ this.crop_bounds.x = Math.max(0, Math.min(this.crop_bounds.x, this.dimensions.width - this.crop_bounds.width));
472
+ // If x position was constrained, adjust width to prevent opposite handle drift
473
+ if ((this.crop_bounds.x !== oldX && this.active_corner_index === 0) ||
474
+ this.active_edge_index === 2) {
475
+ // Left side was constrained, adjust width to compensate
476
+ const xDiff = this.crop_bounds.x - oldX;
477
+ this.crop_bounds.width -= xDiff;
478
+ }
479
+ const oldY = this.crop_bounds.y;
480
+ this.crop_bounds.y = Math.max(0, Math.min(this.crop_bounds.y, this.dimensions.height - this.crop_bounds.height));
481
+ // If y position was constrained, adjust height to prevent opposite handle drift
482
+ if (this.crop_bounds.y !== oldY &&
483
+ (this.active_corner_index === 0 ||
484
+ this.active_corner_index === 1 ||
485
+ this.active_edge_index === 0)) {
486
+ // Top side was constrained, adjust height to compensate
487
+ const yDiff = this.crop_bounds.y - oldY;
488
+ this.crop_bounds.height -= yDiff;
489
+ }
490
+ // Final check to ensure width and height don't exceed image dimensions from current position
491
+ this.crop_bounds.width = Math.max(20, Math.min(this.crop_bounds.width, this.dimensions.width - this.crop_bounds.x));
492
+ this.crop_bounds.height = Math.max(20, Math.min(this.crop_bounds.height, this.dimensions.height - this.crop_bounds.y));
493
+ }
494
+ /**
495
+ * updates the crop mask graphics
496
+ */
497
+ update_crop_mask() {
498
+ if (!this.crop_mask)
499
+ return;
500
+ // Clear the previous mask shape
501
+ this.crop_mask.clear();
502
+ const { width, height } = this.image_editor_context.background_image?.getLocalBounds() ?? {
503
+ width: 0,
504
+ height: 0
505
+ };
506
+ this.crop_mask
507
+ .rect(0, 0, width, height)
508
+ .fill({ color: 0x000000, alpha: 0.4 })
509
+ .rect(this.crop_bounds.x, this.crop_bounds.y, this.crop_bounds.width, this.crop_bounds.height)
510
+ .cut();
511
+ // Reset blend mode
512
+ // this.crop_mask.blendMode = "normal";
513
+ // Make the mask visible (previously we had alpha=0)
514
+ // this.crop_mask.alpha = 1;
515
+ // Since we're not using this as a mask anymore, but as an overlay,
516
+ // we should remove any existing mask assignment
517
+ if (this.image_editor_context.background_image) {
518
+ this.image_editor_context.background_image.mask = null;
519
+ }
520
+ // Make sure the mask is not applied to the image container
521
+ if (this.image_editor_context.image_container.mask === this.crop_mask) {
522
+ this.image_editor_context.image_container.mask = null;
523
+ }
524
+ }
525
+ /**
526
+ * updates the crop ui position and dimensions
527
+ */
528
+ update_crop_ui() {
529
+ if (!this.crop_mask || !this.crop_ui_container)
530
+ return;
531
+ // Move crop_ui_container so that (0,0) of its local space aligns with the crop_bounds on the stage
532
+ this.crop_ui_container.position.set(this.position.x + this.crop_bounds.x * this.scale, this.position.y + this.crop_bounds.y * this.scale);
533
+ // Get the scaled dimensions
534
+ const scaled_width = this.crop_bounds.width * this.scale;
535
+ const scaled_height = this.crop_bounds.height * this.scale;
536
+ // Redraw the outline so it matches the crop_bounds size
537
+ const outline = this.crop_ui_container.getChildAt(0);
538
+ outline
539
+ .clear()
540
+ .rect(0, 0, scaled_width, scaled_height)
541
+ .stroke({ width: 1, color: 0x000000, alignment: 0, alpha: 0.5 });
542
+ // Update the hit area to match the new dimensions
543
+ outline.hitArea = new Rectangle(0, 0, scaled_width, scaled_height);
544
+ // Update handle positions to reflect the cropped area, not the full image
545
+ this.update_handle_positions(scaled_width, scaled_height);
546
+ }
547
+ /**
548
+ * updates the positions of all handles
549
+ * @param width - width of the crop area
550
+ * @param height - height of the crop area
551
+ */
552
+ update_handle_positions(width, height) {
553
+ if (!this.crop_ui_container)
554
+ return;
555
+ // Skip the outline (index 0), get corner/edge handles
556
+ const handles = this.crop_ui_container.children.slice(1);
557
+ // First 4 are corners
558
+ const corners = handles.slice(0, 4);
559
+ const cornerPositions = [
560
+ { x: 0, y: 0 }, // Top-left
561
+ { x: width, y: 0 }, // Top-right
562
+ { x: 0, y: height }, // Bottom-left
563
+ { x: width, y: height } // Bottom-right
564
+ ];
565
+ corners.forEach((handle, i) => {
566
+ const xScale = i % 2 === 0 ? 1 : -1;
567
+ const yScale = i < 2 ? 1 : -1;
568
+ handle.position.set(cornerPositions[i].x -
569
+ (xScale < 0 ? -this.LINE_THICKNESS : this.LINE_THICKNESS), cornerPositions[i].y -
570
+ (yScale < 0 ? -this.LINE_THICKNESS : this.LINE_THICKNESS));
571
+ });
572
+ // Remaining 4 are edges
573
+ const edges = handles.slice(4);
574
+ edges.forEach((handle, i) => {
575
+ if (i < 2) {
576
+ // Top/Bottom edges
577
+ handle.position.set(width / 2 - (this.CORNER_SIZE * 1.5) / 2, // Changed from 3 to 2
578
+ i === 0 ? -this.LINE_THICKNESS : height);
579
+ }
580
+ else {
581
+ // Left/Right edges
582
+ handle.position.set(i === 2 ? 0 : width + this.LINE_THICKNESS, height / 2 - (this.CORNER_SIZE * 1.5) / 2 // Changed from 3 to 2
583
+ );
584
+ }
585
+ });
586
+ }
587
+ /**
588
+ * sets up event listeners for the crop tool
589
+ */
590
+ setup_event_listeners() {
591
+ const stage = this.image_editor_context.app.stage;
592
+ stage.eventMode = "static";
593
+ stage.on("pointermove", this.handle_pointer_move.bind(this));
594
+ stage.on("pointerup", this.handle_pointer_up.bind(this));
595
+ stage.on("pointerupoutside", this.handle_pointer_up.bind(this));
596
+ }
597
+ /**
598
+ * removes event listeners for the crop tool
599
+ */
600
+ cleanup_event_listeners() {
601
+ const stage = this.image_editor_context.app.stage;
602
+ stage.off("pointermove", this.handle_pointer_move.bind(this));
603
+ stage.off("pointerup", this.handle_pointer_up.bind(this));
604
+ stage.off("pointerupoutside", this.handle_pointer_up.bind(this));
605
+ }
606
+ /**
607
+ * handles pointer move events
608
+ * @param event - the pointer event
609
+ */
610
+ handle_pointer_move(event) {
611
+ if (this.current_subtool !== "crop")
612
+ return;
613
+ if (this.is_dragging_window &&
614
+ this.drag_start_position &&
615
+ this.drag_start_bounds) {
616
+ // Handle window dragging
617
+ const local_pos = this.image_editor_context.image_container.toLocal(event.global);
618
+ const delta = new Point(local_pos.x - this.drag_start_position.x, local_pos.y - this.drag_start_position.y);
619
+ // Update crop bounds based on drag
620
+ this.crop_bounds = {
621
+ x: this.drag_start_bounds.x + delta.x,
622
+ y: this.drag_start_bounds.y + delta.y,
623
+ width: this.drag_start_bounds.width,
624
+ height: this.drag_start_bounds.height
625
+ };
626
+ // Constrain the bounds
627
+ this.constrain_crop_bounds();
628
+ // Update the mask
629
+ this.update_crop_mask();
630
+ // Make sure to apply mask to the current background image
631
+ if (this.crop_mask && this.image_editor_context.background_image) {
632
+ this.image_editor_context.background_image.setMask(this.crop_mask);
633
+ }
634
+ return;
635
+ }
636
+ // Handle existing resize logic
637
+ if (this.is_dragging &&
638
+ this.selected_handle &&
639
+ this.last_pointer_position) {
640
+ // Convert from global to local, no extra divide
641
+ const local_pos = this.image_editor_context.image_container.toLocal(event.global);
642
+ const current_position = new Point(local_pos.x, local_pos.y);
643
+ const delta = new Point(current_position.x - this.last_pointer_position.x, current_position.y - this.last_pointer_position.y);
644
+ this.update_crop_bounds(delta);
645
+ this.last_pointer_position = current_position;
646
+ this.update_crop_mask();
647
+ }
648
+ }
649
+ /**
650
+ * handles pointer up events
651
+ */
652
+ handle_pointer_up() {
653
+ if (this.current_subtool !== "crop")
654
+ return;
655
+ this.is_dragging = false;
656
+ this.is_dragging_window = false;
657
+ this.selected_handle = null;
658
+ this.last_pointer_position = null;
659
+ this.drag_start_position = null;
660
+ this.drag_start_bounds = null;
661
+ this.active_corner_index = -1;
662
+ this.active_edge_index = -1;
663
+ this.notify("change");
664
+ }
665
+ /**
666
+ * handles the start of window dragging
667
+ * @param event - the pointer event
668
+ */
669
+ handle_window_drag_start(event) {
670
+ if (this.current_subtool !== "crop")
671
+ return;
672
+ event.stopPropagation();
673
+ this.is_dragging_window = true;
674
+ // Set flag indicating the crop has been manually changed
675
+ this.has_been_manually_changed = true;
676
+ // Store the initial position and bounds
677
+ const local_pos = this.image_editor_context.image_container.toLocal(event.global);
678
+ this.drag_start_position = new Point(local_pos.x, local_pos.y);
679
+ this.drag_start_bounds = { ...this.crop_bounds };
680
+ }
681
+ /**
682
+ * @returns whether the crop has been manually changed by the user
683
+ */
684
+ get crop_manually_changed() {
685
+ return this.has_been_manually_changed;
686
+ }
687
+ on(event, callback) {
688
+ this.event_callbacks.set(event, [
689
+ ...(this.event_callbacks.get(event) || []),
690
+ callback
691
+ ]);
692
+ }
693
+ off(event, callback) {
694
+ this.event_callbacks.set(event, this.event_callbacks.get(event)?.filter((cb) => cb !== callback) || []);
695
+ }
696
+ notify(event) {
697
+ for (const callback of this.event_callbacks.get(event) || []) {
698
+ callback();
699
+ }
700
+ }
701
+ get_crop_bounds() {
702
+ const { width, height } = this.image_editor_context.background_image?.getLocalBounds() ?? {
703
+ width: 0,
704
+ height: 0
705
+ };
706
+ return {
707
+ x: this.crop_bounds.x,
708
+ y: this.crop_bounds.y,
709
+ crop_dimensions: {
710
+ width: this.crop_bounds.width,
711
+ height: this.crop_bounds.height
712
+ },
713
+ image_dimensions: {
714
+ width,
715
+ height
716
+ }
717
+ };
718
+ }
719
+ get_image() {
720
+ if (!this.image_editor_context.background_image)
721
+ return Promise.resolve(null);
722
+ const container = new Container();
723
+ const sprite = new Sprite(this.image_editor_context.background_image.texture);
724
+ container.addChild(sprite);
725
+ return get_canvas_blob(this.image_editor_context.app.renderer, container, this.crop_bounds);
726
+ }
727
+ }