@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,1027 @@
1
+ import {
2
+ Container,
3
+ Graphics,
4
+ Point,
5
+ Rectangle,
6
+ FederatedPointerEvent,
7
+ Sprite
8
+ } from "pixi.js";
9
+ import { type ImageEditorContext, type Tool } from "../core/editor";
10
+
11
+ import { type Tool as ToolbarTool, type Subtool } from "../Toolbar.svelte";
12
+ import { get_canvas_blob } from "../utils/pixi";
13
+
14
+ /**
15
+ * @interface CropBounds
16
+ * @description Defines the bounds of a crop area
17
+ * @property {number} x - The x-coordinate of the crop area
18
+ * @property {number} y - The y-coordinate of the crop area
19
+ * @property {number} width - The width of the crop area
20
+ * @property {number} height - The height of the crop area
21
+ */
22
+ interface CropBounds {
23
+ x: number;
24
+ y: number;
25
+ width: number;
26
+ height: number;
27
+ }
28
+
29
+ /**
30
+ * @interface EditorState
31
+ * @description Defines the state of the editor
32
+ * @property {ToolbarTool} current_tool - The currently selected tool
33
+ * @property {Subtool} current_subtool - The currently selected subtool
34
+ */
35
+ interface EditorState {
36
+ current_tool: ToolbarTool;
37
+ current_subtool: Subtool;
38
+ }
39
+
40
+ /**
41
+ * @class CropTool
42
+ * @implements {Tool}
43
+ * @description Implements the crop tool functionality for the image editor
44
+ */
45
+ export class CropTool implements Tool {
46
+ name = "crop" as const;
47
+ private image_editor_context!: ImageEditorContext;
48
+ current_tool: ToolbarTool = "image";
49
+ current_subtool: Subtool = "crop";
50
+
51
+ // Constants
52
+ private readonly CORNER_SIZE = 25;
53
+ private readonly LINE_THICKNESS = 5;
54
+ private readonly HANDLE_COLOR = 0x000000;
55
+ private readonly HIT_AREA_SIZE = 40;
56
+
57
+ // State
58
+ private is_dragging = false;
59
+ private selected_handle: Container | null = null;
60
+ private active_corner_index = -1;
61
+ private active_edge_index = -1;
62
+ private last_pointer_position: Point | null = null;
63
+ crop_bounds: CropBounds = { x: 0, y: 0, width: 0, height: 0 };
64
+ private crop_ui_container: Container | null = null;
65
+ private crop_mask: Graphics | null = null;
66
+ private dimensions: { width: number; height: number } = {
67
+ width: 0,
68
+ height: 0
69
+ };
70
+ private position: { x: number; y: number } = { x: 0, y: 0 };
71
+ private scale = 1;
72
+ private is_dragging_window = false;
73
+ private drag_start_position: Point | null = null;
74
+ private drag_start_bounds: CropBounds | null = null;
75
+ private background_image_watcher: (() => void) | null = null;
76
+ private has_been_manually_changed = false;
77
+ private event_callbacks: Map<string, (() => void)[]> = new Map();
78
+ /**
79
+ * @method setup
80
+ * @async
81
+ * @description Sets up the crop tool with the given context and state
82
+ * @param {ImageEditorContext} context - The image editor context
83
+ * @param {ToolbarTool} tool - The current tool
84
+ * @param {Subtool} subtool - The current subtool
85
+ * @returns {Promise<void>}
86
+ */
87
+ async setup(
88
+ context: ImageEditorContext,
89
+ tool: ToolbarTool,
90
+ subtool: Subtool
91
+ ): Promise<void> {
92
+ this.image_editor_context = context;
93
+ this.current_tool = tool;
94
+ this.current_subtool = subtool;
95
+
96
+ context.dimensions.subscribe((dimensions) => {
97
+ this.dimensions = dimensions;
98
+
99
+ // Update crop bounds to match new dimensions
100
+ this.crop_bounds = {
101
+ x: 0,
102
+ y: 0,
103
+ width: dimensions.width,
104
+ height: dimensions.height
105
+ };
106
+
107
+ // Force UI update if it exists
108
+ if (this.crop_ui_container) {
109
+ this.update_crop_ui();
110
+ }
111
+
112
+ // Always update the mask when dimensions change
113
+ this.set_crop_mask();
114
+ this.update_crop_mask();
115
+ });
116
+
117
+ context.position.subscribe((position) => {
118
+ this.position = position;
119
+ });
120
+
121
+ context.scale.subscribe((scale) => {
122
+ this.scale = scale;
123
+ });
124
+
125
+ // Setup a background image watcher to ensure we always apply the mask to the current background image
126
+ this.background_image_watcher = () => {
127
+ // Only apply when crop tool is active and we have a mask
128
+ if (
129
+ this.crop_mask &&
130
+ this.current_tool === "image" &&
131
+ this.current_subtool === "crop"
132
+ ) {
133
+ // Get a fresh reference to the background image and apply the mask
134
+ if (this.image_editor_context.background_image) {
135
+ this.image_editor_context.background_image.setMask(this.crop_mask);
136
+ }
137
+ }
138
+ };
139
+
140
+ // Add the watcher to the ticker to regularly check for background image changes
141
+ this.image_editor_context.app.ticker.add(this.background_image_watcher);
142
+
143
+ // Initialize crop UI
144
+ await this.init_crop_ui();
145
+
146
+ // Setup event listeners
147
+ this.setup_event_listeners();
148
+
149
+ // Ensure the crop UI visibility matches the current tool state
150
+ const isCropActive = tool === "image" && subtool === "crop";
151
+ if (this.crop_ui_container) {
152
+ this.crop_ui_container.visible = isCropActive;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * @method cleanup
158
+ * @description Cleans up the crop tool resources and removes event listeners
159
+ * @returns {void}
160
+ */
161
+ cleanup(): void {
162
+ // Remove the crop UI container
163
+ if (this.crop_ui_container) {
164
+ this.image_editor_context.app.stage.removeChild(this.crop_ui_container);
165
+ }
166
+
167
+ // Remove the mask and reset the image container's mask property
168
+ if (this.crop_mask) {
169
+ if (this.crop_mask.parent) {
170
+ this.crop_mask.parent.removeChild(this.crop_mask);
171
+ }
172
+
173
+ // Clear mask from background_image if applied
174
+ if (
175
+ this.image_editor_context.background_image &&
176
+ this.image_editor_context.background_image.mask === this.crop_mask
177
+ ) {
178
+ this.image_editor_context.background_image.mask = null;
179
+ }
180
+
181
+ // Clear mask from image_container if applied
182
+ if (this.image_editor_context.image_container.mask === this.crop_mask) {
183
+ this.image_editor_context.image_container.mask = null;
184
+ }
185
+ }
186
+
187
+ // Ensure all references to the crop mask are cleared
188
+ this.crop_mask = null;
189
+ this.crop_ui_container = null;
190
+
191
+ // Remove the background image watcher from the ticker
192
+ if (this.background_image_watcher) {
193
+ this.image_editor_context.app.ticker.remove(
194
+ this.background_image_watcher
195
+ );
196
+ this.background_image_watcher = null;
197
+ }
198
+
199
+ // Remove event listeners
200
+ this.cleanup_event_listeners();
201
+ }
202
+
203
+ /**
204
+ * @method set_tool
205
+ * @description Sets the current tool and subtool, updating UI visibility
206
+ * @param {ToolbarTool} tool - The tool to set
207
+ * @param {Subtool} subtool - The subtool to set
208
+ * @returns {void}
209
+ */
210
+ set_tool(tool: ToolbarTool, subtool: Subtool): void {
211
+ this.current_tool = tool;
212
+ this.current_subtool = subtool;
213
+
214
+ // Show/hide crop UI based on tool and subtool
215
+ const isCropActive = tool === "image" && subtool === "crop";
216
+
217
+ // Only update the UI visibility, not the mask
218
+ if (this.crop_ui_container) {
219
+ this.crop_ui_container.visible = isCropActive;
220
+ }
221
+
222
+ // If crop tool is active, ensure the mask is up to date
223
+ if (isCropActive && this.crop_mask) {
224
+ this.update_crop_mask();
225
+ }
226
+ }
227
+
228
+ /**
229
+ * @method set_crop_mask
230
+ * @private
231
+ * @description Sets up the crop mask for the image
232
+ * @returns {void}
233
+ */
234
+ private set_crop_mask(): void {
235
+ // Remove any existing mask to avoid duplicates
236
+ if (this.crop_mask) {
237
+ if (this.crop_mask.parent) {
238
+ this.crop_mask.parent.removeChild(this.crop_mask);
239
+ }
240
+
241
+ // Clear mask from image_container if it was applied there
242
+ if (this.image_editor_context.image_container.mask === this.crop_mask) {
243
+ this.image_editor_context.image_container.mask = null;
244
+ }
245
+
246
+ // Clear mask from background_image if it was applied there
247
+ if (
248
+ this.image_editor_context.background_image &&
249
+ this.image_editor_context.background_image.mask === this.crop_mask
250
+ ) {
251
+ this.image_editor_context.background_image.mask = null;
252
+ }
253
+ }
254
+
255
+ // Create a new mask that will actually be used as an overlay
256
+ this.crop_mask = new Graphics();
257
+
258
+ // Add the mask/overlay to the display list
259
+ // This time it should be visible since we want to see the semi-transparent overlay
260
+ this.image_editor_context.image_container.addChild(this.crop_mask);
261
+ }
262
+
263
+ /**
264
+ * @method init_crop_ui
265
+ * @private
266
+ * @async
267
+ * @description Initializes the crop UI elements and sets up the update loop
268
+ * @returns {Promise<void>}
269
+ */
270
+ private async init_crop_ui(): Promise<void> {
271
+ // Create crop UI with scaled dimensions
272
+ const { width, height } =
273
+ this.image_editor_context.background_image?.getLocalBounds() || {
274
+ width: false,
275
+ height: false
276
+ };
277
+
278
+ this.crop_ui_container = this.make_crop_ui(
279
+ this.dimensions.width * this.scale,
280
+ this.dimensions.height * this.scale
281
+ );
282
+
283
+ // Position the crop UI container at the image position
284
+ this.crop_ui_container.position.set(this.position.x, this.position.y);
285
+
286
+ // Initially hide the crop UI until explicitly enabled by set_tool
287
+ this.crop_ui_container.visible = false;
288
+
289
+ this.image_editor_context.app.stage.addChild(this.crop_ui_container);
290
+
291
+ // Create and apply mask to the image container
292
+ this.set_crop_mask();
293
+
294
+ // Start the update loop for the UI
295
+ this.image_editor_context.app.ticker.add(this.update_crop_ui.bind(this));
296
+
297
+ // Initialize the mask with the current crop bounds
298
+ this.update_crop_mask();
299
+ }
300
+
301
+ /**
302
+ * @method make_crop_ui
303
+ * @private
304
+ * @description Creates the crop UI with the specified dimensions
305
+ * @param {number} width - Width of the crop area
306
+ * @param {number} height - Height of the crop area
307
+ * @returns {Container} The created crop UI container
308
+ */
309
+ private make_crop_ui(width: number, height: number): Container {
310
+ const crop_container = new Container();
311
+ crop_container.eventMode = "static";
312
+ crop_container.interactiveChildren = true;
313
+
314
+ // Create crop outline with hit area for dragging
315
+ const crop_outline = new Graphics()
316
+ .rect(0, 0, width, height)
317
+ .stroke({ width: 1, color: 0x000000, alignment: 0, alpha: 0.5 });
318
+
319
+ // Make the outline interactive
320
+ crop_outline.eventMode = "static";
321
+ crop_outline.cursor = "move";
322
+
323
+ // Set hit area to match the outline's dimensions
324
+ crop_outline.hitArea = new Rectangle(0, 0, width, height);
325
+
326
+ // Add drag handlers to the outline
327
+ crop_outline.on("pointerdown", this.handle_window_drag_start.bind(this));
328
+
329
+ crop_container.addChild(crop_outline);
330
+
331
+ // Create corner handles
332
+ this.create_corner_handles(crop_container, width, height);
333
+
334
+ // Create edge handles
335
+ this.create_edge_handles(crop_container, width, height);
336
+
337
+ return crop_container;
338
+ }
339
+
340
+ /**
341
+ * @method create_handle
342
+ * @private
343
+ * @description Creates a handle for the crop UI
344
+ * @param {boolean} [is_edge=false] - Whether the handle is an edge handle
345
+ * @returns {Container} The created handle container
346
+ */
347
+ private create_handle(is_edge = false): Container {
348
+ const handle = new Container();
349
+ handle.eventMode = "static";
350
+
351
+ const handle_graphics = new Graphics();
352
+
353
+ if (is_edge) {
354
+ // Create a shorter bar for edge handles (2x instead of 3x)
355
+ handle_graphics
356
+ .rect(0, 0, this.CORNER_SIZE * 1.5, this.LINE_THICKNESS)
357
+ .fill(this.HANDLE_COLOR);
358
+ } else {
359
+ // Create L-shaped corner handles
360
+ handle_graphics
361
+ .rect(0, 0, this.CORNER_SIZE, this.LINE_THICKNESS)
362
+ .rect(0, 0, this.LINE_THICKNESS, this.CORNER_SIZE)
363
+ .fill(this.HANDLE_COLOR);
364
+ }
365
+
366
+ handle.addChild(handle_graphics);
367
+
368
+ // Adjust hit area based on handle type
369
+ const hit_size = is_edge ? this.HIT_AREA_SIZE * 1.5 : this.HIT_AREA_SIZE;
370
+ handle.hitArea = new Rectangle(
371
+ -hit_size / 2 + this.LINE_THICKNESS,
372
+ -hit_size / 2 + this.LINE_THICKNESS,
373
+ hit_size,
374
+ hit_size
375
+ );
376
+
377
+ return handle;
378
+ }
379
+
380
+ /**
381
+ * @method create_edge_handles
382
+ * @private
383
+ * @description Creates edge handles for the crop UI
384
+ * @param {Container} container - The container to add handles to
385
+ * @param {number} width - Width of the crop area
386
+ * @param {number} height - Height of the crop area
387
+ * @returns {void}
388
+ */
389
+ private create_edge_handles(
390
+ container: Container,
391
+ width: number,
392
+ height: number
393
+ ): void {
394
+ // Horizontal handles at top and bottom
395
+ [-1, 1].forEach((i, index) => {
396
+ const handle = this.create_handle(true); // true for edge handle
397
+ handle.rotation = 0;
398
+
399
+ handle.on("pointerdown", (event: FederatedPointerEvent) => {
400
+ this.handle_pointer_down(event, handle, -1, index); // 0 for top, 1 for bottom
401
+ });
402
+
403
+ // Center the handle
404
+ handle.x = width / 2 - (this.CORNER_SIZE * 3) / 2;
405
+ handle.y = i < 0 ? -this.LINE_THICKNESS : height;
406
+ handle.cursor = "ns-resize";
407
+
408
+ container.addChild(handle);
409
+ });
410
+
411
+ // Vertical handles at left and right
412
+ [-1, 1].forEach((i, index) => {
413
+ const handle = this.create_handle(true); // true for edge handle
414
+ handle.rotation = Math.PI / 2; // Rotate 90 degrees
415
+
416
+ handle.on("pointerdown", (event: FederatedPointerEvent) => {
417
+ this.handle_pointer_down(event, handle, -1, index + 2); // 2 for left, 3 for right
418
+ });
419
+
420
+ // Center the handle
421
+ handle.x = i < 0 ? -this.LINE_THICKNESS : width;
422
+ handle.y = height / 2 - (this.CORNER_SIZE * 3) / 2;
423
+ handle.cursor = "ew-resize";
424
+
425
+ container.addChild(handle);
426
+ });
427
+ }
428
+
429
+ /**
430
+ * @method create_corner_handles
431
+ * @private
432
+ * @description Creates corner handles for the crop UI
433
+ * @param {Container} container - The container to add handles to
434
+ * @param {number} width - Width of the crop area
435
+ * @param {number} height - Height of the crop area
436
+ * @returns {void}
437
+ */
438
+ private create_corner_handles(
439
+ container: Container,
440
+ width: number,
441
+ height: number
442
+ ): void {
443
+ const corners = [
444
+ { x: 0, y: 0, xScale: 1, yScale: 1, cursor: "nwse-resize" }, // Top-left
445
+ { x: width, y: 0, xScale: -1, yScale: 1, cursor: "nesw-resize" }, // Top-right
446
+ { x: 0, y: height, xScale: 1, yScale: -1, cursor: "nesw-resize" }, // Bottom-left
447
+ { x: width, y: height, xScale: -1, yScale: -1, cursor: "nwse-resize" } // Bottom-right
448
+ ];
449
+
450
+ corners.forEach(({ x, y, xScale, yScale, cursor }, i) => {
451
+ const corner = this.create_handle(false); // false for corner handle
452
+ corner.x = x - (xScale < 0 ? -this.LINE_THICKNESS : this.LINE_THICKNESS);
453
+ corner.y = y - (yScale < 0 ? -this.LINE_THICKNESS : this.LINE_THICKNESS);
454
+ corner.scale.set(xScale, yScale);
455
+
456
+ corner.on("pointerdown", (event: FederatedPointerEvent) => {
457
+ this.handle_pointer_down(event, corner, i, -1);
458
+ });
459
+
460
+ corner.cursor = cursor;
461
+
462
+ container.addChild(corner);
463
+ });
464
+ }
465
+
466
+ /**
467
+ * @method handle_pointer_down
468
+ * @private
469
+ * @description Handles pointer down events on handles
470
+ * @param {FederatedPointerEvent} event - The pointer event
471
+ * @param {Container} handle - The handle that was clicked
472
+ * @param {number} corner_index - Index of the corner (-1 if not a corner)
473
+ * @param {number} edge_index - Index of the edge (-1 if not an edge)
474
+ * @returns {void}
475
+ */
476
+ private handle_pointer_down(
477
+ event: FederatedPointerEvent,
478
+ handle: Container,
479
+ corner_index: number,
480
+ edge_index: number
481
+ ): void {
482
+ // Only handle crop events if crop tool is active
483
+ if (this.current_subtool !== "crop") return;
484
+
485
+ event.stopPropagation();
486
+
487
+ this.is_dragging = true;
488
+ this.selected_handle = handle;
489
+ this.active_corner_index = corner_index;
490
+ this.active_edge_index = edge_index;
491
+
492
+ // Convert global position to the container's local coordinates.
493
+ const local_pos = this.image_editor_context.image_container.toLocal(
494
+ event.global
495
+ );
496
+
497
+ this.last_pointer_position = new Point(local_pos.x, local_pos.y);
498
+ }
499
+
500
+ /**
501
+ * updates the crop bounds based on pointer movement
502
+ * @param delta - the change in position
503
+ */
504
+ private update_crop_bounds(delta: Point): void {
505
+ // Set flag indicating the crop has been manually changed
506
+ this.has_been_manually_changed = true;
507
+
508
+ // Scale the delta to match the image coordinates
509
+ const scaled_delta = new Point(delta.x, delta.y);
510
+
511
+ // Store original values to calculate actual changes
512
+ const original = {
513
+ x: this.crop_bounds.x,
514
+ y: this.crop_bounds.y,
515
+ width: this.crop_bounds.width,
516
+ height: this.crop_bounds.height
517
+ };
518
+
519
+ if (this.active_corner_index !== -1) {
520
+ // Handle corner dragging
521
+ switch (this.active_corner_index) {
522
+ case 0: // Top-left
523
+ // Update width and x position
524
+ const newWidth0 = Math.max(20, original.width - scaled_delta.x);
525
+ const widthDiff0 = original.width - newWidth0;
526
+ this.crop_bounds.width = newWidth0;
527
+ this.crop_bounds.x = original.x + widthDiff0;
528
+
529
+ // Update height and y position
530
+ const newHeight0 = Math.max(20, original.height - scaled_delta.y);
531
+ const heightDiff0 = original.height - newHeight0;
532
+ this.crop_bounds.height = newHeight0;
533
+ this.crop_bounds.y = original.y + heightDiff0;
534
+ break;
535
+
536
+ case 1: // Top-right
537
+ // Update width (right side)
538
+ this.crop_bounds.width = Math.max(
539
+ 20,
540
+ original.width + scaled_delta.x
541
+ );
542
+
543
+ // Update height and y position (top side)
544
+ const newHeight1 = Math.max(20, original.height - scaled_delta.y);
545
+ const heightDiff1 = original.height - newHeight1;
546
+ this.crop_bounds.height = newHeight1;
547
+ this.crop_bounds.y = original.y + heightDiff1;
548
+ break;
549
+
550
+ case 2: // Bottom-left
551
+ // Update width and x position (left side)
552
+ const newWidth2 = Math.max(20, original.width - scaled_delta.x);
553
+ const widthDiff2 = original.width - newWidth2;
554
+ this.crop_bounds.width = newWidth2;
555
+ this.crop_bounds.x = original.x + widthDiff2;
556
+
557
+ // Update height (bottom side)
558
+ this.crop_bounds.height = Math.max(
559
+ 20,
560
+ original.height + scaled_delta.y
561
+ );
562
+ break;
563
+
564
+ case 3: // Bottom-right
565
+ // Update width (right side)
566
+ this.crop_bounds.width = Math.max(
567
+ 20,
568
+ original.width + scaled_delta.x
569
+ );
570
+
571
+ // Update height (bottom side)
572
+ this.crop_bounds.height = Math.max(
573
+ 20,
574
+ original.height + scaled_delta.y
575
+ );
576
+ break;
577
+ }
578
+ } else if (this.active_edge_index !== -1) {
579
+ // Handle edge dragging
580
+ switch (this.active_edge_index) {
581
+ case 0: // Top
582
+ // Update height and y position
583
+ const newHeight = Math.max(20, original.height - scaled_delta.y);
584
+ const heightDiff = original.height - newHeight;
585
+ this.crop_bounds.height = newHeight;
586
+ this.crop_bounds.y = original.y + heightDiff;
587
+ break;
588
+
589
+ case 1: // Bottom
590
+ // Update height only
591
+ this.crop_bounds.height = Math.max(
592
+ 20,
593
+ original.height + scaled_delta.y
594
+ );
595
+ break;
596
+
597
+ case 2: // Left
598
+ // Update width and x position
599
+ const newWidth = Math.max(20, original.width - scaled_delta.x);
600
+ const widthDiff = original.width - newWidth;
601
+ this.crop_bounds.width = newWidth;
602
+ this.crop_bounds.x = original.x + widthDiff;
603
+ break;
604
+
605
+ case 3: // Right
606
+ // Update width only
607
+ this.crop_bounds.width = Math.max(
608
+ 20,
609
+ original.width + scaled_delta.x
610
+ );
611
+ break;
612
+ }
613
+ }
614
+
615
+ // Apply constraints
616
+ this.constrain_crop_bounds();
617
+
618
+ // Update the UI and mask
619
+ this.update_crop_ui();
620
+ this.update_crop_mask();
621
+
622
+ // Make sure to apply mask to the current background image
623
+ if (this.crop_mask && this.image_editor_context.background_image) {
624
+ this.image_editor_context.background_image.setMask(this.crop_mask);
625
+ }
626
+ }
627
+
628
+ /**
629
+ * constrains the crop bounds to the image dimensions
630
+ */
631
+ private constrain_crop_bounds(): void {
632
+ // Store original values
633
+ const original = {
634
+ x: this.crop_bounds.x,
635
+ y: this.crop_bounds.y,
636
+ width: this.crop_bounds.width,
637
+ height: this.crop_bounds.height
638
+ };
639
+
640
+ // First, constrain width and height to prevent exceeding image dimensions
641
+ // and ensure minimum size
642
+ this.crop_bounds.width = Math.max(
643
+ 20,
644
+ Math.min(this.crop_bounds.width, this.dimensions.width)
645
+ );
646
+
647
+ this.crop_bounds.height = Math.max(
648
+ 20,
649
+ Math.min(this.crop_bounds.height, this.dimensions.height)
650
+ );
651
+
652
+ // Then constrain x and y to keep the crop window within the image bounds
653
+ const oldX = this.crop_bounds.x;
654
+ this.crop_bounds.x = Math.max(
655
+ 0,
656
+ Math.min(
657
+ this.crop_bounds.x,
658
+ this.dimensions.width - this.crop_bounds.width
659
+ )
660
+ );
661
+
662
+ // If x position was constrained, adjust width to prevent opposite handle drift
663
+ if (
664
+ (this.crop_bounds.x !== oldX && this.active_corner_index === 0) ||
665
+ this.active_edge_index === 2
666
+ ) {
667
+ // Left side was constrained, adjust width to compensate
668
+ const xDiff = this.crop_bounds.x - oldX;
669
+ this.crop_bounds.width -= xDiff;
670
+ }
671
+
672
+ const oldY = this.crop_bounds.y;
673
+ this.crop_bounds.y = Math.max(
674
+ 0,
675
+ Math.min(
676
+ this.crop_bounds.y,
677
+ this.dimensions.height - this.crop_bounds.height
678
+ )
679
+ );
680
+
681
+ // If y position was constrained, adjust height to prevent opposite handle drift
682
+ if (
683
+ this.crop_bounds.y !== oldY &&
684
+ (this.active_corner_index === 0 ||
685
+ this.active_corner_index === 1 ||
686
+ this.active_edge_index === 0)
687
+ ) {
688
+ // Top side was constrained, adjust height to compensate
689
+ const yDiff = this.crop_bounds.y - oldY;
690
+ this.crop_bounds.height -= yDiff;
691
+ }
692
+
693
+ // Final check to ensure width and height don't exceed image dimensions from current position
694
+ this.crop_bounds.width = Math.max(
695
+ 20,
696
+ Math.min(
697
+ this.crop_bounds.width,
698
+ this.dimensions.width - this.crop_bounds.x
699
+ )
700
+ );
701
+
702
+ this.crop_bounds.height = Math.max(
703
+ 20,
704
+ Math.min(
705
+ this.crop_bounds.height,
706
+ this.dimensions.height - this.crop_bounds.y
707
+ )
708
+ );
709
+ }
710
+
711
+ /**
712
+ * updates the crop mask graphics
713
+ */
714
+ private update_crop_mask(): void {
715
+ if (!this.crop_mask) return;
716
+
717
+ // Clear the previous mask shape
718
+ this.crop_mask.clear();
719
+
720
+ const { width, height } =
721
+ this.image_editor_context.background_image?.getLocalBounds() ?? {
722
+ width: 0,
723
+ height: 0
724
+ };
725
+ this.crop_mask
726
+ .rect(0, 0, width, height)
727
+ .fill({ color: 0x000000, alpha: 0.4 })
728
+ .rect(
729
+ this.crop_bounds.x,
730
+ this.crop_bounds.y,
731
+ this.crop_bounds.width,
732
+ this.crop_bounds.height
733
+ )
734
+ .cut();
735
+
736
+ // Reset blend mode
737
+ // this.crop_mask.blendMode = "normal";
738
+
739
+ // Make the mask visible (previously we had alpha=0)
740
+ // this.crop_mask.alpha = 1;
741
+
742
+ // Since we're not using this as a mask anymore, but as an overlay,
743
+ // we should remove any existing mask assignment
744
+ if (this.image_editor_context.background_image) {
745
+ this.image_editor_context.background_image.mask = null;
746
+ }
747
+
748
+ // Make sure the mask is not applied to the image container
749
+ if (this.image_editor_context.image_container.mask === this.crop_mask) {
750
+ this.image_editor_context.image_container.mask = null;
751
+ }
752
+ }
753
+
754
+ /**
755
+ * updates the crop ui position and dimensions
756
+ */
757
+ private update_crop_ui(): void {
758
+ if (!this.crop_mask || !this.crop_ui_container) return;
759
+
760
+ // Move crop_ui_container so that (0,0) of its local space aligns with the crop_bounds on the stage
761
+ this.crop_ui_container.position.set(
762
+ this.position.x + this.crop_bounds.x * this.scale,
763
+ this.position.y + this.crop_bounds.y * this.scale
764
+ );
765
+
766
+ // Get the scaled dimensions
767
+ const scaled_width = this.crop_bounds.width * this.scale;
768
+ const scaled_height = this.crop_bounds.height * this.scale;
769
+
770
+ // Redraw the outline so it matches the crop_bounds size
771
+ const outline = this.crop_ui_container.getChildAt(0) as Graphics;
772
+ outline
773
+ .clear()
774
+ .rect(0, 0, scaled_width, scaled_height)
775
+ .stroke({ width: 1, color: 0x000000, alignment: 0, alpha: 0.5 });
776
+
777
+ // Update the hit area to match the new dimensions
778
+ outline.hitArea = new Rectangle(0, 0, scaled_width, scaled_height);
779
+
780
+ // Update handle positions to reflect the cropped area, not the full image
781
+ this.update_handle_positions(scaled_width, scaled_height);
782
+ }
783
+
784
+ /**
785
+ * updates the positions of all handles
786
+ * @param width - width of the crop area
787
+ * @param height - height of the crop area
788
+ */
789
+ private update_handle_positions(width: number, height: number): void {
790
+ if (!this.crop_ui_container) return;
791
+
792
+ // Skip the outline (index 0), get corner/edge handles
793
+ const handles = this.crop_ui_container.children.slice(1);
794
+
795
+ // First 4 are corners
796
+ const corners = handles.slice(0, 4);
797
+ const cornerPositions = [
798
+ { x: 0, y: 0 }, // Top-left
799
+ { x: width, y: 0 }, // Top-right
800
+ { x: 0, y: height }, // Bottom-left
801
+ { x: width, y: height } // Bottom-right
802
+ ];
803
+
804
+ corners.forEach((handle, i) => {
805
+ const xScale = i % 2 === 0 ? 1 : -1;
806
+ const yScale = i < 2 ? 1 : -1;
807
+ handle.position.set(
808
+ cornerPositions[i].x -
809
+ (xScale < 0 ? -this.LINE_THICKNESS : this.LINE_THICKNESS),
810
+ cornerPositions[i].y -
811
+ (yScale < 0 ? -this.LINE_THICKNESS : this.LINE_THICKNESS)
812
+ );
813
+ });
814
+
815
+ // Remaining 4 are edges
816
+ const edges = handles.slice(4);
817
+ edges.forEach((handle, i) => {
818
+ if (i < 2) {
819
+ // Top/Bottom edges
820
+ handle.position.set(
821
+ width / 2 - (this.CORNER_SIZE * 1.5) / 2, // Changed from 3 to 2
822
+ i === 0 ? -this.LINE_THICKNESS : height
823
+ );
824
+ } else {
825
+ // Left/Right edges
826
+ handle.position.set(
827
+ i === 2 ? 0 : width + this.LINE_THICKNESS,
828
+ height / 2 - (this.CORNER_SIZE * 1.5) / 2 // Changed from 3 to 2
829
+ );
830
+ }
831
+ });
832
+ }
833
+
834
+ /**
835
+ * sets up event listeners for the crop tool
836
+ */
837
+ private setup_event_listeners(): void {
838
+ const stage = this.image_editor_context.app.stage;
839
+ stage.eventMode = "static";
840
+ stage.on("pointermove", this.handle_pointer_move.bind(this));
841
+ stage.on("pointerup", this.handle_pointer_up.bind(this));
842
+ stage.on("pointerupoutside", this.handle_pointer_up.bind(this));
843
+ }
844
+
845
+ /**
846
+ * removes event listeners for the crop tool
847
+ */
848
+ private cleanup_event_listeners(): void {
849
+ const stage = this.image_editor_context.app.stage;
850
+
851
+ stage.off("pointermove", this.handle_pointer_move.bind(this));
852
+ stage.off("pointerup", this.handle_pointer_up.bind(this));
853
+ stage.off("pointerupoutside", this.handle_pointer_up.bind(this));
854
+ }
855
+
856
+ /**
857
+ * handles pointer move events
858
+ * @param event - the pointer event
859
+ */
860
+ private handle_pointer_move(event: FederatedPointerEvent): void {
861
+ if (this.current_subtool !== "crop") return;
862
+
863
+ if (
864
+ this.is_dragging_window &&
865
+ this.drag_start_position &&
866
+ this.drag_start_bounds
867
+ ) {
868
+ // Handle window dragging
869
+ const local_pos = this.image_editor_context.image_container.toLocal(
870
+ event.global
871
+ );
872
+ const delta = new Point(
873
+ local_pos.x - this.drag_start_position.x,
874
+ local_pos.y - this.drag_start_position.y
875
+ );
876
+
877
+ // Update crop bounds based on drag
878
+ this.crop_bounds = {
879
+ x: this.drag_start_bounds.x + delta.x,
880
+ y: this.drag_start_bounds.y + delta.y,
881
+ width: this.drag_start_bounds.width,
882
+ height: this.drag_start_bounds.height
883
+ };
884
+
885
+ // Constrain the bounds
886
+ this.constrain_crop_bounds();
887
+
888
+ // Update the mask
889
+ this.update_crop_mask();
890
+
891
+ // Make sure to apply mask to the current background image
892
+ if (this.crop_mask && this.image_editor_context.background_image) {
893
+ this.image_editor_context.background_image.setMask(this.crop_mask);
894
+ }
895
+
896
+ return;
897
+ }
898
+
899
+ // Handle existing resize logic
900
+ if (
901
+ this.is_dragging &&
902
+ this.selected_handle &&
903
+ this.last_pointer_position
904
+ ) {
905
+ // Convert from global to local, no extra divide
906
+ const local_pos = this.image_editor_context.image_container.toLocal(
907
+ event.global
908
+ );
909
+ const current_position = new Point(local_pos.x, local_pos.y);
910
+
911
+ const delta = new Point(
912
+ current_position.x - this.last_pointer_position.x,
913
+ current_position.y - this.last_pointer_position.y
914
+ );
915
+
916
+ this.update_crop_bounds(delta);
917
+ this.last_pointer_position = current_position;
918
+ this.update_crop_mask();
919
+ }
920
+ }
921
+
922
+ /**
923
+ * handles pointer up events
924
+ */
925
+ private handle_pointer_up(): void {
926
+ if (this.current_subtool !== "crop") return;
927
+
928
+ this.is_dragging = false;
929
+ this.is_dragging_window = false;
930
+ this.selected_handle = null;
931
+ this.last_pointer_position = null;
932
+ this.drag_start_position = null;
933
+ this.drag_start_bounds = null;
934
+ this.active_corner_index = -1;
935
+ this.active_edge_index = -1;
936
+
937
+ this.notify("change");
938
+ }
939
+
940
+ /**
941
+ * handles the start of window dragging
942
+ * @param event - the pointer event
943
+ */
944
+ private handle_window_drag_start(event: FederatedPointerEvent): void {
945
+ if (this.current_subtool !== "crop") return;
946
+ event.stopPropagation();
947
+
948
+ this.is_dragging_window = true;
949
+ // Set flag indicating the crop has been manually changed
950
+ this.has_been_manually_changed = true;
951
+
952
+ // Store the initial position and bounds
953
+ const local_pos = this.image_editor_context.image_container.toLocal(
954
+ event.global
955
+ );
956
+ this.drag_start_position = new Point(local_pos.x, local_pos.y);
957
+ this.drag_start_bounds = { ...this.crop_bounds };
958
+ }
959
+
960
+ /**
961
+ * @returns whether the crop has been manually changed by the user
962
+ */
963
+ public get crop_manually_changed(): boolean {
964
+ return this.has_been_manually_changed;
965
+ }
966
+
967
+ on<T extends string>(event: T, callback: () => void): void {
968
+ this.event_callbacks.set(event, [
969
+ ...(this.event_callbacks.get(event) || []),
970
+ callback
971
+ ]);
972
+ }
973
+
974
+ off<T extends string>(event: T, callback: () => void): void {
975
+ this.event_callbacks.set(
976
+ event,
977
+ this.event_callbacks.get(event)?.filter((cb) => cb !== callback) || []
978
+ );
979
+ }
980
+
981
+ private notify<T extends string>(event: T): void {
982
+ for (const callback of this.event_callbacks.get(event) || []) {
983
+ callback();
984
+ }
985
+ }
986
+
987
+ public get_crop_bounds(): {
988
+ x: number;
989
+ y: number;
990
+ crop_dimensions: { width: number; height: number };
991
+ image_dimensions: { width: number; height: number };
992
+ } {
993
+ const { width, height } =
994
+ this.image_editor_context.background_image?.getLocalBounds() ?? {
995
+ width: 0,
996
+ height: 0
997
+ };
998
+ return {
999
+ x: this.crop_bounds.x,
1000
+ y: this.crop_bounds.y,
1001
+ crop_dimensions: {
1002
+ width: this.crop_bounds.width,
1003
+ height: this.crop_bounds.height
1004
+ },
1005
+ image_dimensions: {
1006
+ width,
1007
+ height
1008
+ }
1009
+ };
1010
+ }
1011
+
1012
+ public get_image(): Promise<Blob | null> {
1013
+ if (!this.image_editor_context.background_image)
1014
+ return Promise.resolve(null);
1015
+ const container = new Container();
1016
+ const sprite = new Sprite(
1017
+ this.image_editor_context.background_image.texture
1018
+ );
1019
+ container.addChild(sprite);
1020
+
1021
+ return get_canvas_blob(
1022
+ this.image_editor_context.app.renderer,
1023
+ container,
1024
+ this.crop_bounds
1025
+ );
1026
+ }
1027
+ }