@gradio/imageeditor 0.12.14 → 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 +14 -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 +10 -9
  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,729 @@
1
+ import {
2
+ Point,
3
+ type FederatedWheelEvent,
4
+ type FederatedPointerEvent
5
+ } from "pixi.js";
6
+ import { type ImageEditorContext, type Tool } from "../core/editor";
7
+ import { type Tool as ToolbarTool, type Subtool } from "../Toolbar.svelte";
8
+ import { get, writable } from "svelte/store";
9
+
10
+ /**
11
+ * ZoomTool class for handling zoom and pan functionality in the image editor
12
+ * Implements the Tool interface
13
+ */
14
+ export class ZoomTool implements Tool {
15
+ name = "zoom";
16
+ min_zoom = writable(true);
17
+
18
+ private image_editor_context!: ImageEditorContext;
19
+
20
+ // Constants
21
+ private readonly max_zoom = 10;
22
+ private readonly border_padding = 30;
23
+
24
+ // State
25
+ private pad_bottom = 0;
26
+ private is_pinching = false;
27
+ private is_dragging = false;
28
+ private is_pointer_dragging = false;
29
+ private last_touch_position: Point | null = null;
30
+ private last_pinch_distance = 0;
31
+ private drag_start = new Point();
32
+ private current_tool!: ToolbarTool;
33
+ private current_subtool!: Subtool;
34
+ private local_scale = 1;
35
+ private local_dimensions: { width: number; height: number } = {
36
+ width: 0,
37
+ height: 0
38
+ };
39
+ private local_position: { x: number; y: number } = { x: 0, y: 0 };
40
+
41
+ /**
42
+ * Prevents default behavior for events
43
+ * @param {FederatedWheelEvent | FederatedPointerEvent} event - The event to prevent default behavior for
44
+ */
45
+ prevent_default(
46
+ event: FederatedWheelEvent | FederatedPointerEvent | WheelEvent
47
+ ): void {
48
+ event.preventDefault();
49
+ event.stopPropagation();
50
+ }
51
+
52
+ /**
53
+ * Sets the current tool and subtool
54
+ * @param {ToolbarTool} tool - The tool to set
55
+ * @param {Subtool} subtool - The subtool to set
56
+ */
57
+ set_tool(tool: ToolbarTool, subtool: Subtool): void {
58
+ this.current_tool = tool;
59
+ this.current_subtool = subtool;
60
+ }
61
+
62
+ /**
63
+ * Sets the zoom level to a specific percentage or fits the image to the screen
64
+ * @param {number | 'fit'} zoom_level - The zoom level to set (0-1) or 'fit' to use min_zoom
65
+ * @returns {void}
66
+ */
67
+ set_zoom(zoom_level: number | "fit"): void {
68
+ // Calculate the min zoom level
69
+ const fit_zoom = this.calculate_min_zoom(
70
+ this.local_dimensions.width,
71
+ this.local_dimensions.height
72
+ );
73
+
74
+ // Determine the target zoom level
75
+ let target_zoom: number;
76
+ const is_fit_zoom = zoom_level === "fit";
77
+ if (is_fit_zoom) {
78
+ target_zoom = fit_zoom;
79
+ } else {
80
+ // Ensure zoom_level is between 0 and 1, then scale it to the range [min_zoom, max_zoom]
81
+ target_zoom = Math.max(0, Math.min(this.max_zoom, zoom_level));
82
+ }
83
+
84
+ // Get the canvas dimensions
85
+ const canvas = this.image_editor_context.app.screen;
86
+ const canvas_width = canvas.width;
87
+ const canvas_height = canvas.height;
88
+
89
+ // Determine the center point for zooming
90
+ let center_point: { x: number; y: number };
91
+
92
+ // Always use the center of the screen
93
+ center_point = {
94
+ x: canvas_width / 2,
95
+ y: canvas_height / 2
96
+ };
97
+
98
+ // Apply the zoom using the existing zoom_to_point method
99
+ this.zoom_to_point(target_zoom, center_point, true, is_fit_zoom);
100
+ }
101
+
102
+ /**
103
+ * Sets up the zoom tool with the given context and state
104
+ * @param {ImageEditorContext} context - The image editor context
105
+ * @param {ToolbarTool} tool - The current tool
106
+ * @param {Subtool} subtool - The current subtool
107
+ * @returns {Promise<void>}
108
+ */
109
+ async setup(
110
+ context: ImageEditorContext,
111
+ tool: ToolbarTool,
112
+ subtool: Subtool
113
+ ): Promise<void> {
114
+ this.image_editor_context = context;
115
+ this.current_tool = tool;
116
+ this.current_subtool = subtool;
117
+ this.pad_bottom = context.pad_bottom;
118
+ // Initialize zoom and position
119
+ const { width, height } = await this.get_container_dimensions();
120
+ const fit_zoom = this.calculate_min_zoom(width, height);
121
+ // Use fit zoom or 1, whichever is smaller
122
+ const min_zoom = Math.min(fit_zoom, 1);
123
+
124
+ // Set initial zoom
125
+ this.local_scale = min_zoom;
126
+
127
+ // Use simple center positioning
128
+ const canvas = this.image_editor_context.app.screen;
129
+
130
+ const center_x = canvas.width / 2;
131
+ const center_y = canvas.height / 2;
132
+
133
+ // Calculate initial position to center the scaled image
134
+ const scaled_width = width * min_zoom;
135
+ const scaled_height = height * min_zoom;
136
+ const initial_x = center_x - scaled_width / 2;
137
+ const initial_y = center_y - scaled_height / 2;
138
+
139
+ this.local_position = { x: initial_x, y: initial_y };
140
+
141
+ // Setup event listeners
142
+ this.setup_event_listeners();
143
+
144
+ // Update context with initial values
145
+ this.image_editor_context.set_image_properties({
146
+ scale: min_zoom,
147
+ position: { x: initial_x, y: initial_y }
148
+ });
149
+
150
+ this.image_editor_context.dimensions.subscribe((dimensions) => {
151
+ this.local_dimensions = dimensions;
152
+ });
153
+
154
+ this.image_editor_context.scale.subscribe((scale) => {
155
+ this.local_scale = scale;
156
+ });
157
+
158
+ this.image_editor_context.position.subscribe((position) => {
159
+ this.local_position = position;
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Sets up event listeners for zoom and pan functionality
165
+ * @private
166
+ */
167
+ private setup_event_listeners(): void {
168
+ const stage = this.image_editor_context.app.stage;
169
+ const canvas = this.image_editor_context.app.canvas;
170
+
171
+ canvas.addEventListener("wheel", this.prevent_default, { passive: false });
172
+
173
+ // Configure stage for interaction
174
+ stage.eventMode = "static";
175
+ stage.hitArea = this.image_editor_context.app.screen;
176
+
177
+ // Add wheel event with options
178
+ const wheel_listener = this.handle_wheel.bind(this);
179
+ stage.addEventListener("wheel", wheel_listener, { passive: false });
180
+
181
+ // Setup touch/pointer events based on device
182
+ if ("ontouchstart" in window) {
183
+ stage.addEventListener(
184
+ "touchstart",
185
+ this.handle_touch_start.bind(this) as EventListener
186
+ );
187
+ stage.addEventListener(
188
+ "touchmove",
189
+ this.handle_touch_move.bind(this) as EventListener
190
+ );
191
+ stage.addEventListener(
192
+ "touchend",
193
+ this.handle_touch_end.bind(this) as EventListener
194
+ );
195
+ } else {
196
+ stage.addEventListener(
197
+ "pointerdown",
198
+ this.handle_pointer_down.bind(this)
199
+ );
200
+ stage.addEventListener(
201
+ "pointermove",
202
+ this.handle_pointer_move.bind(this)
203
+ );
204
+ stage.addEventListener("pointerup", this.handle_pointer_up.bind(this));
205
+ stage.addEventListener(
206
+ "pointerupoutside",
207
+ this.handle_pointer_up.bind(this)
208
+ );
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Handles wheel events for zooming
214
+ * @param {FederatedWheelEvent} event - The wheel event
215
+ * @private
216
+ */
217
+ private handle_wheel(event: FederatedWheelEvent): void {
218
+ const is_trackpad = event.deltaMode === 0 && Math.abs(event.deltaY) < 50;
219
+ const scroll_speed = is_trackpad ? 30 : 10;
220
+
221
+ if (event.altKey || event.metaKey) {
222
+ // Handle zooming with our new simplified logic
223
+ const local_point = this.image_editor_context.app.stage.toLocal(
224
+ event.global
225
+ );
226
+ const zoom_delta = -event.deltaY * (is_trackpad ? 0.001 : 0.0005);
227
+ const new_scale = this.local_scale * (1 + zoom_delta);
228
+ this.zoom_to_point(new_scale, local_point, true);
229
+ } else {
230
+ // Handle panning with bounds
231
+ const delta_x = event.deltaX;
232
+ const delta_y = event.deltaY;
233
+ const raw_position = {
234
+ x: this.local_position.x - (delta_x / 100) * scroll_speed,
235
+ y: this.local_position.y - (delta_y / 100) * scroll_speed
236
+ };
237
+
238
+ const canvas = this.image_editor_context.app.screen;
239
+ const scaled_width = this.local_dimensions.width * this.local_scale;
240
+ const scaled_height = this.local_dimensions.height * this.local_scale;
241
+ const available_width = canvas.width - this.border_padding * 2;
242
+ const available_height =
243
+ canvas.height - this.border_padding * 2 - this.pad_bottom;
244
+
245
+ let final_position = { ...raw_position };
246
+
247
+ // If image is smaller than available space, center it
248
+ if (scaled_width <= available_width) {
249
+ final_position.x = (canvas.width - scaled_width) / 2;
250
+ } else {
251
+ // Apply 50% rule for horizontal bounds
252
+ const max_offset = canvas.width / 2;
253
+ const left_bound = max_offset;
254
+ const right_bound = canvas.width - max_offset - scaled_width;
255
+ final_position.x = Math.min(
256
+ Math.max(raw_position.x, right_bound),
257
+ left_bound
258
+ );
259
+ }
260
+
261
+ if (scaled_height <= available_height) {
262
+ final_position.y =
263
+ (canvas.height - this.pad_bottom - scaled_height) / 2;
264
+ } else {
265
+ // Apply 50% rule for vertical bounds, accounting for pad_bottom
266
+ const max_offset = (canvas.height - this.pad_bottom) / 2;
267
+ const top_bound = max_offset;
268
+ const bottom_bound =
269
+ canvas.height - this.pad_bottom - max_offset - scaled_height;
270
+ final_position.y = Math.min(
271
+ Math.max(raw_position.y, bottom_bound),
272
+ top_bound
273
+ );
274
+ }
275
+
276
+ this.image_editor_context.set_image_properties({
277
+ scale: this.local_scale,
278
+ position: final_position
279
+ });
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Cleans up resources and event listeners
285
+ */
286
+ cleanup(): void {
287
+ const stage = this?.image_editor_context?.app?.stage;
288
+ if (!stage) return;
289
+ stage.removeEventListener("wheel", this.handle_wheel.bind(this));
290
+
291
+ if ("ontouchstart" in window) {
292
+ stage.removeEventListener(
293
+ "touchstart",
294
+ this.handle_touch_start.bind(this) as EventListener
295
+ );
296
+ stage.removeEventListener(
297
+ "touchmove",
298
+ this.handle_touch_move.bind(this) as EventListener
299
+ );
300
+ stage.removeEventListener(
301
+ "touchend",
302
+ this.handle_touch_end.bind(this) as EventListener
303
+ );
304
+ } else {
305
+ stage.removeEventListener(
306
+ "pointerdown",
307
+ this.handle_pointer_down.bind(this)
308
+ );
309
+ stage.removeEventListener(
310
+ "pointermove",
311
+ this.handle_pointer_move.bind(this)
312
+ );
313
+ stage.removeEventListener("pointerup", this.handle_pointer_up.bind(this));
314
+ stage.removeEventListener(
315
+ "pointerupoutside",
316
+ this.handle_pointer_up.bind(this)
317
+ );
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Gets the dimensions of the container
323
+ * @returns {Promise<{width: number, height: number}>} The container dimensions
324
+ * @private
325
+ */
326
+ private async get_container_dimensions(): Promise<{
327
+ width: number;
328
+ height: number;
329
+ }> {
330
+ const bounds = this.image_editor_context.image_container.getLocalBounds();
331
+ return {
332
+ width: bounds.width,
333
+ height: bounds.height
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Calculates the minimum zoom level to fit the image in the viewport
339
+ * @param {number} container_width - The width of the container
340
+ * @param {number} container_height - The height of the container
341
+ * @returns {number} The minimum zoom level
342
+ * @private
343
+ */
344
+ private calculate_min_zoom(
345
+ container_width: number,
346
+ container_height: number
347
+ ): number {
348
+ const canvas = this.image_editor_context.app.screen;
349
+ const viewport_width = canvas.width;
350
+ const viewport_height = canvas.height;
351
+
352
+ if (
353
+ !container_width ||
354
+ !viewport_width ||
355
+ !container_height ||
356
+ !viewport_height
357
+ ) {
358
+ return 1;
359
+ }
360
+
361
+ // Calculate available space accounting for padding
362
+ const available_width = viewport_width - this.border_padding * 2;
363
+ const available_height =
364
+ viewport_height - this.border_padding * 2 - this.pad_bottom;
365
+
366
+ // Calculate zoom ratios for both dimensions
367
+ const width_ratio = available_width / container_width;
368
+ const height_ratio = available_height / container_height;
369
+
370
+ // Use the smaller ratio to ensure the image fits in both dimensions
371
+ // Note: we no longer cap at 1, as 'fit' can be above 1
372
+ return Math.min(width_ratio, height_ratio);
373
+ }
374
+
375
+ /**
376
+ * Handles touch start events
377
+ * @param {TouchEvent} event - The touch event
378
+ * @private
379
+ */
380
+ private handle_touch_start(event: FederatedPointerEvent): void {
381
+ event.preventDefault();
382
+
383
+ // Cast to any to access TouchEvent properties
384
+ const touchEvent = event as any;
385
+
386
+ if (touchEvent.touches && touchEvent.touches.length === 2) {
387
+ this.is_pinching = true;
388
+ this.last_pinch_distance = Math.hypot(
389
+ touchEvent.touches[0].pageX - touchEvent.touches[1].pageX,
390
+ touchEvent.touches[0].pageY - touchEvent.touches[1].pageY
391
+ );
392
+ } else if (touchEvent.touches && touchEvent.touches.length === 1) {
393
+ this.is_dragging = true;
394
+ const rect = this.image_editor_context.app.view.getBoundingClientRect();
395
+ this.last_touch_position = new Point(
396
+ touchEvent.touches[0].pageX - rect.left,
397
+ touchEvent.touches[0].pageY - rect.top
398
+ );
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Handles touch move events
404
+ * @param {TouchEvent} event - The touch event
405
+ * @private
406
+ */
407
+ private handle_touch_move(event: FederatedPointerEvent): void {
408
+ event.preventDefault();
409
+
410
+ // Cast to any to access TouchEvent properties
411
+ const touchEvent = event as any;
412
+
413
+ if (
414
+ this.is_pinching &&
415
+ touchEvent.touches &&
416
+ touchEvent.touches.length === 2
417
+ ) {
418
+ const rect = this.image_editor_context.app.view.getBoundingClientRect();
419
+ const current_distance = Math.hypot(
420
+ touchEvent.touches[0].pageX - touchEvent.touches[1].pageX,
421
+ touchEvent.touches[0].pageY - touchEvent.touches[1].pageY
422
+ );
423
+
424
+ const pinch_center = {
425
+ x:
426
+ (touchEvent.touches[0].pageX + touchEvent.touches[1].pageX) / 2 -
427
+ rect.left,
428
+ y:
429
+ (touchEvent.touches[0].pageY + touchEvent.touches[1].pageY) / 2 -
430
+ rect.top
431
+ };
432
+
433
+ const scale_val = current_distance / this.last_pinch_distance;
434
+ this.last_pinch_distance = current_distance;
435
+
436
+ // Update zoom based on pinch
437
+ this.zoom_to_point(this.local_scale * scale_val, pinch_center);
438
+ } else if (
439
+ this.is_dragging &&
440
+ touchEvent.touches &&
441
+ touchEvent.touches.length === 1 &&
442
+ this.last_touch_position
443
+ ) {
444
+ const rect = this.image_editor_context.app.view.getBoundingClientRect();
445
+ const current_position = new Point(
446
+ touchEvent.touches[0].pageX - rect.left,
447
+ touchEvent.touches[0].pageY - rect.top
448
+ );
449
+
450
+ const dx = current_position.x - this.last_touch_position.x;
451
+ const dy = current_position.y - this.last_touch_position.y;
452
+
453
+ // Update position based on drag
454
+ this.image_editor_context.set_image_properties({
455
+ position: {
456
+ x: this.local_position.x + dx,
457
+ y: this.local_position.y + dy
458
+ }
459
+ });
460
+
461
+ this.last_touch_position = current_position;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Handles touch end events
467
+ * @param {TouchEvent} event - The touch event
468
+ * @private
469
+ */
470
+ private handle_touch_end(event: FederatedPointerEvent): void {
471
+ event.preventDefault();
472
+
473
+ // Cast to any to access TouchEvent properties
474
+ const touchEvent = event as any;
475
+
476
+ if (touchEvent.touches && touchEvent.touches.length < 2) {
477
+ this.is_pinching = false;
478
+ if (touchEvent.touches && touchEvent.touches.length === 1) {
479
+ const rect = this.image_editor_context.app.view.getBoundingClientRect();
480
+ this.last_touch_position = new Point(
481
+ touchEvent.touches[0].pageX - rect.left,
482
+ touchEvent.touches[0].pageY - rect.top
483
+ );
484
+ this.is_dragging = true;
485
+ }
486
+ }
487
+ if (touchEvent.touches && touchEvent.touches.length === 0) {
488
+ this.is_dragging = false;
489
+ this.last_touch_position = null;
490
+ this.last_pinch_distance = 0;
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Gets the bounded position to keep the image within the viewport
496
+ * @param {Object} position - The position to bound
497
+ * @param {number} position.x - The x coordinate
498
+ * @param {number} position.y - The y coordinate
499
+ * @returns {Object} The bounded position
500
+ * @private
501
+ */
502
+ private get_bounded_position(position: { x: number; y: number }): {
503
+ x: number;
504
+ y: number;
505
+ } {
506
+ const canvas = this.image_editor_context.app.screen;
507
+ const scaled_width = this.local_dimensions.width * this.local_scale;
508
+ const scaled_height = this.local_dimensions.height * this.local_scale;
509
+
510
+ // Calculate center position, accounting for pad_bottom
511
+ const center_position = {
512
+ x: (canvas.width - scaled_width) / 2,
513
+ y: (canvas.height - scaled_height - this.pad_bottom) / 2
514
+ };
515
+
516
+ if (scaled_width <= canvas.width && scaled_height <= canvas.height) {
517
+ return center_position;
518
+ }
519
+
520
+ let x = position.x;
521
+ let y = position.y;
522
+
523
+ if (scaled_width <= canvas.width) {
524
+ x = center_position.x;
525
+ } else {
526
+ const min_x = canvas.width - scaled_width;
527
+ const max_x = 0;
528
+ x = Math.max(min_x, Math.min(max_x, position.x));
529
+ }
530
+
531
+ if (scaled_height <= canvas.height - this.pad_bottom) {
532
+ y = center_position.y;
533
+ } else {
534
+ const min_y = canvas.height - scaled_height - this.pad_bottom;
535
+ const max_y = 0;
536
+ y = Math.max(min_y, Math.min(max_y, position.y));
537
+ }
538
+
539
+ return { x, y };
540
+ }
541
+
542
+ /**
543
+ * Zooms to a specific point with a new zoom level
544
+ * @param {number} new_zoom - The new zoom level
545
+ * @param {Object} point - The point to zoom to
546
+ * @param {number} point.x - The x coordinate of the point
547
+ * @param {number} point.y - The y coordinate of the point
548
+ * @param {boolean} hard - Whether to apply a hard zoom (no animation)
549
+ * @param {boolean} is_fit_zoom - Whether this is a fit zoom operation
550
+ * @private
551
+ */
552
+ private zoom_to_point(
553
+ new_zoom: number,
554
+ point: { x: number; y: number },
555
+ hard?: boolean,
556
+ is_fit_zoom?: boolean
557
+ ): void {
558
+ // Get the cursor position relative to the image's top-left corner
559
+ const cursor_relative_to_image = {
560
+ x: (point.x - this.local_position.x) / this.local_scale,
561
+ y: (point.y - this.local_position.y) / this.local_scale
562
+ };
563
+
564
+ // Calculate what percentage of the image width/height this represents
565
+ const image_percentages = {
566
+ x: cursor_relative_to_image.x / this.local_dimensions.width,
567
+ y: cursor_relative_to_image.y / this.local_dimensions.height
568
+ };
569
+
570
+ // Apply zoom limits
571
+ const fit_zoom = this.calculate_min_zoom(
572
+ this.local_dimensions.width,
573
+ this.local_dimensions.height
574
+ );
575
+ // Use the fit zoom or 1, whichever is smaller as the minimum zoom
576
+ const min_zoom = Math.min(fit_zoom, 1);
577
+ new_zoom = Math.min(Math.max(new_zoom, min_zoom), this.max_zoom);
578
+
579
+ // Calculate new dimensions
580
+ const new_scaled_width = this.local_dimensions.width * new_zoom;
581
+ const new_scaled_height = this.local_dimensions.height * new_zoom;
582
+
583
+ // Calculate new position maintaining the same relative cursor position
584
+ let new_position = {
585
+ x: point.x - new_scaled_width * image_percentages.x,
586
+ y: point.y - new_scaled_height * image_percentages.y
587
+ };
588
+
589
+ // Center the image if we're at minimum zoom or if this is a fit zoom operation
590
+ if (new_zoom === min_zoom || is_fit_zoom) {
591
+ const canvas_width = this.image_editor_context.app.screen.width;
592
+ const canvas_height = this.image_editor_context.app.screen.height;
593
+
594
+ // Center the image, accounting for pad_bottom
595
+ new_position = {
596
+ x: (canvas_width - new_scaled_width) / 2,
597
+ y: (canvas_height - this.pad_bottom - new_scaled_height) / 2
598
+ };
599
+ }
600
+
601
+ this.image_editor_context.set_image_properties({
602
+ scale: new_zoom,
603
+ position: new_position,
604
+ animate: typeof hard === "boolean" ? !hard : new_zoom === min_zoom
605
+ });
606
+ this.min_zoom.set(new_zoom === min_zoom);
607
+ }
608
+
609
+ /**
610
+ * Handles pointer down events
611
+ * @param {FederatedPointerEvent} event - The pointer event
612
+ * @private
613
+ */
614
+ private handle_pointer_down(event: FederatedPointerEvent): void {
615
+ // Only allow dragging if the current tool is 'pan'
616
+ if (event.button === 0 && this.current_tool === "pan") {
617
+ this.is_pointer_dragging = true;
618
+ this.drag_start.copyFrom(event.global);
619
+ this.drag_start.x -= this.local_position.x;
620
+ this.drag_start.y -= this.local_position.y;
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Handles pointer move events
626
+ * @param {FederatedPointerEvent} event - The pointer event
627
+ * @private
628
+ */
629
+ private handle_pointer_move(event: FederatedPointerEvent): void {
630
+ // Only handle move if we're dragging and the tool is 'pan'
631
+ if (this.is_pointer_dragging && this.current_tool === "pan") {
632
+ const raw_position = {
633
+ x: event.global.x - this.drag_start.x,
634
+ y: event.global.y - this.drag_start.y
635
+ };
636
+
637
+ const canvas = this.image_editor_context.app.screen;
638
+ const scaled_width = this.local_dimensions.width * this.local_scale;
639
+ const scaled_height = this.local_dimensions.height * this.local_scale;
640
+ const available_width = canvas.width - this.border_padding * 2;
641
+ const available_height =
642
+ canvas.height - this.border_padding * 2 - this.pad_bottom;
643
+
644
+ let final_position = { ...raw_position };
645
+
646
+ // If image is smaller than available space, center it
647
+ if (scaled_width <= available_width) {
648
+ final_position.x = (canvas.width - scaled_width) / 2;
649
+ } else {
650
+ // Allow free movement during drag, bounds will be applied on pointer up
651
+ final_position.x = raw_position.x;
652
+ }
653
+
654
+ if (scaled_height <= available_height) {
655
+ final_position.y =
656
+ (canvas.height - this.pad_bottom - scaled_height) / 2;
657
+ } else {
658
+ // Allow free movement during drag, bounds will be applied on pointer up
659
+ final_position.y = raw_position.y;
660
+ }
661
+
662
+ this.image_editor_context.set_image_properties({
663
+ scale: this.local_scale,
664
+ position: final_position
665
+ });
666
+ }
667
+ }
668
+
669
+ /**
670
+ * Handles pointer up events
671
+ * @param {FederatedPointerEvent} event - The pointer event
672
+ * @private
673
+ */
674
+ private handle_pointer_up(event: FederatedPointerEvent): void {
675
+ // Only handle up if we're dragging and the tool is 'pan'
676
+ if (this.is_pointer_dragging && this.current_tool === "pan") {
677
+ this.is_pointer_dragging = false;
678
+
679
+ const raw_position = {
680
+ x: event.global.x - this.drag_start.x,
681
+ y: event.global.y - this.drag_start.y
682
+ };
683
+
684
+ const canvas = this.image_editor_context.app.screen;
685
+ const scaled_width = this.local_dimensions.width * this.local_scale;
686
+ const scaled_height = this.local_dimensions.height * this.local_scale;
687
+ const available_width = canvas.width - this.border_padding * 2;
688
+ const available_height =
689
+ canvas.height - this.border_padding * 2 - this.pad_bottom;
690
+
691
+ let final_position = { ...raw_position };
692
+
693
+ // If image is smaller than available space, center it
694
+ if (scaled_width <= available_width) {
695
+ final_position.x = (canvas.width - scaled_width) / 2;
696
+ } else {
697
+ // Apply 50% rule for horizontal snapping
698
+ const max_offset = canvas.width / 2;
699
+ const left_bound = max_offset;
700
+ const right_bound = canvas.width - max_offset - scaled_width;
701
+ final_position.x = Math.min(
702
+ Math.max(raw_position.x, right_bound),
703
+ left_bound
704
+ );
705
+ }
706
+
707
+ if (scaled_height <= available_height) {
708
+ final_position.y =
709
+ (canvas.height - this.pad_bottom - scaled_height) / 2;
710
+ } else {
711
+ // Apply 50% rule for vertical snapping, accounting for pad_bottom
712
+ const max_offset = (canvas.height - this.pad_bottom) / 2;
713
+ const top_bound = max_offset;
714
+ const bottom_bound =
715
+ canvas.height - this.pad_bottom - max_offset - scaled_height;
716
+ final_position.y = Math.min(
717
+ Math.max(raw_position.y, bottom_bound),
718
+ top_bound
719
+ );
720
+ }
721
+
722
+ this.image_editor_context.set_image_properties({
723
+ scale: this.local_scale,
724
+ position: final_position,
725
+ animate: true
726
+ });
727
+ }
728
+ }
729
+ }