@gradio/imageslider 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/shared/zoom.ts ADDED
@@ -0,0 +1,487 @@
1
+ export class ZoomableImage {
2
+ container: HTMLDivElement;
3
+ image: HTMLImageElement;
4
+ scale: number;
5
+ offsetX: number;
6
+ offsetY: number;
7
+ isDragging: boolean;
8
+ lastX: number;
9
+ lastY: number;
10
+ initial_left_padding: number;
11
+ initial_top_padding: number;
12
+ initial_width: number;
13
+ initial_height: number;
14
+ subscribers: (({
15
+ x,
16
+ y,
17
+ scale
18
+ }: {
19
+ x: number;
20
+ y: number;
21
+ scale: number;
22
+ }) => void)[];
23
+ handleImageLoad: () => void;
24
+ real_image_size: {
25
+ top: number;
26
+ left: number;
27
+ width: number;
28
+ height: number;
29
+ } = { top: 0, left: 0, width: 0, height: 0 };
30
+
31
+ last_touch_distance: number;
32
+
33
+ constructor(container: HTMLDivElement, image: HTMLImageElement) {
34
+ this.container = container;
35
+ this.image = image;
36
+
37
+ this.scale = 1;
38
+ this.offsetX = 0;
39
+ this.offsetY = 0;
40
+ this.isDragging = false;
41
+ this.lastX = 0;
42
+ this.lastY = 0;
43
+ this.initial_left_padding = 0;
44
+ this.initial_top_padding = 0;
45
+ this.initial_width = 0;
46
+ this.initial_height = 0;
47
+ this.subscribers = [];
48
+ this.last_touch_distance = 0;
49
+
50
+ this.handleWheel = this.handleWheel.bind(this);
51
+ this.handleMouseDown = this.handleMouseDown.bind(this);
52
+ this.handleMouseMove = this.handleMouseMove.bind(this);
53
+ this.handleMouseUp = this.handleMouseUp.bind(this);
54
+ this.handleImageLoad = this.init.bind(this);
55
+ this.handleTouchStart = this.handleTouchStart.bind(this);
56
+ this.handleTouchMove = this.handleTouchMove.bind(this);
57
+ this.handleTouchEnd = this.handleTouchEnd.bind(this);
58
+
59
+ this.image.addEventListener("load", this.handleImageLoad);
60
+
61
+ this.container.addEventListener("wheel", this.handleWheel);
62
+ this.container.addEventListener("mousedown", this.handleMouseDown);
63
+ document.addEventListener("mousemove", this.handleMouseMove);
64
+ document.addEventListener("mouseup", this.handleMouseUp);
65
+
66
+ this.container.addEventListener("touchstart", this.handleTouchStart);
67
+ document.addEventListener("touchmove", this.handleTouchMove);
68
+ document.addEventListener("touchend", this.handleTouchEnd);
69
+
70
+ const observer = new ResizeObserver((entries) => {
71
+ for (const entry of entries) {
72
+ if (entry.target === this.container) {
73
+ this.handleResize();
74
+ this.get_image_size(this.image);
75
+ }
76
+ }
77
+ });
78
+ observer.observe(this.container);
79
+ }
80
+
81
+ handleResize(): void {
82
+ this.init();
83
+ }
84
+
85
+ init(): void {
86
+ const containerRect = this.container.getBoundingClientRect();
87
+
88
+ const imageRect = this.image.getBoundingClientRect();
89
+ this.initial_left_padding = imageRect.left - containerRect.left;
90
+ this.initial_top_padding = imageRect.top - containerRect.top;
91
+ this.initial_width = imageRect.width;
92
+ this.initial_height = imageRect.height;
93
+
94
+ this.reset_zoom();
95
+
96
+ this.updateTransform();
97
+ }
98
+
99
+ reset_zoom(): void {
100
+ this.scale = 1;
101
+ this.offsetX = 0;
102
+ this.offsetY = 0;
103
+ this.updateTransform();
104
+ }
105
+
106
+ handleMouseDown(e: MouseEvent): void {
107
+ const imageRect = this.image.getBoundingClientRect();
108
+
109
+ if (
110
+ e.clientX >= imageRect.left &&
111
+ e.clientX <= imageRect.right &&
112
+ e.clientY >= imageRect.top &&
113
+ e.clientY <= imageRect.bottom
114
+ ) {
115
+ e.preventDefault();
116
+ if (this.scale === 1) return;
117
+ this.isDragging = true;
118
+ this.lastX = e.clientX;
119
+ this.lastY = e.clientY;
120
+ this.image.style.cursor = "grabbing";
121
+ }
122
+ }
123
+
124
+ handleMouseMove(e: MouseEvent): void {
125
+ if (!this.isDragging) return;
126
+
127
+ const deltaX = e.clientX - this.lastX;
128
+ const deltaY = e.clientY - this.lastY;
129
+
130
+ this.offsetX += deltaX;
131
+ this.offsetY += deltaY;
132
+
133
+ this.lastX = e.clientX;
134
+ this.lastY = e.clientY;
135
+
136
+ this.updateTransform();
137
+
138
+ this.updateTransform();
139
+ }
140
+
141
+ handleMouseUp(): void {
142
+ if (this.isDragging) {
143
+ this.constrain_to_bounds(true);
144
+ this.updateTransform();
145
+ this.isDragging = false;
146
+ this.image.style.cursor = this.scale > 1 ? "grab" : "zoom-in";
147
+ }
148
+ }
149
+
150
+ async handleWheel(e: WheelEvent): Promise<void> {
151
+ e.preventDefault();
152
+
153
+ const containerRect = this.container.getBoundingClientRect();
154
+ const imageRect = this.image.getBoundingClientRect();
155
+
156
+ if (
157
+ e.clientX < imageRect.left ||
158
+ e.clientX > imageRect.right ||
159
+ e.clientY < imageRect.top ||
160
+ e.clientY > imageRect.bottom
161
+ ) {
162
+ return;
163
+ }
164
+
165
+ const zoomFactor = 1.05;
166
+ const oldScale = this.scale;
167
+ const newScale =
168
+ -Math.sign(e.deltaY) > 0
169
+ ? Math.min(15, oldScale * zoomFactor) // in
170
+ : Math.max(1, oldScale / zoomFactor); // out
171
+
172
+ if (newScale === oldScale) return;
173
+
174
+ const cursorX = e.clientX - containerRect.left - this.initial_left_padding;
175
+ const cursorY = e.clientY - containerRect.top - this.initial_top_padding;
176
+
177
+ this.scale = newScale;
178
+ this.offsetX = this.compute_new_offset({
179
+ cursor_position: cursorX,
180
+ current_offset: this.offsetX,
181
+ new_scale: newScale,
182
+ old_scale: oldScale
183
+ });
184
+ this.offsetY = this.compute_new_offset({
185
+ cursor_position: cursorY,
186
+ current_offset: this.offsetY,
187
+ new_scale: newScale,
188
+ old_scale: oldScale
189
+ });
190
+
191
+ this.updateTransform(); // apply before constraints
192
+
193
+ this.constrain_to_bounds();
194
+ this.updateTransform(); // apply again after constraints
195
+
196
+ this.image.style.cursor = this.scale > 1 ? "grab" : "zoom-in";
197
+ }
198
+
199
+ // compute_offset_for_positions({ position: number, scale: number }) {
200
+ // return position - (scale / this.scale) * (position - this.offset);
201
+ // }
202
+
203
+ compute_new_position({
204
+ position,
205
+ scale,
206
+ anchor_position
207
+ }: {
208
+ position: number;
209
+ scale: number;
210
+ anchor_position: number;
211
+ }): number {
212
+ return position - (position - anchor_position) * (scale / this.scale);
213
+ }
214
+
215
+ compute_new_offset({
216
+ cursor_position,
217
+ current_offset,
218
+ new_scale,
219
+ old_scale
220
+ }: {
221
+ cursor_position: number;
222
+ current_offset: number;
223
+ new_scale: number;
224
+ old_scale: number;
225
+ }): number {
226
+ return (
227
+ cursor_position -
228
+ (new_scale / old_scale) * (cursor_position - current_offset)
229
+ );
230
+ }
231
+
232
+ constrain_to_bounds(pan = false): void {
233
+ if (this.scale === 1) {
234
+ this.offsetX = 0;
235
+ this.offsetY = 0;
236
+ return;
237
+ }
238
+ const onscreen = {
239
+ top: this.real_image_size.top * this.scale + this.offsetY,
240
+ left: this.real_image_size.left * this.scale + this.offsetX,
241
+ width: this.real_image_size.width * this.scale,
242
+ height: this.real_image_size.height * this.scale,
243
+
244
+ bottom:
245
+ this.real_image_size.top * this.scale +
246
+ this.offsetY +
247
+ this.real_image_size.height * this.scale,
248
+ right:
249
+ this.real_image_size.left * this.scale +
250
+ this.offsetX +
251
+ this.real_image_size.width * this.scale
252
+ };
253
+
254
+ const real_image_size_right =
255
+ this.real_image_size.left + this.real_image_size.width;
256
+ const real_image_size_bottom =
257
+ this.real_image_size.top + this.real_image_size.height;
258
+
259
+ if (pan) {
260
+ if (onscreen.top > this.real_image_size.top) {
261
+ this.offsetY = this.calculate_position(
262
+ this.real_image_size.top,
263
+ 0,
264
+ "y"
265
+ );
266
+ } else if (onscreen.bottom < real_image_size_bottom) {
267
+ this.offsetY = this.calculate_position(real_image_size_bottom, 1, "y");
268
+ }
269
+
270
+ if (onscreen.left > this.real_image_size.left) {
271
+ this.offsetX = this.calculate_position(
272
+ this.real_image_size.left,
273
+ 0,
274
+ "x"
275
+ );
276
+ } else if (onscreen.right < real_image_size_right) {
277
+ this.offsetX = this.calculate_position(real_image_size_right, 1, "x");
278
+ }
279
+ }
280
+ }
281
+
282
+ updateTransform(): void {
283
+ this.notify({ x: this.offsetX, y: this.offsetY, scale: this.scale });
284
+ }
285
+
286
+ destroy(): void {
287
+ this.container.removeEventListener("wheel", this.handleWheel);
288
+ this.container.removeEventListener("mousedown", this.handleMouseDown);
289
+ document.removeEventListener("mousemove", this.handleMouseMove);
290
+ document.removeEventListener("mouseup", this.handleMouseUp);
291
+ this.container.removeEventListener("touchstart", this.handleTouchStart);
292
+ document.removeEventListener("touchmove", this.handleTouchMove);
293
+ document.removeEventListener("touchend", this.handleTouchEnd);
294
+ this.image.removeEventListener("load", this.handleImageLoad);
295
+ }
296
+
297
+ subscribe(
298
+ cb: ({ x, y, scale }: { x: number; y: number; scale: number }) => void
299
+ ): void {
300
+ this.subscribers.push(cb);
301
+ }
302
+
303
+ unsubscribe(
304
+ cb: ({ x, y, scale }: { x: number; y: number; scale: number }) => void
305
+ ): void {
306
+ this.subscribers = this.subscribers.filter(
307
+ (subscriber) => subscriber !== cb
308
+ );
309
+ }
310
+
311
+ notify({ x, y, scale }: { x: number; y: number; scale: number }): void {
312
+ this.subscribers.forEach((subscriber) => subscriber({ x, y, scale }));
313
+ }
314
+
315
+ handleTouchStart(e: TouchEvent): void {
316
+ e.preventDefault();
317
+ const imageRect = this.image.getBoundingClientRect();
318
+ const touch = e.touches[0];
319
+
320
+ if (
321
+ touch.clientX >= imageRect.left &&
322
+ touch.clientX <= imageRect.right &&
323
+ touch.clientY >= imageRect.top &&
324
+ touch.clientY <= imageRect.bottom
325
+ ) {
326
+ if (e.touches.length === 1 && this.scale > 1) {
327
+ // one finger == prepare pan
328
+ this.isDragging = true;
329
+ this.lastX = touch.clientX;
330
+ this.lastY = touch.clientY;
331
+ } else if (e.touches.length === 2) {
332
+ // two fingers == prepare pinch zoom
333
+ const touch1 = e.touches[0];
334
+ const touch2 = e.touches[1];
335
+ this.last_touch_distance = Math.hypot(
336
+ touch2.clientX - touch1.clientX,
337
+ touch2.clientY - touch1.clientY
338
+ );
339
+ }
340
+ }
341
+ }
342
+
343
+ get_image_size(img: HTMLImageElement | null): void {
344
+ if (!img) return;
345
+ const container = img.parentElement?.getBoundingClientRect();
346
+
347
+ if (!container) return;
348
+
349
+ const naturalAspect = img.naturalWidth / img.naturalHeight;
350
+ const containerAspect = container.width / container.height;
351
+ let displayedWidth, displayedHeight;
352
+
353
+ if (naturalAspect > containerAspect) {
354
+ displayedWidth = container.width;
355
+ displayedHeight = container.width / naturalAspect;
356
+ } else {
357
+ displayedHeight = container.height;
358
+ displayedWidth = container.height * naturalAspect;
359
+ }
360
+
361
+ const offsetX = (container.width - displayedWidth) / 2;
362
+ const offsetY = (container.height - displayedHeight) / 2;
363
+
364
+ this.real_image_size = {
365
+ top: offsetY,
366
+ left: offsetX,
367
+ width: displayedWidth,
368
+ height: displayedHeight
369
+ };
370
+ }
371
+
372
+ handleTouchMove(e: TouchEvent): void {
373
+ if (e.touches.length === 1 && this.isDragging) {
374
+ // one finger == pan
375
+ e.preventDefault();
376
+ const touch = e.touches[0];
377
+
378
+ const deltaX = touch.clientX - this.lastX;
379
+ const deltaY = touch.clientY - this.lastY;
380
+
381
+ this.offsetX += deltaX;
382
+ this.offsetY += deltaY;
383
+
384
+ this.lastX = touch.clientX;
385
+ this.lastY = touch.clientY;
386
+
387
+ this.updateTransform();
388
+ } else if (e.touches.length === 2) {
389
+ // two fingers == pinch zoom
390
+ e.preventDefault();
391
+
392
+ const touch1 = e.touches[0];
393
+ const touch2 = e.touches[1];
394
+
395
+ const current_distance = Math.hypot(
396
+ touch2.clientX - touch1.clientX,
397
+ touch2.clientY - touch1.clientY
398
+ );
399
+
400
+ if (this.last_touch_distance === 0) {
401
+ this.last_touch_distance = current_distance;
402
+ return;
403
+ }
404
+
405
+ const zoomFactor = current_distance / this.last_touch_distance;
406
+
407
+ const oldScale = this.scale;
408
+ const newScale = Math.min(15, Math.max(1, oldScale * zoomFactor));
409
+
410
+ if (newScale === oldScale) {
411
+ this.last_touch_distance = current_distance;
412
+ return;
413
+ }
414
+
415
+ // midpoint of touches relative to image
416
+ const containerRect = this.container.getBoundingClientRect();
417
+ const midX =
418
+ (touch1.clientX + touch2.clientX) / 2 -
419
+ containerRect.left -
420
+ this.initial_left_padding;
421
+ const midY =
422
+ (touch1.clientY + touch2.clientY) / 2 -
423
+ containerRect.top -
424
+ this.initial_top_padding;
425
+
426
+ this.scale = newScale;
427
+ this.offsetX = this.compute_new_offset({
428
+ cursor_position: midX,
429
+ current_offset: this.offsetX,
430
+ new_scale: newScale,
431
+ old_scale: oldScale
432
+ });
433
+ this.offsetY = this.compute_new_offset({
434
+ cursor_position: midY,
435
+ current_offset: this.offsetY,
436
+ new_scale: newScale,
437
+ old_scale: oldScale
438
+ });
439
+
440
+ this.updateTransform();
441
+ this.constrain_to_bounds();
442
+ this.updateTransform();
443
+
444
+ this.last_touch_distance = current_distance;
445
+
446
+ this.image.style.cursor = this.scale > 1 ? "grab" : "zoom-in";
447
+ }
448
+ }
449
+
450
+ handleTouchEnd(e: TouchEvent): void {
451
+ if (this.isDragging) {
452
+ this.constrain_to_bounds(true);
453
+ this.updateTransform();
454
+ this.isDragging = false;
455
+ }
456
+
457
+ if (e.touches.length === 0) {
458
+ this.last_touch_distance = 0;
459
+ }
460
+ }
461
+
462
+ calculate_position(
463
+ screen_coord: number,
464
+ image_anchor: number,
465
+ axis: "x" | "y"
466
+ ): number {
467
+ const containerRect = this.container.getBoundingClientRect();
468
+
469
+ // Calculate X offset if requested
470
+ if (axis === "x") {
471
+ const relative_screen_x = screen_coord;
472
+ const anchor_x =
473
+ this.real_image_size.left + image_anchor * this.real_image_size.width;
474
+ return relative_screen_x - anchor_x * this.scale;
475
+ }
476
+
477
+ // Calculate Y offset if requested
478
+ if (axis === "y") {
479
+ const relative_screen_y = screen_coord;
480
+ const anchor_y =
481
+ this.real_image_size.top + image_anchor * this.real_image_size.height;
482
+ return relative_screen_y - anchor_y * this.scale;
483
+ }
484
+
485
+ return 0;
486
+ }
487
+ }